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

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 (159) hide show
  1. package/dist/index.d.ts +123 -14
  2. package/dist/index.js +16166 -10027
  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__/keys.test.ts +3 -9
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/keys.ts +5 -4
  122. package/src/compiler/normalize.ts +150 -7
  123. package/src/compiler/types.ts +11 -0
  124. package/src/compiler/validate.ts +664 -18
  125. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  126. package/src/endpoint-labels/compute.ts +40 -12
  127. package/src/endpoint-labels/format.ts +45 -15
  128. package/src/endpoint-labels/predict.ts +50 -36
  129. package/src/format/__tests__/field-format.test.ts +164 -0
  130. package/src/format/field-format.ts +27 -0
  131. package/src/geo/__tests__/compile-map.test.ts +621 -0
  132. package/src/geo/compile-map.ts +856 -0
  133. package/src/geo/join.ts +115 -0
  134. package/src/geo/projections.ts +45 -0
  135. package/src/geo/types.ts +24 -0
  136. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  137. package/src/graphs/compile-graph.ts +27 -18
  138. package/src/index.ts +20 -2
  139. package/src/layout/axes/ticks.ts +59 -10
  140. package/src/layout/axes.ts +35 -6
  141. package/src/layout/dimensions.ts +139 -83
  142. package/src/layout/facet.ts +14 -3
  143. package/src/layout/plan.ts +83 -33
  144. package/src/layout/scales.ts +132 -15
  145. package/src/legend/__tests__/continuous.test.ts +318 -0
  146. package/src/legend/__tests__/size.test.ts +348 -0
  147. package/src/legend/__tests__/suppression.test.ts +17 -1
  148. package/src/legend/compute.ts +119 -13
  149. package/src/legend/continuous.ts +327 -0
  150. package/src/legend/size.ts +407 -0
  151. package/src/legend/suppression.ts +8 -7
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
package/src/compile.ts CHANGED
@@ -14,35 +14,32 @@
14
14
 
15
15
  import type {
16
16
  AnimationSpec,
17
- BinParams,
18
- BinTransform,
19
17
  ChartLayout,
20
18
  ChartSpec,
21
19
  CompileOptions,
22
20
  CompileTableOptions,
23
21
  Encoding,
24
- EncodingChannel,
25
22
  FacetChannel,
26
23
  FacetPanelLayout,
27
24
  LayerSpec,
28
25
  LayoutStrategy,
26
+ LegendLayout,
29
27
  Mark,
30
28
  ResolvedAnimation,
31
29
  ResolvedAnnotation,
32
30
  ResolvedTheme,
33
31
  TableLayout,
34
- TimeUnit,
35
- TimeUnitTransform,
36
- Transform,
37
32
  } from '@opendata-ai/openchart-core';
38
33
  import {
39
34
  adaptTheme,
40
35
  computeLabelBounds,
36
+ footnoteBandHeight,
41
37
  generateAltText,
42
38
  generateDataTable,
43
39
  getBreakpoint,
44
40
  getHeightClass,
45
41
  getLayoutStrategy,
42
+ isAxislessMark,
46
43
  resolveTheme,
47
44
  } from '@opendata-ai/openchart-core';
48
45
  import { computeAnnotations } from './annotations/compute';
@@ -64,8 +61,12 @@ import { getChartRenderer } from './charts/registry';
64
61
  import { groupByField } from './charts/utils';
65
62
  import { applyColorScaleRange } from './compile/color-scale-range';
66
63
  import { filterClippedDomains } from './compile/data-clip';
64
+ import { applyFillPatterns } from './compile/fill-patterns';
67
65
  import { compileLayer as compileLayerImpl } from './compile/layer';
66
+ import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar';
68
67
  import { computeWatermarkObstacle } from './compile/watermark-obstacle';
68
+ import { resolveYouDrawIt } from './compile/you-draw-it';
69
+ import { collectContrastWarnings } from './compiler/a11y-warnings';
69
70
  import { resolveAnimation } from './compiler/animation';
70
71
  import { compile as compileSpec } from './compiler/index';
