@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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mark post-processing: obstacle computation, renderer dispatch, and
|
|
3
|
+
* animation index assignment. These are mark-type-aware operations that
|
|
4
|
+
* run after the chart renderer has produced marks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Encoding,
|
|
9
|
+
Mark,
|
|
10
|
+
MarkDef,
|
|
11
|
+
PointMark,
|
|
12
|
+
Rect,
|
|
13
|
+
RectMark,
|
|
14
|
+
ResolvedAnimation,
|
|
15
|
+
} from '@opendata-ai/openchart-core';
|
|
16
|
+
import type { ResolvedScales } from '../layout/scales';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Mark obstacles for annotation collision avoidance
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute bounding rects from marks to use as obstacles for annotation nudging.
|
|
24
|
+
*
|
|
25
|
+
* For band-scale charts (bar, dot): groups marks by band row and returns
|
|
26
|
+
* a single obstacle per row spanning the full band height and x-range.
|
|
27
|
+
*
|
|
28
|
+
* For other charts (column, scatter): returns individual mark bounds so
|
|
29
|
+
* annotations avoid overlapping any visible data mark.
|
|
30
|
+
*/
|
|
31
|
+
export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
32
|
+
// Band-scale y-axis: group marks by row for efficient obstacle computation
|
|
33
|
+
if (scales.y?.type === 'band') {
|
|
34
|
+
return computeBandRowObstacles(marks, scales);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// All other charts: use individual rect/point mark bounds as obstacles
|
|
38
|
+
const obstacles: Rect[] = [];
|
|
39
|
+
for (const mark of marks) {
|
|
40
|
+
if (mark.type === 'rect') {
|
|
41
|
+
const rm = mark as RectMark;
|
|
42
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
43
|
+
} else if (mark.type === 'point') {
|
|
44
|
+
const pm = mark as PointMark;
|
|
45
|
+
obstacles.push({
|
|
46
|
+
x: pm.cx - pm.r,
|
|
47
|
+
y: pm.cy - pm.r,
|
|
48
|
+
width: pm.r * 2,
|
|
49
|
+
height: pm.r * 2,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return obstacles;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Group band-scale marks by row, returning one obstacle per band. */
|
|
57
|
+
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
58
|
+
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
59
|
+
|
|
60
|
+
for (const mark of marks) {
|
|
61
|
+
let cy: number;
|
|
62
|
+
let left: number;
|
|
63
|
+
let right: number;
|
|
64
|
+
|
|
65
|
+
if (mark.type === 'point') {
|
|
66
|
+
const pm = mark as PointMark;
|
|
67
|
+
cy = pm.cy;
|
|
68
|
+
left = pm.cx - pm.r;
|
|
69
|
+
right = pm.cx + pm.r;
|
|
70
|
+
} else if (mark.type === 'rect') {
|
|
71
|
+
const rm = mark as RectMark;
|
|
72
|
+
cy = rm.y + rm.height / 2;
|
|
73
|
+
left = rm.x;
|
|
74
|
+
right = rm.x + rm.width;
|
|
75
|
+
} else {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Round cy to group marks on the same band
|
|
80
|
+
const key = Math.round(cy);
|
|
81
|
+
const existing = rows.get(key);
|
|
82
|
+
if (existing) {
|
|
83
|
+
existing.minX = Math.min(existing.minX, left);
|
|
84
|
+
existing.maxX = Math.max(existing.maxX, right);
|
|
85
|
+
} else {
|
|
86
|
+
rows.set(key, { minX: left, maxX: right, bandY: cy });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Get bandwidth from the band scale
|
|
91
|
+
const bandScale = scales.y!.scale as { bandwidth?: () => number };
|
|
92
|
+
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
93
|
+
if (bandwidth === 0) return [];
|
|
94
|
+
|
|
95
|
+
const obstacles: Rect[] = [];
|
|
96
|
+
for (const { minX, maxX, bandY } of rows.values()) {
|
|
97
|
+
obstacles.push({
|
|
98
|
+
x: minX,
|
|
99
|
+
y: bandY - bandwidth / 2,
|
|
100
|
+
width: maxX - minX,
|
|
101
|
+
height: bandwidth,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return obstacles;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Renderer key dispatch
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the renderer key from mark type, encoding, and mark definition.
|
|
114
|
+
*
|
|
115
|
+
* - 'bar' -> 'bar' (horizontal) or 'bar:vertical' based on encoding axis types
|
|
116
|
+
* - 'arc' -> 'arc' (pie) or 'arc:donut' based on innerRadius
|
|
117
|
+
* - All other mark types pass through unchanged
|
|
118
|
+
*/
|
|
119
|
+
export function resolveRendererKey(
|
|
120
|
+
markType: string,
|
|
121
|
+
encoding: Partial<Encoding>,
|
|
122
|
+
markDef: Partial<MarkDef>,
|
|
123
|
+
): string {
|
|
124
|
+
if (markType === 'bar') {
|
|
125
|
+
const xType = encoding.x?.type;
|
|
126
|
+
const yType = encoding.y?.type;
|
|
127
|
+
const isVertical =
|
|
128
|
+
(xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
|
|
129
|
+
yType === 'quantitative';
|
|
130
|
+
if (isVertical) {
|
|
131
|
+
return 'bar:vertical';
|
|
132
|
+
}
|
|
133
|
+
} else if (markType === 'arc') {
|
|
134
|
+
const innerRadius = markDef.innerRadius;
|
|
135
|
+
if (innerRadius && innerRadius > 0) {
|
|
136
|
+
return 'arc:donut';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return markType;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Animation index assignment
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/** Extract the primary quantitative value from a mark for value-based stagger ordering. */
|
|
147
|
+
function getMarkPrimaryValue(mark: Mark): number {
|
|
148
|
+
switch (mark.type) {
|
|
149
|
+
case 'rect':
|
|
150
|
+
return mark.height; // bar height is the primary value encoding
|
|
151
|
+
case 'point':
|
|
152
|
+
return mark.cy; // y position for scatter
|
|
153
|
+
case 'arc':
|
|
154
|
+
return mark.endAngle - mark.startAngle; // arc angle extent
|
|
155
|
+
case 'line':
|
|
156
|
+
case 'area':
|
|
157
|
+
return 0; // series marks don't have individual values
|
|
158
|
+
default:
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Assign animation indices to marks for stagger ordering.
|
|
165
|
+
*
|
|
166
|
+
* Two phases:
|
|
167
|
+
* 1. Value-based stagger: sorts marks by primary value, assigns sequential indices.
|
|
168
|
+
* Skips stacked rects since they get group-based indices in phase 2.
|
|
169
|
+
* 2. Stack-based stagger: groups marks by stackGroup, assigns the same index to
|
|
170
|
+
* all segments in a group, and computes stackPos (segment position: 0, 1, 2...).
|
|
171
|
+
* This intentionally overwrites any value-based indices for stacked marks.
|
|
172
|
+
*/
|
|
173
|
+
export function assignAnimationIndices(
|
|
174
|
+
marks: Mark[],
|
|
175
|
+
animation: ResolvedAnimation | undefined,
|
|
176
|
+
): void {
|
|
177
|
+
if (!animation?.enabled) return;
|
|
178
|
+
|
|
179
|
+
// Phase 1: Value-based stagger ordering. Skip stacked rects
|
|
180
|
+
// since they get group-based indices below (avoids wasted work that gets overwritten).
|
|
181
|
+
if (animation.staggerOrder === 'value') {
|
|
182
|
+
const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
|
|
183
|
+
indexed.sort((a, b) => {
|
|
184
|
+
const av = getMarkPrimaryValue(a.mark);
|
|
185
|
+
const bv = getMarkPrimaryValue(b.mark);
|
|
186
|
+
return av - bv;
|
|
187
|
+
});
|
|
188
|
+
for (let i = 0; i < indexed.length; i++) {
|
|
189
|
+
const m = indexed[i].mark;
|
|
190
|
+
if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
|
|
191
|
+
m.animationIndex = i;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Phase 2: For stacked bars/columns, assign the same animationIndex to all segments
|
|
196
|
+
// sharing a stackGroup so they animate as one contiguous bar per category.
|
|
197
|
+
// Also compute stackPos (segment position within each group: 0, 1, 2...)
|
|
198
|
+
// so the renderer can chain segment animations sequentially.
|
|
199
|
+
const groupIndexMap = new Map<string, number>();
|
|
200
|
+
const groupStackPos = new Map<string, number>();
|
|
201
|
+
let nextGroupIndex = 0;
|
|
202
|
+
for (const mark of marks) {
|
|
203
|
+
if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
|
|
204
|
+
const rect = mark as RectMark;
|
|
205
|
+
const group = rect.stackGroup!;
|
|
206
|
+
if (!groupIndexMap.has(group)) {
|
|
207
|
+
groupIndexMap.set(group, nextGroupIndex++);
|
|
208
|
+
}
|
|
209
|
+
rect.animationIndex = groupIndexMap.get(group)!;
|
|
210
|
+
const pos = groupStackPos.get(group) ?? 0;
|
|
211
|
+
rect.stackPos = pos;
|
|
212
|
+
groupStackPos.set(group, pos + 1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/compile.ts
CHANGED
|
@@ -13,18 +13,22 @@
|
|
|
13
13
|
|
|
14
14
|
import type {
|
|
15
15
|
AnimationSpec,
|
|
16
|
+
BinParams,
|
|
17
|
+
BinTransform,
|
|
16
18
|
ChartLayout,
|
|
17
19
|
ChartSpec,
|
|
18
20
|
CompileOptions,
|
|
19
21
|
CompileTableOptions,
|
|
22
|
+
EncodingChannel,
|
|
20
23
|
LayerSpec,
|
|
21
24
|
Mark,
|
|
22
|
-
PointMark,
|
|
23
25
|
Rect,
|
|
24
|
-
RectMark,
|
|
25
26
|
ResolvedAnnotation,
|
|
26
27
|
ResolvedTheme,
|
|
27
28
|
TableLayout,
|
|
29
|
+
TimeUnit,
|
|
30
|
+
TimeUnitTransform,
|
|
31
|
+
Transform,
|
|
28
32
|
} from '@opendata-ai/openchart-core';
|
|
29
33
|
import {
|
|
30
34
|
adaptTheme,
|
|
@@ -43,6 +47,11 @@ import { columnRenderer } from './charts/column';
|
|
|
43
47
|
import { dotRenderer } from './charts/dot';
|
|
44
48
|
import { areaRenderer, lineRenderer } from './charts/line';
|
|
45
49
|
import { donutRenderer, pieRenderer } from './charts/pie';
|
|
50
|
+
import {
|
|
51
|
+
assignAnimationIndices,
|
|
52
|
+
computeMarkObstacles,
|
|
53
|
+
resolveRendererKey,
|
|
54
|
+
} from './charts/post-process';
|
|
46
55
|
import { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
|
|
47
56
|
import { ruleRenderer } from './charts/rule';
|
|
48
57
|
import { scatterRenderer } from './charts/scatter';
|
|
@@ -90,7 +99,7 @@ import type { GraphCompilation } from './graphs/types';
|
|
|
90
99
|
import { computeAxes } from './layout/axes';
|
|
91
100
|
import { computeDimensions } from './layout/dimensions';
|
|
92
101
|
import { computeGridlines } from './layout/gridlines';
|
|
93
|
-
import { computeScales
|
|
102
|
+
import { computeScales } from './layout/scales';
|
|
94
103
|
import { computeLegend } from './legend/compute';
|
|
95
104
|
import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
|
|
96
105
|
import { compileTableLayout } from './tables/compile-table';
|
|
@@ -98,93 +107,77 @@ import { computeTooltipDescriptors } from './tooltips/compute';
|
|
|
98
107
|
import { runTransforms } from './transforms';
|
|
99
108
|
|
|
100
109
|
// ---------------------------------------------------------------------------
|
|
101
|
-
//
|
|
110
|
+
// Encoding sugar expansion (bin, timeUnit on encoding channels)
|
|
102
111
|
// ---------------------------------------------------------------------------
|
|
103
112
|
|
|
104
113
|
/**
|
|
105
|
-
*
|
|
114
|
+
* Expand encoding-level `bin` and `timeUnit` shorthand into explicit transforms.
|
|
106
115
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
116
|
+
* Vega-Lite allows `encoding.x.bin: true` as sugar for a BinTransform.
|
|
117
|
+
* This function detects those shorthands, generates the corresponding transforms,
|
|
118
|
+
* updates encoding field references to the output field names, and prepends the
|
|
119
|
+
* transforms to the spec's transform array.
|
|
109
120
|
*
|
|
110
|
-
*
|
|
111
|
-
* annotations avoid overlapping any visible data mark.
|
|
121
|
+
* Mutates nothing; returns a new spec object (shallow copy).
|
|
112
122
|
*/
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
export function expandEncodingSugar(spec: Record<string, unknown>): Record<string, unknown> {
|
|
124
|
+
const encoding = spec.encoding as Record<string, EncodingChannel | undefined> | undefined;
|
|
125
|
+
if (!encoding) return spec;
|
|
126
|
+
|
|
127
|
+
const generatedTransforms: Transform[] = [];
|
|
128
|
+
const updatedEncoding = { ...encoding };
|
|
129
|
+
let changed = false;
|
|
130
|
+
|
|
131
|
+
for (const channel of Object.keys(encoding)) {
|
|
132
|
+
const ch = encoding[channel];
|
|
133
|
+
if (!ch || !ch.field) continue;
|
|
134
|
+
|
|
135
|
+
// Expand bin shorthand
|
|
136
|
+
if (ch.bin != null && ch.bin !== false) {
|
|
137
|
+
const field = ch.field;
|
|
138
|
+
const outputField = `bin_${field}`;
|
|
139
|
+
const binTransform: BinTransform = {
|
|
140
|
+
bin: ch.bin === true ? true : (ch.bin as BinParams),
|
|
141
|
+
field,
|
|
142
|
+
as: outputField,
|
|
143
|
+
};
|
|
144
|
+
generatedTransforms.push(binTransform);
|
|
118
145
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const rm = mark as RectMark;
|
|
124
|
-
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
125
|
-
} else if (mark.type === 'point') {
|
|
126
|
-
const pm = mark as PointMark;
|
|
127
|
-
obstacles.push({
|
|
128
|
-
x: pm.cx - pm.r,
|
|
129
|
-
y: pm.cy - pm.r,
|
|
130
|
-
width: pm.r * 2,
|
|
131
|
-
height: pm.r * 2,
|
|
132
|
-
});
|
|
146
|
+
// Update encoding to reference binned output field, remove bin property
|
|
147
|
+
const { bin: _bin, ...rest } = ch;
|
|
148
|
+
updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
|
|
149
|
+
changed = true;
|
|
133
150
|
}
|
|
134
|
-
}
|
|
135
|
-
return obstacles;
|
|
136
|
-
}
|
|
137
151
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
left = pm.cx - pm.r;
|
|
151
|
-
right = pm.cx + pm.r;
|
|
152
|
-
} else if (mark.type === 'rect') {
|
|
153
|
-
const rm = mark as RectMark;
|
|
154
|
-
cy = rm.y + rm.height / 2;
|
|
155
|
-
left = rm.x;
|
|
156
|
-
right = rm.x + rm.width;
|
|
157
|
-
} else {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
152
|
+
// Expand timeUnit shorthand (read from updated encoding in case bin already ran)
|
|
153
|
+
const current = updatedEncoding[channel] ?? ch;
|
|
154
|
+
if (current.timeUnit) {
|
|
155
|
+
const field = current.field;
|
|
156
|
+
const unit = current.timeUnit as TimeUnit;
|
|
157
|
+
const outputField = `${unit}_${field}`;
|
|
158
|
+
const timeUnitTransform: TimeUnitTransform = {
|
|
159
|
+
timeUnit: unit,
|
|
160
|
+
field,
|
|
161
|
+
as: outputField,
|
|
162
|
+
};
|
|
163
|
+
generatedTransforms.push(timeUnitTransform);
|
|
160
164
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
existing.minX = Math.min(existing.minX, left);
|
|
166
|
-
existing.maxX = Math.max(existing.maxX, right);
|
|
167
|
-
} else {
|
|
168
|
-
rows.set(key, { minX: left, maxX: right, bandY: cy });
|
|
165
|
+
// Update encoding to reference timeUnit output field, remove timeUnit property
|
|
166
|
+
const { timeUnit: _tu, ...rest } = current;
|
|
167
|
+
updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
|
|
168
|
+
changed = true;
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
const bandScale = scales.y!.scale as { bandwidth?: () => number };
|
|
174
|
-
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
175
|
-
if (bandwidth === 0) return [];
|
|
172
|
+
if (!changed) return spec;
|
|
176
173
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
});
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return obstacles;
|
|
174
|
+
// Prepend generated transforms before any user-defined transforms
|
|
175
|
+
const existingTransforms = (spec.transform as Transform[] | undefined) ?? [];
|
|
176
|
+
return {
|
|
177
|
+
...spec,
|
|
178
|
+
encoding: updatedEncoding,
|
|
179
|
+
transform: [...generatedTransforms, ...existingTransforms],
|
|
180
|
+
};
|
|
188
181
|
}
|
|
189
182
|
|
|
190
183
|
// ---------------------------------------------------------------------------
|
|
@@ -204,8 +197,15 @@ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[]
|
|
|
204
197
|
* @throws Error if spec is invalid or not a chart type.
|
|
205
198
|
*/
|
|
206
199
|
export function compileChart(spec: unknown, options: CompileOptions): ChartLayout {
|
|
200
|
+
// Expand encoding-level bin/timeUnit sugar before validation + normalization.
|
|
201
|
+
// This converts shorthand (e.g. encoding.x.bin: true) into explicit transforms.
|
|
202
|
+
const expandedSpec =
|
|
203
|
+
spec && typeof spec === 'object' && !Array.isArray(spec)
|
|
204
|
+
? expandEncodingSugar(spec as Record<string, unknown>)
|
|
205
|
+
: spec;
|
|
206
|
+
|
|
207
207
|
// Validate + normalize
|
|
208
|
-
const { spec: normalized } = compileSpec(
|
|
208
|
+
const { spec: normalized } = compileSpec(expandedSpec);
|
|
209
209
|
|
|
210
210
|
if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
|
|
211
211
|
throw new Error('compileChart received a table spec. Use compileTable instead.');
|
|
@@ -222,10 +222,15 @@ 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 = (expandedSpec 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
|
-
// Transforms are defined on the
|
|
227
|
-
//
|
|
228
|
-
|
|
230
|
+
// Transforms are defined on the expanded spec (which includes any auto-generated
|
|
231
|
+
// transforms from encoding-level bin/timeUnit sugar), not the normalized spec,
|
|
232
|
+
// since NormalizedChartSpec doesn't carry the transform field.
|
|
233
|
+
const rawTransforms = (expandedSpec as Record<string, unknown>).transform as
|
|
229
234
|
| import('@opendata-ai/openchart-core').Transform[]
|
|
230
235
|
| undefined;
|
|
231
236
|
if (rawTransforms && rawTransforms.length > 0) {
|
|
@@ -237,8 +242,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
237
242
|
const heightClass = getHeightClass(options.height);
|
|
238
243
|
const strategy = getLayoutStrategy(breakpoint, heightClass);
|
|
239
244
|
|
|
240
|
-
// Apply breakpoint-conditional overrides from the
|
|
241
|
-
const rawSpec =
|
|
245
|
+
// Apply breakpoint-conditional overrides from the expanded spec
|
|
246
|
+
const rawSpec = expandedSpec as Record<string, unknown>;
|
|
242
247
|
const overrides = rawSpec.overrides as
|
|
243
248
|
| Partial<
|
|
244
249
|
Record<
|
|
@@ -306,10 +311,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
306
311
|
width: options.width,
|
|
307
312
|
height: options.height,
|
|
308
313
|
};
|
|
309
|
-
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
|
|
314
|
+
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea, watermark);
|
|
310
315
|
|
|
311
316
|
// Compute dimensions (accounts for chrome + legend + responsive strategy)
|
|
312
|
-
const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
|
|
317
|
+
const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy, watermark);
|
|
313
318
|
const chartArea = dims.chartArea;
|
|
314
319
|
|
|
315
320
|
// Recompute legend bounds relative to actual chart area.
|
|
@@ -332,7 +337,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
332
337
|
break;
|
|
333
338
|
}
|
|
334
339
|
}
|
|
335
|
-
const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
|
|
340
|
+
const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea, watermark);
|
|
336
341
|
|
|
337
342
|
// Apply data filtering after legend (so legend retains all series), but before
|
|
338
343
|
// scale computation (so hidden/clipped data doesn't affect domains or marks).
|
|
@@ -404,26 +409,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
404
409
|
}
|
|
405
410
|
|
|
406
411
|
// Get chart renderer and compute marks (using filtered data).
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
// If x is categorical and y is quantitative, vertical (old 'column')
|
|
413
|
-
const xType = renderSpec.encoding.x?.type;
|
|
414
|
-
const yType = renderSpec.encoding.y?.type;
|
|
415
|
-
const isVertical =
|
|
416
|
-
(xType === 'nominal' || xType === 'ordinal' || xType === 'temporal') &&
|
|
417
|
-
yType === 'quantitative';
|
|
418
|
-
if (isVertical) {
|
|
419
|
-
rendererKey = 'bar:vertical';
|
|
420
|
-
}
|
|
421
|
-
} else if (rendererKey === 'arc') {
|
|
422
|
-
const innerRadius = renderSpec.markDef.innerRadius;
|
|
423
|
-
if (innerRadius && innerRadius > 0) {
|
|
424
|
-
rendererKey = 'arc:donut';
|
|
425
|
-
}
|
|
426
|
-
}
|
|
412
|
+
const rendererKey = resolveRendererKey(
|
|
413
|
+
renderSpec.markType,
|
|
414
|
+
renderSpec.encoding,
|
|
415
|
+
renderSpec.markDef,
|
|
416
|
+
);
|
|
427
417
|
const renderer = getChartRenderer(rendererKey);
|
|
428
418
|
const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
429
419
|
|
|
@@ -444,14 +434,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
444
434
|
// Add brand watermark as an obstacle so annotations avoid overlapping it.
|
|
445
435
|
// The brand is right-aligned on the same baseline as the first bottom chrome element,
|
|
446
436
|
// offset below the chart area by x-axis extent (tick labels + axis title).
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
437
|
+
if (watermark) {
|
|
438
|
+
const brandPadding = theme.spacing.padding;
|
|
439
|
+
const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
|
|
440
|
+
const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
|
|
441
|
+
const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
|
|
442
|
+
const brandY = firstBottomChrome
|
|
443
|
+
? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
|
|
444
|
+
: chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
|
|
445
|
+
obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
|
|
446
|
+
}
|
|
455
447
|
const annotations: ResolvedAnnotation[] = computeAnnotations(
|
|
456
448
|
chartSpec,
|
|
457
449
|
scales,
|
|
@@ -485,44 +477,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
485
477
|
);
|
|
486
478
|
|
|
487
479
|
// Assign animationIndex for stagger ordering when animation is enabled
|
|
488
|
-
|
|
489
|
-
// since they get group-based indices below (avoids wasted work that gets overwritten).
|
|
490
|
-
if (resolvedAnimation?.enabled && resolvedAnimation.staggerOrder === 'value') {
|
|
491
|
-
const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
|
|
492
|
-
indexed.sort((a, b) => {
|
|
493
|
-
const av = getMarkPrimaryValue(a.mark);
|
|
494
|
-
const bv = getMarkPrimaryValue(b.mark);
|
|
495
|
-
return av - bv;
|
|
496
|
-
});
|
|
497
|
-
for (let i = 0; i < indexed.length; i++) {
|
|
498
|
-
const m = indexed[i].mark;
|
|
499
|
-
if (m.type === 'rect' && (m as RectMark).stackGroup) continue;
|
|
500
|
-
m.animationIndex = i;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// For stacked bars/columns, assign the same animationIndex to all segments
|
|
505
|
-
// sharing a stackGroup so they animate as one contiguous bar per category.
|
|
506
|
-
// Also compute stackPos (segment position within each group: 0, 1, 2...)
|
|
507
|
-
// so the renderer can chain segment animations sequentially.
|
|
508
|
-
if (resolvedAnimation?.enabled) {
|
|
509
|
-
const groupIndexMap = new Map<string, number>();
|
|
510
|
-
const groupStackPos = new Map<string, number>();
|
|
511
|
-
let nextGroupIndex = 0;
|
|
512
|
-
for (const mark of marks) {
|
|
513
|
-
if (mark.type === 'rect' && (mark as RectMark).stackGroup) {
|
|
514
|
-
const rect = mark as RectMark;
|
|
515
|
-
const group = rect.stackGroup!;
|
|
516
|
-
if (!groupIndexMap.has(group)) {
|
|
517
|
-
groupIndexMap.set(group, nextGroupIndex++);
|
|
518
|
-
}
|
|
519
|
-
rect.animationIndex = groupIndexMap.get(group)!;
|
|
520
|
-
const pos = groupStackPos.get(group) ?? 0;
|
|
521
|
-
rect.stackPos = pos;
|
|
522
|
-
groupStackPos.set(group, pos + 1);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
}
|
|
480
|
+
assignAnimationIndices(marks, resolvedAnimation);
|
|
526
481
|
|
|
527
482
|
return {
|
|
528
483
|
area: chartArea,
|
|
@@ -547,26 +502,10 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
547
502
|
height: options.height,
|
|
548
503
|
},
|
|
549
504
|
animation: resolvedAnimation,
|
|
505
|
+
watermark,
|
|
550
506
|
};
|
|
551
507
|
}
|
|
552
508
|
|
|
553
|
-
/** Extract the primary quantitative value from a mark for value-based stagger ordering. */
|
|
554
|
-
function getMarkPrimaryValue(mark: Mark): number {
|
|
555
|
-
switch (mark.type) {
|
|
556
|
-
case 'rect':
|
|
557
|
-
return mark.height; // bar height is the primary value encoding
|
|
558
|
-
case 'point':
|
|
559
|
-
return mark.cy; // y position for scatter
|
|
560
|
-
case 'arc':
|
|
561
|
-
return mark.endAngle - mark.startAngle; // arc angle extent
|
|
562
|
-
case 'line':
|
|
563
|
-
case 'area':
|
|
564
|
-
return 0; // series marks don't have individual values
|
|
565
|
-
default:
|
|
566
|
-
return 0;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
509
|
// ---------------------------------------------------------------------------
|
|
571
510
|
// Layer compilation
|
|
572
511
|
// ---------------------------------------------------------------------------
|
|
@@ -656,6 +595,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
|
|
|
656
595
|
responsive: layerSpec.responsive ?? leaves[0].responsive,
|
|
657
596
|
theme: layerSpec.theme ?? leaves[0].theme,
|
|
658
597
|
darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
|
|
598
|
+
watermark: layerSpec.watermark ?? leaves[0].watermark,
|
|
659
599
|
hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
|
|
660
600
|
};
|
|
661
601
|
|
|
@@ -697,7 +637,11 @@ export function compileTable(spec: unknown, options: CompileTableOptions): Table
|
|
|
697
637
|
theme = adaptTheme(theme);
|
|
698
638
|
}
|
|
699
639
|
|
|
700
|
-
|
|
640
|
+
// Resolve watermark: spec-level wins, then options, then default true
|
|
641
|
+
const rawWatermark = (spec as Record<string, unknown>).watermark;
|
|
642
|
+
const watermark = rawWatermark !== undefined ? tableSpec.watermark : (options.watermark ?? true);
|
|
643
|
+
|
|
644
|
+
return compileTableLayout({ ...tableSpec, watermark }, options, theme);
|
|
701
645
|
}
|
|
702
646
|
|
|
703
647
|
// ---------------------------------------------------------------------------
|