@opendata-ai/openchart-engine 6.7.0 → 6.8.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 +2 -0
- package/dist/index.js +170 -31
- 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 +2 -0
- package/src/legend/compute.ts +6 -4
- package/src/sankey/__tests__/compile-sankey.test.ts +113 -8
- package/src/sankey/compile-sankey.ts +78 -13
- package/src/sankey/types.ts +2 -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
|
// ---------------------------------------------------------------------------
|
|
@@ -205,9 +207,18 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
205
207
|
const mergedThemeConfig = options.theme
|
|
206
208
|
? { ...sankeySpec.theme, ...options.theme }
|
|
207
209
|
: sankeySpec.theme;
|
|
208
|
-
|
|
210
|
+
const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
211
|
+
let theme: ResolvedTheme = lightTheme;
|
|
209
212
|
if (options.darkMode) {
|
|
210
213
|
theme = adaptTheme(theme);
|
|
214
|
+
// Sankey nodes and link gradients need vivid colors that stand out on dark
|
|
215
|
+
// backgrounds. The adapted palette preserves contrast ratios designed for
|
|
216
|
+
// text, but those contrast-matched colors are too dark for filled shapes.
|
|
217
|
+
// Use the original light-theme categorical palette for node/link colors.
|
|
218
|
+
theme = {
|
|
219
|
+
...theme,
|
|
220
|
+
colors: { ...theme.colors, categorical: lightTheme.colors.categorical },
|
|
221
|
+
};
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
// 3. Compute chrome
|
|
@@ -282,19 +293,62 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
282
293
|
return emptyLayout(area, chrome, theme, options);
|
|
283
294
|
}
|
|
284
295
|
|
|
285
|
-
// 6. Run d3-sankey layout
|
|
286
|
-
const
|
|
296
|
+
// 6. Run d3-sankey layout (may re-run once if labels overflow)
|
|
297
|
+
const labelFontSize = theme.fonts.sizes.small;
|
|
298
|
+
const labelFontWeight = theme.fonts.weights.normal;
|
|
299
|
+
const nodeWidth = sankeySpec.nodeWidth ?? 12;
|
|
300
|
+
|
|
301
|
+
let layoutArea: Rect = { ...area };
|
|
302
|
+
let { nodes, links } = computeSankeyLayout(
|
|
287
303
|
sankeySpec.data,
|
|
288
304
|
sourceField,
|
|
289
305
|
targetField,
|
|
290
306
|
valueField,
|
|
291
|
-
|
|
307
|
+
layoutArea,
|
|
292
308
|
sankeySpec.nodeWidth,
|
|
293
309
|
sankeySpec.nodePadding,
|
|
294
310
|
sankeySpec.nodeAlign,
|
|
295
311
|
sankeySpec.iterations,
|
|
296
312
|
);
|
|
297
313
|
|
|
314
|
+
// 6b. Check if any non-rightmost node labels overflow the right edge.
|
|
315
|
+
// Non-rightmost nodes get labels to the right (textAnchor: start),
|
|
316
|
+
// which can extend past the drawing area boundary.
|
|
317
|
+
const maxDepthFirst = nodes.reduce((max, n) => Math.max(max, n.depth ?? 0), 0);
|
|
318
|
+
const rightEdge = area.x + area.width;
|
|
319
|
+
let maxOverflow = 0;
|
|
320
|
+
for (const node of nodes) {
|
|
321
|
+
const depth = node.depth ?? 0;
|
|
322
|
+
if (depth === maxDepthFirst) continue; // rightmost labels go left, no overflow
|
|
323
|
+
const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
|
|
324
|
+
const labelText = node.label ?? node.id;
|
|
325
|
+
const labelWidth = estimateTextWidth(labelText, labelFontSize, labelFontWeight);
|
|
326
|
+
const overflow = labelX + labelWidth - rightEdge;
|
|
327
|
+
if (overflow > maxOverflow) maxOverflow = overflow;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Re-run layout with tighter width if labels would clip
|
|
331
|
+
if (maxOverflow > 0) {
|
|
332
|
+
const margin = Math.ceil(maxOverflow) + 4; // small extra buffer
|
|
333
|
+
layoutArea = {
|
|
334
|
+
x: area.x,
|
|
335
|
+
y: area.y,
|
|
336
|
+
width: Math.max(area.width - margin, 40),
|
|
337
|
+
height: area.height,
|
|
338
|
+
};
|
|
339
|
+
({ nodes, links } = computeSankeyLayout(
|
|
340
|
+
sankeySpec.data,
|
|
341
|
+
sourceField,
|
|
342
|
+
targetField,
|
|
343
|
+
valueField,
|
|
344
|
+
layoutArea,
|
|
345
|
+
sankeySpec.nodeWidth,
|
|
346
|
+
sankeySpec.nodePadding,
|
|
347
|
+
sankeySpec.nodeAlign,
|
|
348
|
+
sankeySpec.iterations,
|
|
349
|
+
));
|
|
350
|
+
}
|
|
351
|
+
|
|
298
352
|
// 7. Build node color map
|
|
299
353
|
const nodeColorMap = buildNodeColorMap(
|
|
300
354
|
nodes,
|
|
@@ -328,7 +382,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
328
382
|
data: { id: node.id, label: node.label },
|
|
329
383
|
aria: {
|
|
330
384
|
role: 'img',
|
|
331
|
-
label: `${node.label}: ${
|
|
385
|
+
label: `${node.label}: ${formatFlowValue(node.value ?? 0, sankeySpec.valueFormat)}`,
|
|
332
386
|
},
|
|
333
387
|
animationIndex: 0, // Reassigned below after sorting by depth
|
|
334
388
|
};
|
|
@@ -354,7 +408,8 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
354
408
|
path: generateLinkPath(link),
|
|
355
409
|
sourceColor: colors.sourceColor,
|
|
356
410
|
targetColor: colors.targetColor,
|
|
357
|
-
fillOpacity:
|
|
411
|
+
fillOpacity:
|
|
412
|
+
sankeySpec.linkOpacity ?? (options.darkMode ? LINK_OPACITY_DARK : LINK_OPACITY_LIGHT),
|
|
358
413
|
sourceId: sourceNode.id,
|
|
359
414
|
targetId: targetNode.id,
|
|
360
415
|
width: link.width ?? 0,
|
|
@@ -362,7 +417,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
362
417
|
data: (link as unknown as { data: Record<string, unknown> }).data ?? {},
|
|
363
418
|
aria: {
|
|
364
419
|
role: 'img',
|
|
365
|
-
label: `${sourceNode.label} to ${targetNode.label}: ${
|
|
420
|
+
label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, sankeySpec.valueFormat)}`,
|
|
366
421
|
},
|
|
367
422
|
// Links animate after nodes
|
|
368
423
|
animationIndex: nodeMarks.length + i,
|
|
@@ -381,7 +436,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
381
436
|
);
|
|
382
437
|
|
|
383
438
|
// 13. Build tooltip descriptors
|
|
384
|
-
const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks);
|
|
439
|
+
const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, sankeySpec.valueFormat);
|
|
385
440
|
|
|
386
441
|
// 14. Build a11y metadata
|
|
387
442
|
const a11y = {
|
|
@@ -510,9 +565,18 @@ function buildSankeyLegend(
|
|
|
510
565
|
// Tooltip builder
|
|
511
566
|
// ---------------------------------------------------------------------------
|
|
512
567
|
|
|
568
|
+
function formatFlowValue(value: number, valueFormat?: string): string {
|
|
569
|
+
if (valueFormat) {
|
|
570
|
+
const fmt = buildD3Formatter(valueFormat);
|
|
571
|
+
if (fmt) return fmt(value);
|
|
572
|
+
}
|
|
573
|
+
return formatNumber(value);
|
|
574
|
+
}
|
|
575
|
+
|
|
513
576
|
function buildTooltipDescriptors(
|
|
514
577
|
nodes: SankeyNodeMark[],
|
|
515
578
|
links: SankeyLinkMark[],
|
|
579
|
+
valueFormat?: string,
|
|
516
580
|
): Map<string, TooltipContent> {
|
|
517
581
|
const descriptors = new Map<string, TooltipContent>();
|
|
518
582
|
|
|
@@ -521,7 +585,7 @@ function buildTooltipDescriptors(
|
|
|
521
585
|
const fields: TooltipField[] = [
|
|
522
586
|
{
|
|
523
587
|
label: 'Total flow',
|
|
524
|
-
value:
|
|
588
|
+
value: formatFlowValue(node.value, valueFormat),
|
|
525
589
|
},
|
|
526
590
|
];
|
|
527
591
|
descriptors.set(`node-${node.nodeId}`, {
|
|
@@ -531,14 +595,15 @@ function buildTooltipDescriptors(
|
|
|
531
595
|
}
|
|
532
596
|
|
|
533
597
|
// Link tooltips: keyed by "link-{sourceId}-{targetId}" to match renderer data-mark-id
|
|
534
|
-
for (
|
|
598
|
+
for (let i = 0; i < links.length; i++) {
|
|
599
|
+
const link = links[i];
|
|
535
600
|
const fields: TooltipField[] = [
|
|
536
601
|
{
|
|
537
602
|
label: 'Flow',
|
|
538
|
-
value:
|
|
603
|
+
value: formatFlowValue(link.value, valueFormat),
|
|
539
604
|
},
|
|
540
605
|
];
|
|
541
|
-
descriptors.set(`link-${link.sourceId}-${link.targetId}`, {
|
|
606
|
+
descriptors.set(`link-${link.sourceId}-${link.targetId}-${i}`, {
|
|
542
607
|
title: `${link.sourceId} \u2192 ${link.targetId}`,
|
|
543
608
|
fields,
|
|
544
609
|
});
|
package/src/sankey/types.ts
CHANGED
|
@@ -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
|
|