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

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -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__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
package/src/compile.ts CHANGED
@@ -14,35 +14,34 @@
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,
31
+ ResolveMode,
32
+ ScaleInvert,
33
33
  TableLayout,
34
- TimeUnit,
35
- TimeUnitTransform,
36
- Transform,
37
34
  } from '@opendata-ai/openchart-core';
38
35
  import {
39
36
  adaptTheme,
40
37
  computeLabelBounds,
38
+ footnoteBandHeight,
41
39
  generateAltText,
42
40
  generateDataTable,
43
41
  getBreakpoint,
44
42
  getHeightClass,
45
43
  getLayoutStrategy,
44
+ isAxislessMark,
46
45
  resolveTheme,
47
46
  } from '@opendata-ai/openchart-core';
48
47
  import { computeAnnotations } from './annotations/compute';
@@ -64,8 +63,12 @@ import { getChartRenderer } from './charts/registry';
64
63
  import { groupByField } from './charts/utils';
65
64
  import { applyColorScaleRange } from './compile/color-scale-range';
66
65
  import { filterClippedDomains } from './compile/data-clip';
66
+ import { applyFillPatterns } from './compile/fill-patterns';
67
67
  import { compileLayer as compileLayerImpl } from './compile/layer';
68
+ import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar';
68
69
  import { computeWatermarkObstacle } from './compile/watermark-obstacle';
70
+ import { resolveYouDrawIt } from './compile/you-draw-it';
71
+ import { collectContrastWarnings } from './compiler/a11y-warnings';
69
72
  import { resolveAnimation } from './compiler/animation';
70
73
  import { compile as compileSpec } from './compiler/index';
71
74
  import {
@@ -75,15 +78,17 @@ import {
75
78
  trendColor,
76
79
  } from './compiler/sparkline-defaults';
77
80
  import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
81
+ import { compileMap as compileMapImpl } from './geo/compile-map';
78
82
  import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
79
83
  import type { GraphCompilation } from './graphs/types';
80
84
  import { computeAxes } from './layout/axes';
81
85
  import { computeDimensions } from './layout/dimensions';
82
- import { computeFacetGrid } from './layout/facet';
86
+ import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
83
87
  import { computeGridlines } from './layout/gridlines';
84
88
  import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
85
- import { computeScales } from './layout/scales';
89
+ import { computeScales, type ResolvedScale } from './layout/scales';
86
90
  import { placeLegend } from './legend/compute';
91
+ import { placeSizeLegend } from './legend/size';
87
92
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
88
93
  import { compileTableLayout } from './tables/compile-table';
89
94
  import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
@@ -91,76 +96,26 @@ import { computeTooltipDescriptors } from './tooltips/compute';
91
96
  import { runTransforms } from './transforms';
92
97
 
93
98
  // ---------------------------------------------------------------------------
94
- // Encoding sugar expansion (bin, timeUnit on encoding channels)
99
+ // Spec sugar expansion (VL idioms, bin/timeUnit, deprecation warnings)
95
100
  // ---------------------------------------------------------------------------
96
101
 
102
+ // Implementation lives in ./compile/spec-sugar; re-exported here so existing
103
+ // deep imports (`import { expandEncodingSugar } from '.../compile'`) keep working.
104
+ export { expandEncodingSugar, expandSpecSugar } from './compile/spec-sugar';
105
+
97
106
  /**
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).
107
+ * Apply top-level spec `width`/`height` (VL fixed-size sugar) as overrides on
108
+ * the compile options. Returns the options untouched when the spec carries no
109
+ * size.
106
110
  */
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) ?? [];
111
+ function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions {
112
+ if (!spec || typeof spec !== 'object') return options;
113
+ const sized = spec as Record<string, unknown>;
114
+ if (typeof sized.width !== 'number' && typeof sized.height !== 'number') return options;
160
115
  return {
161
- ...spec,
162
- encoding: updatedEncoding,
163
- transform: [...generatedTransforms, ...existingTransforms],
116
+ ...options,
117
+ width: typeof sized.width === 'number' ? sized.width : options.width,
118
+ height: typeof sized.height === 'number' ? sized.height : options.height,
164
119
  };
165
120
  }
166
121
 
@@ -168,6 +123,15 @@ export function expandEncodingSugar(spec: Record<string, unknown>): Record<strin
168
123
  // Chart compilation
169
124
  // ---------------------------------------------------------------------------
170
125
 
