@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.7.1",
3
+ "version": "6.8.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.7.1",
48
+ "@opendata-ai/openchart-core": "6.8.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -213,6 +213,36 @@ describe('computeLegend', () => {
213
213
  expect(legend.entries.every((e) => !e.overflow)).toBe(true);
214
214
  });
215
215
 
216
+ it('with maxRows: 3 and 8 long-named entries, shows more entries than default maxRows of 2', () => {
217
+ // Use long series names so entries overflow 2 rows but fit in 3
218
+ const longNameData = [
219
+ { date: '2020', value: 10, country: 'Home price to income ratio' },
220
+ { date: '2020', value: 10, country: 'Tuition to income ratio' },
221
+ { date: '2020', value: 10, country: 'Health premium to income' },
222
+ { date: '2020', value: 10, country: 'Childcare cost to income' },
223
+ { date: '2020', value: 10, country: 'Transportation expenses' },
224
+ { date: '2020', value: 10, country: 'Food and groceries cost' },
225
+ { date: '2020', value: 10, country: 'Utilities and services' },
226
+ { date: '2020', value: 10, country: 'Insurance and benefits' },
227
+ ];
228
+ const maxRowsSpec: NormalizedChartSpec = {
229
+ ...specWithColor,
230
+ data: longNameData,
231
+ legend: { maxRows: 3 },
232
+ hiddenSeries: [],
233
+ seriesStyles: {},
234
+ };
235
+ const defaultSpec: NormalizedChartSpec = {
236
+ ...specWithColor,
237
+ data: longNameData,
238
+ };
239
+ const legendDefault = computeLegend(defaultSpec, compactStrategy, theme, chartArea);
240
+ const legendMaxRows = computeLegend(maxRowsSpec, compactStrategy, theme, chartArea);
241
+ const defaultVisible = legendDefault.entries.filter((e) => !e.overflow).length;
242
+ const maxRowsVisible = legendMaxRows.entries.filter((e) => !e.overflow).length;
243
+ expect(maxRowsVisible).toBeGreaterThan(defaultVisible);
244
+ });
245
+
216
246
  it('uses correct swatch shape for chart type', () => {
217
247
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
218
248
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -552,6 +552,99 @@ describe('computeAnnotations', () => {
552
552
  });
553
553
  });
554
554
 
