Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi Kumar
07c7d56711 chore: removed usepieinteractions 2026-09-23 15:55:01 +05:30
Abhi Kumar
281a4bb554 chore: pr review fixes 2026-09-23 15:36:27 +05:30
22 changed files with 547 additions and 760 deletions

View File

@@ -1,6 +1,7 @@
import cx from 'classnames';
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
import { formatCount } from './heatmapTooltipContent';
import { HeatmapBucketRow } from './types';
import Styles from './HeatmapTooltip.module.scss';

View File

@@ -1,8 +1,5 @@
import {
formatCount,
formatPercent,
HeatmapContributionRow,
} from './heatmapTooltipContent';
import { formatCount, formatPercent } from './heatmapTooltipContent';
import { HeatmapContributionRow } from './types';
import Styles from './HeatmapTooltip.module.scss';

View File

@@ -16,10 +16,10 @@ import {
formatColumnRange,
formatCount,
formatGroupFilter,
HeatmapTooltipBody,
resolveGroupByLabel,
resolveTooltipBody,
} from './heatmapTooltipContent';
import { HeatmapTooltipBody } from './types';
import Styles from './HeatmapTooltip.module.scss';

View File

@@ -8,6 +8,12 @@ import {
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import {
HeatmapBucketRow,
HeatmapContributionRow,
HeatmapTooltipBody,
} from './types';
/** Rows shown either side of the hovered one. */
const NEIGHBOUR_SPAN = 2;
/** Below this share a percentage needs a decimal to stay informative. */
@@ -17,30 +23,6 @@ const SUB_MINUTE_STEP = 60;
export const NO_DATA_LABEL = 'no data';
/**
* Which question the second block answers. A cell summed across several groups begs
* "which group?"; a cell that is already one series begs "how does this bucket
* compare with its neighbours?".
*/
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
label: string;
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Share of the cell's total, 0..100. */
percent: number;
}
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
// One enabled group contributes the whole cell, so there is nothing to break
// down — whether the query is ungrouped or the legend has isolated a group.

View File

@@ -0,0 +1,21 @@
/** Which question the second block answers: "which group?" for a cell summed
* across several, "how does this bucket compare?" for one that is one series. */
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
label: string;
/** `null` where the bucket has no observation in that column, never a 0. */
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Share of the cell's total, 0..100. */
percent: number;
}

View File

