@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.1

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 (157) hide show
  1. package/dist/index.d.ts +123 -7
  2. package/dist/index.js +16159 -10024
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/helpers/invariants.ts +54 -20
  12. package/src/__tests__/known-bugs.test.ts +455 -2
  13. package/src/__tests__/legend.test.ts +69 -4
  14. package/src/__tests__/ranking-recipes.test.ts +260 -0
  15. package/src/__tests__/scales.test.ts +9 -3
  16. package/src/__tests__/series-search.test.ts +92 -0
  17. package/src/__tests__/spec-sugar.test.ts +801 -0
  18. package/src/__tests__/you-draw-it.test.ts +160 -0
  19. package/src/annotations/__tests__/compute.test.ts +207 -92
  20. package/src/annotations/__tests__/geometry.test.ts +66 -0
  21. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  22. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  23. package/src/annotations/__tests__/thinning.test.ts +148 -0
  24. package/src/annotations/collisions.ts +130 -84
  25. package/src/annotations/compute.ts +83 -85
  26. package/src/annotations/constants.ts +116 -6
  27. package/src/annotations/geometry.ts +222 -77
  28. package/src/annotations/move.ts +135 -0
  29. package/src/annotations/placement.ts +62 -14
  30. package/src/annotations/resolve-range.ts +2 -0
  31. package/src/annotations/resolve-refline.ts +2 -0
  32. package/src/annotations/resolve-text.ts +312 -111
  33. package/src/annotations/rich-text.ts +65 -0
  34. package/src/annotations/thinning.ts +84 -10
  35. package/src/barlist/compile-barlist.ts +16 -6
  36. package/src/charts/_shared/format-label-value.ts +9 -8
  37. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  38. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  39. package/src/charts/bar/compute.ts +2 -6
  40. package/src/charts/bar/index.ts +8 -1
  41. package/src/charts/bar/labels.ts +3 -3
  42. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  43. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  44. package/src/charts/beeswarm/compute.ts +203 -0
  45. package/src/charts/beeswarm/dodge.ts +155 -0
  46. package/src/charts/beeswarm/index.ts +32 -0
  47. package/src/charts/builtin.ts +12 -1
  48. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  49. package/src/charts/calendar/compute.ts +319 -0
  50. package/src/charts/calendar/index.ts +32 -0
  51. package/src/charts/column/__tests__/compute.test.ts +21 -7
  52. package/src/charts/column/__tests__/labels.test.ts +76 -1
  53. package/src/charts/column/compute.ts +2 -6
  54. package/src/charts/column/index.ts +10 -2
  55. package/src/charts/column/labels.ts +95 -21
  56. package/src/charts/dot/index.ts +8 -1
  57. package/src/charts/dot/labels.ts +3 -3
  58. package/src/charts/line/__tests__/compute.test.ts +15 -19
  59. package/src/charts/line/area.ts +8 -23
  60. package/src/charts/line/compute.ts +32 -2
  61. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  62. package/src/charts/parliament/compute.ts +313 -0
  63. package/src/charts/parliament/index.ts +32 -0
  64. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  65. package/src/charts/pie/compute.ts +63 -8
  66. package/src/charts/pie/labels.ts +1 -0
  67. package/src/charts/range/__tests__/compute.test.ts +486 -0
  68. package/src/charts/range/compute.ts +273 -0
  69. package/src/charts/range/index.ts +82 -0
  70. package/src/charts/range/labels.ts +169 -0
  71. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  72. package/src/charts/rect/index.ts +127 -0
  73. package/src/charts/scatter/compute.ts +7 -21
  74. package/src/charts/text/__tests__/compute.test.ts +307 -0
  75. package/src/charts/text/index.ts +44 -7
  76. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  77. package/src/charts/tick/index.ts +7 -4
  78. package/src/charts/utils.ts +5 -3
  79. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  80. package/src/charts/waffle/compute.ts +197 -0
  81. package/src/charts/waffle/index.ts +31 -0
  82. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  83. package/src/compile/__tests__/data-clip.test.ts +68 -0
  84. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  85. package/src/compile/__tests__/size-scale.test.ts +108 -0
  86. package/src/compile/color-scale-range.ts +54 -33
  87. package/src/compile/data-clip.ts +21 -6
  88. package/src/compile/fill-patterns.ts +74 -0
  89. package/src/compile/layer.ts +117 -21
  90. package/src/compile/size-scale.ts +139 -0
  91. package/src/compile/spec-sugar.ts +758 -0
  92. package/src/compile/you-draw-it.ts +123 -0
  93. package/src/compile.ts +310 -103
  94. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  95. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  114. package/src/compiler/__tests__/normalize.test.ts +65 -1
  115. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  116. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  117. package/src/compiler/__tests__/validate.test.ts +156 -0
  118. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  119. package/src/compiler/a11y-warnings.ts +110 -0
  120. package/src/compiler/normalize.ts +150 -7
  121. package/src/compiler/types.ts +11 -0
  122. package/src/compiler/validate.ts +664 -18
  123. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  124. package/src/endpoint-labels/compute.ts +40 -12
  125. package/src/endpoint-labels/format.ts +45 -15
  126. package/src/endpoint-labels/predict.ts +50 -36
  127. package/src/format/__tests__/field-format.test.ts +164 -0
  128. package/src/format/field-format.ts +27 -0
  129. package/src/geo/__tests__/compile-map.test.ts +621 -0
  130. package/src/geo/compile-map.ts +856 -0
  131. package/src/geo/join.ts +115 -0
  132. package/src/geo/projections.ts +45 -0
  133. package/src/geo/types.ts +24 -0
  134. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  135. package/src/graphs/compile-graph.ts +27 -18
  136. package/src/index.ts +20 -2
  137. package/src/layout/axes/ticks.ts +59 -10
  138. package/src/layout/axes.ts +30 -2
  139. package/src/layout/dimensions.ts +139 -83
  140. package/src/layout/facet.ts +14 -3
  141. package/src/layout/plan.ts +83 -33
  142. package/src/layout/scales.ts +132 -15
  143. package/src/legend/__tests__/continuous.test.ts +318 -0
  144. package/src/legend/__tests__/size.test.ts +348 -0
  145. package/src/legend/__tests__/suppression.test.ts +17 -1
  146. package/src/legend/compute.ts +119 -13
  147. package/src/legend/continuous.ts +327 -0
  148. package/src/legend/size.ts +407 -0
  149. package/src/legend/suppression.ts +8 -7
  150. package/src/sankey/compile-sankey.ts +31 -19
  151. package/src/tables/format-cells.ts +16 -7
  152. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  153. package/src/tilemap/compile-tilemap.ts +24 -7
  154. package/src/tooltips/__tests__/compute.test.ts +2 -2
  155. package/src/tooltips/compute.ts +422 -41
  156. package/src/transforms/__tests__/conditional.test.ts +18 -0
  157. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Size legend: graduated circles keying a quantitative `size` encoding.
