refactor: price chart organization (#7304)

* refactor: implement chart model type

* refactor: move PriceChart component into charts folder

* refactor: relocate pricechart test file

* lint

* fix: pr comments

* fix: use formatter hook in price chart file
This commit is contained in:
cartcrom 2023-09-20 15:58:41 -04:00 committed by GitHub
parent aeef2c2356
commit 60593df077
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1089 additions and 526 deletions

@ -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<number, number>
priceScale: ScaleLinear<number, number>
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,
}
}

@ -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"
>
<span
<div
class="c1"
>
$1.00
</span>
<div
class="css-15popx1"
>
$1.00
</div>
</div>
<div
class="c2"
>
@ -337,10 +345,10 @@ exports[`PriceChart renders correctly with all prices filled 1`] = `
</DocumentFragment>
`;
exports[`PriceChart renders correctly with no prices filled 1`] = `
exports[`PriceChart renders correctly with empty price array 1`] = `
<DocumentFragment>
.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;
}
<div
class="c0"
data-cy="chart-header"
>
<span
class="c1 c2"
<div
class="c1 css-slqfkh"
>
Price Unavailable
</span>
</div>
<div
class="c3 css-142zc9n"
style="color: rgb(206, 206, 206);"
class="c1 css-142zc9n"
>
Missing chart data
Missing price data due to recently low trading volume on Uniswap v3
</div>
</div>
.c0 text {
font-size: 12px;
font-weight: 485;
}
<svg
class="c0"
<svg
data-cy="missing-chart"
height="392"
style="min-width: 100%;"
@ -398,23 +387,18 @@ exports[`PriceChart renders correctly with no prices filled 1`] = `
stroke="#22222212"
stroke-width="2"
/>
<text
fill="#CECECE"
x="20"
y="377"
/>
</svg>
</DocumentFragment>
`;
exports[`PriceChart renders correctly with some prices filled 1`] = `
<DocumentFragment>
.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;
}
<div
class="c0"
data-cy="chart-header"
@ -468,69 +443,66 @@ exports[`PriceChart renders correctly with some prices filled 1`] = `
class="c1"
>
<div
class="c2"
class="css-15popx1"
>
<span
class="c3"
>
$1.00
</span>
<div
class="c4"
>
<div>
<svg
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="16"
y2="12"
/>
<line
x1="12"
x2="12.01"
y1="8"
y2="8"
/>
</svg>
</div>
</div>
$1.00
</div>
<div
class="c5"
class="c2"
>
0.00%
<svg
aria-label="up"
class="c6"
fill="none"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.3021 7.7547L17.6821 14.2475C18.4182 15.3388 17.7942 17 16.6482 17L7.3518 17C6.2058 17 5.5818 15.3376 6.3179 14.2475L10.6979 7.7547C11.377 6.7484 12.623 6.7484 13.3021 7.7547Z"
fill="currentColor"
/>
</svg>
<div>
<svg
data-testid="chart-stale-icon"
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="16"
y2="12"
/>
<line
x1="12"
x2="12.01"
y1="8"
y2="8"
/>
</svg>
</div>
</div>
</div>
<div
class="c3"
>
0.00%
<svg
aria-label="up"
class="c4"
fill="none"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.3021 7.7547L17.6821 14.2475C18.4182 15.3388 17.7942 17 16.6482 17L7.3518 17C6.2058 17 5.5818 15.3376 6.3179 14.2475L10.6979 7.7547C11.377 6.7484 12.623 6.7484 13.3021 7.7547Z"
fill="currentColor"
/>
</svg>
</div>
</div>
<svg
data-cy="price-chart"
@ -805,3 +777,548 @@ exports[`PriceChart renders correctly with some prices filled 1`] = `
</svg>
</DocumentFragment>
`;
exports[`PriceChart renders correctly with undefined prices 1`] = `
<DocumentFragment>
.c1 {
color: #CECECE;
}
.c0 {
position: absolute;
-webkit-animation: iAjNNh 125ms ease-in;
animation: iAjNNh 125ms ease-in;
-webkit-animation-duration: 250ms;
animation-duration: 250ms;
}
<div
class="c0"
data-cy="chart-header"
>
<div
class="c1 css-slqfkh"
>
Price Unavailable
</div>
<div
class="c1 css-142zc9n"
>
Missing chart data
</div>
</div>
<svg
data-cy="missing-chart"
height="392"
style="min-width: 100%;"
width="780"
>
<path
d="M 0 241 Q 104 171, 208 241 T 416 241
M 416 241 Q 520 171, 624 241 T 832 241"
fill="transparent"
stroke="#22222212"
stroke-width="2"
/>
</svg>
</DocumentFragment>
`;
exports[`PriceChart renders stale UI 1`] = `
<DocumentFragment>
.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;
}
<div
class="c0"
data-cy="chart-header"
>
<div
class="c1"
>
<div
class="css-15popx1"
>
$1.00
</div>
<div
class="c2"
>
<div>
<svg
data-testid="chart-stale-icon"
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="12"
cy="12"
r="10"
/>
<line
x1="12"
x2="12"
y1="16"
y2="12"
/>
<line
x1="12"
x2="12.01"
y1="8"
y2="8"
/>
</svg>
</div>
</div>
</div>
<div
class="c3"
>
0.00%
<svg
aria-label="up"
class="c4"
fill="none"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.3021 7.7547L17.6821 14.2475C18.4182 15.3388 17.7942 17 16.6482 17L7.3518 17C6.2058 17 5.5818 15.3376 6.3179 14.2475L10.6979 7.7547C11.377 6.7484 12.623 6.7484 13.3021 7.7547Z"
fill="currentColor"
/>
</svg>
</div>
</div>
<svg
data-cy="price-chart"
height="392"
style="min-width: 100%;"
width="780"
>
<g
class="visx-group visx-axis visx-axis-bottom"
transform="translate(0, 391)"
>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="48.75"
y="18"
>
<tspan
dy="0em"
x="48.75"
>
1,694,538,840
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="109.6875"
y="18"
>
<tspan
dy="0em"
x="109.6875"
>
1,694,538,845
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="170.625"
y="18"
>
<tspan
dy="0em"
x="170.625"
>
1,694,538,850
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="231.5625"
y="18"
>
<tspan
dy="0em"
x="231.5625"
>
1,694,538,855
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="292.5"
y="18"
>
<tspan
dy="0em"
x="292.5"
>
1,694,538,860
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="353.4375"
y="18"
>
<tspan
dy="0em"
x="353.4375"
>
1,694,538,865
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="414.375"
y="18"
>
<tspan
dy="0em"
x="414.375"
>
1,694,538,870
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="475.3125"
y="18"
>
<tspan
dy="0em"
x="475.3125"
>
1,694,538,875
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="536.25"
y="18"
>
<tspan
dy="0em"
x="536.25"
>
1,694,538,880
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="597.1875"
y="18"
>
<tspan
dy="0em"
x="597.1875"
>
1,694,538,885
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="658.125"
y="18"
>
<tspan
dy="0em"
x="658.125"
>
1,694,538,890
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="719.0625"
y="18"
>
<tspan
dy="0em"
x="719.0625"
>
1,694,538,895
</tspan>
</text>
</svg>
</g>
<g
class="visx-group visx-axis-tick"
transform="translate(0, 0)"
>
<svg
font-size="10"
style="overflow: visible;"
x="0"
y="0.25em"
>
<text
fill="#222"
font-family="Arial"
font-size="10"
text-anchor="middle"
transform=""
x="780"
y="18"
>
<tspan
dy="0em"
x="780"
>
1,694,538,900
</tspan>
</text>
</svg>
</g>
</g>
<rect
fill="transparent"
height="392"
width="780"
x="0"
y="0"
/>
</svg>
</DocumentFragment>
`;

