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(() => {