126
+ /**
127
+ * Cap on footnote-reserve convergence passes in compileChart. Reserving space
128
+ * for the footnote list shrinks the plot, which can demote one more label and
129
+ * grow the list; the count only ever grows and is bounded by the annotation
130
+ * count, so this terminates on its own. The cap is a guard against a
131
+ * measurement quirk oscillating, not the normal exit condition.
132
+ */
133
+ const MAX_FOOTNOTE_PASSES = 3;
134
+
171
135
  /**
172
136
  * Inject sparkline-mode visual defaults that depend on the resolved theme.
173
137
  *
@@ -225,6 +189,52 @@ function applySparklineDefaults(
225
189
  return { ...spec, markDef: newMarkDef };
226
190
  }
227
191
 
192
+ /**
193
+ * Extract scale inversion anchors from a resolved scale so the vanilla layer
194
+ * can convert pixel positions back to data-space values without holding the
195
+ * d3 scale object. Generalizes the YouDrawItYInvert precedent to any axis.
196
+ */
197
+ function resolveScaleInvert(resolved: ResolvedScale | undefined): ScaleInvert | undefined {
198
+ if (!resolved) return undefined;
199
+ const { scale, type } = resolved;
200
+
201
+ // Band / point scales: snap to nearest domain value
202
+ if (type === 'band' || type === 'point') {
203
+ const domain = (scale as { domain(): string[] }).domain() as (string | number)[];
204
+ const positions = domain.map((v) => {
205
+ const pos = (scale as (v: string | number) => number | undefined)(v) ?? 0;
206
+ return type === 'band' ? pos + (scale as { bandwidth(): number }).bandwidth() / 2 : pos;
207
+ });
208
+ const range = (scale as { range(): number[] }).range();
209
+ return {
210
+ topPixel: range[0],
211
+ bottomPixel: range[range.length - 1],
212
+ topData: 0,
213
+ bottomData: 0,
214
+ domain,
215
+ positions,
216
+ };
217
+ }
218
+
219
+ // Continuous scales (linear, time, log, pow, sqrt, symlog)
220
+ const invert = (scale as { invert?: (px: number) => unknown }).invert;
221
+ if (typeof invert !== 'function') return undefined;
222
+
223
+ const range = (scale as { range(): number[] }).range();
224
+ const topPx = range[0];
225
+ const bottomPx = range[range.length - 1];
226
+ const topVal = invert.call(scale, topPx);
227
+ const bottomVal = invert.call(scale, bottomPx);
228
+
229
+ // For time scales, convert Date to epoch ms
230
+ const topData = topVal instanceof Date ? +topVal : Number(topVal);
231
+ const bottomData = bottomVal instanceof Date ? +bottomVal : Number(bottomVal);
232
+ if (!Number.isFinite(topData) || !Number.isFinite(bottomData)) return undefined;
233
+
234
+ const scaleType = type as ScaleInvert['scaleType'];
235
+ return { topPixel: topPx, bottomPixel: bottomPx, topData, bottomData, scaleType };
236
+ }
237
+
228
238
  /**
229
239
  * Compile a chart spec into a ChartLayout.
230
240
  *
@@ -233,20 +243,32 @@ function applySparklineDefaults(
233
243
  * ChartLayout with positions, colors, and marks ready for rendering.
234
244
  *
235
245
  * @param spec - Raw chart spec (validated and normalized internally).
236
- * @param options - Compile options (width, height, theme, darkMode).
246
+ * @param optionsInput - Compile options (width, height, theme, darkMode).
247
+ * Top-level spec width/height, when present, override the option values.
237
248
  * @returns ChartLayout with all computed positions.
238
249
  * @throws Error if spec is invalid or not a chart type.
239
250
  */
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.
251
+ export function compileChart(spec: unknown, optionsInput: CompileOptions): ChartLayout {
252
+ // Expand VL-idiom and encoding-level sugar (data {values}, top-level title,
253
+ // sort-by-value, value defs, scheme names, theta, bin/timeUnit, ...) before
254
+ // validation + normalization, surfacing any deprecation warnings.
255
+ const sugarWarnings: string[] = [];
243
256
  const expandedSpec =
244
257
  spec && typeof spec === 'object' && !Array.isArray(spec)
245
- ? expandEncodingSugar(spec as Record<string, unknown>)
258
+ ? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
246
259
  : spec;
247
260
 
248
- // Validate + normalize
249
- const { spec: normalized } = compileSpec(expandedSpec);
261
+ // Top-level width/height are fixed-size overrides (VL alignment): the
262
+ // authored size wins over the container-derived compile options.
263
+ const options = applySpecSize(expandedSpec, optionsInput);
264
+
265
+ // Validate + normalize. Normalize-stage warnings (type mismatches,
266
+ // beeswarm point budget, sparkline mark advice) surface through the same
267
+ // console channel as the sugar warnings. Emit both together in one call so
268
+ // dedup spans the whole compile: a message produced by both a sugar pass and
269
+ // a normalize pass prints once, not once per stage.
270
+ const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec);
271
+ emitSpecWarnings([...sugarWarnings, ...normalizeWarnings], options.onWarn);
250
272
 