@ -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(
<PriceChart prices={mockPrices} width={780} height={392} timePeriod={TimePeriod.HOUR} />
)
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(
<PriceChart prices={mockPrices} width={780} height={392} timePeriod={TimePeriod.HOUR} />
)
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(<PriceChart prices={[]} width={780} height={392} timePeriod={TimePeriod.HOUR} />)
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(
<PriceChart prices={undefined} width={780} height={392} timePeriod={TimePeriod.HOUR} />
)
expect(asFragment()).toMatchSnapshot()
expect(asFragment().textContent).toContain('Price Unavailable')
expect(asFragment().textContent).toContain('Missing chart data')
})
it('renders stale UI', () => {
const { asFragment } = render(
<PriceChart
prices={[
{ value: 1, timestamp: 1694538836 },
{ value: 1, timestamp: 1694538840 },
{ value: 1, timestamp: 1694538844 },
{ value: 0, timestamp: 1694538900 },
]}
width={780}
height={392}
timePeriod={TimePeriod.HOUR}
/>
)
expect(asFragment()).toMatchSnapshot()
expect(asFragment().textContent).toContain('$1.00')
expect(screen.getByTestId('chart-stale-icon')).toBeInTheDocument()
})
})

@ -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 (
<DeltaContainer>
{formatDelta(delta)}
<DeltaArrow delta={delta} noColor={noColor} />
</DeltaContainer>
)
}
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 (
<ChartHeaderWrapper data-cy="chart-header" stale={displayIsStale}>
<PriceContainer>
<ThemedText.HeadlineLarge color="inherit">
{formatFiatPrice({ price: displayPrice.value })}
</ThemedText.HeadlineLarge>
{displayIsStale && (
<MouseoverTooltip text={<Trans>This price may not be up-to-date due to low trading volume.</Trans>}>
<Info size={16} data-testid="chart-stale-icon" />
</MouseoverTooltip>
)}
</PriceContainer>
<ChartDelta startingPrice={startingPrice} endingPrice={displayPrice} noColor={priceOutdated} />
</ChartHeaderWrapper>
)
}
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 (
<>
<ChartHeader chart={chart} crosshairPrice={crosshair?.price} />
<svg data-cy="price-chart" width={dimensions.width} height={dimensions.height} style={{ minWidth: '100%' }}>
<AnimatedInLineChart
data={prices}
getX={getX}
getY={getY}
marginTop={dimensions.marginTop}
curve={curve}
strokeWidth={2}
/>
{blanks.map((blank, index) => (
<FadedInLineChart
key={index}
data={blank}
getX={getX}
getY={getY}
marginTop={dimensions.marginTop}
curve={curve}
strokeWidth={2}
color={theme.neutral3}
dashed
/>
))}
{crosshair !== undefined ? (
<g>
<AxisBottom
top={dimensions.height - 1}
scale={timeScale}
stroke={theme.surface3}
hideTicks={true}
tickValues={ticks}
tickFormat={tickTimestampFormatter}
tickLabelProps={() => ({
fill: theme.neutral2,
fontSize: 12,
textAnchor: 'middle',
transform: 'translate(0 -29)',
})}
/>
<text
x={crosshair.x + (crosshairAtEdge ? -4 : 4)}
y={CHART_MARGIN.crosshair + 10}
textAnchor={crosshairAtEdge ? 'end' : 'start'}
fontSize={12}
fill={theme.neutral2}
>
{crosshairTimestampFormatter(crosshair.price.timestamp)}
</text>
<Line
from={{ x: crosshair.x, y: CHART_MARGIN.crosshair }}
to={{ x: crosshair.x, y: dimensions.height }}
stroke={theme.surface3}
strokeWidth={1}
pointerEvents="none"
strokeDasharray="4,4"
/>
<GlyphCircle
left={crosshair.x}
top={crosshair.y + dimensions.marginTop}
size={50}
fill={theme.accent1}
stroke={theme.surface3}
strokeWidth={0.5}
/>
</g>
) : (
<AxisBottom
hideAxisLine={true}
scale={timeScale}
stroke={theme.surface3}
top={dimensions.height - 1}
hideTicks
/>
)}
{!dimensions.width && (
// Ensures an axis is drawn even if the width is not yet initialized.
<line
x1={0}
y1={dimensions.height - 1}
x2="100%"
y2={dimensions.height - 1}
fill="transparent"
shapeRendering="crispEdges"
stroke={theme.surface3}
strokeWidth={1}
/>
)}
<rect
x={0}
y={0}
width={dimensions.width}
height={dimensions.height}
fill="transparent"
onTouchStart={setCrosshairOnHover}
onTouchMove={setCrosshairOnHover}
onMouseMove={setCrosshairOnHover}
onMouseLeave={resetCrosshair}
/>
</svg>
</>
)
}
const CHART_ERROR_MESSAGES: Record<ChartErrorType, ReactNode> = {
[ChartErrorType.NO_DATA_AVAILABLE]: <Trans>Missing chart data</Trans>,
[ChartErrorType.NO_RECENT_VOLUME]: <Trans>Missing price data due to recently low trading volume on Uniswap v3</Trans>,
[ChartErrorType.INVALID_CHART]: <Trans>Invalid Chart</Trans>,
}
function MissingPriceChart({ chart }: { chart: ErroredChartModel }) {
const theme = useTheme()
const midPoint = chart.dimensions.height / 2 + 45
return (
<>
<ChartHeaderWrapper data-cy="chart-header">
<ThemedText.HeadlineLarge fontSize={24} color="neutral3">
Price Unavailable
</ThemedText.HeadlineLarge>
<ThemedText.BodySmall color="neutral3">{CHART_ERROR_MESSAGES[chart.error]}</ThemedText.BodySmall>
</ChartHeaderWrapper>
<svg
data-cy="missing-chart"
width={chart.dimensions.width}
height={chart.dimensions.height}
style={{ minWidth: '100%' }}
>
<path
d={`M 0 ${midPoint} Q 104 ${midPoint - 70}, 208 ${midPoint} T 416 ${midPoint}
M 416 ${midPoint} Q 520 ${midPoint - 70}, 624 ${midPoint} T 832 ${midPoint}`}
stroke={theme.surface3}
fill="transparent"
strokeWidth="2"
/>
</svg>
</>
)
}
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 <MissingPriceChart chart={chart} />
}
return <ChartBody chart={chart} timePeriod={timePeriod} />
}

