From 99fbeb8cf84d6504d2ced1d44192172735b6ad39 Mon Sep 17 00:00:00 2001 From: colivi Date: Sat, 19 Sep 2026 10:57:17 +0200 Subject: [PATCH] feat(statchart): configurable multi-series layout (auto/row/grid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add seriesLayout and seriesColumns so multi-value stats can render as a matrix (e.g. Traffic distribution 2×2) instead of a single row. - auto: pick columns from series count (4→2×2, 6→3×2, …) - row: legacy horizontal strip - grid: wrap; optional fixed seriesColumns (2, 3, …) - Options editor: Series layout control - Tests: resolveSeriesColumns coverage Signed-off-by: colivi --- statchart/schemas/stat.cue | 6 ++ .../StatChartOptionsEditorSettings.test.tsx | 42 +++++++++++ .../src/StatChartOptionsEditorSettings.tsx | 41 +++++++++- statchart/src/StatChartPanel.tsx | 75 ++++++++----------- statchart/src/series-layout.test.ts | 67 +++++++++++++++++ statchart/src/stat-chart-model.ts | 43 +++++++++++ 6 files changed, 228 insertions(+), 46 deletions(-) create mode 100644 statchart/src/series-layout.test.ts diff --git a/statchart/schemas/stat.cue b/statchart/schemas/stat.cue index 50dc9cf84..aeae5865c 100644 --- a/statchart/schemas/stat.cue +++ b/statchart/schemas/stat.cue @@ -31,5 +31,11 @@ spec: close({ legendFontSize?: number colorMode?: *"value" | "background_solid" | "none" legendMode?: *"auto" | "on" | "off" + // Multi-series cell arrangement. + // auto — pick columns from series count (2→1×2, 3–4→2×2, 5–6→2×3 / 3×2, …) + // row — single horizontal row (legacy) + // grid — wrap; seriesColumns forces column count when set (2, 3, …) + seriesLayout?: *"auto" | "row" | "grid" + seriesColumns?: number & >=1 & <=12 mappings?: [...common.#mappings] }) diff --git a/statchart/src/StatChartOptionsEditorSettings.test.tsx b/statchart/src/StatChartOptionsEditorSettings.test.tsx index 40e70cc04..cda851f8e 100644 --- a/statchart/src/StatChartOptionsEditorSettings.test.tsx +++ b/statchart/src/StatChartOptionsEditorSettings.test.tsx @@ -99,6 +99,48 @@ describe('StatChartOptionsEditorSettings', () => { ); }); + it('can change series layout to grid', () => { + const onChange = vi.fn(); + renderStatChartOptionsEditorSettings( + { + format: { unit: 'percent' }, + calculation: 'last', + seriesLayout: 'auto', + }, + onChange, + ); + const layoutSelector = screen.getByRole('combobox', { name: 'Series layout' }); + userEvent.click(layoutSelector); + userEvent.click(screen.getByRole('option', { name: /Grid/i })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + seriesLayout: 'grid', + }), + ); + }); + + it('clears seriesColumns when switching layout away from grid', () => { + const onChange = vi.fn(); + renderStatChartOptionsEditorSettings( + { + format: { unit: 'percent' }, + calculation: 'last', + seriesLayout: 'grid', + seriesColumns: 2, + }, + onChange, + ); + const layoutSelector = screen.getByRole('combobox', { name: 'Series layout' }); + userEvent.click(layoutSelector); + userEvent.click(screen.getByRole('option', { name: /^Row$/i })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + seriesLayout: 'row', + seriesColumns: undefined, + }), + ); + }); + it('can disable a sparkline', () => { const onChange = vi.fn(); renderStatChartOptionsEditorSettings( diff --git a/statchart/src/StatChartOptionsEditorSettings.tsx b/statchart/src/StatChartOptionsEditorSettings.tsx index 31c9bce3c..9cdb96c60 100644 --- a/statchart/src/StatChartOptionsEditorSettings.tsx +++ b/statchart/src/StatChartOptionsEditorSettings.tsx @@ -39,11 +39,12 @@ import { useCallback, useMemo } from 'react'; import type { ColorModeLabelItem, + SeriesLayoutMode, ShowLegendLabelItem, StatChartOptions, StatChartOptionsEditorProps, } from './stat-chart-model'; -import { COLOR_MODE_LABELS, SHOW_LEGEND_LABELS } from './stat-chart-model'; +import { COLOR_MODE_LABELS, SERIES_LAYOUT_LABELS, SHOW_LEGEND_LABELS } from './stat-chart-model'; const DEFAULT_FORMAT: FormatOptions = { unit: 'percent-decimal' }; @@ -172,6 +173,43 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp ); }, [value.colorMode, handleColorModeChange]); + const handleSeriesLayoutChange = useCallback( + (_: unknown, newValue: { id: SeriesLayoutMode }) => { + onChange( + produce(value, (draft: StatChartOptions) => { + draft.seriesLayout = newValue.id; + if (newValue.id !== 'grid') { + draft.seriesColumns = undefined; + } + }), + ); + }, + [onChange, value], + ); + + const selectSeriesLayout = useMemo((): ReactElement => { + return ( + ({ + id, + label, + description, + }))} + disableClearable + value={ + SERIES_LAYOUT_LABELS.find((i) => i.id === value.seriesLayout) ?? + SERIES_LAYOUT_LABELS.find((i) => i.id === 'auto')! + } + /> + } + /> + ); + }, [value.seriesLayout, handleSeriesLayoutChange]); + return ( @@ -189,6 +227,7 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp {selectColorMode} + {selectSeriesLayout} diff --git a/statchart/src/StatChartPanel.tsx b/statchart/src/StatChartPanel.tsx index b13621cc0..16c44c324 100644 --- a/statchart/src/StatChartPanel.tsx +++ b/statchart/src/StatChartPanel.tsx @@ -22,6 +22,7 @@ import type { FC } from 'react'; import { useMemo } from 'react'; import type { StatChartOptions } from './stat-chart-model'; +import { resolveSeriesColumns } from './stat-chart-model'; import type { StatChartData } from './StatChartBase'; import { StatChartBase } from './StatChartBase'; import { measureTextWidth } from './utils/calculate-font-size'; @@ -30,7 +31,6 @@ import { convertSparkline } from './utils/data-transform'; import { formatStatChartValue } from './utils/format-stat-chart-value'; import { getStatChartColor } from './utils/get-color'; -const MIN_WIDTH = 100; const SPACING = 2; export type StatChartPanelProps = PanelProps; @@ -38,7 +38,7 @@ export type StatChartPanelProps = PanelProps; export const StatChartPanel: FC = (props) => { const { spec, contentDimensions, queryResults } = props; - const { format, sparkline, valueFontSize, legendFontSize, colorMode } = spec; + const { format, sparkline, valueFontSize, legendFontSize, colorMode, seriesLayout, seriesColumns } = spec; const chartsTheme = useChartsTheme(); const statChartData = useStatChartData(queryResults, spec, chartsTheme); @@ -90,27 +90,27 @@ export const StatChartPanel: FC = (props) => { if (!contentDimensions) return null; - // Calculates chart width — ensure cells are wide enough to show full series names - const spacing = SPACING * (statChartData.length - 1); - let chartWidth = (contentDimensions.width - spacing) / statChartData.length; - if (isMultiSeries) { - const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; - const seriesNameFontSize = legendFontSize ?? Math.max(14, Math.min((contentDimensions.height * 0.15) / 1.2, 30)); - const padding = chartsTheme.container.padding.default; - let maxTextWidth = MIN_WIDTH; - for (const series of statChartData) { - const nameWidth = measureTextWidth(series.seriesData?.name ?? '', 400, seriesNameFontSize, fontFamily); - const valWidth = measureTextWidth( - formatStatChartValue(series.calculatedValue, format), - 700, - seriesNameFontSize * 1.5, - fontFamily, - ); - const needed = Math.max(nameWidth, valWidth) + padding * 2; - if (needed > maxTextWidth) maxTextWidth = needed; - } - chartWidth = Math.max(chartWidth, maxTextWidth); + // Multi-series: auto/grid matrix or legacy single row (see resolveSeriesColumns). + const layoutMode = seriesLayout ?? 'auto'; + const cols = resolveSeriesColumns(statChartData.length, layoutMode, seriesColumns); + const rows = Math.max(1, Math.ceil(statChartData.length / cols)); + const wrap = isMultiSeries && layoutMode !== 'row'; + const spacing = SPACING; + let chartWidth = contentDimensions.width; + if (wrap) { + chartWidth = (contentDimensions.width - spacing * (cols - 1)) / cols; + } else if (isMultiSeries) { + chartWidth = (contentDimensions.width - spacing * (statChartData.length - 1)) / statChartData.length; + } + const chartHeight = wrap + ? (contentDimensions.height - spacing * (rows - 1)) / rows + : contentDimensions.height; + + let overflow: 'hidden' | 'auto' = 'hidden'; + if (!wrap && isMultiSeries) { + overflow = 'auto'; } + const alignContent = wrap ? 'flex-start' : 'center'; const noDataTextStyle = (chartsTheme.noDataOption.title as TitleComponentOption).textStyle; @@ -118,30 +118,15 @@ export const StatChartPanel: FC = (props) => { {statChartData.length ? ( @@ -152,7 +137,7 @@ export const StatChartPanel: FC = (props) => { { + it('single series always 1 col', () => { + expect(resolveSeriesColumns(1, 'auto')).toBe(1); + expect(resolveSeriesColumns(1, 'grid', 3)).toBe(1); + expect(resolveSeriesColumns(1, 'row')).toBe(1); + }); + + it('auto picks matrix from count', () => { + expect(resolveSeriesColumns(2, 'auto')).toBe(2); + expect(resolveSeriesColumns(3, 'auto')).toBe(2); + expect(resolveSeriesColumns(4, 'auto')).toBe(2); // 2×2 + expect(resolveSeriesColumns(5, 'auto')).toBe(3); + expect(resolveSeriesColumns(6, 'auto')).toBe(3); // 3×2 + expect(resolveSeriesColumns(9, 'auto')).toBe(3); // 3×3 + }); + + it('row uses full series count as columns', () => { + expect(resolveSeriesColumns(4, 'row')).toBe(4); + }); + + it('grid honors fixed seriesColumns', () => { + expect(resolveSeriesColumns(6, 'grid', 2)).toBe(2); // 2×3 + expect(resolveSeriesColumns(6, 'grid', 3)).toBe(3); + expect(resolveSeriesColumns(4, 'grid', 2)).toBe(2); + }); + + it('grid without columns falls back to auto', () => { + expect(resolveSeriesColumns(4, 'grid')).toBe(2); + }); + + it('auto large N uses ceil(sqrt(n))', () => { + expect(resolveSeriesColumns(10, 'auto')).toBe(4); // ceil(sqrt(10))=4 → ~3×4 + expect(resolveSeriesColumns(16, 'auto')).toBe(4); + }); + + it('grid columns capped at 12', () => { + expect(resolveSeriesColumns(20, 'grid', 20)).toBe(12); + }); + + // Matrix shape helpers for docs / PR examples + it('documents common farm traffic matrices', () => { + // 4 stacks → 2×2 + const cols4 = resolveSeriesColumns(4, 'grid', 2); + expect(cols4).toBe(2); + expect(Math.ceil(4 / cols4)).toBe(2); + // 6 stacks → 2×3 or 3×2 + expect(Math.ceil(6 / resolveSeriesColumns(6, 'grid', 2))).toBe(3); + expect(Math.ceil(6 / resolveSeriesColumns(6, 'grid', 3))).toBe(2); + }); +}); diff --git a/statchart/src/stat-chart-model.ts b/statchart/src/stat-chart-model.ts index 1c69213da..115f5f77f 100644 --- a/statchart/src/stat-chart-model.ts +++ b/statchart/src/stat-chart-model.ts @@ -37,6 +37,9 @@ export const COLOR_MODE_LABELS: ColorModeLabelItem[] = [ export type legendMode = 'auto' | 'on' | 'off'; +/** Multi-series cell layout. */ +export type SeriesLayoutMode = 'auto' | 'row' | 'grid'; + export type ShowLegendLabelItem = { id: legendMode; label: string; @@ -49,6 +52,16 @@ export const SHOW_LEGEND_LABELS: ShowLegendLabelItem[] = [ { id: 'off', label: 'Off', description: 'Always hide legend' }, ]; +export const SERIES_LAYOUT_LABELS: Array<{ id: SeriesLayoutMode; label: string; description: string }> = [ + { + id: 'auto', + label: 'Auto', + description: 'Pick a matrix from series count (e.g. 4 → 2×2, 6 → 3×2)', + }, + { id: 'row', label: 'Row', description: 'Single horizontal row (legacy)' }, + { id: 'grid', label: 'Grid', description: 'Wrap into a grid; optional fixed column count' }, +]; + export interface StatChartOptions { calculation: CalculationType; format: FormatOptions; @@ -60,6 +73,36 @@ export interface StatChartOptions { mappings?: ValueMapping[]; colorMode?: ColorMode; legendMode?: legendMode; + /** Multi-series arrangement: auto | row | grid (default auto). */ + seriesLayout?: SeriesLayoutMode; + /** Fixed columns when seriesLayout is grid (1–12). Ignored for auto/row. */ + seriesColumns?: number; +} + +/** + * Resolve multi-series grid columns. + * auto: 1→1, 2→2, 3–4→2, 5–6→3, 7–9→3, else ceil(sqrt(n)). + */ +export function resolveSeriesColumns( + seriesCount: number, + seriesLayout: SeriesLayoutMode = 'auto', + seriesColumns?: number, +): number { + if (seriesCount <= 1) { + return 1; + } + if (seriesLayout === 'row') { + return seriesCount; + } + if (seriesLayout === 'grid' && seriesColumns !== undefined && seriesColumns !== null && seriesColumns >= 1) { + return Math.min(12, Math.floor(seriesColumns)); + } + // auto (and grid without columns) + if (seriesCount <= 2) return seriesCount; + if (seriesCount <= 4) return 2; + if (seriesCount <= 6) return 3; + if (seriesCount <= 9) return 3; + return Math.ceil(Math.sqrt(seriesCount)); } export interface StatChartSparklineOptions {