Commit 8116525c authored by tom's avatar tom

multiline chart

parent 51b62da5
__snapshots__/** filter=lfs diff=lfs merge=lfs -text
data/charts_eth_txs.json filter=lfs diff=lfs merge=lfs -text
data/charts_eth_token_transfer.json filter=lfs diff=lfs merge=lfs -text
This source diff could not be displayed because it is stored in LFS. You can view the blob instead.
import { useToken, Button, Box } from '@chakra-ui/react';
import _range from 'lodash/range';
import React from 'react';
import json from 'data/charts_eth_txs.json';
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 ChartArea from 'ui/shared/chart/ChartArea';
import ChartAxis from 'ui/shared/chart/ChartAxis';
import ChartGridLine from 'ui/shared/chart/ChartGridLine';
import ChartLegend from 'ui/shared/chart/ChartLegend';
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 useBrushX from 'ui/shared/chart/useBrushX';
// import useBrushX from 'ui/shared/chart/useBrushX';
import useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 };
const EthereumDailyTxsChart = () => {
const EthereumChart = () => {
const ref = React.useRef<SVGSVGElement>(null);
const overlayRef = React.useRef<SVGRectElement>(null);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, CHART_MARGIN);
const [ range, setRange ] = React.useState<[number, number]>([ 0, Infinity ]);
const brushLimits = React.useMemo(() => (
[ [ 0, innerHeight ], [ innerWidth, height ] ] as [[number, number], [number, number]]
), [ height, innerHeight, innerWidth ]);
useBrushX({ anchor: ref.current, limits: brushLimits, setRange });
const data: TimeChartData = [
{
name: 'Daily Transactions',
color: useToken('colors', 'blue.500'),
items: ethTxsData.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
},
{
name: 'ERC-20 Token Transfers',
color: useToken('colors', 'green.500'),
items: ethTokenTransferData.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
},
];
const data = {
items: json.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
};
const { yTickFormat, xScale, yScale } = useTimeChartController({ data, width: innerWidth, height: innerHeight });
const [ selectedLines, setSelectedLines ] = React.useState<Array<number>>(_range(data.length));
const filteredData = data.filter((_, index) => selectedLines.includes(index));
const lineColor = useToken('colors', 'blue.500');
const { yTickFormat, xScale, yScale } = useTimeChartController({
data: filteredData.length === 0 ? data : filteredData,
width: innerWidth,
height: innerHeight,
});
const handleRangeSelect = React.useCallback((nextRange: [number, number]) => {
setRange([ range[0] + nextRange[0], range[0] + nextRange[1] ]);
......@@ -42,6 +57,17 @@ const EthereumDailyTxsChart = () => {
setRange([ 0, Infinity ]);
}, [ ]);
const handleLegendItemClick = React.useCallback((index: number) => {
const nextSelectedLines = selectedLines.includes(index) ? selectedLines.filter((item) => item !== index) : [ ...selectedLines, index ];
setSelectedLines(nextSelectedLines);
}, [ selectedLines ]);
// uncomment if we need brush the chart
// const brushLimits = React.useMemo(() => (
// [ [ 0, innerHeight ], [ innerWidth, height ] ] as [[number, number], [number, number]]
// ), [ height, innerHeight, innerWidth ]);
// useBrushX({ anchor: ref.current, limits: brushLimits, setRange });
return (
<Box display="inline-block" position="relative" width="100%" height="100%">
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref }>
......@@ -73,19 +99,25 @@ const EthereumDailyTxsChart = () => {
/>
{ /* GRAPH */ }
<ChartLine
data={ data.items }
xScale={ xScale }
yScale={ yScale }
stroke={ lineColor }
animation="left"
/>
<ChartArea
data={ data.items }
color={ lineColor }
xScale={ xScale }
yScale={ yScale }
/>
{ filteredData.map((d) => (
<ChartLine
key={ d.name }
data={ d.items }
xScale={ xScale }
yScale={ yScale }
stroke={ d.color }
animation="left"
/>
)) }
{ filteredData.map((d) => (
<ChartArea
key={ d.name }
data={ d.items }
color={ d.color }
xScale={ xScale }
yScale={ yScale }
/>
)) }
{ /* AXISES */ }
<ChartAxis
......@@ -104,22 +136,26 @@ const EthereumDailyTxsChart = () => {
anchorEl={ overlayRef.current }
disableAnimation
/>
<ChartTooltip
anchorEl={ overlayRef.current }
width={ innerWidth }
height={ innerHeight }
margin={ CHART_MARGIN }
xScale={ xScale }
yScale={ yScale }
data={ data }
/>
<ChartSelectionX
anchorEl={ overlayRef.current }
height={ innerHeight }
scale={ xScale }
data={ data }
onSelect={ handleRangeSelect }
/>
{ filteredData.length > 0 && (
<ChartTooltip
anchorEl={ overlayRef.current }
width={ innerWidth }
height={ innerHeight }
margin={ CHART_MARGIN }
xScale={ xScale }
yScale={ yScale }
data={ filteredData }
/>
) }
{ filteredData.length > 0 && (
<ChartSelectionX
anchorEl={ overlayRef.current }
height={ innerHeight }
scale={ xScale }
data={ filteredData }
onSelect={ handleRangeSelect }
/>
) }
</ChartOverlay>
</g>
</svg>
......@@ -132,11 +168,12 @@ const EthereumDailyTxsChart = () => {
right={ `${ CHART_MARGIN?.right || 0 }px` }
onClick={ handleZoomReset }
>
Reset zoom
Reset zoom
</Button>
) }
<ChartLegend data={ data } selectedIndexes={ selectedLines } onClick={ handleLegendItemClick }/>
</Box>
);
};
export default React.memo(EthereumDailyTxsChart);
export default React.memo(EthereumChart);
import { Box } from '@chakra-ui/react';
import { Box, Heading } from '@chakra-ui/react';
import React from 'react';
import EthereumDailyTxsChart from 'ui/charts/EthereumDailyTxsChart';
import EthereumChart from 'ui/charts/EthereumChart';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
const Graph = () => {
return (
<Page>
<PageTitle text="Ethereum Daily Transactions Chart"/>
<PageTitle text="Charts"/>
<Heading as="h2" size="sm" fontWeight="500" mb={ 3 }>Ethereum Daily Transactions & ERC-20 Token Transfer Chart</Heading>
<Box w="100%" h="400px">
<EthereumDailyTxsChart/>
<EthereumChart/>
</Box>
</Page>
);
......
......@@ -37,8 +37,8 @@ const ChartArea = ({ xScale, yScale, color, data, disableAnimation, ...props }:
<path ref={ ref } d={ d } fill={ `url(#gradient-${ color })` } opacity={ 0 } { ...props }/>
<defs>
<linearGradient id={ `gradient-${ color }` } x1="0%" x2="0%" y1="0%" y2="100%">
<stop offset="0%" stopColor={ color } stopOpacity={ 0.9 }/>
<stop offset="100%" stopColor={ color } stopOpacity={ 0.1 }/>
<stop offset="0%" stopColor={ color } stopOpacity={ 1 }/>
<stop offset="100%" stopColor={ color } stopOpacity={ 0.15 }/>
</linearGradient>
</defs>
</>
......
......@@ -45,7 +45,9 @@ const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl,
return;
}
d3.select(anchorEl)
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mouseout.axisX', () => {
d3.select(ref.current)
.selectAll('text')
......@@ -60,6 +62,10 @@ const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl,
textElements
.style('font-weight', (d, i) => i === index - 1 ? 'bold' : 'normal');
});
return () => {
anchorD3.on('mouseout.axisX mousemove.axisX', null);
};
}, [ anchorEl, scale ]);
return <g ref={ ref } { ...props }/>;
......
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);
......@@ -2,7 +2,7 @@ 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 { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
const SELECTION_THRESHOLD = 1;
......@@ -10,9 +10,7 @@ interface Props {
height: number;
anchorEl?: SVGRectElement | null;
scale: d3.ScaleTime<number, number>;
data: {
items: Array<TimeChartItem>;
};
data: TimeChartData;
onSelect: (range: [number, number]) => void;
}
......@@ -28,8 +26,8 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
const getIndexByX = React.useCallback((x: number) => {
const xDate = scale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
return bisectDate(data.items, xDate, 1);
}, [ data.items, scale ]);
return bisectDate(data[0].items, xDate, 1);
}, [ data, scale ]);
const drawSelection = React.useCallback((x0: number, x1: number) => {
const diffX = x1 - x0;
......@@ -99,6 +97,11 @@ const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) =>
handelMouseUp();
}
});
return () => {
anchorD3.on('mousedown.selectionX mouseup.selectionX mousemove.selectionX', null);
d3.select('body').on('mouseup.selectionX', null);
};
}, [ anchorEl, drawSelection, getIndexByX, handelMouseUp ]);
return (
......
......@@ -2,15 +2,13 @@ import { useToken, useColorModeValue } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem, ChartMargin } from 'ui/shared/chart/types';
import type { TimeChartItem, ChartMargin, TimeChartData } from 'ui/shared/chart/types';
interface Props {
width?: number;
height?: number;
margin?: ChartMargin;
data: {
items: Array<TimeChartItem>;
};
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
yScale: d3.ScaleLinear<number, number>;
anchorEl: SVGRectElement | null;
......@@ -60,24 +58,24 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
[ xScale, margin, width ],
);
const onChangePosition = React.useCallback((d: TimeChartItem, isVisible: boolean) => {
d3.select('.ChartTooltip__value')
.text(isVisible ? d.value.toLocaleString() : '');
const updateDisplayedValue = React.useCallback((d: TimeChartItem, i: number) => {
d3.selectAll('.ChartTooltip__value')
.filter((td, tIndex) => tIndex === i)
.text(d.value.toLocaleString());
}, []);
const followPoints = React.useCallback((event: MouseEvent) => {
const drawCircles = React.useCallback((event: MouseEvent) => {
const [ x ] = d3.pointer(event, anchorEl);
const xDate = xScale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
let baseXPos = 0;
// draw circles on line
d3.select(ref.current)
.select('.ChartTooltip__linePoint')
.selectAll('.ChartTooltip__linePoint')
.attr('transform', (cur, i) => {
const index = bisectDate(data.items, xDate, 1);
const d0 = data.items[index - 1];
const d1 = data.items[index];
const index = bisectDate(data[i].items, xDate, 1);
const d0 = data[i].items[index - 1];
const d1 = data[i].items[index];
const d = xDate.getTime() - d0?.date.getTime() > d1?.date.getTime() - xDate.getTime() ? d1 : d0;
if (d.date === undefined && d.value === undefined) {
......@@ -90,30 +88,21 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
baseXPos = xPos;
}
let isVisible = true;
if (xPos !== baseXPos) {
isVisible = false;
}
const yPos = yScale(d.value);
onChangePosition(d, isVisible);
updateDisplayedValue(d, i);
return isVisible ?
`translate(${ xPos }, ${ yPos })` :
'translate(-100,-100)';
return `translate(${ xPos }, ${ yPos })`;
});
return baseXPos;
}, [ anchorEl, data, updateDisplayedValue, xScale, yScale ]);
const followPoints = React.useCallback((event: MouseEvent) => {
const baseXPos = drawCircles(event);
drawLine(baseXPos);
drawContent(baseXPos);
}, [
anchorEl,
drawLine,
drawContent,
xScale,
yScale,
data,
onChangePosition,
]);
}, [ drawCircles, drawLine, drawContent ]);
React.useEffect(() => {
const anchorD3 = d3.select(anchorEl);
......@@ -136,7 +125,7 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
if (!isPressed.current) {
d3.select(ref.current).attr('opacity', 1);
d3.select(ref.current)
.select('.ChartTooltip__linePoint')
.selectAll('.ChartTooltip__linePoint')
.attr('opacity', 1);
followPoints(event);
}
......@@ -148,13 +137,18 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
isPressed.current = false;
}
});
return () => {
anchorD3.on('mousedown.tooltip mouseup.tooltip mouseout.tooltip mouseover.tooltip mousemove.tooltip', null);
d3.select('body').on('mouseup.tooltip', null);
};
}, [ anchorEl, followPoints ]);
return (
<g ref={ ref } opacity={ 0 } { ...props }>
<line className="ChartTooltip__line" stroke={ lineColor }/>
<g className="ChartTooltip__content">
<rect className="ChartTooltip__contentBg" rx={ 8 } ry={ 8 } fill={ bgColor } width={ 125 } height={ 52 }/>
<rect className="ChartTooltip__contentBg" rx={ 8 } ry={ 8 } fill={ bgColor } width={ 125 } height={ data.length * 22 + 34 }/>
<text
className="ChartTooltip__contentTitle"
transform="translate(8,20)"
......@@ -163,15 +157,24 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
fill={ textColor }
pointerEvents="none"
/>
<text
transform="translate(8,40)"
className="ChartTooltip__value"
fontSize="12px"
fill={ textColor }
pointerEvents="none"
/>
<g>
{ data.map(({ name, color }, index) => (
<g key={ name } className="ChartTooltip__contentLine" transform={ `translate(12,${ 40 + index * 20 })` }>
<circle r={ 4 } fill={ color }/>
<text
transform="translate(10,4)"
className="ChartTooltip__value"
fontSize="12px"
fill={ textColor }
pointerEvents="none"
/>
</g>
)) }
</g>
</g>
<circle className="ChartTooltip__linePoint" r={ 3 } opacity={ 0 } fill={ lineColor }/>
{ data.map(({ name, color }) => (
<circle key={ name } className="ChartTooltip__linePoint" r={ 4 } opacity={ 0 } fill={ color } stroke="#FFF" strokeWidth={ 1 }/>
)) }
</g>
);
};
......
......@@ -9,3 +9,11 @@ export interface ChartMargin {
bottom?: number;
left?: number;
}
export interface TimeChartDataItem {
items: Array<TimeChartItem>;
name: string;
color: string;
}
export type TimeChartData = Array<TimeChartDataItem>;
import * as d3 from 'd3';
import { useMemo } from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
import type { TimeChartData } from 'ui/shared/chart/types';
interface Props {
data: {
items: Array<TimeChartItem>;
};
data: TimeChartData;
width: number;
height: number;
}
......@@ -14,12 +12,12 @@ interface Props {
export default function useTimeChartController({ data, width, height }: Props) {
const xMin = useMemo(
() => d3.min(data.items, ({ date }) => date) || new Date(),
() => d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xMax = useMemo(
() => d3.max(data.items, ({ date }) => date) || new Date(),
() => d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)) || new Date(),
[ data ],
);
......@@ -29,17 +27,17 @@ export default function useTimeChartController({ data, width, height }: Props) {
);
const yMin = useMemo(
() => d3.min(data.items, ({ value }) => value) || 0,
() => d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)) || 0,
[ data ],
);
const yMax = useMemo(
() => d3.max(data.items, ({ value }) => value) || 0,
() => d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) || 0,
[ data ],
);
const yScale = useMemo(() => {
const indention = (yMax - yMin) * 0.5;
const indention = (yMax - yMin) * 0.3;
return d3.scaleLinear()
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ])
.range([ height, 0 ]);
......
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