@opendata-ai/openchart-engine 6.11.0 → 6.13.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.
Files changed (45) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +944 -629
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +3 -0
  6. package/src/__tests__/axes.test.ts +12 -30
  7. package/src/__tests__/compile-chart.test.ts +4 -4
  8. package/src/__tests__/dimensions.test.ts +2 -2
  9. package/src/__tests__/encoding-sugar.test.ts +389 -0
  10. package/src/annotations/collisions.ts +268 -0
  11. package/src/annotations/compute.ts +9 -912
  12. package/src/annotations/constants.ts +32 -0
  13. package/src/annotations/geometry.ts +167 -0
  14. package/src/annotations/position.ts +95 -0
  15. package/src/annotations/resolve-range.ts +98 -0
  16. package/src/annotations/resolve-refline.ts +148 -0
  17. package/src/annotations/resolve-text.ts +134 -0
  18. package/src/charts/__tests__/post-process.test.ts +258 -0
  19. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  20. package/src/charts/bar/compute.ts +27 -6
  21. package/src/charts/bar/labels.ts +7 -1
  22. package/src/charts/column/__tests__/compute.test.ts +99 -0
  23. package/src/charts/column/compute.ts +27 -6
  24. package/src/charts/line/area.ts +19 -2
  25. package/src/charts/post-process.ts +215 -0
  26. package/src/compile.ts +113 -169
  27. package/src/compiler/__tests__/normalize.test.ts +110 -0
  28. package/src/compiler/normalize.ts +22 -3
  29. package/src/compiler/types.ts +4 -0
  30. package/src/graphs/compile-graph.ts +8 -0
  31. package/src/graphs/types.ts +2 -0
  32. package/src/layout/axes.ts +10 -13
  33. package/src/layout/dimensions.ts +6 -3
  34. package/src/layout/scales.ts +106 -29
  35. package/src/legend/compute.ts +3 -1
  36. package/src/sankey/compile-sankey.ts +12 -2
  37. package/src/sankey/types.ts +1 -0
  38. package/src/tables/compile-table.ts +5 -0
  39. package/src/tooltips/__tests__/compute.test.ts +188 -0
  40. package/src/tooltips/compute.ts +25 -11
  41. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  42. package/src/transforms/__tests__/fold.test.ts +79 -0
  43. package/src/transforms/aggregate.ts +130 -0
  44. package/src/transforms/fold.ts +49 -0
  45. package/src/transforms/index.ts +8 -0
@@ -1,12 +1,15 @@
1
1
  import type {
2
2
  ChartSpec,
3
3
  GraphSpec,
4
+ LayerSpec,
4
5
  RangeAnnotation,
5
6
  RefLineAnnotation,
7
+ SankeySpec,
6
8
  TableSpec,
7
9
  TextAnnotation,
8
10
  } from '@opendata-ai/openchart-core';
9
11
  import { describe, expect, it } from 'vitest';
12
+ import type { NormalizedSankeySpec } from '../../sankey/types';
10
13
  import { normalizeSpec } from '../normalize';
11
14
  import type { NormalizedChartSpec, NormalizedGraphSpec, NormalizedTableSpec } from '../types';
12
15
 
@@ -52,6 +55,17 @@ describe('normalizeSpec', () => {
52
55
  });
53
56
  });
54
57
 
