From 00762f24fe537034ff610ae3a1afac0870c7f635 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 30 Jun 2026 20:27:09 +0200 Subject: [PATCH] fix(charts): stop duplicate legend labels + make right legend scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard widgets host the chart legend in two places: the widget header strip (widget-shell publishes config labels into a ChartLegendSlotContext) and the in-plot legend. The default ChartLegendContent suppresses its in-plot copy when the slot is present, but the custom QueryBuilderLegend (area/line/ bar query-builder charts) did not — so with legend="visible"/"right" and >=2 series the labels rendered twice (MAP-54). - Add `hoistLegend` (default true) to ChartContainer to gate the slot publish; query-builder charts pass `hoistLegend={!showLegendBlock}` so the header hoist and the in-plot legend are mutually exclusive. Service charts and non-dashboard usage are unchanged. - The right-aligned legend was never height-bounded (only width was set), so a long series list overflowed the card. Add a `maxHeight` prop to QueryBuilderLegend bound to the container height so its overflow-auto body scrolls instead. Bottom legend was already bounded. - widget-lab: add a "Line — 50 series (right legend, scrolls)" stress scenario (line keeps every series, unlike the bucketing bar) to cover right-legend overflow. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/widget-lab/scenarios.ts | 15 +++++++++++++++ .../charts/_shared/query-builder-legend.tsx | 15 ++++++++++++++- .../charts/area/query-builder-area-chart.tsx | 7 ++++++- .../charts/bar/query-builder-bar-chart.tsx | 7 ++++++- .../charts/line/query-builder-line-chart.tsx | 7 ++++++- packages/ui/src/components/ui/chart.tsx | 16 ++++++++++++++-- 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/widget-lab/scenarios.ts b/apps/web/src/components/widget-lab/scenarios.ts index 1a176682..c1839db1 100644 --- a/apps/web/src/components/widget-lab/scenarios.ts +++ b/apps/web/src/components/widget-lab/scenarios.ts @@ -545,6 +545,21 @@ export const stressScenarios: ChartScenario[] = [ chartPresentation: { legend: "right", seriesStats: false }, }, }, + { + // Line keeps every series (no "Other" bucketing), so the right legend is + // genuinely tall — confirms it scrolls within the card instead of overflowing. + label: "Line — 50 series (right legend, scrolls)", + chartId: "query-builder-line", + chartName: "Query Builder Line", + category: "line", + dataState: ready(makeManySeries(50)), + display: { + title: "Latency by service (50, right legend)", + chartId: "query-builder-line", + unit: "duration_ms", + chartPresentation: { legend: "right", seriesStats: true }, + }, + }, { label: "Pie — 20 slices → Other", chartId: "query-builder-pie", diff --git a/packages/ui/src/components/charts/_shared/query-builder-legend.tsx b/packages/ui/src/components/charts/_shared/query-builder-legend.tsx index 3d5faa61..679a5bb1 100644 --- a/packages/ui/src/components/charts/_shared/query-builder-legend.tsx +++ b/packages/ui/src/components/charts/_shared/query-builder-legend.tsx @@ -60,6 +60,12 @@ interface QueryBuilderLegendProps { * per-series Min/Max/Mean/Last columns. */ variant?: "compact" | "stats" + /** + * Upper bound (px) on the legend's own height. A right-aligned legend isn't + * height-constrained by Recharts, so a long series list would overflow the + * card; capping it here lets the `overflow-auto` body scroll instead. + */ + maxHeight?: number } /** Vertical space (px) a bottom-aligned legend block needs. */ @@ -120,12 +126,16 @@ export function QueryBuilderLegend({ unit, layout = "bottom", variant = "stats", + maxHeight, }: QueryBuilderLegendProps) { if (series.length === 0) return null + const maxHeightStyle = maxHeight != null ? { maxHeight } : undefined + if (variant === "compact") { return (
+
diff --git a/packages/ui/src/components/charts/area/query-builder-area-chart.tsx b/packages/ui/src/components/charts/area/query-builder-area-chart.tsx index e4023956..04cc0898 100644 --- a/packages/ui/src/components/charts/area/query-builder-area-chart.tsx +++ b/packages/ui/src/components/charts/area/query-builder-area-chart.tsx @@ -220,7 +220,11 @@ export function QueryBuilderAreaChart({ return (
- + {seriesDefinitions.map((definition) => ( @@ -360,6 +364,7 @@ export function QueryBuilderAreaChart({ unit={unit} layout="right" variant={variant} + maxHeight={containerHeight} /> } /> diff --git a/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx b/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx index 67343ffc..8da0b802 100644 --- a/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx +++ b/packages/ui/src/components/charts/bar/query-builder-bar-chart.tsx @@ -186,7 +186,11 @@ export function QueryBuilderBarChart({ return (
- + } /> diff --git a/packages/ui/src/components/charts/line/query-builder-line-chart.tsx b/packages/ui/src/components/charts/line/query-builder-line-chart.tsx index 99483e65..97be15d6 100644 --- a/packages/ui/src/components/charts/line/query-builder-line-chart.tsx +++ b/packages/ui/src/components/charts/line/query-builder-line-chart.tsx @@ -229,7 +229,11 @@ export function QueryBuilderLineChart({ return (
- + } /> diff --git a/packages/ui/src/components/ui/chart.tsx b/packages/ui/src/components/ui/chart.tsx index f9eaece4..92fc53bd 100644 --- a/packages/ui/src/components/ui/chart.tsx +++ b/packages/ui/src/components/ui/chart.tsx @@ -37,6 +37,9 @@ function useChart() { export type ChartLegendItem = { key: string; label: React.ReactNode; color?: string } +/** Stable empty reference so the legend-slot publish effect doesn't churn. */ +const EMPTY_LEGEND_ITEMS: ChartLegendItem[] = [] + /** * Optional slot for hoisting a chart's legend out of the plot area and into an * ancestor (e.g. a card header). When a provider is present, `ChartContainer` @@ -52,10 +55,18 @@ function ChartContainer({ className, children, config, + hoistLegend = true, ...props }: React.ComponentProps<"div"> & { config: ChartConfig children: React.ComponentProps["children"] + /** + * When `true` (default) and a {@link ChartLegendSlotContext} ancestor is + * present, the chart's series are published into that slot (e.g. a widget + * header strip). Charts that render their own in-plot legend should pass + * `false` so the header doesn't duplicate it. + */ + hoistLegend?: boolean }) { const uniqueId = React.useId() const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` @@ -73,11 +84,12 @@ function ChartContainer({ })), [config], ) + const publishedItems = hoistLegend ? legendItems : EMPTY_LEGEND_ITEMS React.useEffect(() => { if (!legendSlot) return - legendSlot.setItems(legendItems) + legendSlot.setItems(publishedItems) return () => legendSlot.setItems([]) - }, [legendSlot, legendItems]) + }, [legendSlot, publishedItems]) return (