@opendata-ai/openchart-engine 6.5.2 → 6.7.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 +44 -19
- package/dist/index.js +1353 -363
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/__test-fixtures__/specs.ts +3 -3
- package/src/__tests__/dimensions.test.ts +47 -1
- package/src/annotations/__tests__/compute.test.ts +28 -0
- package/src/charts/bar/index.ts +7 -1
- package/src/charts/bar/labels.ts +2 -0
- package/src/charts/column/index.ts +7 -1
- package/src/charts/column/labels.ts +2 -0
- package/src/charts/dot/index.ts +1 -1
- package/src/charts/dot/labels.ts +3 -1
- package/src/compile.ts +30 -0
- package/src/compiler/__tests__/normalize.test.ts +18 -3
- package/src/compiler/normalize.ts +26 -2
- package/src/compiler/types.ts +9 -3
- package/src/compiler/validate.ts +109 -5
- package/src/index.ts +9 -1
- package/src/sankey/__tests__/compile-sankey.test.ts +353 -0
- package/src/sankey/__tests__/layout.test.ts +165 -0
- package/src/sankey/compile-sankey.ts +593 -0
- package/src/sankey/layout.ts +170 -0
- package/src/sankey/types.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.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,10 +45,11 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "6.
|
|
48
|
+
"@opendata-ai/openchart-core": "6.7.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
52
|
+
"d3-sankey": "^0.12.3",
|
|
52
53
|
"d3-scale": "^4.0.0",
|
|
53
54
|
"d3-shape": "^3.2.0"
|
|
54
55
|
},
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"@types/d3-array": "^3.2.1",
|
|
57
58
|
"@types/d3-format": "^3.0.4",
|
|
58
59
|
"@types/d3-interpolate": "^3.0.4",
|
|
60
|
+
"@types/d3-sankey": "^0.12.5",
|
|
59
61
|
"@types/d3-scale": "^4.0.8",
|
|
60
62
|
"@types/d3-shape": "^3.1.6"
|
|
61
63
|
}
|
|
@@ -70,7 +70,7 @@ export function makeLineSpec(): NormalizedChartSpec {
|
|
|
70
70
|
responsive: true,
|
|
71
71
|
theme: {},
|
|
72
72
|
darkMode: 'off',
|
|
73
|
-
labels: { density: 'auto', format: '' },
|
|
73
|
+
labels: { density: 'auto', format: '', prefix: '' },
|
|
74
74
|
hiddenSeries: [],
|
|
75
75
|
seriesStyles: {},
|
|
76
76
|
};
|
|
@@ -99,7 +99,7 @@ export function makeBarSpec(): NormalizedChartSpec {
|
|
|
99
99
|
responsive: true,
|
|
100
100
|
theme: {},
|
|
101
101
|
darkMode: 'off',
|
|
102
|
-
labels: { density: 'auto', format: '' },
|
|
102
|
+
labels: { density: 'auto', format: '', prefix: '' },
|
|
103
103
|
hiddenSeries: [],
|
|
104
104
|
seriesStyles: {},
|
|
105
105
|
};
|
|
@@ -130,7 +130,7 @@ export function makeScatterSpec(): NormalizedChartSpec {
|
|
|
130
130
|
responsive: true,
|
|
131
131
|
theme: {},
|
|
132
132
|
darkMode: 'off',
|
|
133
|
-
labels: { density: 'auto', format: '' },
|
|
133
|
+
labels: { density: 'auto', format: '', prefix: '' },
|
|
134
134
|
hiddenSeries: [],
|
|
135
135
|
seriesStyles: {},
|
|
136
136
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LegendLayout } from '@opendata-ai/openchart-core';
|
|
1
|
+
import type { LayoutStrategy, LegendLayout } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { adaptTheme, resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
@@ -219,4 +219,50 @@ describe('computeDimensions', () => {
|
|
|
219
219
|
// Small angles (< 10 degrees) should not trigger rotated label logic
|
|
220
220
|
expect(dimsSmall.margins.bottom).toBe(dimsNone.margins.bottom);
|
|
221
221
|
});
|
|
222
|
+
|
|
223
|
+
it('does not reserve annotation margin when strategy is tooltip-only', () => {
|
|
224
|
+
const specWithAnnotations: NormalizedChartSpec = {
|
|
225
|
+
...baseSpec,
|
|
226
|
+
annotations: [{ type: 'text', x: '2021-01-01', y: 20, text: 'Right-edge annotation' }],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const inlineStrategy: LayoutStrategy = {
|
|
230
|
+
labelMode: 'all',
|
|
231
|
+
legendPosition: 'right',
|
|
232
|
+
annotationPosition: 'inline',
|
|
233
|
+
axisLabelDensity: 'full',
|
|
234
|
+
};
|
|
235
|
+
const tooltipOnlyStrategy: LayoutStrategy = {
|
|
236
|
+
labelMode: 'none',
|
|
237
|
+
legendPosition: 'top',
|
|
238
|
+
annotationPosition: 'tooltip-only',
|
|
239
|
+
axisLabelDensity: 'minimal',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const dimsInline = computeDimensions(
|
|
243
|
+
specWithAnnotations,
|
|
244
|
+
{ width: 600, height: 400 },
|
|
245
|
+
emptyLegend,
|
|
246
|
+
lightTheme,
|
|
247
|
+
inlineStrategy,
|
|
248
|
+
);
|
|
249
|
+
const dimsTooltipOnly = computeDimensions(
|
|
250
|
+
specWithAnnotations,
|
|
251
|
+
{ width: 600, height: 400 },
|
|
252
|
+
emptyLegend,
|
|
253
|
+
lightTheme,
|
|
254
|
+
tooltipOnlyStrategy,
|
|
255
|
+
);
|
|
256
|
+
const dimsNoAnnotations = computeDimensions(
|
|
257
|
+
baseSpec,
|
|
258
|
+
{ width: 600, height: 400 },
|
|
259
|
+
emptyLegend,
|
|
260
|
+
lightTheme,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Inline strategy should reserve extra right margin for the annotation
|
|
264
|
+
expect(dimsInline.margins.right).toBeGreaterThan(dimsNoAnnotations.margins.right);
|
|
265
|
+
// Tooltip-only should NOT reserve extra margin (annotations are hidden)
|
|
266
|
+
expect(dimsTooltipOnly.margins.right).toBe(dimsNoAnnotations.margins.right);
|
|
267
|
+
});
|
|
222
268
|
});
|
|
@@ -945,6 +945,34 @@ describe('computeAnnotations', () => {
|
|
|
945
945
|
const moved = nudgedLabel.x !== originalLabel.x || nudgedLabel.y !== originalLabel.y;
|
|
946
946
|
expect(moved).toBe(true);
|
|
947
947
|
});
|
|
948
|
+
|
|
949
|
+
it('preserves connector style when nudged away from obstacle', () => {
|
|
950
|
+
// connector: true means "straight line" - obstacle avoidance should not change it
|
|
951
|
+
const spec = makeSpec([
|
|
952
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Explicit connector', connector: true },
|
|
953
|
+
]);
|
|
954
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
955
|
+
|
|
956
|
+
const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
957
|
+
const originalLabel = withoutObstacles[0].label!;
|
|
958
|
+
expect(originalLabel.connector).toBeDefined();
|
|
959
|
+
expect(originalLabel.connector!.style).toBe('straight');
|
|
960
|
+
|
|
961
|
+
// Place obstacle directly on the annotation to force a nudge
|
|
962
|
+
const obstacle: Rect = {
|
|
963
|
+
x: originalLabel.x - 5,
|
|
964
|
+
y: originalLabel.y - 5,
|
|
965
|
+
width: 80,
|
|
966
|
+
height: 30,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
|
|
970
|
+
obstacle,
|
|
971
|
+
]);
|
|
972
|
+
const nudgedLabel = withObstacles[0].label!;
|
|
973
|
+
expect(nudgedLabel.connector).toBeDefined();
|
|
974
|
+
expect(nudgedLabel.connector!.style).toBe('straight');
|
|
975
|
+
});
|
|
948
976
|
});
|
|
949
977
|
|
|
950
978
|
// -----------------------------------------------------------------
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -17,7 +17,13 @@ export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
17
17
|
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
-
const labels = computeBarLabels(
|
|
20
|
+
const labels = computeBarLabels(
|
|
21
|
+
marks,
|
|
22
|
+
chartArea,
|
|
23
|
+
spec.labels.density,
|
|
24
|
+
spec.labels.format,
|
|
25
|
+
spec.labels.prefix,
|
|
26
|
+
);
|
|
21
27
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
22
28
|
marks[i].label = labels[i];
|
|
23
29
|
}
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -82,6 +82,7 @@ export function computeBarLabels(
|
|
|
82
82
|
_chartArea: { x: number; y: number; width: number; height: number },
|
|
83
83
|
density: LabelDensity = 'auto',
|
|
84
84
|
labelFormat?: string,
|
|
85
|
+
labelPrefix?: string,
|
|
85
86
|
): ResolvedLabel[] {
|
|
86
87
|
// 'none': no labels at all
|
|
87
88
|
if (density === 'none') return [];
|
|
@@ -112,6 +113,7 @@ export function computeBarLabels(
|
|
|
112
113
|
const num = parseDisplayNumber(rawValue);
|
|
113
114
|
if (!Number.isNaN(num)) valuePart = formatter(num);
|
|
114
115
|
}
|
|
116
|
+
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
115
117
|
|
|
116
118
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
117
119
|
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
@@ -17,7 +17,13 @@ export const columnRenderer: ChartRenderer = (spec, scales, chartArea, strategy,
|
|
|
17
17
|
const marks = computeColumnMarks(spec, scales, chartArea, strategy);
|
|
18
18
|
|
|
19
19
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
|
-
const labels = computeColumnLabels(
|
|
20
|
+
const labels = computeColumnLabels(
|
|
21
|
+
marks,
|
|
22
|
+
chartArea,
|
|
23
|
+
spec.labels.density,
|
|
24
|
+
spec.labels.format,
|
|
25
|
+
spec.labels.prefix,
|
|
26
|
+
);
|
|
21
27
|
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
22
28
|
marks[i].label = labels[i];
|
|
23
29
|
}
|
|
@@ -45,6 +45,7 @@ export function computeColumnLabels(
|
|
|
45
45
|
_chartArea: { x: number; y: number; width: number; height: number },
|
|
46
46
|
density: LabelDensity = 'auto',
|
|
47
47
|
labelFormat?: string,
|
|
48
|
+
labelPrefix?: string,
|
|
48
49
|
): ResolvedLabel[] {
|
|
49
50
|
// 'none': no labels at all
|
|
50
51
|
if (density === 'none') return [];
|
|
@@ -72,6 +73,7 @@ export function computeColumnLabels(
|
|
|
72
73
|
const num = Number(rawValue.replace(/[^0-9.-]/g, ''));
|
|
73
74
|
if (!Number.isNaN(num)) valuePart = formatter(num);
|
|
74
75
|
}
|
|
76
|
+
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
75
77
|
|
|
76
78
|
const numericValue = parseFloat(valuePart);
|
|
77
79
|
const isNegative = Number.isFinite(numericValue) && numericValue < 0;
|
package/src/charts/dot/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export const dotRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _t
|
|
|
26
26
|
const pointMarks = marks.filter((m): m is PointMark => m.type === 'point');
|
|
27
27
|
|
|
28
28
|
// Compute and attach labels to point marks (respects spec.labels.density)
|
|
29
|
-
const labels = computeDotLabels(pointMarks, chartArea, spec.labels.density);
|
|
29
|
+
const labels = computeDotLabels(pointMarks, chartArea, spec.labels.density, spec.labels.prefix);
|
|
30
30
|
let labelIdx = 0;
|
|
31
31
|
for (const mark of marks) {
|
|
32
32
|
if (mark.type === 'point' && labelIdx < labels.length) {
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -40,6 +40,7 @@ export function computeDotLabels(
|
|
|
40
40
|
marks: PointMark[],
|
|
41
41
|
_chartArea: Rect,
|
|
42
42
|
density: LabelDensity = 'auto',
|
|
43
|
+
labelPrefix?: string,
|
|
43
44
|
): ResolvedLabel[] {
|
|
44
45
|
// 'none': no labels at all
|
|
45
46
|
if (density === 'none') return [];
|
|
@@ -55,8 +56,9 @@ export function computeDotLabels(
|
|
|
55
56
|
// Format is "category: value". Use the last colon to handle colons in category names.
|
|
56
57
|
const ariaLabel = mark.aria.label;
|
|
57
58
|
const lastColon = ariaLabel.lastIndexOf(':');
|
|
58
|
-
|
|
59
|
+
let valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
|
|
59
60
|
if (!valuePart) continue;
|
|
61
|
+
if (labelPrefix) valuePart = labelPrefix + valuePart;
|
|
60
62
|
|
|
61
63
|
const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
62
64
|
const textHeight = LABEL_FONT_SIZE * 1.2;
|
package/src/compile.ts
CHANGED
|
@@ -92,6 +92,7 @@ import { computeDimensions } from './layout/dimensions';
|
|
|
92
92
|
import { computeGridlines } from './layout/gridlines';
|
|
93
93
|
import { computeScales, type ResolvedScales } from './layout/scales';
|
|
94
94
|
import { computeLegend } from './legend/compute';
|
|
95
|
+
import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
|
|
95
96
|
import { compileTableLayout } from './tables/compile-table';
|
|
96
97
|
import { computeTooltipDescriptors } from './tooltips/compute';
|
|
97
98
|
import { runTransforms } from './transforms';
|
|
@@ -212,6 +213,12 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
212
213
|
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'graph') {
|
|
213
214
|
throw new Error('compileChart received a graph spec. Use compileGraph instead.');
|
|
214
215
|
}
|
|
216
|
+
if (
|
|
217
|
+
'type' in normalized &&
|
|
218
|
+
(normalized as unknown as Record<string, unknown>).type === 'sankey'
|
|
219
|
+
) {
|
|
220
|
+
throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
|
|
221
|
+
}
|
|
215
222
|
|
|
216
223
|
let chartSpec = normalized as NormalizedChartSpec;
|
|
217
224
|
|
|
@@ -712,3 +719,26 @@ export function compileTable(spec: unknown, options: CompileTableOptions): Table
|
|
|
712
719
|
export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
|
|
713
720
|
return compileGraphImpl(spec, options);
|
|
714
721
|
}
|
|
722
|
+
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// Sankey compilation
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Compile a sankey spec into a SankeyLayout.
|
|
729
|
+
*
|
|
730
|
+
* Takes a raw sankey spec, validates, normalizes, resolves theme and chrome,
|
|
731
|
+
* runs the d3-sankey layout algorithm, builds node/link marks with colors and
|
|
732
|
+
* labels, and returns a SankeyLayout ready for rendering.
|
|
733
|
+
*
|
|
734
|
+
* @param spec - Raw sankey spec (validated and normalized internally).
|
|
735
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
736
|
+
* @returns SankeyLayout with computed positions and visual properties.
|
|
737
|
+
* @throws Error if spec is invalid or not a sankey type.
|
|
738
|
+
*/
|
|
739
|
+
export function compileSankey(
|
|
740
|
+
spec: unknown,
|
|
741
|
+
options: CompileOptions,
|
|
742
|
+
): import('@opendata-ai/openchart-core').SankeyLayout {
|
|
743
|
+
return compileSankeyImpl(spec, options);
|
|
744
|
+
}
|
|
@@ -44,7 +44,12 @@ describe('normalizeSpec', () => {
|
|
|
44
44
|
expect(result.darkMode).toBe('off');
|
|
45
45
|
expect(result.annotations).toEqual([]);
|
|
46
46
|
expect(result.theme).toEqual({});
|
|
47
|
-
expect(result.labels).toEqual({
|
|
47
|
+
expect(result.labels).toEqual({
|
|
48
|
+
density: 'auto',
|
|
49
|
+
format: '',
|
|
50
|
+
prefix: '',
|
|
51
|
+
offsets: undefined,
|
|
52
|
+
});
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
it('preserves explicit values', () => {
|
|
@@ -139,7 +144,12 @@ describe('normalizeSpec', () => {
|
|
|
139
144
|
labels: { density: 'none', format: ',.0f' },
|
|
140
145
|
};
|
|
141
146
|
const result = normalizeSpec(spec) as NormalizedChartSpec;
|
|
142
|
-
expect(result.labels).toEqual({
|
|
147
|
+
expect(result.labels).toEqual({
|
|
148
|
+
density: 'none',
|
|
149
|
+
format: ',.0f',
|
|
150
|
+
prefix: '',
|
|
151
|
+
offsets: undefined,
|
|
152
|
+
});
|
|
143
153
|
});
|
|
144
154
|
|
|
145
155
|
it('fills partial label config with defaults', () => {
|
|
@@ -148,7 +158,12 @@ describe('normalizeSpec', () => {
|
|
|
148
158
|
labels: { density: 'endpoints' },
|
|
149
159
|
};
|
|
150
160
|
const result = normalizeSpec(spec) as NormalizedChartSpec;
|
|
151
|
-
expect(result.labels).toEqual({
|
|
161
|
+
expect(result.labels).toEqual({
|
|
162
|
+
density: 'endpoints',
|
|
163
|
+
format: '',
|
|
164
|
+
prefix: '',
|
|
165
|
+
offsets: undefined,
|
|
166
|
+
});
|
|
152
167
|
});
|
|
153
168
|
|
|
154
169
|
it('normalizes annotations with default styles', () => {
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
FieldType,
|
|
19
19
|
GraphSpec,
|
|
20
20
|
LayerSpec,
|
|
21
|
+
SankeySpec,
|
|
21
22
|
TableSpec,
|
|
22
23
|
VizSpec,
|
|
23
24
|
} from '@opendata-ai/openchart-core';
|
|
@@ -25,11 +26,12 @@ import {
|
|
|
25
26
|
isChartSpec,
|
|
26
27
|
isGraphSpec,
|
|
27
28
|
isLayerSpec,
|
|
29
|
+
isSankeySpec,
|
|
28
30
|
isTableSpec,
|
|
29
31
|
resolveMarkDef,
|
|
30
32
|
resolveMarkType,
|
|
31
33
|
} from '@opendata-ai/openchart-core';
|
|
32
|
-
|
|
34
|
+
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
33
35
|
import type {
|
|
34
36
|
NormalizedChartSpec,
|
|
35
37
|
NormalizedChrome,
|
|
@@ -204,6 +206,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
204
206
|
labels: {
|
|
205
207
|
density: spec.labels?.density ?? 'auto',
|
|
206
208
|
format: spec.labels?.format ?? '',
|
|
209
|
+
prefix: spec.labels?.prefix ?? '',
|
|
207
210
|
offsets: spec.labels?.offsets,
|
|
208
211
|
},
|
|
209
212
|
legend: spec.legend,
|
|
@@ -233,6 +236,24 @@ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTab
|
|
|
233
236
|
};
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedSankeySpec {
|
|
240
|
+
return {
|
|
241
|
+
type: 'sankey',
|
|
242
|
+
data: spec.data,
|
|
243
|
+
encoding: spec.encoding,
|
|
244
|
+
nodeWidth: spec.nodeWidth ?? 12,
|
|
245
|
+
nodePadding: spec.nodePadding ?? 16,
|
|
246
|
+
nodeAlign: spec.nodeAlign ?? 'justify',
|
|
247
|
+
iterations: spec.iterations ?? 6,
|
|
248
|
+
linkStyle: spec.linkStyle ?? 'gradient',
|
|
249
|
+
chrome: normalizeChrome(spec.chrome),
|
|
250
|
+
legend: spec.legend,
|
|
251
|
+
theme: spec.theme ?? {},
|
|
252
|
+
darkMode: spec.darkMode ?? 'off',
|
|
253
|
+
animation: spec.animation,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
236
257
|
function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGraphSpec {
|
|
237
258
|
// Default layout with chargeStrength and linkDistance
|
|
238
259
|
const defaultLayout = {
|
|
@@ -292,9 +313,12 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
|
|
|
292
313
|
if (isGraphSpec(spec)) {
|
|
293
314
|
return normalizeGraphSpec(spec, warnings);
|
|
294
315
|
}
|
|
316
|
+
if (isSankeySpec(spec)) {
|
|
317
|
+
return normalizeSankeySpec(spec, warnings);
|
|
318
|
+
}
|
|
295
319
|
// Should never happen after validation
|
|
296
320
|
throw new Error(
|
|
297
|
-
`Unknown spec shape. Expected mark (chart), layer, type: 'table', or type: '
|
|
321
|
+
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', or type: 'sankey'.`,
|
|
298
322
|
);
|
|
299
323
|
}
|
|
300
324
|
|
package/src/compiler/types.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
ScaleConfig,
|
|
29
29
|
ThemeConfig,
|
|
30
30
|
} from '@opendata-ai/openchart-core';
|
|
31
|
+
import type { NormalizedSankeySpec } from '../sankey/types';
|
|
31
32
|
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
34
|
// NormalizedChrome: all fields are ChromeText objects (not plain strings)
|
|
@@ -69,8 +70,9 @@ export interface NormalizedChartSpec {
|
|
|
69
70
|
encoding: Encoding;
|
|
70
71
|
chrome: NormalizedChrome;
|
|
71
72
|
annotations: Annotation[];
|
|
72
|
-
/** Normalized label configuration with defaults applied. density and
|
|
73
|
-
labels: Required<Pick<LabelConfig, 'density' | 'format'
|
|
73
|
+
/** Normalized label configuration with defaults applied. density, format, and prefix are always set; offsets stays optional. */
|
|
74
|
+
labels: Required<Pick<LabelConfig, 'density' | 'format' | 'prefix'>> &
|
|
75
|
+
Pick<LabelConfig, 'offsets'>;
|
|
74
76
|
/** Legend configuration (position override). */
|
|
75
77
|
legend?: LegendConfig;
|
|
76
78
|
responsive: boolean;
|
|
@@ -114,7 +116,11 @@ export interface NormalizedGraphSpec {
|
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
/** Discriminated union of all normalized spec types. */
|
|
117
|
-
export type NormalizedSpec =
|
|
119
|
+
export type NormalizedSpec =
|
|
120
|
+
| NormalizedChartSpec
|
|
121
|
+
| NormalizedTableSpec
|
|
122
|
+
| NormalizedGraphSpec
|
|
123
|
+
| NormalizedSankeySpec;
|
|
118
124
|
|
|
119
125
|
// ---------------------------------------------------------------------------
|
|
120
126
|
// Validation types
|
package/src/compiler/validate.ts
CHANGED
|
@@ -544,6 +544,107 @@ function validateGraphSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
544
544
|
}
|
|
545
545
|
}
|
|
546
546
|
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
// Sankey validation
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
function validateSankeySpec(spec: Record<string, unknown>, errors: ValidationError[]): void {
|
|
552
|
+
// Validate data
|
|
553
|
+
if (!Array.isArray(spec.data)) {
|
|
554
|
+
errors.push({
|
|
555
|
+
message: 'Spec error: sankey spec requires a "data" array',
|
|
556
|
+
path: 'data',
|
|
557
|
+
code: 'INVALID_TYPE',
|
|
558
|
+
suggestion:
|
|
559
|
+
'Provide data as an array of objects, e.g. data: [{ source: "A", target: "B", value: 10 }]',
|
|
560
|
+
});
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (spec.data.length === 0) {
|
|
565
|
+
errors.push({
|
|
566
|
+
message: 'Spec error: "data" must be a non-empty array',
|
|
567
|
+
path: 'data',
|
|
568
|
+
code: 'EMPTY_DATA',
|
|
569
|
+
suggestion: 'Add at least one data row, e.g. data: [{ source: "A", target: "B", value: 10 }]',
|
|
570
|
+
});
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const firstRow = spec.data[0] as unknown;
|
|
575
|
+
if (typeof firstRow !== 'object' || firstRow === null || Array.isArray(firstRow)) {
|
|
576
|
+
errors.push({
|
|
577
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
578
|
+
path: 'data[0]',
|
|
579
|
+
code: 'INVALID_TYPE',
|
|
580
|
+
suggestion:
|
|
581
|
+
'Each data item should be an object, e.g. { source: "A", target: "B", value: 10 }',
|
|
582
|
+
});
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Validate encoding
|
|
587
|
+
if (!spec.encoding || typeof spec.encoding !== 'object') {
|
|
588
|
+
errors.push({
|
|
589
|
+
message:
|
|
590
|
+
'Spec error: sankey spec requires an "encoding" object with source, target, and value channels',
|
|
591
|
+
path: 'encoding',
|
|
592
|
+
code: 'MISSING_FIELD',
|
|
593
|
+
suggestion:
|
|
594
|
+
'Add an encoding object, e.g. encoding: { source: { field: "source", type: "nominal" }, target: { field: "target", type: "nominal" }, value: { field: "value", type: "quantitative" } }',
|
|
595
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const encoding = spec.encoding as Record<string, unknown>;
|
|
600
|
+
const dataColumns = new Set(Object.keys(firstRow as Record<string, unknown>));
|
|
601
|
+
const availableColumns = [...dataColumns].join(', ');
|
|
602
|
+
|
|
603
|
+
// Required channels
|
|
604
|
+
for (const channel of ['source', 'target', 'value'] as const) {
|
|
605
|
+
const ch = encoding[channel] as Record<string, unknown> | undefined;
|
|
606
|
+
if (!ch || typeof ch !== 'object') {
|
|
607
|
+
errors.push({
|
|
608
|
+
message: `Spec error: sankey encoding requires "${channel}" channel`,
|
|
609
|
+
path: `encoding.${channel}`,
|
|
610
|
+
code: 'MISSING_FIELD',
|
|
611
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}). Example: ${channel}: { field: "${[...dataColumns][0] ?? 'myField'}", type: "${channel === 'value' ? 'quantitative' : 'nominal'}" }`,
|
|
612
|
+
});
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!ch.field || typeof ch.field !== 'string') {
|
|
617
|
+
errors.push({
|
|
618
|
+
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
619
|
+
path: `encoding.${channel}.field`,
|
|
620
|
+
code: 'MISSING_FIELD',
|
|
621
|
+
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
622
|
+
});
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!dataColumns.has(ch.field as string)) {
|
|
627
|
+
errors.push({
|
|
628
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
629
|
+
path: `encoding.${channel}.field`,
|
|
630
|
+
code: 'DATA_FIELD_MISSING',
|
|
631
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Validate darkMode if provided
|
|
637
|
+
if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) {
|
|
638
|
+
errors.push({
|
|
639
|
+
message: 'Spec error: darkMode must be "auto", "force", or "off"',
|
|
640
|
+
path: 'darkMode',
|
|
641
|
+
code: 'INVALID_VALUE',
|
|
642
|
+
suggestion:
|
|
643
|
+
'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)',
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
547
648
|
// ---------------------------------------------------------------------------
|
|
548
649
|
// Layer validation
|
|
549
650
|
// ---------------------------------------------------------------------------
|
|
@@ -678,19 +779,20 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
678
779
|
const hasMark = 'mark' in obj;
|
|
679
780
|
const isTable = obj.type === 'table';
|
|
680
781
|
const isGraph = obj.type === 'graph';
|
|
681
|
-
const
|
|
682
|
-
const
|
|
782
|
+
const isSankey = obj.type === 'sankey';
|
|
783
|
+
const isLayer = hasLayer && !isTable && !isGraph && !isSankey;
|
|
784
|
+
const isChart = hasMark && !hasLayer && !isTable && !isGraph && !isSankey;
|
|
683
785
|
|
|
684
|
-
if (!isChart && !isTable && !isGraph && !isLayer) {
|
|
786
|
+
if (!isChart && !isTable && !isGraph && !isSankey && !isLayer) {
|
|
685
787
|
return {
|
|
686
788
|
valid: false,
|
|
687
789
|
errors: [
|
|
688
790
|
{
|
|
689
791
|
message:
|
|
690
|
-
'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs',
|
|
792
|
+
'Spec error: spec must have a "mark" field for charts, a "layer" array for layered charts, or a "type" field for tables/graphs/sankey',
|
|
691
793
|
path: 'mark',
|
|
692
794
|
code: 'MISSING_FIELD',
|
|
693
|
-
suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs (type: "table" or type: "
|
|
795
|
+
suggestion: `Add a "mark" field for charts (e.g. mark: "bar"), a "layer" array for layered charts, or a "type" field for tables/graphs/sankey (type: "table", type: "graph", or type: "sankey"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
|
|
694
796
|
},
|
|
695
797
|
],
|
|
696
798
|
normalized: null,
|
|
@@ -733,6 +835,8 @@ export function validateSpec(spec: unknown): ValidationResult {
|
|
|
733
835
|
validateTableSpec(obj, errors);
|
|
734
836
|
} else if (isGraph) {
|
|
735
837
|
validateGraphSpec(obj, errors);
|
|
838
|
+
} else if (isSankey) {
|
|
839
|
+
validateSankeySpec(obj, errors);
|
|
736
840
|
}
|
|
737
841
|
|
|
738
842
|
if (errors.length > 0) {
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Main compile API
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
export { compileChart, compileGraph, compileLayer, compileTable } from './compile';
|
|
15
|
+
export { compileChart, compileGraph, compileLayer, compileSankey, compileTable } from './compile';
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
18
18
|
// Animation resolution
|
|
@@ -31,6 +31,12 @@ export type {
|
|
|
31
31
|
SimulationConfig,
|
|
32
32
|
} from './graphs/types';
|
|
33
33
|
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Sankey compilation types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export type { NormalizedSankeySpec } from './sankey/types';
|
|
39
|
+
|
|
34
40
|
// ---------------------------------------------------------------------------
|
|
35
41
|
// Compiler pipeline (spec validation, normalization, generic compile)
|
|
36
42
|
// ---------------------------------------------------------------------------
|
|
@@ -90,6 +96,8 @@ export type {
|
|
|
90
96
|
GraphLayout,
|
|
91
97
|
GraphSpec,
|
|
92
98
|
LayerSpec,
|
|
99
|
+
SankeyLayout,
|
|
100
|
+
SankeySpec,
|
|
93
101
|
TableLayout,
|
|
94
102
|
TableSpec,
|
|
95
103
|
VizSpec,
|