251
273
  if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
252
274
  throw new Error('compileChart received a table spec. Use compileTable instead.');
@@ -260,6 +282,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
260
282
  ) {
261
283
  throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
262
284
  }
285
+ if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'map') {
286
+ throw new Error('compileChart received a map spec. Use compileMap instead.');
287
+ }
263
288
 
264
289
  let chartSpec = normalized as NormalizedChartSpec;
265
290
 
@@ -593,20 +618,46 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
593
618
  }
594
619
 
595
620
  // ---------------------------------------------------------------------------
596
- // Faceted compilation: when encoding.facet is present, take the faceted path
621
+ // Faceted compilation: desugar row/column into facet, then take faceted path
597
622
  // ---------------------------------------------------------------------------
623
+ let facetDirection: 'row' | 'column' | 'wrap' = 'wrap';
624
+ const rowChannel = renderSpec.encoding.row;
625
+ const colChannel = renderSpec.encoding.column;
626
+ if (rowChannel?.field || colChannel?.field) {
627
+ const channel = (rowChannel?.field ? rowChannel : colChannel)!;
628
+ facetDirection = rowChannel?.field ? 'row' : 'column';
629
+ const desugared = { ...renderSpec.encoding } as Record<string, unknown>;
630
+ delete desugared.row;
631
+ delete desugared.column;
632
+ desugared.facet = {
633
+ field: channel.field,
634
+ type: channel.type,
635
+ sort: channel.sort,
636
+ ...(facetDirection === 'row' ? { columns: 1 } : {}),
637
+ };
638
+ renderSpec = { ...renderSpec, encoding: desugared as typeof renderSpec.encoding };
639
+ const chartDesugared = { ...chartSpec.encoding } as Record<string, unknown>;
640
+ delete chartDesugared.row;
641
+ delete chartDesugared.column;
642
+ chartDesugared.facet = desugared.facet;
643
+ chartSpec = { ...chartSpec, encoding: chartDesugared as typeof chartSpec.encoding };
644
+ }
645
+
598
646
  const facetChannel = renderSpec.encoding.facet;
599
647
  if (facetChannel?.field) {
600
648
  return compileFaceted(
601
649
  chartSpec,
602
650
  renderSpec,
603
651
  facetChannel,
652
+ facetDirection,
604
653
  options,
605
654
  theme,
606
655
  strategy,
607
656
  watermark,
608
657
  resolvedAnimation,
609
658
  crosshair,
659
+ spec,
660
+ optionsInput,
610
661
  );
611
662
  }
612
663
 
@@ -651,6 +702,15 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
651
702
  dims.effectiveAxisGap,
652
703
  );
653
704
 
705
+ // Every legend on the chart. The color legend is primary (and stays in the
706
+ // singular `legend` slot); a size legend joins it when a quantitative `size`
707
+ // channel is encoded. Consumers that reserve space or hit-test must iterate
708
+ // this rather than read `legend` -- see ChartLayout.legends.
709
+ const sizeLegend = plan.sizeLegendContent
710
+ ? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend)
711
+ : null;
712
+ const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])];
713
+
654
714
  // Inline y-axis labels: inset the x-scale range start by the widest tick
655
715
  // label width so data marks start to the right of the labels. The plan
656
716
  // already measured the real tick labels.
@@ -677,8 +737,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
677
737
  scales.defaultColor =
678
738
  chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
679
739
 
680
- // Arc charts (pie/donut) don't use axes or gridlines
681
- const isRadial = chartSpec.markType === 'arc';
740
+ // Dev-mode WCAG contrast diagnostics. Host-gated via options.dev (never an
741
+ // env sniff; the engine is isomorphic). Advisory console.warn only.
742
+ if (options.dev) {
743
+ emitSpecWarnings(collectContrastWarnings(scales, chartSpec.markType, theme), options.onWarn);
744
+ }
745
+
746
+ // Axisless marks (arc, waffle, calendar, parliament) don't use axes or
747
+ // gridlines: arcs and parliament hemicycles are radial, waffles and calendars
748
+ // compute their own grid geometry.
749
+ const isRadial = isAxislessMark(chartSpec.markType);
682
750
 
