Skip to content
14 changes: 7 additions & 7 deletions statchart/src/StatChartBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { FC, ReactNode } from 'react';
import { useMemo } from 'react';

import type { ColorMode } from './stat-chart-model';
import { resolveSparklineBandHeight } from './stat-chart-model';
import { useOptimalFontSize } from './utils/calculate-font-size';
import { formatStatChartValue } from './utils/format-stat-chart-value';

Expand Down Expand Up @@ -101,24 +102,23 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
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));
seriesNameFontSize = Math.max(14, Math.min(height * 0.18, SERIES_NAME_MAX_FONT_SIZE));
}

const seriesNameHeight = showSeriesName ? seriesNameFontSize * LINE_HEIGHT + containerPadding : 0;

const availableHeight = height - seriesNameHeight;
const sparklineBand = sparkline ? resolveSparklineBandHeight(availableHeight) : 0;
const valueAreaHeight = sparkline ? Math.max(0, availableHeight - sparklineBand) : availableHeight * 0.9;
const optimalValueFontSize = useOptimalFontSize({
text: alignmentText || formattedValue,
fontSizeOverride: valueFontSize,
fontWeight: VALUE_FONT_WEIGHT,
width: sparkline ? availableWidth : availableWidth * 0.5,
height: sparkline ? availableHeight * 0.25 : availableHeight * 0.9,
height: valueAreaHeight,
lineHeight: LINE_HEIGHT,
maxSize: maxValueFontSize,
});
const valueFontHeight = optimalValueFontSize * LINE_HEIGHT;

// single-series: keep legend smaller than value (unless explicitly set)
if (alignmentSeriesName === undefined && legendFontSize === undefined) {
seriesNameFontSize = Math.min(optimalValueFontSize * 0.7, seriesNameFontSize);
Expand Down Expand Up @@ -157,7 +157,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
},
grid: {
show: false,
top: '35%', // adds space above sparkline
top: 0,
right: 0,
bottom: 0,
left: 0,
Expand Down Expand Up @@ -267,7 +267,7 @@ export const StatChartBase: FC<StatChartProps> = (props) => {
style={{
// ECharts rounds the height to the nearest integer by default.
// This can cause unneccessary scrollbars when the total height of this chart exceeds the 'height' prop.
height: Math.floor(height - seriesNameHeight - valueFontHeight),
height: Math.floor(sparklineBand),
}}
option={option}
theme={chartsTheme.echartsTheme}
Expand Down
9 changes: 4 additions & 5 deletions statchart/src/StatChartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { FC } from 'react';
import { useMemo } from 'react';

import type { StatChartOptions } from './stat-chart-model';
import { resolveAutoOrientationColumnsCount } from './stat-chart-model';
import { resolveAutoOrientationColumnsCount, resolveAutoOrientationTileHeight } from './stat-chart-model';
import type { StatChartData } from './StatChartBase';
import { StatChartBase } from './StatChartBase';
import { measureTextWidth } from './utils/calculate-font-size';
Expand All @@ -34,7 +34,6 @@ 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>;
Expand Down Expand Up @@ -122,7 +121,7 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
return Math.max(MIN_TILE_HEIGHT, Math.floor(panelHeight / Math.max(1, statChartData.length)));
}
if (isAutoWrapped) {
return Math.min(AUTO_TILE_HEIGHT, Math.max(MIN_TILE_HEIGHT, Math.floor(panelHeight / autoRowCount)));
return resolveAutoOrientationTileHeight(panelHeight, autoRowCount, SPACING, MIN_TILE_HEIGHT);
}
return panelHeight;
}, [autoRowCount, panelHeight, isAutoWrapped, isVerticalLayout, statChartData.length]);
Expand All @@ -142,7 +141,7 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
<Stack
height={panelHeight}
width={panelWidth}
spacing={`${SPACING}px`}
spacing={isAutoWrapped ? 0 : `${SPACING}px`}
direction={isVerticalLayout ? 'column' : 'row'}
flexWrap={isAutoWrapped ? 'wrap' : 'nowrap'}
justifyContent={justifyContent}
Expand Down Expand Up @@ -201,7 +200,7 @@ export const StatChartPanel: FC<StatChartPanelProps> = (props) => {
legendFontSize={legendFontSize}
alignmentText={isAutoWrapped || !isVerticalLayout ? undefined : alignmentText}
alignmentSeriesName={alignmentSeriesName}
maxValueFontSize={!isAutoWrapped && !isVerticalLayout ? MAX_VALUE_FONT_SIZE : undefined}
maxValueFontSize={MAX_VALUE_FONT_SIZE}
/>
);
})
Expand Down
32 changes: 31 additions & 1 deletion statchart/src/series-columns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@

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

