diff --git a/statchart/schemas/migrate/migrate.cue b/statchart/schemas/migrate/migrate.cue index 3a98be0b..87be6d01 100644 --- a/statchart/schemas/migrate/migrate.cue +++ b/statchart/schemas/migrate/migrate.cue @@ -36,17 +36,13 @@ kind: "StatChart" spec: { calculation: *commonMigrate.#mapping.calc[#panel.options.reduceOptions.calcs[0]] | commonMigrate.#defaultCalc // only consider [0] here as Perses's GaugeChart doesn't support individual calcs - // metricLabel - #textMode: *#panel.options.textMode | null - if #textMode == "name" && (*#panel.targets[0].legendFormat | null) != null { - // /!\ best effort logic - // - if legendFormat contains a more complex expression than {{label}}, the result will be broken (but manually fixable afterwards still) - // - Perses's metricLabel is a single setting at panel level, hence the [0], so the result wont fit in case of multiple queries using different legendFormat - metricLabel: strings.Trim(#panel.targets[0].legendFormat, "{}") - } - // /!\ here too using [0] thus not perfect, even though the field getting remapped is unique to the whole panel in that case - if #textMode == "auto" && (*#panel.targets[0].format | null) == "table" && (*#panel.options.reduceOptions.fields | null) != null { - metricLabel: strings.Trim(#panel.options.reduceOptions.fields, "/^$") + // textMode - map directly from Grafana's BigValueTextMode enum + textMode: *#panel.options.textMode | "auto" + + // metricLabel - map from reduceOptions.fields for field selection + #fields: *#panel.options.reduceOptions.fields | null + if #fields != null && #fields != "" { + metricLabel: strings.Trim(#fields, "/^$") } // format diff --git a/statchart/schemas/migrate/tests/basic/expected.json b/statchart/schemas/migrate/tests/basic/expected.json index f76d3311..7b620753 100644 --- a/statchart/schemas/migrate/tests/basic/expected.json +++ b/statchart/schemas/migrate/tests/basic/expected.json @@ -2,6 +2,7 @@ "kind": "StatChart", "spec": { "calculation": "last-number", + "textMode": "auto", "format": { "decimalPlaces": 2, "unit": "bytes/sec" diff --git a/statchart/schemas/migrate/tests/label-display-from-legend/expected.json b/statchart/schemas/migrate/tests/label-display-from-legend/expected.json index 581ef4ff..42c209b5 100644 --- a/statchart/schemas/migrate/tests/label-display-from-legend/expected.json +++ b/statchart/schemas/migrate/tests/label-display-from-legend/expected.json @@ -1,7 +1,7 @@ { "kind": "StatChart", "spec": { - "metricLabel": "version", + "textMode": "name", "format": { "unit": "decimal" }, diff --git a/statchart/schemas/migrate/tests/label-display-from-options/expected.json b/statchart/schemas/migrate/tests/label-display-from-options/expected.json index e00ad170..b9314baa 100644 --- a/statchart/schemas/migrate/tests/label-display-from-options/expected.json +++ b/statchart/schemas/migrate/tests/label-display-from-options/expected.json @@ -1,6 +1,7 @@ { "kind": "StatChart", "spec": { + "textMode": "auto", "metricLabel": "version", "format": { "unit": "decimal" diff --git a/statchart/schemas/migrate/tests/mappings/expected.json b/statchart/schemas/migrate/tests/mappings/expected.json index 069e6ade..61b7d7c6 100644 --- a/statchart/schemas/migrate/tests/mappings/expected.json +++ b/statchart/schemas/migrate/tests/mappings/expected.json @@ -2,6 +2,7 @@ "kind": "StatChart", "spec": { "calculation": "last", + "textMode": "auto", "mappings": [ { "kind": "Value", diff --git a/statchart/schemas/migrate/tests/overrides_to_mappings/expected.json b/statchart/schemas/migrate/tests/overrides_to_mappings/expected.json index 0eb0cec4..e9db6aec 100644 --- a/statchart/schemas/migrate/tests/overrides_to_mappings/expected.json +++ b/statchart/schemas/migrate/tests/overrides_to_mappings/expected.json @@ -2,6 +2,7 @@ "kind": "StatChart", "spec": { "calculation": "mean", + "textMode": "auto", "format": { "unit": "decimal" }, diff --git a/statchart/schemas/stat.cue b/statchart/schemas/stat.cue index 41347422..3b8bff02 100644 --- a/statchart/schemas/stat.cue +++ b/statchart/schemas/stat.cue @@ -20,6 +20,7 @@ import ( kind: "StatChart" spec: close({ calculation: common.#calculation + textMode?: "auto" | "value" | "name" | "none" | "value_and_name" metricLabel?: common.#metricLabel format?: common.#format thresholds?: common.#thresholds diff --git a/statchart/src/StatChartBase.test.tsx b/statchart/src/StatChartBase.test.tsx index 1ce8a13c..7c3edfbb 100644 --- a/statchart/src/StatChartBase.test.tsx +++ b/statchart/src/StatChartBase.test.tsx @@ -39,7 +39,8 @@ describe('StatChart', () => { }; const mockStatData: StatChartData = { - calculatedValue: 7.72931659687181, + numericValue: 7.72931659687181, + displayValue: 7.72931659687181, color: '#1976d2', seriesData: { name: '(((count(count(node_cpu_seconds_total{job="example"}) by (cpu))', diff --git a/statchart/src/StatChartBase.tsx b/statchart/src/StatChartBase.tsx index 57efc90f..56176eb9 100644 --- a/statchart/src/StatChartBase.tsx +++ b/statchart/src/StatChartBase.tsx @@ -23,7 +23,7 @@ import { EChart, FontSizeOption, GraphSeries, useChartsTheme } from '@perses-dev import chroma from 'chroma-js'; import { useOptimalFontSize } from './utils/calculate-font-size'; import { formatStatChartValue } from './utils/format-stat-chart-value'; -import { ColorMode } from './stat-chart-model'; +import { ColorMode, TextMode } from './stat-chart-model'; use([EChartsLineChart, GridComponent, DatasetComponent, TitleComponent, TooltipComponent, CanvasRenderer]); @@ -36,7 +36,9 @@ const BLACK_COLOR_CODE = '#000000'; export interface StatChartData { color: string; - calculatedValue?: string | number | null; + numericValue?: number | null; + displayValue?: string | number | null; + displayName?: string; seriesData?: GraphSeries; } @@ -46,9 +48,11 @@ export interface StatChartProps { data: StatChartData; format?: FormatOptions; sparkline?: LineSeriesOption; - showSeriesName?: boolean; valueFontSize?: FontSizeOption; colorMode?: ColorMode; + textMode?: TextMode; + isMultiSeries?: boolean; + legendMode?: 'auto' | 'on' | 'off'; } export const StatChartBase: FC = (props) => { @@ -58,10 +62,12 @@ export const StatChartBase: FC = (props) => { data, data: { color }, sparkline, - showSeriesName, format, valueFontSize, colorMode, + textMode = 'auto', + isMultiSeries = false, + legendMode = 'auto', } = props; const { @@ -71,12 +77,22 @@ export const StatChartBase: FC = (props) => { }, } = useTheme(); const chartsTheme = useChartsTheme(); - const formattedValue = formatStatChartValue(data.calculatedValue, format); + const formattedValue = formatStatChartValue(data.displayValue, format); const containerPadding = chartsTheme.container.padding.default; - // calculate series name font size and height + // Determine if top text should be shown + // Respect explicit legendMode choice, otherwise let textMode decide + const shouldShowTopText = (() => { + if (legendMode === 'off') return false; + if (legendMode === 'on') return true; + + // legendMode='auto': let textMode decide + return textMode === 'value_and_name' || (textMode === 'auto' && isMultiSeries); + })(); + + // calculate top text font size and height let seriesNameFontSize = useOptimalFontSize({ - text: data?.seriesData?.name ?? '', + text: data.displayName ?? '', fontWeight: SERIES_NAME_FONT_WEIGHT, width, height: height * 0.125, // assume series name will take 12.5% of available height @@ -84,7 +100,7 @@ export const StatChartBase: FC = (props) => { maxSize: SERIES_NAME_MAX_FONT_SIZE, }); - const seriesNameHeight = showSeriesName ? seriesNameFontSize * LINE_HEIGHT + containerPadding : 0; + const seriesNameHeight = shouldShowTopText ? seriesNameFontSize * LINE_HEIGHT + containerPadding : 0; // calculate value font size and height const availableWidth = width - containerPadding * 2; @@ -199,7 +215,7 @@ export const StatChartBase: FC = (props) => { }, [colorMode, containerPadding, optimalValueFontSize, formattedValue, color, paletteMode]); const seriesName = useMemo((): ReactNode | null => { - if (!showSeriesName) return null; + if (!shouldShowTopText || !data.displayName) return null; let textColor = ''; @@ -219,10 +235,10 @@ export const StatChartBase: FC = (props) => { return ( - {data.seriesData?.name} + {data.displayName} ); - }, [colorMode, showSeriesName, secondary, color, containerPadding, seriesNameFontSize, data?.seriesData?.name]); + }, [colorMode, shouldShowTopText, secondary, color, containerPadding, seriesNameFontSize, data.displayName]); return ( = (props) => { }} > {seriesName} - {styledFormattedValue} + {data.displayValue !== undefined && textMode !== 'none' && styledFormattedValue} {sparkline && ( { + onChange( + produce(value, (draft: StatChartOptions) => { + draft.textMode = newTextMode.id; + }) + ); + }, + [onChange, value] + ); + const handleUnitChange: FormatControlsProps['onChange'] = (newFormat) => { onChange( produce(value, (draft: StatChartOptions) => { @@ -164,6 +177,24 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp ); }, [value.colorMode, handleColorModeChange]); + const selectTextMode = useMemo((): ReactElement => { + return ( + i.id === value.textMode) ?? TEXT_MODE_LABELS.find((i) => i.id === 'auto')! + } + /> + } + /> + ); + }, [value.textMode, handleTextModeChange]); + return ( @@ -175,6 +206,7 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp /> + {selectTextMode} {selectColorMode} diff --git a/statchart/src/StatChartPanel.tsx b/statchart/src/StatChartPanel.tsx index 359137d7..2cb4c79d 100644 --- a/statchart/src/StatChartPanel.tsx +++ b/statchart/src/StatChartPanel.tsx @@ -31,15 +31,12 @@ export type StatChartPanelProps = PanelProps; export const StatChartPanel: FC = (props) => { const { spec, contentDimensions, queryResults } = props; - const { format, sparkline, valueFontSize: valueFontSize, colorMode } = spec; + const { format, sparkline, valueFontSize: valueFontSize, colorMode, textMode, legendMode } = spec; const chartsTheme = useChartsTheme(); const statChartData = useStatChartData(queryResults, spec, chartsTheme); const isMultiSeries = statChartData.length > 1; - // Handle three-state showLegend: 'on' | 'off' | 'auto' (or undefined for backward compatibility) - const shouldShowLegend = spec.legendMode === 'on' ? true : spec.legendMode === 'off' ? false : isMultiSeries; - if (!contentDimensions) return null; // Calculates chart width @@ -75,9 +72,11 @@ export const StatChartPanel: FC = (props) => { data={series} format={format} sparkline={sparklineConfig} - showSeriesName={shouldShowLegend} valueFontSize={valueFontSize} colorMode={colorMode} + textMode={textMode} + isMultiSeries={isMultiSeries} + legendMode={legendMode} /> ); }) @@ -94,33 +93,102 @@ const useStatChartData = ( chartsTheme: PersesChartsTheme ): StatChartData[] => { return useMemo(() => { - const { calculation, mappings, metricLabel } = spec; + const { calculation, mappings, metricLabel, textMode } = spec; const statChartData: StatChartData[] = []; + + // Count total series to determine if multi-series + const totalSeries = queryResults.reduce((sum, result) => sum + result.data.series.length, 0); + const isMultiSeries = totalSeries > 1; + for (const result of queryResults) { for (const seriesData of result.data.series) { - const calculatedValue = calculateValue(calculation, seriesData); + const numericValue = calculateValue(calculation, seriesData); - // get label metric value + // Get label if metricLabel is set const labelValue = getLabelValue(metricLabel, seriesData.labels); - // get actual value to display - const displayValue = getValueOrLabel(calculatedValue, mappings, labelValue); + // Use formattedName (with legend format applied) or fallback to raw name + const formattedSeriesName = seriesData.formattedName ?? seriesData.name; + + // Determine what to display based on textMode + const { displayValue, displayName } = getDisplayContent({ + textMode: textMode ?? 'auto', + numericValue, + labelValue, + seriesName: formattedSeriesName, + isMultiSeries, + mappings, + }); - const color = getStatChartColor(chartsTheme, spec, calculatedValue); + // Color based on numeric value (always) + const color = getStatChartColor(chartsTheme, spec, numericValue); const series: GraphSeries = { - name: seriesData.formattedName ?? '', + name: formattedSeriesName, values: seriesData.values, }; - statChartData.push({ calculatedValue: displayValue, seriesData: series, color }); + statChartData.push({ + numericValue, + displayValue, + displayName, + seriesData: series, + color, + }); } } return statChartData; }, [queryResults, spec, chartsTheme]); }; +const getDisplayContent = ({ + textMode, + numericValue, + labelValue, + seriesName, + mappings, + isMultiSeries, +}: { + textMode: string; + numericValue?: number | null; + labelValue?: string; + seriesName: string; + mappings?: ValueMapping[]; + isMultiSeries: boolean; +}): { displayValue?: string | number | null; displayName?: string } => { + const formattedValue = getValueOrLabel(numericValue, mappings, labelValue); + + switch (textMode) { + case 'value': + return { + displayValue: formattedValue, + displayName: undefined, + }; + case 'name': + return { + displayValue: seriesName, + displayName: undefined, + }; + case 'value_and_name': + return { + displayValue: formattedValue, + displayName: seriesName, + }; + case 'none': + return { + displayValue: undefined, + displayName: undefined, + }; + case 'auto': + default: + return { + displayValue: formattedValue, + displayName: isMultiSeries ? seriesName : undefined, + }; + } +}; + const getValueOrLabel = ( value?: number | null, mappings?: ValueMapping[], diff --git a/statchart/src/stat-chart-model.ts b/statchart/src/stat-chart-model.ts index a44d20bd..e6187425 100644 --- a/statchart/src/stat-chart-model.ts +++ b/statchart/src/stat-chart-model.ts @@ -49,8 +49,25 @@ export const SHOW_LEGEND_LABELS: ShowLegendLabelItem[] = [ { id: 'off', label: 'Off', description: 'Always hide legend' }, ]; +export type TextMode = 'auto' | 'value' | 'name' | 'none' | 'value_and_name'; + +export type TextModeLabelItem = { + id: TextMode; + label: string; + description?: string; +}; + +export const TEXT_MODE_LABELS: TextModeLabelItem[] = [ + { id: 'auto', label: 'Auto', description: 'Show value by default, or value and name when label is present' }, + { id: 'value', label: 'Value', description: 'Show only the calculated value' }, + { id: 'name', label: 'Name', description: 'Show only the series name' }, + { id: 'value_and_name', label: 'Value and name', description: 'Show both value and name' }, + { id: 'none', label: 'None', description: 'Show nothing (empty)' }, +]; + export interface StatChartOptions { calculation: CalculationType; + textMode?: TextMode; format: FormatOptions; metricLabel?: string; thresholds?: ThresholdOptions; @@ -76,5 +93,6 @@ export function createInitialStatChartOptions(): StatChartOptions { }, sparkline: {}, legendMode: 'auto', + textMode: 'auto', }; }