58
+ it('watermark defaults to true when not specified', () => {
59
+ const result = normalizeSpec(lineSpec) as NormalizedChartSpec;
60
+ expect(result.watermark).toBe(true);
61
+ });
62
+
63
+ it('watermark respects explicit false', () => {
64
+ const spec: ChartSpec = { ...lineSpec, watermark: false };
65
+ const result = normalizeSpec(spec) as NormalizedChartSpec;
66
+ expect(result.watermark).toBe(false);
67
+ });
68
+
55
69
  it('preserves explicit values', () => {
56
70
  const spec: ChartSpec = {
57
71
  ...lineSpec,
@@ -205,6 +219,27 @@ describe('normalizeSpec', () => {
205
219
  expect(result.responsive).toBe(true);
206
220
  expect(result.darkMode).toBe('off');
207
221
  });
222
+
223
+ it('watermark defaults to true when not specified', () => {
224
+ const spec: TableSpec = {
225
+ type: 'table',
226
+ data: [{ name: 'Alice', age: 30 }],
227
+ columns: [{ key: 'name' }, { key: 'age' }],
228
+ };
229
+ const result = normalizeSpec(spec) as NormalizedTableSpec;
230
+ expect(result.watermark).toBe(true);
231
+ });
232
+
233
+ it('watermark respects explicit false', () => {
234
+ const spec: TableSpec = {
235
+ type: 'table',
236
+ data: [{ name: 'Alice', age: 30 }],
237
+ columns: [{ key: 'name' }, { key: 'age' }],
238
+ watermark: false,
239
+ };
240
+ const result = normalizeSpec(spec) as NormalizedTableSpec;
241
+ expect(result.watermark).toBe(false);
242
+ });
208
243
  });
209
244
 
210
245
  describe('graph spec normalization', () => {
@@ -221,5 +256,80 @@ describe('normalizeSpec', () => {
221
256
  expect(result.annotations).toEqual([]);
222
257
  expect(result.darkMode).toBe('off');
223
258
  });
259
+
260
+ it('watermark defaults to true when not specified', () => {
261
+ const spec: GraphSpec = {
262
+ type: 'graph',
263
+ nodes: [{ id: 'a' }, { id: 'b' }],
264
+ edges: [{ source: 'a', target: 'b' }],
265
+ };
266
+ const result = normalizeSpec(spec) as NormalizedGraphSpec;
267
+ expect(result.watermark).toBe(true);
268
+ });
269
+
270
+ it('watermark respects explicit false', () => {
271
+ const spec: GraphSpec = {
272
+ type: 'graph',
273
+ nodes: [{ id: 'a' }, { id: 'b' }],
274
+ edges: [{ source: 'a', target: 'b' }],
275
+ watermark: false,
276
+ };
277
+ const result = normalizeSpec(spec) as NormalizedGraphSpec;
278
+ expect(result.watermark).toBe(false);
279
+ });
280
+ });
281
+
282
+ describe('sankey spec normalization', () => {
283
+ const baseSankeySpec: SankeySpec = {
284
+ type: 'sankey',
285
+ data: [{ source: 'A', target: 'B', value: 10 }],
286
+ encoding: {
287
+ source: { field: 'source' },
288
+ target: { field: 'target' },
289
+ value: { field: 'value' },
290
+ },
291
+ };
292
+
293
+ it('watermark defaults to true when not specified', () => {
294
+ const result = normalizeSpec(baseSankeySpec) as NormalizedSankeySpec;
295
+ expect(result.watermark).toBe(true);
296
+ });
297
+
298
+ it('watermark respects explicit false', () => {
299
+ const spec: SankeySpec = { ...baseSankeySpec, watermark: false };
300
+ const result = normalizeSpec(spec) as NormalizedSankeySpec;
301
+ expect(result.watermark).toBe(false);
302
+ });
303
+ });
304
+
305
+ describe('layer spec normalization', () => {
306
+ const baseLeaf: ChartSpec = {
307
+ mark: 'line',
308
+ data: [{ x: 1, y: 2 }],
309
+ encoding: {
310
+ x: { field: 'x', type: 'quantitative' },
311
+ y: { field: 'y', type: 'quantitative' },
312
+ },
313
+ };
314
+
315
+ it('watermark defaults to true for layer leaves', () => {
316
+ const layerSpec: LayerSpec = { layer: [baseLeaf] };
317
+ const result = normalizeSpec(layerSpec) as NormalizedChartSpec;
318
+ expect(result.watermark).toBe(true);
319
+ });
320
+
321
+ it('layer-level watermark: false propagates to leaves', () => {
322
+ const layerSpec: LayerSpec = { layer: [baseLeaf], watermark: false };
323
+ const result = normalizeSpec(layerSpec) as NormalizedChartSpec;
324
+ expect(result.watermark).toBe(false);
325
+ });
326
+
327
+ it('leaf-level watermark overrides layer-level', () => {
328
+ const leaf: ChartSpec = { ...baseLeaf, watermark: true };
329
+ const layerSpec: LayerSpec = { layer: [leaf], watermark: false };
330
+ const result = normalizeSpec(layerSpec) as NormalizedChartSpec;
331
+ // Leaf explicitly sets true, which should be preserved
332
+ expect(result.watermark).toBe(true);
333
+ });
224
334
  });
225
335
  });
