@opendata-ai/openchart-engine 6.12.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 (35) hide show
  1. package/dist/index.js +878 -606
  2. package/dist/index.js.map +1 -1
  3. package/package.json +2 -2
  4. package/src/__tests__/axes.test.ts +12 -30
  5. package/src/__tests__/compile-chart.test.ts +4 -4
  6. package/src/__tests__/dimensions.test.ts +2 -2
  7. package/src/__tests__/encoding-sugar.test.ts +389 -0
  8. package/src/annotations/collisions.ts +268 -0
  9. package/src/annotations/compute.ts +9 -912
  10. package/src/annotations/constants.ts +32 -0
  11. package/src/annotations/geometry.ts +167 -0
  12. package/src/annotations/position.ts +95 -0
  13. package/src/annotations/resolve-range.ts +98 -0
  14. package/src/annotations/resolve-refline.ts +148 -0
  15. package/src/annotations/resolve-text.ts +134 -0
  16. package/src/charts/__tests__/post-process.test.ts +258 -0
  17. package/src/charts/bar/__tests__/labels.test.ts +31 -0
  18. package/src/charts/bar/compute.ts +27 -6
  19. package/src/charts/bar/labels.ts +7 -1
  20. package/src/charts/column/__tests__/compute.test.ts +99 -0
  21. package/src/charts/column/compute.ts +27 -6
  22. package/src/charts/line/area.ts +19 -2
  23. package/src/charts/post-process.ts +215 -0
  24. package/src/compile.ts +90 -158
  25. package/src/compiler/normalize.ts +2 -2
  26. package/src/layout/axes.ts +10 -13
  27. package/src/layout/dimensions.ts +3 -3
  28. package/src/layout/scales.ts +106 -29
  29. package/src/tooltips/__tests__/compute.test.ts +188 -0
  30. package/src/tooltips/compute.ts +25 -11
  31. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  32. package/src/transforms/__tests__/fold.test.ts +79 -0
  33. package/src/transforms/aggregate.ts +130 -0
  34. package/src/transforms/fold.ts +49 -0
  35. package/src/transforms/index.ts +8 -0
package/dist/index.js CHANGED
@@ -11,8 +11,10 @@ import {
11
11
  resolveTheme as resolveTheme3
12
12
  } from "@opendata-ai/openchart-core";
13
13
 
14
- // src/annotations/compute.ts
15
- import { detectCollision, estimateTextWidth } from "@opendata-ai/openchart-core";
14
+ // src/annotations/collisions.ts
15
+ import { detectCollision } from "@opendata-ai/openchart-core";
16
+
17
+ // src/annotations/constants.ts
16
18
  var DEFAULT_ANNOTATION_FONT_SIZE = 12;
17
19
  var DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
18
20
  var DEFAULT_LINE_HEIGHT = 1.3;
@@ -24,6 +26,91 @@ var DARK_TEXT_FILL = "#d1d5db";
24
26
  var LIGHT_REFLINE_STROKE = "#888888";
25
27
  var DARK_REFLINE_STROKE = "#9ca3af";
26
28
  var ANCHOR_OFFSET = 8;
29
+ var NUDGE_PADDING = 6;
30
+ var CLAMP_MARGIN = 4;
31
+
32
+ // src/annotations/geometry.ts
33
+ import { estimateTextWidth } from "@opendata-ai/openchart-core";
34
+ function computeTextBounds(labelX, labelY, text, fontSize, fontWeight) {
35
+ const lines = text.split("\n");
36
+ const isMultiLine = lines.length > 1;
37
+ const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
38
+ const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
39
+ const x2 = isMultiLine ? labelX - maxWidth / 2 : labelX;
40
+ return {
41
+ x: x2,
42
+ y: labelY - fontSize,
43
+ width: maxWidth,
44
+ height: totalHeight
45
+ };
46
+ }
47
+ function computeAnchorOffset(anchor, _px, py, chartArea) {
48
+ if (!anchor || anchor === "auto") {
49
+ const isUpperHalf = py < chartArea.y + chartArea.height / 2;
50
+ return isUpperHalf ? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } : { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET };
51
+ }
52
+ switch (anchor) {
53
+ case "top":
54
+ return { dx: 0, dy: -ANCHOR_OFFSET };
55
+ case "bottom":
56
+ return { dx: 0, dy: ANCHOR_OFFSET };
57
+ case "left":
58
+ return { dx: -ANCHOR_OFFSET, dy: 0 };
59
+ case "right":
60
+ return { dx: ANCHOR_OFFSET, dy: 0 };
61
+ }
62
+ }
63
+ function applyOffset(base, offset) {
64
+ if (!offset) return base;
65
+ return {
66
+ dx: base.dx + (offset.dx ?? 0),
67
+ dy: base.dy + (offset.dy ?? 0)
68
+ };
69
+ }
70
+ function computeConnectorOrigin(labelX, labelY, text, fontSize, fontWeight, targetX, targetY, connectorStyle) {
71
+ const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
72
+ const boxCenterX = box.x + box.width / 2;
73
+ const boxCenterY = box.y + box.height / 2;
74
+ if (connectorStyle === "curve") {
75
+ return {
76
+ x: box.x + box.width,
77
+ y: boxCenterY
78
+ };
79
+ }
80
+ const halfW = box.width / 2 || 1;
81
+ const halfH = box.height / 2 || 1;
82
+ const ndx = (targetX - boxCenterX) / halfW;
83
+ const ndy = (targetY - boxCenterY) / halfH;
84
+ if (Math.abs(ndy) >= Math.abs(ndx)) {
85
+ return ndy < 0 ? { x: boxCenterX, y: box.y } : { x: boxCenterX, y: box.y + box.height };
86
+ }
87
+ return ndx < 0 ? { x: box.x, y: boxCenterY } : { x: box.x + box.width, y: boxCenterY };
88
+ }
89
+ function estimateLabelBounds(label) {
90
+ const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
91
+ const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
92
+ return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
93
+ }
94
+ function recomputeConnector(label, targetX, targetY) {
95
+ const connector = label.connector;
96
+ if (!connector) return connector;
97
+ const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
98
+ const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
99
+ const connStyle = connector.style === "curve" ? "curve" : "straight";
100
+ const newFrom = computeConnectorOrigin(
101
+ label.x,
102
+ label.y,
103
+ label.text,
104
+ fontSize,
105
+ fontWeight,
106
+ targetX,
107
+ targetY,
108
+ connStyle
109
+ );
110
+ return { ...connector, from: newFrom };
111
+ }
112
+
113
+ // src/annotations/position.ts
27
114
  function interpolateInDomain(numValue, domain, positionOf) {
28
115
  if (domain.length < 2) return null;
29
116
  const nums = domain.map(Number);
@@ -83,6 +170,150 @@ function resolvePosition(value2, scale) {
83
170
  }
84
171
  return null;
85
172
  }