555
+ // -----------------------------------------------------------------
556
+ // Refline labelAnchor positioning
557
+ // -----------------------------------------------------------------
558
+
559
+ describe('refline labelAnchor positioning', () => {
560
+ it('horizontal refline: "left" places label at start.x with text-anchor start', () => {
561
+ const spec = makeSpec([{ type: 'refline', y: 20, label: 'Left label', labelAnchor: 'left' }]);
562
+ const scales = computeScales(spec, chartArea, spec.data);
563
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
564
+
565
+ const label = annotations[0].label!;
566
+ // Label x should be near chartArea.x (start of line) + small offset
567
+ expect(label.x).toBeCloseTo(chartArea.x + 4, 0);
568
+ expect(label.style.textAnchor).toBe('start');
569
+ });
570
+
571
+ it('horizontal refline: default places label at end.x with text-anchor end', () => {
572
+ const spec = makeSpec([{ type: 'refline', y: 20, label: 'Default label' }]);
573
+ const scales = computeScales(spec, chartArea, spec.data);
574
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
575
+
576
+ const label = annotations[0].label!;
577
+ // Label x should be near chartArea.x + chartArea.width (end of line) - small offset
578
+ expect(label.x).toBeCloseTo(chartArea.x + chartArea.width - 4, 0);
579
+ expect(label.style.textAnchor).toBe('end');
580
+ });
581
+
582
+ it('horizontal refline: "bottom" places label below the line', () => {
583
+ const specTop = makeSpec([{ type: 'refline', y: 20, label: 'Top', labelAnchor: 'top' }]);
584
+ const specBottom = makeSpec([
585
+ { type: 'refline', y: 20, label: 'Bottom', labelAnchor: 'bottom' },
586
+ ]);
587
+ const scales = computeScales(specTop, chartArea, specTop.data);
588
+
589
+ const top = computeAnnotations(specTop, scales, chartArea, fullStrategy);
590
+ const bottom = computeAnnotations(specBottom, scales, chartArea, fullStrategy);
591
+
592
+ // Bottom label should be below top label (larger y value)
593
+ expect(bottom[0].label!.y).toBeGreaterThan(top[0].label!.y);
594
+ });
595
+
596
+ it('vertical refline: "right" places label with text-anchor end', () => {
597
+ const spec = makeSpec([
598
+ { type: 'refline', x: '2020-06-01', label: 'Right', labelAnchor: 'right' },
599
+ ]);
600
+ const scales = computeScales(spec, chartArea, spec.data);
601
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
602
+
603
+ const label = annotations[0].label!;
604
+ expect(label.style.textAnchor).toBe('end');
605
+ });
606
+
607
+ it('vertical refline: "bottom" places label near end.y', () => {
608
+ const specTop = makeSpec([
609
+ { type: 'refline', x: '2020-06-01', label: 'Top', labelAnchor: 'top' },
610
+ ]);
611
+ const specBottom = makeSpec([
612
+ { type: 'refline', x: '2020-06-01', label: 'Bottom', labelAnchor: 'bottom' },
613
+ ]);
614
+ const scales = computeScales(specTop, chartArea, specTop.data);
615
+
616
+ const top = computeAnnotations(specTop, scales, chartArea, fullStrategy);
617
+ const bottom = computeAnnotations(specBottom, scales, chartArea, fullStrategy);
618
+
619
+ // Bottom label should be further down (near end.y which is chartArea.y + chartArea.height)
620
+ expect(bottom[0].label!.y).toBeGreaterThan(top[0].label!.y);
621
+ });
622
+
623
+ it('labelOffset still applies on top of anchor positioning', () => {
624
+ const spec = makeSpec([
625
+ {
626
+ type: 'refline',
627
+ y: 20,
628
+ label: 'Offset left',
629
+ labelAnchor: 'left',
630
+ labelOffset: { dx: 10, dy: -5 },
631
+ },
632
+ ]);
633
+ const specNoOffset = makeSpec([
634
+ { type: 'refline', y: 20, label: 'No offset', labelAnchor: 'left' },
635
+ ]);
636
+ const scales = computeScales(spec, chartArea, spec.data);
637
+
638
+ const withOffset = computeAnnotations(spec, scales, chartArea, fullStrategy);
639
+ const withoutOffset = computeAnnotations(specNoOffset, scales, chartArea, fullStrategy);
640
+
641
+ const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
642
+ const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
643
+ expect(dx).toBe(10);
644
+ expect(dy).toBe(-5);
645
+ });
646
+ });
647
+
555
648
  // -----------------------------------------------------------------
556
649
  // Connector origin auto-selection
557
650
  // -----------------------------------------------------------------
@@ -498,29 +498,82 @@ function resolveRefLineAnnotation(
498
498
  }
499
499
  // 'solid' gets no dasharray
500
500
 
501
- // Label at the right end for horizontal, top end for vertical, with optional offset.
502
- // Horizontal refline labels use text-anchor 'end' so text stays inside the chart.
503
- // labelAnchor controls which side of the line the label sits on:
504
- // "top" (default): above horizontal, left of vertical
505
- // "bottom": below horizontal, right of vertical
501
+ // Label placement on reflines. labelAnchor controls position:
502
+ //
503
+ // Horizontal reflines (y set):
504
+ // "left": left end of line, above "right"/"top" (default): right end, above
505
+ // "bottom": right end of line, below
506
+ //
507
+ // Vertical reflines (x set):
508
+ // "right": label to the left of the line, near top
509
+ // "bottom": label to the right of the line, near bottom
510
+ // "left"/"top" (default): label to the right of the line, near top
506
511
  let label: ResolvedLabel | undefined;
