Commit d5709a48 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #281 from blockscout/graph-prototype

chart prototype
parents 5f0e0c0e ecb512f6
This diff is collapsed.
This diff is collapsed.
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import Graph from 'ui/pages/Graph';
const GraphPage: NextPage = () => {
return (
<>
<Head><title>Graph Page</title></Head>
<Graph/>
</>
);
};
export default GraphPage;
export { getStaticPaths } from 'lib/next/getStaticPaths';
export { getStaticProps } from 'lib/next/getStaticProps';
......@@ -9,6 +9,10 @@ const global = (props: StyleFunctionProps) => ({
...getDefaultTransitionProps(),
'-webkit-tap-highlight-color': 'transparent',
},
'svg *::selection': {
color: 'none',
background: 'none',
},
form: {
w: '100%',
},
......
import { useToken, Button, Box } from '@chakra-ui/react';
import React from 'react';
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 useChartLegend from 'ui/shared/chart/useChartLegend';
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 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 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 { selectedLines, handleLegendItemClick } = useChartLegend(data.length);
const filteredData = data.filter((_, index) => selectedLines.includes(index));
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] ]);
}, [ range ]);
const handleZoomReset = React.useCallback(() => {
setRange([ 0, Infinity ]);
}, [ ]);
// 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 }>
<g transform={ `translate(${ CHART_MARGIN?.left || 0 },${ CHART_MARGIN?.top || 0 })` } opacity={ width ? 1 : 0 }>
{ /* BASE GRID LINE */ }
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 1 }
size={ innerWidth }
disableAnimation
/>
{ /* GIRD LINES */ }
<ChartGridLine
type="vertical"
scale={ xScale }
ticks={ 5 }
size={ innerHeight }
transform={ `translate(0, ${ innerHeight })` }
disableAnimation
/>
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 5 }
size={ innerWidth }
disableAnimation
/>
{ /* GRAPH */ }
{ 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
type="left"
scale={ yScale }
ticks={ 5 }
tickFormat={ yTickFormat }
disableAnimation
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartAxis
type="bottom"
scale={ xScale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ 5 }
anchorEl={ overlayRef.current }
disableAnimation
/>
{ 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>
{ (range[0] !== 0 || range[1] !== Infinity) && (
<Button
size="sm"
variant="outline"
position="absolute"
top={ `${ CHART_MARGIN?.top || 0 }px` }
right={ `${ CHART_MARGIN?.right || 0 }px` }
onClick={ handleZoomReset }
>
Reset zoom
</Button>
) }
<ChartLegend data={ data } selectedIndexes={ selectedLines } onClick={ handleLegendItemClick }/>
</Box>
);
};
export default React.memo(EthereumChart);
import { Box, Heading } from '@chakra-ui/react';
import React from 'react';
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="Charts"/>
<Heading as="h2" size="sm" fontWeight="500" mb={ 3 }>Ethereum Daily Transactions & ERC-20 Token Transfer Chart</Heading>
<Box w="100%" h="400px">
<EthereumChart/>
</Box>
</Page>
);
};
export default Graph;
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
interface Props extends React.SVGProps<SVGPathElement> {
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
color: string;
data: Array<TimeChartItem>;
disableAnimation?: boolean;
}
const ChartArea = ({ xScale, yScale, color, data, disableAnimation, ...props }: Props) => {
const ref = React.useRef(null);
React.useEffect(() => {
if (disableAnimation) {
d3.select(ref.current).attr('opacity', 1);
return;
}
d3.select(ref.current).transition()
.duration(750)
.ease(d3.easeBackIn)
.attr('opacity', 1);
}, [ disableAnimation ]);
const d = React.useMemo(() => {
const area = d3.area<TimeChartItem>()
.x(({ date }) => xScale(date))
.y1(({ value }) => yScale(value))
.y0(() => yScale(yScale.domain()[0]));
return area(data) || undefined;
}, [ xScale, yScale, data ]);
return (
<>
<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={ 1 }/>
<stop offset="100%" stopColor={ color } stopOpacity={ 0.15 }/>
</linearGradient>
</defs>
</>
);
};
export default React.memo(ChartArea);
import { useColorModeValue, useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'left' | 'bottom';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean;
ticks: number;
tickFormat?: (domainValue: d3.AxisDomain, index: number) => string;
anchorEl?: SVGRectElement | null;
}
const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const textColorToken = useColorModeValue('blackAlpha.500', 'whiteAlpha.500');
const textColor = useToken('colors', textColorToken);
React.useEffect(() => {
if (!ref.current) {
return;
}
const axisGenerator = type === 'left' ? d3.axisLeft : d3.axisBottom;
const axis = tickFormat ?
axisGenerator(scale).ticks(ticks).tickFormat(tickFormat) :
axisGenerator(scale).ticks(ticks);
const axisGroup = d3.select(ref.current);
if (disableAnimation) {
axisGroup.call(axis);
} else {
axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
}
axisGroup.select('.domain').remove();
axisGroup.selectAll('line').remove();
axisGroup.selectAll('text')
.attr('opacity', 1)
.attr('color', textColor)
.attr('font-size', '0.75rem');
}, [ scale, ticks, tickFormat, disableAnimation, type, textColor ]);
React.useEffect(() => {
if (!anchorEl) {
return;
}
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mouseout.axisX', () => {
d3.select(ref.current)
.selectAll('text')
.style('font-weight', 'normal');
})
.on('mousemove.axisX', (event) => {
const [ x ] = d3.pointer(event, anchorEl);
const xDate = scale.invert(x);
const textElements = d3.select(ref.current).selectAll('text');
const data = textElements.data();
const index = d3.bisector((d) => d).left(data, xDate);
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 }/>;
};
export default React.memo(ChartAxis);
import { useColorModeValue, useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'vertical' | 'horizontal';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean;
size: number;
ticks: number;
}
const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const strokeColorToken = useColorModeValue('blackAlpha.300', 'whiteAlpha.300');
const strokeColor = useToken('colors', strokeColorToken);
React.useEffect(() => {
if (!ref.current) {
return;
}
const axisGenerator = type === 'vertical' ? d3.axisBottom : d3.axisLeft;
const axis = axisGenerator(scale).ticks(ticks).tickSize(-size);
const gridGroup = d3.select(ref.current);
if (disableAnimation) {
gridGroup.call(axis);
} else {
gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
}
gridGroup.select('.domain').remove();
gridGroup.selectAll('text').remove();
gridGroup.selectAll('line').attr('stroke', strokeColor);
}, [ scale, ticks, size, disableAnimation, type, strokeColor ]);
return <g ref={ ref } { ...props }/>;
};
export default React.memo(ChartGridLine);
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);
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
interface Props 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: 'left' | 'fadeIn' | 'none';
}
const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
const ref = React.useRef<SVGPathElement>(null);
// Define different types of animation that we can use
const animateLeft = React.useCallback(() => {
const totalLength = ref.current?.getTotalLength() || 0;
d3.select(ref.current)
.attr('opacity', 1)
.attr('stroke-dasharray', `${ totalLength },${ totalLength }`)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(750)
.ease(d3.easeLinear)
.attr('stroke-dashoffset', 0);
}, []);
const animateFadeIn = React.useCallback(() => {
d3.select(ref.current)
.transition()
.duration(750)
.ease(d3.easeLinear)
.attr('opacity', 1);
}, []);
const noneAnimation = React.useCallback(() => {
d3.select(ref.current).attr('opacity', 1);
}, []);
React.useEffect(() => {
const ANIMATIONS = {
left: animateLeft,
fadeIn: animateFadeIn,
none: noneAnimation,
};
const animationFn = ANIMATIONS[animation];
window.setTimeout(animationFn, 100);
}, [ animateLeft, animateFadeIn, noneAnimation, animation ]);
// Recalculate line length if scale has changed
React.useEffect(() => {
if (animation === 'left') {
const totalLength = ref.current?.getTotalLength();
d3.select(ref.current).attr(
'stroke-dasharray',
`${ totalLength },${ totalLength }`,
);
}
}, [ xScale, yScale, animation ]);
const line = d3.line<TimeChartItem>()
.x((d) => xScale(d.date))
.y((d) => yScale(d.value));
return (
<path
ref={ ref }
d={ line(data) || undefined }
strokeWidth={ 1 }
fill="none"
opacity={ 0 }
{ ...props }
/>
);
};
export default React.memo(ChartLine);
import React from 'react';
interface Props {
width: number;
height: number;
children: React.ReactNode;
}
const ChartOverlay = ({ width, height, children }: Props, ref: React.ForwardedRef<SVGRectElement>) => {
return (
<g className="ChartOverlay">
{ children }
<rect ref={ ref } width={ width } height={ height } opacity={ 0 }/>
</g>
);
};
export default React.forwardRef(ChartOverlay);
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
const SELECTION_THRESHOLD = 1;
interface Props {
height: number;
anchorEl?: SVGRectElement | null;
scale: d3.ScaleTime<number, number>;
data: TimeChartData;
onSelect: (range: [number, number]) => void;
}
const ChartSelectionX = ({ anchorEl, height, scale, data, onSelect }: Props) => {
const borderColor = useToken('colors', 'blue.200');
const ref = React.useRef(null);
const isPressed = React.useRef(false);
const startX = React.useRef<number>();
const endX = React.useRef<number>();
const startIndex = React.useRef<number>(0);
const getIndexByX = React.useCallback((x: number) => {
const xDate = scale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
return bisectDate(data[0].items, xDate, 1);
}, [ data, scale ]);
const drawSelection = React.useCallback((x0: number, x1: number) => {
const diffX = x1 - x0;
d3.select(ref.current)
.attr('opacity', 1);
d3.select(ref.current)
.select('.ChartSelectionX__line_left')
.attr('x1', x0)
.attr('x2', x0);
d3.select(ref.current)
.select('.ChartSelectionX__line_right')
.attr('x1', x1)
.attr('x2', x1);
d3.select(ref.current)
.select('.ChartSelectionX__rect')
.attr('x', diffX > 0 ? x0 : diffX + x0)
.attr('width', Math.abs(diffX));
}, []);
const handelMouseUp = React.useCallback(() => {
isPressed.current = false;
startX.current = undefined;
d3.select(ref.current).attr('opacity', 0);
if (!endX.current) {
return;
}
const index = getIndexByX(endX.current);
if (Math.abs(index - startIndex.current) > SELECTION_THRESHOLD) {
onSelect([ Math.min(index, startIndex.current), Math.max(index, startIndex.current) ]);
}
}, [ getIndexByX, onSelect ]);
React.useEffect(() => {
if (!anchorEl) {
return;
}
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mousedown.selectionX', (event: MouseEvent) => {
const [ x ] = d3.pointer(event, anchorEl);
isPressed.current = true;
startX.current = x;
const index = getIndexByX(x);
startIndex.current = index;
})
.on('mouseup.selectionX', handelMouseUp)
.on('mousemove.selectionX', (event: MouseEvent) => {
if (isPressed.current) {
const [ x ] = d3.pointer(event, anchorEl);
startX.current && drawSelection(startX.current, x);
endX.current = x;
}
});
d3.select('body').on('mouseup.selectionX', function(event) {
const isOutside = startX.current !== undefined && event.target !== anchorD3.node();
if (isOutside) {
handelMouseUp();
}
});
return () => {
anchorD3.on('mousedown.selectionX mouseup.selectionX mousemove.selectionX', null);
d3.select('body').on('mouseup.selectionX', null);
};
}, [ anchorEl, drawSelection, getIndexByX, handelMouseUp ]);
return (
<g className="ChartSelectionX" ref={ ref } opacity={ 0 }>
<rect className="ChartSelectionX__rect" width={ 0 } height={ height } fill="rgba(66, 153, 225, 0.1)"/>
<line className="ChartSelectionX__line ChartSelectionX__line_left" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
<line className="ChartSelectionX__line ChartSelectionX__line_right" x1={ 0 } x2={ 0 } y1={ 0 } y2={ height } stroke={ borderColor }/>
</g>
);
};
export default React.memo(ChartSelectionX);
import { useToken, useColorModeValue } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem, ChartMargin, TimeChartData } from 'ui/shared/chart/types';
interface Props {
width?: number;
height?: number;
margin?: ChartMargin;
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
yScale: d3.ScaleLinear<number, number>;
anchorEl: SVGRectElement | null;
}
const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, anchorEl, ...props }: Props) => {
const margin = React.useMemo(() => ({
top: 0, bottom: 0, left: 0, right: 0,
..._margin,
}), [ _margin ]);
const lineColor = useToken('colors', 'red.500');
const textColor = useToken('colors', useColorModeValue('white', 'black'));
const bgColor = useToken('colors', useColorModeValue('gray.900', 'gray.400'));
const ref = React.useRef(null);
const isPressed = React.useRef(false);
const drawLine = React.useCallback(
(x: number) => {
d3.select(ref.current)
.select('.ChartTooltip__line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', 0)
.attr('y2', height || 0);
},
[ ref, height ],
);
const drawContent = React.useCallback(
(x: number) => {
const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content');
tooltipContent.attr('transform', (cur, i, nodes) => {
const OFFSET = 8;
const node = nodes[i] as SVGGElement | null;
const nodeWidth = node?.getBoundingClientRect()?.width || 0;
const translateX = nodeWidth + x + OFFSET > (width || 0) ? x - nodeWidth - OFFSET : x + OFFSET;
return `translate(${ translateX }, ${ margin.top + 30 })`;
});
tooltipContent
.select('.ChartTooltip__contentTitle')
.text(d3.timeFormat('%b %d, %Y')(xScale.invert(x)));
},
[ xScale, margin, width ],
);
const updateDisplayedValue = React.useCallback((d: TimeChartItem, i: number) => {
d3.selectAll('.ChartTooltip__value')
.filter((td, tIndex) => tIndex === i)
.text(d.value.toLocaleString());
}, []);
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;
d3.select(ref.current)
.selectAll('.ChartTooltip__linePoint')
.attr('transform', (cur, i) => {
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) {
// move point out of container
return 'translate(-100,-100)';
}
const xPos = xScale(d.date);
if (i === 0) {
baseXPos = xPos;
}
const yPos = yScale(d.value);
updateDisplayedValue(d, i);
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);
}, [ drawCircles, drawLine, drawContent ]);
React.useEffect(() => {
const anchorD3 = d3.select(anchorEl);
anchorD3
.on('mousedown.tooltip', () => {
isPressed.current = true;
d3.select(ref.current).attr('opacity', 0);
})
.on('mouseup.tooltip', () => {
isPressed.current = false;
})
.on('mouseout.tooltip', () => {
d3.select(ref.current).attr('opacity', 0);
})
.on('mouseover.tooltip', () => {
d3.select(ref.current).attr('opacity', 1);
})
.on('mousemove.tooltip', (event: MouseEvent) => {
if (!isPressed.current) {
d3.select(ref.current).attr('opacity', 1);
d3.select(ref.current)
.selectAll('.ChartTooltip__linePoint')
.attr('opacity', 1);
followPoints(event);
}
});
d3.select('body').on('mouseup.tooltip', function(event) {
const isOutside = event.target !== anchorD3.node();
if (isOutside) {
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={ data.length * 22 + 34 }/>
<text
className="ChartTooltip__contentTitle"
transform="translate(8,20)"
fontSize="12px"
fontWeight="bold"
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>
{ data.map(({ name, color }) => (
<circle key={ name } className="ChartTooltip__linePoint" r={ 4 } opacity={ 0 } fill={ color } stroke="#FFF" strokeWidth={ 1 }/>
)) }
</g>
);
};
export default React.memo(ChartTooltip);
export interface TimeChartItem {
date: Date;
value: number;
}
export interface ChartMargin {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface TimeChartDataItem {
items: Array<TimeChartItem>;
name: string;
color: string;
}
export type TimeChartData = Array<TimeChartDataItem>;
import { useToken, useColorModeValue } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
interface Props {
limits: [[number, number], [number, number]];
anchor: SVGSVGElement | null;
setRange: (range: [number, number]) => void;
}
export default function useBrushX({ limits, anchor, setRange }: Props) {
const brushRef = React.useRef<d3.BrushBehavior<unknown>>();
const brushSelectionBg = useToken('colors', useColorModeValue('blackAlpha.400', 'whiteAlpha.500'));
React.useEffect(() => {
if (!anchor || brushRef.current || limits[1][0] === 0) {
return;
}
const svgEl = d3.select(anchor).select('g');
brushRef.current = d3.brushX()
.extent(limits);
brushRef.current.on('end', (event) => {
setRange(event.selection);
});
const gBrush = svgEl?.append('g')
.attr('class', 'ChartBrush')
.call(brushRef.current);
gBrush.select('.selection')
.attr('stroke', 'none')
.attr('fill', brushSelectionBg);
}, [ anchor, brushSelectionBg, limits, setRange ]);
}
import _range from 'lodash/range';
import React from 'react';
export default function useChartLegend(dataLength: number) {
const [ selectedLines, setSelectedLines ] = React.useState<Array<number>>(_range(dataLength));
const handleLegendItemClick = React.useCallback((index: number) => {
const nextSelectedLines = selectedLines.includes(index) ? selectedLines.filter((item) => item !== index) : [ ...selectedLines, index ];
setSelectedLines(nextSelectedLines);
}, [ selectedLines ]);
return {
selectedLines,
handleLegendItemClick,
};
}
import _debounce from 'lodash/debounce';
import React from 'react';
import type { ChartMargin } from 'ui/shared/chart/types';
export default function useChartSize(svgEl: SVGSVGElement | null, margin?: ChartMargin) {
const [ rect, setRect ] = React.useState<{ width: number; height: number}>({ width: 0, height: 0 });
const calculateRect = React.useCallback(() => {
const rect = svgEl?.getBoundingClientRect();
return { width: rect?.width || 0, height: rect?.height || 0 };
}, [ svgEl ]);
React.useEffect(() => {
setRect(calculateRect());
}, [ calculateRect ]);
React.useEffect(() => {
let timeoutId: number;
const resizeHandler = _debounce(() => {
setRect({ width: 0, height: 0 });
timeoutId = window.setTimeout(() => {
setRect(calculateRect());
}, 0);
}, 100);
const resizeObserver = new ResizeObserver(resizeHandler);
resizeObserver.observe(document.body);
return function cleanup() {
resizeObserver.unobserve(document.body);
window.clearTimeout(timeoutId);
};
}, [ calculateRect ]);
return React.useMemo(() => {
return {
width: rect.width,
height: rect.height,
innerWidth: Math.max(rect.width - (margin?.left || 0) - (margin?.right || 0), 0),
innerHeight: Math.max(rect.height - (margin?.bottom || 0) - (margin?.top || 0), 0),
};
}, [ margin?.bottom, margin?.left, margin?.right, margin?.top, rect ]);
}
import * as d3 from 'd3';
import { useMemo } from 'react';
import type { TimeChartData } from 'ui/shared/chart/types';
interface Props {
data: TimeChartData;
width: number;
height: number;
}
export default function useTimeChartController({ data, width, height }: Props) {
const xMin = useMemo(
() => d3.min(data, ({ items }) => d3.min(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ date }) => date)) || new Date(),
[ data ],
);
const xScale = useMemo(
() => d3.scaleTime().domain([ xMin, xMax ]).range([ 0, width ]),
[ xMin, xMax, width ],
);
const yMin = useMemo(
() => d3.min(data, ({ items }) => d3.min(items, ({ value }) => value)) || 0,
[ data ],
);
const yMax = useMemo(
() => d3.max(data, ({ items }) => d3.max(items, ({ value }) => value)) || 0,
[ data ],
);
const yScale = useMemo(() => {
const indention = (yMax - yMin) * 0.3;
return d3.scaleLinear()
.domain([ yMin >= 0 && yMin - indention <= 0 ? 0 : yMin - indention, yMax + indention ])
.range([ height, 0 ]);
}, [ height, yMin, yMax ]);
const yScaleForAxis = useMemo(
() => d3.scaleBand().domain([ String(yMin), String(yMax) ]).range([ height, 0 ]),
[ height, yMin, yMax ],
);
const xTickFormat = (d: d3.AxisDomain) => d.toLocaleString();
const yTickFormat = (d: d3.AxisDomain) => d.toLocaleString();
return {
xTickFormat,
yTickFormat,
xScale,
yScale,
yScaleForAxis,
};
}
This diff is collapsed.
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