@opendata-ai/openchart-engine 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/src/compile.ts CHANGED
@@ -25,9 +25,12 @@ import type {
25
25
  } from '@opendata-ai/openchart-core';
26
26
  import {
27
27
  adaptTheme,
28
+ BRAND_RESERVE_WIDTH,
29
+ computeLabelBounds,
28
30
  generateAltText,
29
31
  generateDataTable,
30
32
  getBreakpoint,
33
+ getHeightClass,
31
34
  getLayoutStrategy,
32
35
  resolveTheme,
33
36
  } from '@opendata-ai/openchart-core';
@@ -74,14 +77,41 @@ import { computeTooltipDescriptors } from './tooltips/compute';
74
77
  // ---------------------------------------------------------------------------
75
78
 
76
79
  /**
77
- * Compute per-row bounding rects for band-scale charts (dot, bar).
78
- * Each obstacle covers the full band height and x-range of marks in that row,
79
- * giving the annotation nudge system awareness of data marks.
80
+ * Compute bounding rects from marks to use as obstacles for annotation nudging.
81
+ *
82
+ * For band-scale charts (bar, dot): groups marks by band row and returns
83
+ * a single obstacle per row spanning the full band height and x-range.
84
+ *
85
+ * For other charts (column, scatter): returns individual mark bounds so
86
+ * annotations avoid overlapping any visible data mark.
80
87
  */
81
- function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
82
- if (!scales.y || scales.y.type !== 'band') return [];
88
+ function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
89
+ // Band-scale y-axis: group marks by row for efficient obstacle computation
90
+ if (scales.y?.type === 'band') {
91
+ return computeBandRowObstacles(marks, scales);
92
+ }
93
+
94
+ // All other charts: use individual rect/point mark bounds as obstacles
95
+ const obstacles: Rect[] = [];
96
+ for (const mark of marks) {
97
+ if (mark.type === 'rect') {
98
+ const rm = mark as RectMark;
99
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
100
+ } else if (mark.type === 'point') {
101
+ const pm = mark as PointMark;
102
+ obstacles.push({
103
+ x: pm.cx - pm.r,
104
+ y: pm.cy - pm.r,
105
+ width: pm.r * 2,
106
+ height: pm.r * 2,
107
+ });
108
+ }
109
+ }
110
+ return obstacles;
111
+ }
83
112
 
84
- // Group marks by their y-center (rounded), compute x-extent per group
113
+ /** Group band-scale marks by row, returning one obstacle per band. */
114
+ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
85
115
  const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
86
116
 
87
117
  for (const mark of marks) {
@@ -115,7 +145,7 @@ function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
115
145
  }
116
146
 
117
147
  // Get bandwidth from the band scale
118
- const bandScale = scales.y.scale as { bandwidth?: () => number };
148
+ const bandScale = scales.y!.scale as { bandwidth?: () => number };
119
149
  const bandwidth = bandScale.bandwidth?.() ?? 0;
120
150
  if (bandwidth === 0) return [];
121
151
 
@@ -163,7 +193,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
163
193
 
164
194
  // Responsive strategy
165
195
  const breakpoint = getBreakpoint(options.width);
166
- const strategy = getLayoutStrategy(breakpoint);
196
+ const heightClass = getHeightClass(options.height);
197
+ const strategy = getLayoutStrategy(breakpoint, heightClass);
167
198
 
168
199
  // Apply breakpoint-conditional overrides from the original spec
169
200
  const rawSpec = spec as Record<string, unknown>;
@@ -230,8 +261,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
230
261
  };
231
262
  const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
232
263
 
233
- // Compute dimensions (accounts for chrome + legend)
234
- const dims = computeDimensions(chartSpec, options, legendLayout, theme);
264
+ // Compute dimensions (accounts for chrome + legend + responsive strategy)
265
+ const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
235
266
  const chartArea = dims.chartArea;
236
267
 
237
268
  // Recompute legend bounds relative to actual chart area.
@@ -313,7 +344,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
313
344
  // Compute axes (skip for radial charts)
314
345
  const axes = isRadial
315
346
  ? { x: undefined, y: undefined }
316
- : computeAxes(scales, chartArea, strategy, theme);
347
+ : computeAxes(scales, chartArea, strategy, theme, options.measureText);
317
348
 