507
512
  if (annotation.label) {
508
513
  const isHorizontal = annotation.y !== undefined;
509
- const anchor = annotation.labelAnchor ?? 'top';
510
- const baseDx = isHorizontal ? -4 : 4;
511
- const baseDy = anchor === 'bottom' ? 14 : -4;
514
+ const anchor = annotation.labelAnchor ?? (isHorizontal ? 'top' : 'left');
515
+
516
+ let baseDx: number;
517
+ let baseDy: number;
518
+ let labelX: number;
519
+ let labelY: number;
520
+ let textAnchor: 'start' | 'middle' | 'end';
521
+
522
+ if (isHorizontal) {
523
+ if (anchor === 'left') {
524
+ baseDx = 4;
525
+ baseDy = -4;
526
+ labelX = start.x;
527
+ labelY = start.y;
528
+ textAnchor = 'start';
529
+ } else if (anchor === 'bottom') {
530
+ baseDx = -4;
531
+ baseDy = 14;
532
+ labelX = end.x;
533
+ labelY = end.y;
534
+ textAnchor = 'end';
535
+ } else {
536
+ // 'right', 'top' (default), 'auto'
537
+ baseDx = -4;
538
+ baseDy = -4;
539
+ labelX = end.x;
540
+ labelY = end.y;
541
+ textAnchor = 'end';
542
+ }
543
+ } else {
544
+ // Vertical refline
545
+ if (anchor === 'right') {
546
+ baseDx = -4;
547
+ baseDy = 14;
548
+ labelX = start.x;
549
+ labelY = start.y;
550
+ textAnchor = 'end';
551
+ } else if (anchor === 'bottom') {
552
+ baseDx = 4;
553
+ baseDy = -4;
554
+ labelX = start.x;
555
+ labelY = end.y;
556
+ textAnchor = 'start';
557
+ } else {
558
+ // 'left', 'top' (default), 'auto' — label to the right of the line, near top
559
+ baseDx = 4;
560
+ baseDy = 14;
561
+ labelX = start.x;
562
+ labelY = start.y;
563
+ textAnchor = 'start';
564
+ }
565
+ }
566
+
512
567
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
513
568
 
514
569
  const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
515
570
  const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke, isDark);
516
- if (isHorizontal) {
517
- style.textAnchor = 'end';
518
- }
571
+ style.textAnchor = textAnchor;
519
572
 
520
573
  label = {
521
574
  text: annotation.label,
522
- x: (isHorizontal ? end.x : start.x) + labelDelta.dx,
523
- y: (isHorizontal ? end.y : start.y) + labelDelta.dy,
575
+ x: labelX + labelDelta.dx,
576
+ y: labelY + labelDelta.dy,
524
577
  style,
525
578
  visible: true,
526
579
  };
@@ -203,6 +203,73 @@ describe('computeBarMarks', () => {
203
203
  });
204
204
  });
205
205
 