import { computeIdealSeriesColumns, MAX_SERIES_COLUMNS, resolveAutoOrientationColumnsCount } from './stat-chart-model';
import {
computeIdealSeriesColumns,
MAX_SERIES_COLUMNS,
resolveAutoOrientationColumnsCount,
resolveAutoOrientationTileHeight,
resolveSparklineBandHeight,
} from './stat-chart-model';

describe('computeIdealSeriesColumns', () => {
it('matches ceil(sqrt(n))', () => {
Expand Down Expand Up @@ -47,3 +53,27 @@ describe('resolveAutoOrientationColumnsCount', () => {
expect(resolveAutoOrientationColumnsCount(10, 2)).toBe(2);
});
});

describe('resolveAutoOrientationTileHeight', () => {
it('shares the panel height so a sparkline fits under the value', () => {
// 2 rows, 2px gap: a 72px cap would hide the sparkline in a tall panel.
expect(resolveAutoOrientationTileHeight(240, 2, 2, 60)).toBe(119);
expect(resolveAutoOrientationTileHeight(300, 3, 2, 60)).toBe(98);
});

it('keeps a minimum tile height when the panel is shorter than the rows', () => {
expect(resolveAutoOrientationTileHeight(80, 2, 2, 60)).toBe(60);
});
});

describe('resolveSparklineBandHeight', () => {
it('leaves most of the tile to the value', () => {
expect(resolveSparklineBandHeight(47)).toBeCloseTo(18.8);
expect(resolveSparklineBandHeight(100)).toBe(30);
expect(resolveSparklineBandHeight(200)).toBe(60);
});

it('returns 0 when there is no room under the series name', () => {
expect(resolveSparklineBandHeight(0)).toBe(0);
});
});
25 changes: 25 additions & 0 deletions statchart/src/stat-chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,31 @@ export function resolveAutoOrientationColumnsCount(
return Math.max(1, Math.min(seriesCount, width, ideal));
}

// Share the panel height across auto-orientation rows
export function resolveAutoOrientationTileHeight(
panelHeight: number,
rowCount: number,
spacing: number,
minTileHeight: number,
): number {
const rows = Math.max(1, Math.floor(rowCount));
const gaps = Math.max(0, spacing) * Math.max(0, rows - 1);
const available = Math.max(0, panelHeight - gaps);
return Math.max(minTileHeight, Math.floor(available / rows));
}

const SPARKLINE_BAND_MIN_HEIGHT = 24;
const SPARKLINE_BAND_MAX_HEIGHT = 72;
// The sparkline keeps at most this share of the space under the series name.
// The value uses the rest, so the number stays readable when the panel grows.
const SPARKLINE_BAND_MAX_RATIO = 0.4;

export function resolveSparklineBandHeight(availableHeight: number): number {
if (availableHeight <= 0) return 0;
const preferred = Math.min(SPARKLINE_BAND_MAX_HEIGHT, Math.max(SPARKLINE_BAND_MIN_HEIGHT, availableHeight * 0.3));
return Math.min(availableHeight * SPARKLINE_BAND_MAX_RATIO, preferred);
}

export interface StatChartSparklineOptions {
color?: string;
width?: number;
Expand Down
Loading