@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/dist/index.js +90 -118
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +39 -0
- package/src/charts/_shared/format-label-value.ts +15 -0
- package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
- package/src/charts/bar/compute.ts +6 -11
- package/src/charts/bar/labels.ts +2 -9
- package/src/charts/column/compute.ts +6 -11
- package/src/charts/column/labels.ts +2 -13
- package/src/charts/dot/labels.ts +2 -13
- package/src/legend/compute.ts +6 -51
- package/src/legend/wrap.ts +94 -0
- package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
- package/src/sankey/__tests__/node-sort.test.ts +45 -0
- 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
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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,
|