71
72
  import {
@@ -75,15 +76,17 @@ import {
75
76
  trendColor,
76
77
  } from './compiler/sparkline-defaults';
77
78
  import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
79
+ import { compileMap as compileMapImpl } from './geo/compile-map';
78
80
  import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
79
81
  import type { GraphCompilation } from './graphs/types';
80
82
  import { computeAxes } from './layout/axes';
81
83
  import { computeDimensions } from './layout/dimensions';
82
- import { computeFacetGrid } from './layout/facet';
84
+ import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
83
85
  import { computeGridlines } from './layout/gridlines';
84
86
  import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
85
87
  import { computeScales } from './layout/scales';
86
88
  import { placeLegend } from './legend/compute';
89
+ import { placeSizeLegend } from './legend/size';
87
90
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
88
91
  import { compileTableLayout } from './tables/compile-table';
89
92
  import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
@@ -91,76 +94,26 @@ import { computeTooltipDescriptors } from './tooltips/compute';
91
94
  import { runTransforms } from './transforms';
92
95
 
93
96
  // ---------------------------------------------------------------------------
94
- // Encoding sugar expansion (bin, timeUnit on encoding channels)
97
+ // Spec sugar expansion (VL idioms, bin/timeUnit, deprecation warnings)
95
98
  // ---------------------------------------------------------------------------
96
99
 
100
+ // Implementation lives in ./compile/spec-sugar; re-exported here so existing
101
+ // deep imports (`import { expandEncodingSugar } from '.../compile'`) keep working.
102
+ export { expandEncodingSugar, expandSpecSugar } from './compile/spec-sugar';
103
+
97
104
  /**
98
- * Expand encoding-level `bin` and `timeUnit` shorthand into explicit transforms.
99
- *
100
- * Vega-Lite allows `encoding.x.bin: true` as sugar for a BinTransform.
101
- * This function detects those shorthands, generates the corresponding transforms,
102
- * updates encoding field references to the output field names, and prepends the
103
- * transforms to the spec's transform array.
104
- *
105
- * Mutates nothing; returns a new spec object (shallow copy).
105
+ * Apply top-level spec `width`/`height` (VL fixed-size sugar) as overrides on
106
+ * the compile options. Returns the options untouched when the spec carries no
107
+ * size.
106
108
  */
107
- export function expandEncodingSugar(spec: Record<string, unknown>): Record<string, unknown> {
108
- const encoding = spec.encoding as Record<string, EncodingChannel | undefined> | undefined;
109
- if (!encoding) return spec;
110
-
111
- const generatedTransforms: Transform[] = [];
112
- const updatedEncoding = { ...encoding };
113
- let changed = false;
114
-
115
- for (const channel of Object.keys(encoding)) {
116
- const ch = encoding[channel];
117
- if (!ch || !ch.field) continue;
118
-
119
- // Expand bin shorthand
120
- if (ch.bin != null && ch.bin !== false) {
121
- const field = ch.field;
122
- const outputField = `bin_${field}`;
123
- const binTransform: BinTransform = {
124
- bin: ch.bin === true ? true : (ch.bin as BinParams),
125
- field,
126
- as: outputField,
127
- };
128
- generatedTransforms.push(binTransform);
129
-
130
- // Update encoding to reference binned output field, remove bin property
131
- const { bin: _bin, ...rest } = ch;
132
- updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
133
- changed = true;
134
- }
135
-
136
- // Expand timeUnit shorthand (read from updated encoding in case bin already ran)
137
- const current = updatedEncoding[channel] ?? ch;
138
- if (current.timeUnit) {
139
- const field = current.field;
140
- const unit = current.timeUnit as TimeUnit;
141
- const outputField = `${unit}_${field}`;
142
- const timeUnitTransform: TimeUnitTransform = {
143
- timeUnit: unit,
144
- field,
145
- as: outputField,
146
- };
147
- generatedTransforms.push(timeUnitTransform);
148
-
149
- // Update encoding to reference timeUnit output field, remove timeUnit property
150
- const { timeUnit: _tu, ...rest } = current;
151
- updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
152
- changed = true;
153
- }
154
- }
155
-
156
- if (!changed) return spec;
157
-
158
- // Prepend generated transforms before any user-defined transforms
159
- const existingTransforms = (spec.transform as Transform[] | undefined) ?? [];
109
+ function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions {
110
+ if (!spec || typeof spec !== 'object') return options;
111
+ const sized = spec as Record<string, unknown>;
112
+ if (typeof sized.width !== 'number' && typeof sized.height !== 'number') return options;
160
113
  return {
161
- ...spec,
162
- encoding: updatedEncoding,
163
- transform: [...generatedTransforms, ...existingTransforms],
114
+ ...options,
115
+ width: typeof sized.width === 'number' ? sized.width : options.width,
116
+ height: typeof sized.height === 'number' ? sized.height : options.height,
164
117
  };
165
118
  }
166
119
 
@@ -168,6 +121,15 @@ export function expandEncodingSugar(spec: Record<string, unknown>): Record<strin
168
121
  // Chart compilation
169
122
  // ---------------------------------------------------------------------------
170
123
 
124
+ /**
125
+ * Cap on footnote-reserve convergence passes in compileChart. Reserving space
126
+ * for the footnote list shrinks the plot, which can demote one more label and
127
+ * grow the list; the count only ever grows and is bounded by the annotation
128
+ * count, so this terminates on its own. The cap is a guard against a
129
+ * measurement quirk oscillating, not the normal exit condition.
130
+ */
131
+ const MAX_FOOTNOTE_PASSES = 3;
132
+
171
133
  /**
172
134
  * Inject sparkline-mode visual defaults that depend on the resolved theme.
173
135
  *
@@ -233,20 +195,32 @@ function applySparklineDefaults(
233
195
  * ChartLayout with positions, colors, and marks ready for rendering.
234
196
  *
235
197
  * @param spec - Raw chart spec (validated and normalized internally).
236
- * @param options - Compile options (width, height, theme, darkMode).
198
+ * @param optionsInput - Compile options (width, height, theme, darkMode).
199
+ * Top-level spec width/height, when present, override the option values.
237
200
  * @returns ChartLayout with all computed positions.
238
201
  * @throws Error if spec is invalid or not a chart type.
239
202
  */
240
- export function compileChart(spec: unknown, options: CompileOptions): ChartLayout {
241
- // Expand encoding-level bin/timeUnit sugar before validation + normalization.
242
- // This converts shorthand (e.g. encoding.x.bin: true) into explicit transforms.
203
+ export function compileChart(spec: unknown, optionsInput: CompileOptions): ChartLayout {
204
+ // Expand VL-idiom and encoding-level sugar (data {values}, top-level title,
205
+ // sort-by-value, value defs, scheme names, theta, bin/timeUnit, ...) before
206
+ // validation + normalization, surfacing any deprecation warnings.
207
+ const sugarWarnings: string[] = [];
243
208
  const expandedSpec =
244
209
  spec && typeof spec === 'object' && !Array.isArray(spec)
245
- ? expandEncodingSugar(spec as Record<string, unknown>)
210
+ ? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
246
211
  : spec;
247
212
 
248
- // Validate + normalize
249
- const { spec: normalized } = compileSpec(expandedSpec);
213
+ // Top-level width/height are fixed-size overrides (VL alignment): the
214
+ // authored size wins over the container-derived compile options.
215
+ const options = applySpecSize(expandedSpec, optionsInput);
216
+
217
+ // Validate + normalize. Normalize-stage warnings (type mismatches,
218
+ // beeswarm point budget, sparkline mark advice) surface through the same
219
+ // console channel as the sugar warnings. Emit both together in one call so
220
+ // dedup spans the whole compile: a message produced by both a sugar pass and
221
+ // a normalize pass prints once, not once per stage.
222
+ const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec);
223
+ emitSpecWarnings([...sugarWarnings, ...normalizeWarnings], options.onWarn);
250
224
 
251
225
  if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
252
226
  throw new Error('compileChart received a table spec. Use compileTable instead.');
@@ -260,6 +234,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
260
234
  ) {
261
235
  throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
262
236
  }
237
+ if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'map') {
238
+ throw new Error('compileChart received a map spec. Use compileMap instead.');
239
+ }
263
240
 
264
241
  let chartSpec = normalized as NormalizedChartSpec;
265
242
 
@@ -607,6 +584,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
607
584
  watermark,
608
585
  resolvedAnimation,
609
586
  crosshair,
587
+ spec,
588
+ optionsInput,
610
589
  );
