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

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 (271) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +123 -54
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  7. package/src/__tests__/annotation-behavior.test.ts +0 -852
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -359
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -376
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -545
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -630
  133. package/src/compile/size-scale.ts +0 -141
  134. package/src/compile/spec-sugar.ts +0 -767
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1548
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  167. package/src/compiler/__tests__/validate.test.ts +0 -731
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -734
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -2093
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  185. package/src/geo/compile-map.ts +0 -1376
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -65
  188. package/src/geo/types.ts +0 -26
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -748
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. 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
- }