Commit 7ecea8b1 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Stats: give emphasis to incomplete data days (#2085)

* draw dotted line for incomplete data

* add title to tooltip

* refactor parts into separate components

* move utils

* refactor render logic of tooltip

* more refactoring

* fix issue in safari

* tweaks

* test

* make an approximation as a straight line.

* update screenshots

* fix the tooltip hiding behavior.

* add prop for disabling animation
parent 0ccd05a8
...@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse }) ...@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
await page.waitForFunction(() => { await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1'; return document.querySelector('path[data-name="chart-Balances-small"]')?.getAttribute('opacity') === '1';
}); });
await page.mouse.move(100, 100);
await page.mouse.move(240, 100); await page.mouse.move(240, 100);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
...@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render }) ...@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render })
test('no data', async({ mockApiResponse, mockAssetResponse, render }) => { test('no data', async({ mockApiResponse, mockAssetResponse, render }) => {
await mockApiResponse('stats', statsMock.noChartData); await mockApiResponse('stats', statsMock.noChartData);
await mockApiResponse('stats_charts_txs', dailyTxsMock.noData); await mockApiResponse('stats_charts_txs', dailyTxsMock.noData);
await mockAssetResponse(statsMock.base.coin_image as string, './playwright/mocks/image_s.jpg'); await mockAssetResponse(statsMock.noChartData.coin_image as string, './playwright/mocks/image_s.jpg');
const component = await render(<ChainIndicators/>); const component = await render(<ChainIndicators/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
......
...@@ -11,10 +11,10 @@ interface Props extends React.SVGProps<SVGPathElement> { ...@@ -11,10 +11,10 @@ interface Props extends React.SVGProps<SVGPathElement> {
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
color?: string; color?: string;
data: Array<TimeChartItem>; data: Array<TimeChartItem>;
disableAnimation?: boolean; noAnimation?: boolean;
} }
const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props }: Props) => { const ChartArea = ({ id, xScale, yScale, color, data, noAnimation, ...props }: Props) => {
const ref = React.useRef(null); const ref = React.useRef(null);
const theme = useTheme(); const theme = useTheme();
...@@ -26,7 +26,7 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props ...@@ -26,7 +26,7 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props
}; };
React.useEffect(() => { React.useEffect(() => {
if (disableAnimation) { if (noAnimation) {
d3.select(ref.current).attr('opacity', 1); d3.select(ref.current).attr('opacity', 1);
return; return;
} }
...@@ -34,10 +34,11 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props ...@@ -34,10 +34,11 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props
.duration(750) .duration(750)
.ease(d3.easeBackIn) .ease(d3.easeBackIn)
.attr('opacity', 1); .attr('opacity', 1);
}, [ disableAnimation ]); }, [ noAnimation ]);
const d = React.useMemo(() => { const d = React.useMemo(() => {
const area = d3.area<TimeChartItem>() const area = d3.area<TimeChartItem>()
.defined(({ isApproximate }) => !isApproximate)
.x(({ date }) => xScale(date)) .x(({ date }) => xScale(date))
.y1(({ value }) => yScale(value)) .y1(({ value }) => yScale(value))
.y0(() => yScale(yScale.domain()[0])) .y0(() => yScale(yScale.domain()[0]))
......
...@@ -5,13 +5,13 @@ import React from 'react'; ...@@ -5,13 +5,13 @@ import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> { interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'left' | 'bottom'; type: 'left' | 'bottom';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean; noAnimation?: boolean;
ticks: number; ticks: number;
tickFormatGenerator?: (axis: d3.Axis<d3.NumberValue>) => (domainValue: d3.AxisDomain, index: number) => string; tickFormatGenerator?: (axis: d3.Axis<d3.NumberValue>) => (domainValue: d3.AxisDomain, index: number) => string;
anchorEl?: SVGRectElement | null; anchorEl?: SVGRectElement | null;
} }
const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, anchorEl, ...props }: Props) => { const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, noAnimation, anchorEl, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null); const ref = React.useRef<SVGGElement>(null);
const textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500'); const textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500');
...@@ -31,7 +31,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, ...@@ -31,7 +31,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation,
const axisGroup = d3.select(ref.current); const axisGroup = d3.select(ref.current);
if (disableAnimation) { if (noAnimation) {
axisGroup.call(axis); axisGroup.call(axis);
} else { } else {
axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis); axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
...@@ -42,7 +42,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation, ...@@ -42,7 +42,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation,
.attr('opacity', 1) .attr('opacity', 1)
.attr('color', textColor) .attr('color', textColor)
.attr('font-size', '0.75rem'); .attr('font-size', '0.75rem');
}, [ scale, ticks, tickFormatGenerator, disableAnimation, type, textColor ]); }, [ scale, ticks, tickFormatGenerator, noAnimation, type, textColor ]);
React.useEffect(() => { React.useEffect(() => {
if (!anchorEl) { if (!anchorEl) {
......
...@@ -5,12 +5,12 @@ import React from 'react'; ...@@ -5,12 +5,12 @@ import React from 'react';
interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> { interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
type: 'vertical' | 'horizontal'; type: 'vertical' | 'horizontal';
scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; scale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
disableAnimation?: boolean; noAnimation?: boolean;
size: number; size: number;
ticks: number; ticks: number;
} }
const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => { const ChartGridLine = ({ type, scale, ticks, size, noAnimation, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null); const ref = React.useRef<SVGGElement>(null);
const strokeColor = useToken('colors', 'divider'); const strokeColor = useToken('colors', 'divider');
...@@ -24,7 +24,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: ...@@ -24,7 +24,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }:
const axis = axisGenerator(scale).ticks(ticks).tickSize(-size); const axis = axisGenerator(scale).ticks(ticks).tickSize(-size);
const gridGroup = d3.select(ref.current); const gridGroup = d3.select(ref.current);
if (disableAnimation) { if (noAnimation) {
gridGroup.call(axis); gridGroup.call(axis);
} else { } else {
gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis); gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
...@@ -32,7 +32,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: ...@@ -32,7 +32,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }:
gridGroup.select('.domain').remove(); gridGroup.select('.domain').remove();
gridGroup.selectAll('text').remove(); gridGroup.selectAll('text').remove();
gridGroup.selectAll('line').attr('stroke', strokeColor); gridGroup.selectAll('line').attr('stroke', strokeColor);
}, [ scale, ticks, size, disableAnimation, type, strokeColor ]); }, [ scale, ticks, size, noAnimation, type, strokeColor ]);
return <g ref={ ref } { ...props }/>; return <g ref={ ref } { ...props }/>;
}; };
......
...@@ -3,56 +3,38 @@ import React from 'react'; ...@@ -3,56 +3,38 @@ import React from 'react';
import type { TimeChartItem } from 'ui/shared/chart/types'; import type { TimeChartItem } from 'ui/shared/chart/types';
import type { AnimationType } from './utils/animations';
import { ANIMATIONS } from './utils/animations';
import { getIncompleteDataLineSource } from './utils/formatters';
interface Props extends React.SVGProps<SVGPathElement> { interface Props extends React.SVGProps<SVGPathElement> {
xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; xScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>; yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
data: Array<TimeChartItem>; data: Array<TimeChartItem>;
animation: 'left' | 'fadeIn' | 'none'; animation: AnimationType;
} }
const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => { const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
const ref = React.useRef<SVGPathElement>(null); const dataPathRef = React.useRef<SVGPathElement>(null);
const incompleteDataPathRef = 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(() => { React.useEffect(() => {
const ANIMATIONS = {
left: animateLeft,
fadeIn: animateFadeIn,
none: noneAnimation,
};
const animationFn = ANIMATIONS[animation]; const animationFn = ANIMATIONS[animation];
window.setTimeout(animationFn, 100); const timeoutId = window.setTimeout(() => {
}, [ animateLeft, animateFadeIn, noneAnimation, animation ]); dataPathRef.current && animationFn(dataPathRef.current);
incompleteDataPathRef.current && animationFn(incompleteDataPathRef.current);
}, 100);
return () => {
window.clearTimeout(timeoutId);
};
}, [ animation ]);
// Recalculate line length if scale has changed // Recalculate line length if scale has changed
React.useEffect(() => { React.useEffect(() => {
if (animation === 'left') { if (animation === 'left') {
const totalLength = ref.current?.getTotalLength(); const totalLength = dataPathRef.current?.getTotalLength();
d3.select(ref.current).attr( d3.select(dataPathRef.current).attr(
'stroke-dasharray', 'stroke-dasharray',
`${ totalLength },${ totalLength }`, `${ totalLength },${ totalLength }`,
); );
...@@ -65,15 +47,27 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => { ...@@ -65,15 +47,27 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
.curve(d3.curveMonotoneX); .curve(d3.curveMonotoneX);
return ( return (
<path <>
ref={ ref } <path
d={ line(data) || undefined } ref={ incompleteDataPathRef }
strokeWidth={ 1 } d={ line(getIncompleteDataLineSource(data)) || undefined }
strokeLinecap="round" strokeWidth={ 1 }
fill="none" strokeLinecap="round"
opacity={ 0 } fill="none"
{ ...props } strokeDasharray="6 6"
/> opacity={ 0 }
{ ...props }
/>
<path
ref={ dataPathRef }
d={ line(data.filter(({ isApproximate }) => !isApproximate)) || undefined }
strokeWidth={ 1 }
strokeLinecap="round"
fill="none"
opacity={ 0 }
{ ...props }
/>
</>
); );
}; };
......
This diff is collapsed.
import React from 'react'; import React from 'react';
import type { TimeChartItem } from './types';
import { test, expect } from 'playwright/lib'; import { test, expect } from 'playwright/lib';
import type { Props } from './ChartWidget'; import type { Props } from './ChartWidget';
...@@ -26,6 +28,7 @@ const props: Props = { ...@@ -26,6 +28,7 @@ const props: Props = {
units: 'ETH', units: 'ETH',
isLoading: false, isLoading: false,
isError: false, isError: false,
noAnimation: true,
}; };
test('base view +@dark-mode', async({ render, page }) => { test('base view +@dark-mode', async({ render, page }) => {
...@@ -41,6 +44,7 @@ test('base view +@dark-mode', async({ render, page }) => { ...@@ -41,6 +44,7 @@ test('base view +@dark-mode', async({ render, page }) => {
await page.mouse.move(0, 0); await page.mouse.move(0, 0);
await page.mouse.click(0, 0); await page.mouse.click(0, 0);
await page.mouse.move(80, 150);
await page.mouse.move(100, 150); await page.mouse.move(100, 150);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
...@@ -109,3 +113,24 @@ test('small variations in big values', async({ render, page }) => { ...@@ -109,3 +113,24 @@ test('small variations in big values', async({ render, page }) => {
}); });
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
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 },
],
};
const component = await render(<ChartWidget { ...modifiedProps }/>);
await page.waitForFunction(() => {
return document.querySelector('path[data-name="chart-Nativecoincirculatingsupply-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(page.getByText('Incomplete day')).toBeVisible();
await expect(component).toHaveScreenshot();
});
...@@ -36,11 +36,12 @@ export type Props = { ...@@ -36,11 +36,12 @@ export type Props = {
className?: string; className?: string;
isError: boolean; isError: boolean;
emptyText?: string; emptyText?: string;
noAnimation?: boolean;
} }
const DOWNLOAD_IMAGE_SCALE = 5; const DOWNLOAD_IMAGE_SCALE = 5;
const ChartWidget = ({ items, title, description, isLoading, className, isError, units, emptyText }: Props) => { const ChartWidget = ({ items, title, description, isLoading, className, isError, units, emptyText, noAnimation }: Props) => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [ isFullscreen, setIsFullscreen ] = useState(false); const [ isFullscreen, setIsFullscreen ] = useState(false);
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true); const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
...@@ -148,6 +149,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError, ...@@ -148,6 +149,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError,
isZoomResetInitial={ isZoomResetInitial } isZoomResetInitial={ isZoomResetInitial }
title={ title } title={ title }
units={ units } units={ units }
noAnimation={ noAnimation }
/> />
</Box> </Box>
); );
......
...@@ -23,13 +23,14 @@ interface Props { ...@@ -23,13 +23,14 @@ interface Props {
onZoom: () => void; onZoom: () => void;
isZoomResetInitial: boolean; isZoomResetInitial: boolean;
margin?: ChartMargin; margin?: ChartMargin;
noAnimation?: boolean;
} }
// temporarily turn off the data aggregation, we need a better algorithm for that // temporarily turn off the data aggregation, we need a better algorithm for that
const MAX_SHOW_ITEMS = 100_000_000_000; const MAX_SHOW_ITEMS = 100_000_000_000;
const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 }; const DEFAULT_CHART_MARGIN = { bottom: 20, left: 10, right: 20, top: 10 };
const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units }: Props) => { const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title, margin: marginProps, units, noAnimation }: Props) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const color = useToken('colors', 'blue.200'); const color = useToken('colors', 'blue.200');
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`; const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
...@@ -99,7 +100,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -99,7 +100,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
scale={ axes.y.scale } scale={ axes.y.scale }
ticks={ axesConfig.y.ticks } ticks={ axesConfig.y.ticks }
size={ innerWidth } size={ innerWidth }
disableAnimation noAnimation
/> />
<ChartArea <ChartArea
...@@ -108,6 +109,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -108,6 +109,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
color={ color } color={ color }
xScale={ axes.x.scale } xScale={ axes.x.scale }
yScale={ axes.y.scale } yScale={ axes.y.scale }
noAnimation={ noAnimation }
/> />
<ChartLine <ChartLine
...@@ -124,7 +126,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -124,7 +126,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
scale={ axes.y.scale } scale={ axes.y.scale }
ticks={ axesConfig.y.ticks } ticks={ axesConfig.y.ticks }
tickFormatGenerator={ axes.y.tickFormatter } tickFormatGenerator={ axes.y.tickFormatter }
disableAnimation noAnimation
/> />
<ChartAxis <ChartAxis
...@@ -134,7 +136,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -134,7 +136,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
ticks={ axesConfig.x.ticks } ticks={ axesConfig.x.ticks }
anchorEl={ overlayRef.current } anchorEl={ overlayRef.current }
tickFormatGenerator={ axes.x.tickFormatter } tickFormatGenerator={ axes.x.tickFormatter }
disableAnimation noAnimation
/> />
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }> <ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
...@@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title ...@@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
xScale={ axes.x.scale } xScale={ axes.x.scale }
yScale={ axes.y.scale } yScale={ axes.y.scale }
data={ chartData } data={ chartData }
noAnimation={ noAnimation }
/> />
<ChartSelectionX <ChartSelectionX
......
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import { calculateContainerHeight } from './utils';
const ChartTooltipBackdrop = () => {
const bgColor = useToken('colors', 'blackAlpha.900');
return (
<rect
className="ChartTooltip__backdrop"
rx={ 12 }
ry={ 12 }
fill={ bgColor }
/>
);
};
export default React.memo(ChartTooltipBackdrop);
interface UseRenderBackdropParams {
seriesNum: number;
transitionDuration: number | null;
}
export function useRenderBackdrop(ref: React.RefObject<SVGGElement>, { seriesNum, transitionDuration }: UseRenderBackdropParams) {
return React.useCallback((width: number, isIncompleteData: boolean) => {
const height = calculateContainerHeight(seriesNum, isIncompleteData);
if (transitionDuration) {
d3.select(ref.current)
.select('.ChartTooltip__backdrop')
.transition()
.duration(transitionDuration)
.ease(d3.easeLinear)
.attr('width', width)
.attr('height', height);
} else {
d3.select(ref.current)
.select('.ChartTooltip__backdrop')
.attr('width', width)
.attr('height', height);
}
}, [ ref, seriesNum, transitionDuration ]);
}
import * as d3 from 'd3';
import _clamp from 'lodash/clamp'; import _clamp from 'lodash/clamp';
import React from 'react';
interface Params { import { POINT_SIZE } from './utils';
interface Props {
children: React.ReactNode;
}
const ChartTooltipContent = ({ children }: Props) => {
return <g className="ChartTooltip__content">{ children }</g>;
};
export default React.memo(ChartTooltipContent);
interface UseRenderContentParams {
chart: {
width?: number;
height?: number;
};
transitionDuration: number | null;
}
export function useRenderContent(ref: React.RefObject<SVGGElement>, { chart, transitionDuration }: UseRenderContentParams) {
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 node = nodes[i] as SVGGElement | null;
const { width: nodeWidth, height: nodeHeight } = node?.getBoundingClientRect() || { width: 0, height: 0 };
const [ translateX, translateY ] = calculatePosition({
canvasWidth: chart.width || 0,
canvasHeight: chart.height || 0,
nodeWidth,
nodeHeight,
pointX: x,
pointY: y,
offset: POINT_SIZE,
});
return `translate(${ translateX }, ${ translateY })`;
};
if (transitionDuration) {
tooltipContent
.transition()
.duration(transitionDuration)
.ease(d3.easeLinear)
.attr('transform', transformAttributeFn);
} else {
tooltipContent
.attr('transform', transformAttributeFn);
}
}, [ chart.height, chart.width, ref, transitionDuration ]);
}
interface CalculatePositionParams {
pointX: number; pointX: number;
pointY: number; pointY: number;
offset: number; offset: number;
...@@ -10,7 +65,7 @@ interface Params { ...@@ -10,7 +65,7 @@ interface Params {
canvasHeight: number; canvasHeight: number;
} }
export default function computeTooltipPosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: Params): [ number, number ] { function calculatePosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: CalculatePositionParams): [ number, number ] {
// right // right
if (pointX + offset + nodeWidth <= canvasWidth) { if (pointX + offset + nodeWidth <= canvasWidth) {
const x = pointX + offset; const x = pointX + offset;
......
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
const ChartTooltipLine = () => {
const lineColor = useToken('colors', 'gray.400');
return <line className="ChartTooltip__line" stroke={ lineColor } strokeDasharray="3"/>;
};
export default React.memo(ChartTooltipLine);
export function useRenderLine(ref: React.RefObject<SVGGElement>, chartHeight: number | undefined) {
return React.useCallback((x: number) => {
d3.select(ref.current)
.select('.ChartTooltip__line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', 0)
.attr('y2', chartHeight || 0);
}, [ ref, chartHeight ]);
}
import { useColorModeValue, useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartData, TimeChartItem } from 'ui/shared/chart/types';
import { POINT_SIZE } from './utils';
const ChartTooltipPoint = () => {
const bgColor = useToken('colors', useColorModeValue('black', 'white'));
const borderColor = useToken('colors', useColorModeValue('white', 'black'));
return (
<circle
className="ChartTooltip__point"
r={ POINT_SIZE / 2 }
opacity={ 1 }
fill={ bgColor }
stroke={ borderColor }
strokeWidth={ 4 }
/>
);
};
export default React.memo(ChartTooltipPoint);
interface UseRenderPointsParams {
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
yScale: d3.ScaleLinear<number, number>;
}
export interface CurrentPoint {
datumIndex: number;
item: TimeChartItem;
}
interface RenderPointsReturnType{
x: number;
y: number;
currentPoints: Array<CurrentPoint>;
}
export function useRenderPoints(ref: React.RefObject<SVGGElement>, params: UseRenderPointsParams) {
return React.useCallback((x: number): RenderPointsReturnType => {
const xDate = params.xScale.invert(x);
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
let baseXPos = 0;
let baseYPos = 0;
const currentPoints: Array<CurrentPoint> = [];
d3.select(ref.current)
.selectAll('.ChartTooltip__point')
.attr('transform', (cur, elementIndex) => {
const datum = params.data[elementIndex];
const index = bisectDate(datum.items, xDate, 1);
const d0 = datum.items[index - 1] as TimeChartItem | undefined;
const d1 = datum.items[index] as TimeChartItem | undefined;
const d = (() => {
if (!d0) {
return d1;
}
if (!d1) {
return d0;
}
return 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 = params.xScale(d.date);
const yPos = params.yScale(d.value);
if (elementIndex === 0) {
baseXPos = xPos;
baseYPos = yPos;
}
currentPoints.push({ item: d, datumIndex: elementIndex });
return `translate(${ xPos }, ${ yPos })`;
});
return {
x: baseXPos,
y: baseYPos,
currentPoints,
};
}, [ ref, params ]);
}
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import type { TimeChartData } from '../types';
import type { CurrentPoint } from './ChartTooltipPoint';
import { calculateRowTransformValue, LABEL_WIDTH, PADDING } from './utils';
type Props = {
lineNum: number;
} & ({ label: string; children?: never } | { children: React.ReactNode; label?: never })
const ChartTooltipRow = ({ label, lineNum, children }: Props) => {
const labelColor = useToken('colors', 'blue.100');
const textColor = useToken('colors', 'white');
return (
<g className="ChartTooltip__row" transform={ calculateRowTransformValue(lineNum) }>
{ children || (
<>
<text
className="ChartTooltip__label"
transform="translate(0,0)"
dominantBaseline="hanging"
fill={ labelColor }
>
{ label }
</text>
<text
className="ChartTooltip__value"
transform={ `translate(${ LABEL_WIDTH },0)` }
dominantBaseline="hanging"
fill={ textColor }
/>
</>
) }
</g>
);
};
export default React.memo(ChartTooltipRow);
interface UseRenderRowsParams {
data: TimeChartData;
xScale: d3.ScaleTime<number, number>;
minWidth: number;
}
interface UseRenderRowsReturnType {
width: number;
}
export function useRenderRows(ref: React.RefObject<SVGGElement>, { data, xScale, minWidth }: UseRenderRowsParams) {
return React.useCallback((x: number, currentPoints: Array<CurrentPoint>): UseRenderRowsReturnType => {
// update "transform" prop of all rows
const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate);
d3.select(ref.current)
.selectAll<Element, TimeChartData>('.ChartTooltip__row')
.attr('transform', (datum, index) => {
return calculateRowTransformValue(index - (isIncompleteData ? 0 : 1));
});
// update date and indicators value
// here we assume that the first value element contains the date
const valueNodes = d3.select(ref.current)
.selectAll<Element, TimeChartData>('.ChartTooltip__value')
.text((_, index) => {
if (index === 0) {
const date = xScale.invert(x);
const dateValue = data[0].items.find((item) => item.date.getTime() === date.getTime())?.dateLabel;
const dateValueFallback = d3.timeFormat('%e %b %Y')(xScale.invert(x));
return dateValue || dateValueFallback;
}
const { datumIndex, item } = currentPoints.find(({ datumIndex }) => datumIndex === index - 1) || {};
if (datumIndex === undefined || !item) {
return null;
}
const value = data[datumIndex]?.valueFormatter?.(item.value) ?? item.value.toLocaleString(undefined, { minimumSignificantDigits: 1 });
const units = data[datumIndex]?.units ? ` ${ data[datumIndex]?.units }` : '';
return value + units;
})
.nodes();
const valueWidths = valueNodes.map((node) => node?.getBoundingClientRect?.().width);
const maxValueWidth = Math.max(...valueWidths);
const maxRowWidth = Math.max(minWidth, 2 * PADDING + LABEL_WIDTH + maxValueWidth);
return { width: maxRowWidth };
}, [ data, minWidth, ref, xScale ]);
}
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
import ChartTooltipRow from './ChartTooltipRow';
const ChartTooltipTitle = () => {
const titleColor = useToken('colors', 'yellow.300');
return (
<ChartTooltipRow lineNum={ 0 }>
<text
className="ChartTooltip__title"
transform="translate(0,0)"
fill={ titleColor }
opacity={ 0 }
dominantBaseline="hanging"
>
Incomplete day
</text>
</ChartTooltipRow>
);
};
export default React.memo(ChartTooltipTitle);
export function useRenderTitle(ref: React.RefObject<SVGGElement>) {
return React.useCallback((isVisible: boolean) => {
d3.select(ref.current)
.select('.ChartTooltip__title')
.attr('opacity', isVisible ? 1 : 0);
}, [ ref ]);
}
export const TEXT_LINE_HEIGHT = 12;
export const PADDING = 16;
export const LINE_SPACE = 10;
export const POINT_SIZE = 16;
export const LABEL_WIDTH = 80;
export const calculateContainerHeight = (seriesNum: number, isIncomplete?: boolean) => {
const linesNum = isIncomplete ? seriesNum + 2 : seriesNum + 1;
return 2 * PADDING + linesNum * TEXT_LINE_HEIGHT + (linesNum - 1) * LINE_SPACE;
};
export const calculateRowTransformValue = (rowNum: number) => {
const top = Math.max(0, PADDING + rowNum * (LINE_SPACE + TEXT_LINE_HEIGHT));
return `translate(${ PADDING },${ top })`;
};
...@@ -8,6 +8,7 @@ export interface TimeChartItem { ...@@ -8,6 +8,7 @@ export interface TimeChartItem {
date: Date; date: Date;
dateLabel?: string; dateLabel?: string;
value: number; value: number;
isApproximate?: boolean;
} }
export interface ChartMargin { export interface ChartMargin {
......
import * as d3 from 'd3';
export type AnimationType = 'left' | 'fadeIn' | 'none';
export const animateLeft = (path: SVGPathElement) => {
const totalLength = path.getTotalLength() || 0;
d3.select(path)
.attr('opacity', 1)
.attr('stroke-dasharray', `${ totalLength },${ totalLength }`)
.attr('stroke-dashoffset', totalLength)
.transition()
.duration(750)
.ease(d3.easeLinear)
.attr('stroke-dashoffset', 0);
};
export const animateFadeIn = (path: SVGPathElement) => {
d3.select(path)
.transition()
.duration(750)
.ease(d3.easeLinear)
.attr('opacity', 1);
};
export const noneAnimation = (path: SVGPathElement) => {
d3.select(path).attr('opacity', 1);
};
export const ANIMATIONS: Record<AnimationType, (path: SVGPathElement) => void> = {
left: animateLeft,
fadeIn: animateFadeIn,
none: noneAnimation,
};
import type { TimeChartItem } from '../types';
export const getIncompleteDataLineSource = (data: Array<TimeChartItem>): Array<TimeChartItem> => {
const result: Array<TimeChartItem> = [];
for (let index = 0; index < data.length; index++) {
const current = data[index];
if (current.isApproximate) {
const prev = data[index - 1];
const next = data[index + 1];
prev && !prev.isApproximate && result.push(prev);
result.push(current);
next && !next.isApproximate && result.push(next);
}
}
return result;
};
...@@ -42,7 +42,7 @@ const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError ...@@ -42,7 +42,7 @@ const ChartWidgetContainer = ({ id, title, description, interval, onLoadingError
}); });
const items = useMemo(() => data?.chart?.map((item) => { const items = useMemo(() => data?.chart?.map((item) => {
return { date: new Date(item.date), value: Number(item.value) }; return { date: new Date(item.date), value: Number(item.value), isApproximate: item.is_approximate };
}), [ data ]); }), [ data ]);
useEffect(() => { useEffect(() => {
......
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