@opendata-ai/openchart-engine 6.10.0 → 6.12.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.10.0",
3
+ "version": "6.12.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,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.10.0",
48
+ "@opendata-ai/openchart-core": "6.12.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -73,6 +73,7 @@ export function makeLineSpec(): NormalizedChartSpec {
73
73
  labels: { density: 'auto', format: '', prefix: '' },
74
74
  hiddenSeries: [],
75
75
  seriesStyles: {},
76
+ watermark: true,
76
77
  };
77
78
  }
78
79
 
@@ -102,6 +103,7 @@ export function makeBarSpec(): NormalizedChartSpec {
102
103
  labels: { density: 'auto', format: '', prefix: '' },
103
104
  hiddenSeries: [],
104
105
  seriesStyles: {},
106
+ watermark: true,
105
107
  };
106
108
  }
107
109
 
@@ -133,5 +135,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
133
135
  labels: { density: 'auto', format: '', prefix: '' },
134
136
  hiddenSeries: [],
135
137
  seriesStyles: {},
138
+ watermark: true,
136
139
  };
137
140
  }
@@ -10,12 +10,13 @@ import type {
10
10
  ConditionalValueDef,
11
11
  DataRow,
12
12
  Encoding,
13
+ GradientDef,
13
14
  LayoutStrategy,
14
15
  MarkAria,
15
16
  Rect,
16
17
  RectMark,
17
18
  } from '@opendata-ai/openchart-core';
18
- import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
19
+ import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
19
20
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
20
21
 
21
22
  import type { NormalizedChartSpec } from '../../compiler/types';