@@ -69,8 +69,8 @@ function normalizeChrome(chrome: Chrome | undefined): NormalizedChrome {
69
69
 
70
70
  /** Sample values from a data column and infer the field type. */
71
71
  function inferFieldType(data: DataRow[], field: string): FieldType {
72
- // Sample up to 10 rows
73
- const sampleSize = Math.min(10, data.length);
72
+ // Sample up to 50 rows for more reliable inference on mixed/messy data
73
+ const sampleSize = Math.min(50, data.length);
74
74
  let numericCount = 0;
75
75
  let dateCount = 0;
76
76
  let totalNonNull = 0;
@@ -215,6 +215,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
215
215
  darkMode: spec.darkMode ?? 'off',
216
216
  hiddenSeries: spec.hiddenSeries ?? [],
217
217
  seriesStyles: spec.seriesStyles ?? {},
218
+ watermark: spec.watermark ?? true,
218
219
  };
219
220
  }
220
221
 
@@ -233,6 +234,7 @@ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTab
233
234
  compact: spec.compact ?? false,
234
235
  responsive: spec.responsive ?? true,
235
236
  animation: spec.animation,
237
+ watermark: spec.watermark ?? true,
236
238
  };
237
239
  }
238
240
 
@@ -254,6 +256,7 @@ function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedS
254
256
  animation: spec.animation,
255
257
  valueFormat: spec.valueFormat,
256
258
  linkOpacity: spec.linkOpacity,
259
+ watermark: spec.watermark ?? true,
257
260
  };
258
261
  }
259
262
 
@@ -282,6 +285,7 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
282
285
  annotations: normalizeAnnotations(spec.annotations),
283
286
  theme: spec.theme ?? {},
284
287
  darkMode: spec.darkMode ?? 'off',
288
+ watermark: spec.watermark ?? true,
285
289
  };
286
290
  }
287
291
 
