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

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 (270) hide show
  1. package/dist/index.js +2 -3
  2. package/dist/index.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/__test-fixtures__/specs.ts +0 -182
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  6. package/src/__tests__/annotation-behavior.test.ts +0 -852
  7. package/src/__tests__/axes.test.ts +0 -1579
  8. package/src/__tests__/compile-animation.test.ts +0 -161
  9. package/src/__tests__/compile-chart.test.ts +0 -1303
  10. package/src/__tests__/compile-footnotes.test.ts +0 -277
  11. package/src/__tests__/compile-layer.test.ts +0 -969
  12. package/src/__tests__/compile-snapshot.test.ts +0 -156
  13. package/src/__tests__/compound-labels.test.ts +0 -147
  14. package/src/__tests__/dimensions.test.ts +0 -918
  15. package/src/__tests__/encoding-sugar.test.ts +0 -390
  16. package/src/__tests__/facet.test.ts +0 -359
  17. package/src/__tests__/helpers/invariants.ts +0 -299
  18. package/src/__tests__/known-bugs.test.ts +0 -566
  19. package/src/__tests__/layout-fuzz.test.ts +0 -358
  20. package/src/__tests__/layout-invariants.test.ts +0 -535
  21. package/src/__tests__/legend.test.ts +0 -721
  22. package/src/__tests__/mark-keys.test.ts +0 -294
  23. package/src/__tests__/ranking-recipes.test.ts +0 -260
  24. package/src/__tests__/rotation.test.ts +0 -83
  25. package/src/__tests__/scales.test.ts +0 -323
  26. package/src/__tests__/series-search.test.ts +0 -92
  27. package/src/__tests__/spec-sugar.test.ts +0 -801
  28. package/src/__tests__/you-draw-it.test.ts +0 -160
  29. package/src/annotations/__tests__/compute.test.ts +0 -1672
  30. package/src/annotations/__tests__/geometry.test.ts +0 -66
  31. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  32. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  33. package/src/annotations/__tests__/placement.test.ts +0 -316
  34. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  35. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  36. package/src/annotations/__tests__/thinning.test.ts +0 -285
  37. package/src/annotations/collisions.ts +0 -326
  38. package/src/annotations/compute.ts +0 -333
  39. package/src/annotations/constants.ts +0 -162
  40. package/src/annotations/geometry.ts +0 -376
  41. package/src/annotations/move.ts +0 -135
  42. package/src/annotations/placement.ts +0 -636
  43. package/src/annotations/position.ts +0 -131
  44. package/src/annotations/resolve-range.ts +0 -117
  45. package/src/annotations/resolve-refline.ts +0 -157
  46. package/src/annotations/resolve-text.ts +0 -545
  47. package/src/annotations/rich-text.ts +0 -65
  48. package/src/annotations/thinning.ts +0 -190
  49. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  50. package/src/barlist/compile-barlist.ts +0 -391
  51. package/src/barlist/types.ts +0 -28
  52. package/src/charts/__tests__/post-process.test.ts +0 -237
  53. package/src/charts/__tests__/registry.test.ts +0 -116
  54. package/src/charts/__tests__/utils.test.ts +0 -195
  55. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  56. package/src/charts/_shared/density-filter.ts +0 -26
  57. package/src/charts/_shared/format-label-value.ts +0 -16
  58. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  59. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  60. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  61. package/src/charts/bar/compute.ts +0 -539
  62. package/src/charts/bar/index.ts +0 -60
  63. package/src/charts/bar/labels.ts +0 -311
  64. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  65. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  66. package/src/charts/beeswarm/compute.ts +0 -203
  67. package/src/charts/beeswarm/dodge.ts +0 -155
  68. package/src/charts/beeswarm/index.ts +0 -32
  69. package/src/charts/builtin.ts +0 -75
  70. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  71. package/src/charts/calendar/compute.ts +0 -319
  72. package/src/charts/calendar/index.ts +0 -32
  73. package/src/charts/column/__tests__/compute.test.ts +0 -540
  74. package/src/charts/column/__tests__/labels.test.ts +0 -179
  75. package/src/charts/column/compute.ts +0 -493
  76. package/src/charts/column/index.ts +0 -53
  77. package/src/charts/column/labels.ts +0 -229
  78. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  79. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  80. package/src/charts/dot/compute.ts +0 -285
  81. package/src/charts/dot/index.ts +0 -62
  82. package/src/charts/dot/labels.ts +0 -121
  83. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  84. package/src/charts/line/__tests__/curves.test.ts +0 -51
  85. package/src/charts/line/__tests__/labels.test.ts +0 -94
  86. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  87. package/src/charts/line/area.ts +0 -484
  88. package/src/charts/line/compute.ts +0 -356
  89. package/src/charts/line/curves.ts +0 -85
  90. package/src/charts/line/index.ts +0 -176
  91. package/src/charts/line/labels.ts +0 -177
  92. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  93. package/src/charts/parliament/compute.ts +0 -313
  94. package/src/charts/parliament/index.ts +0 -32
  95. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  96. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  97. package/src/charts/pie/compute.ts +0 -324
  98. package/src/charts/pie/index.ts +0 -49
  99. package/src/charts/pie/labels.ts +0 -143
  100. package/src/charts/post-process.ts +0 -229
  101. package/src/charts/range/__tests__/compute.test.ts +0 -486
  102. package/src/charts/range/compute.ts +0 -273
  103. package/src/charts/range/index.ts +0 -82
  104. package/src/charts/range/labels.ts +0 -169
  105. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  106. package/src/charts/rect/index.ts +0 -127
  107. package/src/charts/registry.ts +0 -72
  108. package/src/charts/rule/index.ts +0 -139
  109. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  110. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  111. package/src/charts/scatter/compute.ts +0 -192
  112. package/src/charts/scatter/index.ts +0 -46
  113. package/src/charts/scatter/trendline.ts +0 -100
  114. package/src/charts/text/__tests__/compute.test.ts +0 -307
  115. package/src/charts/text/index.ts +0 -141
  116. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  117. package/src/charts/tick/index.ts +0 -99
  118. package/src/charts/utils.ts +0 -170
  119. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  120. package/src/charts/waffle/compute.ts +0 -197
  121. package/src/charts/waffle/index.ts +0 -31
  122. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  123. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  124. package/src/compile/__tests__/data-clip.test.ts +0 -127
  125. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  126. package/src/compile/__tests__/size-scale.test.ts +0 -108
  127. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  128. package/src/compile/color-scale-range.ts +0 -101
  129. package/src/compile/data-clip.ts +0 -48
  130. package/src/compile/fill-patterns.ts +0 -74
  131. package/src/compile/layer.ts +0 -630
  132. package/src/compile/size-scale.ts +0 -141
  133. package/src/compile/spec-sugar.ts +0 -767
  134. package/src/compile/watermark-obstacle.ts +0 -54
  135. package/src/compile/you-draw-it.ts +0 -123
  136. package/src/compile.ts +0 -1548
  137. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  138. package/src/compiler/__tests__/animation.test.ts +0 -177
  139. package/src/compiler/__tests__/compile.test.ts +0 -87
  140. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  141. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  142. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  143. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  144. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  153. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  154. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  155. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  156. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  158. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  159. package/src/compiler/__tests__/keys.test.ts +0 -83
  160. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  161. package/src/compiler/__tests__/normalize.test.ts +0 -399
  162. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  163. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  164. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  165. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  166. package/src/compiler/__tests__/validate.test.ts +0 -731
  167. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  168. package/src/compiler/a11y-warnings.ts +0 -110
  169. package/src/compiler/animation.ts +0 -142
  170. package/src/compiler/index.ts +0 -47
  171. package/src/compiler/keys.ts +0 -55
  172. package/src/compiler/normalize.ts +0 -734
  173. package/src/compiler/sparkline-defaults.ts +0 -138
  174. package/src/compiler/types.ts +0 -241
  175. package/src/compiler/validate.ts +0 -2093
  176. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  177. package/src/endpoint-labels/compute.ts +0 -621
  178. package/src/endpoint-labels/constants.ts +0 -54
  179. package/src/endpoint-labels/format.ts +0 -60
  180. package/src/endpoint-labels/predict.ts +0 -139
  181. package/src/format/__tests__/field-format.test.ts +0 -164
  182. package/src/format/field-format.ts +0 -27
  183. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  184. package/src/geo/compile-map.ts +0 -1376
  185. package/src/geo/join.ts +0 -115
  186. package/src/geo/projections.ts +0 -65
  187. package/src/geo/types.ts +0 -26
  188. package/src/graphs/__tests__/community.test.ts +0 -228
  189. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  190. package/src/graphs/__tests__/encoding.test.ts +0 -415
  191. package/src/graphs/community.ts +0 -92
  192. package/src/graphs/compile-graph.ts +0 -329
  193. package/src/graphs/encoding.ts +0 -356
  194. package/src/graphs/types.ts +0 -106
  195. package/src/index.ts +0 -177
  196. package/src/layout/__tests__/metrics.test.ts +0 -97
  197. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  198. package/src/layout/axes/rotation.ts +0 -152
  199. package/src/layout/axes/thinning.ts +0 -98
  200. package/src/layout/axes/ticks.ts +0 -409
  201. package/src/layout/axes.ts +0 -757
  202. package/src/layout/dimensions.ts +0 -912
  203. package/src/layout/facet.ts +0 -91
  204. package/src/layout/gridlines.ts +0 -84
  205. package/src/layout/metrics.ts +0 -132
  206. package/src/layout/plan.ts +0 -631
  207. package/src/layout/scales.ts +0 -1053
  208. package/src/layout/shared.ts +0 -44
  209. package/src/legend/__tests__/continuous.test.ts +0 -318
  210. package/src/legend/__tests__/redundancy.test.ts +0 -108
  211. package/src/legend/__tests__/size.test.ts +0 -348
  212. package/src/legend/__tests__/suppression.test.ts +0 -382
  213. package/src/legend/compute.ts +0 -748
  214. package/src/legend/continuous.ts +0 -327
  215. package/src/legend/size.ts +0 -407
  216. package/src/legend/suppression.ts +0 -226
  217. package/src/legend/wrap.ts +0 -117
  218. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  219. package/src/sankey/__tests__/layout.test.ts +0 -165
  220. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  221. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  222. package/src/sankey/compile-sankey.ts +0 -706
  223. package/src/sankey/layout.ts +0 -184
  224. package/src/sankey/types.ts +0 -41
  225. package/src/tables/__tests__/bar-column.test.ts +0 -147
  226. package/src/tables/__tests__/category-colors.test.ts +0 -230
  227. package/src/tables/__tests__/compile-table.test.ts +0 -236
  228. package/src/tables/__tests__/format-cells.test.ts +0 -143
  229. package/src/tables/__tests__/heatmap.test.ts +0 -148
  230. package/src/tables/__tests__/pagination.test.ts +0 -78
  231. package/src/tables/__tests__/search.test.ts +0 -94
  232. package/src/tables/__tests__/sort.test.ts +0 -107
  233. package/src/tables/__tests__/sparkline.test.ts +0 -122
  234. package/src/tables/bar-column.ts +0 -94
  235. package/src/tables/category-colors.ts +0 -78
  236. package/src/tables/compile-table.ts +0 -427
  237. package/src/tables/format-cells.ts +0 -115
  238. package/src/tables/heatmap.ts +0 -141
  239. package/src/tables/pagination.ts +0 -46
  240. package/src/tables/search.ts +0 -66
  241. package/src/tables/sort.ts +0 -69
  242. package/src/tables/sparkline.ts +0 -113
  243. package/src/tables/utils.ts +0 -16
  244. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  245. package/src/tilemap/compile-tilemap.ts +0 -755
  246. package/src/tilemap/layout.ts +0 -172
  247. package/src/tilemap/types.ts +0 -33
  248. package/src/tooltips/__tests__/compute.test.ts +0 -582
  249. package/src/tooltips/compute.ts +0 -700
  250. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  251. package/src/transforms/__tests__/bin.test.ts +0 -88
  252. package/src/transforms/__tests__/calculate.test.ts +0 -146
  253. package/src/transforms/__tests__/conditional.test.ts +0 -127
  254. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  255. package/src/transforms/__tests__/filter.test.ts +0 -59
  256. package/src/transforms/__tests__/fold.test.ts +0 -79
  257. package/src/transforms/__tests__/index.test.ts +0 -93
  258. package/src/transforms/__tests__/predicates.test.ts +0 -193
  259. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  260. package/src/transforms/__tests__/window.test.ts +0 -286
  261. package/src/transforms/aggregate.ts +0 -131
  262. package/src/transforms/bin.ts +0 -87
  263. package/src/transforms/calculate.ts +0 -60
  264. package/src/transforms/conditional.ts +0 -46
  265. package/src/transforms/filter.ts +0 -122
  266. package/src/transforms/fold.ts +0 -49
  267. package/src/transforms/index.ts +0 -60
  268. package/src/transforms/predicates.ts +0 -123
  269. package/src/transforms/timeunit.ts +0 -88
  270. package/src/transforms/window.ts +0 -185
