@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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
// "top" (default):
|
|
505
|
-
// "bottom":
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
517
|
-
style.textAnchor = 'end';
|
|
518
|
-
}
|
|
571
|
+
style.textAnchor = textAnchor;
|
|
519
572
|
|
|
520
573
|
label = {
|
|
521
574
|
text: annotation.label,
|
|
522
|
-
x:
|
|
523
|
-
y:
|
|
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
|
-
//
|
|
98
|
-
|
|
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
|
|
package/src/legend/compute.ts
CHANGED
|
@@ -269,11 +269,13 @@ export function computeLegend(
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
//
|
|
272
|
+
// Resolve max rows: explicit maxRows wins, then columns-derived, then default.
|
|
273
273
|
const maxRows =
|
|
274
|
-
spec.legend?.
|
|
275
|
-
? Math.
|
|
276
|
-
:
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
expect(
|
|
208
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
expect(
|
|
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
|
});
|