Skip to content
Open
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
1 change: 1 addition & 0 deletions statchart/schemas/stat.cue
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@ spec: close({
legendFontSize?: number
colorMode?: *"value" | "background_solid" | "none"
legendMode?: *"auto" | "on" | "off"
orientation?: *"auto" | "horizontal" | "vertical"
mappings?: [...common.#mappings]
})
5 changes: 4 additions & 1 deletion statchart/src/StatChartBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface StatChartProps {
colorMode?: ColorMode;
alignmentText?: string;
alignmentSeriesName?: string;
maxValueFontSize?: number;
}

export const StatChartBase: FC<StatChartProps> = (props) => {
Expand All @@ -72,6 +73,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
colorMode,
alignmentText,
alignmentSeriesName,
maxValueFontSize,
} = props;

const {
Expand Down Expand Up @@ -113,6 +115,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
width: sparkline ? availableWidth : availableWidth * 0.5,
height: sparkline ? availableHeight * 0.25 : availableHeight * 0.9,
lineHeight: LINE_HEIGHT,
maxSize: maxValueFontSize,
});
const valueFontHeight = optimalValueFontSize * LINE_HEIGHT;

Expand Down Expand Up @@ -242,7 +245,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
return (
<Box
sx={{
height: '100%',
height,
width: width,
minWidth: width,
flexShrink: 0,
Expand Down
25 changes: 25 additions & 0 deletions statchart/src/StatChartOptionsEditorSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,29 @@ describe('StatChartOptionsEditorSettings', () => {
}),
);
});

it('can change orientation', () => {
const onChange = vi.fn();
renderStatChartOptionsEditorSettings(
{
format: {
unit: 'days',
},
calculation: 'sum',
orientation: 'horizontal',
},
onChange,
);

const orientationSelector = screen.getByRole('combobox', { name: 'Orientation' });
userEvent.click(orientationSelector);
const verticalOption = screen.getByRole('option', { name: 'Vertical' });
userEvent.click(verticalOption);

expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
orientation: 'vertical',
}),
);
});
});
31 changes: 30 additions & 1 deletion statchart/src/StatChartOptionsEditorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ import type {
ShowLegendLabelItem,
StatChartOptions,
StatChartOptionsEditorProps,
StatChartOrientation,
} from './stat-chart-model';
import { COLOR_MODE_LABELS, SHOW_LEGEND_LABELS } from './stat-chart-model';
import { COLOR_MODE_LABELS, SHOW_LEGEND_LABELS, STAT_CHART_ORIENTATION_LABELS } from './stat-chart-model';

const DEFAULT_FORMAT: FormatOptions = { unit: 'percent-decimal' };

Expand Down Expand Up @@ -134,6 +135,17 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
[onChange, value],
);

const handleOrientationChange = useCallback(
(_: unknown, newOrientation: { id: StatChartOrientation; label: string }): void => {
onChange(
produce(value, (draft: StatChartOptions) => {
draft.orientation = newOrientation.id;
}),
);
},
[onChange, value],
);

const selectShowLegend = useMemo((): ReactElement => {
return (
<OptionsEditorControl
Expand Down Expand Up @@ -172,6 +184,22 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
);
}, [value.colorMode, handleColorModeChange]);

const selectOrientation = useMemo((): ReactElement => {
return (
<OptionsEditorControl
label="Orientation"
control={
<SettingsAutocomplete
onChange={handleOrientationChange}
options={STAT_CHART_ORIENTATION_LABELS}
disableClearable
value={STAT_CHART_ORIENTATION_LABELS.find((i) => i.id === value.orientation)}
/>
}
/>
);
}, [value.orientation, handleOrientationChange]);

