diff --git a/ui/address/AddressCoinBalance.pw.tsx b/ui/address/AddressCoinBalance.pw.tsx
index a8fdd971a3..3950798784 100644
--- a/ui/address/AddressCoinBalance.pw.tsx
+++ b/ui/address/AddressCoinBalance.pw.tsx
@@ -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();
});
diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png
index bb79528b20..4b2d50bcea 100644
Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ
diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png
index da77d32fc9..58053b91e2 100644
Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_default_base-view-dark-mode-mobile-1.png differ
diff --git a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png
index fad359546d..c86acec370 100644
Binary files a/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png and b/ui/address/__screenshots__/AddressCoinBalance.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ
diff --git a/ui/home/indicators/ChainIndicators.pw.tsx b/ui/home/indicators/ChainIndicators.pw.tsx
index 9b32a70f8e..ec9bcf97fc 100644
--- a/ui/home/indicators/ChainIndicators.pw.tsx
+++ b/ui/home/indicators/ChainIndicators.pw.tsx
@@ -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();
await expect(component).toHaveScreenshot();
diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png
index 5d23631570..d6c68c11aa 100644
Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-dark-mode-mobile-1.png differ
diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png
index 587d663315..afb024abc0 100644
Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_default_daily-txs-chart-mobile-1.png differ
diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png
index fba7f1d6e5..bbd552cddf 100644
Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-dark-mode-mobile-1.png differ
diff --git a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png
index dabebb47a0..4fd9099e3e 100644
Binary files a/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png and b/ui/home/indicators/__screenshots__/ChainIndicators.pw.tsx_mobile_daily-txs-chart-mobile-1.png differ
diff --git a/ui/shared/chart/ChartArea.tsx b/ui/shared/chart/ChartArea.tsx
index 479e010508..5bb03281a5 100644
--- a/ui/shared/chart/ChartArea.tsx
+++ b/ui/shared/chart/ChartArea.tsx
@@ -11,10 +11,10 @@ interface Props extends React.SVGProps {
yScale: d3.ScaleTime | d3.ScaleLinear;
color?: string;
data: Array;
- 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()
+ .defined(({ isApproximate }) => !isApproximate)
.x(({ date }) => xScale(date))
.y1(({ value }) => yScale(value))
.y0(() => yScale(yScale.domain()[0]))
diff --git a/ui/shared/chart/ChartAxis.tsx b/ui/shared/chart/ChartAxis.tsx
index 487e3ace61..203ba65ccf 100644
--- a/ui/shared/chart/ChartAxis.tsx
+++ b/ui/shared/chart/ChartAxis.tsx
@@ -5,13 +5,13 @@ import React from 'react';
interface Props extends Omit, 'scale'> {
type: 'left' | 'bottom';
scale: d3.ScaleTime | d3.ScaleLinear;
- disableAnimation?: boolean;
+ noAnimation?: boolean;
ticks: number;
tickFormatGenerator?: (axis: d3.Axis) => (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(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) {
diff --git a/ui/shared/chart/ChartGridLine.tsx b/ui/shared/chart/ChartGridLine.tsx
index 2d140f25c1..3756b9239b 100644
--- a/ui/shared/chart/ChartGridLine.tsx
+++ b/ui/shared/chart/ChartGridLine.tsx
@@ -5,12 +5,12 @@ import React from 'react';
interface Props extends Omit, 'scale'> {
type: 'vertical' | 'horizontal';
scale: d3.ScaleTime | d3.ScaleLinear;
- 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(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 ;
};
diff --git a/ui/shared/chart/ChartLine.tsx b/ui/shared/chart/ChartLine.tsx
index a96b7e84c5..2d34add516 100644
--- a/ui/shared/chart/ChartLine.tsx
+++ b/ui/shared/chart/ChartLine.tsx
@@ -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 {
xScale: d3.ScaleTime | d3.ScaleLinear;
yScale: d3.ScaleTime | d3.ScaleLinear;
data: Array;
- animation: 'left' | 'fadeIn' | 'none';
+ animation: AnimationType;
}
const ChartLine = ({ xScale, yScale, data, animation, ...props }: Props) => {
- const ref = React.useRef(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(null);
+ const incompleteDataPathRef = React.useRef(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 (
-
+ <>
+
+ !isApproximate)) || undefined }
+ strokeWidth={ 1 }
+ strokeLinecap="round"
+ fill="none"
+ opacity={ 0 }
+ { ...props }
+ />
+ >
);
};
diff --git a/ui/shared/chart/ChartTooltip.tsx b/ui/shared/chart/ChartTooltip.tsx
index e257b5a519..71e51ab738 100644
--- a/ui/shared/chart/ChartTooltip.tsx
+++ b/ui/shared/chart/ChartTooltip.tsx
@@ -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;
yScale: d3.ScaleLinear;
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(null);
const trackerId = React.useRef();
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 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('.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((d) => d.date).left;
- let baseXPos = 0;
- let baseYPos = 0;
+ const transitionDuration = !noAnimation ? 100 : null;
- 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 (
-
-
- { data.map(({ name }) => (
-
- )) }
-
-
-
-
- Date
-
-
-
- { data.map(({ name }, index) => (
-
-
- { name }
-
-
-
- )) }
-
+
+
+ { data.map(({ name }) => ) }
+
+
+
+
+ { data.map(({ name }, index) => ) }
+
);
};
diff --git a/ui/shared/chart/ChartWidget.pw.tsx b/ui/shared/chart/ChartWidget.pw.tsx
index 409cf5bdd6..21fc07272f 100644
--- a/ui/shared/chart/ChartWidget.pw.tsx
+++ b/ui/shared/chart/ChartWidget.pw.tsx
@@ -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,
+ { date: new Date('2023-02-24'), value: 25136740.887217894 / 4, isApproximate: true },
+ ],
+ };
+
+ const component = await render();
+ 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();
+});
diff --git a/ui/shared/chart/ChartWidget.tsx b/ui/shared/chart/ChartWidget.tsx
index 550f912f56..e32a1e29ae 100644
--- a/ui/shared/chart/ChartWidget.tsx
+++ b/ui/shared/chart/ChartWidget.tsx
@@ -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(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 }
/>
);
diff --git a/ui/shared/chart/ChartWidgetGraph.tsx b/ui/shared/chart/ChartWidgetGraph.tsx
index 7dbce5bb55..ce1ee8e4c9 100644
--- a/ui/shared/chart/ChartWidgetGraph.tsx
+++ b/ui/shared/chart/ChartWidgetGraph.tsx
@@ -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
/>
@@ -146,6 +148,7 @@ const ChartWidgetGraph = ({ isEnlarged, items, onZoom, isZoomResetInitial, title
xScale={ axes.x.scale }
yScale={ axes.y.scale }
data={ chartData }
+ noAnimation={ noAnimation }
/>
{
+ const bgColor = useToken('colors', 'blackAlpha.900');
+
+ return (
+
+ );
+};
+
+export default React.memo(ChartTooltipBackdrop);
+
+interface UseRenderBackdropParams {
+ seriesNum: number;
+ transitionDuration: number | null;
+}
+
+export function useRenderBackdrop(ref: React.RefObject, { 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 ]);
+}
diff --git a/ui/shared/chart/tooltip/ChartTooltipContent.tsx b/ui/shared/chart/tooltip/ChartTooltipContent.tsx
new file mode 100644
index 0000000000..da1a62a5d6
--- /dev/null
+++ b/ui/shared/chart/tooltip/ChartTooltipContent.tsx
@@ -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 { children };
+};
+
+export default React.memo(ChartTooltipContent);
+
+interface UseRenderContentParams {
+ chart: {
+ width?: number;
+ height?: number;
+ };
+ transitionDuration: number | null;
+}
+
+export function useRenderContent(ref: React.RefObject, { chart, transitionDuration }: UseRenderContentParams) {
+ return React.useCallback((x: number, y: number) => {
+ const tooltipContent = d3.select(ref.current).select('.ChartTooltip__content');
+
+ const transformAttributeFn: d3.ValueFn = (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 ];
+}
diff --git a/ui/shared/chart/tooltip/ChartTooltipLine.tsx b/ui/shared/chart/tooltip/ChartTooltipLine.tsx
new file mode 100644
index 0000000000..7397365945
--- /dev/null
+++ b/ui/shared/chart/tooltip/ChartTooltipLine.tsx
@@ -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 ;
+};
+
+export default React.memo(ChartTooltipLine);
+
+export function useRenderLine(ref: React.RefObject, 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 ]);
+}
diff --git a/ui/shared/chart/tooltip/ChartTooltipPoint.tsx b/ui/shared/chart/tooltip/ChartTooltipPoint.tsx
new file mode 100644
index 0000000000..c6dd1052f4
--- /dev/null
+++ b/ui/shared/chart/tooltip/ChartTooltipPoint.tsx
@@ -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 (
+
+ );
+};
+
+export default React.memo(ChartTooltipPoint);
+
+interface UseRenderPointsParams {
+ data: TimeChartData;
+ xScale: d3.ScaleTime;
+ yScale: d3.ScaleLinear;
+}
+
+export interface CurrentPoint {
+ datumIndex: number;
+ item: TimeChartItem;
+}
+
+interface RenderPointsReturnType{
+ x: number;
+ y: number;
+ currentPoints: Array;
+}
+
+export function useRenderPoints(ref: React.RefObject, params: UseRenderPointsParams) {
+ return React.useCallback((x: number): RenderPointsReturnType => {
+ const xDate = params.xScale.invert(x);
+ const bisectDate = d3.bisector((d) => d.date).left;
+ let baseXPos = 0;
+ let baseYPos = 0;
+ const currentPoints: Array = [];
+
+ 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 ]);
+}
diff --git a/ui/shared/chart/tooltip/ChartTooltipRow.tsx b/ui/shared/chart/tooltip/ChartTooltipRow.tsx
new file mode 100644
index 0000000000..a35dcb25f1
--- /dev/null
+++ b/ui/shared/chart/tooltip/ChartTooltipRow.tsx
@@ -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 (
+
+ { children || (
+ <>
+
+ { label }
+
+
+ >
+ ) }
+
+ );
+};
+
+export default React.memo(ChartTooltipRow);
+
+interface UseRenderRowsParams {
+ data: TimeChartData;
+ xScale: d3.ScaleTime;
+ minWidth: number;
+}
+
+interface UseRenderRowsReturnType {
+ width: number;
+}
+
+export function useRenderRows(ref: React.RefObject, { data, xScale, minWidth }: UseRenderRowsParams) {
+ return React.useCallback((x: number, currentPoints: Array): UseRenderRowsReturnType => {
+
+ // update "transform" prop of all rows
+ const isIncompleteData = currentPoints.some(({ item }) => item.isApproximate);
+ d3.select(ref.current)
+ .selectAll('.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('.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 ]);
+}
diff --git a/ui/shared/chart/tooltip/ChartTooltipTitle.tsx b/ui/shared/chart/tooltip/ChartTooltipTitle.tsx
new file mode 100644
index 0000000000..93ab2e9943
--- /dev/null
+++ b/ui/shared/chart/tooltip/ChartTooltipTitle.tsx
@@ -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 (
+
+
+ Incomplete day
+
+
+ );
+};
+
+export default React.memo(ChartTooltipTitle);
+
+export function useRenderTitle(ref: React.RefObject) {
+ return React.useCallback((isVisible: boolean) => {
+ d3.select(ref.current)
+ .select('.ChartTooltip__title')
+ .attr('opacity', isVisible ? 1 : 0);
+ }, [ ref ]);
+}
diff --git a/ui/shared/chart/utils/pointerTracker.tsx b/ui/shared/chart/tooltip/pointerTracker.ts
similarity index 100%
rename from ui/shared/chart/utils/pointerTracker.tsx
rename to ui/shared/chart/tooltip/pointerTracker.ts
diff --git a/ui/shared/chart/tooltip/utils.ts b/ui/shared/chart/tooltip/utils.ts
new file mode 100644
index 0000000000..8b11b366a9
--- /dev/null
+++ b/ui/shared/chart/tooltip/utils.ts
@@ -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 })`;
+};
diff --git a/ui/shared/chart/types.tsx b/ui/shared/chart/types.tsx
index 5810fe2fb5..c1c02b1b12 100644
--- a/ui/shared/chart/types.tsx
+++ b/ui/shared/chart/types.tsx
@@ -8,6 +8,7 @@ export interface TimeChartItem {
date: Date;
dateLabel?: string;
value: number;
+ isApproximate?: boolean;
}
export interface ChartMargin {
diff --git a/ui/shared/chart/utils/animations.ts b/ui/shared/chart/utils/animations.ts
new file mode 100644
index 0000000000..2f873f4a69
--- /dev/null
+++ b/ui/shared/chart/utils/animations.ts
@@ -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 void> = {
+ left: animateLeft,
+ fadeIn: animateFadeIn,
+ none: noneAnimation,
+};
diff --git a/ui/shared/chart/utils/computeTooltipPosition.ts b/ui/shared/chart/utils/computeTooltipPosition.ts
deleted file mode 100644
index 88356d34cd..0000000000
--- a/ui/shared/chart/utils/computeTooltipPosition.ts
+++ /dev/null
@@ -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 ];
-}
diff --git a/ui/shared/chart/utils/formatters.ts b/ui/shared/chart/utils/formatters.ts
new file mode 100644
index 0000000000..9d0d13a7d2
--- /dev/null
+++ b/ui/shared/chart/utils/formatters.ts
@@ -0,0 +1,19 @@
+import type { TimeChartItem } from '../types';
+
+export const getIncompleteDataLineSource = (data: Array): Array => {
+ const result: Array = [];
+
+ 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;
+};
diff --git a/ui/stats/ChartWidgetContainer.tsx b/ui/stats/ChartWidgetContainer.tsx
index b9e45ebc79..5f41a5c2af 100644
--- a/ui/stats/ChartWidgetContainer.tsx
+++ b/ui/stats/ChartWidgetContainer.tsx
@@ -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(() => {