@opendata-ai/openchart-core 2.9.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/styles.css CHANGED
@@ -662,6 +662,8 @@ th[aria-sort="descending"] .viz-table-sort-btn::before {
662
662
  overflow: hidden;
663
663
  background: var(--viz-bg);
664
664
  font-family: var(--viz-font-family);
665
+ width: 100%;
666
+ height: 100%;
665
667
  }
666
668
 
667
669
  .viz-graph-canvas {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "2.9.1",
3
+ "version": "2.11.0",
4
4
  "description": "Types, theme, colors, accessibility, and utilities for openchart",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
package/src/index.ts CHANGED
@@ -54,6 +54,7 @@ export {
54
54
  // ---------------------------------------------------------------------------
55
55
 
56
56
  export {
57
+ BRAND_RESERVE_WIDTH,
57
58
  computeChrome,
58
59
  estimateTextWidth,
59
60
  } from './layout/index';
@@ -66,12 +67,15 @@ export type {
66
67
  AnnotationPosition,
67
68
  AxisLabelDensity,
68
69
  Breakpoint,
70
+ ChromeMode,
71
+ HeightClass,
69
72
  LabelMode,
70
73
  LayoutStrategy,
71
74
  LegendPosition,
72
75
  } from './responsive/index';
73
76
  export {
74
77
  getBreakpoint,
78
+ getHeightClass,
75
79
  getLayoutStrategy,
76
80
  } from './responsive/index';
77
81
 
@@ -83,7 +87,7 @@ export type {
83
87
  LabelCandidate,
84
88
  LabelPriority,
85
89
  } from './labels/index';
86
- export { resolveCollisions } from './labels/index';
90
+ export { computeLabelBounds, detectCollision, resolveCollisions } from './labels/index';
87
91
 
88
92
  // ---------------------------------------------------------------------------
89
93
  // Locale: number and date formatting
@@ -6,6 +6,7 @@
6
6
  * Targeting ~60% of Infrographic quality for Phase 0.
7
7
  */
8
8
 
9
+ import { estimateTextWidth } from '../layout/text-measure';
9
10
  import type { Rect, ResolvedLabel, TextStyle } from '../types/layout';
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -159,3 +160,28 @@ export function resolveCollisions(labels: LabelCandidate[]): ResolvedLabel[] {
159
160
 
160
161
  return results;
161
162
  }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Label bounds estimation
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Compute the bounding rect of a resolved label from its position and text.
170
+ * Uses heuristic text measurement so it works without DOM access.
171
+ */
172
+ export function computeLabelBounds(label: ResolvedLabel): Rect {
173
+ const fontSize = label.style.fontSize;
174
+ const fontWeight = label.style.fontWeight;
175
+ const width = estimateTextWidth(label.text, fontSize, fontWeight);
176
+ const height = fontSize * (label.style.lineHeight ?? 1.2);
177
+
178
+ // Adjust x based on text anchor
179
+ let x = label.x;
180
+ if (label.style.textAnchor === 'middle') {
181
+ x = label.x - width / 2;
182
+ } else if (label.style.textAnchor === 'end') {
183
+ x = label.x - width;
184
+ }
185
+
186
+ return { x, y: label.y, width, height };
187
+ }
@@ -3,4 +3,4 @@
3
3
  */
4
4
 
5
5
  export type { LabelCandidate, LabelPriority } from './collision';
6
- export { detectCollision, resolveCollisions } from './collision';
6
+ export { computeLabelBounds, detectCollision, resolveCollisions } from './collision';
@@ -100,6 +100,23 @@ describe('computeChrome', () => {
100
100
  expect(result.bottomHeight).toBeGreaterThan(0);
101
101
  });
102
102
 
103
+ it('reserves extra height when title wraps to multiple lines at narrow widths', () => {
104
+ const longTitle =
105
+ 'Global Economic Recovery Trends Show Surprising Resilience Across Major Markets';
106
+ const chrome: Chrome = { title: longTitle, subtitle: 'Subtitle text' };
107
+
108
+ // At wide width, title fits on one line
109
+ const wide = computeChrome(chrome, theme, 800);
110
+ // At narrow width, title wraps to multiple lines
111
+ const narrow = computeChrome(chrome, theme, 300);
112
+
113
+ // Narrow should reserve more top height due to title wrapping
114
+ expect(narrow.topHeight).toBeGreaterThan(wide.topHeight);
115
+
116
+ // Subtitle should be pushed further down to avoid collision
117
+ expect(narrow.subtitle!.y).toBeGreaterThan(wide.subtitle!.y);
118
+ });
119
+
103
120
  it('uses measureText function when provided', () => {
104
121
  const measureText = (text: string, fontSize: number) => ({
105
122
  width: text.length * fontSize * 0.6,
@@ -3,8 +3,17 @@
3
3
  *
4
4
  * Takes a Chrome spec + resolved theme and produces a ResolvedChrome
5
5
  * with computed text positions, styles, and total chrome heights.
6
+ *
7
+ * Supports three chrome modes:
8
+ * - full: all chrome elements rendered at normal size
9
+ * - compact: title only, no subtitle/source/byline/footer
10
+ * - hidden: no chrome at all (maximizes chart area)
11
+ *
12
+ * Font sizes scale down continuously at narrow widths to keep
13
+ * chrome proportional to the container.
6
14
  */
7
15
 
16
+ import type { ChromeMode } from '../responsive/breakpoints';
8
17
  import type {
9
18
  MeasureTextFn,
10
19
  ResolvedChrome,
@@ -13,7 +22,7 @@ import type {
13
22
  } from '../types/layout';
14
23
  import type { Chrome, ChromeText } from '../types/spec';
15
24
  import type { ChromeDefaults, ResolvedTheme } from '../types/theme';
16
- import { estimateTextHeight, estimateTextWidth } from './text-measure';
25
+ import { BRAND_RESERVE_WIDTH, estimateCharWidth, estimateTextHeight } from './text-measure';
17
26
 
18
27
  // ---------------------------------------------------------------------------
19
28
  // Helpers
@@ -28,16 +37,32 @@ function normalizeChromeText(
28
37
  return { text: value.text, style: value.style, offset: value.offset };
29
38
  }
30
39
 
31
- /** Build a TextStyle from chrome defaults + optional overrides. */
40
+ /**
41
+ * Scale a font size based on container width. Only applies to default sizes
42
+ * (not user overrides). Scales from 100% at >= 500px down to 72% at <= 250px.
43
+ */
44
+ function scaleFontSize(baseFontSize: number, width: number): number {
45
+ if (width >= 500) return baseFontSize;
46
+ if (width <= 250) return Math.max(Math.round(baseFontSize * 0.72), 10);
47
+ const t = (width - 250) / 250;
48
+ return Math.max(Math.round(baseFontSize * (0.72 + t * 0.28)), 10);
49
+ }
50
+
51
+ /** Build a TextStyle from chrome defaults + optional overrides, with width-based scaling. */
32
52
  function buildTextStyle(
33
53
  defaults: ChromeDefaults,
34
54
  fontFamily: string,
35
55
  textColor: string,
56
+ width: number,
36
57
  overrides?: ChromeText['style'],
37
58
  ): TextStyle {
59
+ const hasExplicitSize = overrides?.fontSize !== undefined;
60
+ const baseFontSize = overrides?.fontSize ?? defaults.fontSize;
61
+ const fontSize = hasExplicitSize ? baseFontSize : scaleFontSize(baseFontSize, width);
62
+
38
63
  return {
39
64
  fontFamily: overrides?.fontFamily ?? fontFamily,
40
- fontSize: overrides?.fontSize ?? defaults.fontSize,
65
+ fontSize,
41
66
  fontWeight: overrides?.fontWeight ?? defaults.fontWeight,
42
67
  fill: overrides?.color ?? textColor ?? defaults.color,
43
68
  lineHeight: defaults.lineHeight,
@@ -46,27 +71,40 @@ function buildTextStyle(
46
71
  };
47
72
  }
48
73
 
49
- /** Measure text width using the provided function or heuristic fallback. */
50
- function measureWidth(text: string, style: TextStyle, measureText?: MeasureTextFn): number {
51
- if (measureText) {
52
- return measureText(text, style.fontSize, style.fontWeight).width;
53
- }
54
- return estimateTextWidth(text, style.fontSize, style.fontWeight);
55
- }
56
-
57
74
  /**
58
75
  * Estimate how many lines text will wrap to, given a max width.
76
+ * Uses character-count word-wrapping that matches the SVG renderer's
77
+ * wrapText behavior (word-boundary breaks, same charWidth heuristic).
59
78
  * Returns at least 1.
60
79
  */
61
80
  function estimateLineCount(
62
81
  text: string,
63
82
  style: TextStyle,
64
83
  maxWidth: number,
65
- measureText?: MeasureTextFn,
84
+ _measureText?: MeasureTextFn,
66
85
  ): number {
67
- const fullWidth = measureWidth(text, style, measureText);
68
- if (fullWidth <= maxWidth) return 1;
69
- return Math.ceil(fullWidth / maxWidth);
86
+ if (maxWidth <= 0) return 1;
87
+
88
+ const charWidth = estimateCharWidth(style.fontSize, style.fontWeight);
89
+ const maxChars = Math.floor(maxWidth / charWidth);
90
+
91
+ if (text.length <= maxChars) return 1;
92
+
93
+ const words = text.split(' ');
94
+ let lines = 1;
95
+ let current = '';
96
+
97
+ for (const word of words) {
98
+ const candidate = current ? `${current} ${word}` : word;
99
+ if (candidate.length > maxChars && current) {
100
+ lines++;
101
+ current = word;
102
+ } else {
103
+ current = candidate;
104
+ }
105
+ }
106
+
107
+ return lines;
70
108
  }
71
109
 
72
110
  // ---------------------------------------------------------------------------
@@ -83,24 +121,28 @@ function estimateLineCount(
83
121
  * @param theme - The fully resolved theme.
84
122
  * @param width - Total available width in pixels.
85
123
  * @param measureText - Optional real text measurement function from the adapter.
124
+ * @param chromeMode - Chrome display mode: full, compact (title only), or hidden.
125
+ * @param padding - Override padding (for scaled padding from dimensions).
86
126
  */
87
127
  export function computeChrome(
88
128
  chrome: Chrome | undefined,
89
129
  theme: ResolvedTheme,
90
130
  width: number,
91
131
  measureText?: MeasureTextFn,
132
+ chromeMode: ChromeMode = 'full',
133
+ padding?: number,
92
134
  ): ResolvedChrome {
93
- if (!chrome) {
135
+ if (!chrome || chromeMode === 'hidden') {
94
136
  return { topHeight: 0, bottomHeight: 0 };
95
137
  }
96
138
 
97
- const padding = theme.spacing.padding;
139
+ const pad = padding ?? theme.spacing.padding;
98
140
  const chromeGap = theme.spacing.chromeGap;
99
- const maxWidth = width - padding * 2;
141
+ const maxWidth = width - pad * 2;
100
142
  const fontFamily = theme.fonts.family;
101
143
 
102
144
  // Track vertical cursor for top elements
103
- let topY = padding;
145
+ let topY = pad;
104
146
  const topElements: Partial<Pick<ResolvedChrome, 'title' | 'subtitle'>> = {};
105
147
 
106
148
  // Title
@@ -110,12 +152,13 @@ export function computeChrome(
110
152
  theme.chrome.title,
111
153
  fontFamily,
112
154
  theme.chrome.title.color,
155
+ width,
113
156
  titleNorm.style,
114
157
  );
115
158
  const lineCount = estimateLineCount(titleNorm.text, style, maxWidth, measureText);
116
159
  const element: ResolvedChromeElement = {
117
160
  text: titleNorm.text,
118
- x: padding + (titleNorm.offset?.dx ?? 0),
161
+ x: pad + (titleNorm.offset?.dx ?? 0),
119
162
  y: topY + (titleNorm.offset?.dy ?? 0),
120
163
  maxWidth,
121
164
  style,
@@ -124,19 +167,20 @@ export function computeChrome(
124
167
  topY += estimateTextHeight(style.fontSize, lineCount, style.lineHeight) + chromeGap;
125
168
  }
126
169
 
127
- // Subtitle
128
- const subtitleNorm = normalizeChromeText(chrome.subtitle);
170
+ // Subtitle (hidden in compact mode)
171
+ const subtitleNorm = chromeMode === 'compact' ? null : normalizeChromeText(chrome.subtitle);
129
172
  if (subtitleNorm) {
130
173
  const style = buildTextStyle(
131
174
  theme.chrome.subtitle,
132
175
  fontFamily,
133
176
  theme.chrome.subtitle.color,
177
+ width,
134
178
  subtitleNorm.style,
135
179
  );
136
180
  const lineCount = estimateLineCount(subtitleNorm.text, style, maxWidth, measureText);
137
181
  const element: ResolvedChromeElement = {
138
182
  text: subtitleNorm.text,
139
- x: padding + (subtitleNorm.offset?.dx ?? 0),
183
+ x: pad + (subtitleNorm.offset?.dx ?? 0),
140
184
  y: topY + (subtitleNorm.offset?.dy ?? 0),
141
185
  maxWidth,
142
186
  style,
@@ -147,10 +191,20 @@ export function computeChrome(
147
191
 
148
192
  // Add chromeToChart gap if there are any top elements
149
193
  const hasTopChrome = titleNorm || subtitleNorm;
150
- const topHeight = hasTopChrome ? topY - padding + theme.spacing.chromeToChart - chromeGap : 0;
194
+ const topHeight = hasTopChrome ? topY - pad + theme.spacing.chromeToChart - chromeGap : 0;
195
+
196
+ // Bottom elements hidden in compact mode
197
+ if (chromeMode === 'compact') {
198
+ return {
199
+ topHeight,
200
+ bottomHeight: 0,
201
+ ...topElements,
202
+ };
203
+ }
151
204
 
152
205
  // Bottom elements: source, byline, footer
153
- // We compute heights bottom-up but position them after knowing total
206
+ // Reserve space on the right for the brand watermark so text doesn't overlap it
207
+ const bottomMaxWidth = maxWidth - BRAND_RESERVE_WIDTH;
154
208
  const bottomElements: Partial<Pick<ResolvedChrome, 'source' | 'byline' | 'footer'>> = {};
155
209
  let bottomHeight = 0;
156
210
 
@@ -191,17 +245,23 @@ export function computeChrome(
191
245
  bottomHeight += theme.spacing.chartToFooter;
192
246
 
193
247
  for (const item of bottomItems) {
194
- const style = buildTextStyle(item.defaults, fontFamily, item.defaults.color, item.norm.style);
195
- const lineCount = estimateLineCount(item.norm.text, style, maxWidth, measureText);
248
+ const style = buildTextStyle(
249
+ item.defaults,
250
+ fontFamily,
251
+ item.defaults.color,
252
+ width,
253
+ item.norm.style,
254
+ );
255
+ const lineCount = estimateLineCount(item.norm.text, style, bottomMaxWidth, measureText);
196
256
  const height = estimateTextHeight(style.fontSize, lineCount, style.lineHeight);
197
257
 
198
258
  // y positions will be computed relative to the bottom of the
199
259
  // chart area by the engine. We store offsets from bottom start.
200
260
  bottomElements[item.key] = {
201
261
  text: item.norm.text,
202
- x: padding + (item.norm.offset?.dx ?? 0),
262
+ x: pad + (item.norm.offset?.dx ?? 0),
203
263
  y: bottomHeight + (item.norm.offset?.dy ?? 0), // offset from where bottom chrome starts
204
- maxWidth,
264
+ maxWidth: bottomMaxWidth,
205
265
  style,
206
266
  };
207
267
 
@@ -211,7 +271,7 @@ export function computeChrome(
211
271
  // Remove trailing gap
212
272
  bottomHeight -= chromeGap;
213
273
  // Add bottom padding
214
- bottomHeight += padding;
274
+ bottomHeight += pad;
215
275
  }
216
276
 
217
277
  return {
@@ -3,4 +3,9 @@
3
3
  */
4
4
 
5
5
  export { computeChrome } from './chrome';
6
- export { estimateTextHeight, estimateTextWidth } from './text-measure';
6
+ export {
7
+ BRAND_RESERVE_WIDTH,
8
+ estimateCharWidth,
9
+ estimateTextHeight,
10
+ estimateTextWidth,
11
+ } from './text-measure';
@@ -27,6 +27,15 @@ const WEIGHT_ADJUSTMENT: Record<number, number> = {
27
27
  900: 1.12,
28
28
  };
29
29
 
30
+ /**
31
+ * Estimate the average character width for a given font size and weight.
32
+ * Used by both text width estimation and word-wrap line counting.
33
+ */
34
+ export function estimateCharWidth(fontSize: number, fontWeight = 400): number {
35
+ const weightFactor = WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0;
36
+ return fontSize * AVG_CHAR_WIDTH_RATIO * weightFactor;
37
+ }
38
+
30
39
  /**
31
40
  * Estimate the rendered width of a text string.
32
41
  *
@@ -38,10 +47,17 @@ const WEIGHT_ADJUSTMENT: Record<number, number> = {
38
47
  * @param fontWeight - Font weight (100-900). Defaults to 400.
39
48
  */
40
49
  export function estimateTextWidth(text: string, fontSize: number, fontWeight = 400): number {
41
- const weightFactor = WEIGHT_ADJUSTMENT[fontWeight] ?? 1.0;
42
- return text.length * fontSize * AVG_CHAR_WIDTH_RATIO * weightFactor;
50
+ return text.length * estimateCharWidth(fontSize, fontWeight);
43
51
  }
44
52
 
53
+ /**
54
+ * Width reserved for the "OpenData" brand watermark in the bottom-right corner.
55
+ * Accounts for ~8 chars at font size 20 with mixed 500/600 weight, plus a gap
56
+ * so adjacent text doesn't crowd it. Used by chrome and legend layout to avoid
57
+ * overlapping the brand.
58
+ */
59
+ export const BRAND_RESERVE_WIDTH = 110;
60
+
45
61
  /**
46
62
  * Estimate the rendered height of a text block.
47
63
  *
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { getBreakpoint, getLayoutStrategy } from '../breakpoints';
2
+ import { getBreakpoint, getHeightClass, getLayoutStrategy } from '../breakpoints';
3
3
 
4
4
  describe('getBreakpoint', () => {
5
5
  it('returns compact for widths below 400', () => {
@@ -55,4 +55,92 @@ describe('getLayoutStrategy', () => {
55
55
  expect(compact.legendPosition).not.toBe(full.legendPosition);
56
56
  expect(compact.labelMode).not.toBe(full.labelMode);
57
57
  });
58
+
59
+ it('includes chromeMode and legendMaxHeight at normal height', () => {
60
+ const strategy = getLayoutStrategy('full');
61
+ expect(strategy.chromeMode).toBe('full');
62
+ expect(strategy.legendMaxHeight).toBe(-1);
63
+ });
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Height class detection
68
+ // ---------------------------------------------------------------------------
69
+
70
+ describe('getHeightClass', () => {
71
+ it('returns cramped for heights below 200', () => {
72
+ expect(getHeightClass(100)).toBe('cramped');
73
+ expect(getHeightClass(199)).toBe('cramped');
74
+ });
75
+
76
+ it('returns short for heights 200-350', () => {
77
+ expect(getHeightClass(200)).toBe('short');
78
+ expect(getHeightClass(280)).toBe('short');
79
+ expect(getHeightClass(350)).toBe('short');
80
+ });
81
+
82
+ it('returns normal for heights above 350', () => {
83
+ expect(getHeightClass(351)).toBe('normal');
84
+ expect(getHeightClass(800)).toBe('normal');
85
+ });
86
+
87
+ it('handles edge cases', () => {
88
+ expect(getHeightClass(0)).toBe('cramped');
89
+ expect(getHeightClass(-10)).toBe('cramped');
90
+ expect(getHeightClass(5000)).toBe('normal');
91
+ });
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Height-aware layout strategy
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe('getLayoutStrategy with height class', () => {
99
+ it('normal height does not modify the width strategy', () => {
100
+ const withoutHeight = getLayoutStrategy('full');
101
+ const withNormal = getLayoutStrategy('full', 'normal');
102
+ expect(withoutHeight).toEqual(withNormal);
103
+ });
104
+
105
+ it('cramped height hides chrome and labels', () => {
106
+ const strategy = getLayoutStrategy('full', 'cramped');
107
+ expect(strategy.chromeMode).toBe('hidden');
108
+ expect(strategy.legendMaxHeight).toBe(0);
109
+ expect(strategy.labelMode).toBe('none');
110
+ expect(strategy.annotationPosition).toBe('tooltip-only');
111
+ });
112
+
113
+ it('cramped overrides even compact width strategy', () => {
114
+ const strategy = getLayoutStrategy('compact', 'cramped');
115
+ expect(strategy.chromeMode).toBe('hidden');
116
+ expect(strategy.legendMaxHeight).toBe(0);
117
+ expect(strategy.labelMode).toBe('none');
118
+ });
119
+
120
+ it('short height compresses chrome and caps legend', () => {
121
+ const strategy = getLayoutStrategy('full', 'short');
122
+ expect(strategy.chromeMode).toBe('compact');
123
+ expect(strategy.legendMaxHeight).toBe(0.15);
124
+ });
125
+
126
+ it('short height preserves width-based label and legend settings', () => {
127
+ const strategy = getLayoutStrategy('full', 'short');
128
+ // Width strategy for 'full' sets these; short only touches chromeMode and legendMaxHeight
129
+ expect(strategy.labelMode).toBe('all');
130
+ expect(strategy.legendPosition).toBe('right');
131
+ expect(strategy.axisLabelDensity).toBe('full');
132
+ });
133
+
134
+ it('short height preserves compact width label settings', () => {
135
+ const strategy = getLayoutStrategy('compact', 'short');
136
+ expect(strategy.labelMode).toBe('none');
137
+ expect(strategy.legendPosition).toBe('top');
138
+ expect(strategy.chromeMode).toBe('compact');
139
+ });
140
+
141
+ it('defaults heightClass to normal when omitted', () => {
142
+ const strategy = getLayoutStrategy('medium');
143
+ expect(strategy.chromeMode).toBe('full');
144
+ expect(strategy.legendMaxHeight).toBe(-1);
145
+ });
58
146
  });
@@ -1,14 +1,19 @@
1
1
  /**
2
2
  * Responsive breakpoints and layout strategies.
3
3
  *
4
- * Three breakpoints based on container width:
4
+ * Width breakpoints:
5
5
  * - compact: < 400px (mobile, small embeds)
6
6
  * - medium: 400-700px (tablet, sidebars)
7
7
  * - full: > 700px (desktop, full-width)
8
+ *
9
+ * Height classes:
10
+ * - cramped: < 200px (dashboard widgets, thumbnails)
11
+ * - short: 200-350px (embedded panels, short containers)
12
+ * - normal: > 350px (standard containers)
8
13
  */
9
14
 
10
15
  // ---------------------------------------------------------------------------
11
- // Breakpoint type and detection
16
+ // Width breakpoint type and detection
12
17
  // ---------------------------------------------------------------------------
13
18
 
14
19
  /** Responsive breakpoint based on container width. */
@@ -27,6 +32,26 @@ export function getBreakpoint(width: number): Breakpoint {
27
32
  return 'full';
28
33
  }
29
34
 
35
+ // ---------------------------------------------------------------------------
36
+ // Height class type and detection
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Height classification based on container height. */
40
+ export type HeightClass = 'cramped' | 'short' | 'normal';
41
+
42
+ /** Height class thresholds in pixels. */
43
+ export const HEIGHT_CRAMPED_MAX = 200;
44
+ export const HEIGHT_SHORT_MAX = 350;
45
+
46
+ /**
47
+ * Determine the height class for a given container height.
48
+ */
49
+ export function getHeightClass(height: number): HeightClass {
50
+ if (height < HEIGHT_CRAMPED_MAX) return 'cramped';
51
+ if (height <= HEIGHT_SHORT_MAX) return 'short';
52
+ return 'normal';
53
+ }
54
+
30
55
  // ---------------------------------------------------------------------------
31
56
  // Layout strategy
32
57
  // ---------------------------------------------------------------------------
@@ -43,9 +68,12 @@ export type AnnotationPosition = 'inline' | 'tooltip-only';
43
68
  /** Axis label density (controls tick count reduction). */
44
69
  export type AxisLabelDensity = 'full' | 'reduced' | 'minimal';
45
70
 
71
+ /** Chrome display mode based on available height. */
72
+ export type ChromeMode = 'full' | 'compact' | 'hidden';
73
+
46
74
  /**
47
75
  * Layout strategy defining how the visualization adapts to available space.
48
- * Returned by getLayoutStrategy() based on the current breakpoint.
76
+ * Returned by getLayoutStrategy() based on width breakpoint and height class.
49
77
  */
50
78
  export interface LayoutStrategy {
51
79
  /** How data labels are displayed. */
@@ -56,16 +84,16 @@ export interface LayoutStrategy {
56
84
  annotationPosition: AnnotationPosition;
57
85
  /** Axis tick density. */
58
86
  axisLabelDensity: AxisLabelDensity;
87
+ /** Chrome display mode: full, compact (title only), or hidden. */
88
+ chromeMode: ChromeMode;
89
+ /** Max fraction of container height for legend (0-1). -1 means unlimited. */
90
+ legendMaxHeight: number;
59
91
  }
60
92
 
61
93
  /**
62
- * Get the layout strategy for a given breakpoint.
63
- *
64
- * Compact: minimal chrome, no inline labels, legend on top, reduced axes.
65
- * Medium: moderate labels, legend on top, reduced axes.
66
- * Full: all labels, legend on right, full axes.
94
+ * Get the base layout strategy for a width breakpoint.
67
95
  */
68
- export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
96
+ function getWidthStrategy(breakpoint: Breakpoint): LayoutStrategy {
69
97
  switch (breakpoint) {
70
98
  case 'compact':
71
99
  return {
@@ -73,6 +101,8 @@ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
73
101
  legendPosition: 'top',
74
102
  annotationPosition: 'tooltip-only',
75
103
  axisLabelDensity: 'minimal',
104
+ chromeMode: 'full',
105
+ legendMaxHeight: -1,
76
106
  };
77
107
  case 'medium':
78
108
  return {
@@ -80,6 +110,8 @@ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
80
110
  legendPosition: 'top',
81
111
  annotationPosition: 'inline',
82
112
  axisLabelDensity: 'reduced',
113
+ chromeMode: 'full',
114
+ legendMaxHeight: -1,
83
115
  };
84
116
  case 'full':
85
117
  return {
@@ -87,6 +119,54 @@ export function getLayoutStrategy(breakpoint: Breakpoint): LayoutStrategy {
87
119
  legendPosition: 'right',
88
120
  annotationPosition: 'inline',
89
121
  axisLabelDensity: 'full',
122
+ chromeMode: 'full',
123
+ legendMaxHeight: -1,
90
124
  };
91
125
  }
92
126
  }
127
+
128
+ /**
129
+ * Apply height constraints to a width-based strategy.
130
+ * Short containers compress chrome and cap legend height.
131
+ * Cramped containers hide chrome and labels entirely.
132
+ */
133
+ function applyHeightConstraints(
134
+ strategy: LayoutStrategy,
135
+ heightClass: HeightClass,
136
+ ): LayoutStrategy {
137
+ if (heightClass === 'normal') return strategy;
138
+
139
+ if (heightClass === 'cramped') {
140
+ return {
141
+ ...strategy,
142
+ chromeMode: 'hidden',
143
+ legendMaxHeight: 0,
144
+ labelMode: 'none',
145
+ annotationPosition: 'tooltip-only',
146
+ };
147
+ }
148
+
149
+ // short
150
+ return {
151
+ ...strategy,
152
+ chromeMode: 'compact',
153
+ legendMaxHeight: 0.15,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Get the layout strategy for a given breakpoint and height class.
159
+ *
160
+ * Compact: minimal chrome, no inline labels, legend on top, reduced axes.
161
+ * Medium: moderate labels, legend on top, reduced axes.
162
+ * Full: all labels, legend on right, full axes.
163
+ *
164
+ * Height constraints further reduce chrome and legend when container is short.
165
+ */
166
+ export function getLayoutStrategy(
167
+ breakpoint: Breakpoint,
168
+ heightClass: HeightClass = 'normal',
169
+ ): LayoutStrategy {
170
+ const base = getWidthStrategy(breakpoint);
171
+ return applyHeightConstraints(base, heightClass);
172
+ }
@@ -6,6 +6,8 @@ export type {
6
6
  AnnotationPosition,
7
7
  AxisLabelDensity,
8
8
  Breakpoint,
9
+ ChromeMode,
10
+ HeightClass,
9
11
  LabelMode,
10
12
  LayoutStrategy,
11
13
  LegendPosition,
@@ -14,5 +16,8 @@ export {
14
16
  BREAKPOINT_COMPACT_MAX,
15
17
  BREAKPOINT_MEDIUM_MAX,
16
18
  getBreakpoint,
19
+ getHeightClass,
17
20
  getLayoutStrategy,
21
+ HEIGHT_CRAMPED_MAX,
22
+ HEIGHT_SHORT_MAX,
18
23
  } from './breakpoints';