Commit 17479294 authored by tom's avatar tom

select to zoom

parent 1dcc7fb6
import { useToken } from '@chakra-ui/react';
import { useToken, Button, Box } from '@chakra-ui/react';
import React from 'react';
import type { ChartMargin } from 'ui/shared/chart/types';
import json 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 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 useChartSize from 'ui/shared/chart/useChartSize';
import useTimeChartController from 'ui/shared/chart/useTimeChartController';
interface Props {
margin?: ChartMargin;
}
const CHART_MARGIN = { bottom: 20, left: 65, right: 30, top: 10 };
const EthereumDailyTxsChart = ({ margin }: Props) => {
const EthereumDailyTxsChart = () => {
const ref = React.useRef<SVGSVGElement>(null);
const overlayRef = React.useRef<SVGRectElement>(null);
const { width, height, innerWidth, innerHeight } = useChartSize(ref.current, margin);
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 ]);
const range = useBrushX({ anchor: ref.current, limits: brushLimits });
useBrushX({ anchor: ref.current, limits: brushLimits, setRange });
const data = {
items: json.slice(range[0], range[1]).map((d) => ({ ...d, date: new Date(d.date) })),
......@@ -36,79 +34,108 @@ const EthereumDailyTxsChart = ({ margin }: Props) => {
const lineColor = useToken('colors', 'blue.500');
return (
<svg width={ width || '100%' } height={ height || '100%' } ref={ ref }>
<g transform={ `translate(${ margin?.left || 0 },${ margin?.top || 0 })` } opacity={ width ? 1 : 0 }>
{ /* BASE GRID LINE */ }
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 1 }
size={ innerWidth }
disableAnimation
/>
const handleRangeSelect = React.useCallback((nextRange: [number, number]) => {
setRange([ range[0] + nextRange[0], range[0] + nextRange[1] ]);
}, [ range ]);
{ /* 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
/>
const handleZoomReset = React.useCallback(() => {
setRange([ 0, Infinity ]);
}, [ ]);
{ /* GRAPH */ }
<ChartLine
data={ data.items }
xScale={ xScale }
yScale={ yScale }
stroke={ lineColor }
animation="left"
/>
<ChartArea
data={ data.items }
color={ lineColor }
xScale={ xScale }
yScale={ yScale }
/>
return (
<Box display="inline" position="relative">
<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
/>
{ /* AXISES */ }
<ChartAxis
type="left"
scale={ yScale }
ticks={ 5 }
tickFormat={ yTickFormat }
disableAnimation
/>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartAxis
type="bottom"
{ /* GIRD LINES */ }
<ChartGridLine
type="vertical"
scale={ xScale }
ticks={ 5 }
size={ innerHeight }
transform={ `translate(0, ${ innerHeight })` }
disableAnimation
/>
<ChartGridLine
type="horizontal"
scale={ yScale }
ticks={ 5 }
anchorEl={ overlayRef.current }
size={ innerWidth }
disableAnimation
/>
<ChartTooltip
anchorEl={ overlayRef.current }
width={ innerWidth }
height={ innerHeight }
margin={ margin }
{ /* GRAPH */ }
<ChartLine
data={ data.items }
xScale={ xScale }
yScale={ yScale }
data={ data }
stroke={ lineColor }
animation="left"
/>
<ChartArea
data={ data.items }
color={ lineColor }
xScale={ xScale }
yScale={ yScale }
/>
{ /* AXISES */ }
<ChartAxis
type="left"
scale={ yScale }
ticks={ 5 }
tickFormat={ yTickFormat }
disableAnimation
/>
</ChartOverlay>
</g>
</svg>
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
<ChartAxis
type="bottom"
scale={ xScale }
transform={ `translate(0, ${ innerHeight })` }
ticks={ 5 }
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 }
/>
</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>
) }
</Box>
);
};
......
......@@ -5,14 +5,12 @@ import EthereumDailyTxsChart from 'ui/charts/EthereumDailyTxsChart';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
const CHART_MARGIN = { bottom: 20, left: 65 };
const Graph = () => {
return (
<Page>
<PageTitle text="Ethereum Daily Transactions Chart"/>
<Box w="100%" h="400px">
<EthereumDailyTxsChart margin={ CHART_MARGIN }/>
<EthereumDailyTxsChart/>
</Box>
</Page>
);
......
......@@ -35,6 +35,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormat, disableAnimation, anchorEl,
axisGroup.select('.domain').remove();
axisGroup.selectAll('line').remove();
axisGroup.selectAll('text')
.attr('user-select', 'none')
.attr('opacity', 1)
.attr('color', textColor)
.attr('font-size', '0.75rem');
......
......@@ -6,7 +6,7 @@ interface Props {
children: React.ReactNode;
}
const ChartOverlay = ({ width, height, children }: Props, ref: React.LegacyRef<SVGRectElement>) => {
const ChartOverlay = ({ width, height, children }: Props, ref: React.ForwardedRef<SVGRectElement>) => {
return (
<g className="ChartOverlay">
{ children }
......
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types';
const SELECTION_THRESHOLD = 1;
interface Props {
height: number;
anchorEl?: SVGRectElement | null;
scale: d3.ScaleTime<number, number>;
data: {
items: Array<TimeChartItem>;
};
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.items, xDate, 1);
}, [ data.items, 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();
}
});
}, [ 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);
......@@ -27,6 +27,7 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
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) => {
......@@ -34,10 +35,10 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
.select('.ChartTooltip__line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', -margin.top)
.attr('y1', 0)
.attr('y2', height || 0);
},
[ ref, height, margin ],
[ ref, height ],
);
const drawContent = React.useCallback(
......@@ -49,11 +50,12 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
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 })`;
return `translate(${ translateX }, ${ margin.top + 30 })`;
});
tooltipContent
.select('.ChartTooltip__contentTitle')
.attr('user-select', 'none')
.text(d3.timeFormat('%b %d, %Y')(xScale.invert(x)));
},
[ xScale, margin, width ],
......@@ -61,6 +63,7 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
const onChangePosition = React.useCallback((d: TimeChartItem, isVisible: boolean) => {
d3.select('.ChartTooltip__value')
.attr('user-select', 'none')
.text(isVisible ? d.value.toLocaleString() : '');
}, []);
......@@ -115,7 +118,16 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
]);
React.useEffect(() => {
d3.select(anchorEl)
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);
})
......@@ -123,11 +135,21 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
d3.select(ref.current).attr('opacity', 1);
})
.on('mousemove.tooltip', (event: MouseEvent) => {
d3.select(ref.current)
.select('.ChartTooltip__linePoint')
.attr('opacity', 1);
followPoints(event);
if (!isPressed.current) {
d3.select(ref.current).attr('opacity', 1);
d3.select(ref.current)
.select('.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;
}
});
}, [ anchorEl, followPoints ]);
return (
......@@ -135,12 +157,20 @@ const ChartTooltip = ({ xScale, yScale, width, height, data, margin: _margin, an
<line className="ChartTooltip__line" stroke={ lineColor }/>
<g className="ChartTooltip__content">
<rect className="ChartTooltip__contentBg" rx={ 8 } ry={ 8 } fill={ bgColor } width={ 125 } height={ 52 }/>
<text className="ChartTooltip__contentTitle" transform="translate(8,20)" fontSize="12px" fontWeight="bold" fill={ textColor }/>
<text
className="ChartTooltip__contentTitle"
transform="translate(8,20)"
fontSize="12px"
fontWeight="bold"
fill={ textColor }
pointerEvents="none"
/>
<text
transform="translate(8,40)"
className="ChartTooltip__value"
fontSize="12px"
fill={ textColor }
pointerEvents="none"
/>
</g>
<circle className="ChartTooltip__linePoint" r={ 3 } opacity={ 0 } fill={ lineColor }/>
......
......@@ -5,11 +5,11 @@ 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 }: Props) {
export default function useBrushX({ limits, anchor, setRange }: Props) {
const brushRef = React.useRef<d3.BrushBehavior<unknown>>();
const [ range, setRange ] = React.useState<[number, number]>([ 0, Infinity ]);
const brushSelectionBg = useToken('colors', useColorModeValue('blackAlpha.400', 'whiteAlpha.500'));
React.useEffect(() => {
......@@ -32,7 +32,5 @@ export default function useBrushX({ limits, anchor }: Props) {
.attr('stroke', 'none')
.attr('fill', brushSelectionBg);
}, [ anchor, brushSelectionBg, limits ]);
return range;
}, [ anchor, brushSelectionBg, limits, setRange ]);
}
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