3
+ *
4
+ * Without this, `size` is an *unkeyed* channel: a bubble chart shows circles of
5
+ * varying area and gives the reader nothing to decode them with.
6
+ *
7
+ * Three rules drive the module, and they are all about not lying to the reader.
8
+ *
9
+ * **Resolve the same scale the marks resolve.** The values come from
10
+ * `buildSizeScale` -- the identical builder `scatter/compute.ts` and
11
+ * `beeswarm/compute.ts` call -- rather than a re-derivation. A key computed
12
+ * independently of the marks is a key that can silently drift from them.
13
+ * (`continuous.ts` mirrors the color path for exactly this reason.)
14
+ *
15
+ * **Key the domain, not the data extent.** The size scale clamps. With an
16
+ * explicit `scale.domain`, every datum past `domain[1]` renders at max radius,
17
+ * so a circle labeled with the largest *datum* would imply a correspondence that
18
+ * doesn't hold. The domain is what the scale actually promises.
19
+ *
20
+ * **A key you can't see keys nothing.** The one place the drawn circles are
21
+ * allowed to depart from the marks: when the mark's radius range is too small to
22
+ * read (a beeswarm's [2, 10] is a 20px smudge), the circles are redrawn across
23
+ * `MIN_LEGIBLE_RANGE`. The labels still carry the true values -- only the swatch
24
+ * is scaled up, and only when the literal version would be illegible.
25
+ */
26
+
27
+ import type {
28
+ DataRow,
29
+ EncodingChannel,
30
+ LegendLayout,
31
+ Rect,
32
+ ResolvedTheme,
33
+ SizeLegendCircle,
34
+ SizeLegendLayout,
35
+ } from '@opendata-ai/openchart-core';
36
+ import {
37
+ abbreviateNumber,
38
+ buildD3Formatter,
39
+ estimateTextWidth,
40
+ formatNumber,
41
+ } from '@opendata-ai/openchart-core';
42
+
43
+ import { scaleLinear, scaleSqrt } from 'd3-scale';
44
+
45
+ import { buildSizeScale, type SizeCurve, sizeScaleDefaultsFor } from '../compile/size-scale';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Constants
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** Gap between the circle stack and its labels. */
52
+ const LABEL_GAP = 6;
53
+
54
+ /** Padding below the baseline so the largest circle's stroke isn't clipped. */
55
+ const BASELINE_PAD = 2;
56
+
57
+ /** How many circles to key. Three reads as a scale; two reads as a comparison. */
58
+ const CIRCLE_COUNT = 3;
59
+
60
+ /**
61
+ * Smallest radius range the drawn circles are allowed to occupy.
62
+ *
63
+ * A beeswarm sizes its dots [2, 10]: rendering the key at those radii puts three
64
+ * labels next to a 20px smudge that reads as no key at all. When the mark's own
65
+ * range is smaller than this, the circles are redrawn across [4, 18] -- still
66
+ * ordered and still proportional to each other, but big enough to see. The key
67
+ * becomes *indicative* rather than a 1:1 swatch of the marks, which is the right
68
+ * trade: a key you can't see keys nothing. Marks that already exceed this (a
69
+ * scatter's [3, 30]) are drawn at their true radii and match the bubbles exactly.
70
+ */
71
+ const MIN_LEGIBLE_RANGE: readonly [number, number] = [4, 18];
72
+
73
+ /** Gap between the plot's right edge and the size legend column. */
74
+ export const SIZE_LEGEND_GAP = 12;
75
+
76
+ /**
77
+ * Vertical gap between the color legend and the size legend stacked beneath it.
78
+ *
79
+ * Bigger than the row gap *inside* either block, so the two read as two keys in
80
+ * one column rather than one long key whose last few rows happen to be circles.
81
+ */
82
+ export const SIZE_LEGEND_STACK_GAP = 16;
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Types
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /** Pre-placement size legend content (origin-relative coordinates). */
89
+ export interface SizeLegendContent {
90
+ circles: SizeLegendCircle[];
91
+ /** Total width of the legend block. */
92
+ width: number;
93
+ /** Total height of the legend block. */
94
+ height: number;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * A radius scale over an explicit range, bypassing the encoding's own
103
+ * `scale.range` (which `buildSizeScale` honours, and which is exactly the
104
+ * too-small range the legible floor exists to escape). Same curve and clamping
105
+ * as the mark scale, so the circles stay proportional to each other.
106
+ */
107
+ function buildRadiusScale(
108
+ curve: SizeCurve,
109
+ domain: [number, number],
110
+ range: readonly [number, number],
111
+ ): (value: number) => number {
112
+ const build = curve === 'linear' ? scaleLinear : scaleSqrt;
113
+ const scale = build().domain(domain).range([range[0], range[1]]).clamp(true);
114
+ return (value: number) => scale(value);
115
+ }
116
+
117
+ /** Format a legend value: channel format wins, then the house number style. */
118
+ function formatValue(value: number, formatStr?: string): string {
119
+ if (formatStr) {
120
+ const fmt = buildD3Formatter(formatStr);
121
+ if (fmt) return fmt(value);
122
+ }
123
+ if (Math.abs(value) >= 1000) return abbreviateNumber(value);
124
+ return formatNumber(value);
125
+ }
126
+
127
+ /**
128
+ * Round a value down to one significant-ish figure (1, 2, 5 x 10^n).
129
+ *
130
+ * Real graduated-circle keys show round numbers -- "500M", not "487.3M". A
131
+ * naive min/median/max would also cluster badly on the skewed (log-normal)
132
+ * data that bubble charts are usually made of: population min/median/max might
133
+ * be 300k / 9M / 1.4B, and under a sqrt scale the first two are visually
134
+ * identical. Nice-rounding off the domain's top spreads the circles out.
135
+ */
136
+ function niceFloor(value: number): number {
137
+ if (!Number.isFinite(value) || value <= 0) return value;
138
+ const magnitude = 10 ** Math.floor(Math.log10(value));
139
+ const normalized = value / magnitude;
140
+ const step = normalized >= 5 ? 5 : normalized >= 2 ? 2 : 1;
141
+ return step * magnitude;
142
+ }
143
+
144
+ /**
145
+ * Pick the values to key, largest first. At most `CIRCLE_COUNT` of them.
146
+ *
147
+ * **Spaced evenly in radius, not in value.** Radius is what the reader actually
148
+ * sees, and the mapping to it is non-linear (sqrt, so circle *area* tracks the
149
+ * value). Stepping the value by halves or by a constant ratio therefore bunches
150
+ * the small circles together: on `[300k, 1.4B]` the geometric midpoint is 20M,
151
+ * whose radius sits a couple of pixels off the floor. Walking the *radius* range
152
+ * in equal steps and inverting back to a value spreads the circles out the way a
153
+ * published graduated-circle key does.
154
+ *
155
+ * The domain top is always keyed first: it is the one value the maximum radius
156
+ * corresponds to, round number or not.
157
+ *
158
+ * Interior values are then nice-rounded, but only when the rounded number still
159
+ * lands inside the domain and doesn't collapse onto the neighbour above --
160
+ * `niceFloor(95)` is 50, which on `[90, 100]` is a value the scale never renders.
161
+ * Prettiness loses to being true.
162
+ */
163
+ function pickValues(domain: [number, number], scale: (v: number) => number): number[] {
164
+ const [lo, hi] = domain;
165
+ const rHi = scale(hi);
166
+ const rLo = scale(lo);
167
+ const out: number[] = [hi];
168
+
169
+ // Invert the scale numerically: it is monotone on [lo, hi], so bisection finds
170
+ // the value for a target radius without needing the scale's own `.invert`
171
+ // (which `buildSizeScale` doesn't surface, and which wouldn't exist for a
172
+ // clamped custom curve anyway).
173
+ const valueAtRadius = (target: number): number => {
174
+ let a = lo;
175
+ let b = hi;
176
+ for (let i = 0; i < 40; i++) {
177
+ const mid = (a + b) / 2;
178
+ if (scale(mid) < target) a = mid;
179
+ else b = mid;
180
+ }
181
+ return (a + b) / 2;
182
+ };
183
+
184
+ for (let i = 1; i < CIRCLE_COUNT; i++) {
185
+ const targetRadius = rHi - ((rHi - rLo) * i) / (CIRCLE_COUNT - 1);
186
+ // Bisection lands on float dust (89.99999...), which formats as "90.00".
187
+ // Snap to 3 significant figures so the fallback label is readable even when
188
+ // it isn't a round number.
189
+ const raw = toPrecision(valueAtRadius(targetRadius), 3);
190
+ const nice = niceFloor(raw);
191
+ const prev = out[out.length - 1];
192
+ // Take the round number only when it's in-domain and still distinct.
193
+ const value = nice >= lo && nice <= hi && nice < prev ? nice : raw;
194
+ if (!Number.isFinite(value) || value <= 0) continue;
195
+ if (value < lo || value >= prev) continue;
196
+ // A zero-anchored domain bisects toward the floor and lands on a value that
197
+ // is positive only in the float sense (1e-9 of the domain). A bubble of ~zero
198
+ // magnitude has no area to key, so drop it rather than label a dot "0".
199
+ if (value < hi * 1e-6) continue;
200
+ out.push(value);
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /** Round to `digits` significant figures. */
206
+ function toPrecision(value: number, digits: number): number {
207
+ if (!Number.isFinite(value) || value === 0) return value;
208
+ const magnitude = 10 ** (Math.floor(Math.log10(Math.abs(value))) - digits + 1);
209
+ return Math.round(value / magnitude) * magnitude;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Public API
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** True when the spec encodes a quantitative `size` channel worth keying. */
217
+ export function hasSizeChannel(encoding: { size?: unknown } | undefined): boolean {
218
+ const size = encoding?.size as EncodingChannel | undefined;
219
+ return !!size && 'field' in size && !!size.field && size.type === 'quantitative';
220
+ }
221
+
222
+ /**
223
+ * Marks whose `size` channel is a *keyed* encoding -- one a reader is meant to
224
+ * decode back into a value, and which therefore earns a legend.
225
+ *
226
+ * `point` and `beeswarm` map size to circle area: keyable, and the graduated
227
+ * circles below key it.
228
+ *
229
+ * `text` is deliberately absent. It maps size to *font size*, and no publication
230
+ * legends font size -- a stack of type at 12/24/48px reads as a font-sample
231
+ * sheet, not a key. Size on a text mark is emphasis, not a channel a reader
232
+ * decodes. (Vega-Lite emits one only because its legend generation is
233
+ * mechanical, not because anyone wants it.)
234
+ */
235
+ const KEYED_SIZE_MARKS = new Set(['point', 'beeswarm']);
236
+
237
+ /** Size-scale options for a mark's legend, or null when its size channel gets none. */
238
+ export function sizeLegendScaleFor(
239
+ markType: string,
240
+ ): { curve: SizeCurve; range: [number, number] } | null {
241
+ if (!KEYED_SIZE_MARKS.has(markType)) return null;
242
+ return sizeScaleDefaultsFor(markType);
243
+ }
244
+
245
+ /**
246
+ * Compute size legend content from a quantitative `size` encoding.
247
+ *
248
+ * Circles are nested: concentric, sharing a bottom edge, so the ratio between
249
+ * magnitudes is legible rather than just their order.
250
+ *
251
+ * @returns null when there is no size channel, or when the scale is degenerate
252
+ * (a zero-width domain encodes no magnitude, so there is nothing to key).
253
+ */
254
+ export function computeSizeLegendContent(
255
+ sizeEncoding: EncodingChannel | undefined,
256
+ data: readonly DataRow[],
257
+ theme: ResolvedTheme,
258
+ options: { curve: SizeCurve; range: [number, number] },
259
+ ): SizeLegendContent | null {
260
+ if (!hasSizeChannel({ size: sizeEncoding })) return null;
261
+
262
+ const resolved = buildSizeScale(sizeEncoding, data, options);
263
+ if (!resolved) return null;
264
+
265
+ // Circle area encodes magnitude, and a negative magnitude has no area to
266
+ // encode -- a key for it would be nonsense (and `sqrt` of a negative is NaN).
267
+ // The marks still render; the channel just isn't keyable, so we say nothing
268
+ // rather than key it wrongly.
269
+ if (!(resolved.domain[0] >= 0) || !(resolved.domain[1] > 0)) return null;
270
+
271
+ // A lone circle is not a graduated key: it tells the reader only that the
272
+ // biggest bubble is the biggest datum, which they already assumed. Two is the
273
+ // minimum that establishes a scale.
274
+ const values = pickValues(resolved.domain, resolved.scale);
275
+ if (values.length < 2) return null;
276
+
277
+ // Draw at the mark's own radii, unless they're too small to see -- then redraw
278
+ // across MIN_LEGIBLE_RANGE. The *values* always come from the real scale, so
279
+ // the labels stay true either way; only the circle sizes are rescaled.
280
+ //
281
+ // Test the RESOLVED range, not `options.range`: an explicit
282
+ // `encoding.size.scale.range` overrides the mark default, and the beeswarm
283
+ // fixture does exactly that ([2, 8]). Checking the default would miss it.
284
+ //
285
+ // The redraw is built straight from the domain rather than through
286
+ // `buildSizeScale`, which reads the range off the encoding and would hand back
287
+ // the same too-small range we're trying to escape.
288
+ const [markMin, markMax] = resolved.range;
289
+ const needsFloor = markMax - markMin < MIN_LEGIBLE_RANGE[1] - MIN_LEGIBLE_RANGE[0];
290
+ const drawScale = needsFloor
291
+ ? buildRadiusScale(options.curve, resolved.domain, MIN_LEGIBLE_RANGE)
292
+ : resolved.scale;
293
+
294
+ const maxRadius = drawScale(resolved.domain[1]);
295
+ if (!Number.isFinite(maxRadius) || maxRadius <= 0) return null;
296
+
297
+ const fontSize = theme.fonts.sizes.small;
298
+
299
+ // Nested: every circle shares the baseline, so cy = baseline - r.
300
+ const baseline = maxRadius * 2;
301
+ const cx = maxRadius;
302
+
303
+ // Labels ride each circle's top edge -- but only if the edges are far enough
304
+ // apart to hold them. A beeswarm's size range is [2, 10], so the whole stack is
305
+ // 20px tall and three labels pinned to those edges land on top of each other.
306
+ // Walk down (largest first) and hold each label at least a line below the last;
307
+ // the leader line still points at the true edge, so a nudged label stays
308
+ // unambiguous.
309
+ const lineHeight = fontSize * 1.25;
310
+ let prevLabelY = Number.NEGATIVE_INFINITY;
311
+
312
+ const circles: SizeLegendCircle[] = values.map((value) => {
313
+ const radius = drawScale(value);
314
+ const edgeY = baseline - radius * 2 + fontSize * 0.35;
315
+ const labelY = Math.max(edgeY, prevLabelY + lineHeight);
316
+ prevLabelY = labelY;
317
+ return {
318
+ value,
319
+ label: formatValue(value, (sizeEncoding as EncodingChannel).format),
320
+ radius,
321
+ cx,
322
+ cy: baseline - radius,
323
+ labelY,
324
+ };
325
+ });
326
+
327
+ // Widest label decides the block width. Measured with the same estimator the
328
+ // renderer uses -- a char-count heuristic would disagree with what actually
329
+ // gets drawn, and the sign of that disagreement flips with the font and the
330
+ // number format (so "it over-reserves today" is not a guarantee).
331
+ const widestLabel = Math.max(
332
+ ...circles.map((c) => estimateTextWidth(c.label, fontSize, theme.fonts.weights.normal)),
333
+ );
334
+
335
+ // The label column can now run past the circle stack (see the nudge above), so
336
+ // the block is as tall as whichever finishes lower.
337
+ const lastLabelBottom = (circles[circles.length - 1]?.labelY ?? 0) + fontSize * 0.5;
338
+
339
+ return {
340
+ circles,
341
+ width: maxRadius * 2 + LABEL_GAP + widestLabel,
342
+ height: Math.max(baseline, lastLabelBottom) + BASELINE_PAD,
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Place size legend content into chart coordinates.
348
+ *
349
+ * Sits in the right column that `dimensions.ts` reserved, top-aligned with the
350
+ * plot. Circle/label coordinates in `content` are bounds-relative; the renderer
351
+ * adds `bounds.x/y` to each.
352
+ *
353
+ * `colorLegend` is the ALREADY-PLACED color legend. When it also lives in the
354
+ * right column, the two share ONE column and the size legend stacks BELOW it.
355
+ *
356
+ * Side-by-side is the obvious placement and it is the wrong one. Two right-hand
357
+ * legends cost `colorWidth + sizeWidth + 2*GAP` of gutter -- on a 934px bubble
358
+ * chart, 213px, nearly a quarter of the canvas -- and every pixel comes out of
359
+ * the plot. Yet each block is only ~70px tall, so the column below them is dead
360
+ * space: the chart pays for a 213px-wide gutter to render 70px of key. Stacking
361
+ * costs `max(colorWidth, sizeWidth)` instead, and spends the empty vertical room
362
+ * that was already reserved and already paid for.
363
+ */
364
+ export function placeSizeLegend(
365
+ content: SizeLegendContent,
366
+ chartArea: Rect,
367
+ theme: ResolvedTheme,
368
+ colorLegend?: LegendLayout | null,
369
+ ): SizeLegendLayout {
370
+ const gutterX = chartArea.x + chartArea.width + SIZE_LEGEND_GAP;
371
+ const sharesGutter =
372
+ colorLegend != null &&
373
+ (colorLegend.position === 'right' || colorLegend.position === 'bottom-right') &&
374
+ colorLegend.bounds.width > 0;
375
+
376
+ // Left-align with the color legend rather than the gutter: `dimensions.ts`
377
+ // reserves the column at `max(colorWidth, sizeWidth)`, so whichever block is
378
+ // narrower sits inside a column sized by the other. Sharing the left edge
379
+ // keys them as one column; anchoring to `gutterX` would leave the narrower
380
+ // one floating away from its partner.
381
+ const x = sharesGutter ? colorLegend.bounds.x : gutterX;
382
+ const y = sharesGutter
383
+ ? colorLegend.bounds.y + colorLegend.bounds.height + SIZE_LEGEND_STACK_GAP
384
+ : chartArea.y;
385
+
386
+ return {
387
+ type: 'size',
388
+ channel: 'size',
389
+ position: 'right',
390
+ bounds: {
391
+ x,
392
+ y,
393
+ width: content.width,
394
+ height: content.height,
395
+ },
396
+ circles: content.circles,
397
+ stroke: theme.colors.axis,
398
+ labelStyle: {
399
+ fontFamily: theme.fonts.family,
400
+ fontSize: theme.fonts.sizes.small,
401
+ fontWeight: theme.fonts.weights.normal,
402
+ fill: theme.colors.text,
403
+ lineHeight: 1.3,
404
+ fontVariant: 'tabular-nums',
405
+ },
406
+ };
407
+ }
@@ -121,12 +121,16 @@ export function resolveSuppression(
121
121
 
122
122
  // Single-series, non-line/area, or compact strategy: never show endpoint
123
123
  // column or end-of-line labels. Legend follows its own rules elsewhere.
124
+ // Explicit user opt-in (endpointLabels config) overrides the compact
125
+ // strip: slope/bump recipes carry their values on the labels, so an
126
+ // author who wrote endpointLabels config keeps the column at 320px.
124
127
  //
125
128
  // Note: `labelsDensityNone` is intentionally NOT in this short-circuit.
126
129
  // `labels.density: 'none'` is the legacy switch for end-of-line labels and
127
130
  // must not affect the endpoint column or the traditional legend (those
128
131
  // are independent concerns governed by the truth table below).
129
- if (!isMultiSeries || ctx.labelsHiddenByStrategy) {
132
+ const strategyHidesEndpoint = ctx.labelsHiddenByStrategy && !endpointLabelsExplicitlyOn(spec);
133
+ if (!isMultiSeries || strategyHidesEndpoint) {
130
134
  return {
131
135
  // Defer the legend's own show/hide rules to computeLegend; this helper
132
136
  // doesn't override the existing legend behavior for non-multi-series.
@@ -144,12 +148,8 @@ export function resolveSuppression(
144
148
  spec.encoding.y?.type === 'quantitative' ? spec.encoding.y : spec.encoding.x
145
149
  ) as Encoding[keyof Encoding] | undefined;
146
150
  const stackValue = quantChannel && 'stack' in quantChannel ? quantChannel.stack : undefined;
147
- const isStacked = isArea
148
- ? stackValue === true ||
149
- stackValue === 'zero' ||
150
- stackValue === 'normalize' ||
151
- stackValue === 'center'
152
- : false;
151
+ // v8 VL-aligned default: area stacks unless stack is explicitly null/false.
152
+ const isStacked = isArea ? stackValue !== null && stackValue !== false : false;
153
153
 
154
154
  // Stacked area: revert to the pre-v6 behavior — show legend, no endpoint column.
155
155
  if (isStacked) {
@@ -214,6 +214,7 @@ export function countColorSeries(spec: NormalizedChartSpec): number {
214
214
  const colorEnc = spec.encoding.color;
215
215
  if (!colorEnc) return 0;
216
216
  if ('condition' in colorEnc) return 0;
217
+ if (!('field' in colorEnc)) return 0;
217
218
  if (colorEnc.type === 'quantitative') return 0;
218
219
  const field = colorEnc.field;
219
220
  if (!field) return 0;
@@ -16,6 +16,7 @@ import type {
16
16
  CompileOptions,
17
17
  LegendEntry,
18
18
  LegendLayout,
19
+ NumberFormatter,
19
20
  Rect,
20
21
  ResolvedAnimation,
21
22
  ResolvedTheme,
@@ -28,15 +29,15 @@ import type {
28
29
  } from '@opendata-ai/openchart-core';
29
30
  import {
30
31
  adaptTheme,
31
- buildD3Formatter,
32
32
  computeChrome,
33
+ defaultNumberFormatter,
33
34
  estimateTextWidth,
34
- formatNumber,
35
35
  resolveTheme,
36
36
  } from '@opendata-ai/openchart-core';
37
-
37
+ import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar';
38
38
  import { resolveAnimation } from '../compiler/animation';
39
39
  import { compile as compileSpec } from '../compiler/index';
40
+ import { resolveFieldFormatter } from '../format/field-format';
40
41
  import { ENTRY_GAP, measureLegendWrap, SWATCH_GAP, SWATCH_SIZE } from '../legend/wrap';
41
42
  import { type ComputedNode, computeSankeyLayout, generateLinkPath } from './layout';
42
43
  import type { NormalizedSankeySpec } from './types';
@@ -216,8 +217,15 @@ function computeNodeLabel(
216
217
  * @throws Error if spec is invalid or not a sankey type.
217
218
  */
218
219
  export function compileSankey(spec: unknown, options: CompileOptions): SankeyLayout {
219
- // 1. Validate + normalize via the shared compiler pipeline
220
- const { spec: normalized } = compileSpec(spec);
220
+ // 1. Expand deprecated top-level sugar (valueFormat -> encoding.value.format)
221
+ // before validation, then validate + normalize via the shared compiler pipeline.
222
+ const sugarWarnings: string[] = [];
223
+ const expandedSpec =
224
+ spec && typeof spec === 'object' && !Array.isArray(spec)
225
+ ? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
226
+ : spec;
227
+ const { spec: normalized, warnings } = compileSpec(expandedSpec);
228
+ emitSpecWarnings([...sugarWarnings, ...warnings], options.onWarn);
221
229
 
222
230
  if (!('type' in normalized) || normalized.type !== 'sankey') {
223
231
  throw new Error(
@@ -227,6 +235,9 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
227
235
 
228
236
  const sankeySpec = normalized as NormalizedSankeySpec;
229
237
 
238
+ // Resolve format: encoding-level (v8 canonical) wins over deprecated top-level valueFormat
239
+ const resolvedValueFormat = sankeySpec.encoding.value.format ?? sankeySpec.valueFormat;
240
+
230
241
  // Resolve watermark: explicit spec value wins, then options fallback, then default true.
231
242
  const rawWatermark = (spec as Record<string, unknown>).watermark;
232
243
  const watermark = rawWatermark !== undefined ? sankeySpec.watermark : (options.watermark ?? true);
@@ -285,6 +296,10 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
285
296
  const targetField = sankeySpec.encoding.target.field;
286
297
  const valueField = sankeySpec.encoding.value.field;
287
298
  const colorField = sankeySpec.encoding.color?.field;
299
+ const flowFmt = resolveFieldFormatter({
300
+ surfaceFormat: resolvedValueFormat,
301
+ values: sankeySpec.data.map((r) => r[valueField]),
302
+ });
288
303
 
289
304
  // 5b. Pre-compute legend to reserve vertical space
290
305
  // We need the color map first, so build a temporary one from raw data
@@ -425,7 +440,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
425
440
  data: { id: node.id, label: node.label },
426
441
  aria: {
427
442
  role: 'img',
428
- label: `${node.label}: ${formatFlowValue(node.value ?? 0, sankeySpec.valueFormat)}`,
443
+ label: `${node.label}: ${formatFlowValue(node.value ?? 0, flowFmt)}`,
429
444
  },
430
445
  animationIndex: 0, // Reassigned below after sorting by depth
431
446
  };
@@ -460,7 +475,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
460
475
  data: (link as unknown as { data: Record<string, unknown> }).data ?? {},
461
476
  aria: {
462
477
  role: 'img',
463
- label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, sankeySpec.valueFormat)}`,
478
+ label: `${sourceNode.label} to ${targetNode.label}: ${formatFlowValue(link.value, flowFmt)}`,
464
479
  },
465
480
  // Links animate after nodes
466
481
  animationIndex: nodeMarks.length + i,
@@ -479,7 +494,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
479
494
  );
480
495
 
481
496
  // 13. Build tooltip descriptors
482
- const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, sankeySpec.valueFormat);
497
+ const tooltipDescriptors = buildTooltipDescriptors(nodeMarks, linkMarks, flowFmt);
483
498
 
484
499
  // 14. Build a11y metadata
485
500
  const a11y = {
@@ -598,27 +613,25 @@ function buildSankeyLegend(
598
613
  // Tooltip builder
599
614
  // ---------------------------------------------------------------------------
600
615
 
601
- function formatFlowValue(value: number, valueFormat?: string): string {
602
- if (valueFormat) {
603
- const fmt = buildD3Formatter(valueFormat);
604
- if (fmt) return fmt(value);
605
- }
606
- return formatNumber(value);
616
+ const defaultFmt = defaultNumberFormatter();
617
+
618
+ function formatFlowValue(value: number, formatter?: NumberFormatter | null): string {
619
+ if (formatter) return formatter(value);
620
+ return defaultFmt(value);
607
621
  }
608
622
 
609
623
  function buildTooltipDescriptors(
610
624
  nodes: SankeyNodeMark[],
611
625
  links: SankeyLinkMark[],
612
- valueFormat?: string,
626
+ formatter?: NumberFormatter | null,
613
627
  ): Map<string, TooltipContent> {
614
628
  const descriptors = new Map<string, TooltipContent>();
615
629
 
616
- // Node tooltips: keyed by "node-{nodeId}" to match renderer data-mark-id
617
630
  for (const node of nodes) {
618
631
  const fields: TooltipField[] = [
619
632
  {
620
633
  label: 'Total flow',
621
- value: formatFlowValue(node.value, valueFormat),
634
+ value: formatFlowValue(node.value, formatter),
622
635
  },
623
636
  ];
624
637
  descriptors.set(`node-${node.nodeId}`, {
@@ -627,13 +640,12 @@ function buildTooltipDescriptors(
627
640
  });
628
641
  }
629
642
 
630
- // Link tooltips: keyed by "link-{sourceId}-{targetId}" to match renderer data-mark-id
631
643
  for (let i = 0; i < links.length; i++) {
632
644
  const link = links[i];
633
645
  const fields: TooltipField[] = [
634
646
  {
635
647
  label: 'Flow',
636
- value: formatFlowValue(link.value, valueFormat),
648
+ value: formatFlowValue(link.value, formatter),
637
649
  },
638
650
  ];
639
651
  descriptors.set(`link-${link.sourceId}-${link.targetId}-${i}`, {
@@ -7,7 +7,12 @@
7
7
  */
8
8
 
9
9
  import type { CellStyle, ColumnConfig, TableCellBase } from '@opendata-ai/openchart-core';
10
- import { buildD3Formatter, formatDate, formatNumber } from '@opendata-ai/openchart-core';
10
+ import {
11
+ buildD3Formatter,
12
+ defaultNumberFormatter,
13
+ formatDate,
14
+ resolveNumberFormatter,
15
+ } from '@opendata-ai/openchart-core';
11
16
 
12
17
  /**
13
18
  * Check if a value is numeric (finite number or parseable numeric string).
@@ -46,9 +51,11 @@ export function formatCell(value: unknown, column: ColumnConfig): TableCellBase
46
51
  };
47
52
  }
48
53
 
49
- // If column has a d3-format string and value is numeric
54
+ // If column has a format string and value is numeric
50
55
  if (column.format && isNumericValue(value)) {
51
- const formatter = buildD3Formatter(column.format);
56
+ const formatter =
57
+ resolveNumberFormatter(column.format, { surface: 'table' }) ??
58
+ buildD3Formatter(column.format);
52
59
  if (formatter) {
53
60
  return {
54
61
  value,
@@ -58,11 +65,11 @@ export function formatCell(value: unknown, column: ColumnConfig): TableCellBase
58
65
  }
59
66
  }
60
67
 
61
- // Auto-format numbers
68
+ // Auto-format numbers (tables get full precision, no compact notation)
62
69
  if (isNumericValue(value)) {
63
70
  return {
64
71
  value,
65
- formattedValue: formatNumber(value),
72
+ formattedValue: defaultNumberFormatter({ surface: 'table' })(value),
66
73
  style,
67
74
  };
68
75
  }
@@ -92,14 +99,16 @@ export function formatValueForSearch(value: unknown, column: ColumnConfig): stri
92
99
  if (value == null) return '';
93
100
 
94
101
  if (column.format && isNumericValue(value)) {
95
- const formatter = buildD3Formatter(column.format);
102
+ const formatter =
103
+ resolveNumberFormatter(column.format, { surface: 'table' }) ??
104
+ buildD3Formatter(column.format);
96
105
  if (formatter) {
97
106
  return formatter(value);
98
107
  }
99
108
  }
100
109
 
101
110
  if (isNumericValue(value)) {
102
- return formatNumber(value);
111
+ return defaultNumberFormatter({ surface: 'table' })(value);
103
112
  }
104
113
 
105
114
  return String(value);