diff --git a/statchart/src/StatChartBase.tsx b/statchart/src/StatChartBase.tsx index cf335c93f..74ce1c70a 100644 --- a/statchart/src/StatChartBase.tsx +++ b/statchart/src/StatChartBase.tsx @@ -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'; @@ -101,24 +102,23 @@ export const StatChartBase: FC = (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); @@ -157,7 +157,7 @@ export const StatChartBase: FC = (props) => { }, grid: { show: false, - top: '35%', // adds space above sparkline + top: 0, right: 0, bottom: 0, left: 0, @@ -267,7 +267,7 @@ export const StatChartBase: FC = (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} diff --git a/statchart/src/StatChartPanel.tsx b/statchart/src/StatChartPanel.tsx index 6c6a68a59..d7fb15ff5 100644 --- a/statchart/src/StatChartPanel.tsx +++ b/statchart/src/StatChartPanel.tsx @@ -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'; @@ -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; @@ -122,7 +121,7 @@ export const StatChartPanel: FC = (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]); @@ -142,7 +141,7 @@ export const StatChartPanel: FC = (props) => { = (props) => { legendFontSize={legendFontSize} alignmentText={isAutoWrapped || !isVerticalLayout ? undefined : alignmentText} alignmentSeriesName={alignmentSeriesName} - maxValueFontSize={!isAutoWrapped && !isVerticalLayout ? MAX_VALUE_FONT_SIZE : undefined} + maxValueFontSize={MAX_VALUE_FONT_SIZE} /> ); }) diff --git a/statchart/src/series-columns.test.ts b/statchart/src/series-columns.test.ts index 78af0ed81..841433455 100644 --- a/statchart/src/series-columns.test.ts +++ b/statchart/src/series-columns.test.ts @@ -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))', () => { @@ -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); + }); +}); diff --git a/statchart/src/stat-chart-model.ts b/statchart/src/stat-chart-model.ts index b55343cdf..5ddeb386d 100644 --- a/statchart/src/stat-chart-model.ts +++ b/statchart/src/stat-chart-model.ts @@ -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;