683
751
  // Compute axes (skip for radial charts).
684
752
  // Sparkline mode skips axes by default unless the user explicitly opted into
@@ -718,6 +786,18 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
718
786
  ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
719
787
  : [];
720
788
 
789
+ // Opt-in per-series fill patterns (mark.fillPattern: 'auto'). Mutates
790
+ // filled marks in place; assignment is deterministic and theme-aware.
791
+ applyFillPatterns(marks, chartSpec.markDef, theme);
792
+
793
+ // Resolve "you draw it" geometry (pixel fromX, x-sample positions, target
794
+ // line color/key). Reads marks so it must run after they're computed.
795
+ const xFieldForYdi =
796
+ chartSpec.encoding.x && 'field' in chartSpec.encoding.x
797
+ ? (chartSpec.encoding.x.field as string | undefined)
798
+ : undefined;
799
+ const youDrawIt = resolveYouDrawIt(chartSpec.youDrawIt, marks, scales, chartArea, xFieldForYdi);
800
+
721
801
  // Compute the right-side endpoint labels column for multi-series line/area
722
802
  // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
723
803
  // dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
@@ -765,6 +845,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
765
845
  obstacles,
766
846
  svg: { width: dims.total.width, height: dims.total.height },
767
847
  measure: annotationMeasure,
848
+ fontFamily: theme.fonts.family,
768
849
  autoThin: chartSpec.autoThin,
769
850
  });
770
851
 
@@ -773,26 +854,67 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
773
854
  // alignment with the resolved array is preserved.
774
855
  let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
