refactor: price chart utility functions (#7266)

* refactor: price chart utility functions

* fix: snapshot

* fix: combine useMemos

* fix: pr nits
This commit is contained in:
cartcrom 2023-09-14 13:13:17 -04:00 committed by GitHub
parent 1c3ce8fdb7
commit 6aaf0db78d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 119 additions and 68 deletions

@ -1,5 +1,100 @@
import { timeDay, timeHour, TimeInterval, timeMinute, timeMonth } from 'd3'
import { TimePeriod } from 'graphql/data/util'
import { bisector, ScaleLinear, timeDay, timeHour, TimeInterval, timeMinute, timeMonth } from 'd3'
import { PricePoint, TimePeriod } from 'graphql/data/util'
/**
* Returns the minimum and maximum values in the given array of PricePoints.
*/
export function getPriceBounds(prices: PricePoint[]): { min: number; max: number } {
if (!prices.length) return { min: 0, max: 0 }
let min = prices[0].value
let max = prices[0].value
for (const pricePoint of prices) {
if (pricePoint.value < min) {
min = pricePoint.value
}
if (pricePoint.value > max) {
max = pricePoint.value
}
}
return { min, max }
}
/**
* Cleans an array of PricePoints by removing zero values and marking gaps in data as blanks.
*
* @param prices - The original array of PricePoints
* @returns An object containing two arrays: fixedChart and blanks
*/
export function cleanPricePoints(prices: PricePoint[]) {
const validPrices: PricePoint[] = [] // PricePoint array with 0 values removed
const blanks: [PricePoint, PricePoint][] = [] // PricePoint pairs that represent blank spaces in the chart
let lastValidPrice: PricePoint | undefined
prices.forEach((pricePoint, index) => {
if (pricePoint.value !== 0) {
const isFirstValidPrice = validPrices.length === 0
if (isFirstValidPrice && index !== 0) {
const blankStart = { timestamp: prices[0].timestamp, value: pricePoint.value }
blanks.push([blankStart, pricePoint])
}
lastValidPrice = pricePoint
validPrices.push(pricePoint)
}
})
if (lastValidPrice) {
const isLastPriceInvalid = lastValidPrice !== prices[prices.length - 1]
if (isLastPriceInvalid) {
const blankEnd = { timestamp: prices[prices.length - 1].timestamp, value: lastValidPrice.value }
blanks.push([lastValidPrice, blankEnd])
}
}
return { prices: validPrices, blanks, lastValidPrice }
}
/**
* Retrieves the nearest PricePoint to a given x-coordinate based on a linear time scale.
*
* @param x - The x-coordinate to find the nearest PricePoint for.
* @param prices - An array of PricePoints, assumed to be sorted by timestamp.
* @param timeScale - A D3 ScaleLinear instance for time scaling.
* @returns The nearest PricePoint to the given x-coordinate.
*/
export function getNearestPricePoint(
x: number,
prices: PricePoint[],
timeScale: ScaleLinear<number, number, never>
): PricePoint | undefined {
// Convert the x-coordinate back to a timestamp
const targetTimestamp = timeScale.invert(x)
// Use bisector for O(log N) complexity, assumes prices are sorted by timestamp
const bisect = bisector((d: PricePoint) => d.timestamp).left
const index = bisect(prices, targetTimestamp, 1)
// Get potential nearest PricePoints
const previousPoint = prices[index - 1]
const nextPoint = prices[index]
// Default to the previous point if next point doesn't exist
if (!nextPoint) {
return previousPoint
}
// Calculate temporal distances to target timestamp
const distanceToPrevious = Math.abs(targetTimestamp.valueOf() - previousPoint.timestamp.valueOf())
const distanceToNext = Math.abs(nextPoint.timestamp.valueOf() - targetTimestamp.valueOf())
// Return the PricePoint with the smallest temporal distance to targetTimestamp
return distanceToPrevious < distanceToNext ? previousPoint : nextPoint
}
const fiveMinutes = timeMinute.every(5)
const TIME_PERIOD_INTERVAL_TABLE: Record<TimePeriod, { interval: TimeInterval; step: number }> = {

@ -5,8 +5,8 @@ import { PricePoint } from 'graphql/data/util'
import { memo } from 'react'
import styled, { useTheme } from 'styled-components'
import { getPriceBounds } from '../Tokens/TokenDetails/PriceChart'
import LineChart from './LineChart'
import { getPriceBounds } from './PriceChart/utils'
const LoadingContainer = styled.div`
height: 100%;
@ -48,7 +48,9 @@ function _SparklineChart({ width, height, tokenData, pricePercentChange, sparkli
// the range of possible output values that the inputs should be transformed to (see https://www.d3indepth.com/scales/ for details)
[0, 110]
)
const rdScale = scaleLinear().domain(getPriceBounds(pricePoints)).range([30, 0])
const { min, max } = getPriceBounds(pricePoints)
const rdScale = scaleLinear().domain([min, max]).range([30, 0])
const curveTension = 0.9
return (

@ -7,9 +7,9 @@ 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 { cleanPricePoints, getNearestPricePoint, getPriceBounds, getTicks } from 'components/Charts/PriceChart/utils'
import { MouseoverTooltip } from 'components/Tooltip'
import { bisect, curveCardinal, scaleLinear } from 'd3'
import { curveCardinal, scaleLinear } from 'd3'
import { PricePoint, TimePeriod } from 'graphql/data/util'
import { useActiveLocale } from 'hooks/useActiveLocale'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
@ -23,13 +23,6 @@ import { calculateDelta, DeltaArrow, formatDelta } from './Delta'
const DATA_EMPTY = { value: 0, timestamp: 0 }
export function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
const prices = pricePoints.map((x) => x.value)
const min = Math.min(...prices)
const max = Math.max(...prices)
return [min, max]
}
const ChartHeader = styled.div`
position: absolute;
${textFadeIn};
@ -65,29 +58,6 @@ const OutdatedPriceContainer = styled.div`
line-height: 44px;
`
function fixChart(prices: PricePoint[] | undefined | null) {
if (!prices) return { prices: null, blanks: [] }
const fixedChart: PricePoint[] = []
const blanks: PricePoint[][] = []
let lastValue: PricePoint | undefined = undefined
for (let i = 0; i < prices.length; i++) {
if (prices[i].value !== 0) {
if (fixedChart.length === 0 && i !== 0) {
blanks.push([{ ...prices[0], value: prices[i].value }, prices[i]])
}
lastValue = prices[i]
fixedChart.push(prices[i])
}
}
if (lastValue && lastValue !== prices[prices.length - 1]) {
blanks.push([lastValue, { ...prices[prices.length - 1], value: lastValue.value }])
}
return { prices: fixedChart, blanks }
}
const margin = { top: 100, bottom: 48, crosshair: 72 }
const timeOptionsHeight = 44
@ -119,7 +89,8 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
const theme = useTheme()
const { prices, blanks } = useMemo(
() => (originalPrices && originalPrices.length > 0 ? fixChart(originalPrices) : { prices: null, blanks: [] }),
() =>
originalPrices && originalPrices.length > 0 ? cleanPricePoints(originalPrices) : { prices: null, blanks: [] },
[originalPrices]
)
@ -180,34 +151,17 @@ export function PriceChart({ width, height, prices: originalPrices, timePeriod }
[startingPrice, endingPrice, width]
)
// y scale
const rdScale = useMemo(
() =>
scaleLinear()
.domain(getPriceBounds(originalPrices ?? []))
.range([graphInnerHeight, 0]),
[originalPrices, graphInnerHeight]
)
const rdScale = useMemo(() => {
const { min, max } = getPriceBounds(originalPrices ?? [])
return scaleLinear().domain([min, max]).range([graphInnerHeight, 0])
}, [originalPrices, graphInnerHeight])
const handleHover = useCallback(
(event: Element | EventType) => {
if (!prices) return
const { x } = localPoint(event) || { x: 0 }
const x0 = timeScale.invert(x) // get timestamp from the scalexw
const index = bisect(
prices.map((x) => x.timestamp),
x0,
1
)
const d0 = prices[index - 1]
const d1 = prices[index]
let pricePoint = d0
const hasPreviousData = d1 && d1.timestamp
if (hasPreviousData) {
pricePoint = x0.valueOf() - d0.timestamp.valueOf() > d1.timestamp.valueOf() - x0.valueOf() ? d1 : d0
}
const pricePoint = getNearestPricePoint(x, prices, timeScale)
if (pricePoint) {
setCrosshair(timeScale(pricePoint.timestamp))

@ -2,15 +2,7 @@
exports[`LoadedRow.tsx renders a row 1`] = `
<DocumentFragment>
.c18 {
color: #40B66B;
}
.c19 {
color: #40B66B;
}
.c9 {
.c9 {
opacity: 0;
-webkit-transition: opacity 250ms ease-in;
transition: opacity 250ms ease-in;
@ -50,6 +42,14 @@ exports[`LoadedRow.tsx renders a row 1`] = `
display: none;
}
.c18 {
color: #40B66B;
}
.c19 {
color: #40B66B;
}
.c2 {
display: -webkit-box;
display: -webkit-flex;