diff --git a/src/components/Charts/PriceChart/utils.ts b/src/components/Charts/PriceChart/utils.ts index 2e16148d3d..1261ff2ff4 100644 --- a/src/components/Charts/PriceChart/utils.ts +++ b/src/components/Charts/PriceChart/utils.ts @@ -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 +): 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 = { diff --git a/src/components/Charts/SparklineChart.tsx b/src/components/Charts/SparklineChart.tsx index e3819112fa..cc24da0264 100644 --- a/src/components/Charts/SparklineChart.tsx +++ b/src/components/Charts/SparklineChart.tsx @@ -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 ( diff --git a/src/components/Tokens/TokenDetails/PriceChart.tsx b/src/components/Tokens/TokenDetails/PriceChart.tsx index 23d777d1b3..1a81a1cb16 100644 --- a/src/components/Tokens/TokenDetails/PriceChart.tsx +++ b/src/components/Tokens/TokenDetails/PriceChart.tsx @@ -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)) diff --git a/src/components/Tokens/TokenTable/__snapshots__/TokenRow.test.tsx.snap b/src/components/Tokens/TokenTable/__snapshots__/TokenRow.test.tsx.snap index 73f958ea2c..c739db76a4 100644 --- a/src/components/Tokens/TokenTable/__snapshots__/TokenRow.test.tsx.snap +++ b/src/components/Tokens/TokenTable/__snapshots__/TokenRow.test.tsx.snap @@ -2,15 +2,7 @@ exports[`LoadedRow.tsx renders a row 1`] = ` - .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;