@@ -338,6 +342,7 @@ export function flattenLayers(
338
342
  parentData?: DataRow[],
339
343
  parentEncoding?: Encoding,
340
344
  parentTransforms?: import('@opendata-ai/openchart-core').Transform[],
345
+ parentWatermark?: boolean,
341
346
  ): ChartSpec[] {
342
347
  const resolvedData = spec.data ?? parentData;
343
348
  const resolvedEncoding: Encoding | undefined =
@@ -345,13 +350,23 @@ export function flattenLayers(
345
350
  ? { ...parentEncoding, ...spec.encoding }
346
351
  : (spec.encoding ?? parentEncoding);
347
352
  const resolvedTransforms = [...(parentTransforms ?? []), ...(spec.transform ?? [])];
353
+ // Layer-level watermark propagates to children (child can still override)
354
+ const resolvedWatermark = spec.watermark ?? parentWatermark;
348
355
 
349
356
  const leaves: ChartSpec[] = [];
350
357
 
351
358
  for (const child of spec.layer) {
352
359
  if (isLayerSpec(child)) {
353
360
  // Nested layer: recurse with merged context
354
- leaves.push(...flattenLayers(child, resolvedData, resolvedEncoding, resolvedTransforms));
361
+ leaves.push(
362
+ ...flattenLayers(
363
+ child,
364
+ resolvedData,
365
+ resolvedEncoding,
366
+ resolvedTransforms,
367
+ resolvedWatermark,
368
+ ),
369
+ );
355
370
  } else {
356
371
  // Leaf ChartSpec: merge inherited properties
357
372
  const mergedData = child.data ?? resolvedData ?? [];
@@ -365,6 +380,10 @@ export function flattenLayers(
365
380
  data: mergedData,
366
381
  encoding: mergedEncoding,
367
382
  transform: mergedTransforms.length > 0 ? mergedTransforms : undefined,
383
+ // Inherit parent watermark if child doesn't explicitly set one
384
+ ...(child.watermark === undefined && resolvedWatermark !== undefined
385
+ ? { watermark: resolvedWatermark }
386
+ : {}),
368
387
  });
369
388
  }
370
389
  }
@@ -78,6 +78,8 @@ export interface NormalizedChartSpec {
78
78
  responsive: boolean;
79
79
  theme: ThemeConfig;
80
80
  darkMode: DarkMode;
81
+ /** Whether the tryOpenData.ai watermark is enabled. */
82
+ watermark: boolean;
81
83
  /** Series names to hide from rendering. */
82
84
  hiddenSeries: string[];
83
85
  /** Per-series visual style overrides. */
@@ -93,6 +95,7 @@ export interface NormalizedTableSpec {
93
95
  chrome: NormalizedChrome;
94
96
  theme: ThemeConfig;
95
97
  darkMode: DarkMode;
98
+ watermark: boolean;
96
99
  search: boolean;
97
100
  pagination: boolean | { pageSize: number };
98
101
  stickyFirstColumn: boolean;
@@ -113,6 +116,7 @@ export interface NormalizedGraphSpec {
113
116
  annotations: Annotation[];
114
117
  theme: ThemeConfig;
115
118
  darkMode: DarkMode;
119
+ watermark: boolean;
116
120
  }
117
121
 
118
122
  /** Discriminated union of all normalized spec types. */
@@ -194,6 +194,10 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
194
194
 
195
195
  const graphSpec = normalized as NormalizedGraphSpec;
196
196
 
197
+ // Resolve watermark: explicit spec value wins, then options fallback, then default true.
198
+ const rawWatermark = (spec as Record<string, unknown>).watermark;
199
+ const watermark = rawWatermark !== undefined ? graphSpec.watermark : (options.watermark ?? true);
200
+
197
201
  // 2. Resolve theme
198
202
  const mergedThemeConfig = options.theme
199
203
  ? { ...graphSpec.theme, ...options.theme }
@@ -288,6 +292,9 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
288
292
  theme,
289
293
  options.width,
290
294
  options.measureText,
295
+ 'full',
296
+ undefined,
297
+ watermark,
291
298
  );
292
299
 
293
300
  // 12. Return compilation
@@ -304,6 +311,7 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
304
311
  height: options.height,
305
312
  },
306
313
  simulationConfig,
314
+ watermark,
307
315
  };
308
316
  }
309
317
 
@@ -101,4 +101,6 @@ export interface GraphCompilation {
101
101
  dimensions: { width: number; height: number };
102
102
  /** Force simulation configuration. */
103
103
  simulationConfig: SimulationConfig;
104
+ /** Whether to show the brand watermark. */
105
+ watermark: boolean;
104
106
  }
@@ -399,8 +399,7 @@ export function computeAxes(
399
399
 
400
400
  // Auto-rotate labels when band scale labels would overlap.
401
401
  // Uses max label width (not average) since one long label is enough to overlap.
402
- // Prefer labelAngle over deprecated tickAngle
403
- let tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
402
+ let tickAngle = axisConfig?.labelAngle;
404
403
  if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
405
404
  const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
406
405
  let maxLabelWidth = 0;
@@ -414,8 +413,7 @@ export function computeAxes(
414
413
  }
415
414
  }