611
590
  }
612
591
 
@@ -651,6 +630,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
651
630
  dims.effectiveAxisGap,
652
631
  );
653
632
 
633
+ // Every legend on the chart. The color legend is primary (and stays in the
634
+ // singular `legend` slot); a size legend joins it when a quantitative `size`
635
+ // channel is encoded. Consumers that reserve space or hit-test must iterate
636
+ // this rather than read `legend` -- see ChartLayout.legends.
637
+ const sizeLegend = plan.sizeLegendContent
638
+ ? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend)
639
+ : null;
640
+ const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])];
641
+
654
642
  // Inline y-axis labels: inset the x-scale range start by the widest tick
655
643
  // label width so data marks start to the right of the labels. The plan
656
644
  // already measured the real tick labels.
@@ -677,8 +665,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
677
665
  scales.defaultColor =
678
666
  chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
679
667
 
680
- // Arc charts (pie/donut) don't use axes or gridlines
681
- const isRadial = chartSpec.markType === 'arc';
668
+ // Dev-mode WCAG contrast diagnostics. Host-gated via options.dev (never an
669
+ // env sniff; the engine is isomorphic). Advisory console.warn only.
670
+ if (options.dev) {
671
+ emitSpecWarnings(collectContrastWarnings(scales, chartSpec.markType, theme), options.onWarn);
672
+ }
673
+
674
+ // Axisless marks (arc, waffle, calendar, parliament) don't use axes or
675
+ // gridlines: arcs and parliament hemicycles are radial, waffles and calendars
676
+ // compute their own grid geometry.
677
+ const isRadial = isAxislessMark(chartSpec.markType);
682
678
 
