@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.5.1",
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.5.1",
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: 'graph'.`,
320
+ `Unknown spec shape. Expected mark (chart), layer, type: 'table', type: 'graph', or type: 'sankey'.`,
298
321
  );
299
322
  }
300
323
 
@@ -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 = NormalizedChartSpec | NormalizedTableSpec | NormalizedGraphSpec;
118
+ export type NormalizedSpec =
119
+ | NormalizedChartSpec
120
+ | NormalizedTableSpec
121
+ | NormalizedGraphSpec
122
+ | NormalizedSankeySpec;
118
123
 
119
124
  // ---------------------------------------------------------------------------
120
125
  // 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,
@@ -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
- if (spec.annotations.length > 0 && encoding.x) {
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;