@@ -46,9 +46,8 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Special handling for time scales (X axis)
if (time) {
// An explicit range wins: the alignment below trims the tail of the window
// to whole minutes, which is right for point-based series but drops the
// final column of any chart whose marks span an interval.
// A range supplied outright wins: marks spanning an interval have to reach
// the end of their last column, past the last timestamp min/max carry.
if (range) {
return { [scaleKey]: { time: true, auto: false, range } };
}

View File

@@ -1,4 +1,4 @@
import { getPaletteStops } from '../palettes';
import { DEFAULT_HEATMAP_PALETTE, getPaletteStops } from '../palettes';
import { HeatmapColorPalette } from '../types';
const ALL_PALETTES = Object.values(HeatmapColorPalette);
@@ -55,11 +55,11 @@ describe('getPaletteStops', () => {
expect(first).toStrictEqual(second);
});
it('falls back to the first ramp for an unknown palette', () => {
it('falls back to the default ramp for a palette this build does not define', () => {
const unknown = 'nope' as HeatmapColorPalette;
expect(getPaletteStops(unknown, true)).toStrictEqual(
getPaletteStops(HeatmapColorPalette.Ice, true),
getPaletteStops(DEFAULT_HEATMAP_PALETTE, true),
);
});

View File

@@ -1,12 +1,11 @@
import { Color as DesignToken } from '@signozhq/design-tokens';
import Color from 'color';
import { getPaletteStops } from './palettes';
import { DEFAULT_HEATMAP_PALETTE, getPaletteStops } from './palettes';
import {
HeatmapColorMode,
HeatmapColorOptions,
HeatmapColorScale,
HeatmapColorPalette,
} from './types';
export const MIN_COLOR_STEPS = 2;
@@ -24,7 +23,7 @@ export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
scale: HeatmapColorScale.Log,
minCount: null,
maxCount: null,
palette: HeatmapColorPalette.Lava,
palette: DEFAULT_HEATMAP_PALETTE,
steps: DEFAULT_COLOR_STEPS,
fill: '',
};

View File

@@ -1,5 +1,7 @@
import { HeatmapColorPalette } from './types';
export const DEFAULT_HEATMAP_PALETTE = HeatmapColorPalette.Lava;
interface PaletteDefinition {
/** Evenly spaced, one end of the ramp to the other. */
stops: string[];
@@ -160,7 +162,8 @@ export function getPaletteStops(
palette: HeatmapColorPalette,
isDarkMode: boolean,
): string[] {
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
// The name comes off a saved panel spec, so it can be one this build dropped.
const definition = PALETTES[palette] ?? PALETTES[DEFAULT_HEATMAP_PALETTE];
return definition.darkFirst === isDarkMode
? definition.stops
: [...definition.stops].reverse();

View File

@@ -64,7 +64,7 @@ export default function ChartWrapper({
if (!showLegend) {
return null;
}
// Charts whose legend does not list uPlot series supply their own.
// A pie's slices and a heatmap's groups are not uPlot series.
if (customLegend) {
return customLegend(averageLegendWidth);
}
@@ -107,7 +107,7 @@ export default function ChartWrapper({
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
seriesLabels={legendLabels}
seriesLabelsOverride={legendLabels}
contentFooter={contentFooter}
layoutChildren={layoutChildren}
>

View File

@@ -27,7 +27,7 @@ import {
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { HeatmapChartProps } from 'lib/visualization/charts/types';
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import { buildHeatmapConfig, prepareHeatmapChartData } from './utils';
/** Vertical space the colour bar takes out of the container. */
@@ -97,8 +97,11 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
seriesColor: resolvedSeriesColor,
});
const { visibleGroups, focusedSeriesIndex, onLegendAction } =
useHeatmapGroupLegend({ groups });
const {
visibleKeys: visibleGroups,
focusedSeriesIndex,
onLegendAction,
} = useLegendVisibility({ keys: groups, indexOffset: 1, id });
const grid = useMemo(
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),

View File

@@ -137,6 +137,9 @@ describe('Heatmap group legend', () => {
const CART = 'service.name=cart';
const CHECKOUT = 'service.name=checkout';
// The selection persists under the panel id, which every case here shares.
beforeEach(() => localStorage.clear());
function legendItem(label: string): HTMLElement {
return screen.getByRole('switch', { name: label });
}

View File

@@ -1,184 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
const GROUPS = ['cart', 'checkout', 'payments'];
function render(
groups: string[] = GROUPS,
): ReturnType<
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
> {
return renderHook(() => useHeatmapGroupLegend({ groups }));
}
describe('useHeatmapGroupLegend', () => {
it('enables every group to begin with', () => {
const { result } = render();
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('isolates a group when the legend asks to show only it', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('restores every group when the legend asks to show all', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('moves the isolation to the group named last', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 3,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['payments']);
});
it('excludes just one group when it is toggled off', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
});
it('re-includes a group when it is toggled again', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('excludes more than one group', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
});
it('allows every group to be excluded, as the other legends do', () => {
const { result } = render();
GROUPS.forEach((_, index) =>
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: index + 1,
}),
),
);
expect(result.current.visibleGroups).toStrictEqual([]);
});
it('ignores an action naming an entry that is not there', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 99,
}),
);
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
});
it('forgets a hidden group that left the result', () => {
const { result, rerender } = renderHook(
({ groups }) => useHeatmapGroupLegend({ groups }),
{ initialProps: { groups: GROUPS } },
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
rerender({ groups: ['cart', 'checkout'] });
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
});
it('tracks the hovered entry for the legend"s focus highlight', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: 2,
}),
);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.focusedSeriesIndex).toBeNull();
});
});

View File

