Commit 4ada09dd authored by Yuri Mikhin's avatar Yuri Mikhin Committed by Yuri Mikhin

Add grouping of values for charts at the long ranges.

parent 9292dd55
......@@ -2,8 +2,10 @@
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import minMax from 'dayjs/plugin/minMax';
import relativeTime from 'dayjs/plugin/relativeTime';
import updateLocale from 'dayjs/plugin/updateLocale';
import weekOfYear from 'dayjs/plugin/weekOfYear';
const relativeTimeConfig = {
thresholds: [
......@@ -26,6 +28,8 @@ dayjs.extend(relativeTime, relativeTimeConfig);
dayjs.extend(updateLocale);
dayjs.extend(localizedFormat);
dayjs.extend(duration);
dayjs.extend(weekOfYear);
dayjs.extend(minMax);
dayjs.updateLocale('en', {
formats: {
......
......@@ -30,7 +30,6 @@ const AddressCoinBalanceChart = ({ addressHash }: Props) => {
return (
<ChartWidget
chartHeight="200px"
title="Balances"
items={ items }
isLoading={ isLoading }
......
......@@ -5,6 +5,7 @@ import type { TimeChartData } from 'ui/shared/chart/types';
import ethTokenTransferData from 'data/charts_eth_token_transfer.json';
import ethTxsData from 'data/charts_eth_txs.json';
import dayjs from 'lib/date/dayjs';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
import ChartGridLine from 'ui/shared/chart/ChartGridLine';
......@@ -22,24 +23,30 @@ const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 };
const CHART_OFFSET = {
y: 26, // legend height
};
const RANGE_DEFAULT_START_DATE = dayjs.min(dayjs(ethTokenTransferData[0].date), dayjs(ethTxsData[0].date)).toDate();
const RANGE_DEFAULT_LAST_DATE = dayjs.max(dayjs(ethTokenTransferData.at(-1)?.date), dayjs(ethTxsData.at(-1)?.date)).toDate();
const EthereumChart = () => {
const ref = React.useRef<SVGSVGElement>(null);
const overlayRef = React.useRef<SVGRectElement>(null);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN, CHART_OFFSET);
const [ range, setRange ] = React.useState<[number, number]>([ 0, Infinity ]);
const [ range, setRange ] = React.useState<[ Date, Date ]>([ RANGE_DEFAULT_START_DATE, RANGE_DEFAULT_LAST_DATE ]);
const data: TimeChartData = [
{
name: 'Daily txs',
color: useToken('colors', 'blue.500'),
items: ethTxsData.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
items: ethTxsData
.map((d) => ({ ...d, date: new Date(d.date) }))
.filter((d) => d.date >= range[0] && d.date <= range[1]),
},
{
name: 'ERC-20 tr.',
color: useToken('colors', 'green.500'),
items: ethTokenTransferData.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
items: ethTokenTransferData
.map((d) => ({ ...d, date: new Date(d.date) }))
.filter((d) => d.date >= range[0] && d.date <= range[1]),
},
];
......@@ -52,12 +59,12 @@ const EthereumChart = () => {
height: innerHeight,
});
const handleRangeSelect = React.useCallback((nextRange: [number, number]) => {
setRange([ range[0] + nextRange[0], range[0] + nextRange[1] ]);
}, [ range ]);
const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => {
setRange([ nextRange[0], nextRange[1] ]);
}, [ ]);
const handleZoomReset = React.useCallback(() => {
setRange([ 0, Infinity ]);
setRange([ RANGE_DEFAULT_START_DATE, RANGE_DEFAULT_LAST_DATE ]);
}, [ ]);
// uncomment if we need brush the chart
......@@ -156,7 +163,7 @@ const EthereumChart = () => {
</ChartOverlay>
</g>
</svg>
{ (range[0] !== 0 || range[1] !== Infinity) && (
{ (range[0] !== RANGE_DEFAULT_START_DATE || range[1] !== RANGE_DEFAULT_LAST_DATE) && (
<Button
size="sm"
variant="outline"
......
......@@ -4,14 +4,16 @@ import React from 'react';
import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
const SELECTION_THRESHOLD = 1;
import dayjs from 'lib/date/dayjs';
const SELECTION_THRESHOLD = 2;
interface Props {
height: number;
anchorEl?: SVGRectElement | null;
scale: d3.ScaleTime<number, number>;
data: TimeChartData;
onSelect: (range: [number, number]) => void;
onSelect: (range: [Date, Date]) => void;
}
const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) => {
......@@ -51,13 +53,13 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
}, []);
const handleSelect = React.useCallback((x0: number, x1: number) => {
const index0 = getIndexByX(x0);
const index1 = getIndexByX(x1);
const startDate = scale.invert(x0);
const endDate = scale.invert(x1);
if (Math.abs(index0 - index1) > SELECTION_THRESHOLD) {
onSelect([ Math.min(index0, index1), Math.max(index0, index1) ]);
if (Math.abs(dayjs(startDate).diff(endDate, 'day')) > SELECTION_THRESHOLD) {
onSelect([ dayjs.min(dayjs(startDate), dayjs(endDate)).toDate(), dayjs.max(dayjs(startDate), dayjs(endDate)).toDate() ]);
}
}, [ getIndexByX, onSelect ]);
}, [ onSelect, scale ]);
const cleanUp = React.useCallback(() => {
isActive.current = false;
......
......@@ -11,6 +11,7 @@ import { trackPointer } from 'ui/shared/chart/utils/pointerTracker';
interface Props {
chartId?: string;
width?: number;
tooltipWidth?: number;
height?: number;
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
......@@ -23,7 +24,7 @@ const PADDING = 16;
const LINE_SPACE = 10;
const POINT_SIZE = 16;
const ChartTooltip = ({ chartId, xScale, yScale, width, height, data, anchorEl, ...props }: Props) => {
const ChartTooltip = ({ chartId, xScale, yScale, width, tooltipWidth, height, data, anchorEl, ...props }: Props) => {
const lineColor = useToken('colors', 'gray.400');
const titleColor = useToken('colors', 'blue.100');
const textColor = useToken('colors', 'white');
......@@ -66,11 +67,14 @@ const ChartTooltip = ({ chartId, xScale, yScale, width, height, data, anchorEl,
return `translate(${ translateX }, ${ translateY })`;
});
const date = xScale.invert(x);
const dateLabel = data[0].items.find((item) => item.date.getTime() === date.getTime())?.dateLabel;
tooltipContent
.select('.ChartTooltip__contentDate')
.text(d3.timeFormat('%e %b %Y')(xScale.invert(x)));
.text(dateLabel || d3.timeFormat('%e %b %Y')(xScale.invert(x)));
},
[ xScale, width, height ],
[ xScale, data, width, height ],
);
const updateDisplayedValue = React.useCallback((d: TimeChartItem, i: number) => {
......@@ -226,7 +230,7 @@ const ChartTooltip = ({ chartId, xScale, yScale, width, height, data, anchorEl,
rx={ 12 }
ry={ 12 }
fill={ bgColor }
width={ 200 }
width={ tooltipWidth || 200 }
height={ 2 * PADDING + (data.length + 1) * TEXT_LINE_HEIGHT + data.length * LINE_SPACE }
/>
<g transform={ `translate(${ PADDING },${ PADDING })` }>
......
......@@ -99,6 +99,7 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
return (
<>
<Box
height={ chartHeight }
ref={ ref }
padding={{ base: 3, lg: 4 }}
borderRadius="md"
......@@ -198,6 +199,7 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight }: Prop
<Box h={ chartHeight || 'auto' }>
<ChartWidgetGraph
margin={{ bottom: 20 }}
items={ items }
onZoom={ handleZoom }
isZoomResetInitial={ isZoomResetInitial }
......
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React, { useEffect, useMemo } from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
import type { ChartMargin, TimeChartItem } from 'ui/shared/chart/types';
import dayjs from 'lib/date/dayjs';
import useIsMobile from 'lib/hooks/useIsMobile';
import ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
......@@ -20,21 +22,35 @@ interface Props {
items: Array<TimeChartItem>;
onZoom: () => void;
isZoomResetInitial: boolean;
margin: ChartMargin;
}
const CHART_MARGIN = { bottom: 20, left: 40, right: 20, top: 10 };
const MAX_SHOW_ITEMS = 100;
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 40, right: 20, top: 10 };
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title }: Props) => {
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin }: Props) => {
const isMobile = useIsMobile();
const ref = React.useRef<SVGSVGElement>(null);
const color = useToken('colors', 'blue.200');
const ref = React.useRef<SVGSVGElement>(null);
const overlayRef = React.useRef<SVGRectElement>(null);
const [ range, setRange ] = React.useState<[ number, number ]>([ 0, Infinity ]);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
const chartMargin = { ...DEFAULT_CHART_MARGIN, ...margin };
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, chartMargin);
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
const displayedData = useMemo(() => items.slice(range[0], range[1]), [ items, range ]);
const chartData = [ { items: items, name: 'Value', color } ];
const [ range, setRange ] = React.useState<[ Date, Date ]>([ items[0].date, items[items.length - 1].date ]);
const rangedItems = useMemo(() =>
items.filter((item) => item.date >= range[0] && item.date <= range[1]),
[ items, range ]);
const isGroupedValues = rangedItems.length > MAX_SHOW_ITEMS;
const displayedData = useMemo(() => {
if (isGroupedValues) {
return groupChartItemsByWeekNumber(rangedItems);
} else {
return rangedItems;
}
}, [ isGroupedValues, rangedItems ]);
const chartData = [ { items: displayedData, name: 'Value', color } ];
const { yTickFormat, xScale, yScale } = useTimeChartController({
data: [ { items: displayedData, name: title, color } ],
......@@ -42,21 +58,21 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
height: innerHeight,
});
const handleRangeSelect = React.useCallback((nextRange: [ number, number ]) => {
setRange([ range[0] + nextRange[0], range[0] + nextRange[1] ]);
const handleRangeSelect = React.useCallback((nextRange: [ Date, Date ]) => {
setRange([ nextRange[0], nextRange[1] ]);
onZoom();
}, [ onZoom, range ]);
}, [ onZoom ]);
useEffect(() => {
if (isZoomResetInitial) {
setRange([ 0, Infinity ]);
setRange([ items[0].date, items[items.length - 1].date ]);
}
}, [ isZoomResetInitial ]);
}, [ isZoomResetInitial, items ]);
return (
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref } cursor="pointer" id={ chartId } opacity={ width ? 1 : 0 }>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` }>
<g transform={ `translate(${ chartMargin?.left || 0 },${ chartMargin?.top || 0 })` }>
<ChartGridLine
type="horizontal"
scale={ yScale }
......@@ -104,6 +120,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
chartId={ chartId }
anchorEl={ overlayRef.current }
width={ innerWidth }
tooltipWidth={ isGroupedValues ? 280 : 200 }
height={ innerHeight }
xScale={ xScale }
yScale={ yScale }
......@@ -124,3 +141,14 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
};
export default React.memo(ChartWidgetGraph);
function groupChartItemsByWeekNumber(items: Array<TimeChartItem>): Array<TimeChartItem> {
return d3.rollups(items,
(group) => ({
date: group[0].date,
value: d3.sum(group, (d) => d.value),
dateLabel: `${ d3.timeFormat('%e %b %Y')(group[0].date) }${ d3.timeFormat('%e %b %Y')(group[group.length - 1].date) }`,
}),
(t) => dayjs(t.date).week(),
).map(([ , v ]) => v);
}
......@@ -91,6 +91,7 @@ const FullscreenChartModal = ({
h="100%"
>
<ChartWidgetGraph
margin={{ bottom: 60 }}
isEnlarged
items={ items }
onZoom={ handleZoom }
......
export interface TimeChartItem {
date: Date;
dateLabel?: string;
value: number;
}
......
......@@ -48,7 +48,7 @@ export const statsChartsScheme: Array<StatsSection> = [
title: 'Blocks',
charts: [
{
apiId: 'newBlocksPerDay',
apiId: 'newBlocks',
title: 'New blocks',
description: 'New blocks number',
},
......@@ -97,7 +97,7 @@ export const statsChartsScheme: Array<StatsSection> = [
{
apiId: 'averageGasPrice',
title: 'Average gas price',
description: 'Average gas price for the period',
description: 'Average gas price for the period (Gwei)',
},
],
},
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment