@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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -129,18 +131,28 @@ function getLinkColors(
129
131
 
130
132
  /**
131
133
  * Determine label position for a node based on its column depth.
132
- * Leftmost column: label to the right.
133
- * Rightmost column: label to the left.
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
- const isRightmost = depth === maxDepth;
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 (isRightmost) {
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
- let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
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 { nodes, links } = computeSankeyLayout(
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
- area,
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}: ${formatNumber(node.value ?? 0)}`,
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: LINK_OPACITY,
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}: ${formatNumber(link.value)}`,
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: formatNumber(node.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 (const link of links) {
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: formatNumber(link.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
  });
@@ -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
- 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