@ -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 (
<ChartContainer data-testid="chart-container">
<ParentSize>
{({ width }) => <PriceChart prices={prices ?? null} width={width} height={436} timePeriod={timePeriod} />}
{({ width }) => <PriceChart prices={prices} width={width} height={392} timePeriod={timePeriod} />}
</ParentSize>
<TimePeriodSelector
currentTimePeriod={timePeriod}

@ -1,47 +0,0 @@
import { TimePeriod } from 'graphql/data/util'
import { render } from 'test-utils/render'
import { PriceChart } from './PriceChart'
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(
<PriceChart prices={mockPrices} width={780} height={436} timePeriod={TimePeriod.HOUR} />
)
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(
<PriceChart prices={mockPrices} width={780} height={436} timePeriod={TimePeriod.HOUR} />
)
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(<PriceChart prices={[]} width={780} height={436} timePeriod={TimePeriod.HOUR} />)
expect(asFragment()).toMatchSnapshot()
expect(asFragment().textContent).toContain('Price Unavailable')
})
})

@ -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 (
<DeltaContainer>
{formatDelta(delta)}
<DeltaArrow delta={delta} noColor={noColor} />
</DeltaContainer>
)
}
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 ? (
<>
<Trans>Missing price data due to recently low trading volume on Uniswap v3</Trans>
</>
) : (
<Trans>Missing chart data</Trans>
)
) : null
const tooltipMessage = (
<>
<Trans>This price may not be up-to-date due to low trading volume.</Trans>
</>
)
//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<number | null>(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 (
<>
<ChartHeader data-cy="chart-header">
{displayPrice.value ? (
<>
<TokenPrice>{formatFiatPrice({ price: displayPrice.value })}</TokenPrice>
<ChartDelta startingPrice={startingPrice} endingPrice={displayPrice} />
</>
) : lastPrice.value ? (
<OutdatedContainer>
<OutdatedPriceContainer>
<TokenPrice>{formatFiatPrice({ price: lastPrice.value })}</TokenPrice>
<MouseoverTooltip text={tooltipMessage}>
<Info size={16} />
</MouseoverTooltip>
</OutdatedPriceContainer>
<ChartDelta startingPrice={firstPrice} endingPrice={lastPrice} noColor />
</OutdatedContainer>
) : (
<>
<MissingPrice>Price Unavailable</MissingPrice>
<ThemedText.BodySmall style={{ color: theme.neutral3 }}>{missingPricesMessage}</ThemedText.BodySmall>
</>
)}
</ChartHeader>
{!chartAvailable ? (
<MissingPriceChart width={width} height={graphHeight} message={!!displayPrice.value && missingPricesMessage} />
) : (
<svg data-cy="price-chart" width={width} height={graphHeight} style={{ minWidth: '100%' }}>
<AnimatedInLineChart
data={prices}
getX={getX}
getY={getY}
marginTop={margin.top}
curve={curve}
strokeWidth={2}
/>
{blanks.map((blank, index) => (
<FadedInLineChart
key={index}
data={blank}
getX={getX}
getY={getY}
marginTop={margin.top}
curve={curve}
strokeWidth={2}
color={theme.neutral3}
dashed
/>
))}
{crosshair !== null ? (
<g>
<AxisBottom
top={graphHeight - 1}
scale={timeScale}
stroke={theme.surface3}
hideTicks={true}
tickValues={ticks}
tickFormat={tickTimestampFormatter}
tickLabelProps={() => ({
fill: theme.neutral2,
fontSize: 12,
textAnchor: 'middle',
transform: 'translate(0 -29)',
})}
/>
<text
x={crosshair + (crosshairAtEdge ? -4 : 4)}
y={margin.crosshair + 10}
textAnchor={crosshairAtEdge ? 'end' : 'start'}
fontSize={12}
fill={theme.neutral2}
>
{crosshairTimestampFormatter(displayPrice.timestamp)}
</text>
<Line
from={{ x: crosshair, y: margin.crosshair }}
to={{ x: crosshair, y: graphHeight }}
stroke={theme.surface3}
strokeWidth={1}
pointerEvents="none"
strokeDasharray="4,4"
/>
<GlyphCircle
left={crosshair}
top={rdScale(displayPrice.value) + margin.top}
size={50}
fill={theme.accent1}
stroke={theme.surface3}
strokeWidth={0.5}
/>
</g>
) : (
<AxisBottom hideAxisLine={true} scale={timeScale} stroke={theme.surface3} top={graphHeight - 1} hideTicks />
)}
{!width && (
// Ensures an axis is drawn even if the width is not yet initialized.
<line
x1={0}
y1={graphHeight - 1}
x2="100%"
y2={graphHeight - 1}
fill="transparent"
shapeRendering="crispEdges"
stroke={theme.surface3}
strokeWidth={1}
/>
)}
<rect
x={0}
y={0}
width={width}
height={graphHeight}
fill="transparent"
onTouchStart={handleHover}
onTouchMove={handleHover}
onMouseMove={handleHover}
onMouseLeave={resetDisplay}
/>
</svg>
)}
</>
)
}
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 (
<StyledMissingChart data-cy="missing-chart" width={width} height={height} style={{ minWidth: '100%' }}>
<path
d={`M 0 ${midPoint} Q 104 ${midPoint - 70}, 208 ${midPoint} T 416 ${midPoint}
M 416 ${midPoint} Q 520 ${midPoint - 70}, 624 ${midPoint} T 832 ${midPoint}`}
stroke={theme.surface3}
fill="transparent"
strokeWidth="2"
/>
{message && <TrendingUp stroke={theme.neutral3} x={0} size={12} y={height - chartBottomPadding - 10} />}
<text y={height - chartBottomPadding} x="20" fill={theme.neutral3}>
{message}
</text>
</StyledMissingChart>
)
}

@ -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 (
<ChartContainer>
<TokenPrice>
<ThemedText.HeadlineLarge>
<PriceBubble />
</TokenPrice>
</ThemedText.HeadlineLarge>
<Space heightSize={6} />
<LoadingChartContainer>
<div>