@opendata-ai/openchart-engine 6.19.2 → 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.
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Characterization test for sankey node-label wrapping at narrow widths.
3
+ *
4
+ * Part of refactor/v7-cohesion step 1. The sankey engine computes a label's
5
+ * `maxWidth` based on available horizontal space from the label anchor to the
6
+ * container edge (see computeNodeLabel in compile-sankey.ts around line 137).
7
+ * When the container is narrow and node labels are long, the computed
8
+ * `maxWidth` falls below the estimated text width — the adapter then wraps the
9
+ * label into multiple lines via its own wrapText routine.
10
+ *
11
+ * Step 3 of the v7 plan extracts `wrapText` to core while keeping the sankey
12
+ * path heuristic-only. This test guards the engine's responsibility: produce a
13
+ * label `maxWidth` that forces wrapping on long-label narrow-width sankeys.
14
+ */
15
+
16
+ import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
17
+ import { describe, expect, it } from 'vitest';
18
+ import { compileSankey } from '../../compile';
19
+
20
+ describe('sankey node-label wrapping', () => {
21
+ it('produces at least one node whose label maxWidth forces wrapping at narrow widths', () => {
22
+ const spec = {
23
+ type: 'sankey' as const,
24
+ data: [
25
+ {
26
+ from: 'Renewable Energy Sources International',
27
+ to: 'Grid Transmission Network',
28
+ amount: 100,
29
+ },
30
+ { from: 'Grid Transmission Network', to: 'Residential Consumption Households', amount: 60 },
31
+ { from: 'Grid Transmission Network', to: 'Commercial Industrial Facilities', amount: 40 },
32
+ ],
33
+ encoding: {
34
+ source: { field: 'from', type: 'nominal' as const },
35
+ target: { field: 'to', type: 'nominal' as const },
36
+ value: { field: 'amount', type: 'quantitative' as const },
37
+ },
38
+ };
39
+
40
+ // 360px wide total is cramped for the labels above across 3 columns,
41
+ // forcing per-label maxWidth to be well below the longest text width.
42
+ const layout = compileSankey(spec, { width: 360, height: 300 });
43
+
44
+ // Every node should have a finite maxWidth set (a proxy for "engine
45
+ // participated in wrap geometry") when overflow-compression kicked in.
46
+ const withMaxWidth = layout.nodes.filter(
47
+ (n) => typeof n.label.maxWidth === 'number' && (n.label.maxWidth as number) >= 0,
48
+ );
49
+ expect(withMaxWidth.length).toBe(layout.nodes.length);
50
+
51
+ // At least one node label requires wrapping: its natural text width
52
+ // exceeds the engine-assigned maxWidth at the same font size/weight.
53
+ const wrapsRequired = withMaxWidth.filter((n) => {
54
+ const tw = estimateTextWidth(
55
+ n.label.text,
56
+ n.label.style.fontSize,
57
+ n.label.style.fontWeight ?? 400,
58
+ );
59
+ return tw > (n.label.maxWidth as number);
60
+ });
61
+ expect(wrapsRequired.length).toBeGreaterThanOrEqual(1);
62
+ });
63
+
64
+ it('does not force wrapping at generous widths for the same long-label spec', () => {
65
+ // Control: the same spec at a comfortable width does not require wrapping.
66
+ const spec = {
67
+ type: 'sankey' as const,
68
+ data: [
69
+ {
70
+ from: 'Renewable Energy Sources International',
71
+ to: 'Grid Transmission Network',
72
+ amount: 100,
73
+ },
74
+ { from: 'Grid Transmission Network', to: 'Residential Consumption Households', amount: 60 },
75
+ ],
76
+ encoding: {
77
+ source: { field: 'from', type: 'nominal' as const },
78
+ target: { field: 'to', type: 'nominal' as const },
79
+ value: { field: 'amount', type: 'quantitative' as const },
80
+ },
81
+ };
82
+
83
+ const layout = compileSankey(spec, { width: 1600, height: 300 });
84
+
85
+ const wrapsRequired = layout.nodes.filter((n) => {
86
+ if (typeof n.label.maxWidth !== 'number') return false;
87
+ const tw = estimateTextWidth(
88
+ n.label.text,
89
+ n.label.style.fontSize,
90
+ n.label.style.fontWeight ?? 400,
91
+ );
92
+ return tw > (n.label.maxWidth as number);
93
+ });
94
+ expect(wrapsRequired.length).toBe(0);
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Pinned behavior (refactor/v7-cohesion code review item 1):
99
+ // The shared wrapText in core/src/layout/text-wrap.ts splits on `\n` before
100
+ // word-wrapping. The previous sankey-local wrapText did not. Pin the new
101
+ // behavior so anyone relying on `\n` in node labels sees the multi-line break.
102
+ // ---------------------------------------------------------------------------
103
+ it('honors explicit `\\n` in node labels by producing multi-line wrap output', () => {
104
+ const lines = wrapText('First line\nSecond line', 12, 400, 1000);
105
+
106
+ expect(lines).toEqual(['First line', 'Second line']);
107
+ });
108
+
109
+ it('preserves blank lines between consecutive `\\n` characters', () => {
110
+ const lines = wrapText('A\n\nB', 12, 400, 1000);
111
+
112
+ expect(lines).toEqual(['A', '', 'B']);
113
+ });
114
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Characterization test for sankey explicit `nodeSort`.
3
+ *
4
+ * Part of refactor/v7-cohesion step 1. Pins the `nodeSort` handling in
5
+ * `packages/engine/src/sankey/layout.ts` around line 120, where an explicit
6
+ * ordered-ID array is translated into a d3-sankey `nodeSort` comparator so
7
+ * nodes stack top-to-bottom within each column following the user's order
8
+ * rather than d3-sankey's default (data-insertion / value-based) ordering.
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+ import { computeSankeyLayout } from '../layout';
13
+
14
+ describe('sankey explicit nodeSort', () => {
15
+ it('orders nodes within a column according to the nodeSort array', () => {
16
+ // A, B, C all live in column 0 and flow into D in column 1.
17
+ // Data-insertion order is A, B, C (the default d3-sankey order varies
18
+ // with value anyway). Explicit nodeSort forces C, A, B top-to-bottom.
19
+ const data = [
20
+ { source: 'A', target: 'D', value: 10 },
21
+ { source: 'B', target: 'D', value: 20 },
22
+ { source: 'C', target: 'D', value: 15 },
23
+ ];
24
+
25
+ const { nodes } = computeSankeyLayout(
26
+ data,
27
+ 'source',
28
+ 'target',
29
+ 'value',
30
+ { x: 0, y: 0, width: 600, height: 400 },
31
+ 12,
32
+ 16,
33
+ 'justify',
34
+ 6,
35
+ ['C', 'A', 'B'],
36
+ );
37
+
38
+ // Collect column-0 nodes, sort them by vertical position, and read back IDs.
39
+ const col0 = nodes.filter((n) => n.depth === 0);
40
+ expect(col0.map((n) => n.id).sort()).toEqual(['A', 'B', 'C']);
41
+
42
+ const ordered = [...col0].sort((a, b) => (a.y0 ?? 0) - (b.y0 ?? 0)).map((n) => n.id);
43
+ expect(ordered).toEqual(['C', 'A', 'B']);
44
+ });
45
+ });
@@ -37,6 +37,7 @@ import {
37
37
 
38
38
  import { resolveAnimation } from '../compiler/animation';
39
39
  import { compile as compileSpec } from '../compiler/index';
40
+ import { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from '../legend/wrap';
40
41
  import { type ComputedNode, computeSankeyLayout, generateLinkPath } from './layout';
41
42
  import type { NormalizedSankeySpec } from './types';
42
43
 
@@ -44,9 +45,6 @@ import type { NormalizedSankeySpec } from './types';
44
45
  // Constants
45
46
  // ---------------------------------------------------------------------------
46
47
 
47
- const SWATCH_SIZE = 12;
48
- const SWATCH_GAP = 6;
49
- const ENTRY_GAP = 16;
50
48
  const LABEL_GAP = 6;
51
49
  const LINK_OPACITY_LIGHT = 0.5;
52
50
  const LINK_OPACITY_DARK = 0.75;
@@ -570,23 +568,10 @@ function buildSankeyLegend(
570
568
  const ROW_HEIGHT = SWATCH_SIZE + 4;
571
569
  const availableWidth = area.width;
572
570
 
573
- // Compute row count by simulating horizontal wrapping
574
- let rowCount = 1;
575
- let rowX = 0;
576
- for (const entry of entries) {
577
- const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
578
- const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
579
- if (rowX > 0 && rowX + entryWidth > availableWidth) {
580
- rowCount++;
581
- rowX = entryWidth;
582
- } else {
583
- rowX += entryWidth;
584
- }
585
- }
586
-
587
- // Cap at 2 rows max
588
- rowCount = Math.min(rowCount, 2);
589
- const legendHeight = rowCount * ROW_HEIGHT;
571
+ // Compute row count via shared wrap geometry, then cap at 2 rows.
572
+ const { rowCount } = measureLegendWrap(entries, availableWidth, labelStyle);
573
+ const cappedRowCount = Math.min(rowCount, 2);
574
+ const legendHeight = cappedRowCount * ROW_HEIGHT;
590
575
 
591
576
  bounds = {
592
577
  x: area.x,