mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-24 04:10:40 +01:00
Compare commits
2 Commits
fix/heatma
...
feat/heatm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07c7d56711 | ||
|
|
281a4bb554 |
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
formatGroupFilter,
|
||||
HeatmapTooltipBody,
|
||||
resolveGroupByLabel,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
import { HeatmapTooltipBody } from './types';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '',
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
frontend/src/lib/visualization/hooks/useLegendVisibility.ts
Normal file
139
frontend/src/lib/visualization/hooks/useLegendVisibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user