Skip to content

Commit 805cc3c

Browse files
HrvojeHemenHrvoje Hemen
andauthored
feat: support debouncing of the chart core tooltip (#129)
* feat: support debouncing of the chart core tooltip * feat: support debouncing of the chart core tooltip v3 * remove testing changes * remove `?` from debouncedTooltip because we're checking whether it's defined * last `?` remnants that aren't needed * Final touches * component -> value --------- Co-authored-by: Hrvoje Hemen <[email protected]>
1 parent 241ed84 commit 805cc3c

File tree

5 files changed

+134
-7
lines changed

5 files changed

+134
-7
lines changed

src/__tests__/__snapshots__/documenter.test.ts.snap

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1744,6 +1744,21 @@ for each visited { x, y } point.",
17441744
"inlineType": {
17451745
"name": "CoreChartProps.TooltipOptions",
17461746
"properties": [
1747+
{
1748+
"inlineType": {
1749+
"name": "number | boolean",
1750+
"type": "union",
1751+
"valueDescriptions": undefined,
1752+
"values": [
1753+
"number",
1754+
"false",
1755+
"true",
1756+
],
1757+
},
1758+
"name": "debounce",
1759+
"optional": true,
1760+
"type": "number | boolean",
1761+
},
17471762
{
17481763
"name": "enabled",
17491764
"optional": true,

src/core/__tests__/chart-core-tooltip.test.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,4 +588,76 @@ describe("CoreChart: tooltip", () => {
588588
});
589589
});
590590
});
591+
592+
describe("debounce functionality", () => {
593+
beforeEach(() => {
594+
vi.useFakeTimers();
595+
});
596+
597+
afterEach(() => {
598+
vi.useRealTimers();
599+
});
600+
601+
test("debounces tooltip rendering with debounce", () => {
602+
const { wrapper } = renderChart({
603+
highcharts,
604+
options: { series: pieSeries },
605+
tooltip: { debounce: 100 },
606+
getTooltipContent: () => ({ header: () => "Header", body: () => "Body" }),
607+
});
608+
609+
act(() => hc.highlightChartPoint(0, 0));
610+
611+
// Tooltip should not be visible immediately
612+
expect(wrapper.findTooltip()).toBe(null);
613+
614+
// Fast forward time by 50ms - still not visible
615+
act(() => vi.advanceTimersByTime(50));
616+
expect(wrapper.findTooltip()).toBe(null);
617+
618+
// Fast forward time by another 50ms (100ms total) - now visible
619+
act(() => vi.advanceTimersByTime(50));
620+
expect(wrapper.findTooltip()).not.toBe(null);
621+
});
622+
623+
test("cancels previous debounced call when new highlight occurs", () => {
624+
const { wrapper } = renderChart({
625+
highcharts,
626+
options: { series: pieSeries },
627+
tooltip: { debounce: 100 },
628+
getTooltipContent: ({ point }) => ({ header: () => `Point ${point?.name}`, body: () => "Body" }),
629+
});
630+
631+
// Highlight first point
632+
act(() => hc.highlightChartPoint(0, 0));
633+
634+
// Fast forward 50ms
635+
act(() => vi.advanceTimersByTime(50));
636+
expect(wrapper.findTooltip()).toBe(null);
637+
638+
// Highlight second point before first debounce completes
639+
act(() => hc.highlightChartPoint(0, 1));
640+
641+
// Fast forward another 50ms (100ms from first highlight, 50ms from second)
642+
act(() => vi.advanceTimersByTime(50));
643+
expect(wrapper.findTooltip()).toBe(null);
644+
645+
// Fast forward another 50ms (100ms from second highlight)
646+
act(() => vi.advanceTimersByTime(50));
647+
expect(wrapper.findTooltip()).not.toBe(null);
648+
expect(wrapper.findTooltip()!.findHeader()!.getElement().textContent).toBe("Point P2");
649+
});
650+
651+
test("renders immediately when debounce is 0", () => {
652+
const { wrapper } = renderChart({
653+
highcharts,
654+
options: { series: pieSeries },
655+
tooltip: { debounce: 0 },
656+
getTooltipContent: () => ({ header: () => "Header", body: () => "Body" }),
657+
});
658+
659+
act(() => hc.highlightChartPoint(0, 0));
660+
expect(wrapper.findTooltip()).not.toBe(null);
661+
});
662+
});
591663
});