173
+
174
+ // src/annotations/collisions.ts
175
+ function generateNudgeCandidates(selfBounds, obstacles, padding) {
176
+ const candidates = [];
177
+ for (const obs of obstacles) {
178
+ const belowDy = obs.y + obs.height + padding - selfBounds.y;
179
+ candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
180
+ const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
181
+ candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
182
+ const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
183
+ candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
184
+ const rightDx = obs.x + obs.width + padding - selfBounds.x;
185
+ candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
186
+ }
187
+ candidates.sort((a, b) => a.distance - b.distance);
188
+ return candidates;
189
+ }
190
+ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, chartArea, obstacles) {
191
+ if (annotation.type !== "text" || !annotation.label) return false;
192
+ const labelBounds = estimateLabelBounds(annotation.label);
193
+ const collidingObs = obstacles.filter(
194
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs)
195
+ );
196
+ if (collidingObs.length === 0) return false;
197
+ const px = resolvePosition(originalAnnotation.x, scales.x);
198
+ const py = resolvePosition(originalAnnotation.y, scales.y);
199
+ if (px === null || py === null) return false;
200
+ const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
201
+ const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split("\n").length);
202
+ for (const { dx, dy } of candidates) {
203
+ const newLabelX = annotation.label.x + dx;
204
+ const newLabelY = annotation.label.y + dy;
205
+ const candidateLabel = {
206
+ ...annotation.label,
207
+ x: newLabelX,
208
+ y: newLabelY,
209
+ connector: recomputeConnector({ ...annotation.label, x: newLabelX, y: newLabelY }, px, py)
210
+ };
211
+ const candidateBounds = estimateLabelBounds(candidateLabel);
212
+ const stillCollides = obstacles.some(
213
+ (obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs)
214
+ );
215
+ if (stillCollides) continue;
216
+ const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
217
+ const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
218
+ const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 10 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
219
+ if (inBounds) {
220
+ annotation.label = candidateLabel;
221
+ return true;
222
+ }
223
+ }
224
+ return false;
225
+ }
226
+ function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartArea) {
227
+ const placedBounds = [];
228
+ for (let i = 0; i < annotations.length; i++) {
229
+ const annotation = annotations[i];
230
+ if (annotation.type !== "text" || !annotation.label) {
231
+ continue;
232
+ }
233
+ const bounds = estimateLabelBounds(annotation.label);
234
+ const collidingBounds = placedBounds.filter(
235
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb)
236
+ );
237
+ if (collidingBounds.length > 0) {
238
+ const originalSpec = originalSpecs[i];
239
+ if (originalSpec?.type === "text") {
240
+ const px = resolvePosition(originalSpec.x, scales.x);
241
+ const py = resolvePosition(originalSpec.y, scales.y);
242
+ if (px !== null && py !== null) {
243
+ const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
244
+ const fontSize = bounds.height / Math.max(1, annotation.label.text.split("\n").length);
245
+ for (const { dx, dy } of candidates) {
246
+ const newLabelX = annotation.label.x + dx;
247
+ const newLabelY = annotation.label.y + dy;
248
+ const candidateLabel = {
249
+ ...annotation.label,
250
+ x: newLabelX,
251
+ y: newLabelY
252
+ };
253
+ const candidateBounds = estimateLabelBounds(candidateLabel);
254
+ const stillCollides = placedBounds.some(
255
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb)
256
+ );
257
+ if (stillCollides) continue;
258
+ const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
259
+ const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
260
+ const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 10 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
261
+ if (inBounds) {
262
+ annotation.label = {
263
+ ...annotation.label,
264
+ x: newLabelX,
265
+ y: newLabelY,
266
+ connector: recomputeConnector(
267
+ { ...annotation.label, x: newLabelX, y: newLabelY },
268
+ px,
269
+ py
270
+ )
271
+ };
272
+ break;
273
+ }
274
+ }
275
+ }
276
+ }
277
+ }
278
+ placedBounds.push(estimateLabelBounds(annotation.label));
279
+ }
280
+ }
281
+ function clampAnnotationsToBounds(annotations, svgWidth, svgHeight) {
282
+ for (const annotation of annotations) {
283
+ if (annotation.type !== "text" || !annotation.label) continue;
284
+ const bounds = estimateLabelBounds(annotation.label);
285
+ let dx = 0;
286
+ let dy = 0;
287
+ if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
288
+ dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
289
+ }
290
+ if (bounds.x + dx < CLAMP_MARGIN) {
291
+ dx = CLAMP_MARGIN - bounds.x;
292
+ }
293
+ if (bounds.y < CLAMP_MARGIN) {
294
+ dy = CLAMP_MARGIN - bounds.y;
295
+ }
296
+ if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
297
+ dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
298
+ }
299
+ if (dx === 0 && dy === 0) continue;
300
+ const newX = annotation.label.x + dx;
301
+ const newY = annotation.label.y + dy;
302
+ const connector = annotation.label.connector;
303
+ annotation.label = {
304
+ ...annotation.label,
305
+ x: newX,
306
+ y: newY,
307
+ connector: connector ? recomputeConnector(
308
+ { ...annotation.label, x: newX, y: newY },
309
+ connector.to.x,
310
+ connector.to.y
311
+ ) : void 0
312
+ };
313
+ }
314
+ }
315
+
316
+ // src/annotations/resolve-text.ts
86
317
  function makeAnnotationLabelStyle(fontSize, fontWeight, fill, isDark) {
87
318
  const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
88
319
  return {
@@ -94,61 +325,6 @@ function makeAnnotationLabelStyle(fontSize, fontWeight, fill, isDark) {
94
325
  textAnchor: "start"
95
326
  };
96
327
  }
97
- function computeTextBounds(labelX, labelY, text, fontSize, fontWeight) {
98
- const lines = text.split("\n");
99
- const isMultiLine = lines.length > 1;
100
- const maxWidth = Math.max(...lines.map((line) => estimateTextWidth(line, fontSize, fontWeight)));
101
- const totalHeight = lines.length * fontSize * DEFAULT_LINE_HEIGHT;
102
- const x2 = isMultiLine ? labelX - maxWidth / 2 : labelX;
103
- return {
104
- x: x2,
105
- y: labelY - fontSize,
106
- width: maxWidth,
107
- height: totalHeight
108
- };
109
- }
110
- function computeAnchorOffset(anchor, _px, py, chartArea) {
111
- if (!anchor || anchor === "auto") {
112
- const isUpperHalf = py < chartArea.y + chartArea.height / 2;
113
- return isUpperHalf ? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } : { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET };
114
- }
115
- switch (anchor) {
116
- case "top":
117
- return { dx: 0, dy: -ANCHOR_OFFSET };
118
- case "bottom":
119
- return { dx: 0, dy: ANCHOR_OFFSET };
120
- case "left":
121
- return { dx: -ANCHOR_OFFSET, dy: 0 };
122
- case "right":
123
- return { dx: ANCHOR_OFFSET, dy: 0 };
124
- }
125
- }
126
- function applyOffset(base, offset) {
127
- if (!offset) return base;
128
- return {
129
- dx: base.dx + (offset.dx ?? 0),
130
- dy: base.dy + (offset.dy ?? 0)
131
- };
132
- }
133
- function computeConnectorOrigin(labelX, labelY, text, fontSize, fontWeight, targetX, targetY, connectorStyle) {
134
- const box = computeTextBounds(labelX, labelY, text, fontSize, fontWeight);
135
- const boxCenterX = box.x + box.width / 2;
136
- const boxCenterY = box.y + box.height / 2;
137
- if (connectorStyle === "curve") {
138
- return {
139
- x: box.x + box.width,
140
- y: boxCenterY
141
- };
142
- }
143
- const halfW = box.width / 2 || 1;
144
- const halfH = box.height / 2 || 1;
145
- const ndx = (targetX - boxCenterX) / halfW;
146
- const ndy = (targetY - boxCenterY) / halfH;
147
- if (Math.abs(ndy) >= Math.abs(ndx)) {
148
- return ndy < 0 ? { x: boxCenterX, y: box.y } : { x: boxCenterX, y: box.y + box.height };
149
- }
150
- return ndx < 0 ? { x: box.x, y: boxCenterY } : { x: box.x + box.width, y: boxCenterY };
151
- }
152
328
  function resolveTextAnnotation(annotation, scales, chartArea, isDark) {
153
329
  const px = resolvePosition(annotation.x, scales.x);
154
330
  const py = resolvePosition(annotation.y, scales.y);
@@ -217,6 +393,8 @@ function resolveTextAnnotation(annotation, scales, chartArea, isDark) {
217
393
  zIndex: annotation.zIndex
218
394
  };
219
395
  }
396
+
397
+ // src/annotations/resolve-range.ts
220
398
  function resolveRangeAnnotation(annotation, scales, chartArea, isDark) {
221
399
  let x2 = chartArea.x;
222
400
  let y2 = chartArea.y;
@@ -252,310 +430,125 @@ function resolveRangeAnnotation(annotation, scales, chartArea, isDark) {
252
430
  }
253
431
  const baseX = centered ? x2 + width / 2 : anchor === "right" ? x2 + width : x2;
254
432
  label = {
255
- text: annotation.label,
256
- x: baseX + labelDelta.dx,
257
- y: y2 + labelDelta.dy,
258
- style,
259
- visible: true
260
- };
261
- }
262
- const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
263
- return {
264
- type: "range",
265
- id: annotation.id,
266
- rect,
267
- label,
268
- fill: annotation.fill ?? DEFAULT_RANGE_FILL,
269
- opacity: annotation.opacity ?? defaultOpacity,
270
- stroke: annotation.stroke,
271
- zIndex: annotation.zIndex
272
- };
273
- }
274
- function resolveRefLineAnnotation(annotation, scales, chartArea, isDark) {
275
- let start;
276
- let end;
277
- if (annotation.y !== void 0) {
278
- const yPx = resolvePosition(annotation.y, scales.y);
279
- if (yPx === null) return null;
280
- start = { x: chartArea.x, y: yPx };
281
- end = { x: chartArea.x + chartArea.width, y: yPx };
282
- } else if (annotation.x !== void 0) {
283
- const xPx = resolvePosition(annotation.x, scales.x);
284
- if (xPx === null) return null;
285
- start = { x: xPx, y: chartArea.y };
286
- end = { x: xPx, y: chartArea.y + chartArea.height };
287
- } else {
288
- return null;
289
- }
290
- let strokeDasharray;
291
- if (annotation.style === "dashed" || annotation.style === void 0) {
292
- strokeDasharray = DEFAULT_REFLINE_DASH;
293
- } else if (annotation.style === "dotted") {
294
- strokeDasharray = "2 2";
295
- }
296
- let label;
297
- if (annotation.label) {
298
- const isHorizontal = annotation.y !== void 0;
299
- const anchor = annotation.labelAnchor ?? (isHorizontal ? "top" : "left");
300
- let baseDx;
301
- let baseDy;
302
- let labelX;
303
- let labelY;
304
- let textAnchor;
305
- if (isHorizontal) {
306
- if (anchor === "left") {
307
- baseDx = 4;
308
- baseDy = -4;
309
- labelX = start.x;
310
- labelY = start.y;
311
- textAnchor = "start";
312
- } else if (anchor === "bottom") {
313
- baseDx = -4;
314
- baseDy = 14;
315
- labelX = end.x;
316
- labelY = end.y;
317
- textAnchor = "end";
318
- } else {
319
- baseDx = -4;
320
- baseDy = -4;
321
- labelX = end.x;
322
- labelY = end.y;
323
- textAnchor = "end";
324
- }
325
- } else {
326
- if (anchor === "right") {
327
- baseDx = -4;
328
- baseDy = 14;
329
- labelX = start.x;
330
- labelY = start.y;
331
- textAnchor = "end";
332
- } else if (anchor === "bottom") {
333
- baseDx = 4;
334
- baseDy = -4;
335
- labelX = start.x;
336
- labelY = end.y;
337
- textAnchor = "start";
338
- } else {
339
- baseDx = 4;
340
- baseDy = 14;
341
- labelX = start.x;
342
- labelY = start.y;
343
- textAnchor = "start";
344
- }
345
- }
346
- const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
347
- const defaultStroke2 = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
348
- const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke2, isDark);
349
- style.textAnchor = textAnchor;
350
- label = {
351
- text: annotation.label,
352
- x: labelX + labelDelta.dx,
353
- y: labelY + labelDelta.dy,
354
- style,
355
- visible: true
356
- };
357
- }
358
- const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
359
- return {
360
- type: "refline",
361
- id: annotation.id,
362
- line: { start, end },
363
- label,
364
- stroke: annotation.stroke ?? defaultStroke,
365
- strokeDasharray,
366
- strokeWidth: annotation.strokeWidth ?? 1,
367
- zIndex: annotation.zIndex
368
- };
369
- }
370
- function estimateLabelBounds(label) {
371
- const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
372
- const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
373
- return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
374
- }
375
- var NUDGE_PADDING = 6;
376
- function generateNudgeCandidates(selfBounds, obstacles, padding) {
377
- const candidates = [];
378
- for (const obs of obstacles) {
379
- const belowDy = obs.y + obs.height + padding - selfBounds.y;
380
- candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
381
- const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
382
- candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
383
- const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
384
- candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
385
- const rightDx = obs.x + obs.width + padding - selfBounds.x;
386
- candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
387
- }
388
- candidates.sort((a, b) => a.distance - b.distance);
389
- return candidates;
390
- }
391
- function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, chartArea, obstacles) {
392
- if (annotation.type !== "text" || !annotation.label) return false;
393
- const labelBounds = estimateLabelBounds(annotation.label);
394
- const collidingObs = obstacles.filter(
395
- (obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs)
396
- );
397
- if (collidingObs.length === 0) return false;
398
- const px = resolvePosition(originalAnnotation.x, scales.x);
399
- const py = resolvePosition(originalAnnotation.y, scales.y);
400
- if (px === null || py === null) return false;
401
- const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
402
- const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split("\n").length);
403
- for (const { dx, dy } of candidates) {
404
- const newLabelX = annotation.label.x + dx;
405
- const newLabelY = annotation.label.y + dy;
406
- let newConnector = annotation.label.connector;
407
- if (newConnector) {
408
- const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
409
- const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
410
- const connStyle = newConnector.style === "curve" ? "curve" : "straight";
411
- const newFrom = computeConnectorOrigin(
412
- newLabelX,
413
- newLabelY,
414
- annotation.label.text,
415
- annFontSize,
416
- annFontWeight,
417
- px,
418
- py,
419
- connStyle
420
- );
421
- newConnector = { ...newConnector, from: newFrom };
422
- }
423
- const candidateLabel = {
424
- ...annotation.label,
425
- x: newLabelX,
426
- y: newLabelY,
427
- connector: newConnector
428
- };
429
- const candidateBounds = estimateLabelBounds(candidateLabel);
430
- const stillCollides = obstacles.some(
431
- (obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs)
432
- );
433
- if (stillCollides) continue;
434
- const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
435
- const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
436
- const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 10 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
437
- if (inBounds) {
438
- annotation.label = candidateLabel;
439
- return true;
440
- }
441
- }
442
- return false;
443
- }
444
- function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartArea) {
445
- const placedBounds = [];
446
- for (let i = 0; i < annotations.length; i++) {
447
- const annotation = annotations[i];
448
- if (annotation.type !== "text" || !annotation.label) {
449
- continue;
450
- }
451
- const bounds = estimateLabelBounds(annotation.label);
452
- const collidingBounds = placedBounds.filter(
453
- (pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb)
454
- );
455
- if (collidingBounds.length > 0) {
456
- const originalSpec = originalSpecs[i];
457
- if (originalSpec?.type === "text") {
458
- const px = resolvePosition(originalSpec.x, scales.x);
459
- const py = resolvePosition(originalSpec.y, scales.y);
460
- if (px !== null && py !== null) {
461
- const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
462
- const fontSize = bounds.height / Math.max(1, annotation.label.text.split("\n").length);
463
- for (const { dx, dy } of candidates) {
464
- const newLabelX = annotation.label.x + dx;
465
- const newLabelY = annotation.label.y + dy;
466
- const candidateLabel = {
467
- ...annotation.label,
468
- x: newLabelX,
469
- y: newLabelY
470
- };
471
- const candidateBounds = estimateLabelBounds(candidateLabel);
472
- const stillCollides = placedBounds.some(
473
- (pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb)
474
- );
475
- if (stillCollides) continue;
476
- const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
477
- const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
478
- const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 10 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
479
- if (inBounds) {
480
- let newConnector = annotation.label.connector;
481
- if (newConnector) {
482
- const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
483
- const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
484
- const connStyle = newConnector.style === "curve" ? "curve" : "straight";
485
- const newFrom = computeConnectorOrigin(
486
- newLabelX,
487
- newLabelY,
488
- annotation.label.text,
489
- annFontSize,
490
- annFontWeight,
491
- px,
492
- py,
493
- connStyle
494
- );
495
- newConnector = { ...newConnector, from: newFrom };
496
- }
497
- annotation.label = {
498
- ...annotation.label,
499
- x: newLabelX,
500
- y: newLabelY,
501
- connector: newConnector
502
- };
503
- break;
504
- }
505
- }
506
- }
507
- }
508
- }
509
- placedBounds.push(estimateLabelBounds(annotation.label));
433
+ text: annotation.label,
434
+ x: baseX + labelDelta.dx,
435
+ y: y2 + labelDelta.dy,
436
+ style,
437
+ visible: true
438
+ };
510
439
  }
