@opendata-ai/openchart-engine 6.5.1 → 6.6.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 +42 -17
- package/dist/index.js +1331 -363
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/__tests__/dimensions.test.ts +47 -1
- package/src/annotations/__tests__/compute.test.ts +28 -0
- package/src/annotations/compute.ts +0 -8
- package/src/compile.ts +30 -0
- package/src/compiler/normalize.ts +25 -2
- package/src/compiler/types.ts +6 -1
- package/src/compiler/validate.ts +109 -5
- package/src/index.ts +9 -1
- package/src/layout/dimensions.ts +6 -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.6.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.6.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
|
}
|
|
@@ -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
|
// -----------------------------------------------------------------
|
|
@@ -675,14 +675,6 @@ function nudgeAnnotationFromObstacles(
|
|
|
675
675
|
labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
|
|
676
676
|
|
|
677
677
|
if (inBounds) {
|
|
678
|
-
// When nudged vertically (directly above/below the data), use a caret
|
|
679
|
-
// instead of a connector line for a cleaner editorial look.
|
|
680
|
-
if (candidateLabel.connector && dx === 0 && dy !== 0) {
|
|
681
|
-
candidateLabel.connector = {
|
|
682
|
-
...candidateLabel.connector,
|
|
683
|
-
style: 'caret',
|
|
684
|
-
};
|
|
685
|
-
}
|
|
686
678
|
annotation.label = candidateLabel;
|
|
687
679
|
return true;
|
|
688
680
|
}
|
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
|
+
}
|
|
@@ -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,
|
|
@@ -233,6 +235,24 @@ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTab
|
|
|
233
235
|
};
|
|
234
236
|
}
|
|
235
237
|
|
|
238
|
+
function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedSankeySpec {
|
|
239
|
+
return {
|
|
240
|
+
type: 'sankey',
|
|
241
|
+
data: spec.data,
|
|
242
|
+
encoding: spec.encoding,
|
|
243
|
+
nodeWidth: spec.nodeWidth ?? 12,
|
|
244
|
+
nodePadding: spec.nodePadding ?? 16,
|
|
245
|
+
nodeAlign: spec.nodeAlign ?? 'justify',
|
|
246
|
+
iterations: spec.iterations ?? 6,
|
|
247
|
+
linkStyle: spec.linkStyle ?? 'gradient',
|
|
248
|
+
chrome: normalizeChrome(spec.chrome),
|
|
249
|
+
legend: spec.legend,
|
|
250
|
+
theme: spec.theme ?? {},
|
|
251
|
+
darkMode: spec.darkMode ?? 'off',
|
|
252
|
+
animation: spec.animation,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
236
256
|
function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGraphSpec {
|
|
237
257
|
// Default layout with chargeStrength and linkDistance
|
|
238
258
|
const defaultLayout = {
|
|
@@ -292,9 +312,12 @@ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): Normalize
|
|
|
292
312
|
if (isGraphSpec(spec)) {
|
|
293
313
|
return normalizeGraphSpec(spec, warnings);
|
|
294
314
|
}
|
|
315
|
+
if (isSankeySpec(spec)) {
|
|
316
|
+
return normalizeSankeySpec(spec, warnings);
|
|
317
|
+
}
|
|
295
318
|
// Should never happen after validation
|
|
296
319
|
throw new Error(
|
|
297
|
-
`Unknown spec shape. Expected mark (chart), layer, type: 'table', or type: '
|
|
320
|
+
`Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', or type: 'sankey'.`,
|
|
298
321
|
);
|
|
299
322
|
}
|
|
300
323
|
|
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)
|
|
@@ -114,7 +115,11 @@ export interface NormalizedGraphSpec {
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/** Discriminated union of all normalized spec types. */
|
|
117
|
-
export type NormalizedSpec =
|
|
118
|
+
export type NormalizedSpec =
|
|
119
|
+
| NormalizedChartSpec
|
|
120
|
+
| NormalizedTableSpec
|
|
121
|
+
| NormalizedGraphSpec
|
|
122
|
+
| NormalizedSankeySpec;
|
|
118
123
|
|
|
119
124
|
// ---------------------------------------------------------------------------
|
|
120
125
|
// 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,
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -191,7 +191,12 @@ export function computeDimensions(
|
|
|
191
191
|
// Reserve right margin for text annotations near the chart's right edge.
|
|
192
192
|
// Without this, annotation text at the last data point clips outside the SVG.
|
|
193
193
|
// Account for anchor direction and offset.dx to avoid over-reserving space.
|
|
194
|
-
|
|
194
|
+
// Skip when annotations are hidden (tooltip-only at compact breakpoints).
|
|
195
|
+
if (
|
|
196
|
+
strategy?.annotationPosition !== 'tooltip-only' &&
|
|
197
|
+
spec.annotations.length > 0 &&
|
|
198
|
+
encoding.x
|
|
199
|
+
) {
|
|
195
200
|
const xField = encoding.x.field;
|
|
196
201
|
// Find the maximum x value in the data
|
|
197
202
|
let maxX: string | number | undefined;
|