diff --git a/src/components/Charts/PriceChart/ChartModel.ts b/src/components/Charts/PriceChart/ChartModel.ts new file mode 100644 index 0000000000..afc5abcf60 --- /dev/null +++ b/src/components/Charts/PriceChart/ChartModel.ts @@ -0,0 +1,70 @@ +import { ScaleLinear, scaleLinear } from 'd3' +import { PricePoint } from 'graphql/data/util' + +import { cleanPricePoints, getPriceBounds } from './utils' + +export enum ChartErrorType { + NO_DATA_AVAILABLE, + NO_RECENT_VOLUME, + INVALID_CHART, +} + +type ChartDimensions = { + width: number + height: number + marginTop: number + marginBottom: number +} + +export type ErroredChartModel = { error: ChartErrorType; dimensions: ChartDimensions } + +export type ChartModel = { + prices: PricePoint[] + startingPrice: PricePoint + endingPrice: PricePoint + lastValidPrice: PricePoint + blanks: PricePoint[][] + timeScale: ScaleLinear + priceScale: ScaleLinear + dimensions: ChartDimensions + error: undefined +} + +type ChartModelArgs = { prices?: PricePoint[]; dimensions: ChartDimensions } +export function buildChartModel({ dimensions, prices }: ChartModelArgs): ChartModel | ErroredChartModel { + if (!prices) { + return { error: ChartErrorType.NO_DATA_AVAILABLE, dimensions } + } + + const innerHeight = dimensions.height - dimensions.marginTop - dimensions.marginBottom + if (innerHeight < 0) { + return { error: ChartErrorType.INVALID_CHART, dimensions } + } + + const { prices: fixedPrices, blanks, lastValidPrice } = cleanPricePoints(prices) + if (fixedPrices.length < 2 || !lastValidPrice) { + return { error: ChartErrorType.NO_RECENT_VOLUME, dimensions } + } + + const startingPrice = prices[0] + const endingPrice = prices[prices.length - 1] + const { min, max } = getPriceBounds(prices) + + // x-axis scale + const timeScale = scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, dimensions.width]) + + // y-axis scale + const priceScale = scaleLinear().domain([min, max]).range([innerHeight, 0]) + + return { + prices: fixedPrices, + startingPrice, + endingPrice, + lastValidPrice, + blanks, + timeScale, + priceScale, + dimensions, + error: undefined, + } +} diff --git a/src/components/Tokens/TokenDetails/__snapshots__/PriceChart.test.tsx.snap b/src/components/Charts/PriceChart/__snapshots__/index.test.tsx.snap similarity index 53% rename from src/components/Tokens/TokenDetails/__snapshots__/PriceChart.test.tsx.snap rename to src/components/Charts/PriceChart/__snapshots__/index.test.tsx.snap index 3a01c765ab..38f62701a7 100644 --- a/src/components/Tokens/TokenDetails/__snapshots__/PriceChart.test.tsx.snap +++ b/src/components/Charts/PriceChart/__snapshots__/index.test.tsx.snap @@ -15,9 +15,13 @@ exports[`PriceChart renders correctly with all prices filled 1`] = ` } .c1 { - font-size: 36px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 6px; + font-size: 24px; line-height: 44px; - font-weight: 485; } .c2 { @@ -38,11 +42,15 @@ exports[`PriceChart renders correctly with all prices filled 1`] = ` class="c0" data-cy="chart-header" > - - $1.00 - +
+ $1.00 +
+
@@ -337,10 +345,10 @@ exports[`PriceChart renders correctly with all prices filled 1`] = ` `; -exports[`PriceChart renders correctly with no prices filled 1`] = ` +exports[`PriceChart renders correctly with empty price array 1`] = ` - .c3 { - color: #222222; + .c1 { + color: #CECECE; } .c0 { @@ -351,41 +359,22 @@ exports[`PriceChart renders correctly with no prices filled 1`] = ` animation-duration: 250ms; } -.c1 { - font-size: 36px; - line-height: 44px; - font-weight: 485; -} - -.c2 { - font-size: 24px; - line-height: 44px; - color: #CECECE; -} -
- Price Unavailable - +
- Missing chart data + Missing price data due to recently low trading volume on Uniswap v3
- .c0 text { - font-size: 12px; - font-weight: 485; -} - - - `; exports[`PriceChart renders correctly with some prices filled 1`] = ` - .c4 { + .c2 { display: inline-block; height: inherit; } -.c6 { +.c4 { color: #7D7D7D; } @@ -424,19 +408,20 @@ exports[`PriceChart renders correctly with some prices filled 1`] = ` animation: iAjNNh 125ms ease-in; -webkit-animation-duration: 250ms; animation-duration: 250ms; -} - -.c3 { - font-size: 36px; - line-height: 44px; - font-weight: 485; -} - -.c1 { color: #7D7D7D; } -.c5 { +.c1 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 6px; + font-size: 24px; + line-height: 44px; +} + +.c3 { height: 16px; display: -webkit-box; display: -webkit-flex; @@ -450,16 +435,6 @@ exports[`PriceChart renders correctly with some prices filled 1`] = ` color: #7D7D7D; } -.c2 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - gap: 6px; - font-size: 24px; - line-height: 44px; -} -
- - $1.00 - -
-
- - - - - -
-
+ $1.00
- 0.00% - - - +
+ + + + + +
+
+ 0.00% + + + +
`; + +exports[`PriceChart renders correctly with undefined prices 1`] = ` + + .c1 { + color: #CECECE; +} + +.c0 { + position: absolute; + -webkit-animation: iAjNNh 125ms ease-in; + animation: iAjNNh 125ms ease-in; + -webkit-animation-duration: 250ms; + animation-duration: 250ms; +} + +
+
+ Price Unavailable +
+
+ Missing chart data +
+
+ + + +
+`; + +exports[`PriceChart renders stale UI 1`] = ` + + .c2 { + display: inline-block; + height: inherit; +} + +.c4 { + color: #7D7D7D; +} + +.c0 { + position: absolute; + -webkit-animation: iAjNNh 125ms ease-in; + animation: iAjNNh 125ms ease-in; + -webkit-animation-duration: 250ms; + animation-duration: 250ms; + color: #7D7D7D; +} + +.c1 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 6px; + font-size: 24px; + line-height: 44px; +} + +.c3 { + height: 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + margin-top: 4px; + color: #7D7D7D; +} + +
+
+
+ $1.00 +
+
+
+ + + + + +
+
+
+
+ 0.00% + + + +
+
+ + + + + + + 1,694,538,840 + + + + + + + + + 1,694,538,845 + + + + + + + + + 1,694,538,850 + + + + + + + + + 1,694,538,855 + + + + + + + + + 1,694,538,860 + + + + + + + + + 1,694,538,865 + + + + + + + + + 1,694,538,870 + + + + + + + + + 1,694,538,875 + + + + + + + + + 1,694,538,880 + + + + + + + + + 1,694,538,885 + + + + + + + + + 1,694,538,890 + + + + + + + + + 1,694,538,895 + + + + + + + + + 1,694,538,900 + + + + + + + +
+`; diff --git a/src/components/Charts/PriceChart/index.test.tsx b/src/components/Charts/PriceChart/index.test.tsx new file mode 100644 index 0000000000..48f76ccb36 --- /dev/null +++ b/src/components/Charts/PriceChart/index.test.tsx @@ -0,0 +1,74 @@ +import { TimePeriod } from 'graphql/data/util' +import { render, screen } from 'test-utils/render' + +import { PriceChart } from '.' + +jest.mock('components/Charts/AnimatedInLineChart', () => ({ + __esModule: true, + default: jest.fn(() => null), +})) +jest.mock('components/Charts/FadeInLineChart', () => ({ + __esModule: true, + default: jest.fn(() => null), +})) + +describe('PriceChart', () => { + it('renders correctly with all prices filled', () => { + const mockPrices = Array.from({ length: 13 }, (_, i) => ({ + value: 1, + timestamp: i * 3600, + })) + + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + expect(asFragment().textContent).toContain('$1.00') + expect(asFragment().textContent).toContain('0.00%') + }) + it('renders correctly with some prices filled', () => { + const mockPrices = Array.from({ length: 13 }, (_, i) => ({ + value: i < 10 ? 1 : 0, + timestamp: i * 3600, + })) + + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + expect(asFragment().textContent).toContain('$1.00') + expect(asFragment().textContent).toContain('0.00%') + }) + it('renders correctly with empty price array', () => { + const { asFragment } = render() + expect(asFragment()).toMatchSnapshot() + expect(asFragment().textContent).toContain('Price Unavailable') + expect(asFragment().textContent).toContain('Missing price data due to recently low trading volume on Uniswap v3') + }) + it('renders correctly with undefined prices', () => { + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + expect(asFragment().textContent).toContain('Price Unavailable') + expect(asFragment().textContent).toContain('Missing chart data') + }) + it('renders stale UI', () => { + const { asFragment } = render( + + ) + expect(asFragment()).toMatchSnapshot() + expect(asFragment().textContent).toContain('$1.00') + expect(screen.getByTestId('chart-stale-icon')).toBeInTheDocument() + }) +}) diff --git a/src/components/Charts/PriceChart/index.tsx b/src/components/Charts/PriceChart/index.tsx new file mode 100644 index 0000000000..cf13d654a0 --- /dev/null +++ b/src/components/Charts/PriceChart/index.tsx @@ -0,0 +1,306 @@ +import { Trans } from '@lingui/macro' +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 { buildChartModel, ChartErrorType, ChartModel, ErroredChartModel } from 'components/Charts/PriceChart/ChartModel' +import { getTimestampFormatter, TimestampFormatterType } from 'components/Charts/PriceChart/format' +import { getNearestPricePoint, getTicks } from 'components/Charts/PriceChart/utils' +import { MouseoverTooltip } from 'components/Tooltip' +import { curveCardinal } from 'd3' +import { PricePoint, TimePeriod } from 'graphql/data/util' +import { useActiveLocale } from 'hooks/useActiveLocale' +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { Info } from 'react-feather' +import styled, { useTheme } from 'styled-components' +import { ThemedText } from 'theme' +import { textFadeIn } from 'theme/styles' +import { useFormatter } from 'utils/formatNumbers' + +import { calculateDelta, DeltaArrow, formatDelta } from '../../Tokens/TokenDetails/Delta' + +const CHART_MARGIN = { top: 100, bottom: 48, crosshair: 72 } + +const ChartHeaderWrapper = styled.div<{ stale?: boolean }>` + position: absolute; + ${textFadeIn}; + animation-duration: ${({ theme }) => theme.transition.duration.medium}; + ${({ theme, stale }) => stale && `color: ${theme.neutral2}`}; +` +const PriceContainer = styled.div` + display: flex; + gap: 6px; + font-size: 24px; + line-height: 44px; +` +const DeltaContainer = styled.div` + height: 16px; + display: flex; + align-items: center; + margin-top: 4px; + color: ${({ theme }) => theme.neutral2}; +` + +interface ChartDeltaProps { + startingPrice: PricePoint + endingPrice: PricePoint + noColor?: boolean +} + +function ChartDelta({ startingPrice, endingPrice, noColor }: ChartDeltaProps) { + const delta = calculateDelta(startingPrice.value, endingPrice.value) + return ( + + {formatDelta(delta)} + + + ) +} + +interface ChartHeaderProps { + crosshairPrice?: PricePoint + chart: ChartModel +} + +function ChartHeader({ crosshairPrice, chart }: ChartHeaderProps) { + const { formatFiatPrice } = useFormatter() + + const { startingPrice, endingPrice, lastValidPrice } = chart + + const priceOutdated = lastValidPrice !== endingPrice + const displayPrice = crosshairPrice ?? (priceOutdated ? lastValidPrice : endingPrice) + + const displayIsStale = priceOutdated && !crosshairPrice + return ( + + + + {formatFiatPrice({ price: displayPrice.value })} + + {displayIsStale && ( + This price may not be up-to-date due to low trading volume.}> + + + )} + + + + ) +} + +function ChartBody({ chart, timePeriod }: { chart: ChartModel; timePeriod: TimePeriod }) { + const locale = useActiveLocale() + + const { prices, blanks, timeScale, priceScale, dimensions } = chart + + const { ticks, tickTimestampFormatter, crosshairTimestampFormatter } = useMemo(() => { + // Limits the number of ticks based on graph width + const maxTicks = Math.floor(dimensions.width / 100) + + const ticks = getTicks(chart.startingPrice.timestamp, chart.endingPrice.timestamp, timePeriod, maxTicks) + const tickTimestampFormatter = getTimestampFormatter(timePeriod, locale, TimestampFormatterType.TICK) + const crosshairTimestampFormatter = getTimestampFormatter(timePeriod, locale, TimestampFormatterType.CROSSHAIR) + + return { ticks, tickTimestampFormatter, crosshairTimestampFormatter } + }, [dimensions.width, chart.startingPrice.timestamp, chart.endingPrice.timestamp, timePeriod, locale]) + + const theme = useTheme() + const [crosshair, setCrosshair] = useState<{ x: number; y: number; price: PricePoint }>() + const resetCrosshair = useCallback(() => setCrosshair(undefined), [setCrosshair]) + + const setCrosshairOnHover = useCallback( + (event: Element | EventType) => { + const { x } = localPoint(event) || { x: 0 } + const price = getNearestPricePoint(x, prices, timeScale) + + if (price) { + const x = timeScale(price.timestamp) + const y = priceScale(price.value) + setCrosshair({ x, y, price }) + } + }, + [priceScale, timeScale, prices] + ) + + // Resets the crosshair when the time period is changed, to avoid stale UI + useEffect(() => resetCrosshair(), [resetCrosshair, timePeriod]) + + const crosshairEdgeMax = dimensions.width * 0.85 + const crosshairAtEdge = !!crosshair && crosshair.x > crosshairEdgeMax + + // Default curve doesn't look good for the HOUR chart. + // Higher values make the curve more rigid, lower values smooth the curve but make it less "sticky" to real data points, + // making it unacceptable for shorter durations / smaller variances. + const curveTension = timePeriod === TimePeriod.HOUR ? 1 : 0.9 + + const getX = useCallback((p: PricePoint) => timeScale(p.timestamp), [timeScale]) + const getY = useCallback((p: PricePoint) => priceScale(p.value), [priceScale]) + const curve = useMemo(() => curveCardinal.tension(curveTension), [curveTension]) + + return ( + <> + + + + {blanks.map((blank, index) => ( + + ))} + {crosshair !== undefined ? ( + + ({ + fill: theme.neutral2, + fontSize: 12, + textAnchor: 'middle', + transform: 'translate(0 -29)', + })} + /> + + {crosshairTimestampFormatter(crosshair.price.timestamp)} + + + + + ) : ( + + )} + {!dimensions.width && ( + // Ensures an axis is drawn even if the width is not yet initialized. + + )} + + + + ) +} + +const CHART_ERROR_MESSAGES: Record = { + [ChartErrorType.NO_DATA_AVAILABLE]: Missing chart data, + [ChartErrorType.NO_RECENT_VOLUME]: Missing price data due to recently low trading volume on Uniswap v3, + [ChartErrorType.INVALID_CHART]: Invalid Chart, +} + +function MissingPriceChart({ chart }: { chart: ErroredChartModel }) { + const theme = useTheme() + const midPoint = chart.dimensions.height / 2 + 45 + + return ( + <> + + + Price Unavailable + + {CHART_ERROR_MESSAGES[chart.error]} + + + + + + ) +} + +interface PriceChartProps { + width: number + height: number + prices?: PricePoint[] + timePeriod: TimePeriod +} + +export function PriceChart({ width, height, prices, timePeriod }: PriceChartProps) { + const chart = useMemo( + () => + buildChartModel({ + dimensions: { width, height, marginBottom: CHART_MARGIN.bottom, marginTop: CHART_MARGIN.top }, + prices, + }), + [width, height, prices] + ) + + if (chart.error !== undefined) { + return + } + + return +} diff --git a/src/components/Tokens/TokenDetails/ChartSection.tsx b/src/components/Tokens/TokenDetails/ChartSection.tsx index 06dbcbead9..4775363e03 100644 --- a/src/components/Tokens/TokenDetails/ChartSection.tsx +++ b/src/components/Tokens/TokenDetails/ChartSection.tsx @@ -7,7 +7,7 @@ import { useAtomValue } from 'jotai/utils' import { pageTimePeriodAtom } from 'pages/TokenDetails' import { startTransition, Suspense, useMemo } from 'react' -import { PriceChart } from './PriceChart' +import { PriceChart } from '../../Charts/PriceChart' import TimePeriodSelector from './TimeSelector' function usePriceHistory(tokenPriceData: TokenPriceQuery): PricePoint[] | undefined { @@ -60,7 +60,7 @@ function Chart({ return ( - {({ width }) => } + {({ width }) => } ({ - __esModule: true, - default: jest.fn(() => null), -})) -jest.mock('components/Charts/FadeInLineChart', () => ({ - __esModule: true, - default: jest.fn(() => null), -})) - -describe('PriceChart', () => { - it('renders correctly with all prices filled', () => { - const mockPrices = Array.from({ length: 13 }, (_, i) => ({ - value: 1, - timestamp: i * 3600, - })) - - const { asFragment } = render( - - ) - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('$1.00') - expect(asFragment().textContent).toContain('0.00%') - }) - it('renders correctly with some prices filled', () => { - const mockPrices = Array.from({ length: 13 }, (_, i) => ({ - value: i < 10 ? 1 : 0, - timestamp: i * 3600, - })) - - const { asFragment } = render( - - ) - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('$1.00') - expect(asFragment().textContent).toContain('0.00%') - }) - it('renders correctly with no prices filled', () => { - const { asFragment } = render() - expect(asFragment()).toMatchSnapshot() - expect(asFragment().textContent).toContain('Price Unavailable') - }) -}) diff --git a/src/components/Tokens/TokenDetails/PriceChart.tsx b/src/components/Tokens/TokenDetails/PriceChart.tsx deleted file mode 100644 index 1647c3f86d..0000000000 --- a/src/components/Tokens/TokenDetails/PriceChart.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import { Trans } from '@lingui/macro' -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 { cleanPricePoints, getNearestPricePoint, getPriceBounds, getTicks } from 'components/Charts/PriceChart/utils' -import { MouseoverTooltip } from 'components/Tooltip' -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' -import { Info, TrendingUp } from 'react-feather' -import styled, { useTheme } from 'styled-components' -import { ThemedText } from 'theme' -import { textFadeIn } from 'theme/styles' -import { useFormatter } from 'utils/formatNumbers' - -import { calculateDelta, DeltaArrow, formatDelta } from './Delta' - -const DATA_EMPTY = { value: 0, timestamp: 0 } - -const ChartHeader = styled.div` - position: absolute; - ${textFadeIn}; - animation-duration: ${({ theme }) => theme.transition.duration.medium}; -` -export const TokenPrice = styled.span` - font-size: 36px; - line-height: 44px; - font-weight: 485; -` -const MissingPrice = styled(TokenPrice)` - font-size: 24px; - line-height: 44px; - color: ${({ theme }) => theme.neutral3}; -` - -const OutdatedContainer = styled.div` - color: ${({ theme }) => theme.neutral2}; -` - -const DeltaContainer = styled.div` - height: 16px; - display: flex; - align-items: center; - margin-top: 4px; - color: ${({ theme }) => theme.neutral2}; -` - -const OutdatedPriceContainer = styled.div` - display: flex; - gap: 6px; - font-size: 24px; - line-height: 44px; -` - -const margin = { top: 100, bottom: 48, crosshair: 72 } -const timeOptionsHeight = 44 - -interface ChartDeltaProps { - startingPrice: PricePoint - endingPrice: PricePoint - noColor?: boolean -} - -function ChartDelta({ startingPrice, endingPrice, noColor }: ChartDeltaProps) { - const delta = calculateDelta(startingPrice.value, endingPrice.value) - return ( - - {formatDelta(delta)} - - - ) -} - -interface PriceChartProps { - width: number - height: number - prices?: PricePoint[] | null - timePeriod: TimePeriod -} - -export function PriceChart({ width, height, prices: originalPrices, timePeriod }: PriceChartProps) { - const locale = useActiveLocale() - const theme = useTheme() - const { formatFiatPrice } = useFormatter() - - const { prices, blanks } = useMemo( - () => - originalPrices && originalPrices.length > 0 ? cleanPricePoints(originalPrices) : { prices: null, blanks: [] }, - [originalPrices] - ) - - const chartAvailable = !!prices && prices.length > 0 - const missingPricesMessage = !chartAvailable ? ( - prices?.length === 0 ? ( - <> - Missing price data due to recently low trading volume on Uniswap v3 - - ) : ( - Missing chart data - ) - ) : null - - const tooltipMessage = ( - <> - This price may not be up-to-date due to low trading volume. - - ) - - //get the last non-zero price point - const lastPrice = useMemo(() => { - if (!prices) return DATA_EMPTY - for (let i = prices.length - 1; i >= 0; i--) { - if (prices[i].value !== 0) return prices[i] - } - return DATA_EMPTY - }, [prices]) - - //get the first non-zero price point - const firstPrice = useMemo(() => { - if (!prices) return DATA_EMPTY - for (let i = 0; i < prices.length; i++) { - if (prices[i].value !== 0) return prices[i] - } - return DATA_EMPTY - }, [prices]) - - // first price point on the x-axis of the current time period's chart - const startingPrice = originalPrices?.[0] ?? DATA_EMPTY - // last price point on the x-axis of the current time period's chart - const endingPrice = originalPrices?.[originalPrices.length - 1] ?? DATA_EMPTY - const [displayPrice, setDisplayPrice] = useState(startingPrice) - - // set display price to ending price when prices have changed. - useEffect(() => { - setDisplayPrice(endingPrice) - }, [prices, endingPrice]) - const [crosshair, setCrosshair] = useState(null) - - const graphHeight = height - timeOptionsHeight > 0 ? height - timeOptionsHeight : 0 - const graphInnerHeight = graphHeight - margin.top - margin.bottom > 0 ? graphHeight - margin.top - margin.bottom : 0 - - // Defining scales - // x scale - const timeScale = useMemo( - () => scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width]), - [startingPrice, endingPrice, width] - ) - // y scale - 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 pricePoint = getNearestPricePoint(x, prices, timeScale) - - if (pricePoint) { - setCrosshair(timeScale(pricePoint.timestamp)) - setDisplayPrice(pricePoint) - } - }, - [timeScale, prices] - ) - - const resetDisplay = useCallback(() => { - setCrosshair(null) - setDisplayPrice(endingPrice) - }, [setCrosshair, setDisplayPrice, endingPrice]) - - // Resets the crosshair when the time period is changed, to avoid stale UI - useEffect(() => { - setCrosshair(null) - }, [timePeriod]) - - 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 crosshairEdgeMax = width * 0.85 - const crosshairAtEdge = !!crosshair && crosshair > crosshairEdgeMax - - // Default curve doesn't look good for the HOUR chart. - // Higher values make the curve more rigid, lower values smooth the curve but make it less "sticky" to real data points, - // making it unacceptable for shorter durations / smaller variances. - const curveTension = timePeriod === TimePeriod.HOUR ? 1 : 0.9 - - const getX = useMemo(() => (p: PricePoint) => timeScale(p.timestamp), [timeScale]) - const getY = useMemo(() => (p: PricePoint) => rdScale(p.value), [rdScale]) - const curve = useMemo(() => curveCardinal.tension(curveTension), [curveTension]) - - return ( - <> - - {displayPrice.value ? ( - <> - {formatFiatPrice({ price: displayPrice.value })} - - - ) : lastPrice.value ? ( - - - {formatFiatPrice({ price: lastPrice.value })} - - - - - - - ) : ( - <> - Price Unavailable - {missingPricesMessage} - - )} - - {!chartAvailable ? ( - - ) : ( - - - {blanks.map((blank, index) => ( - - ))} - {crosshair !== null ? ( - - ({ - fill: theme.neutral2, - fontSize: 12, - textAnchor: 'middle', - transform: 'translate(0 -29)', - })} - /> - - {crosshairTimestampFormatter(displayPrice.timestamp)} - - - - - ) : ( - - )} - {!width && ( - // Ensures an axis is drawn even if the width is not yet initialized. - - )} - - - )} - - ) -} - -const StyledMissingChart = styled.svg` - text { - font-size: 12px; - font-weight: 485; - } -` -const chartBottomPadding = 15 -function MissingPriceChart({ width, height, message }: { width: number; height: number; message: ReactNode }) { - const theme = useTheme() - const midPoint = height / 2 + 45 - return ( - - - {message && } - - {message} - - - ) -} diff --git a/src/components/Tokens/TokenDetails/Skeleton.tsx b/src/components/Tokens/TokenDetails/Skeleton.tsx index 3cb5a9e475..ab15de58b6 100644 --- a/src/components/Tokens/TokenDetails/Skeleton.tsx +++ b/src/components/Tokens/TokenDetails/Skeleton.tsx @@ -2,12 +2,12 @@ import { SwapSkeleton } from 'components/swap/SwapSkeleton' import { ArrowLeft } from 'react-feather' import { useParams } from 'react-router-dom' import styled, { useTheme } from 'styled-components' +import { ThemedText } from 'theme' import { textFadeIn } from 'theme/styles' import { LoadingBubble } from '../loading' import { AboutContainer, AboutHeader } from './About' import { BreadcrumbNavLink } from './BreadcrumbNavLink' -import { TokenPrice } from './PriceChart' import { StatPair, StatsWrapper, StatWrapper } from './StatsSection' const SWAP_COMPONENT_WIDTH = 360 @@ -168,9 +168,9 @@ function Wave() { export function LoadingChart() { return ( - + - +