@opendata-ai/openchart-engine 6.19.3 → 6.20.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": "6.19.3",
3
+ "version": "6.20.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": "6.19.3",
48
+ "@opendata-ai/openchart-core": "6.20.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -1,6 +1,7 @@
1
1
  import type { LayoutStrategy, Rect, ResolvedTheme } from '@opendata-ai/openchart-core';
2
2
  import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
+ import { compileChart } from '../compile';
4
5
  import type { NormalizedChartSpec } from '../compiler/types';
5
6
  import { computeLegend } from '../legend/compute';
6
7
 
@@ -393,4 +394,42 @@ describe('computeLegend', () => {
393
394
  expect(legend.bounds.width).toBe(0);
394
395
  });
395
396
  });
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Characterization test (refactor/v7-cohesion step 1):
400
+ // Pins the 4px gap between a top-positioned legend and the chart area,
401
+ // as enforced at packages/engine/src/compile.ts:331. Refactor step 4 will
402
+ // consolidate legend row-wrapping geometry; this test guards the spacing
403
+ // invariant through that change.
404
+ // ---------------------------------------------------------------------------
405
+ describe('top legend spacing', () => {
406
+ it('places the legend exactly 4px above the chart area', () => {
407
+ const spec = {
408
+ mark: 'bar' as const,
409
+ data: [
410
+ { name: 'A', value: 10, group: 'X' },
411
+ { name: 'A', value: 20, group: 'Y' },
412
+ { name: 'B', value: 30, group: 'X' },
413
+ { name: 'B', value: 25, group: 'Y' },
414
+ ],
415
+ encoding: {
416
+ x: { field: 'name', type: 'nominal' as const },
417
+ y: { field: 'value', type: 'quantitative' as const },
418
+ color: { field: 'group', type: 'nominal' as const },
419
+ },
420
+ legend: { position: 'top' as const },
421
+ };
422
+
423
+ const layout = compileChart(spec, { width: 600, height: 400 });
424
+
425
+ expect(layout.legend.position).toBe('top');
426
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
427
+ expect(layout.legend.bounds.height).toBeGreaterThan(0);
428
+
429
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
430
+ const gap = layout.area.y - legendBottom;
431
+ // Pin value matches the literal at compile.ts:331 (legendArea.y -= legendLayout.bounds.height + 4)
432
+ expect(gap).toBe(4);
433
+ });
434
+ });
396
435
  });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared numeric value formatter for data labels.