206
+ describe('colored (non-stacked) bars', () => {
207
+ it('renders colored bars when each category has one row with color encoding', () => {
208
+ const spec: NormalizedChartSpec = {
209
+ markType: 'bar',
210
+ markDef: { type: 'bar' },
211
+ data: [
212
+ { category: 'Apple', value: 50, type: 'Fruit' },
213
+ { category: 'Banana', value: 30, type: 'Tropical' },
214
+ { category: 'Cherry', value: 70, type: 'Berry' },
215
+ ],
216
+ encoding: {
217
+ x: { field: 'value', type: 'quantitative' },
218
+ y: { field: 'category', type: 'nominal' },
219
+ color: { field: 'type', type: 'nominal' },
220
+ },
221
+ chrome: {},
222
+ annotations: [],
223
+ responsive: true,
224
+ theme: {},
225
+ darkMode: 'off',
226
+ labels: { density: 'auto', format: '' },
227
+ };
228
+ const scales = computeScales(spec, chartArea, spec.data);
229
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
230
+
231
+ expect(marks).toHaveLength(3);
232
+ // Each bar should have different colors
233
+ const colors = new Set(marks.map((m) => m.fill));
234
+ expect(colors.size).toBe(3);
235
+ // Non-stacked bars should have corner radius
236
+ expect(marks[0].cornerRadius).toBe(2);
237
+ // Bars should not be stacked (no stackGroup)
238
+ expect(marks[0].stackGroup).toBeUndefined();
239
+ });
240
+
241
+ it('handles negative values in colored bars', () => {
242
+ const spec: NormalizedChartSpec = {
243
+ markType: 'bar',
244
+ markDef: { type: 'bar' },
245
+ data: [
246
+ { category: 'Growth', value: 15, status: 'positive' },
247
+ { category: 'Decline', value: -10, status: 'negative' },
248
+ ],
249
+ encoding: {
250
+ x: { field: 'value', type: 'quantitative' },
251
+ y: { field: 'category', type: 'nominal' },
252
+ color: { field: 'status', type: 'nominal' },
253
+ },
254
+ chrome: {},
255
+ annotations: [],
256
+ responsive: true,
257
+ theme: {},
258
+ darkMode: 'off',
259
+ labels: { density: 'auto', format: '' },
260
+ };
261
+ const scales = computeScales(spec, chartArea, spec.data);
262
+ const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
263
+
264
+ expect(marks).toHaveLength(2);
265
+ const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
266
+ const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
267
+ // Negative bar starts to the left of positive bar
268
+ expect(decline.x).toBeLessThan(growth.x);
269
+ expect(decline.width).toBeGreaterThan(0);
270
+ });
271
+ });
272
+
206
273
  describe('negative values', () => {
207
274
  it('negative bars extend leftward from baseline', () => {
208
275
  const spec = makeNegativeBarSpec();
@@ -94,8 +94,26 @@ export function computeBarMarks(
94
94
  );
95
95
  }
96
96
 
97
- // Stacked bars when color is present
98
- return computeStackedBars(
97
+ // Color encoding present: decide between colored simple bars vs stacked
98
+ const categoryGroups = groupByField(spec.data, yChannel.field);
99
+ const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
100
+
101
+ if (needsStacking) {
102
+ return computeStackedBars(
103
+ spec.data,
104
+ xChannel.field,
105
+ yChannel.field,
106
+ colorField,
107
+ xScale,
108
+ yScale,
109
+ bandwidth,
110
+ baseline,
111
+ scales,
112
+ );
113
+ }
114
+
115
+ // Single row per category: render like simple bars but with color from scale
116
+ return computeColoredBars(
99
117
  spec.data,
100
118
  xChannel.field,
101
119
  yChannel.field,
@@ -166,6 +184,55 @@ function computeStackedBars(
166
184
  return marks;
167
185
  }
168
186
 
187
+ /** Compute colored (non-stacked) horizontal bars. Used when color encoding
188
+ * is present but each category has only one row (e.g., diverging charts). */
189
+ function computeColoredBars(
190
+ data: DataRow[],
191
+ valueField: string,
192
+ categoryField: string,
193
+ colorField: string,
194
+ xScale: ScaleLinear<number, number>,
195
+ yScale: ScaleBand<string>,
196
+ bandwidth: number,
197
+ baseline: number,
198
+ scales: ResolvedScales,
199
+ ): RectMark[] {
200
+ const marks: RectMark[] = [];
201
+
202
+ for (const row of data) {
203
+ const category = String(row[categoryField] ?? '');
204
+ const value = Number(row[valueField] ?? 0);
205
+ if (!Number.isFinite(value)) continue;
206
+
207
+ const bandY = yScale(category);
208
+ if (bandY === undefined) continue;
209
+
210
+ const groupKey = String(row[colorField] ?? '');
211
+ const color = getColor(scales, groupKey);
212
+ const xPos = value >= 0 ? baseline : xScale(value);
213
+ const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
214
+
215
+ const aria: MarkAria = {
216
+ label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
217
+ };
218
+
219
+ marks.push({
220
+ type: 'rect',
221
+ x: xPos,
222
+ y: bandY,
223
+ width: barWidth,
224
+ height: bandwidth,
225
+ fill: color,
226
+ cornerRadius: 2,
227
+ data: row as Record<string, unknown>,
228
+ aria,
229
+ orient: 'horizontal',
230
+ });
231
+ }
232
+
233
+ return marks;
234
+ }
235
+
169
236
  /** Compute simple (non-grouped) horizontal bars. */
170
237
  function computeSimpleBars(
171
238
  data: DataRow[],
@@ -251,6 +251,8 @@ function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedS
251
251
  theme: spec.theme ?? {},
252
252
  darkMode: spec.darkMode ?? 'off',
253
253
  animation: spec.animation,
254
+ valueFormat: spec.valueFormat,
255
+ linkOpacity: spec.linkOpacity,
254
256
  };
255
257
  }
256
258
 
@@ -269,11 +269,13 @@ export function computeLegend(
269
269
  }
270
270
  }
271
271
 
272
- // When columns is explicitly set, allow that many rows instead of the default max.
272
+ // Resolve max rows: explicit maxRows wins, then columns-derived, then default.
273
273
  const maxRows =
274
- spec.legend?.columns != null
275
- ? Math.ceil(entries.length / spec.legend.columns)
276
- : TOP_LEGEND_MAX_ROWS;
274
+ spec.legend?.maxRows != null
275
+ ? Math.max(1, spec.legend.maxRows)
276
+ : spec.legend?.columns != null
277
+ ? Math.ceil(entries.length / spec.legend.columns)
278
+ : TOP_LEGEND_MAX_ROWS;
277
279
  const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
278
280
 
279
281
  if (maxFit < entries.length) {
@@ -199,13 +199,16 @@ describe('compileSankey', () => {
199
199
  expect(result.tooltipDescriptors.has('node-E')).toBe(true);
200
200
  });
201
201
 
202
- it('contains entries for links keyed as link-{source}-{target}', () => {
202
+ it('contains entries for links keyed as link-{source}-{target}-{index}', () => {
203
203
  const result = compileSankey(basicSpec, defaultOptions);
204
204
 
205
- expect(result.tooltipDescriptors.has('link-A-C')).toBe(true);
206
- expect(result.tooltipDescriptors.has('link-B-C')).toBe(true);
207
- expect(result.tooltipDescriptors.has('link-C-D')).toBe(true);
208
- expect(result.tooltipDescriptors.has('link-C-E')).toBe(true);
205
+ // Keys include index suffix for uniqueness with duplicate source-target pairs
206
+ const linkKeys = [...result.tooltipDescriptors.keys()].filter((k) => k.startsWith('link-'));
207
+ expect(linkKeys.length).toBe(4);
208
+ // Each key should have a numeric suffix
209
+ for (const key of linkKeys) {
210
+ expect(key).toMatch(/link-.+-\d+$/);
211
+ }
209
212
  });
210
213
 
211
214
  it('node tooltip has title and flow field', () => {
@@ -220,9 +223,11 @@ describe('compileSankey', () => {
220
223
  it('link tooltip has title and flow field', () => {
221
224
  const result = compileSankey(basicSpec, defaultOptions);
222
225
 
223
- const tooltip = result.tooltipDescriptors.get('link-A-C')!;
224
- expect(tooltip.title).toContain('A');
225
- expect(tooltip.title).toContain('C');
226
+ // Get the first link tooltip (keyed with index suffix)
227
+ const linkKey = [...result.tooltipDescriptors.keys()].find((k) => k.startsWith('link-'));
228
+ expect(linkKey).toBeTruthy();
229
+ const tooltip = result.tooltipDescriptors.get(linkKey!)!;
230
+ expect(tooltip.title).toContain('\u2192'); // arrow character
226
231
  expect(tooltip.fields.some((f) => f.label === 'Flow')).toBe(true);
227
232
  });
228
233
  });
@@ -350,4 +355,104 @@ describe('compileSankey', () => {
350
355
  expect(() => compileSankey(chartSpec, defaultOptions)).toThrow(/non-sankey spec/);
351
356
  });
352
357
  });
358
+
359
+ describe('special characters in node names', () => {
360
+ it('compiles with spaces and $ in node names', () => {
361
+ const spec = {
362
+ type: 'sankey' as const,
363
+ data: [
364
+ { from: 'Income $104k', to: 'Essential costs', amount: 50 },
365
+ { from: 'Income $104k', to: 'Taxes & fees', amount: 20 },
366
+ { from: 'Essential costs', to: 'Housing #1', amount: 30 },
367
+ { from: 'Essential costs', to: 'Food (groceries)', amount: 20 },
368
+ ],
369
+ encoding: {
370
+ source: { field: 'from', type: 'nominal' as const },
371
+ target: { field: 'to', type: 'nominal' as const },
372
+ value: { field: 'amount', type: 'quantitative' as const },
373
+ },
374
+ };
375
+
376
+ const result = compileSankey(spec, defaultOptions);
377
+ expect(result.nodes.length).toBe(5);
378
+ expect(result.links.length).toBe(4);
379
+ // Node IDs should preserve the original names
380
+ expect(result.nodes.some((n) => n.nodeId === 'Income $104k')).toBe(true);
381
+ expect(result.nodes.some((n) => n.nodeId === 'Taxes & fees')).toBe(true);
382
+ });
383
+ });
384
+
385
+ describe('dark mode colors', () => {
386
+ it('preserves vivid categorical colors in dark mode', () => {
387
+ const spec = {
388
+ ...basicSpec,
389
+ theme: { colors: ['#38bdf8', '#f87171', '#4ade80'] },
390
+ };
391
+
392
+ const lightResult = compileSankey(spec, defaultOptions);
393
+ const darkResult = compileSankey(spec, { ...defaultOptions, darkMode: true });
394
+
395
+ // Dark mode should use the same vivid node colors, not dark-adapted ones
396
+ const lightColors = lightResult.nodes.map((n) => n.fill);
397
+ const darkColors = darkResult.nodes.map((n) => n.fill);
398
+ expect(darkColors).toEqual(lightColors);
399
+ });
400
+
401
+ it('uses higher link opacity in dark mode', () => {
402
+ const lightResult = compileSankey(basicSpec, defaultOptions);
403
+ const darkResult = compileSankey(basicSpec, { ...defaultOptions, darkMode: true });
404
+
405
+ const lightOpacity = lightResult.links[0].fillOpacity;
406
+ const darkOpacity = darkResult.links[0].fillOpacity;
407
+ expect(darkOpacity).toBeGreaterThan(lightOpacity);
408
+ });
409
+ });
410
+
411
+ describe('valueFormat', () => {
412
+ it('formats tooltip values when valueFormat is set', () => {
413
+ const spec = { ...basicSpec, valueFormat: '.0f%' };
414
+ const result = compileSankey(spec, defaultOptions);
415
+
416
+ // Check that node tooltips use the format
417
+ const nodeTooltip = result.tooltipDescriptors.get('node-C');
418
+ expect(nodeTooltip?.fields[0].value).toContain('%');
419
+ });
420
+
421
+ it('uses default formatting when valueFormat is undefined', () => {
422
+ const result = compileSankey(basicSpec, defaultOptions);
423
+
424
+ const nodeTooltip = result.tooltipDescriptors.get('node-C');
425
+ expect(nodeTooltip?.fields[0].value).not.toContain('%');
426
+ });
427
+
428
+ it('falls back to default on invalid format string', () => {
429
+ const spec = { ...basicSpec, valueFormat: 'not-a-format' };
430
+ // Should not throw
431
+ const result = compileSankey(spec, defaultOptions);
432
+ expect(result.nodes.length).toBeGreaterThan(0);
433
+ });
434
+ });
435
+
436
+ describe('linkOpacity', () => {
437
+ it('uses custom linkOpacity when specified', () => {
438
+ const spec = { ...basicSpec, linkOpacity: 0.9 };
439
+ const result = compileSankey(spec, defaultOptions);
440
+
441
+ expect(result.links[0].fillOpacity).toBe(0.9);
442
+ });
443
+
444
+ it('uses default opacity when linkOpacity is not set', () => {
445
+ const result = compileSankey(basicSpec, defaultOptions);
446
+ // Light mode default
447
+ expect(result.links[0].fillOpacity).toBe(0.5);
448
+ });
449
+ });
450
+
451
+ describe('dimensions', () => {
452
+ it('dimensions match the provided container size', () => {
453
+ const result = compileSankey(basicSpec, { width: 600, height: 800 });
454
+ expect(result.dimensions.width).toBe(600);
455
+ expect(result.dimensions.height).toBe(800);
456
+ });
457
+ });
353
458
  });