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:
parent
aeef2c2356
commit
60593df077
70
src/components/Charts/PriceChart/ChartModel.ts
Normal file
70
src/components/Charts/PriceChart/ChartModel.ts
Normal file
@ -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>
|
||||
`;
|
74
src/components/Charts/PriceChart/index.test.tsx
Normal file
74
src/components/Charts/PriceChart/index.test.tsx
Normal file
@ -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()
|
||||
})
|
||||
})
|
306
src/components/Charts/PriceChart/index.tsx
Normal file
306
src/components/Charts/PriceChart/index.tsx
Normal file
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user