@opendata-ai/openchart-engine 2.9.1 → 2.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.9.1",
48
+ "@opendata-ai/openchart-core": "2.10.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -24,6 +24,8 @@ export function makeFullStrategy(): LayoutStrategy {
24
24
  legendPosition: 'right',
25
25
  annotationPosition: 'inline',
26
26
  axisLabelDensity: 'full',
27
+ chromeMode: 'full',
28
+ legendMaxHeight: -1,
27
29
  };
28
30
  }
29
31
 
@@ -34,6 +36,8 @@ export function makeCompactStrategy(): LayoutStrategy {
34
36
  legendPosition: 'top',
35
37
  annotationPosition: 'tooltip-only',
36
38
  axisLabelDensity: 'minimal',
39
+ chromeMode: 'full',
40
+ legendMaxHeight: -1,
37
41
  };
38
42
  }
39
43
 
@@ -32,6 +32,8 @@ const fullStrategy: LayoutStrategy = {
32
32
  legendPosition: 'right',
33
33
  annotationPosition: 'inline',
34
34
  axisLabelDensity: 'full',
35
+ chromeMode: 'full',
36
+ legendMaxHeight: -1,
35
37
  };
36
38
 
37
39
  const minimalStrategy: LayoutStrategy = {
@@ -39,6 +41,8 @@ const minimalStrategy: LayoutStrategy = {
39
41
  legendPosition: 'top',
40
42
  annotationPosition: 'tooltip-only',
41
43
  axisLabelDensity: 'minimal',
44
+ chromeMode: 'full',
45
+ legendMaxHeight: -1,
42
46
  };
43
47
 
44
48
  describe('computeAxes', () => {
@@ -40,6 +40,8 @@ const fullStrategy: LayoutStrategy = {
40
40
  legendPosition: 'right',
41
41
  annotationPosition: 'inline',
42
42
  axisLabelDensity: 'full',
43
+ chromeMode: 'full',
44
+ legendMaxHeight: -1,
43
45
  };
44
46
 
45
47
  const compactStrategy: LayoutStrategy = {
@@ -47,6 +49,8 @@ const compactStrategy: LayoutStrategy = {
47
49
  legendPosition: 'top',
48
50
  annotationPosition: 'tooltip-only',
49
51
  axisLabelDensity: 'minimal',
52
+ chromeMode: 'full',
53
+ legendMaxHeight: -1,
50
54
  };
51
55
 
52
56
  describe('computeLegend', () => {
package/src/compile.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  generateAltText,
29
29
  generateDataTable,
30
30
  getBreakpoint,
31
+ getHeightClass,
31
32
  getLayoutStrategy,
32
33
  resolveTheme,
33
34
  } from '@opendata-ai/openchart-core';
@@ -163,7 +164,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
163
164
 
164
165
  // Responsive strategy
165
166
  const breakpoint = getBreakpoint(options.width);
166
- const strategy = getLayoutStrategy(breakpoint);
167
+ const heightClass = getHeightClass(options.height);
168
+ const strategy = getLayoutStrategy(breakpoint, heightClass);
167
169
 
168
170
  // Apply breakpoint-conditional overrides from the original spec
169
171
  const rawSpec = spec as Record<string, unknown>;
@@ -230,8 +232,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
230
232
  };
231
233
  const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
232
234
 
233
- // Compute dimensions (accounts for chrome + legend)
234
- const dims = computeDimensions(chartSpec, options, legendLayout, theme);
235
+ // Compute dimensions (accounts for chrome + legend + responsive strategy)
236
+ const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
235
237
  const chartArea = dims.chartArea;
236
238
 
237
239
  // Recompute legend bounds relative to actual chart area.
@@ -18,6 +18,7 @@ import type {
18
18
  import {
19
19
  abbreviateNumber,
20
20
  buildD3Formatter,
21
+ estimateTextWidth,
21
22
  formatDate,
22
23
  formatNumber,
23
24
  } from '@opendata-ai/openchart-core';
@@ -234,13 +235,33 @@ export function computeAxes(
234
235
  major: true,
235
236
  }));
236
237
 
238
+ // Auto-rotate labels when band scale labels would overlap.
239
+ // Uses max label width (not average) since one long label is enough to overlap.
240
+ let tickAngle = scales.x.channel.axis?.tickAngle;
241
+ if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
242
+ const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
243
+ let maxLabelWidth = 0;
244
+ for (const t of ticks) {
245
+ const w = estimateTextWidth(
246
+ t.label,
247
+ theme.fonts.sizes.axisTick,
248
+ theme.fonts.weights.normal,
249
+ );
250
+ if (w > maxLabelWidth) maxLabelWidth = w;
251
+ }
252
+ // If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
253
+ if (maxLabelWidth > bandwidth * 0.85) {
254
+ tickAngle = -45;
255
+ }
256
+ }
257
+
237
258
  result.x = {
238
259
  ticks,
239
260
  gridlines: scales.x.channel.axis?.grid ? gridlines : [],
240
261
  label: scales.x.channel.axis?.label,
241
262
  labelStyle: axisLabelStyle,
242
263
  tickLabelStyle,
243
- tickAngle: scales.x.channel.axis?.tickAngle,
264
+ tickAngle,
244
265
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
245
266
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
246
267
  };
@@ -5,11 +5,16 @@
5
5
  * LayoutDimensions with the total area, chrome layout, chart drawing area,
6
6
  * and margins. The chart area is what's left after subtracting chrome,
7
7
  * legend space, and axis margins.
8
+ *
9
+ * Padding and chrome scale down at smaller container sizes to maximize
10
+ * the usable chart area. When the chart area is still too small after
11
+ * scaling, chrome is progressively stripped as a fallback.
8
12
  */
9
13
 
10
14
  import type {
11
15
  CompileOptions,
12
16
  Encoding,
17
+ LayoutStrategy,
13
18
  LegendLayout,
14
19
  Margins,
15
20
  Rect,
@@ -53,6 +58,23 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
53
58
  };
54
59
  }
55
60
 
61
+ /**
62
+ * Scale padding based on the smaller container dimension.
63
+ * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
64
+ * Linear interpolation between 200-500px.
65
+ */
66
+ function scalePadding(basePadding: number, width: number, height: number): number {
67
+ const minDim = Math.min(width, height);
68
+ if (minDim >= 500) return basePadding;
69
+ if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
70
+ const t = (minDim - 200) / 300;
71
+ return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
72
+ }
73
+
74
+ /** Minimum chart area dimensions before guardrails kick in. */
75
+ const MIN_CHART_WIDTH = 60;
76
+ const MIN_CHART_HEIGHT = 40;
77
+
56
78
  // ---------------------------------------------------------------------------
57
79
  // Public API
58
80
  // ---------------------------------------------------------------------------
@@ -64,6 +86,7 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
64
86
  * @param options - Compile options (width, height, theme, darkMode).
65
87
  * @param legendLayout - Pre-computed legend layout (used to reserve space).
66
88
  * @param theme - Already-resolved theme (resolved once in compileChart).
89
+ * @param strategy - Responsive layout strategy (controls chrome mode).
67
90
  * @returns LayoutDimensions with chart area rect.
68
91
  */
69
92
  export function computeDimensions(
@@ -71,14 +94,23 @@ export function computeDimensions(
71
94
  options: CompileOptions,
72
95
  legendLayout: LegendLayout,
73
96
  theme: ResolvedTheme,
97
+ strategy?: LayoutStrategy,
74
98
  ): LayoutDimensions {
75
99
  const { width, height } = options;
76
100
 
77
- const padding = theme.spacing.padding;
101
+ const padding = scalePadding(theme.spacing.padding, width, height);
78
102
  const axisMargin = theme.spacing.axisMargin;
103
+ const chromeMode = strategy?.chromeMode ?? 'full';
79
104
 
80
- // Compute chrome
81
- const chrome = computeChrome(chromeToInput(spec.chrome), theme, width, options.measureText);
105
+ // Compute chrome with mode and scaled padding
106
+ const chrome = computeChrome(
107
+ chromeToInput(spec.chrome),
108
+ theme,
109
+ width,
110
+ options.measureText,
111
+ chromeMode,
112
+ padding,
113
+ );
82
114
 
83
115
  // Start with the total rect
84
116
  const total: Rect = { x: 0, y: 0, width, height };
@@ -216,12 +248,53 @@ export function computeDimensions(
216
248
  }
217
249
 
218
250
  // Chart area is what's left after margins
219
- const chartArea: Rect = {
251
+ let chartArea: Rect = {
220
252
  x: margins.left,
221
253
  y: margins.top,
222
254
  width: Math.max(0, width - margins.left - margins.right),
223
255
  height: Math.max(0, height - margins.top - margins.bottom),
224
256
  };
225
257
 
258
+ // Guardrail: if chart area is too small, progressively strip chrome
259
+ if (
260
+ (chartArea.width < MIN_CHART_WIDTH || chartArea.height < MIN_CHART_HEIGHT) &&
261
+ chromeMode !== 'hidden'
262
+ ) {
263
+ // Try compact first, then hidden
264
+ const fallbackMode = chromeMode === 'full' ? 'compact' : 'hidden';
265
+ const fallbackChrome = computeChrome(
266
+ chromeToInput(spec.chrome),
267
+ theme,
268
+ width,
269
+ options.measureText,
270
+ fallbackMode as 'compact' | 'hidden',
271
+ padding,
272
+ );
273
+
274
+ // Recalculate top/bottom margins with stripped chrome
275
+ const newTop = padding + fallbackChrome.topHeight + axisMargin;
276
+ const topDelta = margins.top - newTop;
277
+ const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
278
+ const bottomDelta = margins.bottom - newBottom;
279
+
280
+ if (topDelta > 0 || bottomDelta > 0) {
281
+ margins.top =
282
+ newTop +
283
+ (legendLayout.entries.length > 0 && legendLayout.position === 'top'
284
+ ? legendLayout.bounds.height + 4
285
+ : 0);
286
+ margins.bottom = newBottom;
287
+
288
+ chartArea = {
289
+ x: margins.left,
290
+ y: margins.top,
291
+ width: Math.max(0, width - margins.left - margins.right),
292
+ height: Math.max(0, height - margins.top - margins.bottom),
293
+ };
294
+
295
+ return { total, chrome: fallbackChrome, chartArea, margins, theme };
296
+ }
297
+ }
298
+
226
299
  return { total, chrome, chartArea, margins, theme };
227
300
  }
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * The legend is computed early (before marks) so the chartArea accounts
9
9
  * for legend space. Entries come from data + encoding, not marks.
10
+ *
11
+ * Overflow protection: when there are too many entries for the available
12
+ * space, entries are truncated and a "+N more" indicator is appended.
10
13
  */
11
14
 
12
15
  import type {
@@ -31,6 +34,12 @@ const ENTRY_GAP = 16;
31
34
  const LEGEND_PADDING = 8;
32
35
  const LEGEND_RIGHT_WIDTH = 120;
33
36
 
37
+ /** Max fraction of chart area height for right-positioned legends. */
38
+ const RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
39
+
40
+ /** Max number of rows for top-positioned legends before truncation. */
41
+ const TOP_LEGEND_MAX_ROWS = 2;
42
+
34
43
  // ---------------------------------------------------------------------------
35
44
  // Helpers
36
45
  // ---------------------------------------------------------------------------
@@ -68,6 +77,57 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
68
77
  }));
69
78
  }