683
679
  // Compute axes (skip for radial charts).
684
680
  // Sparkline mode skips axes by default unless the user explicitly opted into
@@ -718,6 +714,18 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
718
714
  ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
719
715
  : [];
720
716
 
717
+ // Opt-in per-series fill patterns (mark.fillPattern: 'auto'). Mutates
718
+ // filled marks in place; assignment is deterministic and theme-aware.
719
+ applyFillPatterns(marks, chartSpec.markDef, theme);
720
+
721
+ // Resolve "you draw it" geometry (pixel fromX, x-sample positions, target
722
+ // line color/key). Reads marks so it must run after they're computed.
723
+ const xFieldForYdi =
724
+ chartSpec.encoding.x && 'field' in chartSpec.encoding.x
725
+ ? (chartSpec.encoding.x.field as string | undefined)
726
+ : undefined;
727
+ const youDrawIt = resolveYouDrawIt(chartSpec.youDrawIt, marks, scales, chartArea, xFieldForYdi);
728
+
721
729
  // Compute the right-side endpoint labels column for multi-series line/area
722
730
  // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
723
731
  // dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
@@ -765,6 +773,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
765
773
  obstacles,
766
774
  svg: { width: dims.total.width, height: dims.total.height },
767
775
  measure: annotationMeasure,
776
+ fontFamily: theme.fonts.family,
768
777
  autoThin: chartSpec.autoThin,
769
778
  });
770
779
 
@@ -773,26 +782,67 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
773
782
  // alignment with the resolved array is preserved.
774
783
  let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
