Skip to content
Closed
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
6 changes: 6 additions & 0 deletions statchart/schemas/stat.cue
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})
42 changes: 42 additions & 0 deletions statchart/src/StatChartOptionsEditorSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,48 @@
);
});

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 }));

Check failure on line 135 in statchart/src/StatChartOptionsEditorSettings.test.tsx

View workflow job for this annotation

GitHub Actions / test-npm

src/StatChartOptionsEditorSettings.test.tsx > StatChartOptionsEditorSettings > clears seriesColumns when switching layout away from grid

TestingLibraryElementError: Unable to find an accessible element with the role "option" and name `/^Row$/i` Here are the accessible roles: heading: Name "Legend": <h4 class="MuiTypography-root MuiTypography-overline css-1c7xfgu-MuiTypography-root" /> Name "Misc": <h4 class="MuiTypography-root MuiTypography-overline css-1c7xfgu-MuiTypography-root" /> Name "Thresholds": <h4 class="MuiTypography-root MuiTypography-overline css-1c7xfgu-MuiTypography-root" /> -------------------------------------------------- combobox: Name "Show": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-48-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-48-control" role="combobox" spellcheck="false" type="text" value="Auto" /> Name "Font Size": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-49-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-49-control" role="combobox" spellcheck="false" type="text" value="Default" /> Name "Unit": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-52-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-52-control" role="combobox" spellcheck="false" type="text" value="Percent (0-100)" /> Name "Decimals": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-53-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-53-control" role="combobox" spellcheck="false" type="text" value="Default" /> Name "Calculation": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-54-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-54-control" role="combobox" spellcheck="false" type="text" value="Last" /> Name "Font Size": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-56-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-56-control" role="combobox" spellcheck="false" type="text" value="Default" /> Name "Color mode": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-57-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-inpu

Check failure on line 135 in statchart/src/StatChartOptionsEditorSettings.test.tsx

View workflow job for this annotation

GitHub Actions / test-npm

src/StatChartOptionsEditorSettings.test.tsx > StatChartOptionsEditorSettings > clears seriesColumns when switching layout away from grid

TestingLibraryElementError: Unable to find an accessible element with the role "option" and name `/^Row$/i` Here are the accessible roles: heading: Name "Legend": <h4 class="MuiTypography-root MuiTypography-overline css-1c7xfgu-MuiTypography-root" /> Name "Misc": <h4 class="MuiTypography-root MuiTypography-overline css-1c7xfgu-MuiTypography-root" /> Name "Thresholds": <h4 class="MuiTypography-root MuiTypography-overline css-1c7xfgu-MuiTypography-root" /> -------------------------------------------------- combobox: Name "Show": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-48-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-48-control" role="combobox" spellcheck="false" type="text" value="Auto" /> Name "Font Size": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-49-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-49-control" role="combobox" spellcheck="false" type="text" value="Default" /> Name "Unit": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-52-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-52-control" role="combobox" spellcheck="false" type="text" value="Percent (0-100)" /> Name "Decimals": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-53-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-53-control" role="combobox" spellcheck="false" type="text" value="Default" /> Name "Calculation": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-54-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-54-control" role="combobox" spellcheck="false" type="text" value="Last" /> Name "Font Size": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-56-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-input" id="EditorSectionControl-56-control" role="combobox" spellcheck="false" type="text" value="Default" /> Name "Color mode": <input aria-autocomplete="list" aria-expanded="false" aria-invalid="false" aria-labelledby="EditorSectionControl-57-label" autocapitalize="none" autocomplete="off" class="MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputAdornedEnd MuiAutocomplete-input MuiAutocomplete-inputFocused css-1dune0f-MuiInputBase-input-MuiOutlinedInput-inpu
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
seriesLayout: 'row',
seriesColumns: undefined,
}),
);
});

it('can disable a sparkline', () => {
const onChange = vi.fn();
renderStatChartOptionsEditorSettings(
Expand Down
41 changes: 40 additions & 1 deletion statchart/src/StatChartOptionsEditorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand Down Expand Up @@ -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 (
<OptionsEditorControl
label="Series layout"
control={
<SettingsAutocomplete
onChange={handleSeriesLayoutChange}
options={SERIES_LAYOUT_LABELS.map(({ id, label, description }) => ({
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 (
<OptionsEditorGrid>
<OptionsEditorColumn>
Expand All @@ -189,6 +227,7 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
<MetricLabelInput value={value.metricLabel} onChange={handleMetricLabelChange} />
<FontSizeSelector value={value.valueFontSize} onChange={handleFontSizeChange} />
{selectColorMode}
{selectSeriesLayout}
</OptionsEditorGroup>
</OptionsEditorColumn>
<OptionsEditorColumn>
Expand Down
75 changes: 30 additions & 45 deletions statchart/src/StatChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,15 +31,14 @@ 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<StatChartOptions, TimeSeriesData>;

export const StatChartPanel: FC<StatChartPanelProps> = (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);

Expand Down Expand Up @@ -90,58 +90,43 @@ export const StatChartPanel: FC<StatChartPanelProps> = (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;

return (
<Stack
height={contentDimensions.height}
width={contentDimensions.width}
spacing={`${SPACING}px`}
spacing={`${spacing}px`}
direction="row"
justifyContent={isMultiSeries ? 'left' : 'center'}
alignItems="center"
flexWrap={wrap ? 'wrap' : 'nowrap'}
justifyContent={isMultiSeries ? 'flex-start' : 'center'}
alignItems="stretch"
useFlexGap
sx={{
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',
},
overflow,
alignContent,
}}
>
{statChartData.length ? (
Expand All @@ -152,7 +137,7 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
<StatChartBase
key={index}
width={chartWidth}
height={contentDimensions.height}
height={chartHeight}
data={series}
format={format}
sparkline={sparklineConfig}
Expand Down
67 changes: 67 additions & 0 deletions statchart/src/series-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright The Perses Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, expect, it } from 'vitest';

import { resolveSeriesColumns } from './stat-chart-model';

describe('resolveSeriesColumns', () => {
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);
});
});
43 changes: 43 additions & 0 deletions statchart/src/stat-chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down
Loading