Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions statchart/schemas/stat.cue
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ spec: close({
color?: string
width?: number
})
valueFontSize?: number
colorMode?: *"value" | "background_solid" | "none"
legendMode?: *"auto" | "on" | "off"
valueFontSize?: number
legendFontSize?: number
colorMode?: *"value" | "background_solid" | "none"
legendMode?: *"auto" | "on" | "off"
mappings?: [...common.#mappings]
})
40 changes: 26 additions & 14 deletions statchart/src/StatChartBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ export interface StatChartProps {
sparkline?: LineSeriesOption;
showSeriesName?: boolean;
valueFontSize?: FontSizeOption;
legendFontSize?: FontSizeOption;
colorMode?: ColorMode;
alignmentText?: string;
alignmentSeriesName?: string;
}

export const StatChartBase: FC<StatChartProps> = (props) => {
Expand All @@ -65,7 +68,10 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
showSeriesName,
format,
valueFontSize,
legendFontSize,
colorMode,
alignmentText,
alignmentSeriesName,
} = props;

const {
Expand All @@ -78,37 +84,42 @@ export const StatChartBase: FC<StatChartProps> = (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 (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));
}

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 (unless explicitly set)
if (alignmentSeriesName === undefined && legendFontSize === undefined) {
seriesNameFontSize = Math.min(optimalValueFontSize * 0.7, seriesNameFontSize);
}

const option: EChartsCoreOption = useMemo(() => {
if (!data.seriesData) return chartsTheme.noDataOption;
Expand Down Expand Up @@ -232,7 +243,10 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
<Box
sx={{
height: '100%',
width: '100%',
width: width,
minWidth: width,
flexShrink: 0,
overflow: 'hidden',
backgroundColor: colorMode === 'background_solid' ? color : 'transparent',
display: 'flex',
flexDirection: 'column',
Expand Down Expand Up @@ -267,8 +281,6 @@ const SeriesName = styled(Typography, {
color: color,
padding: `${padding}px`,
fontSize: `${fontSize}px`,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}));

Expand Down
13 changes: 12 additions & 1 deletion statchart/src/StatChartOptionsEditorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -167,7 +175,10 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
return (
<OptionsEditorGrid>
<OptionsEditorColumn>
<OptionsEditorGroup title="Legend">{selectShowLegend}</OptionsEditorGroup>
<OptionsEditorGroup title="Legend">
{selectShowLegend}
<FontSizeSelector value={value.legendFontSize} onChange={handleLegendFontSizeChange} />
</OptionsEditorGroup>
<OptionsEditorGroup title="Misc">
<OptionsEditorControl
label="Sparkline"
Expand Down
84 changes: 79 additions & 5 deletions statchart/src/StatChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import { useMemo } from 'react';
import type { StatChartOptions } from './stat-chart-model';
import type { StatChartData } from './StatChartBase';
import { StatChartBase } from './StatChartBase';
import { measureTextWidth } from './utils/calculate-font-size';
import { calculateValue } from './utils/calculate-value';
import { convertSparkline } from './utils/data-transform';
import { formatStatChartValue } from './utils/format-stat-chart-value';
import { getStatChartColor } from './utils/get-color';

const MIN_WIDTH = 100;
Expand All @@ -36,12 +38,48 @@ export type StatChartPanelProps = PanelProps<StatChartOptions, TimeSeriesData>;
export const StatChartPanel: FC<StatChartPanelProps> = (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);

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') {
Expand All @@ -52,11 +90,26 @@ export const StatChartPanel: FC<StatChartPanelProps> = (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 = 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);
}

const noDataTextStyle = (chartsTheme.noDataOption.title as TitleComponentOption).textStyle;
Expand All @@ -70,7 +123,25 @@ export const StatChartPanel: FC<StatChartPanelProps> = (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 ? (
Expand All @@ -88,6 +159,9 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
showSeriesName={shouldShowLegend}
valueFontSize={valueFontSize}
colorMode={colorMode}
legendFontSize={legendFontSize}
alignmentText={alignmentText}
alignmentSeriesName={alignmentSeriesName}
/>
);
})
Expand Down
1 change: 1 addition & 0 deletions statchart/src/stat-chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface StatChartOptions {
thresholds?: ThresholdOptions;
sparkline?: StatChartSparklineOptions;
valueFontSize?: FontSizeOption;
legendFontSize?: FontSizeOption;
mappings?: ValueMapping[];
colorMode?: ColorMode;
legendMode?: legendMode;
Expand Down
9 changes: 9 additions & 0 deletions statchart/src/utils/calculate-font-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading