@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.
- package/dist/index.js +105 -123
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +68 -0
- 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/layout/axes.ts +21 -4
- 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,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
|
|
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,
|