@@ -1,86 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { LegendAction, OnLegendAction } from 'lib/uPlotV2/components/types';
export interface UseHeatmapGroupLegendResult {
/** Groups currently enabled. The grid sums exactly these. */
visibleGroups: string[];
focusedSeriesIndex: number | null;
onLegendAction: OnLegendAction;
}
/**
* Group visibility for the heatmap legend, matching every other legend in the
* product: the shared Legend decides what a click means and sends the action;
* this only applies it. Everything is enabled to begin with.
*
* Counts are additive, so whatever is enabled is summed client-side and needs no
* extra request.
*
* Visibility only. Marker colour is resolved by the caller, which owns the colour
* ramp — and that ramp depends on which groups this hook has enabled.
*/
export function useHeatmapGroupLegend({
groups,
}: {
groups: string[];
}): UseHeatmapGroupLegendResult {
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
null,
);
const visibleGroups = useMemo(
() => groups.filter((group) => !hidden.has(group)),
[groups, hidden],
);
const onLegendAction = useCallback<OnLegendAction>(
(payload): void => {
// Legend items are numbered from 1, mirroring uPlot's 1-based data series.
const groupAt = (seriesIndex: number): string | undefined =>
groups[seriesIndex - 1];
switch (payload.type) {
case LegendAction.TOGGLE: {
const group = groupAt(payload.seriesIndex);
if (group === undefined) {
return;
}
setHidden((previous) => {
const next = new Set(previous);
if (next.has(group)) {
next.delete(group);
} else {
next.add(group);
}
return next;
});
break;
}
case LegendAction.SHOW_ONLY: {
const group = groupAt(payload.seriesIndex);
if (group === undefined) {
return;
}
setHidden(new Set(groups.filter((entry) => entry !== group)));
break;
}
case LegendAction.SHOW_ALL:
setHidden(new Set());
break;
case LegendAction.HOVER:
setFocusedSeriesIndex(payload.seriesIndex);
break;
default:
break;
}
},
[groups],
);
return {
visibleGroups,
focusedSeriesIndex,
onLegendAction,
};
}

View File

