@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
@@ -1,6 +1,7 @@
1
1
  import type { ChartLayout, LineMark } from '@opendata-ai/openchart-core';
2
2
  import { estimateTextWidth } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, test } from 'vitest';
4
+ import { ANCHOR_OFFSET, ARROWHEAD_LENGTH, MIN_CONNECTOR_LENGTH } from '../annotations/constants';
4
5
  import { compileChart } from '../compile';
5
6
 
6
7
  describe('known layout bugs', () => {
@@ -105,9 +106,461 @@ describe('known layout bugs', () => {
105
106
  }
106
107
 
107
108
  // The label bounding box should NOT vertically overlap with the line's y-range.
108
- // Today the label sits only ANCHOR_OFFSET (8px) from its anchor point ON the line,
109
- // so the label box almost certainly overlaps the line path.
109
+ // The label sits ANCHOR_OFFSET (28px) off its anchor point on the line, which
110
+ // is sized to clear both the marker and the line path.
110
111
  const verticallyOverlaps = labelTop < lineMaxY && labelBottom > lineMinY;
111
112
  expect(verticallyOverlaps).toBe(false);
112
113
  });
114
+
115
+ // Regression: the collision pass calls `refreshConnector` on every annotation it
116
+ // moves, and that rebuilt `from`/`to` from a ray-box exit toward the target. A
117
+ // drop-line is not a leader — it's a vertical rule pinned to the data point's x
118
+ // — so the rebuild tilted it into a diagonal (which the renderer then draws with
119
+ // shape-rendering: crispEdges, assuming axis alignment), and the
120
+ // MIN_CONNECTOR_LENGTH rule could delete it outright.
121
+ //
122
+ // Two drop-lines placed close enough to collide is what makes this observable:
123
+ // a lone annotation is never shifted, so `refreshConnector` never runs and the
124
+ // bug hides. Against the unfixed code this pair yields a 12.9px-diagonal first
125
+ // connector and a *deleted* second one.
126
+ describe('drop-line connectors survive the collision pass', () => {
127
+ const collidingDropLines = () =>
128
+ compileChart(
129
+ {
130
+ mark: 'line' as const,
131
+ data: [
132
+ { m: 'Jan', v: 10 },
133
+ { m: 'Feb', v: 50 },
134
+ { m: 'Mar', v: 48 },
135
+ { m: 'Apr', v: 30 },
136
+ ],
137
+ encoding: {
138
+ x: { field: 'm' as const, type: 'ordinal' as const },
139
+ y: { field: 'v' as const, type: 'quantitative' as const },
140
+ },
141
+ annotations: [
142
+ {
143
+ type: 'text' as const,
144
+ x: 'Feb',
145
+ y: 50,
146
+ text: 'First drop-line here',
147
+ connector: 'drop-line' as const,
148
+ },
149
+ {
150
+ type: 'text' as const,
151
+ x: 'Mar',
152
+ y: 48,
153
+ text: 'Second drop-line here',
154
+ connector: 'drop-line' as const,
155
+ },
156
+ ],
157
+ },
158
+ { width: 800, height: 460 },
159
+ ).annotations ?? [];
160
+
161
+ // One test, not three. The `x` pinning is the only assertion the generic
162
+ // ray-box rebuild actually breaks -- it preserves `style` through its
163
+ // `{ ...connector }` spread, and this geometry happens to land on
164
+ // `exit: 'vertical'` either way, so splitting those into their own tests just
165
+ // manufactured two that pass with the fix reverted.
166
+ test('stay vertical, with x pinned to the data point', () => {
167
+ const annotations = collidingDropLines();
168
+ expect(annotations).toHaveLength(2);
169
+ for (const annotation of annotations) {
170
+ const connector = annotation.label?.connector;
171
+ // Survived the min-length rule (the generic rebuild can shorten a
172
+ // drop-line enough to delete it outright).
173
+ expect(connector).toBeDefined();
174
+ // The whole contract of a drop-line: from.x === to.x === the point's x.
175
+ expect(connector?.from.x).toBeCloseTo(connector?.to.x ?? Number.NaN, 5);
176
+ expect(connector?.from.x).toBeCloseTo(annotation.dot?.x ?? Number.NaN, 5);
177
+ expect(connector?.style).toBe('drop-line');
178
+ expect(connector?.exit).toBe('vertical');
179
+ }
180
+ });
181
+ });
182
+
183
+ // Regression: the engine gated connectors on `length >= MIN_CONNECTOR_LENGTH`,
184
+ // but the renderer stops an ARROWED stroke `ARROWHEAD_LENGTH` (7px) short and
185
+ // spends that budget on the head. So an 8.1px arrowed connector cleared the 8px
186
+ // gate and then shipped a 1.1px stub with an arrowhead stuck on the end. Sharing
187
+ // the constant didn't fix it: both sides had the number, only one reasoned about
188
+ // it. The gate now measures what actually gets stroked.
189
+ describe('the connector min-length gate accounts for the arrowhead', () => {
190
+ const strokeFor = (arrow: boolean, dy: number) => {
191
+ const annotation = compileChart(
192
+ {
193
+ mark: 'line' as const,
194
+ data: [
195
+ { m: 'Jan', v: 10 },
196
+ { m: 'Feb', v: 50 },
197
+ { m: 'Mar', v: 30 },
198
+ ],
199
+ encoding: {
200
+ x: { field: 'm' as const, type: 'ordinal' as const },
201
+ y: { field: 'v' as const, type: 'quantitative' as const },
202
+ },
203
+ annotations: [
204
+ {
205
+ type: 'text' as const,
206
+ x: 'Mar',
207
+ y: 30,
208
+ text: 'Note',
209
+ anchor: 'top' as const,
210
+ offset: { dx: 0, dy },
211
+ connector: arrow ? { type: 'straight' as const, arrow: true } : 'straight',
212
+ },
213
+ ],
214
+ },
215
+ { width: 800, height: 460 },
216
+ ).annotations?.[0];
217
+
218
+ const connector = annotation?.label?.connector;
219
+ if (!connector) return null;
220
+ const length = Math.hypot(
221
+ connector.to.x - connector.from.x,
222
+ connector.to.y - connector.from.y,
223
+ );
224
+ // What the renderer actually strokes.
225
+ return arrow ? length - ARROWHEAD_LENGTH : length;
226
+ };
227
+
228
+ test('an arrowed connector never ships a stroke shorter than the minimum', () => {
229
+ // dy: 6 is the case that used to slip through -- an 8.10px connector that
230
+ // stroked 1.10px once the head took its 7px.
231
+ for (let dy = 0; dy <= 8; dy++) {
232
+ const stroke = strokeFor(true, dy);
233
+ if (stroke === null) continue; // suppressed outright, which is correct
234
+ expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
235
+ }
236
+ });
237
+
238
+ test('the plain (non-arrowed) gate is unchanged', () => {
239
+ // The head costs nothing here, so the full length is the stroke and the
240
+ // original 8px rule still holds. Guards against "fixing" the arrow case by
241
+ // tightening the gate for everyone.
242
+ for (let dy = 0; dy <= 8; dy++) {
243
+ const stroke = strokeFor(false, dy);
244
+ if (stroke === null) continue;
245
+ expect(stroke).toBeGreaterThanOrEqual(MIN_CONNECTOR_LENGTH);
246
+ }
247
+ // And a short-but-legal plain connector still draws.
248
+ expect(strokeFor(false, 0)).not.toBeNull();
249
+ });
250
+ });
251
+
252
+ // Regression: `resolveTextAnnotation` bolts the lede rule on (a `subtitle`
253
+ // promotes the primary line to 700) BEFORE branching to the drop-line resolver,
254
+ // which used to resolve no subtitle at all. Net effect: authoring a subtitle on
255
+ // a drop-line silently threw the subtitle away but still bolded the primary --
256
+ // a field whose only visible effect was a mystery weight change.
257
+ describe('a drop-line annotation honors its subtitle', () => {
258
+ // Annotate Mar, not the Feb maximum: a callout on the chart's top value clamps
259
+ // against the chart edge and has nowhere to go, which is a separate (and
260
+ // legitimate) degradation. This is the ordinary case.
261
+ const dropLineWithSubtitle = () =>
262
+ compileChart(
263
+ {
264
+ mark: 'line' as const,
265
+ data: [
266
+ { m: 'Jan', v: 10 },
267
+ { m: 'Feb', v: 50 },
268
+ { m: 'Mar', v: 30 },
269
+ ],
270
+ encoding: {
271
+ x: { field: 'm' as const, type: 'ordinal' as const },
272
+ y: { field: 'v' as const, type: 'quantitative' as const },
273
+ },
274
+ annotations: [
275
+ {
276
+ type: 'text' as const,
277
+ x: 'Mar',
278
+ y: 30,
279
+ text: 'Peak',
280
+ subtitle: 'the highest month',
281
+ connector: 'drop-line' as const,
282
+ },
283
+ ],
284
+ },
285
+ { width: 800, height: 460 },
286
+ ).annotations?.[0];
287
+
288
+ test('resolves the subtitle instead of dropping it', () => {
289
+ const annotation = dropLineWithSubtitle();
290
+ expect(annotation?.subtitle?.text).toBe('the highest month');
291
+ });
292
+
293
+ test('still promotes the primary line to a bold lede', () => {
294
+ expect(dropLineWithSubtitle()?.label?.style.fontWeight).toBe(700);
295
+ });
296
+
297
+ test('renders the subtitle below the primary line, left edges aligned', () => {
298
+ const annotation = dropLineWithSubtitle();
299
+ expect(annotation?.subtitle?.y).toBeGreaterThan(annotation?.label?.y ?? Number.NaN);
300
+ expect(annotation?.subtitle?.x).toBeCloseTo(annotation?.label?.x ?? Number.NaN, 5);
301
+ });
302
+
303
+ test('the whole block stays above the data point, so the line still drops', () => {
304
+ // A drop-line runs DOWN from the block to the point. The subtitle makes the
305
+ // block taller and wider, which made it collide with the line mark -- and the
306
+ // obstacle nudge then shoved it *below* the point, inverting the connector so
307
+ // it pointed up, away from the data, with the block sitting on the mark.
308
+ const annotation = dropLineWithSubtitle();
309
+ const bounds = annotation?.bounds;
310
+ const dotY = annotation?.dot?.y ?? Number.NaN;
311
+ expect((bounds?.y ?? Number.NaN) + (bounds?.height ?? Number.NaN)).toBeLessThanOrEqual(dotY);
312
+
313
+ const connector = annotation?.label?.connector;
314
+ expect(connector?.from.y).toBeLessThan(connector?.to.y ?? Number.NaN);
315
+ });
316
+
317
+ test('the block bounds cover the subtitle, so collisions see it', () => {
318
+ const annotation = dropLineWithSubtitle();
319
+ const bounds = annotation?.bounds;
320
+ const labelBounds = annotation?.label?.bounds;
321
+ // `bounds` is the union; `label.bounds` stays label-only (the renderer sizes
322
+ // the background plate from it).
323
+ expect(bounds?.height).toBeGreaterThan(labelBounds?.height ?? Number.NaN);
324
+ });
325
+ });
326
+
327
+ // Regression: `compute.ts` stamped `result.bounds` (the UNION of label and
328
+ // subtitle, which is what placement scores) into `label.bounds`, whose contract
329
+ // is the label-only text box. The renderer sizes the `background` plate from
330
+ // `label.bounds`, so an auto-placed annotation with a subtitle got a plate sized
331
+ // to both lines while the explicit path got one sized to the label.
332
+ test('auto-placed label bounds are the label box, not the label+subtitle union', () => {
333
+ const layout = compileChart(
334
+ {
335
+ mark: 'line' as const,
336
+ data: [
337
+ { m: 'Jan', v: 10 },
338
+ { m: 'Feb', v: 50 },
339
+ { m: 'Mar', v: 30 },
340
+ ],
341
+ encoding: {
342
+ x: { field: 'm' as const, type: 'ordinal' as const },
343
+ y: { field: 'v' as const, type: 'quantitative' as const },
344
+ },
345
+ annotations: [
346
+ // No anchor/offset => the auto-placement path.
347
+ {
348
+ type: 'text' as const,
349
+ x: 'Feb',
350
+ y: 50,
351
+ text: 'Peak',
352
+ subtitle: 'a considerably wider subtitle line',
353
+ },
354
+ ],
355
+ },
356
+ { width: 800, height: 460 },
357
+ );
358
+ const annotation = layout.annotations?.[0];
359
+ const labelBounds = annotation?.label?.bounds;
360
+ const unionBounds = annotation?.bounds;
361
+ expect(labelBounds).toBeDefined();
362
+ expect(unionBounds).toBeDefined();
363
+
364
+ // The subtitle is wider and sits below, so the union must be strictly bigger.
365
+ // If label.bounds were the union, these would be identical.
366
+ expect(labelBounds?.width).toBeLessThan(unionBounds?.width ?? 0);
367
+ expect(labelBounds?.height).toBeLessThan(unionBounds?.height ?? 0);
368
+ });
369
+
370
+ // Regression: `anchor: 'top'`/`'bottom'` render with textAnchor 'start', so the
371
+ // block used to hang entirely to the RIGHT of its data point — "above" read as
372
+ // "up and to the right", and authors had to hand-compute a negative dx of about
373
+ // half the block width to re-center it. The block now straddles the point
374
+ // horizontally; the TEXT inside stays left-aligned (ragged right).
375
+ describe('top/bottom anchored blocks straddle their data point', () => {
376
+ const centerOffsetFor = (annotation: Record<string, unknown>) => {
377
+ const layout = compileChart(
378
+ {
379
+ mark: 'line' as const,
380
+ data: [
381
+ { m: 'Jan', v: 10 },
382
+ { m: 'Feb', v: 50 },
383
+ { m: 'Mar', v: 30 },
384
+ ],
385
+ encoding: {
386
+ x: { field: 'm' as const, type: 'ordinal' as const },
387
+ y: { field: 'v' as const, type: 'quantitative' as const },
388
+ },
389
+ annotations: [annotation],
390
+ },
391
+ { width: 800, height: 460 },
392
+ );
393
+ const resolved = layout.annotations?.[0];
394
+ const bounds = resolved?.label?.bounds;
395
+ const pointX = resolved?.dot?.x;
396
+ if (!bounds || pointX === undefined) throw new Error('annotation did not resolve');
397
+ return bounds.x + bounds.width / 2 - pointX;
398
+ };
399
+
400
+ /** The block's horizontal span expressed relative to the data point. */
401
+ const spanAround = (annotation: Record<string, unknown>) => {
402
+ const layout = compileChart(
403
+ {
404
+ mark: 'line' as const,
405
+ data: [
406
+ { m: 'Jan', v: 10 },
407
+ { m: 'Feb', v: 50 },
408
+ { m: 'Mar', v: 30 },
409
+ ],
410
+ encoding: {
411
+ x: { field: 'm' as const, type: 'ordinal' as const },
412
+ y: { field: 'v' as const, type: 'quantitative' as const },
413
+ },
414
+ annotations: [annotation],
415
+ },
416
+ { width: 800, height: 460 },
417
+ );
418
+ const resolved = layout.annotations?.[0];
419
+ const bounds = resolved?.label?.bounds;
420
+ const pointX = resolved?.dot?.x;
421
+ if (!bounds || pointX === undefined) throw new Error('annotation did not resolve');
422
+ return { left: bounds.x - pointX, right: bounds.x + bounds.width - pointX };
423
+ };
424
+
425
+ test('a single-line top-anchored block centers on its point', () => {
426
+ expect(
427
+ centerOffsetFor({ type: 'text', x: 'Feb', y: 50, text: 'Peak here', anchor: 'top' }),
428
+ ).toBeCloseTo(0, 1);
429
+ });
430
+
431
+ test('a multi-line top-anchored block centers on its point', () => {
432
+ expect(
433
+ centerOffsetFor({
434
+ type: 'text',
435
+ x: 'Feb',
436
+ y: 50,
437
+ text: 'Hurricanes and\na strike cut',
438
+ anchor: 'top',
439
+ }),
440
+ ).toBeCloseTo(0, 1);
441
+ });
442
+
443
+ test('a multi-line bottom-anchored block centers on its point', () => {
444
+ expect(
445
+ centerOffsetFor({
446
+ type: 'text',
447
+ x: 'Feb',
448
+ y: 50,
449
+ text: 'Two lines\nhere now',
450
+ anchor: 'bottom',
451
+ }),
452
+ ).toBeCloseTo(0, 1);
453
+ });
454
+
455
+ test('the text inside stays left-aligned, not center-aligned', () => {
456
+ const layout = compileChart(
457
+ {
458
+ mark: 'line' as const,
459
+ data: [
460
+ { m: 'Jan', v: 10 },
461
+ { m: 'Feb', v: 50 },
462
+ ],
463
+ encoding: {
464
+ x: { field: 'm' as const, type: 'ordinal' as const },
465
+ y: { field: 'v' as const, type: 'quantitative' as const },
466
+ },
467
+ annotations: [
468
+ { type: 'text' as const, x: 'Feb', y: 50, text: 'Two\nlines', anchor: 'top' as const },
469
+ ],
470
+ },
471
+ { width: 800, height: 460 },
472
+ );
473
+ expect(layout.annotations?.[0]?.label?.style?.textAnchor).toBe('start');
474
+ });
475
+
476
+ test('left/right anchors are unaffected (block sits beside the point)', () => {
477
+ // "Beside", not "straddling": the whole block clears the point by exactly the
478
+ // ANCHOR_OFFSET setback (28px), so the point lies OUTSIDE the block's x-span.
479
+ // A sign check alone (`centerOffset < 0`) still passes for a centered block
480
+ // that has merely drifted — which is the regression this guards.
481
+
482
+ const left = spanAround({ type: 'text', x: 'Feb', y: 50, text: 'Left side', anchor: 'left' });
483
+ // Whole block to the LEFT: its near (right) edge stops one setback short of
484
+ // the point, so the point never falls inside the span.
485
+ expect(left.right).toBeCloseTo(-ANCHOR_OFFSET, 1);
486
+ expect(left.left).toBeLessThan(left.right);
487
+
488
+ const right = spanAround({
489
+ type: 'text',
490
+ x: 'Feb',
491
+ y: 50,
492
+ text: 'Right side',
493
+ anchor: 'right',
494
+ });
495
+ // Mirror image: near (left) edge starts one setback past the point.
496
+ expect(right.left).toBeCloseTo(ANCHOR_OFFSET, 1);
497
+ expect(right.right).toBeGreaterThan(right.left);
498
+
499
+ // And the contrast that gives "beside" its meaning: a top anchor STRADDLES,
500
+ // i.e. the point lies strictly inside the block's x-span.
501
+ const top = spanAround({ type: 'text', x: 'Feb', y: 50, text: 'Left side', anchor: 'top' });
502
+ expect(top.left).toBeLessThan(0);
503
+ expect(top.right).toBeGreaterThan(0);
504
+ });
505
+ });
506
+
507
+ describe('annotation typography survives normalization', () => {
508
+ const baseSpec = (annotation: Record<string, unknown>) => ({
509
+ mark: 'line' as const,
510
+ data: [
511
+ { year: '1980', value: 0.26 },
512
+ { year: '2025', value: 1.17 },
513
+ ],
514
+ encoding: {
515
+ x: { field: 'year' as const, type: 'ordinal' as const },
516
+ y: { field: 'value' as const, type: 'quantitative' as const },
517
+ },
518
+ annotations: [annotation],
519
+ });
520
+
521
+ const styleOf = (annotation: Record<string, unknown>) => {
522
+ const layout = compileChart(baseSpec(annotation), { width: 800, height: 460 });
523
+ const style = layout.annotations?.[0]?.label?.style;
524
+ if (!style) throw new Error('annotation did not resolve a label style');
525
+ return style;
526
+ };
527
+
528
+ test('a subtitle promotes the primary line to a bold lede', () => {
529
+ const style = styleOf({
530
+ type: 'text',
531
+ x: '2025',
532
+ y: 1.17,
533
+ text: 'Peak',
534
+ subtitle: 'the warmest year on record',
535
+ });
536
+ expect(style.fontWeight).toBe(700);
537
+ });
538
+
539
+ test('a plain annotation stays regular weight', () => {
540
+ const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak' });
541
+ expect(style.fontWeight).toBe(400);
542
+ });
543
+
544
+ test('the default font size reaches the compiled output', () => {
545
+ const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak' });
546
+ expect(style.fontSize).toBe(13);
547
+ });
548
+
549
+ test('an explicit fontWeight beats the lede rule', () => {
550
+ const style = styleOf({
551
+ type: 'text',
552
+ x: '2025',
553
+ y: 1.17,
554
+ text: 'Peak',
555
+ subtitle: 'context',
556
+ fontWeight: 300,
557
+ });
558
+ expect(style.fontWeight).toBe(300);
559
+ });
560
+
561
+ test('an explicit fontSize beats the default', () => {
562
+ const style = styleOf({ type: 'text', x: '2025', y: 1.17, text: 'Peak', fontSize: 18 });
563
+ expect(style.fontSize).toBe(18);
564
+ });
565
+ });
113
566
  });
