Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 77 additions & 53 deletions packages/extension-polkagate/src/fullscreen/components/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

import 'chartjs-adapter-date-fns';

import type { ChartOptions, Plugin } from 'chart.js';
import type { ChartOptions, Plugin, TooltipItem } from 'chart.js';

import { Typography, useTheme } from '@mui/material';
import { ToggleButton, ToggleButtonGroup, Typography, useTheme } from '@mui/material';
import { CategoryScale, Chart as ChartJS, LinearScale, LineElement, PointElement, TimeScale, Tooltip } from 'chart.js';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Line } from 'react-chartjs-2';
Expand All @@ -14,9 +14,13 @@ import { useAlerts, useTranslation } from '@polkadot/extension-polkagate/src/hoo

import { DraggableModal } from './DraggableModal';
import SineWaveLoader from './SineWaveLoader';
import { fetchWithTimeout } from './utils';

ChartJS.register(LineElement, CategoryScale, LinearScale, PointElement, Tooltip, TimeScale);

interface MarketChartResponse {
prices: [number, number][];
}
interface PricePoint {
time: number; // timestamp in milliseconds
value: number;
Expand All @@ -29,28 +33,6 @@ interface TokenChartProps {
intervalSec?: number; // update interval
}

const fetchWithTimeout = (url: string, timeout = 10000) => {
const controller = new AbortController();
const signal = controller.signal;

return new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
controller.abort();
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);

fetch(url, { signal })
.then((res) => {
clearTimeout(timer);
resolve(res);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
};

const gradientFillPlugin: Plugin<'line'> = {
beforeDatasetsDraw(chart) {
const { chartArea: { bottom, top }, ctx } = chart;
Expand Down Expand Up @@ -106,21 +88,21 @@ const gradientFillPlugin: Plugin<'line'> = {
id: 'gradientFillPlugin'
};

const TokenChart: React.FC<TokenChartProps> = ({ coinId,
intervalSec = 60,
onClose,
vsCurrency = 'usd' }) => {
const TokenChart: React.FC<TokenChartProps> = ({ coinId, intervalSec = 60, onClose, vsCurrency = 'usd' }) => {
const theme = useTheme();
const { t } = useTranslation();
const chartRef = useRef<ChartJS<'line'>>(null);
const { notify } = useAlerts();

const [priceData, setPriceData] = useState<PricePoint[]>([]);
const [selectedRange, setSelectedRange] = useState<number>(7);

const fetchPriceData = useCallback(async () => {
try {
const days = selectedRange;

const res = await fetchWithTimeout(
`https://api.coingecko.com/api/v3/coins/markets?vs_currency=${vsCurrency}&ids=${coinId.toLowerCase()}&sparkline=true`
`https://api.coingecko.com/api/v3/coins/${coinId}/market_chart?vs_currency=${vsCurrency}&days=${days}`
);

if (!res.ok) {
Expand All @@ -133,30 +115,31 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
return;
}

const data = await res.json();
const raw: unknown = await res.json();
const data = raw as MarketChartResponse;

if (!data || !Array.isArray(data) || data.length === 0) {
if (!data.prices || !Array.isArray(data.prices)) {
notify(t('Something went wrong while fetching token data!'), 'info');

return;
}

const sparkLinePrices = data[0].sparkline_in_7d?.price as number[];
const maybePrices = data.prices;

if (!sparkLinePrices) {
if (!maybePrices) {
notify(t('Sparkline data not available for this token.'), 'info');

return;
}

// Build time series for last 7 days
const now = Date.now();
const prices: { time: number; value: number }[] = sparkLinePrices.map(
(p: number, i: number, arr: number[]) => ({
time: now - (arr.length - 1 - i) * 60 * 60 * 1000, // approximate hourly timestamps
value: p
})
);
let prices: PricePoint[] = [];

if (Array.isArray(maybePrices)) {
prices = maybePrices.map(([time, value]) => ({
time,
value
}));
}

setPriceData(prices);
} catch (err: unknown) {
Expand All @@ -169,11 +152,13 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,

notify(message, isTimeOut ? 'warning' : 'error');
}
}, [coinId, notify, t, vsCurrency]);
}, [coinId, notify, selectedRange, t, vsCurrency]);

useEffect(() => {
fetchPriceData().catch(console.error);
const id = window.setInterval(fetchPriceData, intervalSec * 1000);
const id = window.setInterval(() => {
fetchPriceData().catch(console.error);
}, intervalSec * 1000);

return () => {
clearInterval(id);
Expand All @@ -187,7 +172,6 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
borderColor: '#4caf50',
data: priceData.map((p) => p.value),
fill: false, // no fill here, plugin will handle gradient fill
label: `${coinId.toUpperCase()} Price (${vsCurrency.toUpperCase()})`,
pointRadius: 0,
segment: {
borderColor: (ctx: { p1: { parsed: { y: number; }; }; p0: { parsed: { y: number; }; }; }) => (ctx.p1.parsed.y >= ctx.p0.parsed.y ? '#4caf50' : '#FF3864')
Expand All @@ -196,15 +180,35 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
}
],
labels: priceData.map((p) => new Date(p.time))
}), [priceData, coinId, vsCurrency]);
}), [priceData]);

const options: ChartOptions<'line'> = {
interaction: { intersect: false, mode: 'nearest' },
plugins: {
legend: {
display: false
},
tooltip: { intersect: false, mode: 'index' }
tooltip: {
callbacks: {
label: (context: TooltipItem<'line'>) => {
const value = context.parsed.y.toFixed(2);
const currency = vsCurrency.toUpperCase();

return ` ${value} ${currency}`;
},
title: (items: TooltipItem<'line'>[]) => {
if (!items.length) {
return '';
}

const date = new Date(items[0].parsed.x);

return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' });
}
},
intersect: false,
mode: 'index'
}
},
responsive: true,
scales: {
Expand All @@ -216,11 +220,15 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
},
color: theme.palette.text.highlight,
font: { family: 'Inter', size: 11, weight: 400 },
maxTicksLimit: 7, // force exactly 7 labels
source: 'auto' // calculates tick positions automatically
maxTicksLimit: selectedRange === 7
? 7
: selectedRange === 30
? 6
: 12,
source: 'data'
},
time: { tooltipFormat: 'pp', unit: 'day' },
title: { color: theme.palette.text.secondary, display: true, font: { family: 'Inter', size: 12, weight: 400 }, text: 'Date' },
time: { tooltipFormat: 'PPP', unit: 'day' },
// title: { color: theme.palette.text.secondary, display: true, font: { family: 'Inter', size: 12, weight: 400 }, text: 'Date' },
type: 'time' as const
},
y: {
Expand Down Expand Up @@ -250,15 +258,18 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
return;
}

const point = el as PointElement;

chart.tooltip.setActiveElements(
[{ datasetIndex: 0, index: maxIndex }],
{ x: (el as any).x, y: (el as any).y }
{ x: point.x, y: point.y }
);
chart.update();
}
}, [priceData]);