@@ -8,11 +8,12 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import styles from 'lib/visualization/charts/Pie/Pie.module.scss';
@@ -26,8 +27,8 @@ import {
* Donut chart rendered with @visx. Splits its area into chart + legend with the
* same `calculateChartDimensions` logic as the uPlot charts (right column /
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
* arcs and centre total to PieArc / PieCenterLabel. Pure presentation — slices
* are pre-resolved by the caller.
*/
export default function Pie({
data,
@@ -39,14 +40,27 @@ export default function Pie({
onSliceClick,
'data-testid': testId,
}: PieChartProps): JSX.Element {
const {
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex,
onLegendAction,
} = usePieInteractions(data, id);
const labels = useMemo(() => data.map((slice) => slice.label), [data]);
const { hiddenKeys, focusedSeriesIndex, setFocusedKey, onLegendAction } =
useLegendVisibility({ keys: labels, id });
const legendItems = useMemo<LegendItem[]>(
() =>
data.map((slice, index) => ({
seriesIndex: index,
label: slice.label,
color: slice.color,
show: !hiddenKeys.has(slice.label),
})),
[data, hiddenKeys],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
const visibleData = useMemo(
() => data.filter((slice) => !hiddenKeys.has(slice.label)),
[data, hiddenKeys],
);
const {
tooltipOpen,
@@ -75,9 +89,9 @@ export default function Pie({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: data.map((slice) => slice.label),
seriesLabels: labels,
}),
[containerWidth, containerHeight, position, data],
[containerWidth, containerHeight, position, labels],
);
// Donut geometry derived from the allocated chart box, sized to leave room
@@ -93,7 +107,8 @@ export default function Pie({
);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor = active?.color ?? null;
const activeColor =
focusedSeriesIndex !== null ? data[focusedSeriesIndex].color : null;
const handleSliceEnter = useCallback(
(slice: PieSlice, centroidX: number, centroidY: number): void => {
@@ -110,15 +125,15 @@ export default function Pie({
tooltipTop: centroidY + height / 2,
tooltipLeft: centroidX + width / 2,
});
setActive(slice);
setFocusedKey(slice.label);
},
[showTooltip, setActive, yAxisUnit, decimalPrecision, height, width],
[showTooltip, setFocusedKey, yAxisUnit, decimalPrecision, height, width],
);
const handleSliceLeave = useCallback((): void => {
hideTooltip();
setActive(null);
}, [hideTooltip, setActive]);
setFocusedKey(null);
}, [hideTooltip, setFocusedKey]);
if (!data.length) {
return (

View File

@@ -111,6 +111,18 @@ describe('Pie', () => {
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('recomputes the centre total from the slices left showing', () => {
renderPie();
// The arcs carry leader labels of their own, so read the centre text.
const centreTotal = (): string | null =>
screen.getByTestId('pie').querySelector('text > tspan')?.textContent ?? null;
expect(centreTotal()).toBe('200');
fireEvent.click(screen.getByTestId('legend-item-0'));
expect(centreTotal()).toBe('100');
});
it('excludes a slice when its legend row is clicked with others already hidden', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;

View File

@@ -97,6 +97,7 @@ export interface HistogramChartProps extends ChartWrapperProps {
* rebuilds the config, which recreates the plot.
*/
export interface HeatmapChartProps {
/** Widget id; the group selection persists under it. */
id: string;
/** Ascending. N boundaries describe N+1 rows. */
buckets: number[];
@@ -118,8 +119,8 @@ export interface HeatmapChartProps {
timezone?: Timezone;
/** Colour bar below the grid. Default true. */
showVisualMap?: boolean;
/** Default true; hidden anyway when there is only one group. Every group starts
* enabled — the label isolates one, the marker excludes one. */
/** Default true; hidden anyway when there is only one group. The label isolates
* one, the marker excludes one. */
showLegend?: boolean;
legendPosition?: LegendPosition;
/** Default true. */

View File

@@ -0,0 +1,294 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils');
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const KEYS = ['cart', 'checkout', 'payments'];
type Options = Parameters<typeof useLegendVisibility>[0];
function render(
options: Partial<Options> = {},
): ReturnType<
typeof renderHook<ReturnType<typeof useLegendVisibility>, unknown>
> {
return renderHook(() =>
useLegendVisibility({ keys: KEYS, indexOffset: 1, ...options }),
);
}
describe('useLegendVisibility', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('enables every entry to begin with', () => {
const { result } = render();
expect(result.current.visibleKeys).toStrictEqual(KEYS);
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('isolates an entry when the legend asks to show only it', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['checkout']);
});
it('restores every entry when the legend asks to show all', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleKeys).toStrictEqual(KEYS);
});
it('moves the isolation to the entry named last', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 3,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['payments']);
});
it('excludes just one entry when it is toggled off', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['cart', 'payments']);
});
it('re-includes an entry when it is toggled again', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleKeys).toStrictEqual(KEYS);
});
it('excludes more than one entry', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['checkout']);
});
it('keeps the last entry showing, as the uPlot legends do', () => {
const { result } = render();
KEYS.forEach((_, index) =>
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: index + 1,
}),
),
);
expect(result.current.visibleKeys).toStrictEqual(['payments']);
});
it('ignores an action naming an entry that is not there', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 99,
}),
);
expect(result.current.visibleKeys).toStrictEqual(KEYS);
});
it('addresses entries from zero when there is no offset', () => {
const { result } = render({ indexOffset: 0 });
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['checkout', 'payments']);
});
it('forgets a hidden entry that left the result', () => {
const { result, rerender } = renderHook(
({ keys }) => useLegendVisibility({ keys, indexOffset: 1 }),
{ initialProps: { keys: KEYS } },
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
rerender({ keys: ['cart', 'checkout'] });
expect(result.current.visibleKeys).toStrictEqual(['cart', 'checkout']);
});
it('tracks the hovered entry for the legend"s focus highlight', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: 2,
}),
);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused entry is hidden', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: 2,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.focusedSeriesIndex).toBeNull();
});
describe('persistence', () => {
it('writes the selection under the widget id', () => {
const { result } = render({ id: 'panel-1' });
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'cart', show: true },
{ label: 'checkout', show: false },
{ label: 'payments', show: true },
]);
});
it('does not write without an id', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates the selection from the store, matched by label', () => {
mockGetStored.mockReturnValue([
{ label: 'cart', show: true },
{ label: 'checkout', show: false },
{ label: 'payments', show: true },
]);
const { result } = render({ id: 'panel-1' });
expect(result.current.visibleKeys).toStrictEqual(['cart', 'payments']);
});
it('leaves the stored selection alone when it already matches', () => {
mockGetStored.mockReturnValue([{ label: 'cart', show: false }]);
const { result, rerender } = render({ id: 'panel-1' });
const first = result.current.hiddenKeys;
rerender();
expect(result.current.hiddenKeys).toBe(first);
});
});
});

View File