src/core/components/core-tooltip.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import LiveRegion from "@cloudscape-design/components/live-region";
1111
import ChartSeriesDetails, { ChartSeriesDetailItem } from "../../internal/components/series-details";
1212
import { useSelector } from "../../internal/utils/async-store";
1313
import { getChartSeries } from "../../internal/utils/chart-series";
14+
import { useDebouncedValue } from "../../internal/utils/use-debounced-value";
1415
import { ChartAPI } from "../chart-api";
1516
import { getFormatter } from "../formatters";
1617
import { BaseI18nStrings, CoreChartProps } from "../interfaces";
@@ -19,6 +20,7 @@ import { getPointColor, getSeriesColor, getSeriesId, getSeriesMarkerType, isXThr
1920
import styles from "../styles.css.js";
2021

2122
const MIN_VISIBLE_BLOCK_SIZE = 200;
23+
const DEFAULT_DEBOUNCE = 200;
2224

2325
type ExpandedSeriesState = Record<string, Set<string>>;
2426

@@ -44,18 +46,22 @@ export function ChartTooltip({
4446
getTooltipContent: getTooltipContentOverrides,
4547
api,
4648
i18nStrings,
49+
debounce = false,
4750
}: CoreChartProps.TooltipOptions & {
4851
i18nStrings?: BaseI18nStrings;
4952
getTooltipContent?: CoreChartProps.GetTooltipContent;
5053
api: ChartAPI;
5154
}) {
5255
const [expandedSeries, setExpandedSeries] = useState<ExpandedSeriesState>({});
5356
const tooltip = useSelector(api.tooltipStore, (s) => s);
54-
if (!tooltip.visible || tooltip.group.length === 0) {
57+
const debouncedTooltip = useDebouncedValue(tooltip, debounce === true ? DEFAULT_DEBOUNCE : debounce || 0);
58+
59+
if (!debouncedTooltip || !debouncedTooltip.visible || debouncedTooltip.group.length === 0) {
5560
return null;
5661
}
57-
const chart = tooltip.group[0]?.series.chart;
58-
const renderers = getTooltipContentOverrides?.({ point: tooltip.point, group: tooltip.group });
62+
63+
const chart = debouncedTooltip.group[0]?.series.chart;
64+
const renderers = getTooltipContentOverrides?.({ point: debouncedTooltip.point, group: debouncedTooltip.group });
5965
const getTrack = placement === "target" ? api.getTargetTrack : api.getGroupTrack;
6066
const position = (() => {
6167
if (placement === "target" || placement === "middle") {
@@ -66,8 +72,8 @@ export function ChartTooltip({
6672
})();
6773
const content = getTooltipContent(api, {
6874
renderers,
69-
point: tooltip.point,
70-
group: tooltip.group,
75+
point: debouncedTooltip.point,
76+
group: debouncedTooltip.group,
7177
expandedSeries,
7278
setExpandedSeries,
7379
hideTooltip: () => {
@@ -80,9 +86,9 @@ export function ChartTooltip({
8086
return (
8187
<InternalChartTooltip
8288
getTrack={getTrack}
83-
trackKey={getTrackKey(tooltip.point, tooltip.group)}
89+
trackKey={getTrackKey(debouncedTooltip.point, debouncedTooltip.group)}
8490
container={null}
85-
dismissButton={tooltip.pinned}
91+
dismissButton={debouncedTooltip.pinned}
8692
dismissAriaLabel={i18nStrings?.detailPopoverDismissAriaLabel}
8793
onDismiss={api.onDismissTooltip}
8894
onMouseEnter={api.onMouseEnterTooltip}

src/core/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,7 @@ export namespace CoreChartProps {
437437
enabled?: boolean;
438438
placement?: "middle" | "outside" | "target";
439439
size?: "small" | "medium" | "large";
440+
debounce?: number | boolean;
440441
}
441442

442443
export type GetTooltipContent = (props: GetTooltipContentProps) => TooltipContentRenderer;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useEffect, useRef, useState } from "react";
5+
6+
import { DebouncedCall } from "./utils";
7+
8+
export function useDebouncedValue<T>(value: T, duration: number) {
9+
const [debouncedValue, setDebouncedValue] = useState<T | null>(null);
10+
const [shouldRender, setShouldRender] = useState(false);
11+
const debouncedCall = useRef(new DebouncedCall());
12+
13+
useEffect(() => {
14+
if (duration <= 0) {
15+
return;
16+
}
17+
18+
setShouldRender(false);
19+
const current = debouncedCall.current;
20+
current.call(() => {
21+
setDebouncedValue(value);
22+
setShouldRender(true);
23+
}, duration);
24+
25+
return () => current.cancelPrevious();
26+
}, [value, duration]);
27+
28+
if (duration <= 0) {
29+
return value;
30+
}
31+
32+
return shouldRender ? debouncedValue : null;
33+
}

0 commit comments

Comments
 (0)