package/src/compile.ts DELETED
@@ -1,1548 +0,0 @@
1
- /**
2
- * Main compile API: the public entry points for the engine.
3
- *
4
- * Pipeline for charts:
5
- * validate spec -> normalize -> resolve theme -> dark mode adapt ->
6
- * filter data -> resolveLayoutPlan (measure real labels) ->
7
- * compute dimensions (with plan) -> placeLegend ->
8
- * compute scales -> compute axes (with pinned ticks) -> compute gridlines ->
9
- * get chart renderer -> compute marks -> compute a11y -> return ChartLayout
10
- *
11
- * Table compiler handles full data pipeline (sort, search, pagination, visual enhancements).
12
- * Graph compiler is a stub for future implementation.
13
- */
14
-
15
- import type {
16
- AnimationSpec,
17
- ChartLayout,
18
- ChartSpec,
19
- CompileOptions,
20
- CompileTableOptions,
21
- Encoding,
22
- FacetChannel,
23
- FacetPanelLayout,
24
- LayerSpec,
25
- LayoutStrategy,
26
- LegendLayout,
27
- Mark,
28
- ResolvedAnimation,
29
- ResolvedAnnotation,
30
- ResolvedTheme,
31
- ResolveMode,
32
- ScaleInvert,
33
- TableLayout,
34
- } from '@opendata-ai/openchart-core';
35
- import {
36
- adaptTheme,
37
- computeLabelBounds,
38
- footnoteBandHeight,
39
- generateAltText,
40
- generateDataTable,
41
- getBreakpoint,
42
- getHeightClass,
43
- getLayoutStrategy,
44
- isAxislessMark,
45
- resolveTheme,
46
- } from '@opendata-ai/openchart-core';
47
- import { computeAnnotations } from './annotations/compute';
48
- import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry';
49
- import type { PlacementObstacle } from './annotations/placement';
50
- import { thinAnnotations } from './annotations/thinning';
51
- import { computeEndpointLabels } from './endpoint-labels/compute';
52
- // Side-effect import: registers all built-in chart renderers with the
53
- // registry on module load. Tests that clear the registry can import
54
- // `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
55
- import './charts/builtin';
56
- import { compileBarList as compileBarListImpl } from './barlist/compile-barlist';
57
- import {
58
- assignAnimationIndices,
59
- computeMarkObstacles,
60
- resolveRendererKey,
61
- } from './charts/post-process';
62
- import { getChartRenderer } from './charts/registry';
63
- import { groupByField } from './charts/utils';
64
- import { applyColorScaleRange } from './compile/color-scale-range';
65
- import { filterClippedDomains } from './compile/data-clip';
66
- import { applyFillPatterns } from './compile/fill-patterns';
67
- import { compileLayer as compileLayerImpl } from './compile/layer';
68
- import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar';
69
- import { computeWatermarkObstacle } from './compile/watermark-obstacle';
70
- import { resolveYouDrawIt } from './compile/you-draw-it';
71
- import { collectContrastWarnings } from './compiler/a11y-warnings';
72
- import { resolveAnimation } from './compiler/animation';
73
- import { compile as compileSpec } from './compiler/index';
74
- import {
75
- buildSparklineAreaGradient,
76
- computeTrendFromData,
77
- hasExplicitColor,
78
- trendColor,
79
- } from './compiler/sparkline-defaults';
80
- import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
81
- import { compileMap as compileMapImpl } from './geo/compile-map';
82
- import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
83
- import type { GraphCompilation } from './graphs/types';
84
- import { computeAxes } from './layout/axes';
85
- import { computeDimensions } from './layout/dimensions';
86
- import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
87
- import { computeGridlines } from './layout/gridlines';
88
- import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
89
- import { computeScales, type ResolvedScale } from './layout/scales';
90
- import { placeLegend } from './legend/compute';
91
- import { placeSizeLegend } from './legend/size';
92
- import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
93
- import { compileTableLayout } from './tables/compile-table';
94
- import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
95
- import { computeTooltipDescriptors } from './tooltips/compute';
96
- import { runTransforms } from './transforms';
97
-
98
- // ---------------------------------------------------------------------------
99
- // Spec sugar expansion (VL idioms, bin/timeUnit, deprecation warnings)
100
- // ---------------------------------------------------------------------------
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
-
106
- /**
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.
110
- */
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;
115
- return {
116
- ...options,
117
- width: typeof sized.width === 'number' ? sized.width : options.width,
118
- height: typeof sized.height === 'number' ? sized.height : options.height,
119
- };
120
- }
121
-
122
- // ---------------------------------------------------------------------------
123
- // Chart compilation
124
- // ---------------------------------------------------------------------------
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
-
135
- /**
136
- * Inject sparkline-mode visual defaults that depend on the resolved theme.
137
- *
138
- * Trend-aware color (positive/negative/neutral), default endpoint dot,
139
- * default area gradient, and default pill cornerRadius for bars. Each
140
- * default backs off when the user has set the corresponding markDef
141
- * field. Bar marks do NOT get trend coloring — the design intent is a
142
- * single palette color across all bars.
143
- */
144
- function applySparklineDefaults(
145
- spec: NormalizedChartSpec,
146
- theme: ResolvedTheme,
147
- ): NormalizedChartSpec {
148
- const markType = spec.markType;
149
- const isLineFamily = markType === 'line' || markType === 'area';
150
- const isBar = markType === 'bar';
151
- if (!isLineFamily && !isBar) return spec;
152
-
153
- const yField = spec.encoding.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
154
- const trend = computeTrendFromData(spec.data, yField);
155
- const color = trendColor(trend, theme);
156
-
157
- const encodingHasColor =
158
- !!spec.encoding.color && (spec.encoding.color as { field?: unknown }).field !== undefined;
159
- const explicit = hasExplicitColor(spec.markDef, encodingHasColor);
160
-
161
- const newMarkDef = { ...spec.markDef };
162
-
163
- if (isLineFamily) {
164
- if (!explicit.stroke) newMarkDef.stroke = color;
165
- if (markType === 'area' && !explicit.fill) {
166
- newMarkDef.fill = buildSparklineAreaGradient(color);
167
- }
168
- // Endpoint dot is wired in the line compute path. We skip the default
169
- // when the encoding has a color field, because the multi-series area
170
- // path (linesFromAreas()) ignores markDef.point — the dot would be set
171
- // on the spec but never rendered, and the line compute path's dot logic
172
- // is per-series, not "one dot for the chart."
173
- if (newMarkDef.point === undefined && !encodingHasColor) {
174
- newMarkDef.point = 'last';
175
- }
176
- }
177
-
178
- if (isBar) {
179
- // Pill cornerRadius is safe on stacked bars too — the column compute
180
- // path applies the radius only to the topmost segment via
181
- // `cornerRadiusSides`, leaving the seams between segments square so
182
- // they stay flush. Single-series bars get all four corners rounded
183
- // since there's nothing below them to align against.
184
- if (newMarkDef.cornerRadius === undefined) {
185
- newMarkDef.cornerRadius = 'pill';
186
- }
187
- }
188
-
189
- return { ...spec, markDef: newMarkDef };
190
- }
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
-
238
- /**
239
- * Compile a chart spec into a ChartLayout.
240
- *
241
- * This is the main engine entry point. Takes a raw spec (any shape,
242
- * validated at runtime) and compile options, produces a fully resolved
243
- * ChartLayout with positions, colors, and marks ready for rendering.
244
- *
245
- * @param spec - Raw chart spec (validated and normalized internally).
246
- * @param optionsInput - Compile options (width, height, theme, darkMode).
247
- * Top-level spec width/height, when present, override the option values.
248
- * @returns ChartLayout with all computed positions.
249
- * @throws Error if spec is invalid or not a chart type.
250
- */
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[] = [];
256
- const expandedSpec =
257
- spec && typeof spec === 'object' && !Array.isArray(spec)
258
- ? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
259
- : spec;
260
-
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);
272
-
273
- if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
274
- throw new Error('compileChart received a table spec. Use compileTable instead.');
275
- }
276
- if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'graph') {
277
- throw new Error('compileChart received a graph spec. Use compileGraph instead.');
278
- }
279
- if (
280
- 'type' in normalized &&
281
- (normalized as unknown as Record<string, unknown>).type === 'sankey'
282
- ) {
283
- throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
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
- }
288
-
289
- let chartSpec = normalized as NormalizedChartSpec;
290
-
291
- // Resolve watermark: explicit spec value wins, then options fallback, then default true.
292
- const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
293
- let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
294
-
295
- // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
296
- // Transforms are defined on the expanded spec (which includes any auto-generated
297
- // transforms from encoding-level bin/timeUnit sugar), not the normalized spec,
298
- // since NormalizedChartSpec doesn't carry the transform field.
299
- const rawTransforms = (expandedSpec as Record<string, unknown>).transform as
300
- | import('@opendata-ai/openchart-core').Transform[]
301
- | undefined;
302
- if (rawTransforms && rawTransforms.length > 0) {
303
- chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
304
- }
305
-
306
- // Responsive strategy
307
- const breakpoint = getBreakpoint(options.width);
308
- const heightClass = getHeightClass(options.height);
309
- let strategy = getLayoutStrategy(breakpoint, heightClass);
310
-
311
- // Apply breakpoint-conditional overrides from the expanded spec
312
- const rawSpec = expandedSpec as Record<string, unknown>;
313
- const overrides = rawSpec.overrides as
314
- | Partial<
315
- Record<
316
- string,
317
- {
318
- chrome?: unknown;
319
- labels?: unknown;
320
- legend?: unknown;
321
- annotations?: unknown;
322
- animation?: unknown;
323
- display?: unknown;
324
- encoding?: unknown;
325
- watermark?: unknown;
326
- crosshair?: unknown;
327
- }
328
- >
329
- >
330
- | undefined;
331
-
332
- // Build userExplicit descriptor BEFORE applying any overrides so we capture
333
- // the union of "user wrote this at top-level" and "user wrote this in the
334
- // active breakpoint override." Sparkline display mode reads this to decide
335
- // whether to suppress chrome/axes/legend/etc. by default vs. respecting an
336
- // explicit user opt-in. Precedence: explicit at any level wins.
337
- const rawEncoding = rawSpec.encoding as
338
- | { x?: { axis?: unknown }; y?: { axis?: unknown } }
339
- | undefined;
340
- const bpForExplicit = overrides?.[breakpoint];
341
- const bpEncoding = bpForExplicit?.encoding as
342
- | { x?: { axis?: unknown }; y?: { axis?: unknown } }
343
- | undefined;
344
- // chrome: {} (empty object) is not "explicit" — it's an idiom users write to
345
- // silence defaults. Require at least one chrome key set to count as opt-in.
346
- const hasChromeKeys = (v: unknown): boolean =>
347
- !!v && typeof v === 'object' && Object.keys(v as Record<string, unknown>).length > 0;
348
- const userExplicit = {
349
- chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
350
- legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
351
- endpointLabels:
352
- rawSpec.endpointLabels !== undefined ||
353
- (bpForExplicit as Record<string, unknown> | undefined)?.endpointLabels !== undefined,
354
- // Axis explicitness tracks "user opted IN to render the axis" — `axis: false`
355
- // is an explicit opt-OUT and must not flip these flags on. Sparkline mode
356
- // reads them to decide whether to reserve axis space; treating `false` as
357
- // explicit would leave a phantom gutter for an axis that won't render.
358
- xAxis:
359
- (rawEncoding?.x?.axis !== undefined && rawEncoding.x.axis !== false) ||
360
- (bpEncoding?.x?.axis !== undefined && bpEncoding.x.axis !== false),
361
- yAxis:
362
- (rawEncoding?.y?.axis !== undefined && rawEncoding.y.axis !== false) ||
363
- (bpEncoding?.y?.axis !== undefined && bpEncoding.y.axis !== false),
364
- labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
365
- animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
366
- watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
367
- crosshair: rawSpec.crosshair !== undefined || bpForExplicit?.crosshair !== undefined,
368
- };
369
- chartSpec = { ...chartSpec, userExplicit };
370
-
371
- if (overrides?.[breakpoint]) {
372
- const bp = overrides[breakpoint]!;
373
- if (bp.chrome) {
374
- chartSpec = {
375
- ...chartSpec,
376
- chrome: {
377
- ...chartSpec.chrome,
378
- ...(bp.chrome as NormalizedChartSpec['chrome']),
379
- },
380
- };
381
- }
382
- if (bp.labels !== undefined) {
383
- if (typeof bp.labels === 'boolean') {
384
- chartSpec = {
385
- ...chartSpec,
386
- labels: bp.labels
387
- ? { density: 'auto', format: '', prefix: '' }
388
- : { density: 'none', format: '', prefix: '' },
389
- };
390
- } else {
391
- chartSpec = {
392
- ...chartSpec,
393
- labels: {
394
- ...chartSpec.labels,
395
- ...(bp.labels as NormalizedChartSpec['labels']),
396
- },
397
- };
398
- }
399
- }
400
- if (bp.legend) {
401
- chartSpec = {
402
- ...chartSpec,
403
- legend: {
404
- ...chartSpec.legend,
405
- ...(bp.legend as NormalizedChartSpec['legend']),
406
- },
407
- };
408
- }
409
- if (bp.annotations) {
410
- chartSpec = {
411
- ...chartSpec,
412
- annotations: bp.annotations as NormalizedChartSpec['annotations'],
413
- };
414
- // User explicitly provided annotations at this breakpoint — override the
415
- // responsive strategy so they render inline instead of being stripped.
416
- strategy = { ...strategy, annotationPosition: 'inline' };
417
- }
418
- // New override branches for sparkline mode and related fields:
419
- if (bp.display !== undefined) {
420
- chartSpec = {
421
- ...chartSpec,
422
- display: bp.display as NormalizedChartSpec['display'],
423
- };
424
- }
425
- if (bp.encoding !== undefined) {
426
- // Merge encoding so a breakpoint can flip on/off encoding.x.axis or
427
- // encoding.y.axis (used by sparkline display mode to opt back in to
428
- // axes at a specific breakpoint). Channels merge per-key, and `axis`
429
- // and `scale` deep-merge one level so a breakpoint can set
430
- // `axis: { title: 'foo' }` without dropping the base spec's
431
- // `axis.tickCount` / `axis.format`.
432
- const bpEnc = bp.encoding as Record<string, Record<string, unknown> | undefined>;
433
- const mergedEncoding = { ...chartSpec.encoding } as Record<
434
- string,
435
- Record<string, unknown> | undefined
436
- >;
437
- const NESTED_CHANNEL_KEYS = ['axis', 'scale'];
438
- for (const channel of Object.keys(bpEnc)) {
439
- const baseCh = mergedEncoding[channel];
440
- const bpCh = bpEnc[channel];
441
- if (bpCh && baseCh) {
442
- const merged: Record<string, unknown> = { ...baseCh, ...bpCh };
443
- for (const key of NESTED_CHANNEL_KEYS) {
444
- const baseNested = baseCh[key];
445
- const bpNested = bpCh[key];
446
- if (
447
- baseNested &&
448
- bpNested &&
449
- typeof baseNested === 'object' &&
450
- typeof bpNested === 'object' &&
451
- !Array.isArray(baseNested) &&
452
- !Array.isArray(bpNested)
453
- ) {
454
- merged[key] = { ...baseNested, ...bpNested };
455
- }
456
- }
457
- mergedEncoding[channel] = merged;
458
- } else if (bpCh) {
459
- mergedEncoding[channel] = bpCh;
460
- }
461
- }
462
- chartSpec = {
463
- ...chartSpec,
464
- encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'],
465
- };
466
- }
467
- if (typeof bp.watermark === 'boolean') {
468
- // Update the resolved watermark value used downstream. ChartSpec carries
469
- // this in its normalized shape; the local `watermark` variable controls
470
- // chrome computation and rendering.
471
- watermark = bp.watermark;
472
- chartSpec = { ...chartSpec, watermark };
473
- }
474
- }
475
-
476
- // Sparkline mode: default labels off. Mark renderers draw value labels per
477
- // labels.density (default 'auto'), which fills tiny sparklines with text and
478
- // is never what you want. Explicit user labels at any level wins via
479
- // userExplicit.labels.
480
- if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.labels) {
481
- chartSpec = {
482
- ...chartSpec,
483
- labels: { ...chartSpec.labels, density: 'none' },
484
- };
485
- }
486
-
487
- // Resolve animation spec. Breakpoint override wins over base spec (matching
488
- // chrome, labels, legend, and annotation override precedence).
489
- // Precedence rule for sparkline mode: an explicit user animation at ANY
490
- // level (top-level OR breakpoint) always wins, regardless of display mode.
491
- // resolveAnimation handles the explicit-user value; the sparkline default-off
492
- // behavior is applied below when no explicit value exists.
493
- let rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
494
- ?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
495
- if (rawAnimationSpec === undefined && chartSpec.display === 'sparkline') {
496
- // Sparkline mode: animation defaults to false. User-explicit (top OR bp)
497
- // already short-circuits this branch via userExplicit.animation.
498
- rawAnimationSpec = false;
499
- }
500
- // Sparkline mode: when animation is on but the user didn't specify duration,
501
- // bump to 1100ms so the line/area reveal feels paced rather than mechanical.
502
- // The CSS override pairs this with an expo-out easing curve. AnimationConfig
503
- // nests duration under `enter`, so we set it there.
504
- if (
505
- chartSpec.display === 'sparkline' &&
506
- rawAnimationSpec !== false &&
507
- rawAnimationSpec !== undefined
508
- ) {
509
- const SPARK_DURATION = 1100;
510
- if (rawAnimationSpec === true) {
511
- rawAnimationSpec = { enter: { duration: SPARK_DURATION } } as AnimationSpec;
512
- } else if (typeof rawAnimationSpec === 'object') {
513
- const cfg = rawAnimationSpec as { enter?: unknown; annotationDelay?: number };
514
- const enter = cfg.enter;
515
- if (enter === undefined || enter === true) {
516
- rawAnimationSpec = {
517
- ...cfg,
518
- enter: { duration: SPARK_DURATION },
519
- } as AnimationSpec;
520
- } else if (
521
- typeof enter === 'object' &&
522
- enter !== null &&
523
- (enter as { duration?: number }).duration === undefined
524
- ) {
525
- rawAnimationSpec = {
526
- ...cfg,
527
- enter: { ...(enter as object), duration: SPARK_DURATION },
528
- } as AnimationSpec;
529
- }
530
- }
531
- }
532
- const resolvedAnimation = resolveAnimation(rawAnimationSpec);
533
-
534
- // Crosshair: explicit user value at any level wins. Default is ON for line
535
- // and area marks in full mode (the hover guideline + per-series tooltip is
536
- // the standard interaction pattern for time-series charts). Sparkline mode
537
- // defaults off. The value is plumbed through ChartLayout so the renderer
538
- // doesn't need to re-inspect the raw spec.
539
- const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
540
- const isLineOrAreaMark = chartSpec.markType === 'line' || chartSpec.markType === 'area';
541
- const crosshairDefault = chartSpec.display === 'sparkline' ? false : isLineOrAreaMark;
542
- const crosshair = chartSpec.userExplicit.crosshair ? rawCrosshair === true : crosshairDefault;
543
-
544
- // Watermark default-off in sparkline mode unless user-explicit.
545
- if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
546
- watermark = false;
547
- chartSpec = { ...chartSpec, watermark: false };
548
- }
549
-
550
- // Resolve theme: merge spec-level theme with options-level overrides
551
- const mergedThemeConfig = options.theme
552
- ? { ...chartSpec.theme, ...options.theme }
553
- : chartSpec.theme;
554
- let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
555
- if (options.darkMode) {
556
- theme = adaptTheme(theme);
557
- }
558
-
559
- // Sparkline mode: inject smart defaults that depend on the resolved theme
560
- // (trend-aware color, area gradient, default endpoint dot, pill cornerRadius
561
- // for bars). Each default backs off when the user has set the corresponding
562
- // field explicitly. Must run AFTER theme resolution (color depends on theme)
563
- // and BEFORE computeDimensions (which reads markDef.point for sparkline padding).
564
- if (chartSpec.display === 'sparkline') {
565
- chartSpec = applySparklineDefaults(chartSpec, theme);
566
- }
567
-
568
- // ---------------------------------------------------------------------------
569
- // Data filtering: move BEFORE layout plan so the plan measures real labels
570
- // from the filtered data set. Legend retains all series via chartSpec
571
- // (unfiltered); scales and marks use renderSpec (filtered).
572
- // ---------------------------------------------------------------------------
573
- let renderData = chartSpec.data;
574
-
575
- // Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
576
- if (
577
- chartSpec.hiddenSeries.length > 0 &&
578
- chartSpec.encoding.color &&
579
- 'field' in chartSpec.encoding.color
580
- ) {
581
- const colorField = chartSpec.encoding.color.field;
582
- const hiddenSet = new Set(chartSpec.hiddenSeries);
583
- renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
584
- }
585
-
586
- // Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
587
- renderData = filterClippedDomains(renderData, chartSpec.encoding);
588
-
589
- // Build a filtered spec for scales and marks, keeping all other properties intact
590
- let renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
591
-
592
- // Lock the color scale domain to the unfiltered series list so palette
593
- // assignments stay stable across legend toggles. Without this, hiding a
594
- // series shrinks the ordinal scale's domain and shifts every remaining
595
- // series down one palette index -- the visible lines would mismatch the
596
- // legend swatches that still represent the original assignment.
597
- // Skipped when the user already supplied an explicit domain.
598
- const colorEnc = chartSpec.encoding.color;
599
- if (
600
- chartSpec.hiddenSeries.length > 0 &&
601
- colorEnc &&
602
- 'field' in colorEnc &&
603
- colorEnc.type !== 'quantitative' &&
604
- colorEnc.scale?.domain == null
605
- ) {
606
- const colorField = colorEnc.field;
607
- const stableDomain = Array.from(new Set(chartSpec.data.map((row) => String(row[colorField]))));
608
- renderSpec = {
609
- ...renderSpec,
610
- encoding: {
611
- ...renderSpec.encoding,
612
- color: {
613
- ...colorEnc,
614
- scale: { ...(colorEnc.scale ?? {}), domain: stableDomain },
615
- },
616
- },
617
- };
618
- }
619
-
620
- // ---------------------------------------------------------------------------
621
- // Faceted compilation: desugar row/column into facet, then take faceted path
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
-
646
- const facetChannel = renderSpec.encoding.facet;
647
- if (facetChannel?.field) {
648
- return compileFaceted(
649
- chartSpec,
650
- renderSpec,
651
- facetChannel,
652
- facetDirection,
653
- options,
654
- theme,
655
- strategy,
656
- watermark,
657
- resolvedAnimation,
658
- crosshair,
659
- spec,
660
- optionsInput,
661
- );
662
- }
663
-
664
- // ---------------------------------------------------------------------------
665
- // Layout plan: measure real labels, freeze gutter + chrome + legend content
666
- // ---------------------------------------------------------------------------
667
- const measure = createMeasureFn(options.measureText);
668
- const plan = resolveLayoutPlan(
669
- chartSpec,
670
- renderSpec,
671
- options,
672
- theme,
673
- strategy,
674
- watermark,
675
- measure,
676
- );
677
-
678
- // Compute dimensions (accounts for chrome + legend + responsive strategy).
679
- // The plan provides measured leftGutter, xAxisExtent, and chrome so
680
- // dimensions.ts skips its guess blocks.
681
- const legendStub = placeLegend(
682
- plan.legendContent,
683
- { x: 0, y: 0, width: options.width, height: options.height },
684
- options.width,
685
- theme,
686
- plan.xAxisExtent,
687
- );
688
- const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
689
- // A frozen area (set by compileLayer for leaf layers) overrides the computed
690
- // one so all layers share the primary layout's coordinate space.
691
- const chartArea = options.frozenChartArea ?? dims.chartArea;
692
-
693
- // Place legend in its final position relative to the computed chart area.
694
- // effectiveAxisGap lifts a top legend above the inline-tick overhang zone
695
- // the margin stack reserved below it.
696
- const finalLegend = placeLegend(
697
- plan.legendContent,
698
- chartArea,
699
- options.width,
700
- theme,
701
- plan.xAxisExtent,
702
- dims.effectiveAxisGap,
703
- );
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
-
714
- // Inline y-axis labels: inset the x-scale range start by the widest tick
715
- // label width so data marks start to the right of the labels. The plan
716
- // already measured the real tick labels.
717
- let scaleArea = chartArea;
718
- if (plan.inlineYLabelInset > 0 && plan.inlineYLabelInset < chartArea.width) {
719
- scaleArea = {
720
- x: chartArea.x + plan.inlineYLabelInset,
721
- y: chartArea.y,
722
- width: chartArea.width - plan.inlineYLabelInset,
723
- height: chartArea.height,
724
- };
725
- }
726
-
727
- // Compute scales
728
- const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
729
-
730
- // Update color scale to use theme palette (only when user hasn't provided an explicit range).
731
- // When highlight is active, assign palette colors to highlighted series and mute the rest.
732
- applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
733
-
734
- // INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
735
- // theme context. Do not move into computeScales (would require threading theme through).
736
- // fill wins for bar/area/arc marks; stroke wins for line marks (the stroke IS the color).
737
- scales.defaultColor =
738
- chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
739
-
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);
750
-
751
- // Compute axes (skip for radial charts).
752
- // Sparkline mode skips axes by default unless the user explicitly opted into
753
- // an axis on a specific channel.
754
- const skipX = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.xAxis;
755
- const skipY = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
756
- const axes = isRadial
757
- ? { x: undefined, y: undefined }
758
- : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
759
- data: renderSpec.data,
760
- encoding: renderSpec.encoding as Encoding,
761
- skipX,
762
- skipY,
763
- markType: chartSpec.markType,
764
- totalWidth: options.width,
765
- precomputedYTicks: plan.yTickValues.length > 0 ? plan.yTickValues : undefined,
766
- });
767
-
768
- // Intentional post-hoc mutation: axes must resolve before we know the x-axis extent.
769
- const xAxisExtent = axes.x?.extent ?? 0;
770
- dims.chrome.bottomAnchorY = chartArea.y + chartArea.height + xAxisExtent;
771
-
772
- // INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
773
- // axes.y.gridlines off the same object. Do not introduce a copy-on-write.
774
- if (!isRadial) {
775
- computeGridlines(axes, chartArea);
776
- }
777
-
778
- // Get chart renderer and compute marks (using filtered data).
779
- const rendererKey = resolveRendererKey(
780
- renderSpec.markType,
781
- renderSpec.encoding,
782
- renderSpec.markDef,
783
- );
784
- const renderer = getChartRenderer(rendererKey);
785
- const marks: Mark[] = renderer
786
- ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
787
- : [];
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
-
801
- // Compute the right-side endpoint labels column for multi-series line/area
802
- // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
803
- // dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
804
- const endpointLabels = computeEndpointLabels(
805
- chartSpec,
806
- marks,
807
- theme,
808
- chartArea,
809
- strategy,
810
- plan.endpointLabelsDemoted,
811
- );
812
-
813
- // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
814
- const obstacles: PlacementObstacle[] = [];
815
- if (finalLegend.bounds.width > 0) {
816
- obstacles.push({ ...finalLegend.bounds, kind: 'legend' });
817
- }
818
- obstacles.push(...computeMarkObstacles(marks, scales));
819
-
820
- // Add visible data label bounds as obstacles so annotations avoid overlapping them
821
- for (const mark of marks) {
822
- if ('label' in mark && mark.label?.visible) {
823
- obstacles.push({ ...computeLabelBounds(mark.label), kind: 'data-label' });
824
- }
825
- }
826
-
827
- // Add brand watermark as an obstacle so annotations avoid overlapping it.
828
- const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
829
- if (watermarkRect) obstacles.push({ ...watermarkRect, kind: 'watermark' });
830
-
831
- // Add endpoint label bounds as obstacles
832
- if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
833
- obstacles.push({ ...endpointLabels.bounds, kind: 'endpoint-label' });
834
- }
835
-
836
- const annotationMeasure: AnnotationMeasureTextFn = options.measureText
837
- ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
838
- : heuristicMeasure;
839
-
840
- let annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
841
- scales,
842
- chartArea,
843
- strategy,
844
- isDark: theme.isDark,
845
- obstacles,
846
- svg: { width: dims.total.width, height: dims.total.height },
847
- measure: annotationMeasure,
848
- fontFamily: theme.fonts.family,
849
- autoThin: chartSpec.autoThin,
850
- });
851
-
852
- // Auto-thinning: demote overlapping text annotations to footnote markers.
853
- // Pass the full spec annotations (not filtered to text-only) so index
854
- // alignment with the resolved array is preserved.
855
- let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
856
- if (chartSpec.autoThin && annotations.length > 1) {
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
- );
864
- annotations = result.annotations;
865
- if (result.footnotes.length > 0) {
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
- }
898
- }
899
- }
900
-
901
- // Compute tooltip descriptors from marks and encoding
902
- const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
903
-
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
- );
918
- const dataTableFallback = generateDataTable(
919
- {
920
- mark: chartSpec.markType,
921
- data: chartSpec.data,
922
- encoding: chartSpec.encoding,
923
- } as ChartSpec,
924
- chartSpec.data,
925
- );
926
-
927
- // Assign animationIndex for stagger ordering when animation is enabled
928
- assignAnimationIndices(marks, resolvedAnimation);
929
-
930
- const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
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
-
936
- return {
937
- area: chartArea,
938
- chrome,
939
- metrics: dims.metrics,
940
- ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
941
- ...(youDrawIt ? { youDrawIt } : {}),
942
- axes: {
943
- x: axes.x,
944
- y: axes.y,
945
- },
946
- marks,
947
- annotations,
948
- legend: finalLegend,
949
- legends: allLegends,
950
- ...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
951
- tooltipDescriptors,
952
- a11y: {
953
- altText,
954
- dataTableFallback,
955
- role: 'img',
956
- keyboardNavigable: marks.length > 0,
957
- ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
958
- },
959
- theme,
960
- dimensions: {
961
- width: options.width,
962
- height: options.height,
963
- },
964
- animation: resolvedAnimation,
965
- watermark,
966
- display: chartSpec.display,
967
- crosshair,
968
- measureText: options.measureText,
969
- ...(xInvert ? { xInvert } : {}),
970
- ...(yInvert ? { yInvert } : {}),
971
- };
972
- }
973
-
974
- // ---------------------------------------------------------------------------
975
- // Faceted (small-multiples) compilation
976
- // ---------------------------------------------------------------------------
977
-
978
- function compileFaceted(
979
- chartSpec: NormalizedChartSpec,
980
- renderSpec: NormalizedChartSpec,
981
- facetChannel: FacetChannel,
982
- facetDirection: 'row' | 'column' | 'wrap',
983
- options: CompileOptions,
984
- theme: ResolvedTheme,
985
- strategy: LayoutStrategy,
986
- watermark: boolean,
987
- resolvedAnimation: ResolvedAnimation | undefined,
988
- crosshair: boolean,
989
- rawSpec: unknown,
990
- optionsInput: CompileOptions,
991
- ): ChartLayout {
992
- const measure = createMeasureFn(options.measureText);
993
-
994
- // Strip facet from encoding before passing to per-panel pipeline (panels
995
- // don't know they're faceted -- they compile as regular single charts).
996
- const panelEncoding = { ...renderSpec.encoding };
997
- delete (panelEncoding as Record<string, unknown>).facet;
998
- const panelSpec = { ...renderSpec, encoding: panelEncoding };
999
- const panelChartSpec = { ...chartSpec, encoding: panelEncoding };
1000
-
1001
- // Partition data by facet field
1002
- const groups = groupByField(renderSpec.data, facetChannel.field);
1003
- const facetValues = Array.from(groups.keys());
1004
- if (facetChannel.sort !== null) {
1005
- const dir = facetChannel.sort === 'descending' ? -1 : 1;
1006
- facetValues.sort((a, b) => dir * a.localeCompare(b));
1007
- }
1008
-
1009
- // Compute figure-level chrome and dimensions using the full dataset
1010
- // (no facet channel so it compiles normally). We use a representative
1011
- // single-panel spec to get chrome + legend measurements.
1012
- const plan = resolveLayoutPlan(
1013
- panelChartSpec,
1014
- panelSpec,
1015
- options,
1016
- theme,
1017
- strategy,
1018
- watermark,
1019
- measure,
1020
- );
1021
- const legendStub = placeLegend(
1022
- plan.legendContent,
1023
- { x: 0, y: 0, width: options.width, height: options.height },
1024
- options.width,
1025
- theme,
1026
- plan.xAxisExtent,
1027
- );
1028
- const dims = computeDimensions(
1029
- panelChartSpec,
1030
- options,
1031
- legendStub,
1032
- theme,
1033
- strategy,
1034
- watermark,
1035
- plan,
1036
- );
1037
- const chartArea = dims.chartArea;
1038
-
1039
- // Same effectiveAxisGap threading as the regular (non-faceted) path.
1040
- const finalLegend = placeLegend(
1041
- plan.legendContent,
1042
- chartArea,
1043
- options.width,
1044
- theme,
1045
- plan.xAxisExtent,
1046
- dims.effectiveAxisGap,
1047
- );
1048
-
1049
- // Estimate y-axis gutter width (used as left reservation for outer axes)
1050
- const leftReservation = plan.leftGutter;
1051
- // Estimate x-axis extent (used as bottom reservation for outer axes)
1052
- const bottomReservation = plan.xAxisExtent;
1053
-
1054
- // Compute facet grid geometry
1055
- const effectiveColumns = facetDirection === 'column' ? facetValues.length : facetChannel.columns;
1056
- const grid = computeFacetGrid(facetValues, effectiveColumns, chartArea, {
1057
- left: leftReservation,
1058
- bottom: bottomReservation,
1059
- });
1060
-
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)
1082
- const resolveConfig = chartSpec.resolve;
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;
1087
-
1088
- // Compute shared scale domains from the full dataset (when shared)
1089
- const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
1090
- const sharedXDomain =
1091
- xResolve === 'shared' && fullScales.x ? fullScales.x.scale.domain() : undefined;
1092
- const sharedYDomain =
1093
- yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
1094
-
1095
- const isRadial = isAxislessMark(chartSpec.markType);
1096
- const rendererKey = resolveRendererKey(
1097
- renderSpec.markType,
1098
- panelEncoding as Encoding,
1099
- renderSpec.markDef,
1100
- );
1101
- const renderer = getChartRenderer(rendererKey);
1102
-
1103
- const allMarks: Mark[] = [];
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>();
1110
-
1111
- for (const gridPanel of grid.panels) {
1112
- const panelData = groups.get(gridPanel.key) ?? [];
1113
- const panelSpecLocal = { ...panelSpec, data: panelData };
1114
-
1115
- // Inject shared domains via scale.domain on the encoding channels
1116
- let panelEnc = { ...panelEncoding } as Record<string, unknown>;
1117
- if (sharedXDomain && panelEnc.x) {
1118
- const xCh = panelEnc.x as Record<string, unknown>;
1119
- panelEnc = {
1120
- ...panelEnc,
1121
- x: { ...xCh, scale: { ...((xCh.scale as object) ?? {}), domain: sharedXDomain } },
1122
- };
1123
- }
1124
- if (sharedYDomain && panelEnc.y) {
1125
- const yCh = panelEnc.y as Record<string, unknown>;
1126
- panelEnc = {
1127
- ...panelEnc,
1128
- y: { ...yCh, scale: { ...((yCh.scale as object) ?? {}), domain: sharedYDomain } },
1129
- };
1130
- }
1131
- const panelSpecWithDomains = {
1132
- ...panelSpecLocal,
1133
- encoding: panelEnc as unknown as typeof panelSpec.encoding,
1134
- };
1135
-
1136
- // Compute scales for this panel's area
1137
- const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
1138
- applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme, renderSpec.highlight);
1139
- panelScales.defaultColor =
1140
- chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
1141
-
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
-
1151
- const isLeftCol = gridPanel.col === 0;
1152
-
1153
- const panelAxes = isRadial
1154
- ? { x: undefined, y: undefined }
1155
- : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
1156
- data: panelData,
1157
- encoding: panelSpecWithDomains.encoding as Encoding,
1158
- markType: chartSpec.markType,
1159
- totalWidth: gridPanel.area.width,
1160
- });
1161
-
1162
- if (!isRadial) {
1163
- computeGridlines(panelAxes, gridPanel.area);
1164
- }
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
-
1189
- // Compute marks for this panel
1190
- const panelMarks: Mark[] = renderer
1191
- ? renderer(
1192
- panelSpecWithDomains,
1193
- panelScales,
1194
- gridPanel.area,
1195
- strategy,
1196
- theme,
1197
- gridPanel.area.width,
1198
- )
1199
- : [];
1200
-
1201
- // Compute annotations for this panel
1202
- const annotationMeasure: AnnotationMeasureTextFn = options.measureText
1203
- ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
1204
- : heuristicMeasure;
1205
- let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1206
- scales: panelScales,
1207
- chartArea: gridPanel.area,
1208
- strategy,
1209
- isDark: theme.isDark,
1210
- obstacles: [],
1211
- svg: { width: options.width, height: options.height },
1212
- measure: annotationMeasure,
1213
- fontFamily: theme.fonts.family,
1214
- autoThin: panelSpecWithDomains.autoThin,
1215
- });
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
-
1262
- allMarks.push(...panelMarks);
1263
-
1264
- panelLayouts.push({
1265
- key: gridPanel.key,
1266
- area: gridPanel.area,
1267
- marks: panelMarks,
1268
- axes: { x: panelAxes.x, y: panelAxes.y },
1269
- annotations: panelAnnotations,
1270
- header: {
1271
- text: gridPanel.key,
1272
- x: facetDirection === 'row' ? gridPanel.area.x : gridPanel.headerPos.x,
1273
- y: gridPanel.headerPos.y,
1274
- fontSize: theme.fonts.sizes.body,
1275
- fontWeight: theme.fonts.weights.medium,
1276
- ...(facetDirection === 'row' ? { textAnchor: 'start' as const } : {}),
1277
- },
1278
- });
1279
- }
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
-
1303
- // Compute tooltip descriptors from all marks across panels
1304
- const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
1305
-
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).
1312
- const panelCount = facetValues.length;
1313
- const baseAltText = generateAltText(
1314
- {
1315
- mark: chartSpec.markType,
1316
- data: chartSpec.data,
1317
- encoding: chartSpec.encoding,
1318
- chrome: chartSpec.chrome,
1319
- } as ChartSpec,
1320
- chartSpec.data,
1321
- );
1322
- const altText =
1323
- chartSpec.a11y?.description ??
1324
- `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1325
-
1326
- const dataTableFallback = generateDataTable(
1327
- {
1328
- mark: chartSpec.markType,
1329
- data: chartSpec.data,
1330
- encoding: chartSpec.encoding,
1331
- } as ChartSpec,
1332
- chartSpec.data,
1333
- );
1334
-
1335
- // Assign animation indices across all panels
1336
- assignAnimationIndices(allMarks, resolvedAnimation);
1337
-
1338
- // Figure-level axes are null (axes live in panels)
1339
- // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
1340
- return {
1341
- area: chartArea,
1342
- chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
1343
- metrics: dims.metrics,
1344
- ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
1345
- axes: { x: undefined, y: undefined },
1346
- marks: allMarks,
1347
- annotations: [],
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],
1352
- tooltipDescriptors,
1353
- a11y: {
1354
- altText,
1355
- dataTableFallback,
1356
- role: 'img',
1357
- keyboardNavigable: allMarks.length > 0,
1358
- ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
1359
- },
1360
- theme,
1361
- dimensions: { width: options.width, height: options.height },
1362
- animation: resolvedAnimation,
1363
- watermark,
1364
- display: chartSpec.display,
1365
- crosshair,
1366
- measureText: options.measureText,
1367
- facet: {
1368
- panels: panelLayouts,
1369
- facetField: facetChannel.field,
1370
- columns: grid.columns,
1371
- sharedScales: yResolve === 'shared' && xResolve === 'shared',
1372
- direction: facetDirection,
1373
- },
1374
- };
1375
- }
1376
-
1377
- // ---------------------------------------------------------------------------
1378
- // Layer compilation
1379
- // ---------------------------------------------------------------------------
1380
-
1381
- export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
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);
1393
- }
1394
-
1395
- // ---------------------------------------------------------------------------
1396
- // Table compilation
1397
- // ---------------------------------------------------------------------------
1398
-
1399
- /**
1400
- * Compile a table spec into a TableLayout.
1401
- *
1402
- * Validates and normalizes the spec, resolves the theme, then delegates
1403
- * to compileTableLayout for the full pipeline: column resolution, search,
1404
- * sort, pagination, cell formatting, and visual enhancements.
1405
- *
1406
- * @param spec - Raw table spec.
1407
- * @param options - Compile options with sort, search, pagination state.
1408
- * @returns Fully resolved TableLayout.
1409
- */
1410
- export function compileTable(spec: unknown, options: CompileTableOptions): TableLayout {
1411
- const { spec: normalized } = compileSpec(spec);
1412
-
1413
- const normType =
1414
- 'type' in normalized ? (normalized as unknown as Record<string, unknown>).type : undefined;
1415
- if (normType !== 'table') {
1416
- throw new Error(`compileTable received a non-table spec. Use compileChart instead.`);
1417
- }
1418
-
1419
- const tableSpec = normalized as NormalizedTableSpec;
1420
-
1421
- // Resolve theme: merge spec-level theme with options-level overrides
1422
- const mergedThemeConfig = options.theme
1423
- ? { ...tableSpec.theme, ...options.theme }
1424
- : tableSpec.theme;
1425
- let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
1426
- if (options.darkMode) {
1427
- theme = adaptTheme(theme);
1428
- }
1429
-
1430
- // Resolve watermark: spec-level wins, then options, then default true
1431
- const rawWatermark = (spec as Record<string, unknown>).watermark;
1432
- const watermark = rawWatermark !== undefined ? tableSpec.watermark : (options.watermark ?? true);
1433
-
1434
- return compileTableLayout({ ...tableSpec, watermark }, options, theme);
1435
- }
1436
-
1437
- // ---------------------------------------------------------------------------
1438
- // Graph compilation
1439
- // ---------------------------------------------------------------------------
1440
-
1441
- /**
1442
- * Compile a graph spec into a GraphCompilation.
1443
- *
1444
- * The graph pipeline resolves visual properties (size, color, stroke) for
1445
- * nodes and edges, assigns communities, and builds legend/tooltip/a11y data.
1446
- * Unlike charts, the output does NOT include x/y positions since the force
1447
- * simulation in the adapter handles layout at runtime.
1448
- *
1449
- * @param spec - Raw graph spec (validated and normalized internally).
1450
- * @param options - Compile options (width, height, theme, darkMode).
1451
- * @returns GraphCompilation with resolved visual properties and simulation config.
1452
- * @throws Error if spec is invalid or not a graph type.
1453
- */
1454
- export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
1455
- return compileGraphImpl(spec, options);
1456
- }
1457
-
1458
- // ---------------------------------------------------------------------------
1459
- // Sankey compilation
1460
- // ---------------------------------------------------------------------------
1461
-
1462
- /**
1463
- * Compile a sankey spec into a SankeyLayout.
1464
- *
1465
- * Takes a raw sankey spec, validates, normalizes, resolves theme and chrome,
1466
- * runs the d3-sankey layout algorithm, builds node/link marks with colors and
1467
- * labels, and returns a SankeyLayout ready for rendering.
1468
- *
1469
- * @param spec - Raw sankey spec (validated and normalized internally).
1470
- * @param options - Compile options (width, height, theme, darkMode).
1471
- * @returns SankeyLayout with computed positions and visual properties.
1472
- * @throws Error if spec is invalid or not a sankey type.
1473
- */
1474
- export function compileSankey(
1475
- spec: unknown,
1476
- options: CompileOptions,
1477
- ): import('@opendata-ai/openchart-core').SankeyLayout {
1478
- return compileSankeyImpl(spec, options);
1479
- }
1480
-
1481
- // ---------------------------------------------------------------------------
1482
- // TileMap compilation
1483
- // ---------------------------------------------------------------------------
1484
-
1485
- /**
1486
- * Compile a tilemap spec into a TileMapLayout.
1487
- *
1488
- * Takes a raw tilemap spec, validates, normalizes, resolves theme and chrome,
1489
- * computes tile positions, builds tile marks with colors and labels, and
1490
- * returns a TileMapLayout ready for rendering.
1491
- *
1492
- * @param spec - Raw tilemap spec (validated and normalized internally).
1493
- * @param options - Compile options (width, height, theme, darkMode).
1494
- * @returns TileMapLayout with computed positions and visual properties.
1495
- * @throws Error if spec is invalid or not a tilemap type.
1496
- */
1497
- export function compileTileMap(
1498
- spec: unknown,
1499
- options: CompileOptions,
1500
- ): import('@opendata-ai/openchart-core').TileMapLayout {
1501
- return compileTileMapImpl(spec, options);
1502
- }
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
-
1527
- // ---------------------------------------------------------------------------
1528
- // BarList compilation
1529
- // ---------------------------------------------------------------------------
1530
-
1531
- /**
1532
- * Compile a barlist spec into a BarListLayout.
1533
- *
1534
- * Takes a raw barlist spec, validates, normalizes, resolves theme and chrome,
1535
- * computes row layout with proportional bars, builds tooltips, and returns
1536
- * a BarListLayout ready for rendering.
1537
- *
1538
- * @param spec - Raw barlist spec (validated and normalized internally).
1539
- * @param options - Compile options (width, height, theme, darkMode).
1540
- * @returns BarListLayout with computed positions and visual properties.
1541
- * @throws Error if spec is invalid or not a barlist type.
1542
- */
1543
- export function compileBarList(
1544
- spec: unknown,
1545
- options: CompileOptions,
1546
- ): import('@opendata-ai/openchart-core').BarListLayout {
1547
- return compileBarListImpl(spec, options);
1548
- }