@opendata-ai/openchart-engine 7.10.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 (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  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__/dimensions.test.ts +49 -4
  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 +120 -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 +222 -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 +312 -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 +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  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__/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/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  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
 
@@ -640,14 +619,26 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
640
619
  const chartArea = options.frozenChartArea ?? dims.chartArea;
641
620
 
642
621
  // Place legend in its final position relative to the computed chart area.
622
+ // effectiveAxisGap lifts a top legend above the inline-tick overhang zone
623
+ // the margin stack reserved below it.
643
624
  const finalLegend = placeLegend(
644
625
  plan.legendContent,
645
626
  chartArea,
646
627
  options.width,
647
628
  theme,
648
629
  plan.xAxisExtent,
630
+ dims.effectiveAxisGap,
649
631
  );
650
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
+
651
642
  // Inline y-axis labels: inset the x-scale range start by the widest tick
652
643
  // label width so data marks start to the right of the labels. The plan
653
644
  // already measured the real tick labels.
@@ -674,8 +665,16 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
674
665
  scales.defaultColor =
675
666
  chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
676
667
 
677
- // Arc charts (pie/donut) don't use axes or gridlines
678
- 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);
679
678
 
680
679
  // Compute axes (skip for radial charts).
681
680
  // Sparkline mode skips axes by default unless the user explicitly opted into
@@ -715,6 +714,18 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
715
714
  ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
716
715
  : [];
717
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
+
718
729
  // Compute the right-side endpoint labels column for multi-series line/area
719
730
  // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
720
731
  // dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
@@ -762,6 +773,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
762
773
  obstacles,
763
774
  svg: { width: dims.total.width, height: dims.total.height },
764
775
  measure: annotationMeasure,
776
+ fontFamily: theme.fonts.family,
765
777
  autoThin: chartSpec.autoThin,
766
778
  });
767
779
 
@@ -770,26 +782,67 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
770
782
  // alignment with the resolved array is preserved.
771
783
  let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
772
784
  if (chartSpec.autoThin && annotations.length > 1) {
773
- 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
+ );
774
792
  annotations = result.annotations;
775
793
  if (result.footnotes.length > 0) {
776
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
+ }
777
826
  }
778
827
  }
779
828
 
780
829
  // Compute tooltip descriptors from marks and encoding
781
830
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
782
831
 