70
79
 
80
+ /**
81
+ * Calculate how many entries fit within a given number of horizontal rows.
82
+ */
83
+ function entriesThatFit(
84
+ entries: LegendEntry[],
85
+ maxWidth: number,
86
+ maxRows: number,
87
+ labelStyle: TextStyle,
88
+ ): number {
89
+ let row = 1;
90
+ let rowWidth = 0;
91
+
92
+ for (let i = 0; i < entries.length; i++) {
93
+ const labelWidth = estimateTextWidth(
94
+ entries[i].label,
95
+ labelStyle.fontSize,
96
+ labelStyle.fontWeight,
97
+ );
98
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
99
+
100
+ if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
101
+ row++;
102
+ rowWidth = entryWidth;
103
+ if (row > maxRows) return i;
104
+ } else {
105
+ rowWidth += entryWidth;
106
+ }
107
+ }
108
+
109
+ return entries.length;
110
+ }
111
+
112
+ /**
113
+ * Truncate entries and add a "+N more" indicator if needed.
114
+ */
115
+ function truncateEntries(entries: LegendEntry[], maxCount: number): LegendEntry[] {
116
+ if (maxCount >= entries.length || maxCount <= 0) return entries;
117
+
118
+ const truncated = entries.slice(0, maxCount);
119
+ const remaining = entries.length - maxCount;
120
+ truncated.push({
121
+ label: `+${remaining} more`,
122
+ color: '#999999',
123
+ shape: 'square',
124
+ active: false,
125
+ overflow: true,
126
+ });
127
+
128
+ return truncated;
129
+ }
130
+
71
131
  // ---------------------------------------------------------------------------
