@opendata-ai/openchart-engine 6.19.3 → 6.21.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 (36) hide show
  1. package/dist/index.d.ts +6 -0
  2. package/dist/index.js +865 -3729
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +1989 -0
  6. package/src/__tests__/axes.test.ts +65 -0
  7. package/src/__tests__/compile-snapshot.test.ts +156 -0
  8. package/src/__tests__/legend.test.ts +39 -0
  9. package/src/charts/__tests__/registry.test.ts +6 -0
  10. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  11. package/src/charts/_shared/density-filter.ts +26 -0
  12. package/src/charts/_shared/format-label-value.ts +15 -0
  13. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  14. package/src/charts/bar/compute.ts +6 -11
  15. package/src/charts/bar/labels.ts +4 -15
  16. package/src/charts/builtin.ts +64 -0
  17. package/src/charts/column/compute.ts +6 -11
  18. package/src/charts/column/labels.ts +4 -19
  19. package/src/charts/dot/labels.ts +4 -19
  20. package/src/charts/pie/labels.ts +4 -6
  21. package/src/charts/registry.ts +6 -0
  22. package/src/compile/__tests__/color-scale-range.test.ts +79 -0
  23. package/src/compile/__tests__/data-clip.test.ts +59 -0
  24. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  25. package/src/compile/color-scale-range.ts +38 -0
  26. package/src/compile/data-clip.ts +33 -0
  27. package/src/compile/watermark-obstacle.ts +54 -0
  28. package/src/compile.ts +20 -97
  29. package/src/layout/axes/thinning.ts +96 -0
  30. package/src/layout/axes/ticks.ts +266 -0
  31. package/src/layout/axes.ts +148 -249
  32. package/src/legend/compute.ts +6 -51
  33. package/src/legend/wrap.ts +94 -0
  34. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  35. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  36. package/src/sankey/compile-sankey.ts +5 -20
@@ -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,