783
- // Compute accessibility
784
- const altText = generateAltText(
785
- {
786
- mark: chartSpec.markType,
787
- data: chartSpec.data,
788
- encoding: chartSpec.encoding,
789
- chrome: chartSpec.chrome,
790
- } as ChartSpec,
791
- chartSpec.data,
792
- );
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
+ );
793
846
  const dataTableFallback = generateDataTable(
794
847
  {
795
848
  mark: chartSpec.markType,
@@ -808,6 +861,8 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
808
861
  area: chartArea,
809
862
  chrome,
810
863
  metrics: dims.metrics,
864
+ ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
865
+ ...(youDrawIt ? { youDrawIt } : {}),
811
866
  axes: {
812
867
  x: axes.x,
813
868
  y: axes.y,
@@ -815,6 +870,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
815
870
  marks,
816
871
  annotations,
817
872
  legend: finalLegend,
873
+ legends: allLegends,
818
874
  ...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
819
875
  tooltipDescriptors,
820
876
  a11y: {
@@ -822,6 +878,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
822
878
  dataTableFallback,
823
879
  role: 'img',
824
880
  keyboardNavigable: marks.length > 0,
881
+ ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
825
882
  },
826
883
  theme,
827
884
  dimensions: {
@@ -850,6 +907,8 @@ function compileFaceted(
850
907
  watermark: boolean,
851
908
  resolvedAnimation: ResolvedAnimation | undefined,
852
909
  crosshair: boolean,
910
+ rawSpec: unknown,
911
+ optionsInput: CompileOptions,
853
912
  ): ChartLayout {
854
913
  const measure = createMeasureFn(options.measureText);
855
914
 
@@ -898,12 +957,14 @@ function compileFaceted(
898
957
  );
899
958
  const chartArea = dims.chartArea;
900
959
 
960
+ // Same effectiveAxisGap threading as the regular (non-faceted) path.
901
961
  const finalLegend = placeLegend(
902
962
  plan.legendContent,
903
963
  chartArea,
904
964
  options.width,
905
965
  theme,
906
966
  plan.xAxisExtent,
967
+ dims.effectiveAxisGap,
907
968
  );
908
969
 
909
970
  // Estimate y-axis gutter width (used as left reservation for outer axes)
@@ -917,6 +978,23 @@ function compileFaceted(
917
978
  bottom: bottomReservation,
918
979
  });
919
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
+
920
998
  // Resolve scale for determining if we use 'independent' or 'shared'
921
999
  const resolveConfig = chartSpec.resolve;
922
1000
  const yResolve = resolveConfig?.scale?.y ?? 'shared';
@@ -929,7 +1007,7 @@ function compileFaceted(
929
1007
  const sharedYDomain =
930
1008
  yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
931
1009
 
932
- const isRadial = chartSpec.markType === 'arc';
1010
+ const isRadial = isAxislessMark(chartSpec.markType);
933
1011
  const rendererKey = resolveRendererKey(
934
1012
  renderSpec.markType,
935
1013
  panelEncoding as Encoding,
@@ -939,6 +1017,11 @@ function compileFaceted(
939
1017
 
940
1018
  const allMarks: Mark[] = [];
941
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>();
942
1025
 
943
1026
  for (const gridPanel of grid.panels) {
944
1027
  const panelData = groups.get(gridPanel.key) ?? [];
@@ -971,18 +1054,22 @@ function compileFaceted(
971
1054
  panelScales.defaultColor =
972
1055
  chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
973
1056
 
974
- // Outer-axis economy: only leftmost column gets y ticks, only bottom row gets x ticks.
975
- // 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
+
976
1066
  const isLeftCol = gridPanel.col === 0;
977
- const isBottomRow = gridPanel.row === grid.rows - 1;
978
1067
 
979
1068
  const panelAxes = isRadial
980
1069
  ? { x: undefined, y: undefined }
981
1070
  : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
982
1071
  data: panelData,
983
1072
  encoding: panelSpecWithDomains.encoding as Encoding,
984
- skipX: xResolve === 'shared' ? !isBottomRow : false,
985
- skipY: yResolve === 'shared' ? !isLeftCol : false,
986
1073
  markType: chartSpec.markType,
987
1074
  totalWidth: gridPanel.area.width,
988
1075
  });
@@ -991,6 +1078,18 @@ function compileFaceted(
991
1078
  computeGridlines(panelAxes, gridPanel.area);
992
1079
  }
993
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
+
994
1093
  // Compute marks for this panel
995
1094
  const panelMarks: Mark[] = renderer
996
1095
  ? renderer(
@@ -1007,7 +1106,7 @@ function compileFaceted(
1007
1106
  const annotationMeasure: AnnotationMeasureTextFn = options.measureText
1008
1107
  ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
1009
1108
  : heuristicMeasure;
1010
- const panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1109
+ let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1011
1110
  scales: panelScales,
1012
1111
  chartArea: gridPanel.area,
1013
1112
  strategy,
@@ -1015,9 +1114,55 @@ function compileFaceted(
1015
1114
  obstacles: [],
1016
1115
  svg: { width: options.width, height: options.height },
1017
1116
  measure: annotationMeasure,
1117
+ fontFamily: theme.fonts.family,
1018
1118
  autoThin: panelSpecWithDomains.autoThin,
1019
1119
  });
1020
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
+
1021
1166
  allMarks.push(...panelMarks);
1022
1167
 
1023
1168
  panelLayouts.push({
@@ -1036,10 +1181,37 @@ function compileFaceted(
1036
1181
  });
1037
1182
  }
1038
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
+
1039
1206
  // Compute tooltip descriptors from all marks across panels
1040
1207
  const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
1041
1208
 
1042
- // 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).
1043
1215
  const panelCount = facetValues.length;
1044
1216
  const baseAltText = generateAltText(
1045
1217
  {
@@ -1050,7 +1222,9 @@ function compileFaceted(
1050
1222
  } as ChartSpec,
1051
1223
  chartSpec.data,
1052
1224
  );
1053
- 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}.`;
1054
1228
 
1055
1229
  const dataTableFallback = generateDataTable(
1056
1230
  {
@@ -1068,18 +1242,23 @@ function compileFaceted(
1068
1242
  // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
1069
1243
  return {
1070
1244
  area: chartArea,
1071
- chrome: dims.chrome,
1245
+ chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
1072
1246
  metrics: dims.metrics,
1247
+ ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
1073
1248
  axes: { x: undefined, y: undefined },
1074
1249
  marks: allMarks,
1075
1250
  annotations: [],
1076
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],
1077
1255
  tooltipDescriptors,
1078
1256
  a11y: {
1079
1257
  altText,
1080
1258
  dataTableFallback,
1081
1259
  role: 'img',
1082
1260
  keyboardNavigable: allMarks.length > 0,
1261
+ ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
1083
1262
  },
1084
1263
  theme,
1085
1264
  dimensions: { width: options.width, height: options.height },
@@ -1102,7 +1281,17 @@ function compileFaceted(
1102
1281
  // ---------------------------------------------------------------------------
1103
1282
 
1104
1283
  export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
1105
- 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);
1106
1295
  }
1107
1296
 
1108
1297
  // ---------------------------------------------------------------------------
@@ -1214,6 +1403,29 @@ export function compileTileMap(
1214
1403
  return compileTileMapImpl(spec, options);
1215
1404
  }
1216
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
+
1217
1429
  // ---------------------------------------------------------------------------
1218
1430
  // BarList compilation
1219
1431
  // ---------------------------------------------------------------------------