@@ -419,16 +419,16 @@ describe('computeLegend', () => {
419
419
  expect(legend.entries).toHaveLength(3);
420
420
  });
421
421
 
422
- it('suppresses legend for default (overlap) area chart with labels', () => {
423
- // v6: area defaults to overlap. Endpoint labels identify series, so
424
- // the legend auto-suppresses just like line charts.
422
+ it('preserves legend for default (stacked) area chart', () => {
423
+ // v8: area stacks by default (Vega-Lite aligned). Stacked areas keep
424
+ // the traditional legend rather than auto-suppressing for endpoint labels.
425
425
  const areaSpec: NormalizedChartSpec = {
426
426
  ...lineWithLabels,
427
427
  markType: 'area',
428
428
  markDef: { type: 'area' },
429
429
  };
430
430
  const legend = computeLegend(areaSpec, fullStrategy, theme, chartArea);
431
- expect(legend.entries).toHaveLength(0);
431
+ expect(legend.entries).toHaveLength(3);
432
432
  });
433
433
 
434
434
  it('suppresses legend for explicit overlap area chart (stack: null)', () => {
@@ -653,4 +653,69 @@ describe('computeLegend', () => {
653
653
  expect(legend.entries).toHaveLength(3);
654
654
  });
655
655
  });
656
+
657
+ describe('swatch colors match mark colors', () => {
658
+ const energySpec = {
659
+ mark: 'waffle' as const,
660
+ data: [
661
+ { source: 'Fossil fuels', share: 60 },
662
+ { source: 'Renewables', share: 30 },
663
+ { source: 'Nuclear', share: 10 },
664
+ ],
665
+ encoding: {
666
+ theta: { field: 'share', type: 'quantitative' as const },
667
+ color: { field: 'source', type: 'nominal' as const },
668
+ },
669
+ };
670
+
671
+ /**
672
+ * Map each category to the fill its marks actually render with. Waffle
673
+ * cells carry the category in `data`, not `seriesKey`, so key off the
674
+ * color field.
675
+ */
676
+ function markFillByLabel(layout: ReturnType<typeof compileChart>): Map<string, string> {
677
+ const fills = new Map<string, string>();
678
+ for (const mark of layout.marks) {
679
+ const label = mark.data?.source;
680
+ const fill = 'fill' in mark ? mark.fill : undefined;
681
+ if (typeof label === 'string' && typeof fill === 'string') fills.set(label, fill);
682
+ }
683
+ return fills;
684
+ }
685
+
686
+ it('mutes the swatches of non-highlighted series alongside their marks', () => {
687
+ const layout = compileChart(
688
+ {
689
+ ...energySpec,
690
+ encoding: {
691
+ ...energySpec.encoding,
692
+ color: { ...energySpec.encoding.color, highlight: 'Renewables' },
693
+ },
694
+ },
695
+ { width: 600, height: 400 },
696
+ );
697
+
698
+ const fills = markFillByLabel(layout);
699
+ expect(layout.legend.entries.length).toBe(3);
700
+ for (const entry of layout.legend.entries) {
701
+ expect(entry.color).toBe(fills.get(entry.label));
702
+ }
703
+
704
+ // The muted pair share one gray; the highlighted series does not.
705
+ const highlighted = layout.legend.entries.find((e) => e.label === 'Renewables');
706
+ const muted = layout.legend.entries.filter((e) => e.label !== 'Renewables');
707
+ expect(muted.map((e) => e.color)).toEqual([muted[0].color, muted[0].color]);
708
+ expect(highlighted?.color).not.toBe(muted[0].color);
709
+ });
710
+
711
+ it('matches mark colors when no highlight is active', () => {
712
+ const layout = compileChart(energySpec, { width: 600, height: 400 });
713
+
714
+ const fills = markFillByLabel(layout);
715
+ expect(layout.legend.entries.length).toBe(3);
716
+ for (const entry of layout.legend.entries) {
717
+ expect(entry.color).toBe(fills.get(entry.label));
718
+ }
719
+ });
720
+ });
656
721
  });