@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.
- package/dist/index.d.ts +7 -0
- package/dist/index.js +944 -629
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/axes.test.ts +12 -30
- package/src/__tests__/compile-chart.test.ts +4 -4
- package/src/__tests__/dimensions.test.ts +2 -2
- package/src/__tests__/encoding-sugar.test.ts +389 -0
- package/src/annotations/collisions.ts +268 -0
- package/src/annotations/compute.ts +9 -912
- package/src/annotations/constants.ts +32 -0
- package/src/annotations/geometry.ts +167 -0
- package/src/annotations/position.ts +95 -0
- package/src/annotations/resolve-range.ts +98 -0
- package/src/annotations/resolve-refline.ts +148 -0
- package/src/annotations/resolve-text.ts +134 -0
- package/src/charts/__tests__/post-process.test.ts +258 -0
- package/src/charts/bar/__tests__/labels.test.ts +31 -0
- package/src/charts/bar/compute.ts +27 -6
- package/src/charts/bar/labels.ts +7 -1
- package/src/charts/column/__tests__/compute.test.ts +99 -0
- package/src/charts/column/compute.ts +27 -6
- package/src/charts/line/area.ts +19 -2
- package/src/charts/post-process.ts +215 -0
- package/src/compile.ts +113 -169
- package/src/compiler/__tests__/normalize.test.ts +110 -0
- package/src/compiler/normalize.ts +22 -3
- package/src/compiler/types.ts +4 -0
- package/src/graphs/compile-graph.ts +8 -0
- package/src/graphs/types.ts +2 -0
- package/src/layout/axes.ts +10 -13
- package/src/layout/dimensions.ts +6 -3
- package/src/layout/scales.ts +106 -29
- package/src/legend/compute.ts +3 -1
- package/src/sankey/compile-sankey.ts +12 -2
- package/src/sankey/types.ts +1 -0
- package/src/tables/compile-table.ts +5 -0
- package/src/tooltips/__tests__/compute.test.ts +188 -0
- package/src/tooltips/compute.ts +25 -11
- package/src/transforms/__tests__/aggregate.test.ts +159 -0
- package/src/transforms/__tests__/fold.test.ts +79 -0
- package/src/transforms/aggregate.ts +130 -0
- package/src/transforms/fold.ts +49 -0
- 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
|
|
73
|
-
const sampleSize = Math.min(
|
|
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(
|
|
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
|
}
|
package/src/compiler/types.ts
CHANGED
|
@@ -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
|
|
package/src/graphs/types.ts
CHANGED
package/src/layout/axes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
470
|
-
const
|
|
471
|
-
|
|
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,
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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> & {
|
|
127
|
-
const hasXAxisLabel = !!xAxis?.
|
|
128
|
-
const xTickAngle = xAxis?.
|
|
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
|
package/src/layout/scales.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
772
|
+
yChannel,
|
|
696
773
|
yData,
|
|
697
774
|
chartArea.y + chartArea.height,
|
|
698
775
|
chartArea.y,
|
package/src/legend/compute.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|