From bfa6c598652e13d941156f83b4abd4cd4a151908 Mon Sep 17 00:00:00 2001 From: christophrichtersap Date: Mon, 24 Aug 2026 10:18:20 +0200 Subject: [PATCH 1/4] fix(statchart): multi-series auto-sizing cells with thin scrollbar (#3519) - Cells auto-size to fit full series names (no truncation) - Thin scrollbar appears on hover when content overflows - Consistent font sizing across multi-series panels - Add measureTextWidth utility for pixel-accurate layout Signed-off-by: christophrichtersap --- statchart/src/StatChartBase.tsx | 36 ++++++---- statchart/src/StatChartPanel.tsx | 81 ++++++++++++++++++++-- statchart/src/utils/calculate-font-size.ts | 9 +++ 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/statchart/src/StatChartBase.tsx b/statchart/src/StatChartBase.tsx index 2ac458ee1..a612f230f 100644 --- a/statchart/src/StatChartBase.tsx +++ b/statchart/src/StatChartBase.tsx @@ -53,6 +53,8 @@ export interface StatChartProps { showSeriesName?: boolean; valueFontSize?: FontSizeOption; colorMode?: ColorMode; + alignmentText?: string; + alignmentSeriesName?: string; } export const StatChartBase: FC = (props) => { @@ -66,6 +68,8 @@ export const StatChartBase: FC = (props) => { format, valueFontSize, colorMode, + alignmentText, + alignmentSeriesName, } = props; const { @@ -78,37 +82,40 @@ export const StatChartBase: FC = (props) => { const formattedValue = formatStatChartValue(data.calculatedValue, format); const containerPadding = chartsTheme.container.padding.default; - // calculate series name font size and height + const availableWidth = width - containerPadding * 2; + + // in multi-series: legend gets a fixed portion of height (like Grafana) let seriesNameFontSize = useOptimalFontSize({ text: data?.seriesData?.name ?? '', fontWeight: SERIES_NAME_FONT_WEIGHT, width, - height: height * 0.125, // assume series name will take 12.5% of available height + height: height * 0.2, lineHeight: LINE_HEIGHT, maxSize: SERIES_NAME_MAX_FONT_SIZE, }); + if (alignmentSeriesName !== undefined) { + // multi-series: use 15% of cell height for legend, clamped between 14px and 30px + seriesNameFontSize = Math.max(14, Math.min((height * 0.15) / LINE_HEIGHT, SERIES_NAME_MAX_FONT_SIZE)); + } + const seriesNameHeight = showSeriesName ? seriesNameFontSize * LINE_HEIGHT + containerPadding : 0; - // calculate value font size and height - const availableWidth = width - containerPadding * 2; const availableHeight = height - seriesNameHeight; const optimalValueFontSize = useOptimalFontSize({ - text: formattedValue, - // override the font size if user selects it in the settings + text: alignmentText || formattedValue, fontSizeOverride: valueFontSize, fontWeight: VALUE_FONT_WEIGHT, - // without sparkline, use only 50% of the available width so it looks better for multiseries width: sparkline ? availableWidth : availableWidth * 0.5, - // with sparkline, use only 25% of available height to leave room for chart - // without sparkline, value should take up 90% of available space height: sparkline ? availableHeight * 0.25 : availableHeight * 0.9, lineHeight: LINE_HEIGHT, }); const valueFontHeight = optimalValueFontSize * LINE_HEIGHT; - // make sure the series name font size is slightly smaller than value font size - seriesNameFontSize = Math.min(optimalValueFontSize * 0.7, seriesNameFontSize); + // single-series: keep legend smaller than value + if (alignmentSeriesName === undefined) { + seriesNameFontSize = Math.min(optimalValueFontSize * 0.7, seriesNameFontSize); + } const option: EChartsCoreOption = useMemo(() => { if (!data.seriesData) return chartsTheme.noDataOption; @@ -232,7 +239,10 @@ export const StatChartBase: FC = (props) => { = (props) => { const isMultiSeries = statChartData.length > 1; + // Find the widest value text (by pixel width) to use as alignment reference + const alignmentText = useMemo(() => { + if (!isMultiSeries) return undefined; + const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; + const fontSize = Number(chartsTheme.echartsTheme.textStyle?.fontSize) ?? 12; + let widest = ''; + let maxWidth = 0; + for (const series of statChartData) { + const formatted = formatStatChartValue(series.calculatedValue, format); + const width = measureTextWidth(formatted, 700, fontSize, fontFamily); + if (width > maxWidth) { + maxWidth = width; + widest = formatted; + } + } + return widest; + }, [statChartData, format, isMultiSeries, chartsTheme.echartsTheme.textStyle]); + + // Find the longest series name (by pixel width) to unify legend sizing + const alignmentSeriesName = useMemo(() => { + if (!isMultiSeries) return undefined; + const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; + const fontSize = Number(chartsTheme.echartsTheme.textStyle?.fontSize) ?? 12; + let widest = ''; + let maxWidth = 0; + for (const series of statChartData) { + const name = series.seriesData?.name ?? ''; + const width = measureTextWidth(name, 400, fontSize, fontFamily); + if (width > maxWidth) { + maxWidth = width; + widest = name; + } + } + return widest; + }, [statChartData, isMultiSeries, chartsTheme.echartsTheme.textStyle]); + // Handle three-state showLegend: 'on' | 'off' | 'auto' (or undefined for backward compatibility) let shouldShowLegend = isMultiSeries; if (spec.legendMode === 'on') { @@ -52,11 +90,26 @@ export const StatChartPanel: FC = (props) => { if (!contentDimensions) return null; - // Calculates chart width + // 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 && chartWidth < MIN_WIDTH) { - chartWidth = MIN_WIDTH; + if (isMultiSeries) { + const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; + const seriesNameFontSize = 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); } const noDataTextStyle = (chartsTheme.noDataOption.title as TitleComponentOption).textStyle; @@ -70,7 +123,25 @@ export const StatChartPanel: FC = (props) => { justifyContent={isMultiSeries ? 'left' : 'center'} alignItems="center" sx={{ - overflowX: isMultiSeries ? 'scroll' : 'auto', + overflowX: isMultiSeries ? 'auto' : 'hidden', + '&::-webkit-scrollbar': { + height: '4px', + }, + '&::-webkit-scrollbar-track': { + background: 'transparent', + }, + '&::-webkit-scrollbar-thumb': { + background: 'transparent', + borderRadius: '2px', + }, + '&:hover::-webkit-scrollbar-thumb': { + background: 'rgba(128, 128, 128, 0.4)', + }, + scrollbarWidth: 'thin', + scrollbarColor: 'transparent transparent', + '&:hover': { + scrollbarColor: 'rgba(128, 128, 128, 0.4) transparent', + }, }} > {statChartData.length ? ( @@ -88,6 +159,8 @@ export const StatChartPanel: FC = (props) => { showSeriesName={shouldShowLegend} valueFontSize={valueFontSize} colorMode={colorMode} + alignmentText={alignmentText} + alignmentSeriesName={alignmentSeriesName} /> ); }) diff --git a/statchart/src/utils/calculate-font-size.ts b/statchart/src/utils/calculate-font-size.ts index c5ef2ca8c..d4116646b 100644 --- a/statchart/src/utils/calculate-font-size.ts +++ b/statchart/src/utils/calculate-font-size.ts @@ -36,6 +36,15 @@ function getGlobalCanvasContext(): CanvasRenderingContext2D { return canvasContext; } +/** + * Measure the pixel width of text at a given font weight and size. + */ +export function measureTextWidth(text: string, fontWeight: number, fontSize: number, fontFamily: string): number { + const ctx = getGlobalCanvasContext(); + ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; + return ctx.measureText(text).width; +} + /** * Find the optimal font size given available space */ From e390813cba34ae7847b1d62e65e378f32921b10e Mon Sep 17 00:00:00 2001 From: christophrichtersap Date: Thu, 3 Sep 2026 23:49:36 +0200 Subject: [PATCH 2/4] fix(statchart): add legendFontSize to CUE schema and model Persist the new legendFontSize option in the plugin spec, TypeScript model, and options editor so it survives save/reload. Signed-off-by: christophrichtersap --- statchart/schemas/stat.cue | 5 +++-- statchart/src/StatChartOptionsEditorSettings.tsx | 13 ++++++++++++- statchart/src/StatChartPanel.tsx | 5 +++-- statchart/src/stat-chart-model.ts | 1 + 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/statchart/schemas/stat.cue b/statchart/schemas/stat.cue index 582c3fc47..88fa9a787 100644 --- a/statchart/schemas/stat.cue +++ b/statchart/schemas/stat.cue @@ -27,8 +27,9 @@ spec: close({ color?: string width?: number }) - valueFontSize?: number - colorMode?: *"value" | "background_solid" | "none" + valueFontSize?: number + legendFontSize?: number + colorMode?: *"value" | "background_solid" | "none" legendMode?: *"auto" | "on" | "off" mappings?: [...common.#mappings] }) diff --git a/statchart/src/StatChartOptionsEditorSettings.tsx b/statchart/src/StatChartOptionsEditorSettings.tsx index b965a2ff9..31c9bce3c 100644 --- a/statchart/src/StatChartOptionsEditorSettings.tsx +++ b/statchart/src/StatChartOptionsEditorSettings.tsx @@ -115,6 +115,14 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp ); }; + const handleLegendFontSizeChange: FontSizeSelectorProps['onChange'] = (fontSize: FontSizeOption) => { + onChange( + produce(value, (draft: StatChartOptions) => { + draft.legendFontSize = fontSize; + }), + ); + }; + const handleColorModeChange = useCallback( (_: unknown, newColorMode: ColorModeLabelItem): void => { onChange( @@ -167,7 +175,10 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp return ( - {selectShowLegend} + + {selectShowLegend} + + ; export const StatChartPanel: FC = (props) => { const { spec, contentDimensions, queryResults } = props; - const { format, sparkline, valueFontSize, colorMode } = spec; + const { format, sparkline, valueFontSize, legendFontSize, colorMode } = spec; const chartsTheme = useChartsTheme(); const statChartData = useStatChartData(queryResults, spec, chartsTheme); @@ -95,7 +95,7 @@ export const StatChartPanel: FC = (props) => { let chartWidth = (contentDimensions.width - spacing) / statChartData.length; if (isMultiSeries) { const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; - const seriesNameFontSize = Math.max(14, Math.min((contentDimensions.height * 0.15) / 1.2, 30)); + 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) { @@ -159,6 +159,7 @@ export const StatChartPanel: FC = (props) => { showSeriesName={shouldShowLegend} valueFontSize={valueFontSize} colorMode={colorMode} + legendFontSize={legendFontSize} alignmentText={alignmentText} alignmentSeriesName={alignmentSeriesName} /> diff --git a/statchart/src/stat-chart-model.ts b/statchart/src/stat-chart-model.ts index dee7a575f..1c69213da 100644 --- a/statchart/src/stat-chart-model.ts +++ b/statchart/src/stat-chart-model.ts @@ -56,6 +56,7 @@ export interface StatChartOptions { thresholds?: ThresholdOptions; sparkline?: StatChartSparklineOptions; valueFontSize?: FontSizeOption; + legendFontSize?: FontSizeOption; mappings?: ValueMapping[]; colorMode?: ColorMode; legendMode?: legendMode; From fb7d99aec8dd7abdeda6aa41607f725557c298fd Mon Sep 17 00:00:00 2001 From: christophrichtersap Date: Thu, 3 Sep 2026 23:57:17 +0200 Subject: [PATCH 3/4] fix(statchart): fix CUE alignment and lint nullish-coalescing errors Signed-off-by: christophrichtersap --- statchart/schemas/stat.cue | 2 +- statchart/src/StatChartPanel.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/statchart/schemas/stat.cue b/statchart/schemas/stat.cue index 88fa9a787..50dc9cf84 100644 --- a/statchart/schemas/stat.cue +++ b/statchart/schemas/stat.cue @@ -30,6 +30,6 @@ spec: close({ valueFontSize?: number legendFontSize?: number colorMode?: *"value" | "background_solid" | "none" - legendMode?: *"auto" | "on" | "off" + legendMode?: *"auto" | "on" | "off" mappings?: [...common.#mappings] }) diff --git a/statchart/src/StatChartPanel.tsx b/statchart/src/StatChartPanel.tsx index 1d436c211..b13621cc0 100644 --- a/statchart/src/StatChartPanel.tsx +++ b/statchart/src/StatChartPanel.tsx @@ -48,7 +48,7 @@ export const StatChartPanel: FC = (props) => { const alignmentText = useMemo(() => { if (!isMultiSeries) return undefined; const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; - const fontSize = Number(chartsTheme.echartsTheme.textStyle?.fontSize) ?? 12; + const fontSize = Number(chartsTheme.echartsTheme.textStyle?.fontSize) || 12; let widest = ''; let maxWidth = 0; for (const series of statChartData) { @@ -66,7 +66,7 @@ export const StatChartPanel: FC = (props) => { const alignmentSeriesName = useMemo(() => { if (!isMultiSeries) return undefined; const fontFamily = chartsTheme.echartsTheme.textStyle?.fontFamily ?? 'Lato'; - const fontSize = Number(chartsTheme.echartsTheme.textStyle?.fontSize) ?? 12; + const fontSize = Number(chartsTheme.echartsTheme.textStyle?.fontSize) || 12; let widest = ''; let maxWidth = 0; for (const series of statChartData) { @@ -104,7 +104,7 @@ export const StatChartPanel: FC = (props) => { formatStatChartValue(series.calculatedValue, format), 700, seriesNameFontSize * 1.5, - fontFamily + fontFamily, ); const needed = Math.max(nameWidth, valWidth) + padding * 2; if (needed > maxTextWidth) maxTextWidth = needed; From 2d4136438d775ec3b8ed78c8a11767f8494f42db Mon Sep 17 00:00:00 2001 From: christophrichtersap Date: Fri, 4 Sep 2026 00:03:52 +0200 Subject: [PATCH 4/4] fix(statchart): add legendFontSize prop to StatChartBase interface Signed-off-by: christophrichtersap --- statchart/src/StatChartBase.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/statchart/src/StatChartBase.tsx b/statchart/src/StatChartBase.tsx index a612f230f..d64d21eb1 100644 --- a/statchart/src/StatChartBase.tsx +++ b/statchart/src/StatChartBase.tsx @@ -52,6 +52,7 @@ export interface StatChartProps { sparkline?: LineSeriesOption; showSeriesName?: boolean; valueFontSize?: FontSizeOption; + legendFontSize?: FontSizeOption; colorMode?: ColorMode; alignmentText?: string; alignmentSeriesName?: string; @@ -67,6 +68,7 @@ export const StatChartBase: FC = (props) => { showSeriesName, format, valueFontSize, + legendFontSize, colorMode, alignmentText, alignmentSeriesName, @@ -94,7 +96,9 @@ export const StatChartBase: FC = (props) => { maxSize: SERIES_NAME_MAX_FONT_SIZE, }); - if (alignmentSeriesName !== undefined) { + if (legendFontSize !== undefined) { + seriesNameFontSize = legendFontSize; + } else if (alignmentSeriesName !== undefined) { // multi-series: use 15% of cell height for legend, clamped between 14px and 30px seriesNameFontSize = Math.max(14, Math.min((height * 0.15) / LINE_HEIGHT, SERIES_NAME_MAX_FONT_SIZE)); } @@ -112,8 +116,8 @@ export const StatChartBase: FC = (props) => { }); const valueFontHeight = optimalValueFontSize * LINE_HEIGHT; - // single-series: keep legend smaller than value - if (alignmentSeriesName === undefined) { + // single-series: keep legend smaller than value (unless explicitly set) + if (alignmentSeriesName === undefined && legendFontSize === undefined) { seriesNameFontSize = Math.min(optimalValueFontSize * 0.7, seriesNameFontSize); }