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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }>
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 53 KiB |
|
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 }
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
@@ -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,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();
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 136 KiB |
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -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 ]);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]'>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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';
|
||||
|
||||