440
+ const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
441
+ return {
442
+ type: "range",
443
+ id: annotation.id,
444
+ rect,
445
+ label,
446
+ fill: annotation.fill ?? DEFAULT_RANGE_FILL,
447
+ opacity: annotation.opacity ?? defaultOpacity,
448
+ stroke: annotation.stroke,
449
+ zIndex: annotation.zIndex
450
+ };
511
451
  }
512
- var CLAMP_MARGIN = 4;
513
- function clampAnnotationsToBounds(annotations, svgWidth, svgHeight) {
514
- for (const annotation of annotations) {
515
- if (annotation.type !== "text" || !annotation.label) continue;
516
- const bounds = estimateLabelBounds(annotation.label);
517
- let dx = 0;
518
- let dy = 0;
519
- if (bounds.x + bounds.width > svgWidth - CLAMP_MARGIN) {
520
- dx = svgWidth - CLAMP_MARGIN - (bounds.x + bounds.width);
521
- }
522
- if (bounds.x + dx < CLAMP_MARGIN) {
523
- dx = CLAMP_MARGIN - bounds.x;
524
- }
525
- if (bounds.y < CLAMP_MARGIN) {
526
- dy = CLAMP_MARGIN - bounds.y;
527
- }
528
- if (bounds.y + bounds.height + dy > svgHeight - CLAMP_MARGIN) {
529
- dy = svgHeight - CLAMP_MARGIN - (bounds.y + bounds.height);
530
- }
531
- if (dx === 0 && dy === 0) continue;
532
- const newX = annotation.label.x + dx;
533
- const newY = annotation.label.y + dy;
534
- let newConnector = annotation.label.connector;
535
- if (newConnector) {
536
- const fontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
537
- const fontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
538
- const connStyle = newConnector.style === "curve" ? "curve" : "straight";
539
- const newFrom = computeConnectorOrigin(
540
- newX,
541
- newY,
542
- annotation.label.text,
543
- fontSize,
544
- fontWeight,
545
- newConnector.to.x,
546
- newConnector.to.y,
547
- connStyle
548
- );
549
- newConnector = { ...newConnector, from: newFrom };
452
+
453
+ // src/annotations/resolve-refline.ts
454
+ function resolveRefLineAnnotation(annotation, scales, chartArea, isDark) {
455
+ let start;
456
+ let end;
457
+ if (annotation.y !== void 0) {
458
+ const yPx = resolvePosition(annotation.y, scales.y);
459
+ if (yPx === null) return null;
460
+ start = { x: chartArea.x, y: yPx };
461
+ end = { x: chartArea.x + chartArea.width, y: yPx };
462
+ } else if (annotation.x !== void 0) {
463
+ const xPx = resolvePosition(annotation.x, scales.x);
464
+ if (xPx === null) return null;
465
+ start = { x: xPx, y: chartArea.y };
466
+ end = { x: xPx, y: chartArea.y + chartArea.height };
467
+ } else {
468
+ return null;
469
+ }
470
+ let strokeDasharray;
471
+ if (annotation.style === "dashed" || annotation.style === void 0) {
472
+ strokeDasharray = DEFAULT_REFLINE_DASH;
473
+ } else if (annotation.style === "dotted") {
474
+ strokeDasharray = "2 2";
475
+ }
476
+ let label;
477
+ if (annotation.label) {
478
+ const isHorizontal = annotation.y !== void 0;
479
+ const anchor = annotation.labelAnchor ?? (isHorizontal ? "top" : "left");
480
+ let baseDx;
481
+ let baseDy;
482
+ let labelX;
483
+ let labelY;
484
+ let textAnchor;
485
+ if (isHorizontal) {
486
+ if (anchor === "left") {
487
+ baseDx = 4;
488
+ baseDy = -4;
489
+ labelX = start.x;
490
+ labelY = start.y;
491
+ textAnchor = "start";
492
+ } else if (anchor === "bottom") {
493
+ baseDx = -4;
494
+ baseDy = 14;
495
+ labelX = end.x;
496
+ labelY = end.y;
497
+ textAnchor = "end";
498
+ } else {
499
+ baseDx = -4;
500
+ baseDy = -4;
501
+ labelX = end.x;
502
+ labelY = end.y;
503
+ textAnchor = "end";
504
+ }
505
+ } else {
506
+ if (anchor === "right") {
507
+ baseDx = -4;
508
+ baseDy = 14;
509
+ labelX = start.x;
510
+ labelY = start.y;
511
+ textAnchor = "end";
512
+ } else if (anchor === "bottom") {
513
+ baseDx = 4;
514
+ baseDy = -4;
515
+ labelX = start.x;
516
+ labelY = end.y;
517
+ textAnchor = "start";
518
+ } else {
519
+ baseDx = 4;
520
+ baseDy = 14;
521
+ labelX = start.x;
522
+ labelY = start.y;
523
+ textAnchor = "start";
524
+ }
550
525
  }
551
- annotation.label = {
552
- ...annotation.label,
553
- x: newX,
554
- y: newY,
555
- connector: newConnector
526
+ const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
527
+ const defaultStroke2 = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
528
+ const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke2, isDark);
529
+ style.textAnchor = textAnchor;
530
+ label = {
531
+ text: annotation.label,
532
+ x: labelX + labelDelta.dx,
533
+ y: labelY + labelDelta.dy,
534
+ style,
535
+ visible: true
556
536
  };
557
537
  }
538
+ const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
539
+ return {
540
+ type: "refline",
541
+ id: annotation.id,
542
+ line: { start, end },
543
+ label,
544
+ stroke: annotation.stroke ?? defaultStroke,
545
+ strokeDasharray,
546
+ strokeWidth: annotation.strokeWidth ?? 1,
547
+ zIndex: annotation.zIndex
548
+ };
558
549
  }
550
+
551
+ // src/annotations/compute.ts
559
552
  function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = [], svgDimensions) {
560
553
  if (strategy.annotationPosition === "tooltip-only") {
561
554
  return [];
@@ -788,6 +781,7 @@ function computeBarMarks(spec, scales, _chartArea, _strategy) {
788
781
  scales
789
782
  );
790
783
  }
784
+ const stackMode = xChannel.stack === "normalize" ? "normalize" : xChannel.stack === "center" ? "center" : "zero";
791
785
  return computeStackedBars(
792
786
  spec.data,
793
787
  xChannel.field,
@@ -797,7 +791,8 @@ function computeBarMarks(spec, scales, _chartArea, _strategy) {
797
791
  yScale,
798
792
  bandwidth,
799
793
  baseline,
800
- scales
794
+ scales,
795
+ stackMode
801
796
  );
802
797
  }
803
798
  return computeColoredBars(
@@ -812,27 +807,33 @@ function computeBarMarks(spec, scales, _chartArea, _strategy) {
812
807
  scales
813
808
  );
814
809
  }
815
- function computeStackedBars(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, _baseline, scales) {
810
+ function computeStackedBars(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, _baseline, scales, stackMode = "zero") {
816
811
  const marks = [];
817
812
  const categoryGroups = groupByField(data, categoryField);
818
813
  for (const [category, rows] of categoryGroups) {
819
814
  const bandY = yScale(category);
820
815
  if (bandY === void 0) continue;
821
- let cumulativeValue = 0;
816
+ let categoryTotal = 0;
822
817
  for (const row of rows) {
823
- const groupKey = String(row[colorField] ?? "");
824
- const value2 = Number(row[valueField] ?? 0);
825
- if (!Number.isFinite(value2) || value2 <= 0) continue;
826
- const color2 = getColor(scales, groupKey);
818
+ const v = Number(row[valueField] ?? 0);
819
+ if (Number.isFinite(v) && v > 0) categoryTotal += v;
820
+ }
821
+ let cumulativeValue = stackMode === "center" ? -categoryTotal / 2 : 0;
822
+ for (const row of rows) {
823
+ const groupKey2 = String(row[colorField] ?? "");
824
+ const rawValue = Number(row[valueField] ?? 0);
825
+ if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
826
+ const value2 = stackMode === "normalize" && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
827
+ const color2 = getColor(scales, groupKey2);
827
828
  const xLeft = xScale(cumulativeValue);
828
829
  const xRight = xScale(cumulativeValue + value2);
829
830
  const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
830
831
  const aria = {
831
- label: `${category}, ${groupKey}: ${formatBarValue(value2)}`
832
+ label: `${category}, ${groupKey2}: ${formatBarValue(rawValue)}`
832
833
  };
833
834
  marks.push({
834
835
  type: "rect",
835
- x: xLeft,
836
+ x: Math.min(xLeft, xRight),
836
837
  y: bandY,
837
838
  width: barWidth,
838
839
  height: bandwidth,
@@ -866,16 +867,16 @@ function computeGroupedBars(data, valueField, categoryField, colorField, xScale,
866
867
  const bandY = yScale(category);
867
868
  if (bandY === void 0) continue;
868
869
  for (const row of rows) {
869
- const groupKey = String(row[colorField] ?? "");
870
+ const groupKey2 = String(row[colorField] ?? "");
870
871
  const value2 = Number(row[valueField] ?? 0);
871
872
  if (!Number.isFinite(value2)) continue;
872
- const groupIndex = groupIndexMap.get(groupKey) ?? 0;
873
- const color2 = getColor(scales, groupKey);
873
+ const groupIndex = groupIndexMap.get(groupKey2) ?? 0;
874
+ const color2 = getColor(scales, groupKey2);
874
875
  const xPos = value2 >= 0 ? baseline : xScale(value2);
875
876
  const barWidth = Math.max(Math.abs(xScale(value2) - baseline), MIN_BAR_WIDTH);
876
877
  const subY = bandY + groupIndex * (subBandHeight + gap);
877
878
  const aria = {
878
- label: `${category}, ${groupKey}: ${formatBarValue(value2)}`
879
+ label: `${category}, ${groupKey2}: ${formatBarValue(value2)}`
879
880
  };
880
881
  marks.push({
881
882
  type: "rect",
@@ -901,12 +902,12 @@ function computeColoredBars(data, valueField, categoryField, colorField, xScale,
901
902
  if (!Number.isFinite(value2)) continue;
902
903
  const bandY = yScale(category);
903
904
  if (bandY === void 0) continue;
904
- const groupKey = String(row[colorField] ?? "");
905
- const color2 = getColor(scales, groupKey);
905
+ const groupKey2 = String(row[colorField] ?? "");
906
+ const color2 = getColor(scales, groupKey2);
906
907
  const xPos = value2 >= 0 ? baseline : xScale(value2);
907
908
  const barWidth = Math.max(Math.abs(xScale(value2) - baseline), MIN_BAR_WIDTH);
908
909
  const aria = {
909
- label: `${category}, ${groupKey}: ${formatBarValue(value2)}`
910
+ label: `${category}, ${groupKey2}: ${formatBarValue(value2)}`
910
911
  };
911
912
  marks.push({
912
913
  type: "rect",
@@ -979,7 +980,7 @@ var SUFFIX_MULTIPLIERS = {
979
980
  T: 1e12
980
981
  };
981
982
  function parseDisplayNumber(raw) {
982
- const trimmed = raw.trim();
983
+ const trimmed = raw.trim().replace(/\u2212/g, "-");
983
984
  if (!trimmed) return NaN;
984
985
  const last = trimmed[trimmed.length - 1].toUpperCase();
985
986
  const multiplier = SUFFIX_MULTIPLIERS[last];
@@ -988,6 +989,9 @@ function parseDisplayNumber(raw) {
988
989
  const n = Number(numPart);
989
990
  return Number.isNaN(n) ? NaN : n * multiplier;
990
991
  }
992
+ if (last === "%") {
993
+ return Number(trimmed.slice(0, -1).replace(/,/g, ""));
994
+ }
991
995
  return Number(trimmed.replace(/,/g, ""));
992
996
  }
993
997
  var LABEL_FONT_SIZE = 11;
@@ -1149,6 +1153,7 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy) {
1149
1153
  scales
1150
1154
  );
1151
1155
  }
1156
+ const stackMode = yChannel.stack === "normalize" ? "normalize" : yChannel.stack === "center" ? "center" : "zero";
1152
1157
  return computeStackedColumns(
1153
1158
  spec.data,
1154
1159
  xChannel.field,
@@ -1158,7 +1163,8 @@ function computeColumnMarks(spec, scales, _chartArea, _strategy) {
1158
1163
  yScale,
1159
1164
  bandwidth,
1160
1165
  baseline,
1161
- scales
1166
+ scales,
1167
+ stackMode
1162
1168
  );
1163
1169
  }
1164
1170
  return computeColoredColumns(
@@ -1236,13 +1242,13 @@ function computeColoredColumns(data, categoryField, valueField, colorField, xSca
1236
1242
  if (!Number.isFinite(value2)) continue;
1237
1243
  const bandX = xScale(category);
1238
1244
  if (bandX === void 0) continue;
1239
- const groupKey = String(row[colorField] ?? "");
1240
- const color2 = getColor(scales, groupKey);
1245
+ const groupKey2 = String(row[colorField] ?? "");
1246
+ const color2 = getColor(scales, groupKey2);
1241
1247
  const yPos = yScale(value2);
1242
1248
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
1243
1249
  const y2 = value2 >= 0 ? yPos : baseline;
1244
1250
  const aria = {
1245
- label: `${category}, ${groupKey}: ${formatColumnValue(value2)}`
1251
+ label: `${category}, ${groupKey2}: ${formatColumnValue(value2)}`
1246
1252
  };
1247
1253
  marks.push({
1248
1254
  type: "rect",
@@ -1280,17 +1286,17 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
1280
1286
  const bandX = xScale(category);
1281
1287
  if (bandX === void 0) continue;
1282
1288
  for (const row of rows) {
1283
- const groupKey = String(row[colorField] ?? "");
1289
+ const groupKey2 = String(row[colorField] ?? "");
1284
1290
  const value2 = Number(row[valueField] ?? 0);
1285
1291
  if (!Number.isFinite(value2)) continue;
1286
- const groupIndex = groupIndexMap.get(groupKey) ?? 0;
1287
- const color2 = getColor(scales, groupKey);
1292
+ const groupIndex = groupIndexMap.get(groupKey2) ?? 0;
1293
+ const color2 = getColor(scales, groupKey2);
1288
1294
  const yPos = yScale(value2);
1289
1295
  const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
1290
1296
  const y2 = value2 >= 0 ? yPos : baseline;
1291
1297
  const subX = bandX + groupIndex * (subBandWidth + gap);
1292
1298
  const aria = {
1293
- label: `${category}, ${groupKey}: ${formatColumnValue(value2)}`
1299
+ label: `${category}, ${groupKey2}: ${formatColumnValue(value2)}`
1294
1300
  };
1295
1301
  marks.push({
1296
1302
  type: "rect",
@@ -1308,28 +1314,34 @@ function computeGroupedColumns(data, categoryField, valueField, colorField, xSca
1308
1314
  }
1309
1315
  return marks;
1310
1316
  }
1311
- function computeStackedColumns(data, categoryField, valueField, colorField, xScale, yScale, bandwidth, _baseline, scales) {
1317
+ function computeStackedColumns(data, categoryField, valueField, colorField, xScale, yScale, bandwidth, _baseline, scales, stackMode = "zero") {
1312
1318
  const marks = [];
1313
1319
  const categoryGroups = groupByField(data, categoryField);
1314
1320
  for (const [category, rows] of categoryGroups) {
1315
1321
  const bandX = xScale(category);
1316
1322
  if (bandX === void 0) continue;
1317
- let cumulativeValue = 0;
1323
+ let categoryTotal = 0;
1318
1324
  for (const row of rows) {
1319
- const groupKey = String(row[colorField] ?? "");
1320
- const value2 = Number(row[valueField] ?? 0);
1321
- if (!Number.isFinite(value2) || value2 <= 0) continue;
1322
- const color2 = getColor(scales, groupKey);
1325
+ const v = Number(row[valueField] ?? 0);
1326
+ if (Number.isFinite(v) && v > 0) categoryTotal += v;
1327
+ }
1328
+ let cumulativeValue = stackMode === "center" ? -categoryTotal / 2 : 0;
1329
+ for (const row of rows) {
1330
+ const groupKey2 = String(row[colorField] ?? "");
1331
+ const rawValue = Number(row[valueField] ?? 0);
1332
+ if (!Number.isFinite(rawValue) || rawValue <= 0) continue;
1333
+ const value2 = stackMode === "normalize" && categoryTotal > 0 ? rawValue / categoryTotal : rawValue;
1334
+ const color2 = getColor(scales, groupKey2);
1323
1335
  const yTop = yScale(cumulativeValue + value2);
1324
1336
  const yBottom = yScale(cumulativeValue);
1325
1337
  const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
1326
1338
  const aria = {
1327
- label: `${category}, ${groupKey}: ${formatColumnValue(value2)}`
1339
+ label: `${category}, ${groupKey2}: ${formatColumnValue(rawValue)}`
1328
1340
  };
1329
1341
  marks.push({
1330
1342
  type: "rect",
1331
1343
  x: bandX,
1332
- y: yTop,
1344
+ y: Math.min(yTop, yBottom),
1333
1345
  width: bandwidth,
1334
1346
  height: columnHeight,
1335
1347
  fill: color2,
@@ -2521,6 +2533,26 @@ function stack_default() {
2521
2533
  return stack;
2522
2534
  }
2523
2535
 
2536
+ // ../../node_modules/.bun/d3-shape@3.2.0/node_modules/d3-shape/src/offset/expand.js
2537
+ function expand_default(series, order) {
2538
+ if (!((n = series.length) > 0)) return;
2539
+ for (var i, n, j = 0, m = series[0].length, y2; j < m; ++j) {
2540
+ for (y2 = i = 0; i < n; ++i) y2 += series[i][j][1] || 0;
2541
+ if (y2) for (i = 0; i < n; ++i) series[i][j][1] /= y2;
2542
+ }
2543
+ none_default(series, order);
2544
+ }
2545
+
2546
+ // ../../node_modules/.bun/d3-shape@3.2.0/node_modules/d3-shape/src/offset/silhouette.js
2547
+ function silhouette_default(series, order) {
2548
+ if (!((n = series.length) > 0)) return;
2549
+ for (var j = 0, s0 = series[order[0]], n, m = s0.length; j < m; ++j) {
2550
+ for (var i = 0, y2 = 0; i < n; ++i) y2 += series[i][j][1] || 0;
2551
+ s0[j][1] += s0[j][0] = -y2 / 2;
2552
+ }
2553
+ none_default(series, order);
2554
+ }
2555
+
2524
2556
  // src/charts/line/curves.ts
2525
2557
  var CURVE_MAP = {
2526
2558
  linear: linear_default,
@@ -2648,7 +2680,9 @@ function computeStackedArea(spec, scales, chartArea) {
2648
2680
  }
2649
2681
  return pivot;
2650
2682
  });
2651
- const stackGenerator = stack_default().keys(keys).order(none_default2).offset(none_default);
2683
+ const stackProp = yChannel.stack;
2684
+ const offsetFn = stackProp === "normalize" ? expand_default : stackProp === "center" ? silhouette_default : none_default;
2685
+ const stackGenerator = stack_default().keys(keys).order(none_default2).offset(offsetFn);
2652
2686
  const stackedData = stackGenerator(pivotData);
2653
2687
  const yScale = scales.y.scale;
2654
2688
  const marks = [];
@@ -3102,53 +3136,185 @@ function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333
3102
3136
  dominantBaseline: "central"
3103
3137
  }
3104
3138
  });
3105
- targetMarkIndices.push(mi);
3106
- }
3107
- if (candidates.length === 0) return [];
3108
- let resolved;
3109
- if (density === "all") {
3110
- resolved = candidates.map((c) => ({
3111
- text: c.text,
3112
- x: c.anchorX,
3113
- y: c.anchorY,
3114
- style: c.style,
3115
- visible: true
3116
- }));
3117
- } else {
3118
- resolved = resolveCollisions5(candidates);
3139
+ targetMarkIndices.push(mi);
3140
+ }
3141
+ if (candidates.length === 0) return [];
3142
+ let resolved;
3143
+ if (density === "all") {
3144
+ resolved = candidates.map((c) => ({
3145
+ text: c.text,
3146
+ x: c.anchorX,
3147
+ y: c.anchorY,
3148
+ style: c.style,
3149
+ visible: true
3150
+ }));
3151
+ } else {
3152
+ resolved = resolveCollisions5(candidates);
3153
+ }
3154
+ for (let i = 0; i < resolved.length && i < targetMarks.length; i++) {
3155
+ const label = resolved[i];
3156
+ const mark = targetMarks[i];
3157
+ if (label.visible) {
3158
+ label.connector = {
3159
+ from: { x: label.x, y: label.y },
3160
+ to: { x: mark.centroid.x, y: mark.centroid.y },
3161
+ stroke: _textFill,
3162
+ style: "straight"
3163
+ };
3164
+ }
3165
+ }
3166
+ return resolved;
3167
+ }
3168
+
3169
+ // src/charts/pie/index.ts
3170
+ var pieRenderer = (spec, scales, chartArea, strategy, theme) => {
3171
+ const marks = computePieMarks(spec, scales, chartArea, strategy, false);
3172
+ const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
3173
+ for (let i = 0; i < marks.length && i < labels.length; i++) {
3174
+ marks[i].label = labels[i];
3175
+ }
3176
+ return marks;
3177
+ };
3178
+ var donutRenderer = (spec, scales, chartArea, strategy, theme) => {
3179
+ const marks = computePieMarks(spec, scales, chartArea, strategy, true);
3180
+ const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
3181
+ for (let i = 0; i < marks.length && i < labels.length; i++) {
3182
+ marks[i].label = labels[i];
3183
+ }
3184
+ return marks;
3185
+ };
3186
+
3187
+ // src/charts/post-process.ts
3188
+ function computeMarkObstacles(marks, scales) {
3189
+ if (scales.y?.type === "band") {
3190
+ return computeBandRowObstacles(marks, scales);
3191
+ }
3192
+ const obstacles = [];
3193
+ for (const mark of marks) {
3194
+ if (mark.type === "rect") {
3195
+ const rm = mark;
3196
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
3197
+ } else if (mark.type === "point") {
3198
+ const pm = mark;
3199
+ obstacles.push({
3200
+ x: pm.cx - pm.r,
3201
+ y: pm.cy - pm.r,
3202
+ width: pm.r * 2,
3203
+ height: pm.r * 2
3204
+ });
3205
+ }
3206
+ }
3207
+ return obstacles;
3208
+ }
3209
+ function computeBandRowObstacles(marks, scales) {
3210
+ const rows = /* @__PURE__ */ new Map();
3211
+ for (const mark of marks) {
3212
+ let cy;
3213
+ let left2;
3214
+ let right2;
3215
+ if (mark.type === "point") {
3216
+ const pm = mark;
3217
+ cy = pm.cy;
3218
+ left2 = pm.cx - pm.r;
3219
+ right2 = pm.cx + pm.r;
3220
+ } else if (mark.type === "rect") {
3221
+ const rm = mark;
3222
+ cy = rm.y + rm.height / 2;
3223
+ left2 = rm.x;
3224
+ right2 = rm.x + rm.width;
3225
+ } else {
3226
+ continue;
3227
+ }
3228
+ const key = Math.round(cy);
3229
+ const existing = rows.get(key);
3230
+ if (existing) {
3231
+ existing.minX = Math.min(existing.minX, left2);
3232
+ existing.maxX = Math.max(existing.maxX, right2);
3233
+ } else {
3234
+ rows.set(key, { minX: left2, maxX: right2, bandY: cy });
3235
+ }
3236
+ }
3237
+ const bandScale = scales.y.scale;
3238
+ const bandwidth = bandScale.bandwidth?.() ?? 0;
3239
+ if (bandwidth === 0) return [];
3240
+ const obstacles = [];
3241
+ for (const { minX, maxX, bandY } of rows.values()) {
3242
+ obstacles.push({
3243
+ x: minX,
3244
+ y: bandY - bandwidth / 2,
3245
+ width: maxX - minX,
3246
+ height: bandwidth
3247
+ });
3119
3248
  }
3120
- for (let i = 0; i < resolved.length && i < targetMarks.length; i++) {
3121
- const label = resolved[i];
3122
- const mark = targetMarks[i];
3123
- if (label.visible) {
3124
- label.connector = {
3125
- from: { x: label.x, y: label.y },
3126
- to: { x: mark.centroid.x, y: mark.centroid.y },
3127
- stroke: _textFill,
3128
- style: "straight"
3129
- };
3249
+ return obstacles;
3250
+ }
3251
+ function resolveRendererKey(markType, encoding, markDef) {
3252
+ if (markType === "bar") {
3253
+ const xType = encoding.x?.type;
3254
+ const yType = encoding.y?.type;
3255
+ const isVertical = (xType === "nominal" || xType === "ordinal" || xType === "temporal") && yType === "quantitative";
3256
+ if (isVertical) {
3257
+ return "bar:vertical";
3258
+ }
3259
+ } else if (markType === "arc") {
3260
+ const innerRadius = markDef.innerRadius;
3261
+ if (innerRadius && innerRadius > 0) {
3262
+ return "arc:donut";
3130
3263
  }
3131
3264
  }
3132
- return resolved;
3265
+ return markType;
3133
3266
  }
3134
-
3135
- // src/charts/pie/index.ts
3136
- var pieRenderer = (spec, scales, chartArea, strategy, theme) => {
3137
- const marks = computePieMarks(spec, scales, chartArea, strategy, false);
3138
- const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
3139
- for (let i = 0; i < marks.length && i < labels.length; i++) {
3140
- marks[i].label = labels[i];
3267
+ function getMarkPrimaryValue(mark) {
3268
+ switch (mark.type) {
3269
+ case "rect":
3270
+ return mark.height;
3271
+ // bar height is the primary value encoding
3272
+ case "point":
3273
+ return mark.cy;
3274
+ // y position for scatter
3275
+ case "arc":
3276
+ return mark.endAngle - mark.startAngle;
3277
+ // arc angle extent
3278
+ case "line":
3279
+ case "area":
3280
+ return 0;
3281
+ // series marks don't have individual values
3282
+ default:
3283
+ return 0;
3141
3284
  }
3142
- return marks;
3143
- };
3144
- var donutRenderer = (spec, scales, chartArea, strategy, theme) => {
3145
- const marks = computePieMarks(spec, scales, chartArea, strategy, true);
3146
- const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
3147
- for (let i = 0; i < marks.length && i < labels.length; i++) {
3148
- marks[i].label = labels[i];
3285
+ }
3286
+ function assignAnimationIndices(marks, animation) {
3287
+ if (!animation?.enabled) return;
3288
+ if (animation.staggerOrder === "value") {
3289
+ const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
3290
+ indexed.sort((a, b) => {
3291
+ const av = getMarkPrimaryValue(a.mark);
3292
+ const bv = getMarkPrimaryValue(b.mark);
3293
+ return av - bv;
3294
+ });
3295
+ for (let i = 0; i < indexed.length; i++) {
3296
+ const m = indexed[i].mark;
3297
+ if (m.type === "rect" && m.stackGroup) continue;
3298
+ m.animationIndex = i;
3299
+ }
3149
3300
  }
3150
- return marks;
3151
- };
3301
+ const groupIndexMap = /* @__PURE__ */ new Map();
3302
+ const groupStackPos = /* @__PURE__ */ new Map();
3303
+ let nextGroupIndex = 0;
3304
+ for (const mark of marks) {
3305
+ if (mark.type === "rect" && mark.stackGroup) {
3306
+ const rect = mark;
3307
+ const group = rect.stackGroup;
3308
+ if (!groupIndexMap.has(group)) {
3309
+ groupIndexMap.set(group, nextGroupIndex++);
3310
+ }
3311
+ rect.animationIndex = groupIndexMap.get(group);
3312
+ const pos = groupStackPos.get(group) ?? 0;
3313
+ rect.stackPos = pos;
3314
+ groupStackPos.set(group, pos + 1);
3315
+ }
3316
+ }
3317
+ }
3152
3318
 
3153
3319
  // src/charts/registry.ts
3154
3320
  var renderers = /* @__PURE__ */ new Map();
@@ -6135,7 +6301,7 @@ function normalizeChrome(chrome) {
6135
6301
  };
6136
6302
  }
6137
6303
  function inferFieldType(data, field) {
6138
- const sampleSize = Math.min(10, data.length);
6304
+ const sampleSize = Math.min(50, data.length);
6139
6305
  let numericCount = 0;
6140
6306
  let dateCount = 0;
6141
6307
  let totalNonNull = 0;
@@ -7714,7 +7880,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText) {
7714
7880
  }));
7715
7881
  const shouldThin = scales.x.type !== "band" && !axisConfig?.tickCount && !axisConfig?.values;
7716
7882
  const ticks2 = shouldThin ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText) : allTicks;
7717
- let tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
7883
+ let tickAngle = axisConfig?.labelAngle;
7718
7884
  if (tickAngle === void 0 && scales.x.type === "band" && ticks2.length > 1) {
7719
7885
  const bandwidth = scales.x.scale.bandwidth();
7720
7886
  let maxLabelWidth = 0;
@@ -7726,7 +7892,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText) {
7726
7892
  tickAngle = -45;
7727
7893
  }
7728
7894
  }
7729
- const axisTitle = axisConfig?.title ?? axisConfig?.label;
7895
+ const axisTitle = axisConfig?.title;
7730
7896
  result.x = {
7731
7897
  ticks: ticks2,
7732
7898
  gridlines: axisConfig?.grid ? gridlines : [],
@@ -7756,14 +7922,14 @@ function computeAxes(scales, chartArea, strategy, theme, measureText) {
7756
7922
  } else {
7757
7923
  allTicks = continuousTicks(scales.y, yDensity);
7758
7924
  }
7759
- const gridlines = allTicks.map((t) => ({
7925
+ const shouldThin = scales.y.type !== "band" && !axisConfig?.tickCount && !axisConfig?.values;
7926
+ const ticks2 = shouldThin ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText) : allTicks;
7927
+ const gridlines = ticks2.map((t) => ({
7760
7928
  position: t.position,
7761
7929
  major: true
7762
7930
  }));
7763
- const shouldThin = scales.y.type !== "band" && !axisConfig?.tickCount && !axisConfig?.values;
7764
- const ticks2 = shouldThin ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText) : allTicks;
7765
- const axisTitle = axisConfig?.title ?? axisConfig?.label;
7766
- const tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
7931
+ const axisTitle = axisConfig?.title;
7932
+ const tickAngle = axisConfig?.labelAngle;
7767
7933
  result.y = {
7768
7934
  ticks: ticks2,
7769
7935
  // Y-axis gridlines are shown by default (standard editorial practice)
@@ -7825,8 +7991,8 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
7825
7991
  const isRadial = spec.markType === "arc";
7826
7992
  const encoding = spec.encoding;
7827
7993
  const xAxis = encoding.x?.axis;
7828
- const hasXAxisLabel = !!xAxis?.label;
7829
- const xTickAngle = xAxis?.tickAngle;
7994
+ const hasXAxisLabel = !!xAxis?.title;
7995
+ const xTickAngle = xAxis?.labelAngle;
7830
7996
  let xAxisHeight;
7831
7997
  if (isRadial) {
7832
7998
  xAxisHeight = 0;
@@ -8051,6 +8217,12 @@ function uniqueStrings(values) {
8051
8217
  }
8052
8218
  return result;
8053
8219
  }
8220
+ function applyCategoricalSort(values, sort) {
8221
+ if (sort === null) return values;
8222
+ const sorted = [...values].sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
8223
+ if (sort === "descending") sorted.reverse();
8224
+ return sorted;
8225
+ }
8054
8226
  function applyContinuousConfig(scale, channel) {
8055
8227
  if (channel.scale?.clamp) {
8056
8228
  scale.clamp(true);
@@ -8212,7 +8384,7 @@ function evenRange(start, end, count) {
8212
8384
  return Array.from({ length: count }, (_, i) => start + step * i);
8213
8385
  }
8214
8386
  function buildBandScale(channel, data, rangeStart, rangeEnd) {
8215
- const values = channel.scale?.domain ? channel.scale.domain : uniqueStrings(fieldValues(data, channel.field));
8387
+ const values = channel.scale?.domain ? channel.scale.domain : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
8216
8388
  const padding = channel.scale?.padding ?? 0.35;
8217
8389
  const scale = band().domain(values).range([rangeStart, rangeEnd]).padding(padding);
8218
8390
  if (channel.scale?.paddingInner !== void 0) {
@@ -8228,7 +8400,7 @@ function buildBandScale(channel, data, rangeStart, rangeEnd) {
8228
8400
  return { scale, type: "band", channel };
8229
8401
  }
8230
8402
  function buildPointScale(channel, data, rangeStart, rangeEnd) {
8231
- const values = channel.scale?.domain ? channel.scale.domain : uniqueStrings(fieldValues(data, channel.field));
8403
+ const values = channel.scale?.domain ? channel.scale.domain : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
8232
8404
  const padding = channel.scale?.padding ?? 0.5;
8233
8405
  const scale = point4().domain(values).range([rangeStart, rangeEnd]).padding(padding);
8234
8406
  if (channel.scale?.reverse) {
@@ -8238,7 +8410,8 @@ function buildPointScale(channel, data, rangeStart, rangeEnd) {
8238
8410
  return { scale, type: "point", channel };
8239
8411
  }
8240
8412
  function buildOrdinalColorScale(channel, data, palette) {
8241
- const values = uniqueStrings(fieldValues(data, channel.field));
8413
+ const explicitDomain = channel.scale?.domain;
8414
+ const values = explicitDomain ? explicitDomain.map(String) : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
8242
8415
  const scale = ordinal().domain(values).range(palette);
8243
8416
  return { scale, type: "ordinal", channel };
8244
8417
  }
@@ -8316,25 +8489,49 @@ function computeScales(spec, chartArea, data) {
8316
8489
  }
8317
8490
  if (encoding.x) {
8318
8491
  let xData = data;
8492
+ let xChannel = encoding.x;
8319
8493
  const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
8320
8494
  if (spec.markType === "bar" && encoding.color && encoding.x.type === "quantitative" && !xStackDisabled) {
8321
- const yField = encoding.y?.field;
8322
- const xField = encoding.x.field;
8323
- if (yField) {
8324
- const sums = /* @__PURE__ */ new Map();
8325
- for (const row of data) {
8326
- const cat = String(row[yField] ?? "");
8327
- const val = Number(row[xField] ?? 0);
8328
- if (Number.isFinite(val) && val > 0) {
8329
- sums.set(cat, (sums.get(cat) ?? 0) + val);
8495
+ if (encoding.x.stack === "normalize") {
8496
+ xChannel = { ...encoding.x, scale: { ...encoding.x.scale, domain: [0, 1], nice: false } };
8497
+ } else if (encoding.x.stack === "center") {
8498
+ const yField = encoding.y?.field;
8499
+ const xField = encoding.x.field;
8500
+ if (yField) {
8501
+ const sums = /* @__PURE__ */ new Map();
8502
+ for (const row of data) {
8503
+ const cat = String(row[yField] ?? "");
8504
+ const val = Number(row[xField] ?? 0);
8505
+ if (Number.isFinite(val) && val > 0) {
8506
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
8507
+ }
8508
+ }
8509
+ const maxSum = Math.max(...sums.values(), 0);
8510
+ const half = maxSum / 2;
8511
+ xChannel = {
8512
+ ...encoding.x,
8513
+ scale: { ...encoding.x.scale, domain: [-half, half], zero: true }
8514
+ };
8515
+ }
8516
+ } else {
8517
+ const yField = encoding.y?.field;
8518
+ const xField = encoding.x.field;
8519
+ if (yField) {
8520
+ const sums = /* @__PURE__ */ new Map();
8521
+ for (const row of data) {
8522
+ const cat = String(row[yField] ?? "");
8523
+ const val = Number(row[xField] ?? 0);
8524
+ if (Number.isFinite(val) && val > 0) {
8525
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
8526
+ }
8330
8527
  }
8528
+ const maxSum = Math.max(...sums.values(), 0);
8529
+ xData = [...data, { [xField]: maxSum }];
8331
8530
  }
8332
- const maxSum = Math.max(...sums.values(), 0);
8333
- xData = [...data, { [xField]: maxSum }];
8334
8531
  }
8335
8532
  }
8336
8533
  result.x = buildPositionalScale(
8337
- encoding.x,
8534
+ xChannel,
8338
8535
  xData,
8339
8536
  chartArea.x,
8340
8537
  chartArea.x + chartArea.width,
@@ -8344,26 +8541,50 @@ function computeScales(spec, chartArea, data) {
8344
8541
  }
8345
8542
  if (encoding.y) {
8346
8543
  let yData = data;
8544
+ let yChannel = encoding.y;
8347
8545
  const isVerticalBar = spec.markType === "bar" && (encoding.x?.type === "nominal" || encoding.x?.type === "ordinal") && encoding.y.type === "quantitative";
8348
8546
  const yStackDisabled = encoding.y.stack === null || encoding.y.stack === false;
8349
8547
  if ((isVerticalBar || spec.markType === "area") && encoding.color && encoding.y.type === "quantitative" && !yStackDisabled) {
8350
- const xField = encoding.x?.field;
8351
- const yField = encoding.y.field;
8352
- if (xField) {
8353
- const sums = /* @__PURE__ */ new Map();
8354
- for (const row of data) {
8355
- const cat = String(row[xField] ?? "");
8356
- const val = Number(row[yField] ?? 0);
8357
- if (Number.isFinite(val) && val > 0) {
8358
- sums.set(cat, (sums.get(cat) ?? 0) + val);
8548
+ if (encoding.y.stack === "normalize") {
8549
+ yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };
8550
+ } else if (encoding.y.stack === "center") {
8551
+ const xField = encoding.x?.field;
8552
+ const yField = encoding.y.field;
8553
+ if (xField) {
8554
+ const sums = /* @__PURE__ */ new Map();
8555
+ for (const row of data) {
8556
+ const cat = String(row[xField] ?? "");
8557
+ const val = Number(row[yField] ?? 0);
8558
+ if (Number.isFinite(val) && val > 0) {
8559
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
8560
+ }
8359
8561
  }
8562
+ const maxSum = Math.max(...sums.values(), 0);
8563
+ const half = maxSum / 2;
8564
+ yChannel = {
8565
+ ...encoding.y,
8566
+ scale: { ...encoding.y.scale, domain: [-half, half], zero: true }
8567
+ };
8568
+ }
8569
+ } else {
8570
+ const xField = encoding.x?.field;
8571
+ const yField = encoding.y.field;
8572
+ if (xField) {
8573
+ const sums = /* @__PURE__ */ new Map();
8574
+ for (const row of data) {
8575
+ const cat = String(row[xField] ?? "");
8576
+ const val = Number(row[yField] ?? 0);
8577
+ if (Number.isFinite(val) && val > 0) {
8578
+ sums.set(cat, (sums.get(cat) ?? 0) + val);
8579
+ }
8580
+ }
8581
+ const maxSum = Math.max(...sums.values(), 0);
8582
+ yData = [...data, { [yField]: maxSum }];
8360
8583
  }
8361
- const maxSum = Math.max(...sums.values(), 0);
8362
- yData = [...data, { [yField]: maxSum }];
8363
8584
  }
8364
8585
  }
8365
8586
  result.y = buildPositionalScale(
8366
- encoding.y,
8587
+ yChannel,
8367
8588
  yData,
8368
8589
  chartArea.y + chartArea.height,
8369
8590
  chartArea.y,
@@ -10183,10 +10404,16 @@ function formatValue(value2, fieldType, format2) {
10183
10404
  }
10184
10405
  return String(value2);
10185
10406
  }
10407
+ function resolveLabel(ch) {
10408
+ return ch.title ?? ch.axis?.title ?? ch.field;
10409
+ }
10410
+ function resolveFormat(ch) {
10411
+ return ch.format ?? ch.axis?.format;
10412
+ }
10186
10413
  function buildExplicitTooltipFields(row, channels) {
10187
10414
  return channels.map((ch) => ({
10188
- label: ch.axis?.label ?? ch.field,
10189
- value: formatValue(row[ch.field], ch.type, ch.axis?.format)
10415
+ label: resolveLabel(ch),
10416
+ value: formatValue(row[ch.field], ch.type, resolveFormat(ch))
10190
10417
  }));
10191
10418
  }
10192
10419
  function buildFields(row, encoding, color2) {
@@ -10197,21 +10424,25 @@ function buildFields(row, encoding, color2) {
10197
10424
  const fields = [];
10198
10425
  if (encoding.y) {
10199
10426
  fields.push({
10200
- label: encoding.y.axis?.label ?? encoding.y.field,
10201
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
10427
+ label: resolveLabel(encoding.y),
10428
+ value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
10202
10429
  color: color2
10203
10430
  });
10204
10431
  }
10205
10432
  if (encoding.x) {
10206
10433
  fields.push({
10207
- label: encoding.x.axis?.label ?? encoding.x.field,
10208
- value: formatValue(row[encoding.x.field], encoding.x.type, encoding.x.axis?.format)
10434
+ label: resolveLabel(encoding.x),
10435
+ value: formatValue(row[encoding.x.field], encoding.x.type, resolveFormat(encoding.x))
10209
10436
  });
10210
10437
  }
10211
10438
  if (encoding.size && "field" in encoding.size) {
10212
10439
  fields.push({
10213
- label: encoding.size.axis?.label ?? encoding.size.field,
10214
- value: formatValue(row[encoding.size.field], encoding.size.type, encoding.size.axis?.format)
10440
+ label: resolveLabel(encoding.size),
10441
+ value: formatValue(
10442
+ row[encoding.size.field],
10443
+ encoding.size.type,
10444
+ resolveFormat(encoding.size)
10445
+ )
10215
10446
  });
10216
10447
  }
10217
10448
  return fields;
@@ -10264,14 +10495,14 @@ function tooltipsForArc(mark, encoding, markIndex) {
10264
10495
  if (encoding.y) {
10265
10496
  fields.push({
10266
10497
  label: categoryName,
10267
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
10498
+ value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
10268
10499
  color: getRepresentativeColor9(mark.fill)
10269
10500
  });
10270
10501
  }
10271
10502
  } else if (encoding.y) {
10272
10503
  fields.push({
10273
- label: encoding.y.field,
10274
- value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
10504
+ label: resolveLabel(encoding.y),
10505
+ value: formatValue(row[encoding.y.field], encoding.y.type, resolveFormat(encoding.y)),
10275
10506
  color: getRepresentativeColor9(mark.fill)
10276
10507
  });
10277
10508
  }
@@ -10319,6 +10550,93 @@ function computeTooltipDescriptors(spec, marks) {
10319
10550
  return descriptors;
10320
10551
  }
10321
10552
 
10553
+ // src/transforms/aggregate.ts
10554
+ function computeAggregate(op, values) {
10555
+ if (values.length === 0) return 0;
10556
+ switch (op) {
10557
+ case "count":
10558
+ return values.length;
10559
+ case "sum":
10560
+ return values.reduce((a, b) => a + b, 0);
10561
+ case "mean": {
10562
+ const sum2 = values.reduce((a, b) => a + b, 0);
10563
+ return sum2 / values.length;
10564
+ }
10565
+ case "median": {
10566
+ const sorted = [...values].sort((a, b) => a - b);
10567
+ const mid = Math.floor(sorted.length / 2);
10568
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
10569
+ }
10570
+ case "min":
10571
+ return Math.min(...values);
10572
+ case "max":
10573
+ return Math.max(...values);
10574
+ case "variance": {
10575
+ if (values.length < 2) return 0;
10576
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
10577
+ return values.reduce((a, v) => a + (v - mean) ** 2, 0) / values.length;
10578
+ }
10579
+ case "stdev": {
10580
+ if (values.length < 2) return 0;
10581
+ const m = values.reduce((a, b) => a + b, 0) / values.length;
10582
+ return Math.sqrt(values.reduce((a, v) => a + (v - m) ** 2, 0) / values.length);
10583
+ }
10584
+ case "q1": {
10585
+ const s = [...values].sort((a, b) => a - b);
10586
+ const i = (s.length - 1) * 0.25;
10587
+ const lo = Math.floor(i);
10588
+ const frac = i - lo;
10589
+ return s[lo] + frac * ((s[lo + 1] ?? s[lo]) - s[lo]);
10590
+ }
10591
+ case "q3": {
10592
+ const s = [...values].sort((a, b) => a - b);
10593
+ const i = (s.length - 1) * 0.75;
10594
+ const lo = Math.floor(i);
10595
+ const frac = i - lo;
10596
+ return s[lo] + frac * ((s[lo + 1] ?? s[lo]) - s[lo]);
10597
+ }
10598
+ default:
10599
+ return 0;
10600
+ }
10601
+ }
10602
+ function groupKey(row, groupby) {
10603
+ return groupby.map((f) => String(row[f] ?? "")).join("\0");
10604
+ }
10605
+ function runAggregate(data, transform) {
10606
+ const { aggregate, groupby } = transform;
10607
+ const groups = /* @__PURE__ */ new Map();
10608
+ for (const row of data) {
10609
+ const key = groupKey(row, groupby);
10610
+ const existing = groups.get(key);
10611
+ if (existing) {
10612
+ existing.push(row);
10613
+ } else {
10614
+ groups.set(key, [row]);
10615
+ }
10616
+ }
10617
+ const result = [];
10618
+ for (const rows of groups.values()) {
10619
+ const outRow = {};
10620
+ for (const field of groupby) {
10621
+ outRow[field] = rows[0][field];
10622
+ }
10623
+ for (const agg of aggregate) {
10624
+ if (agg.op === "distinct") {
10625
+ outRow[agg.as] = new Set(rows.map((r) => r[agg.field])).size;
10626
+ continue;
10627
+ }
10628
+ const values = rows.map((r) => {
10629
+ if (agg.op === "count") return 1;
10630
+ const v = Number(r[agg.field]);
10631
+ return Number.isFinite(v) ? v : NaN;
10632
+ }).filter((v) => !Number.isNaN(v));
10633
+ outRow[agg.as] = computeAggregate(agg.op, values);
10634
+ }
10635
+ result.push(outRow);
10636
+ }
10637
+ return result;
10638
+ }
10639
+
10322
10640
  // src/transforms/bin.ts
10323
10641
  function computeStep(extent2, maxbins, nice2) {
10324
10642
  const span = extent2[1] - extent2[0];
@@ -10415,6 +10733,30 @@ function runFilter(data, predicate) {
10415
10733
  return data.filter((datum) => evaluatePredicate(datum, predicate));
10416
10734
  }
10417
10735
 
10736
+ // src/transforms/fold.ts
10737
+ function runFold(data, transform) {
10738
+ const { fold } = transform;
10739
+ const [keyAs, valueAs] = transform.as ?? ["key", "value"];
10740
+ const foldSet = new Set(fold);
10741
+ const result = [];
10742
+ for (const row of data) {
10743
+ const base = {};
10744
+ for (const [k, v] of Object.entries(row)) {
10745
+ if (!foldSet.has(k)) {
10746
+ base[k] = v;
10747
+ }
10748
+ }
10749
+ for (const field of fold) {
10750
+ result.push({
10751
+ ...base,
10752
+ [keyAs]: field,
10753
+ [valueAs]: row[field]
10754
+ });
10755
+ }
10756
+ }
10757
+ return result;
10758
+ }
10759
+
10418
10760
  // src/transforms/timeunit.ts
10419
10761
  function extractTimeUnit(date2, unit2) {
10420
10762
  switch (unit2) {
@@ -10492,6 +10834,10 @@ function runTransforms(data, transforms) {
10492
10834
  result = runCalculate(result, transform);
10493
10835
  } else if ("timeUnit" in transform) {
10494
10836
  result = runTimeUnit(result, transform);
10837
+ } else if ("aggregate" in transform) {
10838
+ result = runAggregate(result, transform);
10839
+ } else if ("fold" in transform) {
10840
+ result = runFold(result, transform);
10495
10841
  }
10496
10842
  }
10497
10843
  return result;
@@ -10524,71 +10870,55 @@ var builtinRenderers = {
10524
10870
  for (const [type, renderer] of Object.entries(builtinRenderers)) {
10525
10871
  registerChartRenderer(type, renderer);
10526
10872
  }
10527
- function computeMarkObstacles(marks, scales) {
10528
- if (scales.y?.type === "band") {
10529
- return computeBandRowObstacles(marks, scales);
10530
- }
10531
- const obstacles = [];
10532
- for (const mark of marks) {
10533
- if (mark.type === "rect") {
10534
- const rm = mark;
10535
- obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
10536
- } else if (mark.type === "point") {
10537
- const pm = mark;
10538
- obstacles.push({
10539
- x: pm.cx - pm.r,
10540
- y: pm.cy - pm.r,
10541
- width: pm.r * 2,
10542
- height: pm.r * 2
10543
- });
10544
- }
10545
- }
10546
- return obstacles;
10547
- }
10548
- function computeBandRowObstacles(marks, scales) {
10549
- const rows = /* @__PURE__ */ new Map();
10550
- for (const mark of marks) {
10551
- let cy;
10552
- let left2;
10553
- let right2;
10554
- if (mark.type === "point") {
10555
- const pm = mark;
10556
- cy = pm.cy;
10557
- left2 = pm.cx - pm.r;
10558
- right2 = pm.cx + pm.r;
10559
- } else if (mark.type === "rect") {
10560
- const rm = mark;
10561
- cy = rm.y + rm.height / 2;
10562
- left2 = rm.x;
10563
- right2 = rm.x + rm.width;
10564
- } else {
10565
- continue;
10566
- }
10567
- const key = Math.round(cy);
10568
- const existing = rows.get(key);
10569
- if (existing) {
10570
- existing.minX = Math.min(existing.minX, left2);
10571
- existing.maxX = Math.max(existing.maxX, right2);
10572
- } else {
10573
- rows.set(key, { minX: left2, maxX: right2, bandY: cy });
10873
+ function expandEncodingSugar(spec) {
10874
+ const encoding = spec.encoding;
10875
+ if (!encoding) return spec;
10876
+ const generatedTransforms = [];
10877
+ const updatedEncoding = { ...encoding };
10878
+ let changed = false;
10879
+ for (const channel of Object.keys(encoding)) {
10880
+ const ch = encoding[channel];
10881
+ if (!ch || !ch.field) continue;
10882
+ if (ch.bin != null && ch.bin !== false) {
10883
+ const field = ch.field;
10884
+ const outputField = `bin_${field}`;
10885
+ const binTransform = {
10886
+ bin: ch.bin === true ? true : ch.bin,
10887
+ field,
10888
+ as: outputField
10889
+ };
10890
+ generatedTransforms.push(binTransform);
10891
+ const { bin: _bin, ...rest } = ch;
10892
+ updatedEncoding[channel] = { ...rest, field: outputField };
10893
+ changed = true;
10894
+ }
10895
+ const current = updatedEncoding[channel] ?? ch;
10896
+ if (current.timeUnit) {
10897
+ const field = current.field;
10898
+ const unit2 = current.timeUnit;
10899
+ const outputField = `${unit2}_${field}`;
10900
+ const timeUnitTransform = {
10901
+ timeUnit: unit2,
10902
+ field,
10903
+ as: outputField
10904
+ };
10905
+ generatedTransforms.push(timeUnitTransform);
10906
+ const { timeUnit: _tu, ...rest } = current;
10907
+ updatedEncoding[channel] = { ...rest, field: outputField };
10908
+ changed = true;
10574
10909
  }
10575
10910
  }
10576
- const bandScale = scales.y.scale;
10577
- const bandwidth = bandScale.bandwidth?.() ?? 0;
10578
- if (bandwidth === 0) return [];
10579
- const obstacles = [];
10580
- for (const { minX, maxX, bandY } of rows.values()) {
10581
- obstacles.push({
10582
- x: minX,
10583
- y: bandY - bandwidth / 2,
10584
- width: maxX - minX,
10585
- height: bandwidth
10586
- });
10587
- }
10588
- return obstacles;
10911
+ if (!changed) return spec;
10912
+ const existingTransforms = spec.transform ?? [];
10913
+ return {
10914
+ ...spec,
10915
+ encoding: updatedEncoding,
10916
+ transform: [...generatedTransforms, ...existingTransforms]
10917
+ };
10589
10918
  }
10590
10919
  function compileChart(spec, options) {
10591
- const { spec: normalized } = compile(spec);
10920
+ const expandedSpec = spec && typeof spec === "object" && !Array.isArray(spec) ? expandEncodingSugar(spec) : spec;
10921
+ const { spec: normalized } = compile(expandedSpec);
10592
10922
  if ("type" in normalized && normalized.type === "table") {
10593
10923
  throw new Error("compileChart received a table spec. Use compileTable instead.");
10594
10924
  }
@@ -10599,16 +10929,16 @@ function compileChart(spec, options) {
10599
10929
  throw new Error("compileChart received a sankey spec. Use compileSankey instead.");
10600
10930
  }
10601
10931
  let chartSpec = normalized;
10602
- const rawWatermark = spec.watermark;
10932
+ const rawWatermark = expandedSpec.watermark;
10603
10933
  const watermark = rawWatermark !== void 0 ? chartSpec.watermark : options.watermark ?? true;
10604
- const rawTransforms = spec.transform;
10934
+ const rawTransforms = expandedSpec.transform;
10605
10935
  if (rawTransforms && rawTransforms.length > 0) {
10606
10936
  chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
10607
10937
  }
10608
10938
  const breakpoint = getBreakpoint(options.width);
10609
10939
  const heightClass = getHeightClass(options.height);
10610
10940
  const strategy = getLayoutStrategy(breakpoint, heightClass);
10611
- const rawSpec = spec;
10941
+ const rawSpec = expandedSpec;
10612
10942
  const overrides = rawSpec.overrides;
10613
10943
  if (overrides?.[breakpoint]) {
10614
10944
  const bp = overrides[breakpoint];
@@ -10719,20 +11049,11 @@ function compileChart(spec, options) {
10719
11049
  if (!isRadial) {
10720
11050
  computeGridlines(axes, chartArea);
10721
11051
  }
10722
- let rendererKey = renderSpec.markType;
10723
- if (rendererKey === "bar") {
10724
- const xType = renderSpec.encoding.x?.type;
10725
- const yType = renderSpec.encoding.y?.type;
10726
- const isVertical = (xType === "nominal" || xType === "ordinal" || xType === "temporal") && yType === "quantitative";
10727
- if (isVertical) {
10728
- rendererKey = "bar:vertical";
10729
- }
10730
- } else if (rendererKey === "arc") {
10731
- const innerRadius = renderSpec.markDef.innerRadius;
10732
- if (innerRadius && innerRadius > 0) {
10733
- rendererKey = "arc:donut";
10734
- }
10735
- }
11052
+ const rendererKey = resolveRendererKey(
11053
+ renderSpec.markType,
11054
+ renderSpec.encoding,
11055
+ renderSpec.markDef
11056
+ );
10736
11057
  const renderer = getChartRenderer(rendererKey);
10737
11058
  const marks = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
10738
11059
  const obstacles = [];
@@ -10780,37 +11101,7 @@ function compileChart(spec, options) {
10780
11101
  },
10781
11102
  chartSpec.data
10782
11103
  );
10783
- if (resolvedAnimation?.enabled && resolvedAnimation.staggerOrder === "value") {
10784
- const indexed = marks.map((m, i) => ({ mark: m, idx: i }));
10785
- indexed.sort((a, b) => {
10786
- const av = getMarkPrimaryValue(a.mark);
10787
- const bv = getMarkPrimaryValue(b.mark);
10788
- return av - bv;
10789
- });
10790
- for (let i = 0; i < indexed.length; i++) {
10791
- const m = indexed[i].mark;
10792
- if (m.type === "rect" && m.stackGroup) continue;
10793
- m.animationIndex = i;
10794
- }
10795
- }
10796
- if (resolvedAnimation?.enabled) {
10797
- const groupIndexMap = /* @__PURE__ */ new Map();
10798
- const groupStackPos = /* @__PURE__ */ new Map();
10799
- let nextGroupIndex = 0;
10800
- for (const mark of marks) {
10801
- if (mark.type === "rect" && mark.stackGroup) {
10802
- const rect = mark;
10803
- const group = rect.stackGroup;
10804
- if (!groupIndexMap.has(group)) {
10805
- groupIndexMap.set(group, nextGroupIndex++);
10806
- }
10807
- rect.animationIndex = groupIndexMap.get(group);
10808
- const pos = groupStackPos.get(group) ?? 0;
10809
- rect.stackPos = pos;
10810
- groupStackPos.set(group, pos + 1);
10811
- }
10812
- }
10813
- }
11104
+ assignAnimationIndices(marks, resolvedAnimation);
10814
11105
  return {
10815
11106
  area: chartArea,
10816
11107
  chrome: dims.chrome,
@@ -10837,25 +11128,6 @@ function compileChart(spec, options) {
10837
11128
  watermark
10838
11129
  };
10839
11130
  }
10840
- function getMarkPrimaryValue(mark) {
10841
- switch (mark.type) {
10842
- case "rect":
10843
- return mark.height;
10844
- // bar height is the primary value encoding
10845
- case "point":
10846
- return mark.cy;
10847
- // y position for scatter
10848
- case "arc":
10849
- return mark.endAngle - mark.startAngle;
10850
- // arc angle extent
10851
- case "line":
10852
- case "area":
10853
- return 0;
10854
- // series marks don't have individual values
10855
- default:
10856
- return 0;
10857
- }
10858
- }
10859
11131
  function compileLayer(spec, options) {
10860
11132
  const leaves = flattenLayers(spec);
10861
11133
  if (leaves.length === 0) {