@opendata-ai/openchart-engine 6.7.1 → 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.
@@ -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 LINK_OPACITY = 0.35;
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
- let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
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 { nodes, links } = computeSankeyLayout(
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
- area,
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}: ${formatNumber(node.value ?? 0)}`,
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: LINK_OPACITY,
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}: ${formatNumber(link.value)}`,
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: formatNumber(node.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 (const link of links) {
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: formatNumber(link.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
  });
@@ -33,4 +33,6 @@ export interface NormalizedSankeySpec {
33
33
  theme: ThemeConfig;
34
34
  darkMode: DarkMode;
35
35
  animation?: AnimationSpec;
36
+ valueFormat?: string;
37
+ linkOpacity?: number;
36
38
  }
@@ -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
- try {
53
- const formatter = d3Format(column.format);
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
- try {
99
- return d3Format(column.format)(value);
100
- } catch {
101
- // Fall through
95
+ const formatter = buildD3Formatter(column.format);
96
+ if (formatter) {
97
+ return formatter(value);
102
98
  }
103
99
  }
104
100