3
+ *
4
+ * Used by bar, column, and dot label computation to display a value:
5
+ * abbreviated (K/M/B/T) for magnitudes >= 1000, otherwise the default
6
+ * numeric format.
7
+ */
8
+
9
+ import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
10
+
11
+ /** Format a label value for display (abbreviate large numbers). */
12
+ export function formatLabelValue(value: number): string {
13
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
14
+ return formatNumber(value);
15
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Characterization tests for horizontal-bar gradient auto-orientation.
3
+ *
4
+ * Part of refactor/v7-cohesion step 1. Pins the behavior of
5
+ * `orientGradientForHorizontalBar` in `packages/engine/src/charts/bar/compute.ts`:
6
+ * when a user supplies the default vertical gradient (top-to-bottom) as a
7
+ * horizontal-bar fill, the engine rotates it to horizontal (left-to-right) so
8
+ * the gradient follows the bar's data direction. Vertical bars (columns) keep
9
+ * the gradient unchanged. Explicit non-default coordinates are never rewritten.
10
+ *
11
+ * These tests protect the behavior through upcoming refactors that may consolidate
12
+ * gradient logic across bar/column/stacked variants.
13
+ */
14
+
15
+ import type { ChartSpec, LinearGradient, RectMark } from '@opendata-ai/openchart-core';
16
+ import { isGradientDef } from '@opendata-ai/openchart-core';
17
+ import { describe, expect, it } from 'vitest';
18
+ import { compileChart } from '../../../compile';
19
+
20
+ // A default-vertical linear gradient (no explicit coords — defaults resolve to
21
+ // x1:0, y1:0, x2:0, y2:1 which is the "top-to-bottom" default).
22
+ const defaultVerticalGradient: LinearGradient = {
23
+ gradient: 'linear',
24
+ stops: [
25
+ { offset: 0, color: '#1b7fa3', opacity: 0.4 },
26
+ { offset: 1, color: '#1b7fa3' },
27
+ ],
28
+ };
29
+
30
+ function firstGradientRectFill(marks: RectMark[]): LinearGradient {
31
+ const mark = marks.find((m) => m.type === 'rect' && isGradientDef(m.fill));
32
+ if (!mark) throw new Error('expected at least one RectMark with a gradient fill');
33
+ const fill = mark.fill;
34
+ if (!isGradientDef(fill) || fill.gradient !== 'linear') {
35
+ throw new Error('expected a linear gradient fill');
36
+ }
37
+ return fill as LinearGradient;
38
+ }
39
+
40
+ describe('horizontal bar gradient auto-orientation', () => {
41
+ it('rotates the default vertical gradient to horizontal on horizontal bars', () => {
42
+ const spec: ChartSpec = {
43
+ mark: { type: 'bar', fill: defaultVerticalGradient },
44
+ data: [
45
+ { category: 'A', value: 10 },
46
+ { category: 'B', value: 20 },
47
+ { category: 'C', value: 30 },
48
+ ],
49
+ // Horizontal bar: x is quantitative, y is nominal
50
+ encoding: {
51
+ x: { field: 'value', type: 'quantitative' },
52
+ y: { field: 'category', type: 'nominal' },
53
+ },
54
+ };
55
+
56
+ const layout = compileChart(spec, { width: 600, height: 400 });
57
+ const grad = firstGradientRectFill(layout.marks as RectMark[]);
58
+
59
+ // Horizontal gradient: x1 != x2, y1 == y2
60
+ expect(grad.x1).toBe(0);
61
+ expect(grad.y1).toBe(0);
62
+ expect(grad.x2).toBe(1);
63
+ expect(grad.y2).toBe(0);
64
+ expect(grad.x1).not.toBe(grad.x2);
65
+ expect(grad.y1).toBe(grad.y2);
66
+ });
67
+
68
+ it('leaves the default vertical gradient unchanged on vertical (column) bars', () => {
69
+ const spec: ChartSpec = {
70
+ mark: { type: 'bar', fill: defaultVerticalGradient },
71
+ data: [
72
+ { category: 'A', value: 10 },
73
+ { category: 'B', value: 20 },
74
+ { category: 'C', value: 30 },
75
+ ],
76
+ // Vertical bar (column): x is nominal, y is quantitative
77
+ encoding: {
78
+ x: { field: 'category', type: 'nominal' },
79
+ y: { field: 'value', type: 'quantitative' },
80
+ },
81
+ };
82
+
83
+ const layout = compileChart(spec, { width: 600, height: 400 });
84
+ const grad = firstGradientRectFill(layout.marks as RectMark[]);
85
+
86
+ // Vertical gradient preserved: x1 == x2 (both 0), y1 != y2 (0 vs 1)
87
+ expect(grad.x1 ?? 0).toBe(0);
88
+ expect(grad.x2 ?? 0).toBe(0);
89
+ expect(grad.y1 ?? 0).toBe(0);
90
+ expect(grad.y2 ?? 1).toBe(1);
91
+ expect(grad.x1 ?? 0).toBe(grad.x2 ?? 0);
92
+ expect(grad.y1 ?? 0).not.toBe(grad.y2 ?? 1);
93
+ });
94
+
95
+ it('leaves an explicitly-oriented gradient unchanged on horizontal bars', () => {
96
+ // Explicit non-default direction — user knows what they want, the engine
97
+ // must not rewrite it. Here we pass a diagonal gradient.
98
+ const explicitDiagonal: LinearGradient = {
99
+ gradient: 'linear',
100
+ x1: 0,
101
+ y1: 0,
102
+ x2: 1,
103
+ y2: 1,
104
+ stops: [
105
+ { offset: 0, color: '#1b7fa3' },
106
+ { offset: 1, color: '#ff6600' },
107
+ ],
108
+ };
109
+
110
+ const spec: ChartSpec = {
111
+ mark: { type: 'bar', fill: explicitDiagonal },
112
+ data: [{ category: 'A', value: 10 }],
113
+ encoding: {
114
+ x: { field: 'value', type: 'quantitative' },
115
+ y: { field: 'category', type: 'nominal' },
116
+ },
117
+ };
118
+
119
+ const layout = compileChart(spec, { width: 600, height: 400 });
120
+ const grad = firstGradientRectFill(layout.marks as RectMark[]);
121
+
122
+ expect(grad.x1).toBe(0);
123
+ expect(grad.y1).toBe(0);
124
+ expect(grad.x2).toBe(1);
125
+ expect(grad.y2).toBe(1);
126
+ });
127
+ });
@@ -17,11 +17,12 @@ import type {
17
17
  Rect,
18
18
  RectMark,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
20
+ import { isGradientDef } from '@opendata-ai/openchart-core';
21
21
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
22
  import type { NormalizedChartSpec } from '../../compiler/types';
23
23
  import type { ResolvedScales } from '../../layout/scales';
24
24
  import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
25
+ import { formatLabelValue } from '../_shared/format-label-value';
25
26
  import { getColor, getSequentialColor, groupByField } from '../utils';
26
27
 
27
28
  /**
@@ -53,12 +54,6 @@ function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
53
54
 
54
55
  const MIN_BAR_WIDTH = 1;
55
56
 
56
- /** Format a bar value for display (abbreviate large numbers). */
57
- function formatBarValue(value: number): string {
58
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
59
- return formatNumber(value);
60
- }
61
-
62
57
  // ---------------------------------------------------------------------------
63
58
  // Public API
64
59
  // ---------------------------------------------------------------------------
@@ -221,7 +216,7 @@ function computeStackedBars(
221
216
  const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
222
217
 
223
218
  const aria: MarkAria = {
224
- label: `${category}, ${groupKey}: ${formatBarValue(rawValue)}`,
219
+ label: `${category}, ${groupKey}: ${formatLabelValue(rawValue)}`,
225
220
  };
226
221
 
227
222
  marks.push({
@@ -291,7 +286,7 @@ function computeGroupedBars(
291
286
  const subY = bandY + groupIndex * (subBandHeight + gap);
292
287
 
293
288
  const aria: MarkAria = {
294
- label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
289
+ label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
295
290
  };
296
291
 
297
292
  marks.push({
@@ -341,7 +336,7 @@ function computeColoredBars(
341
336
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
342
337
 
343
338
  const aria: MarkAria = {
344
- label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
339
+ label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
345
340
  };
346
341
 
347
342
  marks.push({
@@ -401,7 +396,7 @@ function computeSimpleBars(
401
396
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
402
397
 
403
398
  const aria: MarkAria = {
404
- label: `${category}: ${formatBarValue(value)}`,
399
+ label: `${category}: ${formatLabelValue(value)}`,
405
400
  };
406
401
 
407
402
  marks.push({
@@ -18,24 +18,17 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
- abbreviateNumber,
22
21
  buildD3Formatter,
23
22
  estimateTextWidth,
24
- formatNumber,
25
23
  getRepresentativeColor,
26
24
  resolveCollisions,
27
25
  } from '@opendata-ai/openchart-core';
26
+ import { formatLabelValue } from '../_shared/format-label-value';
28
27
 
29
28
  // ---------------------------------------------------------------------------
30
29
  // Helpers
31
30
  // ---------------------------------------------------------------------------
32
31
 
33
- /** Format a bar value for display (abbreviate large numbers). */
34
- function formatBarValue(value: number): string {
35
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
- return formatNumber(value);
37
- }
38
-
39
32
  /** Suffix multipliers mirroring core's abbreviateNumber output (K/M/B/T). */
40
33
  const SUFFIX_MULTIPLIERS: Record<string, number> = {
41
34
  K: 1_000,
@@ -124,7 +117,7 @@ export function computeBarLabels(
124
117
  if (formatter && Number.isFinite(rawNum)) {
125
118
  valuePart = formatter(rawNum);
126
119
  } else if (Number.isFinite(rawNum)) {
127
- valuePart = formatBarValue(rawNum);
120
+ valuePart = formatLabelValue(rawNum);
128
121
  } else {
129
122
  // Fallback: extract from aria label
130
123
  const ariaLabel = mark.aria.label;
@@ -20,11 +20,12 @@ import type {
20
20
  Rect,
21
21
  RectMark,
22
22
  } from '@opendata-ai/openchart-core';
23
- import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
23
+ import { isGradientDef } from '@opendata-ai/openchart-core';
24
24
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
25
25
  import type { NormalizedChartSpec } from '../../compiler/types';
26
26
  import type { ResolvedScales } from '../../layout/scales';
27
27
  import { isConditionalValueDef, resolveConditionalValue } from '../../transforms/conditional';
28
+ import { formatLabelValue } from '../_shared/format-label-value';
28
29
  import { getColor, getSequentialColor, groupByField } from '../utils';
29
30
 
30
31
  // ---------------------------------------------------------------------------
@@ -33,12 +34,6 @@ import { getColor, getSequentialColor, groupByField } from '../utils';
33
34
 
34
35
  const MIN_COLUMN_HEIGHT = 1;
35
36
 
36
- /** Format a column value for display (abbreviate large numbers). */
37
- function formatColumnValue(value: number): string {
38
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
39
- return formatNumber(value);
40
- }
41
-
42
37
  // ---------------------------------------------------------------------------
43
38
  // Public API
44
39
  // ---------------------------------------------------------------------------
@@ -199,7 +194,7 @@ function computeSimpleColumns(
199
194
  const y = value >= 0 ? yPos : baseline;
200
195
 
201
196
  const aria: MarkAria = {
202
- label: `${category}: ${formatColumnValue(value)}`,
197
+ label: `${category}: ${formatLabelValue(value)}`,
203
198
  };
204
199
 
205
200
  marks.push({
@@ -250,7 +245,7 @@ function computeColoredColumns(
250
245
  const y = value >= 0 ? yPos : baseline;
251
246
 
252
247
  const aria: MarkAria = {
253
- label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
248
+ label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
254
249
  };
255
250
 
256
251
  marks.push({
@@ -320,7 +315,7 @@ function computeGroupedColumns(
320
315
  const subX = bandX + groupIndex * (subBandWidth + gap);
321
316
 
322
317
  const aria: MarkAria = {
323
- label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
318
+ label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
324
319
  };
325
320
 
326
321
  marks.push({
@@ -389,7 +384,7 @@ function computeStackedColumns(
389
384
  const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
390
385
 
391
386
  const aria: MarkAria = {
392
- label: `${category}, ${groupKey}: ${formatColumnValue(rawValue)}`,
387
+ label: `${category}, ${groupKey}: ${formatLabelValue(rawValue)}`,
393
388
  };
394
389
 
395
390
  marks.push({
@@ -18,23 +18,12 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
- abbreviateNumber,
22
21
  buildD3Formatter,
23
22
  estimateTextWidth,
24
- formatNumber,
25
23
  getRepresentativeColor,
26
24
  resolveCollisions,
27
25
  } from '@opendata-ai/openchart-core';
28
-
29
- // ---------------------------------------------------------------------------
30
- // Helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- /** Format a column value for display (abbreviate large numbers). */
34
- function formatColumnValue(value: number): string {
35
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
- return formatNumber(value);
37
- }
26
+ import { formatLabelValue } from '../_shared/format-label-value';
38
27
 
39
28
  // ---------------------------------------------------------------------------
40
29
  // Constants
@@ -82,7 +71,7 @@ export function computeColumnLabels(
82
71
  if (formatter && Number.isFinite(rawNum)) {
83
72
  valuePart = formatter(rawNum);
84
73
  } else if (Number.isFinite(rawNum)) {
85
- valuePart = formatColumnValue(rawNum);
74
+ valuePart = formatLabelValue(rawNum);
86
75
  } else {
87
76
  // Fallback: extract from aria label
88
77
  const ariaLabel = mark.aria.label;
@@ -18,23 +18,12 @@ import type {
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
20
  import {
21
- abbreviateNumber,
22
21
  buildD3Formatter,
23
22
  estimateTextWidth,
24
- formatNumber,
25
23
  getRepresentativeColor,
26
24
  resolveCollisions,
27
25
  } from '@opendata-ai/openchart-core';
28
-
29
- // ---------------------------------------------------------------------------
30
- // Helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- /** Format a dot value for display (abbreviate large numbers). */
34
- function formatDotValue(value: number): string {
35
- if (Math.abs(value) >= 1000) return abbreviateNumber(value);
36
- return formatNumber(value);
37
- }
26
+ import { formatLabelValue } from '../_shared/format-label-value';
38
27
 
39
28
  // ---------------------------------------------------------------------------
40
29
  // Constants
@@ -81,7 +70,7 @@ export function computeDotLabels(
81
70
  if (formatter && Number.isFinite(rawNum)) {
82
71
  valuePart = formatter(rawNum);
83
72
  } else if (Number.isFinite(rawNum)) {
84
- valuePart = formatDotValue(rawNum);
73
+ valuePart = formatLabelValue(rawNum);
85
74
  } else {
86
75
  // Fallback: extract from aria label
87
76
  const ariaLabel = mark.aria.label;
@@ -23,14 +23,12 @@ import type {
23
23
  import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
24
24
 
25
25
  import type { NormalizedChartSpec } from '../compiler/types';
26
+ import { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Constants
29
30
  // ---------------------------------------------------------------------------
30
31
 
31
- const SWATCH_SIZE = 12;
32
- const SWATCH_GAP = 6;
33
- const ENTRY_GAP = 16;
34
32
  const LEGEND_PADDING = 8;
35
33
  const LEGEND_RIGHT_WIDTH = 120;
36
34
 
@@ -92,38 +90,6 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
92
90
  });
93
91
  }
94
92
 
95
- /**
96
- * Calculate how many entries fit within a given number of horizontal rows.
97
- */
98
- function entriesThatFit(
99
- entries: LegendEntry[],
100
- maxWidth: number,
101
- maxRows: number,
102
- labelStyle: TextStyle,
103
- ): number {
104
- let row = 1;
105
- let rowWidth = 0;
106
-
107
- for (let i = 0; i < entries.length; i++) {
108
- const labelWidth = estimateTextWidth(
109
- entries[i].label,
110
- labelStyle.fontSize,
111
- labelStyle.fontWeight,
112
- );
113
- const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
114
-
115
- if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
116
- row++;
117
- rowWidth = entryWidth;
118
- if (row > maxRows) return i;
119
- } else {
120
- rowWidth += entryWidth;
121
- }
122
- }
123
-
124
- return entries.length;
125
- }
126
-
127
93
  /**
128
94
  * Truncate entries and add a "+N more" indicator if needed.
129
95
  */
@@ -310,10 +276,10 @@ export function computeLegend(
310
276
  : spec.legend?.columns != null
311
277
  ? Math.ceil(entries.length / spec.legend.columns)
312
278
  : TOP_LEGEND_MAX_ROWS;
313
- const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
279
+ const { fittingCount } = measureLegendWrap(entries, availableWidth, labelStyle, maxRows);
314
280
 
315
- if (maxFit < entries.length) {
316
- entries = truncateEntries(entries, maxFit);
281
+ if (fittingCount < entries.length) {
282
+ entries = truncateEntries(entries, fittingCount);
317
283
  }
318
284
 
319
285
  const totalWidth = entries.reduce((sum, entry) => {
@@ -321,19 +287,8 @@ export function computeLegend(
321
287
  return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
322
288
  }, 0);
323
289
 
324
- // Calculate actual row count for height
325
- let rowCount = 1;
326
- let rowWidth = 0;
327
- for (const entry of entries) {
328
- const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
329
- const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
330
- if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
331
- rowCount++;
332
- rowWidth = entryWidth;
333
- } else {
334
- rowWidth += entryWidth;
335
- }
336
- }
290
+ // Calculate actual row count for height (recompute after truncation).
291
+ const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
337
292
 
338
293
  const rowHeight = SWATCH_SIZE + 4;
339
294
  const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Legend row-wrap geometry.
3
+ *
4
+ * Shared helper for measuring how legend entries flow across horizontal rows
5
+ * when wrapped at a max width. Both the main legend compute and the sankey
6
+ * legend compile use this to size their legends — the main legend uses
7
+ * `fittingCount` for truncation decisions, while sankey uses `rowCount` to
8
+ * reserve vertical height.
9
+ *
10
+ * The geometry matches the existing layout exactly: each entry occupies
11
+ * SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP pixels, a new row is
12
+ * started when the accumulated row width plus the next entry would exceed
13
+ * maxWidth (and the current row is non-empty), and rowWidths captures the
14
+ * in-row accumulated width at the point of wrapping.
15
+ */
16
+
17
+ import type { LegendEntry, TextStyle } from '@opendata-ai/openchart-core';
18
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+ //
24
+ // Single source of truth for legend row geometry. Both compute.ts and the
25
+ // sankey compile site import these so the wrap math here can never drift from
26
+ // the layout math at the call sites.
27
+
28
+ export const SWATCH_SIZE = 12;
29
+ export const SWATCH_GAP = 6;
30
+ export const ENTRY_GAP = 16;
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Public API
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export interface LegendWrapResult {
37
+ /** Total number of rows the entries occupy when wrapped at maxWidth. */
38
+ rowCount: number;
39
+ /** Entries that fit within maxRows (for truncation). Equals entries.length when maxRows is not set or all entries fit. */
40
+ fittingCount: number;
41
+ /** Width (in px) of each row — callers can use for alignment. */
42
+ rowWidths: number[];
43
+ }
44
+
45
+ /**
46
+ * Measure how legend entries wrap across rows at a given max width.
47
+ *
48
+ * @param entries - Legend entries to measure.
49
+ * @param maxWidth - Maximum width (in px) available for a single row.
50
+ * @param labelStyle - Text style used to estimate label widths.
51
+ * @param maxRows - Optional cap used only for the `fittingCount` truncation decision. When provided, `fittingCount` will be the index of the first entry that would spill onto a row beyond `maxRows`. `rowCount` is always the real row count regardless of this cap.
52
+ */
53
+ export function measureLegendWrap(
54
+ entries: LegendEntry[],
55
+ maxWidth: number,
56
+ labelStyle: TextStyle,
57
+ maxRows?: number,
58
+ ): LegendWrapResult {
59
+ if (entries.length === 0) {
60
+ return { rowCount: 0, fittingCount: 0, rowWidths: [] };
61
+ }
62
+
63
+ let rowCount = 1;
64
+ let rowWidth = 0;
65
+ const rowWidths: number[] = [];
66
+ let fittingCount = entries.length;
67
+ let fittingCountLocked = false;
68
+
69
+ for (let i = 0; i < entries.length; i++) {
70
+ const labelWidth = estimateTextWidth(
71
+ entries[i].label,
72
+ labelStyle.fontSize,
73
+ labelStyle.fontWeight,
74
+ );
75
+ const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
76
+
77
+ if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
78
+ rowWidths.push(rowWidth);
79
+ rowCount++;
80
+ rowWidth = entryWidth;
81
+ if (!fittingCountLocked && maxRows != null && rowCount > maxRows) {
82
+ fittingCount = i;
83
+ fittingCountLocked = true;
84
+ }
85
+ } else {
86
+ rowWidth += entryWidth;
87
+ }
88
+ }
89
+
90
+ // Flush the final row width so rowWidths has one entry per row.
91
+ rowWidths.push(rowWidth);
92
+
93
+ return { rowCount, fittingCount, rowWidths };
94
+ }