return (
<OptionsEditorGrid>
<OptionsEditorColumn>
Expand All @@ -189,6 +217,7 @@ export function StatChartOptionsEditorSettings(props: StatChartOptionsEditorProp
<MetricLabelInput value={value.metricLabel} onChange={handleMetricLabelChange} />
<FontSizeSelector value={value.valueFontSize} onChange={handleFontSizeChange} />
{selectColorMode}
{selectOrientation}
</OptionsEditorGroup>
</OptionsEditorColumn>
<OptionsEditorColumn>
Expand Down
98 changes: 68 additions & 30 deletions statchart/src/StatChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,26 @@ import { formatStatChartValue } from './utils/format-stat-chart-value';
import { getStatChartColor } from './utils/get-color';

const MIN_WIDTH = 100;
const MIN_TILE_HEIGHT = 60;
const SPACING = 2;
const AUTO_TILE_HEIGHT = 72;
const MAX_VALUE_FONT_SIZE = 96;

export type StatChartPanelProps = PanelProps<StatChartOptions, TimeSeriesData>;

export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
const { spec, contentDimensions, queryResults } = props;
const panelWidth = contentDimensions?.width ?? 0;
const panelHeight = contentDimensions?.height ?? 0;

const { format, sparkline, valueFontSize, legendFontSize, colorMode } = spec;
const chartsTheme = useChartsTheme();
const statChartData = useStatChartData(queryResults, spec, chartsTheme);

const orientation = spec.orientation ?? 'auto';
const isMultiSeries = statChartData.length > 1;
const isAutoWrapped = orientation === 'auto' && isMultiSeries;
const isVerticalLayout = orientation === 'vertical';

// Find the widest value text (by pixel width) to use as alignment reference
const alignmentText = useMemo(() => {
Expand Down Expand Up @@ -88,42 +96,65 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
shouldShowLegend = false;
}

if (!contentDimensions) return null;

// Calculates chart width — ensure cells are wide enough to show full series names
// Keep horizontal tiles equal-sized so long metric names do not create large gaps between values.
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;
const chartWidth = Math.max(MIN_WIDTH, (panelWidth - spacing) / Math.max(1, statChartData.length));

const autoColumnCount = useMemo(() => {
if (!isAutoWrapped) return 1;
return Math.max(1, Math.min(statChartData.length, Math.floor((panelWidth + SPACING) / (MIN_WIDTH + SPACING))));
}, [panelWidth, isAutoWrapped, statChartData.length]);

const autoGridWidth = useMemo(() => {
if (!isAutoWrapped) return chartWidth;
return Math.max(MIN_WIDTH, Math.floor((panelWidth - (autoColumnCount - 1) * SPACING) / autoColumnCount));
}, [autoColumnCount, chartWidth, panelWidth, isAutoWrapped]);

const autoRowCount = useMemo(() => {
if (!isAutoWrapped) return 1;
return Math.max(1, Math.ceil(statChartData.length / autoColumnCount));
}, [autoColumnCount, isAutoWrapped, statChartData.length]);

const statTileHeight = useMemo(() => {
if (isVerticalLayout) {
return Math.max(MIN_TILE_HEIGHT, Math.floor(panelHeight / Math.max(1, statChartData.length)));
}
chartWidth = Math.max(chartWidth, maxTextWidth);
}
if (isAutoWrapped) {
return Math.min(AUTO_TILE_HEIGHT, Math.max(MIN_TILE_HEIGHT, Math.floor(panelHeight / autoRowCount)));
}
return panelHeight;
}, [autoRowCount, panelHeight, isAutoWrapped, isVerticalLayout, statChartData.length]);

if (!contentDimensions) return null;

const noDataTextStyle = (chartsTheme.noDataOption.title as TitleComponentOption).textStyle;
let justifyContent = 'center';
if (isAutoWrapped) {
justifyContent = 'flex-start';
} else if (isMultiSeries) {
justifyContent = 'left';
}
const overflowX = !isVerticalLayout && !isAutoWrapped && isMultiSeries ? 'auto' : 'hidden';

return (
<Stack
height={contentDimensions.height}
width={contentDimensions.width}
height={panelHeight}
width={panelWidth}
spacing={`${SPACING}px`}
direction="row"
justifyContent={isMultiSeries ? 'left' : 'center'}
alignItems="center"
direction={isVerticalLayout ? 'column' : 'row'}
flexWrap={isAutoWrapped ? 'wrap' : 'nowrap'}
justifyContent={justifyContent}
alignItems={isAutoWrapped ? 'flex-start' : 'center'}
alignContent={isAutoWrapped ? 'flex-start' : 'center'}
sx={{
overflowX: isMultiSeries ? 'auto' : 'hidden',
...(isAutoWrapped && {
display: 'grid',
gridTemplateColumns: `repeat(${autoColumnCount}, minmax(0, 1fr))`,
gridAutoRows: `${statTileHeight}px`,
gap: `${SPACING}px`,
}),
overflowX,
overflowY: isVerticalLayout || isAutoWrapped ? 'auto' : 'hidden',
'&::-webkit-scrollbar': {
height: '4px',
},
Expand All @@ -147,21 +178,28 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
{statChartData.length ? (
statChartData.map((series, index) => {
const sparklineConfig = convertSparkline(chartsTheme, series.color, sparkline);
let tileWidth = chartWidth;
if (isAutoWrapped) {
tileWidth = autoGridWidth;
} else if (isVerticalLayout) {
tileWidth = panelWidth;
}

return (
<StatChartBase
key={index}
width={chartWidth}
height={contentDimensions.height}
width={tileWidth}
height={statTileHeight}
data={series}
format={format}
sparkline={sparklineConfig}
showSeriesName={shouldShowLegend}
valueFontSize={valueFontSize}
colorMode={colorMode}
legendFontSize={legendFontSize}
alignmentText={alignmentText}
alignmentText={isAutoWrapped || !isVerticalLayout ? undefined : alignmentText}
alignmentSeriesName={alignmentSeriesName}
maxValueFontSize={!isAutoWrapped && !isVerticalLayout ? MAX_VALUE_FONT_SIZE : undefined}
/>
);
})
Expand Down
13 changes: 11 additions & 2 deletions statchart/src/stat-chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const COLOR_MODE_LABELS: ColorModeLabelItem[] = [

export type legendMode = 'auto' | 'on' | 'off';

export type StatChartOrientation = 'auto' | 'horizontal' | 'vertical';

export type ShowLegendLabelItem = {
id: legendMode;
label: string;
Expand All @@ -49,6 +51,12 @@ export const SHOW_LEGEND_LABELS: ShowLegendLabelItem[] = [
{ id: 'off', label: 'Off', description: 'Always hide legend' },
];

export const STAT_CHART_ORIENTATION_LABELS: Array<{ id: StatChartOrientation; label: string }> = [
{ id: 'auto', label: 'Auto' },
{ id: 'horizontal', label: 'Horizontal' },
{ id: 'vertical', label: 'Vertical' },
];

export interface StatChartOptions {
calculation: CalculationType;
format: FormatOptions;
Expand All @@ -60,6 +68,7 @@ export interface StatChartOptions {
mappings?: ValueMapping[];
colorMode?: ColorMode;
legendMode?: legendMode;
orientation?: StatChartOrientation;
}

export interface StatChartSparklineOptions {
Expand All @@ -75,7 +84,7 @@ export function createInitialStatChartOptions(): StatChartOptions {
format: {
unit: 'decimal',
},
sparkline: {},
legendMode: 'auto',
legendMode: 'off',
orientation: 'auto',
};
}