@@ -339,11 +340,14 @@ function computeSimpleBars(
339
340
  const bandY = yScale(category);
340
341
  if (bandY === undefined) continue;
341
342
 
342
- let color: string;
343
+ let color: string | GradientDef;
343
344
  if (conditionalColor) {
344
- color = String(
345
- resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
346
- );
345
+ const resolved = resolveConditionalValue(row, conditionalColor);
346
+ if (resolved != null) {
347
+ color = isGradientDef(resolved) ? resolved : String(resolved);
348
+ } else {
349
+ color = getColor(scales, '__default__');
350
+ }
347
351
  } else if (sequentialColor) {
348
352
  color = getSequentialColor(scales, value);
349
353
  } else {
@@ -20,6 +20,7 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
+ getRepresentativeColor,
23
24
  resolveCollisions,
24
25
  } from '@opendata-ai/openchart-core';
25
26
 
@@ -141,7 +142,7 @@ export function computeBarLabels(
141
142
  } else {
142
143
  // Outside: just past the bar's right edge
143
144
  anchorX = mark.x + mark.width + LABEL_PADDING;
144
- fill = mark.fill;
145
+ fill = getRepresentativeColor(mark.fill);
145
146
  textAnchor = 'start';
146
147
  }
147
148
 
@@ -14,12 +14,13 @@ import type {
14
14
  ConditionalValueDef,
15
15
  DataRow,
16
16
  Encoding,
17
+ GradientDef,
17
18
  LayoutStrategy,
18
19
  MarkAria,
19
20
  Rect,
20
21
  RectMark,
21
22
  } from '@opendata-ai/openchart-core';
22
- import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
23
+ import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
23
24
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
24
25
  import type { NormalizedChartSpec } from '../../compiler/types';
25
26
  import type { ResolvedScales } from '../../layout/scales';
@@ -169,11 +170,14 @@ function computeSimpleColumns(
169
170
  const bandX = xScale(category);
170
171
  if (bandX === undefined) continue;
171
172
 
172
- let color: string;
173
+ let color: string | GradientDef;
173
174
  if (conditionalColor) {
174
- color = String(
175
- resolveConditionalValue(row, conditionalColor) ?? getColor(scales, '__default__'),
176
- );
175
+ const resolved = resolveConditionalValue(row, conditionalColor);
176
+ if (resolved != null) {
177
+ color = isGradientDef(resolved) ? resolved : String(resolved);
178
+ } else {
179
+ color = getColor(scales, '__default__');
180
+ }
177
181
  } else if (sequentialColor) {
178
182
  color = getSequentialColor(scales, value);
179
183
  } else {
@@ -20,6 +20,7 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
+ getRepresentativeColor,
23
24
  resolveCollisions,
24
25
  } from '@opendata-ai/openchart-core';
25
26
 
@@ -99,7 +100,7 @@ export function computeColumnLabels(
99
100
  fontFamily: 'system-ui, -apple-system, sans-serif',
100
101
  fontSize: LABEL_FONT_SIZE,
101
102
  fontWeight: LABEL_FONT_WEIGHT,
102
- fill: mark.fill,
103
+ fill: getRepresentativeColor(mark.fill),
103
104
  lineHeight: 1.2,
104
105
  textAnchor: 'middle',
105
106
  dominantBaseline: isNegative ? 'hanging' : 'auto',
@@ -17,7 +17,11 @@ import type {
17
17
  Rect,
18
18
  ResolvedLabel,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { estimateTextWidth, resolveCollisions } from '@opendata-ai/openchart-core';
20
+ import {
21
+ estimateTextWidth,
22
+ getRepresentativeColor,
23
+ resolveCollisions,
24
+ } from '@opendata-ai/openchart-core';
21
25
 
22
26
  // ---------------------------------------------------------------------------
23
27
  // Constants
@@ -74,7 +78,7 @@ export function computeDotLabels(
74
78
  fontFamily: 'system-ui, -apple-system, sans-serif',
75
79
  fontSize: LABEL_FONT_SIZE,
76
80
  fontWeight: LABEL_FONT_WEIGHT,
77
- fill: mark.fill,
81
+ fill: getRepresentativeColor(mark.fill),
78
82
  lineHeight: 1.2,
79
83
  textAnchor: 'start',
80
84
  dominantBaseline: 'central',
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
11
  import type { ScaleLinear } from 'd3-scale';
11
12
  import { area, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
12
13
 
@@ -122,7 +123,7 @@ function computeSingleArea(
122
123
  topPath: topPathStr,
123
124
  fill: color,
124
125
  fillOpacity: DEFAULT_FILL_OPACITY,
125
- stroke: color,
126
+ stroke: getRepresentativeColor(color),
126
127
  strokeWidth: 2,
127
128
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
128
129
  data: validPoints.map((p) => p.row),
@@ -258,7 +259,7 @@ function computeStackedArea(
258
259
  topPath: topPathStr,
259
260
  fill: color,
260
261
  fillOpacity: 0.7, // Higher opacity for stacked so layers are visible
261
- stroke: color,
262
+ stroke: getRepresentativeColor(color),
262
263
  strokeWidth: 1,
263
264
  seriesKey,
264
265
  data: layer.map((d) => {
@@ -10,12 +10,14 @@
10
10
  import type {
11
11
  DataRow,
12
12
  Encoding,
13
+ GradientDef,
13
14
  LayoutStrategy,
14
15
  LineMark,
15
16
  MarkAria,
16
17
  PointMark,
17
18
  Rect,
18
19
  } from '@opendata-ai/openchart-core';
20
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
19
21
  import { line } from 'd3-shape';
20
22
 
21
23
  import type { NormalizedChartSpec } from '../../compiler/types';
@@ -68,9 +70,10 @@ export function computeLineMarks(
68
70
 
69
71
  for (const [seriesKey, rows] of groups) {
70
72
  // For sequential color, use a mid-range color for the line stroke
71
- const color = isSequentialColor
73
+ const color: string | GradientDef = isSequentialColor
72
74
  ? getSequentialColor(scales, _getMidValue(rows, sequentialColorField!))
73
75
  : getColor(scales, seriesKey);
76
+ const strokeColor = getRepresentativeColor(color);
74
77
 
75
78
  // Sort rows by x-axis field so lines draw left-to-right
76
79
  const sortedRows = sortByField(rows, xChannel.field);
@@ -165,7 +168,7 @@ export function computeLineMarks(
165
168
  type: 'line',
166
169
  points: allPoints,
167
170
  path: combinedPath,
168
- stroke: color,
171
+ stroke: strokeColor,
169
172
  strokeWidth: styleOverride?.strokeWidth ?? DEFAULT_STROKE_WIDTH,
170
173
  strokeDasharray,
171
174
  opacity: styleOverride?.opacity,
@@ -8,17 +8,21 @@
8
8
 
9
9
  import type {
10
10
  ArcMark,
11
+ ConditionalValueDef,
11
12
  DataRow,
12
13
  Encoding,
14
+ GradientDef,
13
15
  LayoutStrategy,
14
16
  MarkAria,
15
17
  Rect,
16
18
  } from '@opendata-ai/openchart-core';
19
+ import { isConditionalDef, isGradientDef } from '@opendata-ai/openchart-core';
17
20
  import type { PieArcDatum } from 'd3-shape';
18
21
  import { arc as d3Arc, pie as d3Pie } from 'd3-shape';
19
22
 
20
23
  import type { NormalizedChartSpec } from '../../compiler/types';
21
24
  import type { ResolvedScales } from '../../layout/scales';
25
+ import { resolveConditionalValue } from '../../transforms/conditional';
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Constants
@@ -109,6 +113,10 @@ export function computePieMarks(
109
113
  const valueChannel = encoding.y ?? encoding.x;
110
114
  const categoryField =
111
115
  encoding.color && 'field' in encoding.color ? encoding.color.field : undefined;
116
+ const conditionalColor =
117
+ encoding.color && isConditionalDef(encoding.color)
118
+ ? (encoding.color as ConditionalValueDef)
119
+ : undefined;
112
120
 
113
121
  if (!valueChannel) return [];
114
122
 
@@ -190,9 +198,22 @@ export function computePieMarks(
190
198
  const arcDatum = arcs[i];
191
199
  const slice = arcDatum.data;
192
200
 
193
- // Get color from scale or default palette
194
- let color: string;
195
- if (scales.color && categoryField) {
201
+ // Get color: conditional (supports gradients) > scale > default palette
202
+ let color: string | GradientDef;
203
+ if (conditionalColor) {
204
+ const resolved = resolveConditionalValue(
205
+ slice.originalRow as Record<string, unknown>,
206
+ conditionalColor,
207
+ );
208
+ if (resolved != null) {
209
+ color = isGradientDef(resolved) ? resolved : String(resolved);
210
+ } else if (scales.color && categoryField) {
211
+ const colorScale = scales.color.scale as (v: string) => string;
212
+ color = colorScale(slice.label);
213
+ } else {
214
+ color = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
215
+ }
216
+ } else if (scales.color && categoryField) {
196
217
  const colorScale = scales.color.scale as (v: string) => string;
197
218
  color = colorScale(slice.label);
198
219
  } else {
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, Rect, RuleMarkLayout } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
11
 
11
12
  import type { NormalizedChartSpec } from '../../compiler/types';
12
13
  import type { ResolvedScales } from '../../layout/scales';
@@ -84,9 +85,11 @@ export function computeRuleMarks(
84
85
  if (y2Val != null) y2 = y2Val;
85
86
  }
86
87
 
87
- const color = colorField
88
- ? getColor(scales, String(row[colorField] ?? '__default__'))
89
- : getColor(scales, '__default__');
88
+ const color = getRepresentativeColor(
89
+ colorField
90
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
91
+ : getColor(scales, '__default__'),
92
+ );
90
93
 
91
94
  const strokeDashEncoding =
92
95
  encoding.strokeDash && 'field' in encoding.strokeDash ? encoding.strokeDash : undefined;
@@ -10,6 +10,7 @@
10
10
  import type {
11
11
  Encoding,
12
12
  FieldType,
13
+ GradientDef,
13
14
  LayoutStrategy,
14
15
  MarkAria,
15
16
  PointMark,
@@ -139,7 +140,7 @@ export function computeScatterMarks(
139
140
  if (cx === undefined || cy === undefined) continue;
140
141
 
141
142
  const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
142
- let color: string;
143
+ let color: string | GradientDef;
143
144
  if (isSequentialColor && colorField) {
144
145
  const val = Number(row[colorField]);
145
146
  color = Number.isFinite(val)
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
11
 
11
12
  import type { NormalizedChartSpec } from '../../compiler/types';
12
13
  import type { ResolvedScales } from '../../layout/scales';
@@ -52,9 +53,11 @@ export function computeTextMarks(
52
53
  const text = String(row[textChannel.field] ?? '');
53
54
  if (!text) continue;
54
55
 
55
- const color = colorField
56
- ? getColor(scales, String(row[colorField] ?? '__default__'))
57
- : getColor(scales, '__default__');
56
+ const color = getRepresentativeColor(
57
+ colorField
58
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
59
+ : getColor(scales, '__default__'),
60
+ );
58
61
 
59
62
  const fontSize = sizeEncoding
60
63
  ? Math.max(8, Math.min(48, Number(row[sizeEncoding.field]) || 12))
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, Rect, TickMarkLayout } from '@opendata-ai/openchart-core';
10
+ import { getRepresentativeColor } from '@opendata-ai/openchart-core';
10
11
 
11
12
  import type { NormalizedChartSpec } from '../../compiler/types';
12
13
  import type { ResolvedScales } from '../../layout/scales';
@@ -48,9 +49,11 @@ export function computeTickMarks(
48
49
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
49
50
  if (xVal == null || yVal == null) continue;
50
51
 
51
- const color = colorField
52
- ? getColor(scales, String(row[colorField] ?? '__default__'))
53
- : getColor(scales, '__default__');
52
+ const color = getRepresentativeColor(
53
+ colorField
54
+ ? getColor(scales, String(row[colorField] ?? '__default__'))
55
+ : getColor(scales, '__default__'),
56
+ );
54
57
 
55
58
  const aria: MarkAria = {
56
59
  label: `${row[xChannel.field]}, ${row[yChannel.field]}`,
@@ -5,7 +5,7 @@
5
5
  * data grouping, color lookup, and shared constants.
6
6
  */
7
7
 
8
- import type { DataRow } from '@opendata-ai/openchart-core';
8
+ import type { DataRow, GradientDef } from '@opendata-ai/openchart-core';
9
9
  import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
10
10
  import type { D3Scale, ResolvedScales } from '../layout/scales';
11
11
 
@@ -143,7 +143,7 @@ export function getColor(
143
143
  key: string,
144
144
  _index?: number,
145
145
  fallback: string = DEFAULT_COLOR,
146
- ): string {
146
+ ): string | GradientDef {
147
147
  if (scales.color && key !== '__default__') {
148
148
  const colorScale = scales.color.scale as (v: string) => string;
149
149
  return colorScale(key);
@@ -159,7 +159,7 @@ export function getSequentialColor(
159
159
  scales: ResolvedScales,
160
160
  value: number,
161
161
  fallback: string = DEFAULT_COLOR,
162
- ): string {
162
+ ): string | GradientDef {
163
163
  if (scales.color?.type === 'sequential') {
164
164
  const colorScale = scales.color.scale as unknown as (v: number) => string;
165
165
  return colorScale(value);
package/src/compile.ts CHANGED
@@ -222,6 +222,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
222
222
 
223
223
  let chartSpec = normalized as NormalizedChartSpec;
224
224
 
225
+ // Resolve watermark: explicit spec value wins, then options fallback, then default true.
226
+ const rawWatermark = (spec as Record<string, unknown>).watermark;
227
+ const watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
228
+
225
229
  // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
226
230
  // Transforms are defined on the original spec, not the normalized spec, since
227
231
  // NormalizedChartSpec doesn't carry the transform field.
@@ -306,10 +310,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
306
310
  width: options.width,
307
311
  height: options.height,
308
312
  };
309
- const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
313
+ const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea, watermark);
310
314
 
311
315
  // Compute dimensions (accounts for chrome + legend + responsive strategy)
312
- const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
316
+ const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy, watermark);
313
317
  const chartArea = dims.chartArea;
314
318
 
315
319
  // Recompute legend bounds relative to actual chart area.
@@ -332,7 +336,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
332
336
  break;
333
337
  }
334
338
  }
335
- const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
339
+ const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea, watermark);
336
340
 
337
341
  // Apply data filtering after legend (so legend retains all series), but before
338
342
  // scale computation (so hidden/clipped data doesn't affect domains or marks).
@@ -386,8 +390,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
386
390
  }
387
391
  }
388
392
 
389
- // Set default color for single-series charts (no color encoding)
390
- scales.defaultColor = theme.colors.categorical[0];
393
+ // Set default color for single-series charts. If the user set a fill on the mark def
394
+ // (string or gradient), that takes priority over the theme's first categorical color.
395
+ scales.defaultColor = chartSpec.markDef.fill ?? theme.colors.categorical[0];
391
396
 
392
397
  // Arc charts (pie/donut) don't use axes or gridlines
393
398
  const isRadial = chartSpec.markType === 'arc';
@@ -443,14 +448,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
443
448
  // Add brand watermark as an obstacle so annotations avoid overlapping it.
444
449
  // The brand is right-aligned on the same baseline as the first bottom chrome element,
445
450
  // offset below the chart area by x-axis extent (tick labels + axis title).
446
- const brandPadding = theme.spacing.padding;
447
- const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
448
- const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
449
- const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
450
- const brandY = firstBottomChrome
451
- ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
452
- : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
453
- obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
451
+ if (watermark) {
452
+ const brandPadding = theme.spacing.padding;
453
+ const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
454
+ const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
455
+ const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
456
+ const brandY = firstBottomChrome
457
+ ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
458
+ : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
459
+ obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
460
+ }
454
461
  const annotations: ResolvedAnnotation[] = computeAnnotations(
455
462
  chartSpec,
456
463
  scales,
@@ -546,6 +553,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
546
553
  height: options.height,
547
554
  },
548
555
  animation: resolvedAnimation,
556
+ watermark,
549
557
  };
550
558
  }
551
559
 
@@ -655,6 +663,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
655
663
  responsive: layerSpec.responsive ?? leaves[0].responsive,
656
664
  theme: layerSpec.theme ?? leaves[0].theme,
657
665
  darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
666
+ watermark: layerSpec.watermark ?? leaves[0].watermark,
658
667
  hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
659
668
  };
660
669
 
@@ -696,7 +705,11 @@ export function compileTable(spec: unknown, options: CompileTableOptions): Table
696
705
  theme = adaptTheme(theme);
697
706
  }
698
707
 
699
- return compileTableLayout(tableSpec, options, theme);
708
+ // Resolve watermark: spec-level wins, then options, then default true
709
+ const rawWatermark = (spec as Record<string, unknown>).watermark;
710
+ const watermark = rawWatermark !== undefined ? tableSpec.watermark : (options.watermark ?? true);
711
+
712
+ return compileTableLayout({ ...tableSpec, watermark }, options, theme);
700
713
  }
701
714
 
702
715
  // ---------------------------------------------------------------------------
@@ -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
  });