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:
cartcrom 2023-09-11 13:16:30 -04:00 committed by GitHub
parent f71c781530
commit e4d103b015
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 114 additions and 129 deletions

@ -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)
}
}

@ -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 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, crosshairTimestampFormatter, ticks } = useMemo(() => {
// max ticks based on screen size
const maxTicks = Math.floor(width / 100)
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' })