@@ -1,235 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils');
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#a' },
{ label: 'cart', value: 60, color: '#b' },
{ label: 'checkout', value: 40, color: '#c' },
];
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('starts with everything visible and nothing focused', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
true,
true,
]);
expect(result.current.focusedSeriesIndex).toBeNull();
expect(result.current.active).toBeNull();
});
describe('row toggle', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('the last slice showing', () => {
it('cannot be hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
// An empty donut is never a state worth reaching.
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
});
});
describe('Only', () => {
it('isolates the slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
false,
false,
]);
});
it('switches the isolation to another slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
});
});
describe('All', () => {
it('brings every hidden slice back', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleData).toStrictEqual(DATA);
});
});
describe('hover', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
);
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused slice is hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
// Otherwise every remaining arc stays dimmed and the donut reads as an
// isolation instead of one slice being excluded.
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
expect(result.current.active).toBeNull();
});
});
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
mockGetStored.mockReturnValue([
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
});
});
});

View File

@@ -0,0 +1,139 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { LegendAction, OnLegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
export interface UseLegendVisibilityResult {
visibleKeys: string[];
hiddenKeys: ReadonlySet<string>;
focusedSeriesIndex: number | null;
setFocusedKey: (key: string | null) => void;
onLegendAction: OnLegendAction;
}
function isSameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((entry) => b.has(entry));
}
/**
* Legend visibility and focus for charts whose legend does not list uPlot series.
* Keyed by label, so a selection survives reordering, and persists under the same
* widget store the uPlot legends use.
*/
export function useLegendVisibility({
keys,
indexOffset = 0,
id,
}: {
keys: string[];
/** Legend `seriesIndex` of `keys[0]`. Charts that mirror uPlot's 1-based data
* series pass 1. */
indexOffset?: number;
/** Widget id the selection persists under. Left out, nothing is stored. */
id?: string;
}): UseLegendVisibilityResult {
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const visibleKeys = useMemo(
() => keys.filter((key) => !hidden.has(key)),
[keys, hidden],
);
// The store is the source of truth: reread it whenever the entries change.
useEffect(() => {
if (!id || !keys.length) {
return;
}
const stored = getStoredSeriesVisibility(id);
if (!stored) {
return;
}
const restored = new Set(
keys.filter((key) => stored.find((s) => s.label === key)?.show === false),
);
setHidden((previous) =>
isSameSet(previous, restored) ? previous : restored,
);
}, [id, keys]);
const applyHidden = useCallback(
(next: Set<string>): void => {
setHidden(next);
if (id) {
updateSeriesVisibilityToLocalStorage(
id,
keys.map((key) => ({ label: key, show: !next.has(key) })),
);
}
},
[id, keys],
);
const onLegendAction = useCallback<OnLegendAction>(
(payload): void => {
const keyAt = (seriesIndex: number): string | undefined =>
keys[seriesIndex - indexOffset];
switch (payload.type) {
case LegendAction.TOGGLE: {
const key = keyAt(payload.seriesIndex);
if (key === undefined) {
return;
}
const next = new Set(hidden);
if (next.has(key)) {
next.delete(key);
} else {
// An empty chart is never a state worth reaching, as
// PlotContext holds for uPlot series.
if (keys.length - next.size <= 1) {
return;
}
next.add(key);
}
applyHidden(next);
break;
}
case LegendAction.SHOW_ONLY: {
const key = keyAt(payload.seriesIndex);
if (key === undefined) {
return;
}
applyHidden(new Set(keys.filter((entry) => entry !== key)));
break;
}
case LegendAction.SHOW_ALL:
applyHidden(new Set());
break;
case LegendAction.HOVER:
setFocusedKey(
payload.seriesIndex === null
? null
: (keyAt(payload.seriesIndex) ?? null),
);
break;
default:
break;
}
},
[keys, indexOffset, hidden, applyHidden],
);
// Left focused, a hidden entry keeps everything else dimmed.
const focusedIndex =
focusedKey !== null && !hidden.has(focusedKey)
? keys.indexOf(focusedKey)
: -1;
return {
visibleKeys,
hiddenKeys: hidden,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex + indexOffset : null,
setFocusedKey,
onLegendAction,
};
}

View File