775
856
  if (chartSpec.autoThin && annotations.length > 1) {
776
- const result = thinAnnotations(annotations, chartSpec.annotations, annotationMeasure);
857
+ const result = thinAnnotations(
858
+ annotations,
859
+ chartSpec.annotations,
860
+ annotationMeasure,
861
+ chartArea,
862
+ { x: 0, y: 0, width: dims.total.width, height: dims.total.height },
863
+ );
777
864
  annotations = result.annotations;
778
865
  if (result.footnotes.length > 0) {
779
866
  footnotes = result.footnotes;
867
+
868
+ // The footnote list renders below the plot, but nothing reserved space for
869
+ // it — thinning only just now discovered how many lines there are. Recompile
870
+ // with that band carved out of the bottom margin so the list can't overrun
871
+ // the source row and the brand watermark.
872
+ //
873
+ // Reserving shrinks the plot, which can push a further label out of bounds
874
+ // and grow the list by one, so re-reserve until the count settles. The
875
+ // count only ever grows (a smaller plot never keeps more labels inline) and
876
+ // is bounded by the annotation count, so this terminates; the explicit cap
877
+ // is a belt-and-braces guard against a measurement quirk oscillating.
878
+ //
879
+ // A frozen area short-circuits all of this. `compileLayer` pins every
880
+ // leaf to the primary layout's coordinate space, and `chartArea` above
881
+ // takes `frozenChartArea` over anything `computeDimensions` produces --
882
+ // so a grown bottom margin is computed and then thrown away. The
883
+ // recursion would re-run the whole compile up to MAX_FOOTNOTE_PASSES
884
+ // times and return the identical layout. Skip it rather than pay for it.
885
+ const reserve = footnoteBandHeight(footnotes.length, theme);
886
+ const currentReserve = options.footnoteReserve ?? 0;
887
+ if (
888
+ !options.frozenChartArea &&
889
+ reserve > currentReserve &&
890
+ (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES
891
+ ) {
892
+ return compileChart(spec, {
893
+ ...optionsInput,
894
+ footnoteReserve: reserve,
895
+ footnotePass: (options.footnotePass ?? 0) + 1,
896
+ });
897
+ }
780
898
  }
781
899
  }
782
900
 
783
901
  // Compute tooltip descriptors from marks and encoding
784
902
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
785
903
 
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
- );
904
+ // Compute accessibility. An author-provided description (a11y.description,
905
+ // or top-level `description` folded in by the sugar expansion) replaces the
906
+ // auto-generated alt text; auto-generation stays the fallback.
907
+ const altText =
908
+ chartSpec.a11y?.description ??
909
+ generateAltText(
910
+ {
911
+ mark: chartSpec.markType,
912
+ data: chartSpec.data,
913
+ encoding: chartSpec.encoding,
914
+ chrome: chartSpec.chrome,
915
+ } as ChartSpec,
916
+ chartSpec.data,
917
+ );
796
918
  const dataTableFallback = generateDataTable(
797
919
  {
798
920
  mark: chartSpec.markType,
@@ -807,10 +929,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
807
929
 
808
930
  const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
809
931
 
932
+ // Scale inversion anchors for edit-mode anchor drag (pixel -> data-space)
933
+ const xInvert = resolveScaleInvert(scales.x);
934
+ const yInvert = resolveScaleInvert(scales.y);
935
+
810
936
  return {
811
937
  area: chartArea,
812
938
  chrome,
813
939
  metrics: dims.metrics,
940
+ ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
941
+ ...(youDrawIt ? { youDrawIt } : {}),
814
942
  axes: {
815
943
  x: axes.x,
816
944
  y: axes.y,
@@ -818,6 +946,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
818
946
  marks,
819
947
  annotations,
820
948
  legend: finalLegend,
949
+ legends: allLegends,
821
950
  ...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
822
951
  tooltipDescriptors,
823
952
  a11y: {
@@ -825,6 +954,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
825
954
  dataTableFallback,
826
955
  role: 'img',
827
956
  keyboardNavigable: marks.length > 0,
957
+ ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
828
958
  },
829
959
  theme,
830
960
  dimensions: {
@@ -836,6 +966,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
836
966
  display: chartSpec.display,
837
967
  crosshair,
838
968
  measureText: options.measureText,
969
+ ...(xInvert ? { xInvert } : {}),
970
+ ...(yInvert ? { yInvert } : {}),
839
971
  };
840
972
  }
841
973
 
@@ -847,12 +979,15 @@ function compileFaceted(
847
979
  chartSpec: NormalizedChartSpec,
848
980
  renderSpec: NormalizedChartSpec,
849
981
  facetChannel: FacetChannel,
982
+ facetDirection: 'row' | 'column' | 'wrap',
850
983
  options: CompileOptions,
851
984
  theme: ResolvedTheme,
852
985
  strategy: LayoutStrategy,
853
986
  watermark: boolean,
854
987
  resolvedAnimation: ResolvedAnimation | undefined,
855
988
  crosshair: boolean,
989
+ rawSpec: unknown,
990
+ optionsInput: CompileOptions,
856
991
  ): ChartLayout {
857
992
  const measure = createMeasureFn(options.measureText);
858
993
 
@@ -917,15 +1052,38 @@ function compileFaceted(
917
1052
  const bottomReservation = plan.xAxisExtent;
918
1053
 
919
1054
  // Compute facet grid geometry
920
- const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
1055
+ const effectiveColumns = facetDirection === 'column' ? facetValues.length : facetChannel.columns;
1056
+ const grid = computeFacetGrid(facetValues, effectiveColumns, chartArea, {
921
1057
  left: leftReservation,
922
1058
  bottom: bottomReservation,
923
1059
  });
924
1060
 
925
- // Resolve scale for determining if we use 'independent' or 'shared'
1061
+ // If panels are too short, grow the compile height and restart (once).
1062
+ if (
1063
+ !optionsInput.facetHeightGrown &&
1064
+ grid.panels.length > 0 &&
1065
+ grid.panels[0].area.height < MIN_PANEL_HEIGHT
1066
+ ) {
1067
+ const neededChartHeight = facetMinHeight(facetValues.length, grid.columns);
1068
+ const heightGrowth = neededChartHeight - chartArea.height;
1069
+ if (heightGrowth > 0) {
1070
+ return compileChart(rawSpec, {
1071
+ ...optionsInput,
1072
+ height: options.height + heightGrowth,
1073
+ facetHeightGrown: true,
1074
+ });
1075
+ }
1076
+ }
1077
+
1078
+ // Resolve scale: defaults depend on facet direction.
1079
+ // row: x shared (comparable values), y independent (different categories)
1080
+ // column: y shared (comparable values), x independent (different categories)
1081
+ // wrap: both shared (original facet behavior)
926
1082
  const resolveConfig = chartSpec.resolve;
927
- const yResolve = resolveConfig?.scale?.y ?? 'shared';
928
- const xResolve = resolveConfig?.scale?.x ?? 'shared';
1083
+ const xDefault: ResolveMode = facetDirection === 'column' ? 'independent' : 'shared';
1084
+ const yDefault: ResolveMode = facetDirection === 'row' ? 'independent' : 'shared';
1085
+ const yResolve = resolveConfig?.scale?.y ?? yDefault;
1086
+ const xResolve = resolveConfig?.scale?.x ?? xDefault;
929
1087
 
930
1088
  // Compute shared scale domains from the full dataset (when shared)
931
1089
  const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
@@ -934,7 +1092,7 @@ function compileFaceted(
934
1092
  const sharedYDomain =
935
1093
  yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
936
1094
 
937
- const isRadial = chartSpec.markType === 'arc';
1095
+ const isRadial = isAxislessMark(chartSpec.markType);
938
1096
  const rendererKey = resolveRendererKey(
939
1097
  renderSpec.markType,
940
1098
  panelEncoding as Encoding,
@@ -944,6 +1102,11 @@ function compileFaceted(
944
1102
 
945
1103
  const allMarks: Mark[] = [];
946
1104
  const panelLayouts: FacetPanelLayout[] = [];
1105
+ // Footnotes pool across panels into one figure-level list (the renderer draws
1106
+ // a single list below the grid from `chrome.footnotes`), so the numbering is
1107
+ // continuous and each panel's markers index into it.
1108
+ const allFootnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] = [];
1109
+ const footnoteNumberBySpecIndex = new Map<number, number>();
947
1110
 
948
1111
  for (const gridPanel of grid.panels) {
949
1112
  const panelData = groups.get(gridPanel.key) ?? [];
@@ -976,18 +1139,22 @@ function compileFaceted(
976
1139
  panelScales.defaultColor =
977
1140
  chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
978
1141
 
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.
1142
+ // Dev-mode contrast diagnostics: panels share one palette resolution, so
1143
+ // checking the first panel covers the figure without repeating warnings.
1144
+ if (options.dev && panelLayouts.length === 0) {
1145
+ emitSpecWarnings(
1146
+ collectContrastWarnings(panelScales, chartSpec.markType, theme),
1147
+ options.onWarn,
1148
+ );
1149
+ }
1150
+
981
1151
  const isLeftCol = gridPanel.col === 0;
982
- const isBottomRow = gridPanel.row === grid.rows - 1;
983
1152
 
984
1153
  const panelAxes = isRadial
985
1154
  ? { x: undefined, y: undefined }
986
1155
  : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
987
1156
  data: panelData,
988
1157
  encoding: panelSpecWithDomains.encoding as Encoding,
989
- skipX: xResolve === 'shared' ? !isBottomRow : false,
990
- skipY: yResolve === 'shared' ? !isLeftCol : false,
991
1158
  markType: chartSpec.markType,
992
1159
  totalWidth: gridPanel.area.width,
993
1160
  });
@@ -996,6 +1163,29 @@ function compileFaceted(
996
1163
  computeGridlines(panelAxes, gridPanel.area);
997
1164
  }
998
1165
 
1166
+ // Strip y-axis tick labels from non-leftmost panels (gridlines stay).
1167
+ if (yResolve === 'shared' && !isLeftCol && panelAxes.y) {
1168
+ panelAxes.y = {
1169
+ ...panelAxes.y,
1170
+ ticks: [],
1171
+ label: undefined,
1172
+ domainLine: false,
1173
+ tickMarks: false,
1174
+ };
1175
+ }
1176
+
1177
+ // Strip x-axis tick labels from non-bottom panels for row faceting.
1178
+ const isBottomRow = gridPanel.row === grid.rows - 1;
1179
+ if (xResolve === 'shared' && facetDirection === 'row' && !isBottomRow && panelAxes.x) {
1180
+ panelAxes.x = {
1181
+ ...panelAxes.x,
1182
+ ticks: [],
1183
+ label: undefined,
1184
+ domainLine: false,
1185
+ tickMarks: false,
1186
+ };
1187
+ }
1188
+
999
1189
  // Compute marks for this panel
1000
1190
  const panelMarks: Mark[] = renderer
1001
1191
  ? renderer(
@@ -1012,7 +1202,7 @@ function compileFaceted(
1012
1202
  const annotationMeasure: AnnotationMeasureTextFn = options.measureText
1013
1203
  ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
1014
1204
  : heuristicMeasure;
1015
- const panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1205
+ let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1016
1206
  scales: panelScales,
1017
1207
  chartArea: gridPanel.area,
1018
1208
  strategy,
@@ -1020,9 +1210,55 @@ function compileFaceted(
1020
1210
  obstacles: [],
1021
1211
  svg: { width: options.width, height: options.height },
1022
1212
  measure: annotationMeasure,
1213
+ fontFamily: theme.fonts.family,
1023
1214
  autoThin: panelSpecWithDomains.autoThin,
1024
1215
  });
1025
1216
 
1217
+ // Auto-thinning, per panel. A panel is its own little chart: the box a label
1218
+ // must stay inside is the panel, not the figure. Thinning against the whole
1219
+ // SVG (what the single-chart path passes) would let a label from the left
1220
+ // column sprawl across its neighbour and still count as "fits".
1221
+ //
1222
+ // The spec's annotations resolve into *every* panel, so the same annotation
1223
+ // can demote in more than one. The footnote list is figure-level and its text
1224
+ // is per-spec-annotation, so number by spec index and reuse that number in
1225
+ // every panel that demoted it -- otherwise the list repeats itself once per
1226
+ // panel.
1227
+ if (chartSpec.autoThin && panelAnnotations.length > 1) {
1228
+ const thinned = thinAnnotations(
1229
+ panelAnnotations,
1230
+ chartSpec.annotations,
1231
+ annotationMeasure,
1232
+ gridPanel.area,
1233
+ gridPanel.area,
1234
+ );
1235
+ panelAnnotations = thinned.annotations;
1236
+ // `thinAnnotations` numbers footnotes 1..n *within the panel it was given*,
1237
+ // so restamp each demoted annotation with its figure-level number. Take the
1238
+ // text from `thinned.footnotes` (the 1-based panel-local index it just
1239
+ // assigned) rather than re-reading `label.text`: thinning owns that string,
1240
+ // and re-deriving it here would be a second producer to drift out of sync.
1241
+ //
1242
+ // Key on `specIndex`, NOT on position: with `resolve.scale.x: 'independent'`
1243
+ // each panel derives its own domain, so an annotation can resolve in one
1244
+ // panel and be dropped in another. Position i then means a different spec
1245
+ // annotation in each panel, and the markers point at other panels' text.
1246
+ for (const a of panelAnnotations) {
1247
+ const panelIndex = a.footnoteIndex;
1248
+ if (panelIndex == null || a.specIndex === undefined) continue;
1249
+ let num = footnoteNumberBySpecIndex.get(a.specIndex);
1250
+ if (num === undefined) {
1251
+ num = allFootnotes.length + 1;
1252
+ footnoteNumberBySpecIndex.set(a.specIndex, num);
1253
+ allFootnotes.push({
1254
+ index: num,
1255
+ text: thinned.footnotes[panelIndex - 1]?.text ?? '',
1256
+ });
1257
+ }
1258
+ a.footnoteIndex = num;
1259
+ }
1260
+ }
1261
+
1026
1262
  allMarks.push(...panelMarks);
1027
1263
 
1028
1264
  panelLayouts.push({
@@ -1033,18 +1269,46 @@ function compileFaceted(
1033
1269
  annotations: panelAnnotations,
1034
1270
  header: {
1035
1271
  text: gridPanel.key,
1036
- x: gridPanel.headerPos.x,
1272
+ x: facetDirection === 'row' ? gridPanel.area.x : gridPanel.headerPos.x,
1037
1273
  y: gridPanel.headerPos.y,
1038
1274
  fontSize: theme.fonts.sizes.body,
1039
1275
  fontWeight: theme.fonts.weights.medium,
1276
+ ...(facetDirection === 'row' ? { textAnchor: 'start' as const } : {}),
1040
1277
  },
1041
1278
  });
1042
1279
  }
1043
1280
 
1281
+ // The footnote list renders below the grid, but nothing reserved space for it
1282
+ // -- thinning only just now discovered how many lines there are. Recompile with
1283
+ // that band carved out of the bottom margin, same contract as the single-chart
1284
+ // path (see the matching block in compileChart). computeDimensions applies
1285
+ // options.footnoteReserve generically, so the facet grid shrinks with it.
1286
+ //
1287
+ // A frozen area short-circuits this, same as the single-chart path: compileLayer
1288
+ // pins every leaf to the primary layout's coordinate space, so the grown bottom
1289
+ // margin is computed and then thrown away. Recursing would re-run the whole
1290
+ // compile up to MAX_FOOTNOTE_PASSES times for an identical layout.
1291
+ if (allFootnotes.length > 0 && !options.frozenChartArea) {
1292
+ const reserve = footnoteBandHeight(allFootnotes.length, theme);
1293
+ const currentReserve = options.footnoteReserve ?? 0;
1294
+ if (reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
1295
+ return compileChart(rawSpec, {
1296
+ ...optionsInput,
1297
+ footnoteReserve: reserve,
1298
+ footnotePass: (options.footnotePass ?? 0) + 1,
1299
+ });
1300
+ }
1301
+ }
1302
+
1044
1303
  // Compute tooltip descriptors from all marks across panels
1045
1304
  const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
1046
1305
 
1047
- // A11y: mention faceting in alt text
1306
+ // Opt-in per-series fill patterns across all panels at once so a series
1307
+ // gets the same pattern in every panel.
1308
+ applyFillPatterns(allMarks, chartSpec.markDef, theme);
1309
+
1310
+ // A11y: mention faceting in alt text. An author description is used
1311
+ // verbatim (the author already describes the whole figure).
1048
1312
  const panelCount = facetValues.length;
1049
1313
  const baseAltText = generateAltText(
1050
1314
  {
@@ -1055,7 +1319,9 @@ function compileFaceted(
1055
1319
  } as ChartSpec,
1056
1320
  chartSpec.data,
1057
1321
  );
1058
- const altText = `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1322
+ const altText =
1323
+ chartSpec.a11y?.description ??
1324
+ `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1059
1325
 
1060
1326
  const dataTableFallback = generateDataTable(
1061
1327
  {
@@ -1073,18 +1339,23 @@ function compileFaceted(
1073
1339
  // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
1074
1340
  return {
1075
1341
  area: chartArea,
1076
- chrome: dims.chrome,
1342
+ chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
1077
1343
  metrics: dims.metrics,
1344
+ ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
1078
1345
  axes: { x: undefined, y: undefined },
1079
1346
  marks: allMarks,
1080
1347
  annotations: [],
1081
1348
  legend: finalLegend,
1349
+ // Facets share one color legend across the grid; the size channel isn't
1350
+ // supported in facet specs, so there is no second legend to carry.
1351
+ legends: [finalLegend],
1082
1352
  tooltipDescriptors,
1083
1353
  a11y: {
1084
1354
  altText,
1085
1355
  dataTableFallback,
1086
1356
  role: 'img',
1087
1357
  keyboardNavigable: allMarks.length > 0,
1358
+ ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
1088
1359
  },
1089
1360
  theme,
1090
1361
  dimensions: { width: options.width, height: options.height },
@@ -1098,6 +1369,7 @@ function compileFaceted(
1098
1369
  facetField: facetChannel.field,
1099
1370
  columns: grid.columns,
1100
1371
  sharedScales: yResolve === 'shared' && xResolve === 'shared',
1372
+ direction: facetDirection,
1101
1373
  },
1102
1374
  };
1103
1375
  }
@@ -1107,7 +1379,17 @@ function compileFaceted(
1107
1379
  // ---------------------------------------------------------------------------
1108
1380
 
1109
1381
  export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
1110
- return compileLayerImpl(spec, options, compileChart);
1382
+ // Expand VL-idiom sugar on the layer spec and every child up front. Leaf
1383
+ // compiles re-run the (idempotent) expansion after encoding inheritance;
1384
+ // warning triggers are stripped or stamped here so each warns exactly once.
1385
+ const sugarWarnings: string[] = [];
1386
+ const expanded = expandSpecSugar(
1387
+ spec as unknown as Record<string, unknown>,
1388
+ sugarWarnings,
1389
+ ) as unknown as LayerSpec;
1390
+ emitSpecWarnings(sugarWarnings, options.onWarn);
1391
+ const resolvedOptions = applySpecSize(expanded, options);
1392
+ return compileLayerImpl(expanded, resolvedOptions, compileChart);
1111
1393
  }
1112
1394
 
1113
1395
  // ---------------------------------------------------------------------------
@@ -1219,6 +1501,29 @@ export function compileTileMap(
1219
1501
  return compileTileMapImpl(spec, options);
1220
1502
  }
1221
1503
 
1504
+ // ---------------------------------------------------------------------------
1505
+ // Map compilation
1506
+ // ---------------------------------------------------------------------------
1507
+
1508
+ /**
1509
+ * Compile a map spec into a MapLayout.
1510
+ *
1511
+ * Takes a raw map spec, validates, normalizes, resolves theme and chrome,
1512
+ * projects geo features, joins data, builds feature marks with color fills,
1513
+ * and returns a MapLayout ready for rendering.
1514
+ *
1515
+ * @param spec - Raw map spec (validated and normalized internally).
1516
+ * @param options - Compile options (width, height, theme, darkMode).
1517
+ * @returns MapLayout with computed positions and visual properties.
1518
+ * @throws Error if spec is invalid or not a map type.
1519
+ */
1520
+ export function compileMap(
1521
+ spec: unknown,
1522
+ options: CompileOptions,
1523
+ ): import('@opendata-ai/openchart-core').MapLayout {
1524
+ return compileMapImpl(spec, options);
1525
+ }
1526
+
1222
1527
  // ---------------------------------------------------------------------------
1223
1528
  // BarList compilation
1224
1529
  // ---------------------------------------------------------------------------