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
@@ -19,6 +19,7 @@ test('base view +@dark-mode +@mobile', async({ render, page, mockApiResponse })
|
||||
await page.waitForFunction(() => {
|
||||
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 expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
@@ -56,7 +56,7 @@ test('partial data', async({ page, mockApiResponse, mockAssetResponse, render })
|
||||
test('no data', async({ mockApiResponse, mockAssetResponse, render }) => {
|
||||
await mockApiResponse('stats', statsMock.noChartData);
|
||||
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/>);
|
||||
await expect(component).toHaveScreenshot();
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -11,10 +11,10 @@ interface Props extends React.SVGProps<SVGPathElement> {
|
||||
yScale: d3.ScaleTime<number, number> | d3.ScaleLinear<number, number>;
|
||||
color?: string;
|
||||
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 theme = useTheme();
|
||||
|
||||
@@ -26,7 +26,7 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disableAnimation) {
|
||||
if (noAnimation) {
|
||||
d3.select(ref.current).attr('opacity', 1);
|
||||
return;
|
||||
}
|
||||
@@ -34,10 +34,11 @@ const ChartArea = ({ id, xScale, yScale, color, data, disableAnimation, ...props
|
||||
.duration(750)
|
||||
.ease(d3.easeBackIn)
|
||||
.attr('opacity', 1);
|
||||
}, [ disableAnimation ]);
|
||||
}, [ noAnimation ]);
|
||||
|
||||
const d = React.useMemo(() => {
|
||||
const area = d3.area<TimeChartItem>()
|
||||
.defined(({ isApproximate }) => !isApproximate)
|
||||
.x(({ date }) => xScale(date))
|
||||
.y1(({ value }) => yScale(value))
|
||||
.y0(() => yScale(yScale.domain()[0]))
|
||||
|
||||
@@ -5,13 +5,13 @@ 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;
|
||||
noAnimation?: boolean;
|
||||
ticks: number;
|
||||
tickFormatGenerator?: (axis: d3.Axis<d3.NumberValue>) => (domainValue: d3.AxisDomain, index: number) => string;
|
||||
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 textColorToken = useColorModeValue('blackAlpha.600', 'whiteAlpha.500');
|
||||
@@ -31,7 +31,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation,
|
||||
|
||||
const axisGroup = d3.select(ref.current);
|
||||
|
||||
if (disableAnimation) {
|
||||
if (noAnimation) {
|
||||
axisGroup.call(axis);
|
||||
} else {
|
||||
axisGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
|
||||
@@ -42,7 +42,7 @@ const ChartAxis = ({ type, scale, ticks, tickFormatGenerator, disableAnimation,
|
||||
.attr('opacity', 1)
|
||||
.attr('color', textColor)
|
||||
.attr('font-size', '0.75rem');
|
||||
}, [ scale, ticks, tickFormatGenerator, disableAnimation, type, textColor ]);
|
||||
}, [ scale, ticks, tickFormatGenerator, noAnimation, type, textColor ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!anchorEl) {
|
||||
|
||||
@@ -5,12 +5,12 @@ 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;
|
||||
noAnimation?: boolean;
|
||||
size: 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 strokeColor = useToken('colors', 'divider');
|
||||
@@ -24,7 +24,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }:
|
||||
const axis = axisGenerator(scale).ticks(ticks).tickSize(-size);
|
||||
|
||||
const gridGroup = d3.select(ref.current);
|
||||
if (disableAnimation) {
|
||||
if (noAnimation) {
|
||||
gridGroup.call(axis);
|
||||
} else {
|
||||
gridGroup.transition().duration(750).ease(d3.easeLinear).call(axis);
|
||||
@@ -32,7 +32,7 @@ const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }:
|
||||
gridGroup.select('.domain').remove();
|
||||
gridGroup.selectAll('text').remove();
|
||||
gridGroup.selectAll('line').attr('stroke', strokeColor);
|
||||
}, [ scale, ticks, size, disableAnimation, type, strokeColor ]);
|
||||
}, [ scale, ticks, size, noAnimation, type, strokeColor ]);
|
||||
|
||||
return <g ref={ ref } { ...props }/>;
|
||||
};
|
||||
|
||||
@@ -3,56 +3,38 @@ import React from 'react';
|
||||
|
||||
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> {
|
||||
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';
|
||||
animation: AnimationType;
|
||||
}
|
||||
|
||||
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);
|
||||
}, []);
|
||||
const dataPathRef = React.useRef<SVGPathElement>(null);
|
||||
const incompleteDataPathRef = React.useRef<SVGPathElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const ANIMATIONS = {
|
||||
left: animateLeft,
|
||||
fadeIn: animateFadeIn,
|
||||
none: noneAnimation,
|
||||
};
|
||||
const animationFn = ANIMATIONS[animation];
|
||||
window.setTimeout(animationFn, 100);
|
||||
}, [ animateLeft, animateFadeIn, noneAnimation, animation ]);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
dataPathRef.current && animationFn(dataPathRef.current);
|
||||
incompleteDataPathRef.current && animationFn(incompleteDataPathRef.current);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [ animation ]);
|
||||
|
||||
// Recalculate line length if scale has changed
|
||||
React.useEffect(() => {
|
||||
if (animation === 'left') {
|
||||
const totalLength = ref.current?.getTotalLength();
|
||||
d3.select(ref.current).attr(
|
||||
const totalLength = dataPathRef.current?.getTotalLength();
|
||||
d3.select(dataPathRef.current).attr(
|
||||
'stroke-dasharray',
|
||||
`${ totalLength },${ totalLength }`,
|
||||
);
|
||||
@@ -65,15 +47,27 @@ const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
return (
|
||||
<path
|
||||
ref={ ref }
|
||||
d={ line(data) || undefined }
|
||||
strokeWidth={ 1 }
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
opacity={ 0 }
|
||||
{ ...props }
|
||||
/>
|
||||
<>
|
||||
<path
|
||||
ref={ incompleteDataPathRef }
|
||||
d={ line(getIncompleteDataLineSource(data)) || undefined }
|
||||
strokeWidth={ 1 }
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
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 }
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useToken, useColorModeValue } from '@chakra-ui/react';
|
||||
import * as d3 from 'd3';
|
||||
import React from 'react';
|
||||
|
||||
import type { TimeChartItem, TimeChartData } from 'ui/shared/chart/types';
|
||||
import type { TimeChartData } from 'ui/shared/chart/types';
|
||||
|
||||
import computeTooltipPosition from 'ui/shared/chart/utils/computeTooltipPosition';
|
||||
import type { Pointer } from 'ui/shared/chart/utils/pointerTracker';
|
||||
import { trackPointer } from 'ui/shared/chart/utils/pointerTracker';
|
||||
import ChartTooltipBackdrop, { useRenderBackdrop } from './tooltip/ChartTooltipBackdrop';
|
||||
import ChartTooltipContent, { useRenderContent } from './tooltip/ChartTooltipContent';
|
||||
import ChartTooltipLine, { useRenderLine } from './tooltip/ChartTooltipLine';
|
||||
import ChartTooltipPoint, { useRenderPoints } from './tooltip/ChartTooltipPoint';
|
||||
import ChartTooltipRow, { useRenderRows } from './tooltip/ChartTooltipRow';
|
||||
import ChartTooltipTitle, { useRenderTitle } from './tooltip/ChartTooltipTitle';
|
||||
import { trackPointer } from './tooltip/pointerTracker';
|
||||
import type { Pointer } from './tooltip/pointerTracker';
|
||||
|
||||
interface Props {
|
||||
width?: number;
|
||||
@@ -16,151 +20,62 @@ interface Props {
|
||||
xScale: d3.ScaleTime<number, number>;
|
||||
yScale: d3.ScaleLinear<number, number>;
|
||||
anchorEl: SVGRectElement | null;
|
||||
noAnimation?: boolean;
|
||||
}
|
||||
|
||||
const TEXT_LINE_HEIGHT = 12;
|
||||
const PADDING = 16;
|
||||
const LINE_SPACE = 10;
|
||||
const POINT_SIZE = 16;
|
||||
const LABEL_WIDTH = 80;
|
||||
|
||||
const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, ...props }: Props) => {
|
||||
const lineColor = useToken('colors', 'gray.400');
|
||||
const titleColor = useToken('colors', 'blue.100');
|
||||
const textColor = useToken('colors', 'white');
|
||||
const markerBgColor = useToken('colors', useColorModeValue('black', 'white'));
|
||||
const markerBorderColor = useToken('colors', useColorModeValue('white', 'black'));
|
||||
const bgColor = useToken('colors', 'blackAlpha.900');
|
||||
|
||||
const ref = React.useRef(null);
|
||||
const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data, anchorEl, noAnimation, ...props }: Props) => {
|
||||
const ref = React.useRef<SVGGElement>(null);
|
||||
const trackerId = React.useRef<number>();
|
||||
const isVisible = 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 transitionDuration = !noAnimation ? 100 : null;
|
||||
|
||||
const drawContent = React.useCallback(
|
||||
(x: number, y: number) => {
|
||||
const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content');
|
||||
|
||||
tooltipContent.attr('transform', (cur, i, nodes) => {
|
||||
const node = nodes[i] as SVGGElement | null;
|
||||
const { width: nodeWidth, height: nodeHeight } = node?.getBoundingClientRect() || { width: 0, height: 0 };
|
||||
const [ translateX, translateY ] = computeTooltipPosition({
|
||||
canvasWidth: width || 0,
|
||||
canvasHeight: height || 0,
|
||||
nodeWidth,
|
||||
nodeHeight,
|
||||
pointX: x,
|
||||
pointY: y,
|
||||
offset: POINT_SIZE,
|
||||
});
|
||||
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(dateLabel || d3.timeFormat('%e %b %Y')(xScale.invert(x)));
|
||||
},
|
||||
[ xScale, data, width, height ],
|
||||
);
|
||||
|
||||
const updateDisplayedValue = React.useCallback((d: TimeChartItem, i: number) => {
|
||||
const nodes = d3.select(ref.current)
|
||||
.selectAll<Element, TimeChartData>('.ChartTooltip__value')
|
||||
.filter((td, tIndex) => tIndex === i)
|
||||
.text(
|
||||
(data[i].valueFormatter?.(d.value) || d.value.toLocaleString(undefined, { minimumSignificantDigits: 1 })) +
|
||||
(data[i].units ? ` ${ data[i].units }` : ''),
|
||||
)
|
||||
.nodes();
|
||||
|
||||
const widthLimit = tooltipWidth - 2 * PADDING - LABEL_WIDTH;
|
||||
const width = nodes.map((node) => node?.getBoundingClientRect?.().width);
|
||||
const maxNodeWidth = Math.max(...width);
|
||||
d3.select(ref.current)
|
||||
.select('.ChartTooltip__contentBg')
|
||||
.attr('width', tooltipWidth + Math.max(0, (maxNodeWidth - widthLimit)));
|
||||
|
||||
}, [ data, tooltipWidth ]);
|
||||
|
||||
const drawPoints = React.useCallback((x: number) => {
|
||||
const xDate = xScale.invert(x);
|
||||
const bisectDate = d3.bisector<TimeChartItem, unknown>((d) => d.date).left;
|
||||
let baseXPos = 0;
|
||||
let baseYPos = 0;
|
||||
|
||||
d3.select(ref.current)
|
||||
.selectAll('.ChartTooltip__point')
|
||||
.attr('transform', (cur, i) => {
|
||||
const index = bisectDate(data[i].items, xDate, 1);
|
||||
const d0 = data[i].items[index - 1] as TimeChartItem | undefined;
|
||||
const d1 = data[i].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 = xScale(d.date);
|
||||
const yPos = yScale(d.value);
|
||||
|
||||
if (i === 0) {
|
||||
baseXPos = xPos;
|
||||
baseYPos = yPos;
|
||||
}
|
||||
|
||||
updateDisplayedValue(d, i);
|
||||
|
||||
return `translate(${ xPos }, ${ yPos })`;
|
||||
});
|
||||
|
||||
return [ baseXPos, baseYPos ];
|
||||
}, [ data, updateDisplayedValue, xScale, yScale ]);
|
||||
const renderLine = useRenderLine(ref, height);
|
||||
const renderContent = useRenderContent(ref, { chart: { width, height }, transitionDuration });
|
||||
const renderPoints = useRenderPoints(ref, { data, xScale, yScale });
|
||||
const renderTitle = useRenderTitle(ref);
|
||||
const renderRows = useRenderRows(ref, { data, xScale, minWidth: tooltipWidth });
|
||||
const renderBackdrop = useRenderBackdrop(ref, { seriesNum: data.length, transitionDuration });
|
||||
|
||||
const draw = React.useCallback((pointer: Pointer) => {
|
||||
if (pointer.point) {
|
||||
const [ baseXPos, baseYPos ] = drawPoints(pointer.point[0]);
|
||||
drawLine(baseXPos);
|
||||
drawContent(baseXPos, baseYPos);
|
||||
const { x, y, currentPoints } = renderPoints(pointer.point[0]);
|
||||
const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate);
|
||||
renderLine(x);
|
||||
renderContent(x, y);
|
||||
renderTitle(isIncompleteData);
|
||||
const { width } = renderRows(x, currentPoints);
|
||||
renderBackdrop(width, isIncompleteData);
|
||||
}
|
||||
}, [ drawPoints, drawLine, drawContent ]);
|
||||
}, [ renderPoints, renderLine, renderContent, renderTitle, renderRows, renderBackdrop ]);
|
||||
|
||||
const showContent = React.useCallback(() => {
|
||||
if (!isVisible.current) {
|
||||
d3.select(ref.current).attr('opacity', 1);
|
||||
d3.select(ref.current)
|
||||
.selectAll('.ChartTooltip__point')
|
||||
.attr('opacity', 1);
|
||||
if (transitionDuration) {
|
||||
d3.select(ref.current)
|
||||
.transition()
|
||||
.delay(transitionDuration)
|
||||
.attr('opacity', 1);
|
||||
} else {
|
||||
d3.select(ref.current)
|
||||
.attr('opacity', 1);
|
||||
}
|
||||
isVisible.current = true;
|
||||
}
|
||||
}, []);
|
||||
}, [ transitionDuration ]);
|
||||
|
||||
const hideContent = React.useCallback(() => {
|
||||
d3.select(ref.current).attr('opacity', 0);
|
||||
if (transitionDuration) {
|
||||
d3.select(ref.current)
|
||||
.transition()
|
||||
.delay(transitionDuration)
|
||||
.attr('opacity', 0);
|
||||
} else {
|
||||
d3.select(ref.current)
|
||||
.attr('opacity', 0);
|
||||
}
|
||||
isVisible.current = false;
|
||||
}, []);
|
||||
}, [ transitionDuration ]);
|
||||
|
||||
const createPointerTracker = React.useCallback((event: PointerEvent, isSubsequentCall?: boolean) => {
|
||||
let isPressed = event.pointerType === 'mouse' && event.type === 'pointerdown' && !isSubsequentCall;
|
||||
@@ -224,73 +139,21 @@ const ChartTooltip = ({ xScale, yScale, width, tooltipWidth = 200, height, data,
|
||||
}, [ anchorEl, createPointerTracker, draw, hideContent, showContent ]);
|
||||
|
||||
return (
|
||||
<g ref={ ref } opacity={ 0 } { ...props }>
|
||||
<line className="ChartTooltip__line" stroke={ lineColor } strokeDasharray="3"/>
|
||||
{ data.map(({ name }) => (
|
||||
<circle
|
||||
key={ name }
|
||||
className="ChartTooltip__point"
|
||||
r={ POINT_SIZE / 2 }
|
||||
opacity={ 0 }
|
||||
fill={ markerBgColor }
|
||||
stroke={ markerBorderColor }
|
||||
strokeWidth={ 4 }
|
||||
/>
|
||||
)) }
|
||||
<g className="ChartTooltip__content">
|
||||
<rect
|
||||
className="ChartTooltip__contentBg"
|
||||
rx={ 12 }
|
||||
ry={ 12 }
|
||||
fill={ bgColor }
|
||||
width={ tooltipWidth }
|
||||
height={ 2 * PADDING + (data.length + 1) * TEXT_LINE_HEIGHT + data.length * LINE_SPACE }
|
||||
/>
|
||||
<g transform={ `translate(${ PADDING },${ PADDING })` }>
|
||||
<text
|
||||
className="ChartTooltip__contentTitle"
|
||||
transform="translate(0,0)"
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
fill={ titleColor }
|
||||
dominantBaseline="hanging"
|
||||
>
|
||||
Date
|
||||
</text>
|
||||
<text
|
||||
className="ChartTooltip__contentDate"
|
||||
transform={ `translate(${ LABEL_WIDTH },0)` }
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
fill={ textColor }
|
||||
dominantBaseline="hanging"
|
||||
/>
|
||||
</g>
|
||||
{ data.map(({ name }, index) => (
|
||||
<g
|
||||
key={ name }
|
||||
transform={ `translate(${ PADDING },${ PADDING + (index + 1) * (LINE_SPACE + TEXT_LINE_HEIGHT) })` }
|
||||
>
|
||||
<text
|
||||
className="ChartTooltip__contentTitle"
|
||||
transform="translate(0,0)"
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
fill={ titleColor }
|
||||
dominantBaseline="hanging"
|
||||
>
|
||||
{ name }
|
||||
</text>
|
||||
<text
|
||||
transform={ `translate(${ LABEL_WIDTH },0)` }
|
||||
className="ChartTooltip__value"
|
||||
fontSize="12px"
|
||||
fill={ textColor }
|
||||
dominantBaseline="hanging"
|
||||
/>
|
||||
</g>
|
||||
)) }
|
||||
</g>
|
||||
<g
|
||||
ref={ ref }
|
||||
opacity={ 0 }
|
||||
fontSize="12px"
|
||||
fontWeight="500"
|
||||
{ ...props }
|
||||
>
|
||||
<ChartTooltipLine/>
|
||||
{ data.map(({ name }) => <ChartTooltipPoint key={ name }/>) }
|
||||
<ChartTooltipContent>
|
||||
<ChartTooltipBackdrop/>
|
||||
<ChartTooltipTitle/>
|
||||
<ChartTooltipRow label="Date" lineNum={ 1 }/>
|
||||
{ data.map(({ name }, index) => <ChartTooltipRow key={ name } label={ name } lineNum={ index + 1 }/>) }
|
||||
</ChartTooltipContent>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { TimeChartItem } from './types';
|
||||
|
||||
import { test, expect } from 'playwright/lib';
|
||||
|
||||
import type { Props } from './ChartWidget';
|
||||
@@ -26,6 +28,7 @@ const props: Props = {
|
||||
units: 'ETH',
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
noAnimation: true,
|
||||
};
|
||||
|
||||
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.click(0, 0);
|
||||
await page.mouse.move(80, 150);
|
||||
await page.mouse.move(100, 150);
|
||||
await expect(component).toHaveScreenshot();
|
||||
|
||||
@@ -109,3 +113,24 @@ test('small variations in big values', async({ render, page }) => {
|
||||
});
|
||||
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 = {
|
||||
className?: string;
|
||||
isError: boolean;
|
||||
emptyText?: string;
|
||||
noAnimation?: boolean;
|
||||
}
|
||||
|
||||
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 [ isFullscreen, setIsFullscreen ] = useState(false);
|
||||
const [ isZoomResetInitial, setIsZoomResetInitial ] = React.useState(true);
|
||||
@@ -148,6 +149,7 @@ const ChartWidget = ({ items, title, description, isLoading, className, isError,
|
||||
isZoomResetInitial={ isZoomResetInitial }
|
||||
title={ title }
|
||||
units={ units }
|
||||
noAnimation={ noAnimation }
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,13 +23,14 @@ interface Props {
|
||||
onZoom: () => void;
|
||||
isZoomResetInitial: boolean;
|
||||
margin?: ChartMargin;
|
||||
noAnimation?: boolean;
|
||||
}
|
||||
|
||||
// temporarily turn off the data aggregation, we need a better algorithm for that
|
||||
const MAX_SHOW_ITEMS = 100_000_000_000;
|
||||
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 color = useToken('colors', 'blue.200');
|
||||
const chartId = `chart-${ title.split(' ').join('') }-${ isEnlarged ? 'fullscreen' : 'small' }`;
|
||||
@@ -99,7 +100,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
|
||||
scale={ axes.y.scale }
|
||||
ticks={ axesConfig.y.ticks }
|
||||
size={ innerWidth }
|
||||
disableAnimation
|
||||
noAnimation
|
||||
/>
|
||||
|
||||
<ChartArea
|
||||
@@ -108,6 +109,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
|
||||
color={ color }
|
||||
xScale={ axes.x.scale }
|
||||
yScale={ axes.y.scale }
|
||||
noAnimation={ noAnimation }
|
||||
/>
|
||||
|
||||
<ChartLine
|
||||
@@ -124,7 +126,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
|
||||
scale={ axes.y.scale }
|
||||
ticks={ axesConfig.y.ticks }
|
||||
tickFormatGenerator={ axes.y.tickFormatter }
|
||||
disableAnimation
|
||||
noAnimation
|
||||
/>
|
||||
|
||||
<ChartAxis
|
||||
@@ -134,7 +136,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
|
||||
ticks={ axesConfig.x.ticks }
|
||||
anchorEl={ overlayRef.current }
|
||||
tickFormatGenerator={ axes.x.tickFormatter }
|
||||
disableAnimation
|
||||
noAnimation
|
||||
/>
|
||||
|
||||
<ChartOverlay ref={ overlayRef } width={ innerWidth } height={ innerHeight }>
|
||||
@@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
|
||||
xScale={ axes.x.scale }
|
||||
yScale={ axes.y.scale }
|
||||
data={ chartData }
|
||||
noAnimation={ noAnimation }
|
||||
/>
|
||||
|
||||
<ChartSelectionX
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,46 @@
|
||||
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 ]);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as d3 from 'd3';
|
||||
import _clamp from 'lodash/clamp';
|
||||
import React from 'react';
|
||||
|
||||
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;
|
||||
pointY: number;
|
||||
offset: number;
|
||||
nodeWidth: number;
|
||||
nodeHeight: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
function calculatePosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: CalculatePositionParams): [ number, number ] {
|
||||
// right
|
||||
if (pointX + offset + nodeWidth <= canvasWidth) {
|
||||
const x = pointX + offset;
|
||||
const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight);
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
// left
|
||||
if (nodeWidth + offset <= pointX) {
|
||||
const x = pointX - offset - nodeWidth;
|
||||
const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight);
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
// top
|
||||
if (nodeHeight + offset <= pointY) {
|
||||
const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth);
|
||||
const y = pointY - offset - nodeHeight;
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (pointY + offset + nodeHeight <= canvasHeight) {
|
||||
const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth);
|
||||
const y = pointY + offset;
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
const x = _clamp(pointX / 2, 0, canvasWidth - nodeWidth);
|
||||
const y = _clamp(pointY / 2, 0, canvasHeight - nodeHeight);
|
||||
|
||||
return [ x, y ];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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 ]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 ]);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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 ]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 ]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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 {
|
||||
date: Date;
|
||||
dateLabel?: string;
|
||||
value: number;
|
||||
isApproximate?: boolean;
|
||||
}
|
||||
|
||||
export interface ChartMargin {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import _clamp from 'lodash/clamp';
|
||||
|
||||
interface Params {
|
||||
pointX: number;
|
||||
pointY: number;
|
||||
offset: number;
|
||||
nodeWidth: number;
|
||||
nodeHeight: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
export default function computeTooltipPosition({ pointX, pointY, canvasWidth, canvasHeight, nodeWidth, nodeHeight, offset }: Params): [ number, number ] {
|
||||
// right
|
||||
if (pointX + offset + nodeWidth <= canvasWidth) {
|
||||
const x = pointX + offset;
|
||||
const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight);
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
// left
|
||||
if (nodeWidth + offset <= pointX) {
|
||||
const x = pointX - offset - nodeWidth;
|
||||
const y = _clamp(pointY - nodeHeight / 2, 0, canvasHeight - nodeHeight);
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
// top
|
||||
if (nodeHeight + offset <= pointY) {
|
||||
const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth);
|
||||
const y = pointY - offset - nodeHeight;
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (pointY + offset + nodeHeight <= canvasHeight) {
|
||||
const x = _clamp(pointX - nodeWidth / 2, 0, canvasWidth - nodeWidth);
|
||||
const y = pointY + offset;
|
||||
return [ x, y ];
|
||||
}
|
||||
|
||||
const x = _clamp(pointX / 2, 0, canvasWidth - nodeWidth);
|
||||
const y = _clamp(pointY / 2, 0, canvasHeight - nodeHeight);
|
||||
|
||||
return [ x, y ];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
});
|
||||
|
||||
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 ]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||