refactor: price chart timestamps (#7265)
* refactor: price chart timestamps * fix: remove unnused file * refactor: util file name * fix: use correct var for axis * refactor: use backup var instead of throwing error for timeMinute interval
This commit is contained in:
parent
f71c781530
commit
e4d103b015
57
src/components/Charts/PriceChart/format.ts
Normal file
57
src/components/Charts/PriceChart/format.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { NumberValue } from 'd3'
|
||||
import { TimePeriod } from 'graphql/data/util'
|
||||
|
||||
const HOUR_OPTIONS = { hour: 'numeric', minute: 'numeric', hour12: true } as const // e.g. '12:00 PM'
|
||||
const DAY_HOUR_OPTIONS = { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true } as const // e.g. 'Jul 4, 12:00 PM'
|
||||
const MONTH_DAY_OPTIONS = { month: 'long', day: 'numeric' } as const // e.g. 'July 4'
|
||||
const MONTH_YEAR_DAY_OPTIONS = { month: 'short', year: 'numeric', day: 'numeric' } as const // e.g. 'Jul 4, 2021'
|
||||
const MONTH_OPTIONS = { month: 'long' } as const // e.g. 'July'
|
||||
const WEEK_OPTIONS = { weekday: 'long' } as const // e.g. 'Sunday'
|
||||
|
||||
// Timestamps are formatted differently based on their location/usage in charts
|
||||
export enum TimestampFormatterType {
|
||||
TICK = 'tick',
|
||||
CROSSHAIR = 'crosshair',
|
||||
}
|
||||
|
||||
const TIME_PERIOD_TO_FORMAT_OPTIONS: Record<TimePeriod, Record<TimestampFormatterType, Intl.DateTimeFormatOptions>> = {
|
||||
[TimePeriod.HOUR]: {
|
||||
[TimestampFormatterType.TICK]: HOUR_OPTIONS,
|
||||
[TimestampFormatterType.CROSSHAIR]: DAY_HOUR_OPTIONS,
|
||||
},
|
||||
[TimePeriod.DAY]: {
|
||||
[TimestampFormatterType.TICK]: HOUR_OPTIONS,
|
||||
[TimestampFormatterType.CROSSHAIR]: DAY_HOUR_OPTIONS,
|
||||
},
|
||||
[TimePeriod.WEEK]: {
|
||||
[TimestampFormatterType.TICK]: WEEK_OPTIONS,
|
||||
[TimestampFormatterType.CROSSHAIR]: DAY_HOUR_OPTIONS,
|
||||
},
|
||||
[TimePeriod.MONTH]: {
|
||||
[TimestampFormatterType.TICK]: MONTH_DAY_OPTIONS,
|
||||
[TimestampFormatterType.CROSSHAIR]: DAY_HOUR_OPTIONS,
|
||||
},
|
||||
[TimePeriod.YEAR]: {
|
||||
[TimestampFormatterType.TICK]: MONTH_OPTIONS,
|
||||
[TimestampFormatterType.CROSSHAIR]: MONTH_YEAR_DAY_OPTIONS,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function to format timestamps, specialized by timePeriod and type to display ('tick' or 'crosshair'),
|
||||
* localized for the given locale.
|
||||
*/
|
||||
export function getTimestampFormatter(
|
||||
timePeriod: TimePeriod,
|
||||
locale: string,
|
||||
formatterType: TimestampFormatterType
|
||||
): (n: NumberValue) => string {
|
||||
// Choose appropriate formatting options based on type and timePeriod
|
||||
const options = TIME_PERIOD_TO_FORMAT_OPTIONS[timePeriod][formatterType]
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat(locale, options)
|
||||
|
||||
return (timestamp: NumberValue): string => {
|
||||
const epochTimeInMilliseconds = timestamp.valueOf() * 1000
|
||||
return dateTimeFormatter.format(epochTimeInMilliseconds)
|
||||
}
|
||||
}
|
39
src/components/Charts/PriceChart/utils.ts
Normal file
39
src/components/Charts/PriceChart/utils.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { timeDay, timeHour, TimeInterval, timeMinute, timeMonth } from 'd3'
|
||||
import { TimePeriod } from 'graphql/data/util'
|
||||
|
||||
const fiveMinutes = timeMinute.every(5)
|
||||
const TIME_PERIOD_INTERVAL_TABLE: Record<TimePeriod, { interval: TimeInterval; step: number }> = {
|
||||
[TimePeriod.HOUR]: fiveMinutes
|
||||
? { interval: fiveMinutes, step: 2 } // spaced 10 minutes apart at times that end in 0 or 5
|
||||
: { interval: timeMinute, step: 10 }, // spaced 10 minutes apart, backup incase fiveMinutes doesn't initialize
|
||||
[TimePeriod.DAY]: { interval: timeHour, step: 4 }, // spaced 4 hours apart
|
||||
[TimePeriod.WEEK]: { interval: timeDay, step: 1 }, // spaced 1 day apart
|
||||
[TimePeriod.MONTH]: { interval: timeDay, step: 7 }, // spaced 1 week apart
|
||||
[TimePeriod.YEAR]: { interval: timeMonth, step: 2 }, // spaced 2 months apart
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of tick values for a given time range and time period.
|
||||
* This function makes sure that the ticks are evenly spaced and are not too close to the edges.
|
||||
*/
|
||||
export function getTicks(startTime: number, endTime: number, timePeriod: TimePeriod, maxTicks: number) {
|
||||
if (maxTicks === 0 || endTime <= startTime) return []
|
||||
|
||||
// Prevents ticks from being too close to the axis edge
|
||||
const tickMargin = (endTime - startTime) / 24
|
||||
|
||||
const startDate = new Date((startTime + tickMargin) * 1000)
|
||||
const endDate = new Date((endTime - tickMargin) * 1000)
|
||||
|
||||
const { interval, step } = TIME_PERIOD_INTERVAL_TABLE[timePeriod]
|
||||
const ticks = interval.range(startDate, endDate, step).map((x) => x.valueOf() / 1000) // convert to seconds
|
||||
|
||||
if (ticks.length <= maxTicks) return ticks
|
||||
|
||||
const newTicks = []
|
||||
const tickSpacing = Math.floor(ticks.length / maxTicks)
|
||||
for (let i = 1; i < ticks.length; i += tickSpacing) {
|
||||
newTicks.push(ticks[i])
|
||||
}
|
||||
return newTicks
|
||||
}
|
@ -1,13 +1,15 @@
|
||||
import { Trans } from '@lingui/macro'
|
||||
import { AxisBottom, TickFormatter } from '@visx/axis'
|
||||
import { AxisBottom } from '@visx/axis'
|
||||
import { localPoint } from '@visx/event'
|
||||
import { EventType } from '@visx/event/lib/types'
|
||||
import { GlyphCircle } from '@visx/glyph'
|
||||
import { Line } from '@visx/shape'
|
||||
import AnimatedInLineChart from 'components/Charts/AnimatedInLineChart'
|
||||
import FadedInLineChart from 'components/Charts/FadeInLineChart'
|
||||
import { getTimestampFormatter, TimestampFormatterType } from 'components/Charts/PriceChart/format'
|
||||
import { getTicks } from 'components/Charts/PriceChart/utils'
|
||||
import { MouseoverTooltip } from 'components/Tooltip'
|
||||
import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3'
|
||||
import { bisect, curveCardinal, scaleLinear } from 'd3'
|
||||
import { PricePoint, TimePeriod } from 'graphql/data/util'
|
||||
import { useActiveLocale } from 'hooks/useActiveLocale'
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@ -15,14 +17,6 @@ import { Info, TrendingUp } from 'react-feather'
|
||||
import styled, { useTheme } from 'styled-components'
|
||||
import { ThemedText } from 'theme'
|
||||
import { textFadeIn } from 'theme/styles'
|
||||
import {
|
||||
dayHourFormatter,
|
||||
hourFormatter,
|
||||
monthDayFormatter,
|
||||
monthTickFormatter,
|
||||
monthYearDayFormatter,
|
||||
weekFormatter,
|
||||
} from 'utils/formatChartTimes'
|
||||
import { formatUSDPrice } from 'utils/formatNumbers'
|
||||
|
||||
import { calculateDelta, DeltaArrow, formatDelta } from './Delta'
|
||||
@ -194,52 +188,6 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
|
||||
[originalPrices, graphInnerHeight]
|
||||
)
|
||||
|
||||
function tickFormat(
|
||||
timePeriod: TimePeriod,
|
||||
locale: string
|
||||
): [TickFormatter<NumberValue>, (v: number) => string, NumberValue[]] {
|
||||
const offsetTime = (endingPrice.timestamp.valueOf() - startingPrice.timestamp.valueOf()) / 24
|
||||
const startDateWithOffset = new Date((startingPrice.timestamp.valueOf() + offsetTime) * 1000)
|
||||
const endDateWithOffset = new Date((endingPrice.timestamp.valueOf() - offsetTime) * 1000)
|
||||
switch (timePeriod) {
|
||||
case TimePeriod.HOUR: {
|
||||
const interval = timeMinute.every(5)
|
||||
|
||||
return [
|
||||
hourFormatter(locale),
|
||||
dayHourFormatter(locale),
|
||||
(interval ?? timeMinute)
|
||||
.range(startDateWithOffset, endDateWithOffset, interval ? 2 : 10)
|
||||
.map((x) => x.valueOf() / 1000),
|
||||
]
|
||||
}
|
||||
case TimePeriod.DAY:
|
||||
return [
|
||||
hourFormatter(locale),
|
||||
dayHourFormatter(locale),
|
||||
timeHour.range(startDateWithOffset, endDateWithOffset, 4).map((x) => x.valueOf() / 1000),
|
||||
]
|
||||
case TimePeriod.WEEK:
|
||||
return [
|
||||
weekFormatter(locale),
|
||||
dayHourFormatter(locale),
|
||||
timeDay.range(startDateWithOffset, endDateWithOffset, 1).map((x) => x.valueOf() / 1000),
|
||||
]
|
||||
case TimePeriod.MONTH:
|
||||
return [
|
||||
monthDayFormatter(locale),
|
||||
dayHourFormatter(locale),
|
||||
timeDay.range(startDateWithOffset, endDateWithOffset, 7).map((x) => x.valueOf() / 1000),
|
||||
]
|
||||
case TimePeriod.YEAR:
|
||||
return [
|
||||
monthTickFormatter(locale),
|
||||
monthYearDayFormatter(locale),
|
||||
timeMonth.range(startDateWithOffset, endDateWithOffset, 2).map((x) => x.valueOf() / 1000),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const handleHover = useCallback(
|
||||
(event: Element | EventType) => {
|
||||
if (!prices) return
|
||||
@ -279,19 +227,16 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
|
||||
setCrosshair(null)
|
||||
}, [timePeriod])
|
||||
|
||||
const [tickFormatter, crosshairDateFormatter, ticks] = tickFormat(timePeriod, locale)
|
||||
//max ticks based on screen size
|
||||
const { tickTimestampFormatter, crosshairTimestampFormatter, ticks } = useMemo(() => {
|
||||
// max ticks based on screen size
|
||||
const maxTicks = Math.floor(width / 100)
|
||||
function calculateTicks(ticks: NumberValue[]) {
|
||||
const newTicks = []
|
||||
const tickSpacing = Math.floor(ticks.length / maxTicks)
|
||||
for (let i = 1; i < ticks.length; i += tickSpacing) {
|
||||
newTicks.push(ticks[i])
|
||||
}
|
||||
return newTicks
|
||||
}
|
||||
const tickTimestampFormatter = getTimestampFormatter(timePeriod, locale, TimestampFormatterType.TICK)
|
||||
const crosshairTimestampFormatter = getTimestampFormatter(timePeriod, locale, TimestampFormatterType.CROSSHAIR)
|
||||
const ticks = getTicks(startingPrice.timestamp, endingPrice.timestamp, timePeriod, maxTicks)
|
||||
|
||||
return { tickTimestampFormatter, crosshairTimestampFormatter, ticks }
|
||||
}, [endingPrice.timestamp, locale, startingPrice.timestamp, timePeriod, width])
|
||||
|
||||
const updatedTicks = maxTicks > 0 ? (ticks.length > maxTicks ? calculateTicks(ticks) : ticks) : []
|
||||
const crosshairEdgeMax = width * 0.85
|
||||
const crosshairAtEdge = !!crosshair && crosshair > crosshairEdgeMax
|
||||
|
||||
@ -357,20 +302,17 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
|
||||
{crosshair !== null ? (
|
||||
<g>
|
||||
<AxisBottom
|
||||
top={graphHeight - 1}
|
||||
scale={timeScale}
|
||||
stroke={theme.surface3}
|
||||
tickFormat={tickFormatter}
|
||||
tickStroke={theme.surface3}
|
||||
tickLength={4}
|
||||
hideTicks={true}
|
||||
tickTransform="translate(0 -5)"
|
||||
tickValues={updatedTicks}
|
||||
top={graphHeight - 1}
|
||||
tickValues={ticks}
|
||||
tickFormat={tickTimestampFormatter}
|
||||
tickLabelProps={() => ({
|
||||
fill: theme.neutral2,
|
||||
fontSize: 12,
|
||||
textAnchor: 'middle',
|
||||
transform: 'translate(0 -24)',
|
||||
transform: 'translate(0 -29)',
|
||||
})}
|
||||
/>
|
||||
<text
|
||||
@ -380,7 +322,7 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
|
||||
fontSize={12}
|
||||
fill={theme.neutral2}
|
||||
>
|
||||
{crosshairDateFormatter(displayPrice.timestamp)}
|
||||
{crosshairTimestampFormatter(displayPrice.timestamp)}
|
||||
</text>
|
||||
<Line
|
||||
from={{ x: crosshair, y: margin.crosshair }}
|
||||
|
@ -1,53 +0,0 @@
|
||||
import { NumberValue } from 'd3'
|
||||
|
||||
const createTimeFormatter = (timestamp: NumberValue, locale: string, options: Intl.DateTimeFormatOptions) =>
|
||||
new Date(timestamp.valueOf() * 1000).toLocaleTimeString(locale, options)
|
||||
|
||||
export const hourFormatter = (locale: string) => (timestamp: NumberValue) =>
|
||||
createTimeFormatter(timestamp, locale, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
export const dayHourFormatter = (locale: string) => (timestamp: NumberValue) =>
|
||||
createTimeFormatter(timestamp, locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
const createDateFormatter = (timestamp: NumberValue, locale: string, options: Intl.DateTimeFormatOptions) =>
|
||||
new Date(timestamp.valueOf() * 1000).toLocaleDateString(locale, options)
|
||||
|
||||
export const monthDayFormatter = (locale: string) => (timestamp: NumberValue) =>
|
||||
createDateFormatter(timestamp, locale, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
export const monthYearDayFormatter = (locale: string) => (timestamp: NumberValue) =>
|
||||
createDateFormatter(timestamp, locale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
export const monthTickFormatter = (locale: string) => (timestamp: NumberValue) => {
|
||||
let date = new Date(timestamp.valueOf() * 1000)
|
||||
|
||||
// when a tick maps to a date not on the first of the month, modify the tick to the closest
|
||||
// first of month date. For example, Dec 31 becomes Jan 1, and Dec 5 becomes Dec 1.
|
||||
if (date.getDate() !== 1) {
|
||||
date =
|
||||
date.getDate() >= 15
|
||||
? new Date(date.getFullYear(), date.getMonth() + 1, 1)
|
||||
: new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
}
|
||||
return date.toLocaleDateString(locale, { month: 'long' })
|
||||
}
|
||||
|
||||
export const weekFormatter = (locale: string) => (timestamp: NumberValue) =>
|
||||
createDateFormatter(timestamp, locale, { weekday: 'long' })
|
Loading…
Reference in New Issue
Block a user