Move charts to toolkit and improve color mode hook (#3075)

* move charts to toolkit

* refactor imports

* fix tests

* change the behavior of the color mode hook and add the noWatermark prop

* fix gas tracker test and fix toolkit build
This commit is contained in:
tom goriunov
2025-10-21 17:33:04 +02:00
committed by GitHub
parent 4c78f91771
commit 5c0c18149d
102 changed files with 1032 additions and 777 deletions
-10
View File
@@ -1,10 +0,0 @@
export default function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
-14
View File
@@ -1,14 +0,0 @@
import { unparse } from 'papaparse';
import downloadBlob from 'lib/downloadBlob';
export default function saveAsCSV(headerRows: Array<string>, dataRows: Array<Array<string>>, filename: string) {
const csv = unparse([
headerRows,
...dataRows,
]);
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
downloadBlob(blob, filename);
}
+2 -2
View File
@@ -25,7 +25,7 @@ export function ColorModeProvider(props: ColorModeProviderProps) {
export function useColorMode() {
const { resolvedTheme, setTheme } = useTheme();
const toggleColorMode = () => {
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return {
colorMode: resolvedTheme as ColorMode,
@@ -36,5 +36,5 @@ export function useColorMode() {
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode();
return colorMode === 'light' ? light : dark;
return colorMode === 'dark' ? dark : light;
}
@@ -1,69 +1,72 @@
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import { defaultsDeep } from 'es-toolkit/compat';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { AxesConfigFn, ChartMargin, TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import type { AxesConfigFn, ChartMargin, TimeChartData } from './types';
import { Resolution } from './types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
import ChartGridLine from 'ui/shared/chart/ChartGridLine';
import ChartLine from 'ui/shared/chart/ChartLine';
import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartSelectionX from 'ui/shared/chart/ChartSelectionX';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
interface Props {
isEnlarged?: boolean;
import { ChartArea } from './parts/ChartArea';
import { ChartAxis } from './parts/ChartAxis';
import { ChartGridLine } from './parts/ChartGridLine';
import { ChartLine } from './parts/ChartLine';
import { ChartOverlay } from './parts/ChartOverlay';
import { ChartSelectionX } from './parts/ChartSelectionX';
import { ChartTooltip } from './parts/ChartTooltip';
import { getDateLabel } from './utils/getDateLabel';
import { useTimeChartController } from './utils/useTimeChartController';
export interface ChartProps {
charts: TimeChartData;
title: string;
units?: string;
items: Array<TimeChartItem>;
zoomRange?: [ Date, Date ];
onZoom: (range: [ Date, Date ]) => void;
margin?: ChartMargin;
noAnimation?: boolean;
resolution?: Resolution;
axesConfig?: AxesConfigFn;
isEnlarged?: boolean;
}
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 };
const ChartWidgetGraph = ({
export const Chart = React.memo(({
isEnlarged,
items,
charts,
onZoom,
title,
margin: marginProps,
units,
noAnimation,
resolution,
zoomRange,
axesConfig: axesConfigProps,
}: Props) => {
}: ChartProps) => {
const isMobile = useIsMobile();
const [ color ] = useToken('colors', useColorModeValue('theme.graph.line._light', 'theme.graph.line._dark'));
const chartId = `chart-${ title.replace(/[^\w\s]/g, '').split(' ').join('_') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const overlayRef = React.useRef<SVGRectElement>(null);
const range = React.useMemo(() => zoomRange || [ items[0].date, items[items.length - 1].date ], [ zoomRange, items ]);
const range = React.useMemo(
() =>
zoomRange || [
charts[0].items[0].date,
charts[0].items[charts[0].items.length - 1].date,
],
[ zoomRange, charts ],
);
const displayedData = React.useMemo(() =>
items
.filter((item) => item.date >= range[0] && item.date <= range[1])
.map((item) => ({
dateLabel: getDateLabel(item.date, item.date_to, resolution),
...item,
})),
[ items, range, resolution ]);
const chartData: TimeChartData = React.useMemo(() => ([ { items: displayedData, name: 'Value', color, units } ]), [ color, displayedData, units ]);
const displayedCharts = React.useMemo(() => {
return charts.map((chart) => ({
...chart,
items: chart.items
.filter((item) => item.date >= range[0] && item.date <= range[1])
.map((item) => (item.dateLabel ? item : {
...item,
dateLabel: getDateLabel(item.date, item.date_to, resolution),
})),
}));
}, [ charts, range, resolution ]);
const margin: ChartMargin = React.useMemo(() => ({ ...DEFAULT_CHART_MARGIN, ...marginProps }), [ marginProps ]);
const axesConfig = React.useMemo(() => {
return defaultsDeep(axesConfigProps?.({ isEnlarged, isMobile }), {
x: {
@@ -84,13 +87,13 @@ const ChartWidgetGraph = ({
chartMargin,
axes,
} = useTimeChartController({
data: chartData,
data: displayedCharts,
margin,
axesConfig,
});
return (
<svg width="100%" height="100%" ref={ ref } cursor="pointer" id={ chartId } opacity={ rect ? 1 : 0 }>
<svg width="100%" height="100%" ref={ ref } cursor="pointer" opacity={ rect ? 1 : 0 }>
<g transform={ `translate(${ chartMargin?.left || 0 },${ chartMargin?.top || 0 })` }>
<ChartGridLine
@@ -101,23 +104,42 @@ const ChartWidgetGraph = ({
noAnimation
/>
<ChartArea
id={ chartId }
data={ displayedData }
color={ color }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
noAnimation={ noAnimation }
/>
{ displayedCharts.map((chart) => {
const id = `${ chart.id }-${ isEnlarged ? 'fullscreen' : 'small' }`;
return (
<React.Fragment key={ id }>
{ chart.charts.map((chartConfig) => {
<ChartLine
data={ displayedData }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
stroke={ color }
animation="none"
strokeWidth={ isMobile ? 1 : 2 }
/>
if (chartConfig.type === 'area') {
return (
<ChartArea
key={ chartConfig.type }
id={ id }
data={ chart.items }
gradient={ chartConfig.gradient }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
noAnimation={ noAnimation }
/>
);
}
return (
<ChartLine
key={ chartConfig.type }
data={ chart.items }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
stroke={ chartConfig.color }
strokeWidth={ chartConfig.strokeWidth || 2 }
strokeDasharray={ chartConfig.strokeDasharray }
animation="none"
/>
);
}) }
</React.Fragment>
);
}) }
<ChartAxis
type="left"
@@ -145,7 +167,7 @@ const ChartWidgetGraph = ({
height={ innerHeight }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
data={ chartData }
data={ displayedCharts }
noAnimation={ noAnimation }
resolution={ resolution }
/>
@@ -154,26 +176,11 @@ const ChartWidgetGraph = ({
anchorEl={ overlayRef.current }
height={ innerHeight }
scale={ axes.x.scale }
data={ chartData }
data={ displayedCharts }
onSelect={ onZoom }
/>
</ChartOverlay>
</g>
</svg>
);
};
export default React.memo(ChartWidgetGraph);
function getDateLabel(date: Date, dateTo?: Date, resolution?: Resolution): string {
switch (resolution) {
case Resolution.WEEK:
return d3.timeFormat('%e %b %Y')(date) + (dateTo ? ` – ${ d3.timeFormat('%e %b %Y')(dateTo) }` : '');
case Resolution.MONTH:
return d3.timeFormat('%b %Y')(date);
case Resolution.YEAR:
return d3.timeFormat('%Y')(date);
default:
return d3.timeFormat('%e %b %Y')(date);
}
}
});
@@ -1,23 +1,21 @@
import { Grid, Text } from '@chakra-ui/react';
import { Grid, Icon, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import type { Resolution, TimeChartData } from './types';
import { Button } from 'toolkit/chakra/button';
import { DialogBody, DialogContent, DialogHeader, DialogRoot } from 'toolkit/chakra/dialog';
import RepeatIcon from 'icons/repeat.svg';
import { Heading } from 'toolkit/chakra/heading';
import IconSvg from 'ui/shared/IconSvg';
import ChartWidgetContent from './ChartWidgetContent';
import { Button } from '../../chakra/button';
import { DialogBody, DialogContent, DialogHeader, DialogRoot } from '../../chakra/dialog';
import { ChartWidgetContent } from './ChartWidgetContent';
type Props = {
export interface Props {
open: boolean;
onOpenChange: ({ open }: { open: boolean }) => void;
title: string;
description?: string;
items: Array<TimeChartItem>;
units?: string;
charts: TimeChartData;
resolution?: Resolution;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
@@ -25,12 +23,11 @@ type Props = {
};
const FullscreenChartModal = ({
charts,
open,
onOpenChange,
title,
description,
items,
units,
resolution,
zoomRange,
handleZoom,
@@ -71,15 +68,14 @@ const FullscreenChartModal = ({
variant="outline"
onClick={ handleZoomReset }
>
<IconSvg name="repeat" w={ 4 } h={ 4 }/>
<Icon boxSize={ 4 }><RepeatIcon/></Icon>
Reset zoom
</Button>
) }
</Grid>
<ChartWidgetContent
isEnlarged
items={ items }
units={ units }
charts={ charts }
handleZoom={ handleZoom }
zoomRange={ zoomRange }
title={ title }
+195
View File
@@ -0,0 +1,195 @@
import type { FlexProps } from '@chakra-ui/react';
import { Flex, Icon } from '@chakra-ui/react';
import { range } from 'es-toolkit';
import React, { useRef } from 'react';
import type { AxesConfigFn, TimeChartData } from './types';
import RepeatIcon from 'icons/repeat.svg';
import { IconButton } from '../../chakra/icon-button';
import { Link } from '../../chakra/link';
import { Skeleton } from '../../chakra/skeleton';
import { Tooltip } from '../../chakra/tooltip';
import { ChartWidgetContent } from './ChartWidgetContent';
import { ChartLegend } from './parts/ChartLegend';
import type { ChartMenuItemId } from './parts/ChartMenu';
import ChartMenu from './parts/ChartMenu';
import { useChartZoom } from './utils/useChartZoom';
export interface ChartWidgetProps extends FlexProps {
charts: TimeChartData;
title: string;
description?: string;
isLoading: boolean;
isError: boolean;
emptyText?: string;
noAnimation?: boolean;
href?: string;
chartUrl?: string;
axesConfig?: AxesConfigFn;
menuItemIds?: Array<ChartMenuItemId>;
noWatermark?: boolean;
};
export const ChartWidget = React.memo(({
charts,
title,
description,
isLoading,
isError,
emptyText,
noAnimation,
href,
chartUrl,
axesConfig,
menuItemIds,
noWatermark,
...rest
}: ChartWidgetProps) => {
const ref = useRef<HTMLDivElement>(null);
const { zoomRange, handleZoom, handleZoomReset } = useChartZoom();
const [ selectedCharts, setSelectedCharts ] = React.useState<Array<number>>(
range(charts.length),
);
React.useEffect(() => {
if (charts.length > 0) {
setSelectedCharts(range(charts.length));
}
}, [ charts.length ]);
const handleLegendItemClick = React.useCallback((index: number) => {
setSelectedCharts((prev) => {
if (prev.includes(index)) {
return prev.filter((item) => item !== index);
}
return [ ...prev, index ];
});
}, []);
const displayedCharts = React.useMemo(() => {
return charts.filter((_, index) => selectedCharts.includes(index));
}, [ charts, selectedCharts ]);
const hasNonEmptyCharts = charts.some(({ items }) => items && items.length > 2);
const hasMenu = (() => {
const hasIds = !(menuItemIds && menuItemIds.length === 0);
if (!hasIds) {
return false;
}
if (isError) {
return false;
}
if (!hasNonEmptyCharts) {
return false;
}
return true;
})();
const content = (
<ChartWidgetContent
charts={ displayedCharts }
isError={ isError }
isLoading={ isLoading }
title={ title }
empty={ !hasNonEmptyCharts }
emptyText={ emptyText }
handleZoom={ handleZoom }
zoomRange={ zoomRange }
noAnimation={ noAnimation }
axesConfig={ axesConfig }
noWatermark={ noWatermark }
/>
);
const chartHeader = (
<Flex
flexGrow={ 1 }
flexDir="column"
alignItems="flex-start"
cursor={ href ? 'pointer' : 'default' }
_hover={ href ? { color: 'link.primary.hovered' } : {} }
>
<Skeleton
loading={ isLoading }
fontWeight={ 600 }
textStyle="md"
>
<span>{ title }</span>
</Skeleton>
{ description && (
<Skeleton
loading={ isLoading }
color="text.secondary"
textStyle="xs"
mt={ 1 }
>
<span>{ description }</span>
</Skeleton>
) }
</Flex>
);
return (
<Flex
height="100%"
ref={ ref }
flexDir="column"
padding={{ base: 3, lg: 4 }}
borderRadius="lg"
borderWidth="1px"
borderColor={{ _light: 'gray.200', _dark: 'gray.600' }}
{ ...rest }
>
<Flex columnGap={ 6 } mb={ 2 } alignItems="flex-start">
{ href ? (
<Link href={ href }>
{ chartHeader }
</Link>
) : chartHeader }
<Flex ml="auto" columnGap={ 2 }>
<Tooltip content="Reset zoom">
<IconButton
hidden={ !zoomRange }
aria-label="Reset zoom"
size="md"
variant="icon_secondary"
onClick={ handleZoomReset }
>
<Icon><RepeatIcon/></Icon>
</IconButton>
</Tooltip>
{ hasMenu && (
<ChartMenu
charts={ charts }
itemIds={ menuItemIds }
title={ title }
description={ description }
chartUrl={ chartUrl }
isLoading={ isLoading }
chartRef={ ref }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
zoomRange={ zoomRange }
/>
) }
</Flex>
</Flex>
{ content }
{ charts.length > 1 && (
<ChartLegend
data={ charts }
selectedIndexes={ selectedCharts }
onItemClick={ handleLegendItemClick }
/>
) }
</Flex>
);
});
@@ -1,22 +1,20 @@
import { Box, Center, Flex, Text } from '@chakra-ui/react';
import React from 'react';
import type { AxesConfigFn, TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import type { AxesConfigFn, Resolution, TimeChartData } from './types';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { apos } from 'toolkit/utils/htmlEntities';
import { Link } from '../../chakra/link';
import { Skeleton } from '../../chakra/skeleton';
import { apos } from '../../utils/htmlEntities';
import { Chart } from './Chart';
import { ChartWatermark } from './parts/ChartWatermark';
import ChartWatermarkIcon from './ChartWatermarkIcon';
import ChartWidgetGraph from './ChartWidgetGraph';
export type Props = {
items?: Array<TimeChartItem>;
export interface ChartWidgetContentProps {
charts: TimeChartData;
title: string;
units?: string;
isLoading?: boolean;
isError?: boolean;
empty?: boolean;
emptyText?: string;
zoomRange?: [ Date, Date ];
handleZoom: (range: [ Date, Date ]) => void;
@@ -24,14 +22,15 @@ export type Props = {
noAnimation?: boolean;
resolution?: Resolution;
axesConfig?: AxesConfigFn;
noWatermark?: boolean;
};
const ChartWidgetContent = ({
items,
export const ChartWidgetContent = React.memo(({
charts,
title,
isLoading,
isError,
units,
empty,
emptyText,
zoomRange,
handleZoom,
@@ -39,9 +38,8 @@ const ChartWidgetContent = ({
noAnimation,
resolution,
axesConfig,
}: Props) => {
const hasItems = items && items.length > 2;
noWatermark,
}: ChartWidgetContentProps) => {
if (isError) {
return (
<Flex
@@ -66,7 +64,7 @@ const ChartWidgetContent = ({
return <Skeleton loading flexGrow={ 1 } w="100%"/>;
}
if (!hasItems) {
if (empty || charts.length === 0) {
return (
<Center flexGrow={ 1 }>
<Text color="text.secondary" fontSize="sm">{ emptyText || 'No data' }</Text>
@@ -76,20 +74,17 @@ const ChartWidgetContent = ({
return (
<Box flexGrow={ 1 } maxW="100%" position="relative" h="100%">
<ChartWidgetGraph
items={ items }
<Chart
charts={ charts }
zoomRange={ zoomRange }
onZoom={ handleZoom }
title={ title }
units={ units }
isEnlarged={ isEnlarged }
noAnimation={ noAnimation }
resolution={ resolution }
axesConfig={ axesConfig }
/>
<ChartWatermarkIcon w="162px" h="15%"/>
{ !noWatermark && <ChartWatermark w="162px" h="15%"/> }
</Box>
);
};
export default React.memo(ChartWidgetContent);
});
+20
View File
@@ -0,0 +1,20 @@
export * from './Chart';
export * from './ChartWidget';
export * from './ChartWidgetContent';
export * from './parts/ChartArea';
export * from './parts/ChartAxis';
export * from './parts/ChartGridLine';
export * from './parts/ChartLegend';
export * from './parts/ChartLine';
export * from './parts/ChartMenu';
export * from './parts/ChartOverlay';
export * from './parts/ChartSelectionX';
export * from './parts/ChartTooltip';
export * from './parts/ChartWatermark';
export * from './utils/useChartBrushX';
export * from './utils/useChartZoom';
export * from './utils/useChartLegend';
export * from './utils/useTimeChartController';
export * from './types';
@@ -1,27 +1,24 @@
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
import type { TimeChartItem } from '../types';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
interface Props extends React.SVGProps<SVGPathElement> {
id?: string;
export interface ChartAreaProps extends React.SVGProps<SVGPathElement> {
id: string;
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
color?: string;
gradient: {
startColor: string;
stopColor: string;
};
data: Array<TimeChartItem>;
noAnimation?: boolean;
}
const ChartArea = ({ id, xScale, yScale, color, data, noAnimation, ...props }: Props) => {
export const ChartArea = React.memo(({ id, xScale, yScale, gradient, data, noAnimation, ...props }: ChartAreaProps) => {
const ref = React.useRef(null);
const defaultGradient = {
startColor: useToken('colors', useColorModeValue('theme.graph.gradient.start._light', 'theme.graph.gradient.start._dark')),
stopColor: useToken('colors', useColorModeValue('theme.graph.gradient.stop._light', 'theme.graph.gradient.stop._dark')),
};
const gradientId = `gradient-chart-area-${ id }`;
React.useEffect(() => {
if (noAnimation) {
@@ -49,19 +46,17 @@ const ChartArea = ({ id, xScale, yScale, color, data, noAnimation, ...props }: P
<path
ref={ ref }
d={ d }
fill="url(#gradient-chart-area-default)"
fill={ `url(#${ gradientId })` }
opacity={ 0 }
data-name={ id || 'gradient-chart-area' }
{ ...props }
/>
<defs>
<linearGradient id="gradient-chart-area-default" x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stopColor={ defaultGradient.startColor[0] }/>
<stop offset="100%" stopColor={ defaultGradient.stopColor[0] }/>
<linearGradient id={ gradientId } x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stopColor={ gradient.startColor }/>
<stop offset="100%" stopColor={ gradient.stopColor }/>
</linearGradient>
</defs>
</>
);
};
export default React.memo(ChartArea);
});
@@ -2,9 +2,9 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import { useColorModeValue } from '../../../chakra/color-mode';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
export interface ChartAxisProps extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'left' | 'bottom';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
noAnimation?: boolean;
@@ -13,7 +13,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
anchorEl?: SVGRectElement | null;
}
const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, noAnimation, anchorEl, ...props }: Props) => {
export const ChartAxis = React.memo(({ type, scale, ticks, tickFormatGenerator, noAnimation, anchorEl, ...props }: ChartAxisProps) => {
const ref = React.useRef<SVGGElement>(null);
const textColor = useToken('colors', useColorModeValue('blackAlpha.600', 'whiteAlpha.500'));
@@ -65,7 +65,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, noAnimation, ancho
const data = textElements.data();
const index = d3.bisector((d) => d).left(data, xDate);
textElements
.style('font-weight', (d, i) => i === index - 1 ? 'bold' : 'normal');
.style('font-weight', (_, i) => i === index - 1 ? 'bold' : 'normal');
});
return () => {
@@ -74,6 +74,4 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, noAnimation, ancho
}, [ anchorEl, scale ]);
return <g ref={ ref } { ...props }/>;
};
export default React.memo(ChartAxis);
});
@@ -2,7 +2,7 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
export interface ChartGridLineProps extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'vertical' | 'horizontal';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
noAnimation?: boolean;
@@ -10,7 +10,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
ticks: number;
}
const ChartGridLine = ({ type, scale, ticks, size, noAnimation, ...props }: Props) => {
export const ChartGridLine = React.memo(({ type, scale, ticks, size, noAnimation, ...props }: ChartGridLineProps) => {
const ref = React.useRef<SVGGElement>(null);
const strokeColor = useToken('colors', 'border.divider');
@@ -35,6 +35,4 @@ const ChartGridLine = ({ type, scale, ticks, size, noAnimation, ...props }: Prop
}, [ scale, ticks, size, noAnimation, type, strokeColor ]);
return <g ref={ ref } { ...props }/>;
};
export default React.memo(ChartGridLine);
});
@@ -0,0 +1,61 @@
import type { BoxProps } from '@chakra-ui/react';
import { Box, Circle, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from '../types';
export interface ChartLegendProps extends BoxProps {
data: TimeChartData;
selectedIndexes?: Array<number>;
onItemClick?: (index: number) => void;
}
export const ChartLegend = React.memo(({ data, selectedIndexes, onItemClick, ...props }: ChartLegendProps) => {
const handleItemClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const itemIndex = (event.currentTarget as HTMLDivElement).getAttribute(
'data-index',
);
onItemClick?.(Number(itemIndex));
},
[ onItemClick ],
);
return (
<Box display="flex" columnGap={ 3 } { ...props }>
{ data.map((item, index) => {
const isSelected = selectedIndexes?.includes(index);
const lineColor = (() => {
const lineChart = item.charts.find((chart) => chart.type === 'line');
const areaChart = item.charts.find((chart) => chart.type === 'area');
return (
lineChart?.color || areaChart?.gradient.startColor || 'transparent'
);
})();
return (
<Box
key={ item.name }
data-index={ index }
display="flex"
alignItems="center"
columnGap={ 1 }
p="2px"
onClick={ handleItemClick }
cursor="pointer"
>
<Circle
size={ 2 }
bgColor={ isSelected ? lineColor : 'transparent' }
borderWidth={ 2 }
borderColor={ lineColor }
/>
<Text fontSize="xs" color="text.secondary">
{ item.name }
</Text>
</Box>
);
}) }
</Box>
);
});
@@ -1,20 +1,20 @@
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
import type { TimeChartItem } from '../types';
import type { AnimationType } from './utils/animations';
import { ANIMATIONS } from './utils/animations';
import { getIncompleteDataLineSource } from './utils/formatters';
import type { AnimationType } from '../utils/animations';
import { ANIMATIONS } from '../utils/animations';
import { getIncompleteDataLineSource } from '../utils/formatters';
interface Props extends React.SVGProps<SVGPathElement> {
export interface ChartLineProps extends React.SVGProps<SVGPathElement> {
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
data: Array<TimeChartItem>;
animation: AnimationType;
}
const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
export const ChartLine = React.memo(({ xScale, yScale, data, animation, strokeDasharray, ...props }: ChartLineProps) => {
const dataPathRef = React.useRef<SVGPathElement>(null);
const incompleteDataPathRef = React.useRef<SVGPathElement>(null);
@@ -63,12 +63,11 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
d={ line(data.filter(({ isApproximate }) => !isApproximate)) || undefined }
strokeWidth={ 1 }
strokeLinecap="round"
strokeDasharray={ strokeDasharray }
fill="none"
opacity={ 0 }
{ ...props }
/>
</>
);
};
export default React.memo(ChartLine);
});
@@ -1,26 +1,40 @@
import { Icon } from '@chakra-ui/react';
import { useCopyToClipboard } from '@uidotdev/usehooks';
import dayjs from 'dayjs';
import domToImage from 'dom-to-image';
import React from 'react';
import type { TimeChartItem } from './types';
import type { Resolution } from '@blockscout/stats-types';
import type { Resolution, TimeChartData } from '../types';
import dayjs from 'lib/date/dayjs';
import saveAsCSV from 'lib/saveAsCSV';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import { IconButton } from 'toolkit/chakra/icon-button';
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from 'toolkit/chakra/menu';
import { useDisclosure } from 'toolkit/hooks/useDisclosure';
import { isBrowser } from 'toolkit/utils/isBrowser';
import IconSvg from 'ui/shared/IconSvg';
import CopyIcon from 'icons/copy.svg';
import DotsIcon from 'icons/dots.svg';
import CsvIcon from 'icons/files/csv.svg';
import ImageIcon from 'icons/files/image.svg';
import ScopeIcon from 'icons/scope.svg';
import ShareIcon from 'icons/share.svg';
import FullscreenChartModal from './FullscreenChartModal';
import { useColorModeValue } from '../../../chakra/color-mode';
import { IconButton } from '../../../chakra/icon-button';
import { MenuContent, MenuItem, MenuRoot, MenuTrigger } from '../../../chakra/menu';
import { useDisclosure } from '../../../hooks/useDisclosure';
import { saveAsCsv } from '../../../utils/file';
import { isBrowser } from '../../../utils/isBrowser';
import ChartFullscreenDialog from '../ChartFullscreenDialog';
export type Props = {
items?: Array<TimeChartItem>;
export const CHART_MENU_ITEMS_IDS = [
'share' as const,
'fullscreen' as const,
'save_png' as const,
'save_csv' as const,
];
export type ChartMenuItemId = (typeof CHART_MENU_ITEMS_IDS)[number];
export interface Props {
itemIds?: Array<ChartMenuItemId>;
charts: TimeChartData;
title: string;
description?: string;
units?: string;
isLoading: boolean;
chartRef: React.RefObject<HTMLDivElement | null>;
chartUrl?: string;
@@ -33,10 +47,10 @@ export type Props = {
const DOWNLOAD_IMAGE_SCALE = 5;
const ChartMenu = ({
items,
itemIds = CHART_MENU_ITEMS_IDS,
charts,
title,
description,
units,
isLoading,
chartRef,
chartUrl,
@@ -85,17 +99,16 @@ const ChartMenu = ({
}, [ pngBackgroundColor, title, chartRef ]);
const handleSVGSavingClick = React.useCallback(() => {
if (items) {
charts.forEach((chart) => {
const headerRows = [
'Date', 'Value',
];
const dataRows = items.map((item) => [
const dataRows = chart.items.map((item) => [
dayjs(item.date).format('YYYY-MM-DD'), String(item.value),
]);
saveAsCSV(headerRows, dataRows, `${ title } (Blockscout stats)`);
}
}, [ items, title ]);
saveAsCsv(headerRows, dataRows, `${ chart.name } (Blockscout stats)`);
});
}, [ charts ]);
// TS thinks window.navigator.share can't be undefined, but it can
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -120,57 +133,60 @@ const ChartMenu = ({
<MenuRoot>
<MenuTrigger asChild>
<IconButton variant="icon_secondary" size="md" aria-label="Open chart options menu" loadingSkeleton={ isLoading }>
<IconSvg name="dots"/>
<Icon><DotsIcon/></Icon>
</IconButton>
</MenuTrigger>
<MenuContent>
{ chartUrl && (
{ itemIds.includes('share') && chartUrl && (
<MenuItem
value={ hasShare ? 'share' : 'copy' }
onClick={ hasShare ? handleShare : handleCopy }
closeOnSelect={ hasShare ? false : true }
>
<IconSvg name={ hasShare ? 'share' : 'copy' } boxSize={ 5 }/>
<Icon boxSize={ 5 }>{ hasShare ? <ShareIcon/> : <CopyIcon/> }</Icon>
{ hasShare ? 'Share' : 'Copy link' }
</MenuItem>
) }
<MenuItem
value="fullscreen"
onClick={ showChartFullscreen }
>
<IconSvg name="scope" boxSize={ 5 }/>
View fullscreen
</MenuItem>
<MenuItem
value="save-png"
onClick={ handleFileSaveClick }
>
<IconSvg name="files/image" boxSize={ 5 }/>
Save as PNG
</MenuItem>
<MenuItem
value="save-csv"
onClick={ handleSVGSavingClick }
>
<IconSvg name="files/csv" boxSize={ 5 }/>
Save as CSV
</MenuItem>
{ itemIds.includes('fullscreen') && (
<MenuItem
value="fullscreen"
onClick={ showChartFullscreen }
>
<Icon boxSize={ 5 }><ScopeIcon/></Icon>
View fullscreen
</MenuItem>
) }
{ itemIds.includes('save_png') && (
<MenuItem
value="save-png"
onClick={ handleFileSaveClick }
>
<Icon boxSize={ 5 }><ImageIcon/></Icon>
Save as PNG
</MenuItem>
) }
{ itemIds.includes('save_csv') && (
<MenuItem
value="save-csv"
onClick={ handleSVGSavingClick }
>
<Icon boxSize={ 5 }><CsvIcon/></Icon>
Save as CSV
</MenuItem>
) }
</MenuContent>
</MenuRoot>
{ items && (
<FullscreenChartModal
open={ fullscreenDialog.open }
onOpenChange={ fullscreenDialog.onOpenChange }
items={ items }
title={ title }
description={ description }
units={ units }
resolution={ resolution }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
/>
) }
<ChartFullscreenDialog
open={ fullscreenDialog.open }
onOpenChange={ fullscreenDialog.onOpenChange }
charts={ charts }
title={ title }
description={ description }
resolution={ resolution }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
/>
</>
);
};
@@ -1,18 +1,16 @@
import React from 'react';
interface Props {
export interface ChartOverlayProps {
width: number;
height: number;
children: React.ReactNode;
}
const ChartOverlay = ({ width, height, children }: Props, ref: React.ForwardedRef<SVGRectElement>) => {
export const ChartOverlay = React.forwardRef(({ width, height, children }: ChartOverlayProps, ref: React.ForwardedRef<SVGRectElement>) => {
return (
<g className="ChartOverlay">
{ children }
<rect ref={ ref } width={ width } height={ height } opacity={ 0 }/>
</g>
);
};
export default React.forwardRef(ChartOverlay);
});
@@ -1,14 +1,16 @@
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import dayjs from 'dayjs';
import minMax from 'dayjs/plugin/minMax';
import React from 'react';
import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
dayjs.extend(minMax);
import dayjs from 'lib/date/dayjs';
import type { TimeChartData, TimeChartItem } from '../types';
const SELECTION_THRESHOLD = 2;
interface Props {
export interface ChartSelectionXProps {
height: number;
anchorEl?: SVGRectElement | null;
scale: d3.ScaleTime<number, number>;
@@ -16,7 +18,7 @@ interface Props {
onSelect: (range: [Date, Date]) => void;
}
const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) => {
export const ChartSelectionX = React.memo(({ anchorEl, height, scale, data, onSelect }: ChartSelectionXProps) => {
const [ borderColor ] = useToken('colors', 'blue.200');
const ref = React.useRef(null);
@@ -140,6 +142,4 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
<line className="ChartSelectionX__line ChartSelectionX__line_right" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
</g>
);
};
export default React.memo(ChartSelectionX);
});
@@ -1,8 +1,8 @@
import * as d3 from 'd3';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import type { TimeChartData } from 'ui/shared/chart/types';
import { Resolution } from '../types';
import type { TimeChartData } from '../types';
import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop';
import ChartTooltipContent, { useRenderContent } from './tooltip/ChartTooltipContent';
@@ -13,7 +13,7 @@ import ChartTooltipTitle, { useRenderTitle } from './tooltip/ChartTooltipTitle';
import { trackPointer } from './tooltip/pointerTracker';
import type { Pointer } from './tooltip/pointerTracker';
interface Props {
export interface ChartTooltipProps {
width?: number;
tooltipWidth?: number;
height?: number;
@@ -25,7 +25,7 @@ interface Props {
resolution?: Resolution;
}
const ChartTooltip = ({
export const ChartTooltip = React.memo(({
xScale,
yScale,
width,
@@ -36,7 +36,7 @@ const ChartTooltip = ({
noAnimation,
resolution,
...props
}: Props) => {
}: ChartTooltipProps) => {
const ref = React.useRef<SVGGElement>(null);
const trackerId = React.useRef<number>(undefined);
const isVisible = React.useRef(false);
@@ -171,9 +171,7 @@ const ChartTooltip = ({
</ChartTooltipContent>
</g>
);
};
export default React.memo(ChartTooltip);
});
function getDateLabel(resolution?: Resolution): string {
switch (resolution) {
@@ -2,14 +2,11 @@ import type { IconProps } from '@chakra-ui/react';
import { Icon } from '@chakra-ui/react';
import React from 'react';
// eslint-disable-next-line no-restricted-imports
import logoIcon from 'icons/networks/logo-placeholder.svg';
import LogoIcon from 'icons/networks/logo-placeholder.svg';
const ChartWatermarkIcon = (props: IconProps) => {
export const ChartWatermark = React.memo((props: IconProps) => {
return (
<Icon
{ ...props }
as={ logoIcon }
position="absolute"
opacity={ 0.1 }
top="50%"
@@ -18,8 +15,9 @@ const ChartWatermarkIcon = (props: IconProps) => {
pointerEvents="none"
viewBox="0 0 114 20"
color={{ _light: 'link.primary', _dark: 'white' }}
/>
{ ...props }
>
<LogoIcon/>
</Icon>
);
};
export default ChartWatermarkIcon;
});
@@ -26,7 +26,7 @@ export function useRenderContent(ref: React.RefObject<SVGGElement | null>, { cha
return React.useCallback((x: number, y: number) => {
const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content');
const transformAttributeFn: d3.ValueFn<d3.BaseType, unknown, string> = (cur, i, nodes) => {
const transformAttributeFn: d3.ValueFn<d3.BaseType, unknown, string> = (_, i, nodes) => {
const node = nodes[i] as SVGGElement | null;
const { width: nodeWidth, height: nodeHeight } = node?.getBoundingClientRect() || { width: 0, height: 0 };
const [ translateX, translateY ] = calculatePosition({
@@ -2,10 +2,9 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import type { TimeChartData, TimeChartItem } from '../../types';
import { useColorModeValue } from '../../../../chakra/color-mode';
import { POINT_SIZE } from './utils';
const ChartTooltipPoint = () => {
@@ -53,7 +52,7 @@ export function useRenderPoints(ref: React.RefObject<SVGGElement | null>, params
d3.select(ref.current)
.selectAll('.ChartTooltip__point')
.attr('transform', (cur, elementIndex) => {
.attr('transform', (_, elementIndex) => {
const datum = params.data[elementIndex];
const index = bisectDate(datum.items, xDate, 1);
const d0 = datum.items[index - 1] as TimeChartItem | undefined;
@@ -2,7 +2,7 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartData } from '../types';
import type { TimeChartData } from '../../types';
import type { CurrentPoint } from './ChartTooltipPoint';
import { calculateRowTransformValue, LABEL_WIDTH, PADDING } from './utils';
@@ -58,7 +58,7 @@ export function useRenderRows(ref: React.RefObject<SVGGElement | null>, { data,
const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate);
d3.select(ref.current)
.selectAll<Element, TimeChartData>('.ChartTooltip__row')
.attr('transform', (datum, index) => {
.attr('transform', (_, index) => {
return calculateRowTransformValue(index - (isIncompleteData ? 0 : 1));
});
@@ -2,15 +2,13 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { Resolution } from '@blockscout/stats-types';
import { STATS_RESOLUTIONS } from 'ui/stats/constants';
import { Resolution, RESOLUTION_LABELS } from '../../types';
import ChartTooltipRow from './ChartTooltipRow';
const ChartTooltipTitle = ({ resolution = Resolution.DAY }: { resolution?: Resolution }) => {
const titleColor = useToken('colors', 'yellow.300');
const resolutionTitle = STATS_RESOLUTIONS.find(r => r.id === resolution)?.title || 'day';
const resolutionTitle = RESOLUTION_LABELS.find(({ id }) => id === resolution)?.title || 'day';
return (
<ChartTooltipRow lineNum={ 0 }>
+95
View File
@@ -0,0 +1,95 @@
import type * as d3 from 'd3';
export { Resolution } from '@blockscout/stats-types';
import { Resolution as ResolutionEnum } from '@blockscout/stats-types';
export const RESOLUTION_LABELS: Array<{ id: ResolutionEnum; title: string }> = [
{
id: ResolutionEnum.DAY,
title: 'Day',
},
{
id: ResolutionEnum.WEEK,
title: 'Week',
},
{
id: ResolutionEnum.MONTH,
title: 'Month',
},
{
id: ResolutionEnum.YEAR,
title: 'Year',
},
];
export interface TimeChartItemRaw {
date: Date;
dateLabel?: string;
value: number | string | null;
}
export interface TimeChartItem {
date: Date;
date_to?: Date;
dateLabel?: string;
value: number;
isApproximate?: boolean;
}
export interface ChartMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface ChartOffset {
x?: number;
y?: number;
}
export type ChartConfig =
| {
type: 'line';
color: string;
strokeWidth?: number;
strokeDasharray?: string;
} |
{
type: 'area';
gradient: {
startColor: string;
stopColor: string;
};
};
export interface TimeChartDataItem {
id: string;
name: string;
items: Array<TimeChartItem>;
charts: Array<ChartConfig>;
units?: string;
valueFormatter?: (value: number) => string;
}
export type TimeChartData = Array<TimeChartDataItem>;
export interface AxisConfig {
ticks?: number;
nice?: boolean;
noLabel?: boolean;
scale?: {
min?: number;
};
tickFormatter?: () => (d: d3.AxisDomain) => string;
}
export interface AxesConfig {
x?: AxisConfig;
y?: AxisConfig;
}
export type AxesConfigFn = (props: {
isEnlarged?: boolean;
isMobile?: boolean;
}) => AxesConfig;
@@ -1,6 +1,6 @@
import type { ChartMargin, ChartOffset } from 'ui/shared/chart/types';
import type { ChartMargin, ChartOffset } from '../types';
export default function calculateInnerSize(rect: DOMRect | null, margin?: ChartMargin, offsets?: ChartOffset) {
export function calculateInnerSize(rect: DOMRect | null, margin?: ChartMargin, offsets?: ChartOffset) {
if (!rect) {
return { innerWidth: 0, innerHeight: 0 };
}
@@ -0,0 +1,16 @@
import * as d3 from 'd3';
import { Resolution } from '../types';
export function getDateLabel(date: Date, dateTo?: Date, resolution?: Resolution): string {
switch (resolution) {
case Resolution.WEEK:
return d3.timeFormat('%e %b %Y')(date) + (dateTo ? ` – ${ d3.timeFormat('%e %b %Y')(dateTo) }` : '');
case Resolution.MONTH:
return d3.timeFormat('%b %Y')(date);
case Resolution.YEAR:
return d3.timeFormat('%Y')(date);
default:
return d3.timeFormat('%e %b %Y')(date);
}
}
@@ -3,7 +3,7 @@ import { maxBy, uniq } from 'es-toolkit';
import type { AxesConfig, AxisConfig, TimeChartData } from '../types';
import { DAY, MONTH, YEAR } from 'toolkit/utils/consts';
import { DAY, MONTH, YEAR } from '../../../utils/consts';
export const DEFAULT_MAXIMUM_SIGNIFICANT_DIGITS = 2;
export const DEFAULT_MAXIMUM_FRACTION_DIGITS = 3;
@@ -2,7 +2,7 @@ import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import { useColorModeValue } from '../../../chakra/color-mode';
interface Props {
limits: [[number, number], [number, number]];
@@ -10,7 +10,7 @@ interface Props {
setRange: (range: [number, number]) => void;
}
export default function useBrushX({ limits, anchor, setRange }: Props) {
export function useChartBrushX({ limits, anchor, setRange }: Props) {
const brushRef = React.useRef<d3.BrushBehavior<unknown>>(undefined);
const [ brushSelectionBg ] = useToken('colors', useColorModeValue('blackAlpha.400', 'whiteAlpha.500'));
@@ -1,7 +1,7 @@
import { range } from 'es-toolkit';
import React from 'react';
export default function useChartLegend(dataLength: number) {
export function useChartLegend(dataLength: number) {
const [ selectedLines, setSelectedLines ] = React.useState<Array<number>>(range(dataLength));
const handleLegendItemClick = React.useCallback((index: number) => {
@@ -1,6 +1,6 @@
import React from 'react';
export default function useZoom() {
export function useChartZoom() {
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
const [ zoomRange, setZoomRange ] = React.useState<[ Date, Date ] | undefined>();
@@ -1,11 +1,10 @@
import React from 'react';
import type { AxesConfig, ChartMargin, TimeChartData } from 'ui/shared/chart/types';
import type { AxesConfig, ChartMargin, TimeChartData } from '../types';
import useClientRect from 'lib/hooks/useClientRect';
import calculateInnerSize from './utils/calculateInnerSize';
import { getAxesParams } from './utils/timeChartAxis';
import { useClientRect } from '../../../hooks/useClientRect';
import { calculateInnerSize } from './calculateInnerSize';
import { getAxesParams } from './timeChartAxis';
interface Props {
data: TimeChartData;
@@ -13,7 +12,7 @@ interface Props {
axesConfig?: AxesConfig;
}
export default function useTimeChartController({ data, margin, axesConfig }: Props) {
export function useTimeChartController({ data, margin, axesConfig }: Props) {
const [ rect, ref ] = useClientRect<SVGSVGElement>();
@@ -2,7 +2,7 @@ import { debounce } from 'es-toolkit';
import type { LegacyRef } from 'react';
import React from 'react';
export default function useClientRect<E extends Element>(): [ DOMRect | null, LegacyRef<E> | undefined ] {
export function useClientRect<E extends Element>(): [ DOMRect | null, LegacyRef<E> | undefined ] {
const [ rect, setRect ] = React.useState<DOMRect | null>(null);
const nodeRef = React.useRef<E>(null);
+5
View File
@@ -34,6 +34,11 @@ Ensure you have the following peer dependencies installed:
"@blockscout/ui-toolkit": "latest",
"@chakra-ui/react": ">=3.15.0",
"@emotion/react": ">=11.14.0",
"@uidotdev/usehooks": ">=2.4.1",
"d3": ">=7.6.1",
"dayjs": ">=1.11.5",
"dom-to-image": ">=2.6.0",
"es-toolkit": ">=1.39.10",
"next": ">=15.2.3",
"next-themes": ">=0.4.4",
"react": ">=18.3.1",
+1 -1
View File
@@ -56,7 +56,7 @@
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.4.2",
"typescript": "5.9.2",
"vite": "6.2.7",
"vite-plugin-dts": "4.5.3",
"vite-plugin-svgr": "4.3.0",
+3
View File
@@ -95,6 +95,7 @@ export * as AdaptiveTabs from '../../components/AdaptiveTabs/index';
export * from '../../components/RoutedTabs/index';
export * from '../../components/buttons/BackToButton';
export * from '../../components/buttons/ClearButton';
export * from '../../components/charts';
export * from '../../components/Hint/Hint';
export * from '../../components/filters/FilterInput';
export * from '../../components/forms/components';
@@ -111,6 +112,7 @@ export * as html from '../../utils/htmlEntities';
export * as consts from '../../utils/consts';
export * as regexp from '../../utils/regexp';
export * as guards from '../../utils/guards';
export * as file from '../../utils/file';
export * from '../../utils/url';
export * from '../../utils/isBrowser';
@@ -121,3 +123,4 @@ export { useUpdateEffect } from '../../hooks/useUpdateEffect';
export { useFirstMountState } from '../../hooks/useFirstMountState';
export { useIsSticky } from '../../hooks/useIsSticky';
export { useViewportSize } from '../../hooks/useViewportSize';
export { useClientRect } from '../../hooks/useClientRect';
+1 -1
View File
@@ -44,6 +44,6 @@
"../../decs.d.ts",
"../../reset.d.ts",
],
"exclude": ["node_modules", "dist", "../../node_modules"],
"exclude": ["node_modules", "dist", "../../node_modules", "vite.config.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -44,6 +44,12 @@ export default defineConfig({
'../../decs.d.ts',
'../../reset.d.ts',
],
exclude: [
'../**/*.pw.tsx',
'../**/*.pw.ts',
'../**/*.test.*',
'../**/*.spec.*',
],
rollupTypes: false,
outDir: './dist',
insertTypesEntry: true,
@@ -80,6 +86,11 @@ export default defineConfig({
'next/router',
'next-themes',
'react-hook-form',
'es-toolkit',
'dayjs',
'd3',
'dom-to-image',
'@uidotdev/usehooks',
],
output: {
preserveModules: false,
+5 -5
View File
@@ -1560,16 +1560,16 @@ tslib@^2.0.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
[email protected]:
version "5.4.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.2.tgz#0ae9cebcfae970718474fe0da2c090cad6577372"
integrity sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==
[email protected]:
version "5.8.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4"
integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==
[email protected]:
version "5.9.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==
ufo@^1.5.4:
version "1.6.1"
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
+23
View File
@@ -0,0 +1,23 @@
import { unparse } from 'papaparse';
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export function saveAsCsv(headerRows: Array<string>, dataRows: Array<Array<string>>, filename: string) {
const csv = unparse([
headerRows,
...dataRows,
]);
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
downloadBlob(blob, filename);
}
+2 -2
View File
@@ -17,7 +17,7 @@ test('base view +@dark-mode', async({ render, page, mockApiResponse }) => {
await mockApiResponse('general:address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } });
const component = await render(<AddressCoinBalance/>, { hooksConfig });
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="balance-small"]')?.getAttribute('opacity') === '1';
});
await page.mouse.move(100, 100);
await page.mouse.move(240, 100);
@@ -32,7 +32,7 @@ test.describe('mobile', () => {
await mockApiResponse('general:address_coin_balance_chart', balanceHistoryMock.chartResponse, { pathParams: { hash: addressHash } });
const component = await render(<AddressCoinBalance/>, { hooksConfig });
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="balance-small"]')?.getAttribute('opacity') === '1';
});
await page.mouse.move(100, 100);
await page.mouse.move(240, 100);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

@@ -4,7 +4,8 @@ import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { currencyUnits } from 'lib/units';
import ChartWidget from 'ui/shared/chart/ChartWidget';
import { ChartWidget } from 'toolkit/components/charts/ChartWidget';
import { useChartsConfig } from 'ui/shared/chart/config';
interface Props {
addressHash: string;
@@ -14,26 +15,34 @@ const AddressCoinBalanceChart = ({ addressHash }: Props) => {
const { data, isPending, isError } = useApiQuery('general:address_coin_balance_chart', {
pathParams: { hash: addressHash },
});
const chartsConfig = useChartsConfig();
const items = React.useMemo(() => {
const charts = React.useMemo(() => {
if (!data) {
return undefined;
return [];
}
return data.items.map(({ date, value }) => ({
date: new Date(date),
value: BigNumber(value).div(10 ** config.chain.currency.decimals).toNumber(),
}));
}, [ data ]);
return [
{
id: 'balance',
name: 'Value',
items: data.items.map(({ date, value }) => ({
date: new Date(date),
value: BigNumber(value).div(10 ** config.chain.currency.decimals).toNumber(),
})),
charts: chartsConfig,
units: currencyUnits.ether,
},
];
}, [ chartsConfig, data ]);
return (
<ChartWidget
isError={ isError }
title="Balances"
items={ items }
charts={ charts }
isLoading={ isPending }
h="300px"
units={ currencyUnits.ether }
emptyText={ data?.days ? `Insufficient data for the past ${ data.days } days` : undefined }
/>
);
+1 -1
View File
@@ -6,10 +6,10 @@ import config from 'configs/app';
import buildUrl from 'lib/api/buildUrl';
import { useMultichainContext } from 'lib/contexts/multichain';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import { Button } from 'toolkit/chakra/button';
import { toaster } from 'toolkit/chakra/toaster';
import { Tooltip } from 'toolkit/chakra/tooltip';
import { downloadBlob } from 'toolkit/utils/file';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
+1 -1
View File
@@ -4,7 +4,6 @@ import React from 'react';
import * as blobUtils from 'lib/blob';
import removeNonSignificantZeroBytes from 'lib/blob/removeNonSignificantZeroBytes';
import bytesToBase64 from 'lib/bytesToBase64';
import downloadBlob from 'lib/downloadBlob';
import hexToBase64 from 'lib/hexToBase64';
import hexToBytes from 'lib/hexToBytes';
import hexToUtf8 from 'lib/hexToUtf8';
@@ -12,6 +11,7 @@ import { Button } from 'toolkit/chakra/button';
import type { SelectOption } from 'toolkit/chakra/select';
import { Select } from 'toolkit/chakra/select';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { downloadBlob } from 'toolkit/utils/file';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
+1 -1
View File
@@ -11,10 +11,10 @@ import buildUrl from 'lib/api/buildUrl';
import type { ResourceName } from 'lib/api/resources';
import { useMultichainContext } from 'lib/contexts/multichain';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import { Alert } from 'toolkit/chakra/alert';
import { Button } from 'toolkit/chakra/button';
import { toaster } from 'toolkit/chakra/toaster';
import { downloadBlob } from 'toolkit/utils/file';
import ReCaptcha from 'ui/shared/reCaptcha/ReCaptcha';
import useReCaptcha from 'ui/shared/reCaptcha/useReCaptcha';
-1
View File
@@ -47,7 +47,6 @@ const GasTrackerChart = () => {
title={ chart.title }
description={ chart.description }
interval="oneMonth"
units={ chart.units || undefined }
isPlaceholderData={ isPlaceholderData }
onLoadingError={ handleLoadingError }
h="320px"
@@ -1,7 +1,7 @@
import { chakra, Box } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
import type { TimeChartData } from 'toolkit/components/charts/types';
import { ContentLoader } from 'toolkit/components/loaders/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
@@ -1,14 +1,9 @@
import { useToken } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
import type { TimeChartData } from 'toolkit/components/charts/types';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartLine from 'ui/shared/chart/ChartLine';
import ChartOverlay from 'ui/shared/chart/ChartOverlay';
import ChartTooltip from 'ui/shared/chart/ChartTooltip';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
import { ChartArea, ChartLine, ChartOverlay, ChartTooltip, useTimeChartController } from 'toolkit/components/charts';
import { useDefaultGradient, useDefaultLineColor } from 'ui/shared/chart/config';
interface Props {
data: TimeChartData;
@@ -19,7 +14,8 @@ const CHART_MARGIN = { bottom: 5, left: 10, right: 10, top: 5 };
const ChainIndicatorChartContent = ({ data }: Props) => {
const overlayRef = React.useRef<SVGRectElement>(null);
const lineColor = useToken('colors', useColorModeValue('theme.graph.line._light', 'theme.graph.line._dark'));
const lineColor = useDefaultLineColor();
const gradient = useDefaultGradient();
const axesConfig = React.useMemo(() => {
return {
@@ -38,17 +34,19 @@ const ChainIndicatorChartContent = ({ data }: Props) => {
<svg width="100%" height="100%" ref={ ref } cursor="pointer">
<g transform={ `translate(${ chartMargin.left || 0 },${ chartMargin.top || 0 })` } opacity={ rect ? 1 : 0 }>
<ChartArea
id={ data[0].name }
data={ data[0].items }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
gradient={ gradient }
/>
<ChartLine
data={ data[0].items }
xScale={ axes.x.scale }
yScale={ axes.y.scale }
stroke={ lineColor[0] }
animation="left"
stroke={ lineColor }
strokeWidth={ 3 }
animation="left"
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartTooltip
+2 -2
View File
@@ -25,7 +25,7 @@ test.describe('daily txs chart', () => {
await mockAssetResponse(statsMock.withSecondaryCoin.secondary_coin_image as string, './playwright/mocks/image_s.jpg');
component = await render(<ChainIndicators/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="gradient-chart-area"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="Tx/day"]')?.getAttribute('opacity') === '1';
});
await page.hover('.ChartOverlay', { position: { x: 50, y: 50 } });
});
@@ -50,7 +50,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render })
const component = await render(<ChainIndicators/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="gradient-chart-area"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="Tx/day"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
+3 -1
View File
@@ -1,5 +1,5 @@
import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw } from 'toolkit/components/charts/types';
import type { ChainIndicatorId } from 'types/homepage';
import type { TimeChartData, TimeChartDataItem, TimeChartItemRaw } from 'ui/shared/chart/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
@@ -47,6 +47,8 @@ type UseFetchChartDataResult = {
function getChartData(indicatorId: ChainIndicatorId, data: Array<TimeChartItemRaw>): TimeChartData {
return [ {
id: indicatorId,
charts: [],
items: prepareChartItems(data),
name: CHART_ITEMS[indicatorId].name,
valueFormatter: CHART_ITEMS[indicatorId].valueFormatter,
@@ -1,6 +1,6 @@
import type { TimeChartItem, TimeChartItemRaw } from 'ui/shared/chart/types';
import type { TimeChartItem, TimeChartItemRaw } from 'toolkit/components/charts/types';
import { sortByDateDesc } from 'ui/shared/chart/utils/sorts';
import { sortByDateDesc } from 'ui/shared/chart/utils';
const nonNullTailReducer = (result: Array<TimeChartItemRaw>, item: TimeChartItemRaw) => {
if (item.value === null && result.length === 0) {
+3 -3
View File
@@ -76,13 +76,13 @@ test('default view', async({ render, createSocket, mockEnvs, page }) => {
socket.send(JSON.stringify(REALTIME_DATA));
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-TPS-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="tps-small"]')?.getAttribute('opacity') === '1';
});
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-MGass-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="gas-small"]')?.getAttribute('opacity') === '1';
});
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Block_time_ms-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="blockInterval-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
+43 -14
View File
@@ -2,12 +2,13 @@ import { Box, Flex, Grid, GridItem } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { AxesConfigFn } from 'toolkit/components/charts/types';
import type { UptimeHistoryFull, UptimeHistoryItem } from 'types/api/megaEth';
import type { AxesConfigFn } from 'ui/shared/chart/types';
import { Heading } from 'toolkit/chakra/heading';
import { ChartWidget } from 'toolkit/components/charts/ChartWidget';
import { DAY, HOUR, SECOND } from 'toolkit/utils/consts';
import ChartWidget from 'ui/shared/chart/ChartWidget';
import { useChartsConfig } from 'ui/shared/chart/config';
import TagGroupSelect from 'ui/shared/tagGroupSelect/TagGroupSelect';
const INTERVALS = [
@@ -74,6 +75,7 @@ interface Props {
const UptimeCharts = ({ historyData }: Props) => {
const [ interval, setInterval ] = React.useState<IntervalId>('3h');
const chartsConfig = useChartsConfig();
const axesConfig = React.useMemo(() => {
switch (interval) {
@@ -85,7 +87,7 @@ const UptimeCharts = ({ historyData }: Props) => {
}
}, [ interval ]);
const tpsItems = React.useMemo(() => {
const tpsCharts = React.useMemo(() => {
if (!historyData) {
return [];
}
@@ -103,15 +105,24 @@ const UptimeCharts = ({ historyData }: Props) => {
const now = Date.now();
const smoothedData = smoothData(data, 7);
return smoothedData
const items = smoothedData
.map(({ value, timestamp }) => {
const date = new Date(timestamp * SECOND);
return { date, value: Number(value.toFixed(0)), dateLabel: d3.timeFormat(TIME_FORMAT)(date) };
})
.filter(filterByInterval(interval, now));
}, [ historyData, interval ]);
const gasItems = React.useMemo(() => {
return [
{
id: 'tps',
name: 'Value',
items,
charts: chartsConfig,
},
];
}, [ chartsConfig, historyData, interval ]);
const gasCharts = React.useMemo(() => {
if (!historyData) {
return [];
}
@@ -131,15 +142,24 @@ const UptimeCharts = ({ historyData }: Props) => {
const smoothedData = smoothData(data, 7);
return smoothedData
const items = smoothedData
.map(({ value, timestamp }) => {
const date = new Date(timestamp * SECOND);
return { date, value: Number((value / 1_000_000).toFixed(2)), dateLabel: d3.timeFormat(TIME_FORMAT)(date) };
})
.filter(filterByInterval(interval, now));
}, [ historyData, interval ]);
const blockIntervalItems = React.useMemo(() => {
return [
{
id: 'gas',
name: 'Value',
items,
charts: chartsConfig,
},
];
}, [ chartsConfig, historyData, interval ]);
const blockIntervalCharts = React.useMemo(() => {
if (!historyData) {
return [];
}
@@ -159,13 +179,22 @@ const UptimeCharts = ({ historyData }: Props) => {
const smoothedData = smoothData(data, 7);
return smoothedData
const items = smoothedData
.map(({ value, timestamp }) => {
const date = new Date(timestamp * SECOND);
return { date, value: Number(value.toFixed(1)), dateLabel: d3.timeFormat(TIME_FORMAT)(date) };
})
.filter(filterByInterval(interval, now));
}, [ historyData, interval ]);
return [
{
id: 'blockInterval',
name: 'Value',
items,
charts: chartsConfig,
},
];
}, [ chartsConfig, historyData, interval ]);
const handleIntervalChange = React.useCallback((newInterval: IntervalId) => {
setInterval(newInterval);
@@ -188,7 +217,7 @@ const UptimeCharts = ({ historyData }: Props) => {
<GridItem colSpan={{ base: 1, lg: 2 }} minH={{ base: '220px', lg: '320px' }}>
<ChartWidget
title="TPS"
items={ tpsItems }
charts={ tpsCharts }
isLoading={ false }
isError={ false }
axesConfig={ axesConfig }
@@ -197,7 +226,7 @@ const UptimeCharts = ({ historyData }: Props) => {
<GridItem minH={{ base: '220px', lg: '320px' }}>
<ChartWidget
title="MGas/s"
items={ gasItems }
charts={ gasCharts }
isLoading={ false }
isError={ false }
axesConfig={ axesConfig }
@@ -206,7 +235,7 @@ const UptimeCharts = ({ historyData }: Props) => {
<GridItem minH={{ base: '220px', lg: '320px' }}>
<ChartWidget
title="Block time (ms)"
items={ blockIntervalItems }
charts={ blockIntervalCharts }
isLoading={ false }
isError={ false }
axesConfig={ axesConfig }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+1 -1
View File
@@ -7,7 +7,6 @@ import { route } from 'nextjs/routes';
import useApiQuery from 'lib/api/useApiQuery';
import { useMultichainContext } from 'lib/contexts/multichain';
import dayjs from 'lib/date/dayjs';
import downloadBlob from 'lib/downloadBlob';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { Button } from 'toolkit/chakra/button';
@@ -15,6 +14,7 @@ import { Heading } from 'toolkit/chakra/heading';
import { Image } from 'toolkit/chakra/image';
import { Link } from 'toolkit/chakra/link';
import { ContentLoader } from 'toolkit/components/loaders/ContentLoader';
import { downloadBlob } from 'toolkit/utils/file';
import BlockCountdownTimer from 'ui/blockCountdown/BlockCountdownTimer';
import createGoogleCalendarLink from 'ui/blockCountdown/createGoogleCalendarLink';
import createIcsFileBlob from 'ui/blockCountdown/createIcsFileBlob';
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react';
import * as statsLineMock from 'mocks/stats/line';
import { test, expect } from 'playwright/lib';
import formatDate from 'ui/shared/chart/utils/formatIntervalDate';
import { formatDate } from 'ui/shared/chart/utils';
import Chart from './Chart';
@@ -38,7 +38,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, page })
const component = await render(<Chart/>, { hooksConfig });
await page.waitForResponse(chartApiUrl);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Chart_title-fullscreen"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-fullscreen"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
+26 -7
View File
@@ -17,13 +17,13 @@ import { Button } from 'toolkit/chakra/button';
import type { SelectOption } from 'toolkit/chakra/select';
import { Select } from 'toolkit/chakra/select';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { ChartWidgetContent, useChartZoom } from 'toolkit/components/charts';
import ChartMenu from 'toolkit/components/charts/parts/ChartMenu';
import { isBrowser } from 'toolkit/utils/isBrowser';
import isCustomAppError from 'ui/shared/AppError/isCustomAppError';
import ChartIntervalSelect from 'ui/shared/chart/ChartIntervalSelect';
import ChartMenu from 'ui/shared/chart/ChartMenu';
import ChartWidgetContent from 'ui/shared/chart/ChartWidgetContent';
import { useChartsConfig } from 'ui/shared/chart/config';
import useChartQuery from 'ui/shared/chart/useChartQuery';
import useZoom from 'ui/shared/chart/useZoom';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import IconSvg from 'ui/shared/IconSvg';
import PageTitle from 'ui/shared/Page/PageTitle';
@@ -74,7 +74,7 @@ const Chart = () => {
const defaultResolution = resolutionFromQuery || DEFAULT_RESOLUTION;
const [ intervalState, setIntervalState ] = React.useState<StatsIntervalIds | undefined>(intervalFromQuery);
const [ resolution, setResolution ] = React.useState<Resolution>(defaultResolution);
const { zoomRange, handleZoom, handleZoomReset } = useZoom();
const { zoomRange, handleZoom, handleZoomReset } = useChartZoom();
const interval = intervalState || getIntervalByResolution(resolution);
@@ -82,6 +82,7 @@ const Chart = () => {
const isMobile = useIsMobile();
const isInBrowser = isBrowser();
const chartsConfig = useChartsConfig();
const onIntervalChange = React.useCallback((interval: StatsIntervalIds) => {
setIntervalState(interval);
@@ -113,6 +114,24 @@ const Chart = () => {
const { items, info, lineQuery } = useChartQuery(id, resolution, interval);
const charts = React.useMemo(() => {
if (!info || !items) {
return [];
}
return [
{
id: info.id,
name: 'Value',
items,
charts: chartsConfig,
units: info.units,
},
];
}, [ chartsConfig, info, items ]);
const hasNonEmptyCharts = charts.some((chart) => chart.items.length > 2);
React.useEffect(() => {
if (info && !config.meta.seo.enhancedDataEnabled) {
metadata.update({ pathname: '/stats/[id]', query: { id } }, info);
@@ -226,7 +245,7 @@ const Chart = () => {
)) }
{ (hasItems || lineQuery.isPlaceholderData) && (
<ChartMenu
items={ items }
charts={ charts }
title={ info?.title || '' }
description={ info?.description || '' }
isLoading={ lineQuery.isPlaceholderData }
@@ -249,13 +268,13 @@ const Chart = () => {
>
<ChartWidgetContent
isError={ lineQuery.isError }
items={ items }
charts={ charts }
title={ info?.title || '' }
units={ info?.units || undefined }
isEnlarged
isLoading={ lineQuery.isPlaceholderData }
zoomRange={ zoomRange }
handleZoom={ handleZoom }
empty={ !hasNonEmptyCharts }
emptyText="No data for the selected resolution & interval."
resolution={ resolution }
/>
+1 -1
View File
@@ -25,7 +25,7 @@ test('base view +@dark-mode +@mobile', async({ render, mockApiResponse, mockEnvs
const component = await render(<GasTracker/>);
await page.waitForResponse(chartApiUrl);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Average_gas_price-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="chart-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

-48
View File
@@ -1,48 +0,0 @@
import { Box, Circle, Text } from '@chakra-ui/react';
import React from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
interface Props {
data: TimeChartData;
selectedIndexes: Array<number>;
onClick: (index: number) => void;
}
const ChartLegend = ({ data, selectedIndexes, onClick }: Props) => {
const handleItemClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const itemIndex = (event.currentTarget as HTMLDivElement).getAttribute('data-index');
onClick(Number(itemIndex));
}, [ onClick ]);
return (
<Box display="flex" columnGap={ 3 } mt={ 2 }>
{ data.map(({ name, color }, index) => {
const isSelected = selectedIndexes.includes(index);
return (
<Box
key={ name }
data-index={ index }
display="flex"
columnGap={ 1 }
alignItems="center"
onClick={ handleItemClick }
cursor="pointer"
>
<Circle
size={ 2 }
bgColor={ isSelected ? color : 'transparent' }
borderWidth={ 2 }
borderColor={ color }
/>
<Text fontSize="xs">
{ name }
</Text>
</Box>
);
}) }
</Box>
);
};
export default React.memo(ChartLegend);
-73
View File
@@ -1,73 +0,0 @@
import { useToken, chakra } from '@chakra-ui/react';
import React from 'react';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
// eslint-disable-next-line max-len
const d = 'M2 87.8491C2 87.8491 33.0576 108.005 66.5621 87.8491C100.067 67.693 104.693 112.847 115.444 112.847C126.196 112.847 127.564 -14.2956 150.132 4.10659C172.701 22.5087 204.973 118.132 231.009 87.8491C257.044 57.5664 282.524 27.2837 300.355 57.5664C318.185 87.8491 419.225 111.026 439.651 57.5664C460.077 4.10659 479.504 244.505 516.708 244.505C553.911 244.505 560.47 122.168 589.929 144.014C619.388 165.861 604.48 198.172 633.774 198.172C663.068 198.172 704.562 89 704.562 89';
const INCREMENT = 3;
const ChartLineLoader = ({ className }: { className?: string }) => {
const ref = React.useRef<SVGPathElement>(null);
const raf = React.useRef<number>(undefined);
const offset = React.useRef(0);
const [ lineBgColor ] = useToken('colors', useColorModeValue('gray.200', 'gray.500'));
const [ lineFgColor ] = useToken('colors', useColorModeValue('gray.400', 'gray.300'));
const [ gradientStopColor ] = useToken('colors', useColorModeValue('whiteAlpha.200', 'blackAlpha.100'));
React.useEffect(() => {
const length = ref.current?.getTotalLength() || 0;
ref.current?.setAttribute('stroke-dasharray', `${ length },${ length }`);
const animatePath = () => {
ref.current?.setAttribute('stroke-dashoffset', `${ length - offset.current }`);
const nextOffset = offset.current + INCREMENT <= length ? offset.current + INCREMENT : 0;
offset.current = nextOffset;
raf.current = window.requestAnimationFrame(animatePath);
};
raf.current = window.requestAnimationFrame(animatePath);
return () => {
raf.current && window.cancelAnimationFrame(raf.current);
};
}, []);
return (
<svg className={ className } width="100%" viewBox="0 0 707 272" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="chart_line_loader" x1="0" y1="0" x2="0" y2="272" gradientUnits="userSpaceOnUse">
<stop offset="0.02" stopColor="#D9D9D9"/>
<stop offset="0.78" stopColor={ gradientStopColor }/>
</linearGradient>
</defs>
<path
// eslint-disable-next-line max-len
d="M2 87.8491C2 87.8491 33.0576 108.005 66.5621 87.8491C100.067 67.693 104.693 112.847 115.444 112.847C126.196 112.847 127.564 -14.2956 150.132 4.10659C172.701 22.5087 204.973 118.132 231.009 87.8491C257.044 57.5664 282.524 27.2837 300.355 57.5664C318.185 87.8491 419.225 111.026 439.651 57.5664C460.077 4.10659 479.504 244.505 516.708 244.505C553.911 244.505 560.47 122.168 589.929 144.014C619.388 165.861 604.48 198.172 633.774 198.172C663.068 198.172 704.562 89 704.562 83.4575L702.467 231.992V268H0V85.5Z"
fill="url(#chart_line_loader)"
transform="translate(0,2)"
/>
<path
d={ d }
stroke={ lineBgColor }
strokeWidth="4"
strokeLinecap="round"
fill="none"
/>
<path
ref={ ref }
d={ d }
stroke={ lineFgColor }
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="10000,100000"
strokeDashoffset="-10000"
fill="none"
/>
</svg>
);
};
export default chakra(ChartLineLoader);
+121 -50
View File
@@ -1,41 +1,46 @@
import React from 'react';
import type { TimeChartItem } from './types';
import { test, expect } from 'playwright/lib';
import type { ChartWidgetProps } from 'toolkit/components/charts/ChartWidget';
import type { Props } from './ChartWidget';
import ChartWidget from './ChartWidget';
import ChartWidget from './ChartWidget.pwstory';
test.use({ viewport: { width: 400, height: 300 } });
const props: Props = {
items: [
{ date: new Date('2023-02-13'), value: 1.7631568828337087 },
{ date: new Date('2023-02-14'), value: 9547912.248607066 },
{ date: new Date('2023-02-15'), value: 19795391.669569757 },
{ date: new Date('2023-02-16'), value: 18338481.6037719 },
{ date: new Date('2023-02-17'), value: 18716729.946751505 },
{ date: new Date('2023-02-18'), value: 32164355.603443976 },
{ date: new Date('2023-02-19'), value: 20856850.45498412 },
{ date: new Date('2023-02-20'), value: 18846303.416569296 },
{ date: new Date('2023-02-21'), value: 26366091.117737416 },
{ date: new Date('2023-02-22'), value: 30446292.68212635 },
{ date: new Date('2023-02-23'), value: 25136740.887217894 },
const props: ChartWidgetProps = {
charts: [
{
id: 'native-coin-circulating-supply',
name: 'Value',
items: [
{ date: new Date('2023-02-13'), value: 1.7631568828337087 },
{ date: new Date('2023-02-14'), value: 9547912.248607066 },
{ date: new Date('2023-02-15'), value: 19795391.669569757 },
{ date: new Date('2023-02-16'), value: 18338481.6037719 },
{ date: new Date('2023-02-17'), value: 18716729.946751505 },
{ date: new Date('2023-02-18'), value: 32164355.603443976 },
{ date: new Date('2023-02-19'), value: 20856850.45498412 },
{ date: new Date('2023-02-20'), value: 18846303.416569296 },
{ date: new Date('2023-02-21'), value: 26366091.117737416 },
{ date: new Date('2023-02-22'), value: 30446292.68212635 },
{ date: new Date('2023-02-23'), value: 25136740.887217894 },
],
charts: [],
units: 'ETH',
},
],
title: 'Native coin circulating supply',
description: 'Amount of token circulating supply for the period',
units: 'ETH',
isLoading: false,
isError: false,
noAnimation: true,
};
test('base view +@dark-mode', async({ render, page }) => {
const component = await render(<ChartWidget { ...props } href={{ pathname: '/stats/[id]', query: { id: 'test' } }}/>);
const component = await render(<ChartWidget { ...props } href="/stats/test"/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="native-coin-circulating-supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
@@ -67,24 +72,29 @@ test('error', async({ render }) => {
test('small values', async({ render, page }) => {
const modifiedProps = {
...props,
items: [
{ date: new Date('2023-02-13'), value: 0.000005041012112611958 },
{ date: new Date('2023-02-14'), value: 0.000004781545670577531 },
{ date: new Date('2023-02-15'), value: 0.00000520510604212437 },
{ date: new Date('2023-02-16'), value: 0.000005274901030625893 },
{ date: new Date('2023-02-17'), value: 0.00000534325322320271 },
{ date: new Date('2023-02-18'), value: 0.00000579140116207668 },
{ date: new Date('2023-02-19'), value: 0.000004878307079043056 },
{ date: new Date('2023-02-20'), value: 0.0000053454186920910215 },
{ date: new Date('2023-02-21'), value: 0.000005770588532081243 },
{ date: new Date('2023-02-22'), value: 0.00000589334810122426 },
{ date: new Date('2023-02-23'), value: 0.00000547040196358741 },
charts: [
{
...props.charts[0],
items: [
{ date: new Date('2023-02-13'), value: 0.000005041012112611958 },
{ date: new Date('2023-02-14'), value: 0.000004781545670577531 },
{ date: new Date('2023-02-15'), value: 0.00000520510604212437 },
{ date: new Date('2023-02-16'), value: 0.000005274901030625893 },
{ date: new Date('2023-02-17'), value: 0.00000534325322320271 },
{ date: new Date('2023-02-18'), value: 0.00000579140116207668 },
{ date: new Date('2023-02-19'), value: 0.000004878307079043056 },
{ date: new Date('2023-02-20'), value: 0.0000053454186920910215 },
{ date: new Date('2023-02-21'), value: 0.000005770588532081243 },
{ date: new Date('2023-02-22'), value: 0.00000589334810122426 },
{ date: new Date('2023-02-23'), value: 0.00000547040196358741 },
],
},
],
};
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="native-coin-circulating-supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
@@ -92,24 +102,29 @@ test('small values', async({ render, page }) => {
test('small variations in big values', async({ render, page }) => {
const modifiedProps = {
...props,
items: [
{ date: new Date('2023-02-13'), value: 8886203 },
{ date: new Date('2023-02-14'), value: 8890184 },
{ date: new Date('2023-02-15'), value: 8893483 },
{ date: new Date('2023-02-16'), value: 8897924 },
{ date: new Date('2023-02-17'), value: 8902268 },
{ date: new Date('2023-02-18'), value: 8906320 },
{ date: new Date('2023-02-19'), value: 8910264 },
{ date: new Date('2023-02-20'), value: 8914827 },
{ date: new Date('2023-02-21'), value: 8918592 },
{ date: new Date('2023-02-22'), value: 8921988 },
{ date: new Date('2023-02-23'), value: 8922206 },
charts: [
{
...props.charts[0],
items: [
{ date: new Date('2023-02-13'), value: 8886203 },
{ date: new Date('2023-02-14'), value: 8890184 },
{ date: new Date('2023-02-15'), value: 8893483 },
{ date: new Date('2023-02-16'), value: 8897924 },
{ date: new Date('2023-02-17'), value: 8902268 },
{ date: new Date('2023-02-18'), value: 8906320 },
{ date: new Date('2023-02-19'), value: 8910264 },
{ date: new Date('2023-02-20'), value: 8914827 },
{ date: new Date('2023-02-21'), value: 8918592 },
{ date: new Date('2023-02-22'), value: 8921988 },
{ date: new Date('2023-02-23'), value: 8922206 },
],
},
],
};
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="native-coin-circulating-supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
});
@@ -117,15 +132,20 @@ test('small variations in big values', async({ render, page }) => {
test('incomplete day', async({ render, page }) => {
const modifiedProps = {
...props,
items: [
...props.items as Array<TimeChartItem>,
{ date: new Date('2023-02-24'), value: 25136740.887217894 / 4, isApproximate: true },
charts: [
{
...props.charts[0],
items: [
...props.charts[0].items,
{ date: new Date('2023-02-24'), value: 25136740.887217894 / 4, isApproximate: true },
],
},
],
};
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Native_coin_circulating_supply-small"]')?.getAttribute('opacity') === '1';
return document.querySelector('path[data-name="native-coin-circulating-supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
@@ -134,3 +154,54 @@ test('incomplete day', async({ render, page }) => {
await expect(page.getByText('Incomplete day')).toBeVisible();
await expect(component).toHaveScreenshot();
});
test('multiple charts', async({ render, page }) => {
const modifiedProps: ChartWidgetProps = {
...props,
charts: [
{
...props.charts[0],
charts: [
{
type: 'area' as const,
gradient: {
startColor: 'rgba(233, 216, 253, 1)',
stopColor: 'rgba(233, 216, 253, 0)',
},
},
{
type: 'line' as const,
color: '#DBC7F5',
},
],
},
{
id: 'native-coin-circulating-supply-2',
name: 'Limit',
charts: [
{
type: 'line' as const,
color: '#D53F8C',
strokeWidth: 3,
strokeDasharray: '6 6',
},
],
items: props.charts[0].items.map(({ date }) => ({ date, value: 20000000 })),
units: 'ETH',
},
],
};
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="native-coin-circulating-supply-small"]')?.getAttribute('opacity') === '1';
});
await expect(component).toHaveScreenshot();
await page.hover('.ChartOverlay', { position: { x: 120, y: 120 } });
await page.hover('.ChartOverlay', { position: { x: 320, y: 120 } });
await expect(component).toHaveScreenshot();
await page.locator('p').filter({ hasText: 'Value' }).click();
await expect(component).toHaveScreenshot();
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import type { ChartWidgetProps } from 'toolkit/components/charts/ChartWidget';
import { ChartWidget } from 'toolkit/components/charts/ChartWidget';
import { useChartsConfig } from './config';
const ChartWidgetPwStory = ({ charts, ...props }: ChartWidgetProps) => {
const chartsConfig = useChartsConfig();
const modifiedCharts = React.useMemo(() => {
return charts.map((chart) => {
if (chart.charts.length === 0) {
return {
...chart,
charts: chartsConfig,
};
}
return chart;
});
}, [ charts, chartsConfig ]);
return <ChartWidget { ...props } charts={ modifiedCharts }/>;
};
export default React.memo(ChartWidgetPwStory);
-147
View File
@@ -1,147 +0,0 @@
import { chakra, Flex } from '@chakra-ui/react';
import React, { useRef } from 'react';
import type { AxesConfigFn, TimeChartItem } from './types';
import { route, type Route } from 'nextjs-routes';
import config from 'configs/app';
import { IconButton } from 'toolkit/chakra/icon-button';
import { Link } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip';
import IconSvg from 'ui/shared/IconSvg';
import ChartMenu from './ChartMenu';
import ChartWidgetContent from './ChartWidgetContent';
import useZoom from './useZoom';
export type Props = {
items?: Array<TimeChartItem>;
title: string;
description?: string;
units?: string;
isLoading: boolean;
className?: string;
isError: boolean;
emptyText?: string;
noAnimation?: boolean;
href?: Route;
axesConfig?: AxesConfigFn;
};
const ChartWidget = ({
items,
title,
description,
isLoading,
className,
isError,
units,
emptyText,
noAnimation,
href,
axesConfig,
}: Props) => {
const ref = useRef<HTMLDivElement>(null);
const { zoomRange, handleZoom, handleZoomReset } = useZoom();
const hasItems = items && items.length > 2;
const content = (
<ChartWidgetContent
items={ items }
isError={ isError }
isLoading={ isLoading }
units={ units }
title={ title }
emptyText={ emptyText }
handleZoom={ handleZoom }
zoomRange={ zoomRange }
noAnimation={ noAnimation }
axesConfig={ axesConfig }
/>
);
const chartHeader = (
<Flex
flexGrow={ 1 }
flexDir="column"
alignItems="flex-start"
cursor={ href ? 'pointer' : 'default' }
_hover={ href ? { color: 'link.primary.hovered' } : {} }
>
<Skeleton
loading={ isLoading }
fontWeight={ 600 }
textStyle="md"
>
<span>{ title }</span>
</Skeleton>
{ description && (
<Skeleton
loading={ isLoading }
color="text.secondary"
fontSize="xs"
mt={ 1 }
>
<span>{ description }</span>
</Skeleton>
) }
</Flex>
);
return (
<Flex
height="100%"
ref={ ref }
flexDir="column"
padding={{ base: 3, lg: 4 }}
borderRadius="lg"
borderWidth="1px"
borderColor={{ _light: 'gray.200', _dark: 'gray.600' }}
className={ className }
>
<Flex columnGap={ 6 } mb={ 2 } alignItems="flex-start">
{ href ? (
<Link href={ route(href) }>
{ chartHeader }
</Link>
) : chartHeader }
<Flex ml="auto" columnGap={ 2 }>
<Tooltip content="Reset zoom">
<IconButton
hidden={ !zoomRange }
aria-label="Reset zoom"
size="md"
variant="icon_secondary"
onClick={ handleZoomReset }
>
<IconSvg name="repeat"/>
</IconButton>
</Tooltip>
{ hasItems && (
<ChartMenu
items={ items }
title={ title }
description={ description }
chartUrl={ href ? config.app.baseUrl + route(href) : undefined }
isLoading={ isLoading }
chartRef={ ref }
units={ units }
handleZoom={ handleZoom }
handleZoomReset={ handleZoomReset }
zoomRange={ zoomRange }
/>
) }
</Flex>
</Flex>
{ content }
</Flex>
);
};
export default React.memo(chakra(ChartWidget));
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+36
View File
@@ -0,0 +1,36 @@
import { useToken } from '@chakra-ui/react';
import React from 'react';
import type { ChartConfig } from 'toolkit/components/charts/types';
import useIsMobile from 'lib/hooks/useIsMobile';
import { useColorModeValue } from 'toolkit/chakra/color-mode';
export function useChartsConfig(): Array<ChartConfig> {
const lineColor = useDefaultLineColor();
const gradient = useDefaultGradient();
const isMobile = useIsMobile();
return React.useMemo(() => [
{
type: 'line',
color: lineColor,
strokeWidth: isMobile ? 1 : 2,
},
{
type: 'area',
gradient,
},
], [ lineColor, isMobile, gradient ]);
}
export function useDefaultLineColor() {
const [ lineColor ] = useToken('colors', useColorModeValue('theme.graph.line._light', 'theme.graph.line._dark'));
return React.useMemo(() => lineColor, [ lineColor ]);
}
export function useDefaultGradient() {
const [ startColor ] = useToken('colors', useColorModeValue('theme.graph.gradient.start._light', 'theme.graph.gradient.start._dark'));
const [ stopColor ] = useToken('colors', useColorModeValue('theme.graph.gradient.stop._light', 'theme.graph.gradient.stop._dark'));
return React.useMemo(() => ({ startColor, stopColor }), [ startColor, stopColor ]);
}
-51
View File
@@ -1,51 +0,0 @@
export interface TimeChartItemRaw {
date: Date;
dateLabel?: string;
value: number | string | null;
}
export interface TimeChartItem {
date: Date;
date_to?: Date;
dateLabel?: string;
value: number;
isApproximate?: boolean;
}
export interface ChartMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface ChartOffset {
x?: number;
y?: number;
}
export interface TimeChartDataItem {
items: Array<TimeChartItem>;
name: string;
units?: string;
color?: string;
valueFormatter?: (value: number) => string;
}
export type TimeChartData = Array<TimeChartDataItem>;
export interface AxisConfig {
ticks?: number;
nice?: boolean;
noLabel?: boolean;
scale?: {
min?: number;
};
}
export interface AxesConfig {
x?: AxisConfig;
y?: AxisConfig;
}
export type AxesConfigFn = (props: { isEnlarged?: boolean; isMobile?: boolean }) => AxesConfig;
+1 -1
View File
@@ -7,7 +7,7 @@ import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import { STATS_INTERVALS } from 'ui/stats/constants';
import formatDate from './utils/formatIntervalDate';
import { formatDate } from './utils';
export default function useChartQuery(id: string, resolution: Resolution, interval: StatsIntervalIds, enabled = true) {
const { apiData } = useAppContext<'/stats/[id]'>();
+9
View File
@@ -0,0 +1,9 @@
import type { TimeChartItem } from 'toolkit/components/charts/types';
export function formatDate(date: Date) {
return date.toISOString().substring(0, 10);
}
export const sortByDateDesc = (a: Pick<TimeChartItem, 'date'>, b: Pick<TimeChartItem, 'date'>) => {
return a.date.getTime() - b.date.getTime();
};
@@ -1,3 +0,0 @@
export default function formatDate(date: Date) {
return date.toISOString().substring(0, 10);
}
-30
View File
@@ -1,30 +0,0 @@
import React from 'react';
export const BlueLineGradient = {
id: 'blue-line-gradient',
defs: () => (
<linearGradient id="blue-line-gradient">
<stop offset="0%" stopColor="#4299E1"/>
<stop offset="100%" stopColor="#00B5D8"/>
</linearGradient>
),
};
export const RainbowGradient = {
id: 'rainbow-gradient',
defs: () => (
<linearGradient id="rainbow-gradient">
<stop offset="0%" stopColor="rgba(255, 0, 0, 1)"/>
<stop offset="10%" stopColor="rgba(255, 154, 0, 1)"/>
<stop offset="20%" stopColor="rgba(208, 222, 33, 1)"/>
<stop offset="30%" stopColor="rgba(79, 220, 74, 1)"/>
<stop offset="40%" stopColor="rgba(63, 218, 216, 1)"/>
<stop offset="50%" stopColor="rgba(47, 201, 226, 1)"/>
<stop offset="60%" stopColor="rgba(28, 127, 238, 1)"/>
<stop offset="70%" stopColor="rgba(95, 21, 242, 1)"/>
<stop offset="80%" stopColor="rgba(186, 12, 248, 1)"/>
<stop offset="90%" stopColor="rgba(251, 7, 217, 1)"/>
<stop offset="100%" stopColor="rgba(255, 0, 0, 1)"/>
</linearGradient>
),
};
-5
View File
@@ -1,5 +0,0 @@
import type { TimeChartItem } from '../types';
export const sortByDateDesc = (a: Pick<TimeChartItem, 'date'>, b: Pick<TimeChartItem, 'date'>) => {
return a.date.getTime() - b.date.getTime();
};
+1 -1
View File
@@ -8,10 +8,10 @@ import React from 'react';
import type { File, Monaco } from './types';
import type { SmartContractExternalLibrary } from 'types/api/contract';
import useClientRect from 'lib/hooks/useClientRect';
import useIsMobile from 'lib/hooks/useIsMobile';
import isMetaKey from 'lib/isMetaKey';
import { useColorMode } from 'toolkit/chakra/color-mode';
import { useClientRect } from 'toolkit/hooks/useClientRect';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
import CodeEditorBreadcrumbs from './CodeEditorBreadcrumbs';

Some files were not shown because too many files have changed in this diff Show More