775
784
  if (chartSpec.autoThin && annotations.length > 1) {
776
- const result = thinAnnotations(annotations, chartSpec.annotations, annotationMeasure);
785
+ const result = thinAnnotations(
786
+ annotations,
787
+ chartSpec.annotations,
788
+ annotationMeasure,
789
+ chartArea,
790
+ { x: 0, y: 0, width: dims.total.width, height: dims.total.height },
791
+ );
777
792
  annotations = result.annotations;
778
793
  if (result.footnotes.length > 0) {
779
794
  footnotes = result.footnotes;
795
+
796
+ // The footnote list renders below the plot, but nothing reserved space for
797
+ // it — thinning only just now discovered how many lines there are. Recompile
798
+ // with that band carved out of the bottom margin so the list can't overrun
799
+ // the source row and the brand watermark.
800
+ //
801
+ // Reserving shrinks the plot, which can push a further label out of bounds
802
+ // and grow the list by one, so re-reserve until the count settles. The
803
+ // count only ever grows (a smaller plot never keeps more labels inline) and
804
+ // is bounded by the annotation count, so this terminates; the explicit cap
805
+ // is a belt-and-braces guard against a measurement quirk oscillating.
806
+ //
807
+ // A frozen area short-circuits all of this. `compileLayer` pins every
808
+ // leaf to the primary layout's coordinate space, and `chartArea` above
809
+ // takes `frozenChartArea` over anything `computeDimensions` produces --
810
+ // so a grown bottom margin is computed and then thrown away. The
811
+ // recursion would re-run the whole compile up to MAX_FOOTNOTE_PASSES
812
+ // times and return the identical layout. Skip it rather than pay for it.
813
+ const reserve = footnoteBandHeight(footnotes.length, theme);
814
+ const currentReserve = options.footnoteReserve ?? 0;
815
+ if (
816
+ !options.frozenChartArea &&
817
+ reserve > currentReserve &&
818
+ (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES
819
+ ) {
820
+ return compileChart(spec, {
821
+ ...optionsInput,
822
+ footnoteReserve: reserve,
823
+ footnotePass: (options.footnotePass ?? 0) + 1,
824
+ });
825
+ }
780
826
  }
781
827
  }
782
828
 
783
829
  // Compute tooltip descriptors from marks and encoding
784
830
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
785
831
 
786
- // Compute accessibility
787
- const altText = generateAltText(
788
- {
789
- mark: chartSpec.markType,
790
- data: chartSpec.data,
791
- encoding: chartSpec.encoding,
792
- chrome: chartSpec.chrome,
793
- } as ChartSpec,
794
- chartSpec.data,
795
- );
832
+ // Compute accessibility. An author-provided description (a11y.description,
833
+ // or top-level `description` folded in by the sugar expansion) replaces the
834
+ // auto-generated alt text; auto-generation stays the fallback.
835
+ const altText =
836
+ chartSpec.a11y?.description ??
837
+ generateAltText(
838
+ {
839
+ mark: chartSpec.markType,
840
+ data: chartSpec.data,
841
+ encoding: chartSpec.encoding,
842
+ chrome: chartSpec.chrome,
843
+ } as ChartSpec,
844
+ chartSpec.data,
845
+ );
796
846
  const dataTableFallback = generateDataTable(
797
847
  {
798
848
  mark: chartSpec.markType,
@@ -811,6 +861,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
811
861
  area: chartArea,
812
862
  chrome,
813
863
  metrics: dims.metrics,
864
+ ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
865
+ ...(youDrawIt ? { youDrawIt } : {}),
814
866
  axes: {
815
867
  x: axes.x,
816
868
  y: axes.y,
@@ -818,6 +870,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
818
870
  marks,
819
871
  annotations,
820
872
  legend: finalLegend,
873
+ legends: allLegends,
821
874
  ...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
822
875
  tooltipDescriptors,
823
876
  a11y: {
@@ -825,6 +878,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
825
878
  dataTableFallback,
826
879
  role: 'img',
827
880
  keyboardNavigable: marks.length > 0,
881
+ ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
828
882
  },
829
883
  theme,
830
884
  dimensions: {
@@ -853,6 +907,8 @@ function compileFaceted(
853
907
  watermark: boolean,
854
908
  resolvedAnimation: ResolvedAnimation | undefined,
855
909
  crosshair: boolean,
910
+ rawSpec: unknown,
911
+ optionsInput: CompileOptions,
856
912
  ): ChartLayout {
857
913
  const measure = createMeasureFn(options.measureText);
858
914
 
@@ -922,6 +978,23 @@ function compileFaceted(
922
978
  bottom: bottomReservation,
923
979
  });
924
980
 
981
+ // If panels are too short, grow the compile height and restart (once).
982
+ if (
983
+ !optionsInput.facetHeightGrown &&
984
+ grid.panels.length > 0 &&
985
+ grid.panels[0].area.height < MIN_PANEL_HEIGHT
986
+ ) {
987
+ const neededChartHeight = facetMinHeight(facetValues.length, grid.columns);
988
+ const heightGrowth = neededChartHeight - chartArea.height;
989
+ if (heightGrowth > 0) {
990
+ return compileChart(rawSpec, {
991
+ ...optionsInput,
992
+ height: options.height + heightGrowth,
993
+ facetHeightGrown: true,
994
+ });
995
+ }
996
+ }
997
+
925
998
  // Resolve scale for determining if we use 'independent' or 'shared'
926
999
  const resolveConfig = chartSpec.resolve;
927
1000
  const yResolve = resolveConfig?.scale?.y ?? 'shared';
@@ -934,7 +1007,7 @@ function compileFaceted(
934
1007
  const sharedYDomain =
935
1008
  yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
936
1009
 
937
- const isRadial = chartSpec.markType === 'arc';
1010
+ const isRadial = isAxislessMark(chartSpec.markType);
938
1011
  const rendererKey = resolveRendererKey(
939
1012
  renderSpec.markType,
940
1013
  panelEncoding as Encoding,
@@ -944,6 +1017,11 @@ function compileFaceted(
944
1017
 
945
1018
  const allMarks: Mark[] = [];
946
1019
  const panelLayouts: FacetPanelLayout[] = [];
1020
+ // Footnotes pool across panels into one figure-level list (the renderer draws
1021
+ // a single list below the grid from `chrome.footnotes`), so the numbering is
1022
+ // continuous and each panel's markers index into it.
1023
+ const allFootnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] = [];
1024
+ const footnoteNumberBySpecIndex = new Map<number, number>();
947
1025
 
948
1026
  for (const gridPanel of grid.panels) {
949
1027
  const panelData = groups.get(gridPanel.key) ?? [];
@@ -976,18 +1054,22 @@ function compileFaceted(
976
1054
  panelScales.defaultColor =
977
1055
  chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
978
1056
 
979
- // Outer-axis economy: only leftmost column gets y ticks, only bottom row gets x ticks.
980
- // Exception: when scales are independent, every panel needs its own axis ticks.
1057
+ // Dev-mode contrast diagnostics: panels share one palette resolution, so
1058
+ // checking the first panel covers the figure without repeating warnings.
1059
+ if (options.dev && panelLayouts.length === 0) {
1060
+ emitSpecWarnings(
1061
+ collectContrastWarnings(panelScales, chartSpec.markType, theme),
1062
+ options.onWarn,
1063
+ );
1064
+ }
1065
+
981
1066
  const isLeftCol = gridPanel.col === 0;
982
- const isBottomRow = gridPanel.row === grid.rows - 1;
983
1067
 
984
1068
  const panelAxes = isRadial
985
1069
  ? { x: undefined, y: undefined }
986
1070
  : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
987
1071
  data: panelData,
988
1072
  encoding: panelSpecWithDomains.encoding as Encoding,
989
- skipX: xResolve === 'shared' ? !isBottomRow : false,
990
- skipY: yResolve === 'shared' ? !isLeftCol : false,
991
1073
  markType: chartSpec.markType,
992
1074
  totalWidth: gridPanel.area.width,
993
1075
  });
@@ -996,6 +1078,18 @@ function compileFaceted(
996
1078
  computeGridlines(panelAxes, gridPanel.area);
997
1079
  }
998
1080
 
1081
+ // Strip y-axis tick labels from non-leftmost panels (gridlines stay).
1082
+ // X-axis labels show on every panel for readability.
1083
+ if (yResolve === 'shared' && !isLeftCol && panelAxes.y) {
1084
+ panelAxes.y = {
1085
+ ...panelAxes.y,
1086
+ ticks: [],
1087
+ label: undefined,
1088
+ domainLine: false,
1089
+ tickMarks: false,
1090
+ };
1091
+ }
1092
+
999
1093
  // Compute marks for this panel
1000
1094
  const panelMarks: Mark[] = renderer
1001
1095
  ? renderer(
@@ -1012,7 +1106,7 @@ function compileFaceted(
1012
1106
  const annotationMeasure: AnnotationMeasureTextFn = options.measureText
1013
1107
  ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
1014
1108
  : heuristicMeasure;
1015
- const panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1109
+ let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1016
1110
  scales: panelScales,
1017
1111
  chartArea: gridPanel.area,
1018
1112
  strategy,
@@ -1020,9 +1114,55 @@ function compileFaceted(
1020
1114
  obstacles: [],
1021
1115
  svg: { width: options.width, height: options.height },
1022
1116
  measure: annotationMeasure,
1117
+ fontFamily: theme.fonts.family,
1023
1118
  autoThin: panelSpecWithDomains.autoThin,
1024
1119
  });
1025
1120
 
1121
+ // Auto-thinning, per panel. A panel is its own little chart: the box a label
1122
+ // must stay inside is the panel, not the figure. Thinning against the whole
1123
+ // SVG (what the single-chart path passes) would let a label from the left
1124
+ // column sprawl across its neighbour and still count as "fits".
1125
+ //
1126
+ // The spec's annotations resolve into *every* panel, so the same annotation
1127
+ // can demote in more than one. The footnote list is figure-level and its text
1128
+ // is per-spec-annotation, so number by spec index and reuse that number in
1129
+ // every panel that demoted it -- otherwise the list repeats itself once per
1130
+ // panel.
1131
+ if (chartSpec.autoThin && panelAnnotations.length > 1) {
1132
+ const thinned = thinAnnotations(
1133
+ panelAnnotations,
1134
+ chartSpec.annotations,
1135
+ annotationMeasure,
1136
+ gridPanel.area,
1137
+ gridPanel.area,
1138
+ );
1139
+ panelAnnotations = thinned.annotations;
1140
+ // `thinAnnotations` numbers footnotes 1..n *within the panel it was given*,
1141
+ // so restamp each demoted annotation with its figure-level number. Take the
1142
+ // text from `thinned.footnotes` (the 1-based panel-local index it just
1143
+ // assigned) rather than re-reading `label.text`: thinning owns that string,
1144
+ // and re-deriving it here would be a second producer to drift out of sync.
1145
+ //
1146
+ // Key on `specIndex`, NOT on position: with `resolve.scale.x: 'independent'`
1147
+ // each panel derives its own domain, so an annotation can resolve in one
1148
+ // panel and be dropped in another. Position i then means a different spec
1149
+ // annotation in each panel, and the markers point at other panels' text.
1150
+ for (const a of panelAnnotations) {
1151
+ const panelIndex = a.footnoteIndex;
1152
+ if (panelIndex == null || a.specIndex === undefined) continue;
1153
+ let num = footnoteNumberBySpecIndex.get(a.specIndex);
1154
+ if (num === undefined) {
1155
+ num = allFootnotes.length + 1;
1156
+ footnoteNumberBySpecIndex.set(a.specIndex, num);
1157
+ allFootnotes.push({
1158
+ index: num,
1159
+ text: thinned.footnotes[panelIndex - 1]?.text ?? '',
1160
+ });
1161
+ }
1162
+ a.footnoteIndex = num;
1163
+ }
1164
+ }
1165
+
1026
1166
  allMarks.push(...panelMarks);
1027
1167
 
1028
1168
  panelLayouts.push({
@@ -1041,10 +1181,37 @@ function compileFaceted(
1041
1181
  });
1042
1182
  }
1043
1183
 
1184
+ // The footnote list renders below the grid, but nothing reserved space for it
1185
+ // -- thinning only just now discovered how many lines there are. Recompile with
1186
+ // that band carved out of the bottom margin, same contract as the single-chart
1187
+ // path (see the matching block in compileChart). computeDimensions applies
1188
+ // options.footnoteReserve generically, so the facet grid shrinks with it.
1189
+ //
1190
+ // A frozen area short-circuits this, same as the single-chart path: compileLayer
1191
+ // pins every leaf to the primary layout's coordinate space, so the grown bottom
1192
+ // margin is computed and then thrown away. Recursing would re-run the whole
1193
+ // compile up to MAX_FOOTNOTE_PASSES times for an identical layout.
1194
+ if (allFootnotes.length > 0 && !options.frozenChartArea) {
1195
+ const reserve = footnoteBandHeight(allFootnotes.length, theme);
1196
+ const currentReserve = options.footnoteReserve ?? 0;
1197
+ if (reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
1198
+ return compileChart(rawSpec, {
1199
+ ...optionsInput,
1200
+ footnoteReserve: reserve,
1201
+ footnotePass: (options.footnotePass ?? 0) + 1,
1202
+ });
1203
+ }
1204
+ }
1205
+
1044
1206
  // Compute tooltip descriptors from all marks across panels
1045
1207
  const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
1046
1208
 
1047
- // A11y: mention faceting in alt text
1209
+ // Opt-in per-series fill patterns across all panels at once so a series
1210
+ // gets the same pattern in every panel.
1211
+ applyFillPatterns(allMarks, chartSpec.markDef, theme);
1212
+
1213
+ // A11y: mention faceting in alt text. An author description is used
1214
+ // verbatim (the author already describes the whole figure).
1048
1215
  const panelCount = facetValues.length;
1049
1216
  const baseAltText = generateAltText(
1050
1217
  {
@@ -1055,7 +1222,9 @@ function compileFaceted(
1055
1222
  } as ChartSpec,
1056
1223
  chartSpec.data,
1057
1224
  );
1058
- const altText = `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1225
+ const altText =
1226
+ chartSpec.a11y?.description ??
1227
+ `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1059
1228
 
1060
1229
  const dataTableFallback = generateDataTable(
1061
1230
  {
@@ -1073,18 +1242,23 @@ function compileFaceted(
1073
1242
  // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
1074
1243
  return {
1075
1244
  area: chartArea,
1076
- chrome: dims.chrome,
1245
+ chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
1077
1246
  metrics: dims.metrics,
1247
+ ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
1078
1248
  axes: { x: undefined, y: undefined },
1079
1249
  marks: allMarks,
1080
1250
  annotations: [],
1081
1251
  legend: finalLegend,
1252
+ // Facets share one color legend across the grid; the size channel isn't
1253
+ // supported in facet specs, so there is no second legend to carry.
1254
+ legends: [finalLegend],
1082
1255
  tooltipDescriptors,
1083
1256
  a11y: {
1084
1257
  altText,
1085
1258
  dataTableFallback,
1086
1259
  role: 'img',
1087
1260
  keyboardNavigable: allMarks.length > 0,
1261
+ ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
1088
1262
  },
1089
1263
  theme,
1090
1264
  dimensions: { width: options.width, height: options.height },
@@ -1107,7 +1281,17 @@ function compileFaceted(
1107
1281
  // ---------------------------------------------------------------------------
1108
1282
 
1109
1283
  export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
1110
- return compileLayerImpl(spec, options, compileChart);
1284
+ // Expand VL-idiom sugar on the layer spec and every child up front. Leaf
1285
+ // compiles re-run the (idempotent) expansion after encoding inheritance;
1286
+ // warning triggers are stripped or stamped here so each warns exactly once.
1287
+ const sugarWarnings: string[] = [];
1288
+ const expanded = expandSpecSugar(
1289
+ spec as unknown as Record<string, unknown>,
1290
+ sugarWarnings,
1291
+ ) as unknown as LayerSpec;
1292
+ emitSpecWarnings(sugarWarnings, options.onWarn);
1293
+ const resolvedOptions = applySpecSize(expanded, options);
1294
+ return compileLayerImpl(expanded, resolvedOptions, compileChart);
1111
1295
  }
1112
1296
 
1113
1297
  // ---------------------------------------------------------------------------
@@ -1219,6 +1403,29 @@ export function compileTileMap(
1219
1403
  return compileTileMapImpl(spec, options);
1220
1404
  }
1221
1405
 
1406
+ // ---------------------------------------------------------------------------
1407
+ // Map compilation
1408
+ // ---------------------------------------------------------------------------
1409
+
1410
+ /**
1411
+ * Compile a map spec into a MapLayout.
1412
+ *
1413
+ * Takes a raw map spec, validates, normalizes, resolves theme and chrome,
1414
+ * projects geo features, joins data, builds feature marks with color fills,
1415
+ * and returns a MapLayout ready for rendering.
1416
+ *
1417
+ * @param spec - Raw map spec (validated and normalized internally).
1418
+ * @param options - Compile options (width, height, theme, darkMode).
1419
+ * @returns MapLayout with computed positions and visual properties.
1420
+ * @throws Error if spec is invalid or not a map type.
1421
+ */
1422
+ export function compileMap(
1423
+ spec: unknown,
1424
+ options: CompileOptions,
1425
+ ): import('@opendata-ai/openchart-core').MapLayout {
1426
+ return compileMapImpl(spec, options);
1427
+ }
1428
+
1222
1429
  // ---------------------------------------------------------------------------
1223
1430
  // BarList compilation
1224
1431
  // ---------------------------------------------------------------------------