const _onClose = useCallback(() => onClose(undefined), [onClose]);
const btnStyle = { color: theme.palette.text.secondary, fontFamily: 'Inter', fontSize: 12, fontWeight: 400 };

return (
<DraggableModal
Expand All @@ -267,7 +278,7 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
open={true}
showBackIconAsClose
style={{ left: (window.innerWidth - 677) / 2, minHeight: '400px', padding: '20px 20px 6px', width: '677px' }}
title={`${coinId.toUpperCase()} Price — Last 7 Days`}
title={`${coinId.toUpperCase()} Price — Last ${selectedRange} Days`}
Comment thread
Nick-1979 marked this conversation as resolved.
>
<>
{priceData.length === 0
Expand All @@ -277,7 +288,20 @@ const TokenChart: React.FC<TokenChartProps> = ({ coinId,
: (
<Line data={chartData} options={options} plugins={[gradientFillPlugin]} ref={chartRef} />
)}
<Typography sx={{ color: 'text.disabled', display: 'block', pr: '16px', textAlign: 'right', width: '100%' }} variant='S-2'>
<ToggleButtonGroup
aria-label='Time range'
exclusive
// eslint-disable-next-line react/jsx-no-bind, @typescript-eslint/no-unsafe-return
onChange={(_e, value) => value && setSelectedRange(value as number)}
size='small'
sx={{ mt: '10px' }}
value={selectedRange}
>
<ToggleButton sx={btnStyle} value={7}>{t('Week')}</ToggleButton>
<ToggleButton sx={btnStyle} value={30}>{t('Month')}</ToggleButton>
<ToggleButton sx={btnStyle} value={365}>{t('Year')}</ToggleButton>
</ToggleButtonGroup>
<Typography sx={{ color: 'text.disabled', display: 'block', mt: '-10px', pr: '16px', textAlign: 'right', width: '100%' }} variant='S-2'>
{t('powered by CoinGecko')}
</Typography>
</>
Expand Down
24 changes: 24 additions & 0 deletions packages/extension-polkagate/src/fullscreen/components/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2019-2026 @polkadot/extension-polkagate authors & contributors
// SPDX-License-Identifier: Apache-2.0

export const fetchWithTimeout = (url: string, timeout = 10000) => {
const controller = new AbortController();
const signal = controller.signal;

return new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
controller.abort();
reject(new Error(`Request timeout after ${timeout}ms`));
}, timeout);

fetch(url, { signal })
.then((res) => {
clearTimeout(timer);
resolve(res);
})
.catch((err) => {
clearTimeout(timer);
reject(err);
});
});
};
5 changes: 4 additions & 1 deletion packages/extension/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -929,5 +929,8 @@
"Choose a name for this contact": "",
"Add to address book": "",
"Looking for accounts that are proxied by the selected account on the {{chainName}} chain.": "",
"The account{{s}} below {{verb}} already proxied by the selected account on the {{chainName}} chain in your extension. Select {{selectionText}} to import.": ""
"The account{{s}} below {{verb}} already proxied by the selected account on the {{chainName}} chain in your extension. Select {{selectionText}} to import.": "",
"Week": "",
"Month": "",
"Year": ""
}
Loading