@@ -1,183 +0,0 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from 'lib/uPlotV2/components/types';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { PieSlice } from 'lib/visualization/charts/types';
export interface UsePieInteractionsResult {
/** The hovered/focused slice (drives donut dimming + tooltip). */
active: PieSlice | null;
setActive: Dispatch<SetStateAction<PieSlice | null>>;
/** Slices currently shown (hidden ones removed). */
visibleData: PieSlice[];
/** Legend item per slice (`show` reflects hide state). */
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
/** Every legend interaction, dispatched by type. */
onLegendAction: OnLegendAction;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
* shared legend's actions, and persistence of the hidden set to localStorage
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
* slices, legend items, focus index, and the legend action dispatch.
*/
export function usePieInteractions(
data: PieSlice[],
id?: string,
): UsePieInteractionsResult {
const [active, setActive] = useState<PieSlice | null>(null);
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const legendItems = useMemo<LegendItem[]>(
() =>
data.map((slice, index) => ({
seriesIndex: index,
label: slice.label,
color: slice.color,
show: !hiddenIndices.has(index),
})),
[data, hiddenIndices],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
const visibleData = useMemo(
() => data.filter((_, index) => !hiddenIndices.has(index)),
[data, hiddenIndices],
);
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
// data set changes — including first load and every refetch, since the store
// is the source of truth and toggles write back to it.
useEffect(() => {
if (!id || !data.length) {
return;
}
const stored = getStoredSeriesVisibility(id);
if (!stored) {
return;
}
const hidden = new Set<number>();
data.forEach((slice, index) => {
if (stored.find((s) => s.label === slice.label)?.show === false) {
hidden.add(index);
}
});
setHiddenIndices(hidden);
}, [id, data]);
// Apply a new hidden set and persist it (label + show) to localStorage.
const applyHidden = useCallback(
(hidden: Set<number>): void => {
setHiddenIndices(hidden);
if (id) {
updateSeriesVisibilityToLocalStorage(
id,
data.map((slice, index) => ({
label: slice.label,
show: !hidden.has(index),
})),
);
}
},
[id, data],
);
const hoverSeries = useCallback(
(sliceIndex: number | null): void => {
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(
sliceIndex != null && !hiddenIndices.has(sliceIndex)
? data[sliceIndex]
: null,
);
},
[data, hiddenIndices],
);
const toggleSeries = useCallback(
(sliceIndex: number): void => {
const next = new Set(hiddenIndices);
if (next.has(sliceIndex)) {
next.delete(sliceIndex);
} else {
// An empty donut is never worth reaching.
if (data.length - next.size <= 1) {
return;
}
next.add(sliceIndex);
}
applyHidden(next);
},
[data.length, hiddenIndices, applyHidden],
);
const showOnlySeries = useCallback(
(sliceIndex: number): void => {
const next = new Set<number>();
data.forEach((_, index) => {
if (index !== sliceIndex) {
next.add(index);
}
});
applyHidden(next);
},
[data, applyHidden],
);
const showAllSeries = useCallback(
(): void => applyHidden(new Set()),
[applyHidden],
);
const onLegendAction = useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
toggleSeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
showOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
showAllSeries();
break;
case LegendAction.HOVER:
hoverSeries(payload.seriesIndex);
break;
default:
break;
}
},
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
);
const activeIndex = active ? data.indexOf(active) : -1;
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
// isolation rather than as one slice being excluded.
const effectiveActive =
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
const focusedIndex = effectiveActive ? activeIndex : -1;
return {
active: effectiveActive,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendAction,
};
}

View File

@@ -28,7 +28,7 @@ export interface ChartLayoutProps {
config: UPlotConfigBuilder;
/** Defaults to the chart's series labels. Pass them when the legend lists
* something else, or the split is measured against the wrong text. */
seriesLabels?: string[];
seriesLabelsOverride?: string[];
}
export default function ChartLayout({
showLegend = true,
@@ -40,7 +40,7 @@ export default function ChartLayout({
containerHeight,
legendConfig,
config,
seriesLabels,
seriesLabelsOverride,
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
@@ -53,8 +53,8 @@ export default function ChartLayout({
averageLegendWidth: MAX_LEGEND_WIDTH,
};
}
const resolvedLabels =
seriesLabels ??
const seriesLabels =
seriesLabelsOverride ??
Object.values(config.getLegendItems())
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
@@ -62,11 +62,17 @@ export default function ChartLayout({
containerWidth,
containerHeight,
legendConfig,
seriesLabels: resolvedLabels,
seriesLabels,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
[
containerWidth,
containerHeight,
legendConfig,
showLegend,
seriesLabelsOverride,
],
);
return (