@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.5.2",
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.5.2",
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
  // -----------------------------------------------------------------
@@ -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(marks, chartArea, spec.labels.density, spec.labels.format);
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
  }
@@ -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(marks, chartArea, spec.labels.density, spec.labels.format);
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;
@@ -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) {
@@ -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
- const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : '';
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({ density: 'auto', format: '', offsets: undefined });
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({ density: 'none', format: ',.0f', offsets: undefined });
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({ density: 'endpoints', format: '', offsets: undefined });
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: 'graph'.`,
321
+ `Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', or type: 'sankey'.`,
298
322
  );
299
323
  }
300
324
 
@@ -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 format are always set; offsets stays optional. */
73
- labels: Required<Pick<LabelConfig, 'density' | 'format'>> & Pick<LabelConfig, 'offsets'>;
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 = NormalizedChartSpec | NormalizedTableSpec | NormalizedGraphSpec;
119
+ export type NormalizedSpec =
120
+ | NormalizedChartSpec
121
+ | NormalizedTableSpec
122
+ | NormalizedGraphSpec
123
+ | NormalizedSankeySpec;
118
124
 
119
125
  // ---------------------------------------------------------------------------
120
126
  // Validation types
@@ -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 isLayer = hasLayer && !isTable && !isGraph;
682
- const isChart = hasMark && !hasLayer && !isTable && !isGraph;
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: "graph"). Valid mark types: ${[...MARK_TYPES].join(', ')}`,
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,