@opendata-ai/openchart-engine 7.2.4 → 7.3.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.
Files changed (37) hide show
  1. package/dist/index.d.ts +84 -36
  2. package/dist/index.js +4010 -2871
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
  6. package/src/__tests__/axes.test.ts +67 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +7 -2
  10. package/src/__tests__/helpers/invariants.ts +267 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +342 -0
  13. package/src/__tests__/layout-invariants.test.ts +533 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/post-process.ts +21 -7
  26. package/src/compile/layer.ts +31 -9
  27. package/src/compile.ts +85 -148
  28. package/src/index.ts +9 -0
  29. package/src/layout/axes.ts +93 -1
  30. package/src/layout/dimensions.ts +80 -98
  31. package/src/layout/plan.ts +524 -0
  32. package/src/layout/shared.ts +44 -0
  33. package/src/legend/compute.ts +315 -78
  34. package/src/legend/wrap.ts +11 -7
  35. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  36. package/src/tables/__tests__/heatmap.test.ts +24 -20
  37. package/src/tables/heatmap.ts +25 -9
@@ -10,11 +10,19 @@
10
10
  *
11
11
  * Overflow protection: when there are too many entries for the available
12
12
  * space, entries are truncated and a "+N more" indicator is appended.
13
+ *
14
+ * The computation is split into two phases:
15
+ * - `computeLegendContent`: everything width-dependent (entry derivation,
16
+ * truncation, wrapping, row count, dimensions). No chart-area positioning.
17
+ * - `placeLegend`: pure placement (computes `bounds` from chartArea).
18
+ *
19
+ * `computeLegend` is a thin compat wrapper that calls both.
13
20
  */
14
21
 
