@opendata-ai/openchart-engine 6.7.1 → 6.9.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.d.ts +3 -0
- package/dist/index.js +184 -35
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +30 -0
- package/src/annotations/__tests__/compute.test.ts +93 -0
- package/src/annotations/compute.ts +66 -13
- package/src/charts/bar/__tests__/compute.test.ts +67 -0
- package/src/charts/bar/compute.ts +69 -2
- package/src/compiler/normalize.ts +3 -0
- package/src/legend/compute.ts +6 -4
- package/src/sankey/__tests__/compile-sankey.test.ts +113 -8
- package/src/sankey/compile-sankey.ts +96 -21
- package/src/sankey/types.ts +3 -0
- package/src/tables/__tests__/format-cells.test.ts +17 -0
- package/src/tables/format-cells.ts +6 -10
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
} from '@opendata-ai/openchart-core';
|
|
29
29
|
import {
|
|
30
30
|
adaptTheme,
|
|
31
|
+
buildD3Formatter,
|
|
31
32
|
computeChrome,
|
|
32
33
|
estimateTextWidth,
|
|
33
34
|
formatNumber,
|
|
@@ -47,7 +48,8 @@ const SWATCH_SIZE = 12;
|
|
|
47
48
|
const SWATCH_GAP = 6;
|
|
48
49
|
const ENTRY_GAP = 16;
|
|
49
50
|
const LABEL_GAP = 6;
|
|
50
|
-
const
|
|
51
|
+
const LINK_OPACITY_LIGHT = 0.5;
|
|
52
|
+
const LINK_OPACITY_DARK = 0.75;
|
|
51
53
|
const NODE_CORNER_RADIUS = 2;
|
|
52
54
|
|
|
53
55
|
// ---------------------------------------------------------------------------
|
|
@@ -129,18 +131,28 @@ function getLinkColors(
|
|
|
129
131
|
|
|
130
132
|
/**
|
|
131
133
|
* Determine label position for a node based on its column depth.
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* Middle columns: label to the right (default).
|
|
134
|
+
* Default ('auto'): leftmost/middle columns label right, rightmost column labels left.
|
|
135
|
+
* 'right': all labels to the right. 'left': all labels to the left.
|
|
135
136
|
*/
|
|
136
137
|
function computeNodeLabel(
|
|
137
138
|
node: ComputedNode,
|
|
138
139
|
maxDepth: number,
|
|
139
140
|
theme: ResolvedTheme,
|
|
140
141
|
nodeWidth: number,
|
|
142
|
+
nodeLabelAlign: 'auto' | 'left' | 'right' = 'auto',
|
|
141
143
|
): SankeyNodeMark['label'] {
|
|
142
144
|
const depth = node.depth ?? 0;
|
|
143
|
-
|
|
145
|
+
|
|
146
|
+
// Determine which side to place the label
|
|
147
|
+
let placeLeft: boolean;
|
|
148
|
+
if (nodeLabelAlign === 'left') {
|
|
149
|
+
placeLeft = true;
|
|
150
|
+
} else if (nodeLabelAlign === 'right') {
|
|
151
|
+
placeLeft = false;
|
|
152
|
+
} else {
|
|
153
|
+
// 'auto': rightmost column goes left, everything else goes right
|
|
154
|
+
placeLeft = depth === maxDepth;
|
|
155
|
+
}
|
|
144
156
|
|
|
145
157
|
const style: TextStyle = {
|
|
146
158
|
fontFamily: theme.fonts.family,
|
|
@@ -156,8 +168,7 @@ function computeNodeLabel(
|
|
|
156
168
|
const y1 = node.y1 ?? 0;
|
|
157
169
|
const midY = (y0 + y1) / 2;
|
|
158
170
|
|
|
159
|
-
if (
|
|
160
|
-
// Label to the left of the node
|
|
171
|
+
if (placeLeft) {
|
|
161
172
|
return {
|
|
162
173
|
text: node.label ?? node.id,
|
|
163
174
|
x: x0 - LABEL_GAP,
|
|
@@ -167,7 +178,6 @@ function computeNodeLabel(
|
|
|
167
178
|
};
|
|
168
179
|
}
|
|
169
180
|
|
|
170
|
-
// Label to the right of the node (leftmost and middle columns)
|
|
171
181
|
return {
|
|
172
182
|
text: node.label ?? node.id,
|
|
173
183
|
x: x1 + LABEL_GAP,
|
|
@@ -205,9 +215,18 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
205
215
|
const mergedThemeConfig = options.theme
|
|
206
216
|
? { ...sankeySpec.theme, ...options.theme }
|
|
207
217
|
: sankeySpec.theme;
|
|
208
|
-
|
|
218
|
+
const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
219
|
+
let theme: ResolvedTheme = lightTheme;
|
|
209
220
|
if (options.darkMode) {
|
|
210
221
|
theme = adaptTheme(theme);
|
|
222
|
+
// Sankey nodes and link gradients need vivid colors that stand out on dark
|
|
223
|
+
// backgrounds. The adapted palette preserves contrast ratios designed for
|
|
224
|
+
// text, but those contrast-matched colors are too dark for filled shapes.
|
|
225
|
+
// Use the original light-theme categorical palette for node/link colors.
|
|
226
|
+
theme = {
|
|
227
|
+
...theme,
|
|
228
|
+
colors: { ...theme.colors, categorical: lightTheme.colors.categorical },
|
|
229
|
+
};
|
|
211
230
|
}
|
|
212
231
|
|
|
213
232
|
// 3. Compute chrome
|
|
@@ -282,19 +301,64 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
282
301
|
return emptyLayout(area, chrome, theme, options);
|
|
283
302
|
}
|
|
284
303
|
|
|
285
|
-
// 6. Run d3-sankey layout
|
|
286
|
-
const
|
|
304
|
+
// 6. Run d3-sankey layout (may re-run once if labels overflow)
|
|
305
|
+
const labelFontSize = theme.fonts.sizes.small;
|
|
306
|
+
const labelFontWeight = theme.fonts.weights.normal;
|
|
307
|
+
const nodeWidth = sankeySpec.nodeWidth ?? 12;
|
|
308
|
+
|
|
309
|
+
let layoutArea: Rect = { ...area };
|
|
310
|
+
let { nodes, links } = computeSankeyLayout(
|
|
287
311
|
sankeySpec.data,
|
|
288
312
|
sourceField,
|
|
289
313
|
targetField,
|
|
290
314
|
valueField,
|
|
291
|
-
|
|
315
|
+
layoutArea,
|
|
292
316
|
sankeySpec.nodeWidth,
|
|
293
317
|
sankeySpec.nodePadding,
|
|
294
318
|
sankeySpec.nodeAlign,
|
|
295
319
|
sankeySpec.iterations,
|
|
296
320
|
);
|
|
297
321
|
|
|
322
|
+
// 6b. Check if any right-side node labels overflow the right edge.
|
|
323
|
+
const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? 'auto';
|
|
324
|
+
const maxDepthFirst = nodes.reduce((max, n) => Math.max(max, n.depth ?? 0), 0);
|
|
325
|
+
const rightEdge = area.x + area.width;
|
|
326
|
+
let maxOverflow = 0;
|
|
327
|
+
for (const node of nodes) {
|
|
328
|
+
const depth = node.depth ?? 0;
|
|
329
|
+
// Skip nodes whose labels go left (they can't overflow the right edge)
|
|
330
|
+
const labelsLeft =
|
|
331
|
+
nodeLabelAlign === 'left' || (nodeLabelAlign === 'auto' && depth === maxDepthFirst);
|
|
332
|
+
if (labelsLeft) continue;
|
|
333
|
+
const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
|
|
334
|
+
const labelText = node.label ?? node.id;
|
|
335
|
+
const labelWidth = estimateTextWidth(labelText, labelFontSize, labelFontWeight);
|
|
336
|
+
const overflow = labelX + labelWidth - rightEdge;
|
|
337
|
+
if (overflow > maxOverflow) maxOverflow = overflow;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Re-run layout with tighter width if labels would clip
|
|
341
|
+
if (maxOverflow > 0) {
|
|
342
|
+
const margin = Math.ceil(maxOverflow) + 4; // small extra buffer
|
|
343
|
+
layoutArea = {
|
|
344
|
+
x: area.x,
|
|
345
|
+
y: area.y,
|
|
346
|
+
width: Math.max(area.width - margin, 40),
|
|
347
|
+
height: area.height,
|
|
348
|
+
};
|
|
349
|
+
({ nodes, links } = computeSankeyLayout(
|
|
350
|
+
sankeySpec.data,
|
|
351
|
+
sourceField,
|
|
352
|
+
targetField,
|
|
353
|
+
valueField,
|
|
354
|
+
layoutArea,
|
|
355
|
+
sankeySpec.nodeWidth,
|
|
356
|
+
sankeySpec.nodePadding,
|
|
357
|
+
sankeySpec.nodeAlign,
|
|
358
|
+
sankeySpec.iterations,
|
|
359
|
+
));
|
|
360
|
+
}
|
|
361
|
+
|
|
298
362
|
// 7. Build node color map
|
|
299
363
|
const nodeColorMap = buildNodeColorMap(
|
|
300
364
|
nodes,
|
|
@@ -321,14 +385,14 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
321
385
|
height: (node.y1 ?? 0) - (node.y0 ?? 0),
|
|
322
386
|
fill,
|
|
323
387
|
cornerRadius: NODE_CORNER_RADIUS,
|
|
324
|
-
label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth),
|
|
388
|
+
label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth, nodeLabelAlign),
|
|
325
389
|
nodeId: node.id,
|
|
326
390
|
value: node.value ?? 0,
|
|
327
391
|
depth,
|
|
328
392
|
data: { id: node.id, label: node.label },
|
|
329
393
|
aria: {
|
|
330
394
|
role: 'img',
|
|
331
|
-
label: `${node.label}: ${
|
|
395
|
+
label: `${node.label}: ${formatFlowValue(node.value ?? 0, sankeySpec.valueFormat)}`,
|
|
332
396
|
},
|
|
333
397
|
animationIndex: 0, // Reassigned below after sorting by depth
|
|
334
398
|
};
|
|
@@ -354,7 +418,8 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
354
418
|
path: generateLinkPath(link),
|
|
355
419
|
sourceColor: colors.sourceColor,
|
|
356
420
|
targetColor: colors.targetColor,
|
|
357
|
-
fillOpacity:
|
|
421
|
+
fillOpacity:
|
|
422
|
+
sankeySpec.linkOpacity ?? (options.darkMode ? LINK_OPACITY_DARK : LINK_OPACITY_LIGHT),
|
|
358
423
|
sourceId: sourceNode.id,
|
|
359
424
|
targetId: targetNode.id,
|
|
360
425
|
width: link.width ?? 0,
|
|
@@ -362,7 +427,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
362
427
|
data: (link as unknown as { data: Record<string, unknown> }).data ?? {},
|
|
363
428
|
aria: {
|
|
364
429
|
role: 'img',
|
|
365
|
-
label: `${sourceNode.label} to ${targetNode.label}: ${
|
|
430
|
+
label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, sankeySpec.valueFormat)}`,
|
|
366
431
|
},
|
|
367
432
|
// Links animate after nodes
|
|
368
433
|
animationIndex: nodeMarks.length + i,
|
|
@@ -381,7 +446,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
381
446
|
);
|
|
382
447
|
|
|
383
448
|
// 13. Build tooltip descriptors
|
|
384
|
-
const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks);
|
|
449
|
+
const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, sankeySpec.valueFormat);
|
|
385
450
|
|
|
386
451
|
// 14. Build a11y metadata
|
|
387
452
|
const a11y = {
|
|
@@ -510,9 +575,18 @@ function buildSankeyLegend(
|
|
|
510
575
|
// Tooltip builder
|
|
511
576
|
// ---------------------------------------------------------------------------
|
|
512
577
|
|
|
578
|
+
function formatFlowValue(value: number, valueFormat?: string): string {
|
|
579
|
+
if (valueFormat) {
|
|
580
|
+
const fmt = buildD3Formatter(valueFormat);
|
|
581
|
+
if (fmt) return fmt(value);
|
|
582
|
+
}
|
|
583
|
+
return formatNumber(value);
|
|
584
|
+
}
|
|
585
|
+
|
|
513
586
|
function buildTooltipDescriptors(
|
|
514
587
|
nodes: SankeyNodeMark[],
|
|
515
588
|
links: SankeyLinkMark[],
|
|
589
|
+
valueFormat?: string,
|
|
516
590
|
): Map<string, TooltipContent> {
|
|
517
591
|
const descriptors = new Map<string, TooltipContent>();
|
|
518
592
|
|
|
@@ -521,7 +595,7 @@ function buildTooltipDescriptors(
|
|
|
521
595
|
const fields: TooltipField[] = [
|
|
522
596
|
{
|
|
523
597
|
label: 'Total flow',
|
|
524
|
-
value:
|
|
598
|
+
value: formatFlowValue(node.value, valueFormat),
|
|
525
599
|
},
|
|
526
600
|
];
|
|
527
601
|
descriptors.set(`node-${node.nodeId}`, {
|
|
@@ -531,14 +605,15 @@ function buildTooltipDescriptors(
|
|
|
531
605
|
}
|
|
532
606
|
|
|
533
607
|
// Link tooltips: keyed by "link-{sourceId}-{targetId}" to match renderer data-mark-id
|
|
534
|
-
for (
|
|
608
|
+
for (let i = 0; i < links.length; i++) {
|
|
609
|
+
const link = links[i];
|
|
535
610
|
const fields: TooltipField[] = [
|
|
536
611
|
{
|
|
537
612
|
label: 'Flow',
|
|
538
|
-
value:
|
|
613
|
+
value: formatFlowValue(link.value, valueFormat),
|
|
539
614
|
},
|
|
540
615
|
];
|
|
541
|
-
descriptors.set(`link-${link.sourceId}-${link.targetId}`, {
|
|
616
|
+
descriptors.set(`link-${link.sourceId}-${link.targetId}-${i}`, {
|
|
542
617
|
title: `${link.sourceId} \u2192 ${link.targetId}`,
|
|
543
618
|
fields,
|
|
544
619
|
});
|
package/src/sankey/types.ts
CHANGED
|
@@ -28,9 +28,12 @@ export interface NormalizedSankeySpec {
|
|
|
28
28
|
nodeAlign: SankeyNodeAlign;
|
|
29
29
|
iterations: number;
|
|
30
30
|
linkStyle: SankeyLinkColor;
|
|
31
|
+
nodeLabelAlign: 'auto' | 'left' | 'right';
|
|
31
32
|
chrome: NormalizedChrome;
|
|
32
33
|
legend?: LegendConfig;
|
|
33
34
|
theme: ThemeConfig;
|
|
34
35
|
darkMode: DarkMode;
|
|
35
36
|
animation?: AnimationSpec;
|
|
37
|
+
valueFormat?: string;
|
|
38
|
+
linkOpacity?: number;
|
|
36
39
|
}
|
|
@@ -106,6 +106,18 @@ describe('formatCell', () => {
|
|
|
106
106
|
const result = formatCell('not a number', col);
|
|
107
107
|
expect(result.formattedValue).toBe('not a number');
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
it('supports literal suffix format .0f%', () => {
|
|
111
|
+
const col: ColumnConfig = { key: 'x', format: '.0f%' };
|
|
112
|
+
const result = formatCell(32.5, col);
|
|
113
|
+
expect(result.formattedValue).toBe('33%');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('supports literal suffix format $,.2~fT', () => {
|
|
117
|
+
const col: ColumnConfig = { key: 'x', format: '$,.2~fT' };
|
|
118
|
+
const result = formatCell(3.75, col);
|
|
119
|
+
expect(result.formattedValue).toBe('$3.75T');
|
|
120
|
+
});
|
|
109
121
|
});
|
|
110
122
|
|
|
111
123
|
describe('formatValueForSearch', () => {
|
|
@@ -123,4 +135,9 @@ describe('formatValueForSearch', () => {
|
|
|
123
135
|
const col: ColumnConfig = { key: 'x' };
|
|
124
136
|
expect(formatValueForSearch('hello', col)).toBe('hello');
|
|
125
137
|
});
|
|
138
|
+
|
|
139
|
+
it('supports literal suffix format in search', () => {
|
|
140
|
+
const col: ColumnConfig = { key: 'x', format: '.0f%' };
|
|
141
|
+
expect(formatValueForSearch(32.5, col)).toBe('33%');
|
|
142
|
+
});
|
|
126
143
|
});
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CellStyle, ColumnConfig, TableCellBase } from '@opendata-ai/openchart-core';
|
|
10
|
-
import { formatDate, formatNumber } from '@opendata-ai/openchart-core';
|
|
11
|
-
import { format as d3Format } from 'd3-format';
|
|
10
|
+
import { buildD3Formatter, formatDate, formatNumber } from '@opendata-ai/openchart-core';
|
|
12
11
|
|
|
13
12
|
/**
|
|
14
13
|
* Check if a value is numeric (finite number or parseable numeric string).
|
|
@@ -49,15 +48,13 @@ export function formatCell(value: unknown, column: ColumnConfig): TableCellBase
|
|
|
49
48
|
|
|
50
49
|
// If column has a d3-format string and value is numeric
|
|
51
50
|
if (column.format && isNumericValue(value)) {
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
const formatter = buildD3Formatter(column.format);
|
|
52
|
+
if (formatter) {
|
|
54
53
|
return {
|
|
55
54
|
value,
|
|
56
55
|
formattedValue: formatter(value),
|
|
57
56
|
style,
|
|
58
57
|
};
|
|
59
|
-
} catch {
|
|
60
|
-
// Fall through to auto-format if format string is invalid
|
|
61
58
|
}
|
|
62
59
|
}
|
|
63
60
|
|
|
@@ -95,10 +92,9 @@ export function formatValueForSearch(value: unknown, column: ColumnConfig): stri
|
|
|
95
92
|
if (value == null) return '';
|
|
96
93
|
|
|
97
94
|
if (column.format && isNumericValue(value)) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
// Fall through
|
|
95
|
+
const formatter = buildD3Formatter(column.format);
|
|
96
|
+
if (formatter) {
|
|
97
|
+
return formatter(value);
|
|
102
98
|
}
|
|
103
99
|
}
|
|
104
100
|
|