Squash chart bars/legend into top-N + Other for many-series panels

PanelChart's BarChart and PieChart rendered one bar/slice and legend
row per PromqlSeries with no bound, so panels with many series wrapped
the 8-color palette (repeating colors) and produced unreadable,
unbounded legends. Add squashRows(), which sorts by value descending
and collapses series beyond COLORS.length - 1 into a single "Other"
bucket (sum of the rest), applied to both bar and pie chart data before
render. Panels at or below the threshold are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-06 01:44:05 +01:00
co-authored by Claude Sonnet 5
parent 85fb145be7
commit 8a8e3f60ec
+18 -2
View File
@@ -25,6 +25,22 @@ export function seriesLabel(labels: Record<string, string>): string {
// Fixed, small categorical palette to avoid a colour-scale dependency; cycles if there are more series than colours.
const COLORS = ['#38bdf8', '#f472b6', '#a78bfa', '#4ade80', '#fbbf24', '#fb7185', '#2dd4bf', '#f97316']
// One less than the palette size: above this many series, keep the top MAX_KEPT_SERIES (by value) and squash the
// rest into a single "Other" bucket, so the squashed range never exceeds COLORS.length and never repeats a colour.
const MAX_KEPT_SERIES = COLORS.length - 1
// Sorts rows by value descending and, if there are more than MAX_KEPT_SERIES, keeps the top MAX_KEPT_SERIES and
// collapses the remainder into a single "Other" row summing their values. Below the threshold, rows are returned
// unchanged so panels with few series keep their existing bar/slice order and colour assignment.
function squashRows(rows: { name: string; value: number }[]): { name: string; value: number }[] {
if (rows.length <= MAX_KEPT_SERIES) return rows
const sorted = [...rows].sort((a, b) => b.value - a.value)
const kept = sorted.slice(0, MAX_KEPT_SERIES)
const rest = sorted.slice(MAX_KEPT_SERIES)
const otherValue = rest.reduce((sum, r) => sum + r.value, 0)
return [...kept, { name: `Other (${rest.length})`, value: otherValue }]
}
// Merges series into one row-per-timestamp dataset (recharts wants a single array keyed by series name); a timestamp missing from a series is left undefined there, which recharts skips when drawing that line.
function toLineData(series: PromqlSeries[]): { rows: Record<string, number | string>[]; keys: string[] } {
const keys = series.map((s) => seriesLabel(s.labels))
@@ -101,7 +117,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
}
if (chartType === 'bar') {
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) }))
const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: latestValue(s) })))
return (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={rows} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
@@ -119,7 +135,7 @@ export function PanelChart({ series, chartType }: { series: PromqlSeries[]; char
)
}
const rows = series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) }))
const rows = squashRows(series.map((s) => ({ name: seriesLabel(s.labels), value: sumValue(s) })))
const total = rows.reduce((sum, r) => sum + r.value, 0)
if (total === 0) {
return <div className="flex h-48 items-center justify-center text-xs text-text-faint">No non-zero values in range.</div>