15
22
  import type {
16
23
  LayoutStrategy,
17
24
  LegendEntry,
25
+ LegendEntryPosition,
18
26
  LegendLayout,
19
27
  Rect,
20
28
  ResolvedTheme,
@@ -23,8 +31,16 @@ import type {
23
31
  import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
24
32
 
25
33
  import type { NormalizedChartSpec } from '../compiler/types';
34
+ import type { MeasureFn } from '../layout/plan';
26
35
  import { countColorSeries, resolveSuppression } from './suppression';
27
- import { ENTRY_GAP, ENTRY_GAP_COMPACT, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from './wrap';
36
+ import {
37
+ ENTRY_GAP,
38
+ ENTRY_GAP_COMPACT,
39
+ legendGap,
40
+ measureLegendWrap,
41
+ SWATCH_GAP,
42
+ SWATCH_SIZE,
43
+ } from './wrap';
28
44
 
29
45
  // ---------------------------------------------------------------------------
30
46
  // Constants
@@ -39,6 +55,32 @@ const RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
39
55
  /** Max number of rows for top-positioned legends before truncation. */
40
56
  const TOP_LEGEND_MAX_ROWS = 2;
41
57
 
58
+ // ---------------------------------------------------------------------------
59
+ // LegendContent -- pre-computed content before placement
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /** Pre-computed legend content produced before final layout. */
63
+ export interface LegendContent {
64
+ entries: LegendEntry[];
65
+ position: 'top' | 'bottom' | 'right' | 'bottom-right' | 'inline';
66
+ labelStyle: TextStyle;
67
+ rowCount: number;
68
+ totalWidth: number;
69
+ height: number;
70
+ entryGap: number;
71
+ swatchSize: number;
72
+ swatchGap: number;
73
+ swatchChipFill: string;
74
+ /** Width of the legend box (for right legends this is the column width). */
75
+ legendWidth: number;
76
+ /** User-provided legend offset, threaded through for placement. */
77
+ offset?: { dx?: number; dy?: number };
78
+ /** Relative entry positions (origin 0,0). Offset by bounds in placeLegend. */
79
+ relativePositions?: LegendEntryPosition[];
80
+ /** Row advance used to build entry positions. */
81
+ rowHeight?: number;
82
+ }
83
+
42
84
  // ---------------------------------------------------------------------------
43
85
  // Helpers
44
86
  // ---------------------------------------------------------------------------
@@ -62,6 +104,21 @@ function categoricalDefaults(theme: ResolvedTheme): {
62
104
  };
63
105
  }
64
106
 
107
+ function resolveEntryPositions(
108
+ relative: LegendEntryPosition[] | undefined,
109
+ bounds: Rect,
110
+ ): LegendEntryPosition[] | undefined {
111
+ if (!relative) return undefined;
112
+ return relative.map((p) => ({
113
+ x: p.x + bounds.x,
114
+ y: p.y + bounds.y,
115
+ labelX: p.labelX + bounds.x,
116
+ labelY: p.labelY + bounds.y,
117
+ width: p.width,
118
+ row: p.row,
119
+ }));
120
+ }
121
+
65
122
  /** Determine the swatch shape based on mark type. */
66
123
  function swatchShapeForType(markType: string): LegendEntry['shape'] {
67
124
  switch (markType) {
@@ -147,26 +204,57 @@ function truncateEntries(entries: LegendEntry[], maxCount: number): LegendEntry[
147
204
  return truncated;
148
205
  }
149
206
 
207
+ /** Build an empty LegendContent for suppressed/hidden legends. */
208
+ function emptyContent(position: LegendContent['position'], theme: ResolvedTheme): LegendContent {
209
+ return {
210
+ entries: [],
211
+ position,
212
+ labelStyle: {
213
+ fontFamily: theme.fonts.family,
214
+ fontSize: theme.fonts.sizes.small,
215
+ fontWeight: theme.fonts.weights.normal,
216
+ fill: theme.colors.text,
217
+ lineHeight: 1.3,
218
+ },
219
+ rowCount: 0,
220
+ totalWidth: 0,
221
+ height: 0,
222
+ legendWidth: 0,
223
+ ...categoricalDefaults(theme),
224
+ };
225
+ }
226
+
150
227
  // ---------------------------------------------------------------------------
151
228
  // Public API
152
229
  // ---------------------------------------------------------------------------
153
230
 
154
231
  /**
155
- * Compute legend layout for a chart spec.
232
+ * Compute legend content (entries, dimensions, wrapping) without placement.
233
+ *
234
+ * This is the "measure" half of legend computation. It determines which
235
+ * entries to show, how they wrap/truncate, and the resulting dimensions,
236
+ * but does not compute a bounding box in chart coordinates.
156
237
  *
157
238
  * @param spec - Normalized chart spec.
158
239
  * @param strategy - Responsive layout strategy.
159
240
  * @param theme - Resolved theme.
160
- * @param chartArea - The available chart area (before legend space is reserved).
161
- * @returns LegendLayout with position, entries, and bounds.
241
+ * @param availableWidth - Width constraint for layout (e.g. chartArea.width).
242
+ * @param availableHeight - Height constraint for right-legend truncation.
243
+ * @param watermark - Whether the brand watermark is shown (reserves space for bottom legends).
244
+ * @param measure - Optional text measurement function; defaults to estimateTextWidth.
245
+ * @returns LegendContent with entries, dimensions, but no bounds.
162
246
  */
163
- export function computeLegend(
247
+ export function computeLegendContent(
164
248
  spec: NormalizedChartSpec,
165
249
  strategy: LayoutStrategy,
166
250
  theme: ResolvedTheme,
167
- chartArea: Rect,
251
+ availableWidth: number,
252
+ availableHeight: number,
168
253
  watermark: boolean = true,
169
- ): LegendLayout {
254
+ measure?: MeasureFn,
255
+ ): LegendContent {
256
+ const measureWidth = measure ?? estimateTextWidth;
257
+
170
258
  // Sparkline mode: legend hidden by default unless the user opted in. Color
171
259
  // scales still resolve normally (legend hidden != no colors), so multi-series
172
260
  // sparklines retain their categorical palette.
@@ -174,20 +262,7 @@ export function computeLegend(
174
262
 
175
263
  // Legend explicitly hidden via show: false, or height strategy says no legend
176
264
  if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
177
- return {
178
- type: 'categorical' as const,
179
- position: 'top',
180
- entries: [],
181
- bounds: { x: 0, y: 0, width: 0, height: 0 },
182
- labelStyle: {
183
- fontFamily: theme.fonts.family,
184
- fontSize: theme.fonts.sizes.small,
185
- fontWeight: theme.fonts.weights.normal,
186
- fill: theme.colors.text,
187
- lineHeight: 1.3,
188
- },
189
- ...categoricalDefaults(theme),
190
- };
265
+ return emptyContent('top', theme);
191
266
  }
192
267
 
193
268
  let entries = extractColorEntries(spec, theme);
@@ -195,7 +270,7 @@ export function computeLegend(
195
270
  // Consult the shared suppression truth table so the legend, endpoint column,
196
271
  // and end-of-line labels stay in sync. The helper returns
197
272
  // `showTraditionalLegend: false` when the endpoint column auto-takes over
198
- // for ≥2-series line/area charts (and the user hasn't forced the legend on).
273
+ // for >=2-series line/area charts (and the user hasn't forced the legend on).
199
274
  const seriesCount = countColorSeries(spec);
200
275
  const suppression = resolveSuppression(spec, {
201
276
  seriesCount,
@@ -220,20 +295,15 @@ export function computeLegend(
220
295
 
221
296
  // No entries = empty legend with no space
222
297
  if (entries.length === 0) {
223
- return {
224
- type: 'categorical' as const,
225
- position: resolvedPosition,
226
- entries: [],
227
- bounds: { x: 0, y: 0, width: 0, height: 0 },
228
- labelStyle,
229
- ...categoricalDefaults(theme),
230
- };
298
+ return emptyContent(resolvedPosition, theme);
231
299
  }
232
300
 
301
+ const offset = spec.legend?.offset;
302
+
233
303
  if (resolvedPosition === 'right' || resolvedPosition === 'bottom-right') {
234
304
  // Right-positioned legend: vertical stack
235
305
  const maxLabelWidth = Math.max(
236
- ...entries.map((e) => estimateTextWidth(e.label, labelStyle.fontSize, labelStyle.fontWeight)),
306
+ ...entries.map((e) => measureWidth(e.label, labelStyle.fontSize, labelStyle.fontWeight)),
237
307
  );
238
308
  const legendWidth = Math.min(
239
309
  LEGEND_RIGHT_WIDTH,
@@ -244,7 +314,7 @@ export function computeLegend(
244
314
  // Apply max height ratio (default 40% of chart area, strategy can override)
245
315
  const maxHeightRatio =
246
316
  strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
247
- const maxLegendHeight = chartArea.height * maxHeightRatio;
317
+ const maxLegendHeight = availableHeight * maxHeightRatio;
248
318
 
249
319
  // Calculate how many entries fit
250
320
  const maxFromSpace = Math.max(
@@ -260,34 +330,37 @@ export function computeLegend(
260
330
 
261
331
  const legendHeight =
262
332
  entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
263
- const clampedHeight = Math.min(legendHeight, chartArea.height);
264
-
265
- // bottom-right anchors to the bottom of the chart area
266
- const legendY =
267
- resolvedPosition === 'bottom-right'
268
- ? chartArea.y + chartArea.height - clampedHeight
269
- : chartArea.y;
270
-
271
- // Apply user-provided legend offset
272
- const offsetDx = spec.legend?.offset?.dx ?? 0;
273
- const offsetDy = spec.legend?.offset?.dy ?? 0;
333
+ const clampedHeight = Math.min(legendHeight, availableHeight);
334
+
335
+ const rightEntryGap = 4;
336
+ const relativePositions: LegendEntryPosition[] = [];
337
+ for (let i = 0; i < entries.length; i++) {
338
+ const ex = 0;
339
+ const ey = i * (entryHeight + rightEntryGap);
340
+ const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
341
+ relativePositions.push({
342
+ x: ex,
343
+ y: ey,
344
+ labelX: ex + SWATCH_SIZE + SWATCH_GAP,
345
+ labelY: ey + SWATCH_SIZE / 2,
346
+ width: SWATCH_SIZE + SWATCH_GAP + labelWidth + rightEntryGap,
347
+ row: i,
348
+ });
349
+ }
274
350
 
275
351
  return {
276
- type: 'categorical' as const,
277
- position: resolvedPosition,
278
352
  entries,
279
- bounds: {
280
- x: chartArea.x + chartArea.width - legendWidth + offsetDx,
281
- y: legendY + offsetDy,
282
- width: legendWidth,
283
- height: clampedHeight,
284
- },
353
+ position: resolvedPosition,
285
354
  labelStyle,
355
+ rowCount: entries.length,
356
+ totalWidth: legendWidth,
357
+ height: clampedHeight,
358
+ legendWidth,
359
+ offset,
286
360
  ...categoricalDefaults(theme),
287
- // Right-positioned legends pack rows tighter than the default entryGap
288
- // because each row is its own swatch+label and the gap controls
289
- // vertical breathing room rather than horizontal spacing.
290
- entryGap: 4,
361
+ entryGap: rightEntryGap,
362
+ relativePositions,
363
+ rowHeight: entryHeight + rightEntryGap,
291
364
  };
292
365
  }
293
366
 
@@ -296,11 +369,11 @@ export function computeLegend(
296
369
  // watermark. Top legends don't need this since the brand renders at the bottom.
297
370
  const reserveBrand = watermark && resolvedPosition === 'bottom';
298
371
  // Tighten gaps on narrow viewports so horizontal legends keep fitting on one row.
299
- const isCompact = chartArea.width < COMPACT_WIDTH;
372
+ const isCompact = availableWidth < COMPACT_WIDTH;
300
373
  const effectivePadding = isCompact ? 2 : LEGEND_PADDING;
301
374
  const effectiveEntryGap = isCompact ? ENTRY_GAP_COMPACT : ENTRY_GAP;
302
- const availableWidth =
303
- chartArea.width - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
375
+ const contentWidth =
376
+ availableWidth - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
304
377
 
305
378
  // Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
306
379
  if (spec.legend?.symbolLimit != null) {
@@ -319,10 +392,11 @@ export function computeLegend(
319
392
  : TOP_LEGEND_MAX_ROWS;
320
393
  const { fittingCount } = measureLegendWrap(
321
394
  entries,
322
- availableWidth,
395
+ contentWidth,
323
396
  labelStyle,
324
397
  maxRows,
325
398
  effectiveEntryGap,
399
+ measureWidth,
326
400
  );
327
401
 
328
402
  if (fittingCount < entries.length) {
@@ -330,43 +404,206 @@ export function computeLegend(
330
404
  }
331
405
 
332
406
  const totalWidth = entries.reduce((sum, entry) => {
333
- const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
407
+ const labelWidth = measureWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
334
408
  return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap;
335
409
  }, 0);
336
410
 
337
411
  // Calculate actual row count for height (recompute after truncation).
338
- const { rowCount } = measureLegendWrap(
412
+ const finalWrap = measureLegendWrap(
339
413
  entries,
340
- availableWidth,
414
+ contentWidth,
341
415
  labelStyle,
342
416
  undefined,
343
417
  effectiveEntryGap,
418
+ measureWidth,
344
419
  );
420
+ const { rowCount } = finalWrap;
345
421
 
346
422
  const rowHeight = SWATCH_SIZE + 4;
347
423
  const legendHeight = rowCount * rowHeight + effectivePadding * 2;
348
424
 
349
- // Apply user-provided legend offset
350
- const offsetDx = spec.legend?.offset?.dx ?? 0;
351
- const offsetDy = spec.legend?.offset?.dy ?? 0;
425
+ // Build relative positions (origin 0,0), offset by bounds in placeLegend
426
+ const relativePositions: LegendEntryPosition[] = [];
427
+ for (let i = 0; i < entries.length; i++) {
428
+ const placement = finalWrap.placements[i];
429
+ if (!placement) break;
430
+ const ex = placement.xOffset;
431
+ const ey = placement.row * rowHeight;
432
+ const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
433
+ relativePositions.push({
434
+ x: ex,
435
+ y: ey,
436
+ labelX: ex + SWATCH_SIZE + SWATCH_GAP,
437
+ labelY: ey + SWATCH_SIZE / 2,
438
+ width: SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap,
439
+ row: placement.row,
440
+ });
441
+ }
352
442
 
353
443
  return {
354
- type: 'categorical' as const,
355
- position: resolvedPosition,
356
444
  entries,
357
- bounds: {
358
- x: chartArea.x + offsetDx,
359
- y:
360
- (resolvedPosition === 'bottom'
361
- ? chartArea.y + chartArea.height - legendHeight
362
- : chartArea.y) + offsetDy,
363
- width: Math.min(totalWidth, availableWidth),
364
- height: legendHeight,
365
- },
445
+ position: resolvedPosition,
366
446
  labelStyle,
447
+ rowCount,
448
+ totalWidth,
449
+ height: legendHeight,
450
+ legendWidth: Math.min(totalWidth, contentWidth),
451
+ offset,
367
452
  ...categoricalDefaults(theme),
368
- // Top/bottom legends honor the compact-viewport-aware entry gap so
369
- // chips stay readable on narrow widths.
370
453
  entryGap: effectiveEntryGap,
454
+ relativePositions,
455
+ rowHeight,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Place a pre-computed legend into chart coordinates.
461
+ *
462
+ * This is the "layout" half of legend computation. It takes a `LegendContent`
463
+ * (from `computeLegendContent`) and produces a full `LegendLayout` with a
464
+ * positioned bounding box.
465
+ *
466
+ * @param content - Pre-computed legend content.
467
+ * @param chartArea - The final chart area (legend space already reserved in margins).
468
+ * @param containerWidth - Full container width, used for responsive legend gap.
469
+ * @param _theme - Resolved theme (reserved for future use).
470
+ * @param xAxisHeight - Height of the x-axis, used for bottom legend positioning.
471
+ * @returns Full LegendLayout with bounds.
472
+ */
473
+ export function placeLegend(
474
+ content: LegendContent,
475
+ chartArea: Rect,
476
+ containerWidth: number,
477
+ _theme: ResolvedTheme,
478
+ xAxisHeight: number,
479
+ ): LegendLayout {
480
+ const { position, entries, labelStyle, legendWidth, height, offset } = content;
481
+
482
+ // Empty legend = no bounds
483
+ if (entries.length === 0) {
484
+ return {
485
+ type: 'categorical' as const,
486
+ position,
487
+ entries: [],
488
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
489
+ labelStyle,
490
+ swatchSize: content.swatchSize,
491
+ swatchGap: content.swatchGap,
492
+ entryGap: content.entryGap,
493
+ swatchChipFill: content.swatchChipFill,
494
+ };
495
+ }
496
+
497
+ const offsetDx = offset?.dx ?? 0;
498
+ const offsetDy = offset?.dy ?? 0;
499
+ const gap = legendGap(containerWidth);
500
+
501
+ let boundsX: number;
502
+ let boundsY: number;
503
+
504
+ if (position === 'right' || position === 'bottom-right') {
505
+ boundsX = chartArea.x + chartArea.width + 8 + offsetDx;
506
+ boundsY =
507
+ (position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
508
+ offsetDy;
509
+ } else if (position === 'bottom') {
510
+ boundsX = chartArea.x + offsetDx;
511
+ boundsY = chartArea.y + chartArea.height + xAxisHeight + gap + offsetDy;
512
+ } else {
513
+ // top
514
+ boundsX = chartArea.x + offsetDx;
515
+ boundsY = chartArea.y - gap - height + offsetDy;
516
+ }
517
+
518
+ const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
519
+
520
+ const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
521
+
522
+ return {
523
+ type: 'categorical' as const,
524
+ position,
525
+ entries,
526
+ bounds,
527
+ labelStyle,
528
+ swatchSize: content.swatchSize,
529
+ swatchGap: content.swatchGap,
530
+ entryGap: content.entryGap,
531
+ swatchChipFill: content.swatchChipFill,
532
+ entryPositions,
533
+ rowHeight: content.rowHeight,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * Compute legend layout for a chart spec.
539
+ *
540
+ * Thin wrapper that calls `computeLegendContent` then `placeLegend`.
541
+ * Existing callers are untouched.
542
+ *
543
+ * @param spec - Normalized chart spec.
544
+ * @param strategy - Responsive layout strategy.
545
+ * @param theme - Resolved theme.
546
+ * @param chartArea - The available chart area (before legend space is reserved).
547
+ * @returns LegendLayout with position, entries, and bounds.
548
+ */
549
+ export function computeLegend(
550
+ spec: NormalizedChartSpec,
551
+ strategy: LayoutStrategy,
552
+ theme: ResolvedTheme,
553
+ chartArea: Rect,
554
+ watermark: boolean = true,
555
+ ): LegendLayout {
556
+ const content = computeLegendContent(
557
+ spec,
558
+ strategy,
559
+ theme,
560
+ chartArea.width,
561
+ chartArea.height,
562
+ watermark,
563
+ );
564
+ const { position, entries, labelStyle, legendWidth, height, offset: off } = content;
565
+ const offsetDx = off?.dx ?? 0;
566
+ const offsetDy = off?.dy ?? 0;
567
+ let boundsX = chartArea.x + offsetDx;
568
+ let boundsY = chartArea.y + offsetDy;
569
+ const boundsW = legendWidth;
570
+ if (entries.length === 0) {
571
+ return {
572
+ type: 'categorical' as const,
573
+ position,
574
+ entries: [],
575
+ bounds: { x: 0, y: 0, width: 0, height: 0 },
576
+ labelStyle,
577
+ swatchSize: content.swatchSize,
578
+ swatchGap: content.swatchGap,
579
+ entryGap: content.entryGap,
580
+ swatchChipFill: content.swatchChipFill,
581
+ };
582
+ }
583
+ if (position === 'right' || position === 'bottom-right') {
584
+ boundsX = chartArea.x + chartArea.width - legendWidth + offsetDx;
585
+ boundsY =
586
+ (position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
587
+ offsetDy;
588
+ } else if (position === 'bottom') {
589
+ boundsY = chartArea.y + chartArea.height - height + offsetDy;
590
+ }
591
+
592
+ const bounds = { x: boundsX, y: boundsY, width: boundsW, height };
593
+
594
+ const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
595
+
596
+ return {
597
+ type: 'categorical' as const,
598
+ position,
599
+ entries,
600
+ bounds,
601
+ labelStyle,
602
+ swatchSize: content.swatchSize,
603
+ swatchGap: content.swatchGap,
604
+ entryGap: content.entryGap,
605
+ swatchChipFill: content.swatchChipFill,
606
+ entryPositions,
607
+ rowHeight: content.rowHeight,
371
608
  };
372
609
  }
@@ -50,6 +50,8 @@ export interface LegendWrapResult {
50
50
  fittingCount: number;
51
51
  /** Width (in px) of each row — callers can use for alignment. */
52
52
  rowWidths: number[];
53
+ /** Per-entry placement: wrap row and x offset within that row. */
54
+ placements: Array<{ row: number; xOffset: number }>;
53
55
  }
54
56
 
55
57
  /**
@@ -66,34 +68,36 @@ export function measureLegendWrap(
66
68
  labelStyle: TextStyle,
67
69
  maxRows?: number,
68
70
  entryGap: number = ENTRY_GAP,
71
+ measure?: (text: string, fontSize: number, fontWeight: number) => number,
69
72
  ): LegendWrapResult {
70
73
  if (entries.length === 0) {
71
- return { rowCount: 0, fittingCount: 0, rowWidths: [] };
74
+ return { rowCount: 0, fittingCount: 0, rowWidths: [], placements: [] };
72
75
  }
73
76
 
77
+ const measureWidth = measure ?? estimateTextWidth;
78
+
74
79
  let rowCount = 1;
75
80
  let rowWidth = 0;
76
81
  const rowWidths: number[] = [];
82
+ const placements: Array<{ row: number; xOffset: number }> = [];
77
83
  let fittingCount = entries.length;
78
84
  let fittingCountLocked = false;
79
85
 
80
86
  for (let i = 0; i < entries.length; i++) {
81
- const labelWidth = estimateTextWidth(
82
- entries[i].label,
83
- labelStyle.fontSize,
84
- labelStyle.fontWeight,
85
- );
87
+ const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
86
88
  const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + entryGap;
87
89
 
88
90
  if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
89
91
  rowWidths.push(rowWidth);
90
92
  rowCount++;
93
+ placements.push({ row: rowCount - 1, xOffset: 0 });
91
94
  rowWidth = entryWidth;
92
95
  if (!fittingCountLocked && maxRows != null && rowCount > maxRows) {
93
96
  fittingCount = i;
94
97
  fittingCountLocked = true;
95
98
  }
96
99
  } else {
100
+ placements.push({ row: rowCount - 1, xOffset: rowWidth });
97
101
  rowWidth += entryWidth;
98
102
  }
99
103
  }
@@ -101,5 +105,5 @@ export function measureLegendWrap(
101
105
  // Flush the final row width so rowWidths has one entry per row.
102
106
  rowWidths.push(rowWidth);
103
107
 
104
- return { rowCount, fittingCount, rowWidths };
108
+ return { rowCount, fittingCount, rowWidths, placements };
105
109
  }
@@ -37,9 +37,9 @@ describe('sankey node-label wrapping', () => {
37
37
  },
38
38
  };
39
39
 
40
- // 360px wide total is cramped for the labels above across 3 columns,
40
+ // 300px wide total is cramped for the labels above across 3 columns,
41
41
  // forcing per-label maxWidth to be well below the longest text width.
42
- const layout = compileSankey(spec, { width: 360, height: 300 });
42
+ const layout = compileSankey(spec, { width: 300, height: 300 });
43
43
 
44
44
  // Every node should have a finite maxWidth set (a proxy for "engine
45
45
  // participated in wrap geometry") when overflow-compression kicked in.