72
132
  // Public API
73
133
  // ---------------------------------------------------------------------------
@@ -87,8 +147,8 @@ export function computeLegend(
87
147
  theme: ResolvedTheme,
88
148
  chartArea: Rect,
89
149
  ): LegendLayout {
90
- // Legend explicitly hidden via show: false
91
- if (spec.legend?.show === false) {
150
+ // Legend explicitly hidden via show: false, or height strategy says no legend
151
+ if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
92
152
  return {
93
153
  position: 'top',
94
154
  entries: [],
@@ -106,7 +166,7 @@ export function computeLegend(
106
166
  };
107
167
  }
108
168
 
109
- const entries = extractColorEntries(spec, theme);
169
+ let entries = extractColorEntries(spec, theme);
110
170
 
111
171
  const labelStyle: TextStyle = {
112
172
  fontFamily: theme.fonts.family,
@@ -143,6 +203,21 @@ export function computeLegend(
143
203
  SWATCH_SIZE + SWATCH_GAP + maxLabelWidth + LEGEND_PADDING * 2,
144
204
  );
145
205
  const entryHeight = Math.max(SWATCH_SIZE, labelStyle.fontSize * labelStyle.lineHeight);
206
+
207
+ // Apply max height ratio (default 40% of chart area, strategy can override)
208
+ const maxHeightRatio =
209
+ strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
210
+ const maxLegendHeight = chartArea.height * maxHeightRatio;
211
+
212
+ // Calculate how many entries fit
213
+ const maxEntries = Math.max(
214
+ 1,
215
+ Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
216
+ );
217
+ if (entries.length > maxEntries) {
218
+ entries = truncateEntries(entries, maxEntries);
219
+ }
220
+
146
221
  const legendHeight =
147
222
  entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
148
223
  const clampedHeight = Math.min(legendHeight, chartArea.height);
@@ -173,13 +248,35 @@ export function computeLegend(
173
248
  };
174
249
  }
175
250
 
176
- // Top/bottom-positioned legend: horizontal flow
251
+ // Top/bottom-positioned legend: horizontal flow with overflow protection
252
+ const availableWidth = chartArea.width - LEGEND_PADDING * 2;
253
+ const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
254
+
255
+ if (maxFit < entries.length) {
256
+ entries = truncateEntries(entries, maxFit);
257
+ }
258
+
177
259
  const totalWidth = entries.reduce((sum, entry) => {
178
260
  const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
179
261
  return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
180
262
  }, 0);
181
263
 
182
- const legendHeight = SWATCH_SIZE + LEGEND_PADDING * 2;
264
+ // Calculate actual row count for height
265
+ let rowCount = 1;
266
+ let rowWidth = 0;
267
+ for (const entry of entries) {
268
+ const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
269
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
270
+ if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
271
+ rowCount++;
272
+ rowWidth = entryWidth;
273
+ } else {
274
+ rowWidth += entryWidth;
275
+ }
276
+ }
277
+
278
+ const rowHeight = SWATCH_SIZE + 4;
279
+ const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
183
280
 
184
281
  // Apply user-provided legend offset
185
282
  const offsetDx = spec.legend?.offset?.dx ?? 0;