416
415
 
417
- // Prefer title over deprecated label
418
- const axisTitle = axisConfig?.title ?? axisConfig?.label;
416
+ const axisTitle = axisConfig?.title;
419
417
 
420
418
  result.x = {
421
419
  ticks,
@@ -454,21 +452,20 @@ export function computeAxes(
454
452
  allTicks = continuousTicks(scales.y, yDensity);
455
453
  }
456
454
 
457
- // Gridlines use the full tick set (label thinning shouldn't remove gridlines).
458
- const gridlines: Gridline[] = allTicks.map((t) => ({
459
- position: t.position,
460
- major: true,
461
- }));
462
-
463
455
  // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
464
456
  const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
465
457
  const ticks = shouldThin
466
458
  ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
467
459
  : allTicks;
468
460
 
469
- // Prefer title over deprecated label, labelAngle over deprecated tickAngle
470
- const axisTitle = axisConfig?.title ?? axisConfig?.label;
471
- const tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
461
+ // Gridlines match the tick set so every gridline has a label.
462
+ const gridlines: Gridline[] = ticks.map((t) => ({
463
+ position: t.position,
464
+ major: true,
465
+ }));
466
+
467
+ const axisTitle = axisConfig?.title;
468
+ const tickAngle = axisConfig?.labelAngle;
472
469
 
473
470
  result.y = {
474
471
  ticks,
@@ -96,6 +96,7 @@ export function computeDimensions(
96
96
  legendLayout: LegendLayout,
97
97
  theme: ResolvedTheme,
98
98
  strategy?: LayoutStrategy,
99
+ watermark: boolean = true,
99
100
  ): LayoutDimensions {
100
101
  const { width, height } = options;
101
102
 
@@ -111,6 +112,7 @@ export function computeDimensions(
111
112
  options.measureText,
112
113
  chromeMode,
113
114
  padding,
115
+ watermark,
114
116
  );
115
117
 
116
118
  // Start with the total rect
@@ -123,9 +125,9 @@ export function computeDimensions(
123
125
  // Estimate x-axis height below chart area: tick labels sit 14px below,
124
126
  // axis title sits 35px below. These extend past the chart area bottom
125
127
  // and source/footer chrome must be positioned below them.
126
- const xAxis = encoding.x?.axis as (Record<string, unknown> & { tickAngle?: number }) | undefined;
127
- const hasXAxisLabel = !!xAxis?.label;
128
- const xTickAngle = xAxis?.tickAngle;
128
+ const xAxis = encoding.x?.axis as (Record<string, unknown> & { labelAngle?: number }) | undefined;
129
+ const hasXAxisLabel = !!xAxis?.title;
130
+ const xTickAngle = xAxis?.labelAngle;
129
131
 
130
132
  let xAxisHeight: number;
131
133
  if (isRadial) {
@@ -334,6 +336,7 @@ export function computeDimensions(
334
336
  options.measureText,
335
337
  fallbackMode as 'compact' | 'hidden',
336
338
  padding,
339
+ watermark,
337
340
  );
338
341
 
339
342
  // Recalculate top/bottom margins with stripped chrome
@@ -140,6 +140,25 @@ function uniqueStrings(values: unknown[]): string[] {
140
140
  return result;
141
141
  }
142
142
 
143
+ /**
144
+ * Apply sort order to categorical domain values (Vega-Lite aligned).
145
+ * - 'ascending': sort alphabetically/numerically ascending
146
+ * - 'descending': sort descending
147
+ * - null: preserve data order (no sorting)
148
+ * - undefined: ascending (VL default)
149
+ */
150
+ function applyCategoricalSort(
151
+ values: string[],
152
+ sort: 'ascending' | 'descending' | null | undefined,
153
+ ): string[] {
154
+ // null means use data order
155
+ if (sort === null) return values;
156
+
157
+ const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
158
+ if (sort === 'descending') sorted.reverse();
159
+ return sorted;
160
+ }
161
+
143
162
  // ---------------------------------------------------------------------------
144
163
  // Helpers: apply common scale config
145
164
  // ---------------------------------------------------------------------------
@@ -439,7 +458,7 @@ function buildBandScale(
439
458
  ): ResolvedScale {
440
459
  const values = channel.scale?.domain
441
460
  ? (channel.scale.domain as string[])
442
- : uniqueStrings(fieldValues(data, channel.field));
461
+ : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
443
462
 
444
463
  const padding = channel.scale?.padding ?? 0.35;
445
464
  const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
@@ -466,7 +485,7 @@ function buildPointScale(
466
485
  ): ResolvedScale {
467
486
  const values = channel.scale?.domain
468
487
  ? (channel.scale.domain as string[])
469
- : uniqueStrings(fieldValues(data, channel.field));
488
+ : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
470
489
 
471
490
  const padding = channel.scale?.padding ?? 0.5;
472
491
  const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
@@ -484,7 +503,11 @@ function buildOrdinalColorScale(
484
503
  data: DataRow[],
485
504
  palette: string[],
486
505
  ): ResolvedScale {
487
- const values = uniqueStrings(fieldValues(data, channel.field));
506
+ // Use explicit domain if provided, otherwise derive from data
507
+ const explicitDomain = channel.scale?.domain as string[] | undefined;
508
+ const values = explicitDomain
509
+ ? explicitDomain.map(String)
510
+ : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
488
511
 
489
512
  const scale = scaleOrdinal<string>().domain(values).range(palette);
490
513
 
@@ -622,6 +645,7 @@ export function computeScales(
622
645
  // For stacked bars, the x-domain needs the max category sum, not max individual value.
623
646
  // Without this, stacked bars would clip past the chart area.
624
647
  let xData = data;
648
+ let xChannel = encoding.x;
625
649
  const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
626
650
  if (
627
651
  spec.markType === 'bar' &&
@@ -629,25 +653,51 @@ export function computeScales(
629
653
  encoding.x.type === 'quantitative' &&
630
654
  !xStackDisabled
631
655
  ) {
632
- const yField = encoding.y?.field;
633
- const xField = encoding.x.field;
634
- if (yField) {
635
- const sums = new Map<string, number>();
636
- for (const row of data) {
637
- const cat = String(row[yField] ?? '');
638
- const val = Number(row[xField] ?? 0);
639
- if (Number.isFinite(val) && val > 0) {
640
- sums.set(cat, (sums.get(cat) ?? 0) + val);
656
+ if (encoding.x.stack === 'normalize') {
657
+ // Normalize: domain is [0, 1]
658
+ xChannel = { ...encoding.x, scale: { ...encoding.x.scale, domain: [0, 1], nice: false } };
659
+ } else if (encoding.x.stack === 'center') {
660
+ // Center: compute max half-sum for symmetric domain
661
+ const yField = encoding.y?.field;
662
+ const xField = encoding.x.field;
663
+ if (yField) {
664
+ const sums = new Map<string, number>();
665
+ for (const row of data) {
666
+ const cat = String(row[yField] ?? '');
667
+ const val = Number(row[xField] ?? 0);
668
+ if (Number.isFinite(val) && val > 0) {
669
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
670
+ }
671
+ }
672
+ const maxSum = Math.max(...sums.values(), 0);
673
+ const half = maxSum / 2;
674
+ xChannel = {
675
+ ...encoding.x,
676
+ scale: { ...encoding.x.scale, domain: [-half, half], zero: true },
677
+ };
678
+ }
679
+ } else {
680
+ // Zero (default): domain extends to max category sum
681
+ const yField = encoding.y?.field;
682
+ const xField = encoding.x.field;
683
+ if (yField) {
684
+ const sums = new Map<string, number>();
685
+ for (const row of data) {
686
+ const cat = String(row[yField] ?? '');
687
+ const val = Number(row[xField] ?? 0);
688
+ if (Number.isFinite(val) && val > 0) {
689
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
690
+ }
641
691
  }
692
+ const maxSum = Math.max(...sums.values(), 0);
693
+ // Create a synthetic row with the max stack sum so buildLinearScale sees it
694
+ xData = [...data, { [xField]: maxSum } as DataRow];
642
695
  }
643
- const maxSum = Math.max(...sums.values(), 0);
644
- // Create a synthetic row with the max stack sum so buildLinearScale sees it
645
- xData = [...data, { [xField]: maxSum } as DataRow];
646
696
  }
647
697
  }
648
698
 
649
699
  result.x = buildPositionalScale(
650
- encoding.x,
700
+ xChannel,
651
701
  xData,
652
702
  chartArea.x,
653
703
  chartArea.x + chartArea.width,
@@ -662,6 +712,7 @@ export function computeScales(
662
712
  // would clip above the chart area.
663
713
  // Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
664
714
  let yData = data;
715
+ let yChannel = encoding.y;
665
716
  const isVerticalBar =
666
717
  spec.markType === 'bar' &&
667
718
  (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
@@ -673,26 +724,52 @@ export function computeScales(
673
724
  encoding.y.type === 'quantitative' &&
674
725
  !yStackDisabled
675
726
  ) {
676
- const xField = encoding.x?.field;
677
- const yField = encoding.y.field;
678
- if (xField) {
679
- const sums = new Map<string, number>();
680
- for (const row of data) {
681
- const cat = String(row[xField] ?? '');
682
- const val = Number(row[yField] ?? 0);
683
- if (Number.isFinite(val) && val > 0) {
684
- sums.set(cat, (sums.get(cat) ?? 0) + val);
727
+ if (encoding.y.stack === 'normalize') {
728
+ // Normalize: domain is [0, 1] (VL convention)
729
+ yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
730
+ } else if (encoding.y.stack === 'center') {
731
+ // Center: compute max half-sum for symmetric domain
732
+ const xField = encoding.x?.field;
733
+ const yField = encoding.y.field;
734
+ if (xField) {
735
+ const sums = new Map<string, number>();
736
+ for (const row of data) {
737
+ const cat = String(row[xField] ?? '');
738
+ const val = Number(row[yField] ?? 0);
739
+ if (Number.isFinite(val) && val > 0) {
740
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
741
+ }
742
+ }
743
+ const maxSum = Math.max(...sums.values(), 0);
744
+ const half = maxSum / 2;
745
+ yChannel = {
746
+ ...encoding.y,
747
+ scale: { ...encoding.y.scale, domain: [-half, half], zero: true },
748
+ };
749
+ }
750
+ } else {
751
+ // Zero (default): domain extends to max category sum
752
+ const xField = encoding.x?.field;
753
+ const yField = encoding.y.field;
754
+ if (xField) {
755
+ const sums = new Map<string, number>();
756
+ for (const row of data) {
757
+ const cat = String(row[xField] ?? '');
758
+ const val = Number(row[yField] ?? 0);
759
+ if (Number.isFinite(val) && val > 0) {
760
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
761
+ }
685
762
  }
763
+ const maxSum = Math.max(...sums.values(), 0);
764
+ // Create a synthetic row with the max stack sum so buildLinearScale sees it
765
+ yData = [...data, { [yField]: maxSum } as DataRow];
686
766
  }
687
- const maxSum = Math.max(...sums.values(), 0);
688
- // Create a synthetic row with the max stack sum so buildLinearScale sees it
689
- yData = [...data, { [yField]: maxSum } as DataRow];
690
767
  }
691
768
  }
692
769
 
693
770
  // Y axis: range is inverted (SVG y goes down, data y goes up)
694
771
  result.y = buildPositionalScale(
695
- encoding.y,
772
+ yChannel,
696
773
  yData,
697
774
  chartArea.y + chartArea.height,
698
775
  chartArea.y,
@@ -150,6 +150,7 @@ export function computeLegend(
150
150
  strategy: LayoutStrategy,
151
151
  theme: ResolvedTheme,
152
152
  chartArea: Rect,
153
+ watermark: boolean = true,
153
154
  ): LegendLayout {
154
155
  // Legend explicitly hidden via show: false, or height strategy says no legend
155
156
  if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
@@ -259,7 +260,8 @@ export function computeLegend(
259
260
 
260
261
  // Top/bottom-positioned legend: horizontal flow with overflow protection.
261
262
  // Reserve space on the right so legend entries don't overlap the brand watermark.
262
- const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
263
+ const availableWidth =
264
+ chartArea.width - LEGEND_PADDING * 2 - (watermark ? BRAND_RESERVE_WIDTH : 0);
263
265
 
264
266
  // Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
265
267
  if (spec.legend?.symbolLimit != null) {
@@ -211,6 +211,10 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
211
211
 
212
212
  const sankeySpec = normalized as NormalizedSankeySpec;
213
213
 
214
+ // Resolve watermark: explicit spec value wins, then options fallback, then default true.
215
+ const rawWatermark = (spec as Record<string, unknown>).watermark;
216
+ const watermark = rawWatermark !== undefined ? sankeySpec.watermark : (options.watermark ?? true);
217
+
214
218
  // 2. Resolve theme
215
219
  const mergedThemeConfig = options.theme
216
220
  ? { ...sankeySpec.theme, ...options.theme }
@@ -241,6 +245,9 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
241
245
  theme,
242
246
  options.width,
243
247
  options.measureText,
248
+ 'full',
249
+ undefined,
250
+ watermark,
244
251
  );
245
252
 
246
253
  // 4. Compute drawing area (total space minus chrome)
@@ -254,7 +261,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
254
261
 
255
262
  // Guard against negative dimensions
256
263
  if (fullArea.width <= 0 || fullArea.height <= 0) {
257
- return emptyLayout(fullArea, chrome, theme, options);
264
+ return emptyLayout(fullArea, chrome, theme, options, watermark);
258
265
  }
259
266
 
260
267
  // 5. Extract encoding fields
@@ -298,7 +305,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
298
305
  };
299
306
 
300
307
  if (area.height <= 0) {
301
- return emptyLayout(area, chrome, theme, options);
308
+ return emptyLayout(area, chrome, theme, options, watermark);
302
309
  }
303
310
 
304
311
  // 6. Run d3-sankey layout (may re-run once if labels overflow)
@@ -473,6 +480,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
473
480
  height: options.height,
474
481
  },
475
482
  animation: resolvedAnimation,
483
+ watermark,
476
484
  };
477
485
  }
478
486
 
@@ -631,6 +639,7 @@ function emptyLayout(
631
639
  chrome: ReturnType<typeof computeChrome>,
632
640
  theme: ResolvedTheme,
633
641
  options: CompileOptions,
642
+ watermark: boolean,
634
643
  ): SankeyLayout {
635
644
  return {
636
645
  area,
@@ -664,5 +673,6 @@ function emptyLayout(
664
673
  width: options.width,
665
674
  height: options.height,
666
675
  },
676
+ watermark,
667
677
  };
668
678
  }
@@ -33,6 +33,7 @@ export interface NormalizedSankeySpec {
33
33
  legend?: LegendConfig;
34
34
  theme: ThemeConfig;
35
35
  darkMode: DarkMode;
36
+ watermark: boolean;
36
37
  animation?: AnimationSpec;
37
38
  valueFormat?: string;
38
39
  linkOpacity?: number;