318
349
  // Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
319
350
  if (!isRadial) {
@@ -324,12 +355,31 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
324
355
  const renderer = getChartRenderer(renderSpec.type);
325
356
  const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
326
357
 
327
- // Compute annotations from spec, passing legend + mark bounds as obstacles for collision avoidance
358
+ // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
328
359
  const obstacles: Rect[] = [];
329
360
  if (finalLegend.bounds.width > 0) {
330
361
  obstacles.push(finalLegend.bounds);
331
362
  }
332
- obstacles.push(...computeRowObstacles(marks, scales));
363
+ obstacles.push(...computeMarkObstacles(marks, scales));
364
+
365
+ // Add visible data label bounds as obstacles so annotations avoid overlapping them
366
+ for (const mark of marks) {
367
+ if (mark.type !== 'area' && mark.label?.visible) {
368
+ obstacles.push(computeLabelBounds(mark.label));
369
+ }
370
+ }
371
+
372
+ // Add brand watermark as an obstacle so annotations avoid overlapping it.
373
+ // The brand is right-aligned on the same baseline as the first bottom chrome element,
374
+ // offset below the chart area by x-axis extent (tick labels + axis title).
375
+ const brandPadding = theme.spacing.padding;
376
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
377
+ const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
378
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
379
+ const brandY = firstBottomChrome
380
+ ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
381
+ : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
382
+ obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
333
383
  const annotations: ResolvedAnnotation[] = computeAnnotations(
334
384
  chartSpec,
335
385
  scales,
@@ -11,6 +11,7 @@ import type {
11
11
  AxisTick,
12
12
  Gridline,
13
13
  LayoutStrategy,
14
+ MeasureTextFn,
14
15
  Rect,
15
16
  ResolvedTheme,
16
17
  TextStyle,
@@ -18,6 +19,7 @@ import type {
18
19
  import {
19
20
  abbreviateNumber,
20
21
  buildD3Formatter,
22
+ estimateTextWidth,
21
23
  formatDate,
22
24
  formatNumber,
23
25
  } from '@opendata-ai/openchart-core';
@@ -56,6 +58,15 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
56
58
  const WIDTH_MINIMAL_THRESHOLD = 150;
57
59
  const WIDTH_REDUCED_THRESHOLD = 300;
58
60
 
61
+ /**
62
+ * Minimum gap between adjacent tick labels as a multiple of font size.
63
+ * At the default 12px axis font, this yields ~12px of breathing room.
64
+ */
65
+ const MIN_TICK_GAP_FACTOR = 1.0;
66
+
67
+ /** Always show at least this many ticks, even if they overlap. */
68
+ const MIN_TICK_COUNT = 2;
69
+
59
70
  /** Ordered densities from most to fewest ticks. */
60
71
  const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
61
72
 
@@ -94,25 +105,106 @@ export function effectiveDensity(
94
105
  return density;
95
106
  }
96
107
 
108
+ // ---------------------------------------------------------------------------
109
+ // Label overlap detection and thinning
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** Measure a single label's width using real measurement or heuristic fallback. */
113
+ function measureLabel(
114
+ text: string,
115
+ fontSize: number,
116
+ fontWeight: number,
117
+ measureText?: MeasureTextFn,
118
+ ): number {
119
+ return measureText
120
+ ? measureText(text, fontSize, fontWeight).width
121
+ : estimateTextWidth(text, fontSize, fontWeight);
122
+ }
123
+
124
+ /** Check whether any adjacent tick labels overlap horizontally. */
125
+ export function ticksOverlap(
126
+ ticks: AxisTick[],
127
+ fontSize: number,
128
+ fontWeight: number,
129
+ measureText?: MeasureTextFn,
130
+ ): boolean {
131
+ if (ticks.length < 2) return false;
132
+ const minGap = fontSize * MIN_TICK_GAP_FACTOR;
133
+ for (let i = 0; i < ticks.length - 1; i++) {
134
+ const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
135
+ const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
136
+ const aRight = ticks[i].position + aWidth / 2;
137
+ const bLeft = ticks[i + 1].position - bWidth / 2;
138
+ if (aRight + minGap > bLeft) return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Thin a tick array by removing every other tick until labels don't overlap.
145
+ * Always keeps first and last tick. O(log n) iterations max.
146
+ * Returns the original array if no thinning is needed.
147
+ */
148
+ export function thinTicksUntilFit(
149
+ ticks: AxisTick[],
150
+ fontSize: number,
151
+ fontWeight: number,
152
+ measureText?: MeasureTextFn,
153
+ ): AxisTick[] {
154
+ if (!ticksOverlap(ticks, fontSize, fontWeight, measureText)) return ticks;
155
+
156
+ let current = ticks;
157
+ while (current.length > MIN_TICK_COUNT) {
158
+ // Keep first, last, and every other tick in between
159
+ const thinned = [current[0]];
160
+ for (let i = 2; i < current.length - 1; i += 2) {
161
+ thinned.push(current[i]);
162
+ }
163
+ if (current.length > 1) thinned.push(current[current.length - 1]);
164
+ current = thinned;
165
+
166
+ if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
167
+ }
168
+ return current;
169
+ }
170
+
97
171
  // ---------------------------------------------------------------------------
98
172
  // Tick generation
99
173
  // ---------------------------------------------------------------------------
100
174
 
101
175
  /** Generate ticks for a continuous scale (linear, time, log). */
102
- function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
176
+ function continuousTicks(
177
+ resolvedScale: ResolvedScale,
178
+ density: AxisLabelDensity,
179
+ fontSize: number,
180
+ fontWeight: number,
181
+ measureText?: MeasureTextFn,
182
+ ): AxisTick[] {
103
183
  const scale = resolvedScale.scale as D3ContinuousScale;
104
- const count = resolvedScale.channel.axis?.tickCount ?? TICK_COUNTS[density];
105
- const ticks: unknown[] = scale.ticks(count);
184
+ const explicitCount = resolvedScale.channel.axis?.tickCount;
185
+ const count = explicitCount ?? TICK_COUNTS[density];
186
+ const rawTicks: unknown[] = scale.ticks(count);
106
187
 
107
- return ticks.map((value: unknown) => ({
188
+ const ticks = rawTicks.map((value: unknown) => ({
108
189
  value,
109
190
  position: scale(value as number & Date) as number,
110
191
  label: formatTickLabel(value, resolvedScale),
111
192
  }));
193
+
194
+ // Respect explicit tickCount: user asked for this many, don't override
195
+ if (explicitCount) return ticks;
196
+
197
+ return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
112
198
  }
113
199
 
114
200
  /** Generate ticks for a band/point/ordinal scale. */
115
- function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
201
+ function categoricalTicks(
202
+ resolvedScale: ResolvedScale,
203
+ density: AxisLabelDensity,
204
+ fontSize: number,
205
+ fontWeight: number,
206
+ measureText?: MeasureTextFn,
207
+ ): AxisTick[] {
116
208
  const scale = resolvedScale.scale as D3CategoricalScale;
117
209
  const domain: string[] = scale.domain();
118
210
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
@@ -126,7 +218,7 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
126
218
  selectedValues = domain.filter((_: string, i: number) => i % step === 0);
127
219
  }
128
220
 
129
- return selectedValues.map((value: string) => {
221
+ const ticks = selectedValues.map((value: string) => {
130
222
  // Band scales: use the center of the band
131
223
  const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
132
224
  const pos = bandScale
@@ -139,6 +231,13 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
139
231
  label: value,
140
232
  };
141
233
  });
234
+
235
+ // For non-band scales without explicit tickCount, thin based on label width
236
+ if (resolvedScale.type !== 'band' && !explicitTickCount) {
237
+ return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
238
+ }
239
+
240
+ return ticks;
142
241
  }
143
242
 
144
243
  /** Format a tick value based on the scale type. */
@@ -181,12 +280,14 @@ export interface AxesResult {
181
280
  * @param chartArea - The chart drawing area.
182
281
  * @param strategy - Responsive layout strategy.
183
282
  * @param theme - Resolved theme for styling.
283
+ * @param measureText - Optional real text measurement from the adapter.
184
284
  */
185
285
  export function computeAxes(
186
286
  scales: ResolvedScales,
187
287
  chartArea: Rect,
188
288
  strategy: LayoutStrategy,
189
289
  theme: ResolvedTheme,
290
+ measureText?: MeasureTextFn,
190
291
  ): AxesResult {
191
292
  const result: AxesResult = {};
192
293
  const baseDensity = strategy.axisLabelDensity;
@@ -223,24 +324,43 @@ export function computeAxes(
223
324
  lineHeight: 1.3,
224
325
  };
225
326
 
327
+ const { fontSize } = tickLabelStyle;
328
+ const { fontWeight } = tickLabelStyle;
329
+
226
330
  if (scales.x) {
227
331
  const ticks =
228
332
  scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
229
- ? categoricalTicks(scales.x, xDensity)
230
- : continuousTicks(scales.x, xDensity);
333
+ ? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText)
334
+ : continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
231
335
 
232
336
  const gridlines: Gridline[] = ticks.map((t) => ({
233
337
  position: t.position,
234
338
  major: true,
235
339
  }));
236
340
 
341
+ // Auto-rotate labels when band scale labels would overlap.
342
+ // Uses max label width (not average) since one long label is enough to overlap.
343
+ let tickAngle = scales.x.channel.axis?.tickAngle;
344
+ if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
345
+ const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
346
+ let maxLabelWidth = 0;
347
+ for (const t of ticks) {
348
+ const w = measureLabel(t.label, fontSize, fontWeight, measureText);
349
+ if (w > maxLabelWidth) maxLabelWidth = w;
350
+ }
351
+ // If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
352
+ if (maxLabelWidth > bandwidth * 0.85) {
353
+ tickAngle = -45;
354
+ }
355
+ }
356
+
237
357
  result.x = {
238
358
  ticks,
239
359
  gridlines: scales.x.channel.axis?.grid ? gridlines : [],
240
360
  label: scales.x.channel.axis?.label,
241
361
  labelStyle: axisLabelStyle,
242
362
  tickLabelStyle,
243
- tickAngle: scales.x.channel.axis?.tickAngle,
363
+ tickAngle,
244
364
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
245
365
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
246
366
  };
@@ -249,8 +369,8 @@ export function computeAxes(
249
369
  if (scales.y) {
250
370
  const ticks =
251
371
  scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
252
- ? categoricalTicks(scales.y, yDensity)
253
- : continuousTicks(scales.y, yDensity);
372
+ ? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText)
373
+ : continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
254
374
 
255
375
  const gridlines: Gridline[] = ticks.map((t) => ({
256
376
  position: t.position,
@@ -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 {
@@ -17,7 +20,7 @@ import type {
17
20
  ResolvedTheme,
18
21
  TextStyle,
19
22
  } from '@opendata-ai/openchart-core';
20
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
23
+ import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
21
24
 
22
25
  import type { NormalizedChartSpec } from '../compiler/types';
23
26
 
@@ -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,36 @@ 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
+ // Reserve space on the right so legend entries don't overlap the brand watermark.
253
+ const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
254
+ const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
255
+
256
+ if (maxFit < entries.length) {
257
+ entries = truncateEntries(entries, maxFit);
258
+ }
259
+
177
260
  const totalWidth = entries.reduce((sum, entry) => {
178
261
  const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
179
262
  return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
180
263
  }, 0);
181
264
 
182
- const legendHeight = SWATCH_SIZE + LEGEND_PADDING * 2;
265
+ // Calculate actual row count for height
266
+ let rowCount = 1;
267
+ let rowWidth = 0;
268
+ for (const entry of entries) {
269
+ const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
270
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
271
+ if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
272
+ rowCount++;
273
+ rowWidth = entryWidth;
274
+ } else {
275
+ rowWidth += entryWidth;
276
+ }
277
+ }
278
+
279
+ const rowHeight = SWATCH_SIZE + 4;
280
+ const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
183
281
 
184
282
  // Apply user-provided legend offset
185
283
  const offsetDx = spec.legend?.offset?.dx ?? 0;
@@ -194,7 +292,7 @@ export function computeLegend(
194
292
  (resolvedPosition === 'bottom'
195
293
  ? chartArea.y + chartArea.height - legendHeight
196
294
  : chartArea.y) + offsetDy,
197
- width: Math.min(totalWidth, chartArea.width),
295
+ width: Math.min(totalWidth, availableWidth),
198
296
  height: legendHeight,
199
297
  },
200
298
  labelStyle,