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

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 +140 -2
  2. package/dist/index.js +1403 -210
  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 -2339
  7. package/src/__tests__/annotation-behavior.test.ts +0 -721
  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 -215
  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 -370
  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 -539
  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 -626
  133. package/src/compile/size-scale.ts +0 -139
  134. package/src/compile/spec-sugar.ts +0 -758
  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 -1450
  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 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  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 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  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 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  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 -747
  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,1450 +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
- TableLayout,
32
- } from '@opendata-ai/openchart-core';
33
- import {
34
- adaptTheme,
35
- computeLabelBounds,
36
- footnoteBandHeight,
37
- generateAltText,
38
- generateDataTable,
39
- getBreakpoint,
40
- getHeightClass,
41
- getLayoutStrategy,
42
- isAxislessMark,
43
- resolveTheme,
44
- } from '@opendata-ai/openchart-core';
45
- import { computeAnnotations } from './annotations/compute';
46
- import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry';
47
- import type { PlacementObstacle } from './annotations/placement';
48
- import { thinAnnotations } from './annotations/thinning';
49
- import { computeEndpointLabels } from './endpoint-labels/compute';
50
- // Side-effect import: registers all built-in chart renderers with the
51
- // registry on module load. Tests that clear the registry can import
52
- // `registerBuiltinRenderers` from `./charts/builtin` to restore defaults.
53
- import './charts/builtin';
54
- import { compileBarList as compileBarListImpl } from './barlist/compile-barlist';
55
- import {
56
- assignAnimationIndices,
57
- computeMarkObstacles,
58
- resolveRendererKey,
59
- } from './charts/post-process';
60
- import { getChartRenderer } from './charts/registry';
61
- import { groupByField } from './charts/utils';
62
- import { applyColorScaleRange } from './compile/color-scale-range';
63
- import { filterClippedDomains } from './compile/data-clip';
64
- import { applyFillPatterns } from './compile/fill-patterns';
65
- import { compileLayer as compileLayerImpl } from './compile/layer';
66
- import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar';
67
- import { computeWatermarkObstacle } from './compile/watermark-obstacle';
68
- import { resolveYouDrawIt } from './compile/you-draw-it';
69
- import { collectContrastWarnings } from './compiler/a11y-warnings';
70
- import { resolveAnimation } from './compiler/animation';
71
- import { compile as compileSpec } from './compiler/index';
72
- import {
73
- buildSparklineAreaGradient,
74
- computeTrendFromData,
75
- hasExplicitColor,
76
- trendColor,
77
- } from './compiler/sparkline-defaults';
78
- import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
79
- import { compileMap as compileMapImpl } from './geo/compile-map';
80
- import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
81
- import type { GraphCompilation } from './graphs/types';
82
- import { computeAxes } from './layout/axes';
83
- import { computeDimensions } from './layout/dimensions';
84
- import { computeFacetGrid, facetMinHeight, MIN_PANEL_HEIGHT } from './layout/facet';
85
- import { computeGridlines } from './layout/gridlines';
86
- import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
87
- import { computeScales } from './layout/scales';
88
- import { placeLegend } from './legend/compute';
89
- import { placeSizeLegend } from './legend/size';
90
- import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
91
- import { compileTableLayout } from './tables/compile-table';
92
- import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
93
- import { computeTooltipDescriptors } from './tooltips/compute';
94
- import { runTransforms } from './transforms';
95
-
96
- // ---------------------------------------------------------------------------
97
- // Spec sugar expansion (VL idioms, bin/timeUnit, deprecation warnings)
98
- // ---------------------------------------------------------------------------
99
-
100
- // Implementation lives in ./compile/spec-sugar; re-exported here so existing
101
- // deep imports (`import { expandEncodingSugar } from '.../compile'`) keep working.
102
- export { expandEncodingSugar, expandSpecSugar } from './compile/spec-sugar';
103
-
104
- /**
105
- * Apply top-level spec `width`/`height` (VL fixed-size sugar) as overrides on
106
- * the compile options. Returns the options untouched when the spec carries no
107
- * size.
108
- */
109
- function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions {
110
- if (!spec || typeof spec !== 'object') return options;
111
- const sized = spec as Record<string, unknown>;
112
- if (typeof sized.width !== 'number' && typeof sized.height !== 'number') return options;
113
- return {
114
- ...options,
115
- width: typeof sized.width === 'number' ? sized.width : options.width,
116
- height: typeof sized.height === 'number' ? sized.height : options.height,
117
- };
118
- }
119
-
120
- // ---------------------------------------------------------------------------
121
- // Chart compilation
122
- // ---------------------------------------------------------------------------
123
-
124
- /**
125
- * Cap on footnote-reserve convergence passes in compileChart. Reserving space
126
- * for the footnote list shrinks the plot, which can demote one more label and
127
- * grow the list; the count only ever grows and is bounded by the annotation
128
- * count, so this terminates on its own. The cap is a guard against a
129
- * measurement quirk oscillating, not the normal exit condition.
130
- */
131
- const MAX_FOOTNOTE_PASSES = 3;
132
-
133
- /**
134
- * Inject sparkline-mode visual defaults that depend on the resolved theme.
135
- *
136
- * Trend-aware color (positive/negative/neutral), default endpoint dot,
137
- * default area gradient, and default pill cornerRadius for bars. Each
138
- * default backs off when the user has set the corresponding markDef
139
- * field. Bar marks do NOT get trend coloring — the design intent is a
140
- * single palette color across all bars.
141
- */
142
- function applySparklineDefaults(
143
- spec: NormalizedChartSpec,
144
- theme: ResolvedTheme,
145
- ): NormalizedChartSpec {
146
- const markType = spec.markType;
147
- const isLineFamily = markType === 'line' || markType === 'area';
148
- const isBar = markType === 'bar';
149
- if (!isLineFamily && !isBar) return spec;
150
-
151
- const yField = spec.encoding.y && 'field' in spec.encoding.y ? spec.encoding.y.field : undefined;
152
- const trend = computeTrendFromData(spec.data, yField);
153
- const color = trendColor(trend, theme);
154
-
155
- const encodingHasColor =
156
- !!spec.encoding.color && (spec.encoding.color as { field?: unknown }).field !== undefined;
157
- const explicit = hasExplicitColor(spec.markDef, encodingHasColor);
158
-
159
- const newMarkDef = { ...spec.markDef };
160
-
161
- if (isLineFamily) {
162
- if (!explicit.stroke) newMarkDef.stroke = color;
163
- if (markType === 'area' && !explicit.fill) {
164
- newMarkDef.fill = buildSparklineAreaGradient(color);
165
- }
166
- // Endpoint dot is wired in the line compute path. We skip the default
167
- // when the encoding has a color field, because the multi-series area
168
- // path (linesFromAreas()) ignores markDef.point — the dot would be set
169
- // on the spec but never rendered, and the line compute path's dot logic
170
- // is per-series, not "one dot for the chart."
171
- if (newMarkDef.point === undefined && !encodingHasColor) {
172
- newMarkDef.point = 'last';
173
- }
174
- }
175
-
176
- if (isBar) {
177
- // Pill cornerRadius is safe on stacked bars too — the column compute
178
- // path applies the radius only to the topmost segment via
179
- // `cornerRadiusSides`, leaving the seams between segments square so
180
- // they stay flush. Single-series bars get all four corners rounded
181
- // since there's nothing below them to align against.
182
- if (newMarkDef.cornerRadius === undefined) {
183
- newMarkDef.cornerRadius = 'pill';
184
- }
185
- }
186
-
187
- return { ...spec, markDef: newMarkDef };
188
- }
189
-
190
- /**
191
- * Compile a chart spec into a ChartLayout.
192
- *
193
- * This is the main engine entry point. Takes a raw spec (any shape,
194
- * validated at runtime) and compile options, produces a fully resolved
195
- * ChartLayout with positions, colors, and marks ready for rendering.
196
- *
197
- * @param spec - Raw chart spec (validated and normalized internally).
198
- * @param optionsInput - Compile options (width, height, theme, darkMode).
199
- * Top-level spec width/height, when present, override the option values.
200
- * @returns ChartLayout with all computed positions.
201
- * @throws Error if spec is invalid or not a chart type.
202
- */
203
- export function compileChart(spec: unknown, optionsInput: CompileOptions): ChartLayout {
204
- // Expand VL-idiom and encoding-level sugar (data {values}, top-level title,
205
- // sort-by-value, value defs, scheme names, theta, bin/timeUnit, ...) before
206
- // validation + normalization, surfacing any deprecation warnings.
207
- const sugarWarnings: string[] = [];
208
- const expandedSpec =
209
- spec && typeof spec === 'object' && !Array.isArray(spec)
210
- ? expandSpecSugar(spec as Record<string, unknown>, sugarWarnings)
211
- : spec;
212
-
213
- // Top-level width/height are fixed-size overrides (VL alignment): the
214
- // authored size wins over the container-derived compile options.
215
- const options = applySpecSize(expandedSpec, optionsInput);
216
-
217
- // Validate + normalize. Normalize-stage warnings (type mismatches,
218
- // beeswarm point budget, sparkline mark advice) surface through the same
219
- // console channel as the sugar warnings. Emit both together in one call so
220
- // dedup spans the whole compile: a message produced by both a sugar pass and
221
- // a normalize pass prints once, not once per stage.
222
- const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec);
223
- emitSpecWarnings([...sugarWarnings, ...normalizeWarnings], options.onWarn);
224
-
225
- if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'table') {
226
- throw new Error('compileChart received a table spec. Use compileTable instead.');
227
- }
228
- if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'graph') {
229
- throw new Error('compileChart received a graph spec. Use compileGraph instead.');
230
- }
231
- if (
232
- 'type' in normalized &&
233
- (normalized as unknown as Record<string, unknown>).type === 'sankey'
234
- ) {
235
- throw new Error('compileChart received a sankey spec. Use compileSankey instead.');
236
- }
237
- if ('type' in normalized && (normalized as unknown as Record<string, unknown>).type === 'map') {
238
- throw new Error('compileChart received a map spec. Use compileMap instead.');
239
- }
240
-
241
- let chartSpec = normalized as NormalizedChartSpec;
242
-
243
- // Resolve watermark: explicit spec value wins, then options fallback, then default true.
244
- const rawWatermark = (expandedSpec as Record<string, unknown>).watermark;
245
- let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true);
246
-
247
- // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing.
248
- // Transforms are defined on the expanded spec (which includes any auto-generated
249
- // transforms from encoding-level bin/timeUnit sugar), not the normalized spec,
250
- // since NormalizedChartSpec doesn't carry the transform field.
251
- const rawTransforms = (expandedSpec as Record<string, unknown>).transform as
252
- | import('@opendata-ai/openchart-core').Transform[]
253
- | undefined;
254
- if (rawTransforms && rawTransforms.length > 0) {
255
- chartSpec = { ...chartSpec, data: runTransforms(chartSpec.data, rawTransforms) };
256
- }
257
-
258
- // Responsive strategy
259
- const breakpoint = getBreakpoint(options.width);
260
- const heightClass = getHeightClass(options.height);
261
- let strategy = getLayoutStrategy(breakpoint, heightClass);
262
-
263
- // Apply breakpoint-conditional overrides from the expanded spec
264
- const rawSpec = expandedSpec as Record<string, unknown>;
265
- const overrides = rawSpec.overrides as
266
- | Partial<
267
- Record<
268
- string,
269
- {
270
- chrome?: unknown;
271
- labels?: unknown;
272
- legend?: unknown;
273
- annotations?: unknown;
274
- animation?: unknown;
275
- display?: unknown;
276
- encoding?: unknown;
277
- watermark?: unknown;
278
- crosshair?: unknown;
279
- }
280
- >
281
- >
282
- | undefined;
283
-
284
- // Build userExplicit descriptor BEFORE applying any overrides so we capture
285
- // the union of "user wrote this at top-level" and "user wrote this in the
286
- // active breakpoint override." Sparkline display mode reads this to decide
287
- // whether to suppress chrome/axes/legend/etc. by default vs. respecting an
288
- // explicit user opt-in. Precedence: explicit at any level wins.
289
- const rawEncoding = rawSpec.encoding as
290
- | { x?: { axis?: unknown }; y?: { axis?: unknown } }
291
- | undefined;
292
- const bpForExplicit = overrides?.[breakpoint];
293
- const bpEncoding = bpForExplicit?.encoding as
294
- | { x?: { axis?: unknown }; y?: { axis?: unknown } }
295
- | undefined;
296
- // chrome: {} (empty object) is not "explicit" — it's an idiom users write to
297
- // silence defaults. Require at least one chrome key set to count as opt-in.
298
- const hasChromeKeys = (v: unknown): boolean =>
299
- !!v && typeof v === 'object' && Object.keys(v as Record<string, unknown>).length > 0;
300
- const userExplicit = {
301
- chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome),
302
- legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined,
303
- endpointLabels:
304
- rawSpec.endpointLabels !== undefined ||
305
- (bpForExplicit as Record<string, unknown> | undefined)?.endpointLabels !== undefined,
306
- // Axis explicitness tracks "user opted IN to render the axis" — `axis: false`
307
- // is an explicit opt-OUT and must not flip these flags on. Sparkline mode
308
- // reads them to decide whether to reserve axis space; treating `false` as
309
- // explicit would leave a phantom gutter for an axis that won't render.
310
- xAxis:
311
- (rawEncoding?.x?.axis !== undefined && rawEncoding.x.axis !== false) ||
312
- (bpEncoding?.x?.axis !== undefined && bpEncoding.x.axis !== false),
313
- yAxis:
314
- (rawEncoding?.y?.axis !== undefined && rawEncoding.y.axis !== false) ||
315
- (bpEncoding?.y?.axis !== undefined && bpEncoding.y.axis !== false),
316
- labels: rawSpec.labels !== undefined || bpForExplicit?.labels !== undefined,
317
- animation: rawSpec.animation !== undefined || bpForExplicit?.animation !== undefined,
318
- watermark: rawSpec.watermark !== undefined || bpForExplicit?.watermark !== undefined,
319
- crosshair: rawSpec.crosshair !== undefined || bpForExplicit?.crosshair !== undefined,
320
- };
321
- chartSpec = { ...chartSpec, userExplicit };
322
-
323
- if (overrides?.[breakpoint]) {
324
- const bp = overrides[breakpoint]!;
325
- if (bp.chrome) {
326
- chartSpec = {
327
- ...chartSpec,
328
- chrome: {
329
- ...chartSpec.chrome,
330
- ...(bp.chrome as NormalizedChartSpec['chrome']),
331
- },
332
- };
333
- }
334
- if (bp.labels !== undefined) {
335
- if (typeof bp.labels === 'boolean') {
336
- chartSpec = {
337
- ...chartSpec,
338
- labels: bp.labels
339
- ? { density: 'auto', format: '', prefix: '' }
340
- : { density: 'none', format: '', prefix: '' },
341
- };
342
- } else {
343
- chartSpec = {
344
- ...chartSpec,
345
- labels: {
346
- ...chartSpec.labels,
347
- ...(bp.labels as NormalizedChartSpec['labels']),
348
- },
349
- };
350
- }
351
- }
352
- if (bp.legend) {
353
- chartSpec = {
354
- ...chartSpec,
355
- legend: {
356
- ...chartSpec.legend,
357
- ...(bp.legend as NormalizedChartSpec['legend']),
358
- },
359
- };
360
- }
361
- if (bp.annotations) {
362
- chartSpec = {
363
- ...chartSpec,
364
- annotations: bp.annotations as NormalizedChartSpec['annotations'],
365
- };
366
- // User explicitly provided annotations at this breakpoint — override the
367
- // responsive strategy so they render inline instead of being stripped.
368
- strategy = { ...strategy, annotationPosition: 'inline' };
369
- }
370
- // New override branches for sparkline mode and related fields:
371
- if (bp.display !== undefined) {
372
- chartSpec = {
373
- ...chartSpec,
374
- display: bp.display as NormalizedChartSpec['display'],
375
- };
376
- }
377
- if (bp.encoding !== undefined) {
378
- // Merge encoding so a breakpoint can flip on/off encoding.x.axis or
379
- // encoding.y.axis (used by sparkline display mode to opt back in to
380
- // axes at a specific breakpoint). Channels merge per-key, and `axis`
381
- // and `scale` deep-merge one level so a breakpoint can set
382
- // `axis: { title: 'foo' }` without dropping the base spec's
383
- // `axis.tickCount` / `axis.format`.
384
- const bpEnc = bp.encoding as Record<string, Record<string, unknown> | undefined>;
385
- const mergedEncoding = { ...chartSpec.encoding } as Record<
386
- string,
387
- Record<string, unknown> | undefined
388
- >;
389
- const NESTED_CHANNEL_KEYS = ['axis', 'scale'];
390
- for (const channel of Object.keys(bpEnc)) {
391
- const baseCh = mergedEncoding[channel];
392
- const bpCh = bpEnc[channel];
393
- if (bpCh && baseCh) {
394
- const merged: Record<string, unknown> = { ...baseCh, ...bpCh };
395
- for (const key of NESTED_CHANNEL_KEYS) {
396
- const baseNested = baseCh[key];
397
- const bpNested = bpCh[key];
398
- if (
399
- baseNested &&
400
- bpNested &&
401
- typeof baseNested === 'object' &&
402
- typeof bpNested === 'object' &&
403
- !Array.isArray(baseNested) &&
404
- !Array.isArray(bpNested)
405
- ) {
406
- merged[key] = { ...baseNested, ...bpNested };
407
- }
408
- }
409
- mergedEncoding[channel] = merged;
410
- } else if (bpCh) {
411
- mergedEncoding[channel] = bpCh;
412
- }
413
- }
414
- chartSpec = {
415
- ...chartSpec,
416
- encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'],
417
- };
418
- }
419
- if (typeof bp.watermark === 'boolean') {
420
- // Update the resolved watermark value used downstream. ChartSpec carries
421
- // this in its normalized shape; the local `watermark` variable controls
422
- // chrome computation and rendering.
423
- watermark = bp.watermark;
424
- chartSpec = { ...chartSpec, watermark };
425
- }
426
- }
427
-
428
- // Sparkline mode: default labels off. Mark renderers draw value labels per
429
- // labels.density (default 'auto'), which fills tiny sparklines with text and
430
- // is never what you want. Explicit user labels at any level wins via
431
- // userExplicit.labels.
432
- if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.labels) {
433
- chartSpec = {
434
- ...chartSpec,
435
- labels: { ...chartSpec.labels, density: 'none' },
436
- };
437
- }
438
-
439
- // Resolve animation spec. Breakpoint override wins over base spec (matching
440
- // chrome, labels, legend, and annotation override precedence).
441
- // Precedence rule for sparkline mode: an explicit user animation at ANY
442
- // level (top-level OR breakpoint) always wins, regardless of display mode.
443
- // resolveAnimation handles the explicit-user value; the sparkline default-off
444
- // behavior is applied below when no explicit value exists.
445
- let rawAnimationSpec = ((overrides?.[breakpoint] as Record<string, unknown> | undefined)
446
- ?.animation ?? rawSpec.animation) as AnimationSpec | undefined;
447
- if (rawAnimationSpec === undefined && chartSpec.display === 'sparkline') {
448
- // Sparkline mode: animation defaults to false. User-explicit (top OR bp)
449
- // already short-circuits this branch via userExplicit.animation.
450
- rawAnimationSpec = false;
451
- }
452
- // Sparkline mode: when animation is on but the user didn't specify duration,
453
- // bump to 1100ms so the line/area reveal feels paced rather than mechanical.
454
- // The CSS override pairs this with an expo-out easing curve. AnimationConfig
455
- // nests duration under `enter`, so we set it there.
456
- if (
457
- chartSpec.display === 'sparkline' &&
458
- rawAnimationSpec !== false &&
459
- rawAnimationSpec !== undefined
460
- ) {
461
- const SPARK_DURATION = 1100;
462
- if (rawAnimationSpec === true) {
463
- rawAnimationSpec = { enter: { duration: SPARK_DURATION } } as AnimationSpec;
464
- } else if (typeof rawAnimationSpec === 'object') {
465
- const cfg = rawAnimationSpec as { enter?: unknown; annotationDelay?: number };
466
- const enter = cfg.enter;
467
- if (enter === undefined || enter === true) {
468
- rawAnimationSpec = {
469
- ...cfg,
470
- enter: { duration: SPARK_DURATION },
471
- } as AnimationSpec;
472
- } else if (
473
- typeof enter === 'object' &&
474
- enter !== null &&
475
- (enter as { duration?: number }).duration === undefined
476
- ) {
477
- rawAnimationSpec = {
478
- ...cfg,
479
- enter: { ...(enter as object), duration: SPARK_DURATION },
480
- } as AnimationSpec;
481
- }
482
- }
483
- }
484
- const resolvedAnimation = resolveAnimation(rawAnimationSpec);
485
-
486
- // Crosshair: explicit user value at any level wins. Default is ON for line
487
- // and area marks in full mode (the hover guideline + per-series tooltip is
488
- // the standard interaction pattern for time-series charts). Sparkline mode
489
- // defaults off. The value is plumbed through ChartLayout so the renderer
490
- // doesn't need to re-inspect the raw spec.
491
- const rawCrosshair = (bpForExplicit?.crosshair ?? rawSpec.crosshair) as boolean | undefined;
492
- const isLineOrAreaMark = chartSpec.markType === 'line' || chartSpec.markType === 'area';
493
- const crosshairDefault = chartSpec.display === 'sparkline' ? false : isLineOrAreaMark;
494
- const crosshair = chartSpec.userExplicit.crosshair ? rawCrosshair === true : crosshairDefault;
495
-
496
- // Watermark default-off in sparkline mode unless user-explicit.
497
- if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) {
498
- watermark = false;
499
- chartSpec = { ...chartSpec, watermark: false };
500
- }
501
-
502
- // Resolve theme: merge spec-level theme with options-level overrides
503
- const mergedThemeConfig = options.theme
504
- ? { ...chartSpec.theme, ...options.theme }
505
- : chartSpec.theme;
506
- let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
507
- if (options.darkMode) {
508
- theme = adaptTheme(theme);
509
- }
510
-
511
- // Sparkline mode: inject smart defaults that depend on the resolved theme
512
- // (trend-aware color, area gradient, default endpoint dot, pill cornerRadius
513
- // for bars). Each default backs off when the user has set the corresponding
514
- // field explicitly. Must run AFTER theme resolution (color depends on theme)
515
- // and BEFORE computeDimensions (which reads markDef.point for sparkline padding).
516
- if (chartSpec.display === 'sparkline') {
517
- chartSpec = applySparklineDefaults(chartSpec, theme);
518
- }
519
-
520
- // ---------------------------------------------------------------------------
521
- // Data filtering: move BEFORE layout plan so the plan measures real labels
522
- // from the filtered data set. Legend retains all series via chartSpec
523
- // (unfiltered); scales and marks use renderSpec (filtered).
524
- // ---------------------------------------------------------------------------
525
- let renderData = chartSpec.data;
526
-
527
- // Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
528
- if (
529
- chartSpec.hiddenSeries.length > 0 &&
530
- chartSpec.encoding.color &&
531
- 'field' in chartSpec.encoding.color
532
- ) {
533
- const colorField = chartSpec.encoding.color.field;
534
- const hiddenSet = new Set(chartSpec.hiddenSeries);
535
- renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
536
- }
537
-
538
- // Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
539
- renderData = filterClippedDomains(renderData, chartSpec.encoding);
540
-
541
- // Build a filtered spec for scales and marks, keeping all other properties intact
542
- let renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
543
-
544
- // Lock the color scale domain to the unfiltered series list so palette
545
- // assignments stay stable across legend toggles. Without this, hiding a
546
- // series shrinks the ordinal scale's domain and shifts every remaining
547
- // series down one palette index -- the visible lines would mismatch the
548
- // legend swatches that still represent the original assignment.
549
- // Skipped when the user already supplied an explicit domain.
550
- const colorEnc = chartSpec.encoding.color;
551
- if (
552
- chartSpec.hiddenSeries.length > 0 &&
553
- colorEnc &&
554
- 'field' in colorEnc &&
555
- colorEnc.type !== 'quantitative' &&
556
- colorEnc.scale?.domain == null
557
- ) {
558
- const colorField = colorEnc.field;
559
- const stableDomain = Array.from(new Set(chartSpec.data.map((row) => String(row[colorField]))));
560
- renderSpec = {
561
- ...renderSpec,
562
- encoding: {
563
- ...renderSpec.encoding,
564
- color: {
565
- ...colorEnc,
566
- scale: { ...(colorEnc.scale ?? {}), domain: stableDomain },
567
- },
568
- },
569
- };
570
- }
571
-
572
- // ---------------------------------------------------------------------------
573
- // Faceted compilation: when encoding.facet is present, take the faceted path
574
- // ---------------------------------------------------------------------------
575
- const facetChannel = renderSpec.encoding.facet;
576
- if (facetChannel?.field) {
577
- return compileFaceted(
578
- chartSpec,
579
- renderSpec,
580
- facetChannel,
581
- options,
582
- theme,
583
- strategy,
584
- watermark,
585
- resolvedAnimation,
586
- crosshair,
587
- spec,
588
- optionsInput,
589
- );
590
- }
591
-
592
- // ---------------------------------------------------------------------------
593
- // Layout plan: measure real labels, freeze gutter + chrome + legend content
594
- // ---------------------------------------------------------------------------
595
- const measure = createMeasureFn(options.measureText);
596
- const plan = resolveLayoutPlan(
597
- chartSpec,
598
- renderSpec,
599
- options,
600
- theme,
601
- strategy,
602
- watermark,
603
- measure,
604
- );
605
-
606
- // Compute dimensions (accounts for chrome + legend + responsive strategy).
607
- // The plan provides measured leftGutter, xAxisExtent, and chrome so
608
- // dimensions.ts skips its guess blocks.
609
- const legendStub = placeLegend(
610
- plan.legendContent,
611
- { x: 0, y: 0, width: options.width, height: options.height },
612
- options.width,
613
- theme,
614
- plan.xAxisExtent,
615
- );
616
- const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
617
- // A frozen area (set by compileLayer for leaf layers) overrides the computed
618
- // one so all layers share the primary layout's coordinate space.
619
- const chartArea = options.frozenChartArea ?? dims.chartArea;
620
-
621
- // Place legend in its final position relative to the computed chart area.
622
- // effectiveAxisGap lifts a top legend above the inline-tick overhang zone
623
- // the margin stack reserved below it.
624
- const finalLegend = placeLegend(
625
- plan.legendContent,
626
- chartArea,
627
- options.width,
628
- theme,
629
- plan.xAxisExtent,
630
- dims.effectiveAxisGap,
631
- );
632
-
633
- // Every legend on the chart. The color legend is primary (and stays in the
634
- // singular `legend` slot); a size legend joins it when a quantitative `size`
635
- // channel is encoded. Consumers that reserve space or hit-test must iterate
636
- // this rather than read `legend` -- see ChartLayout.legends.
637
- const sizeLegend = plan.sizeLegendContent
638
- ? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend)
639
- : null;
640
- const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])];
641
-
642
- // Inline y-axis labels: inset the x-scale range start by the widest tick
643
- // label width so data marks start to the right of the labels. The plan
644
- // already measured the real tick labels.
645
- let scaleArea = chartArea;
646
- if (plan.inlineYLabelInset > 0 && plan.inlineYLabelInset < chartArea.width) {
647
- scaleArea = {
648
- x: chartArea.x + plan.inlineYLabelInset,
649
- y: chartArea.y,
650
- width: chartArea.width - plan.inlineYLabelInset,
651
- height: chartArea.height,
652
- };
653
- }
654
-
655
- // Compute scales
656
- const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
657
-
658
- // Update color scale to use theme palette (only when user hasn't provided an explicit range).
659
- // When highlight is active, assign palette colors to highlighted series and mute the rest.
660
- applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
661
-
662
- // INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
663
- // theme context. Do not move into computeScales (would require threading theme through).
664
- // fill wins for bar/area/arc marks; stroke wins for line marks (the stroke IS the color).
665
- scales.defaultColor =
666
- chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
667
-
668
- // Dev-mode WCAG contrast diagnostics. Host-gated via options.dev (never an
669
- // env sniff; the engine is isomorphic). Advisory console.warn only.
670
- if (options.dev) {
671
- emitSpecWarnings(collectContrastWarnings(scales, chartSpec.markType, theme), options.onWarn);
672
- }
673
-
674
- // Axisless marks (arc, waffle, calendar, parliament) don't use axes or
675
- // gridlines: arcs and parliament hemicycles are radial, waffles and calendars
676
- // compute their own grid geometry.
677
- const isRadial = isAxislessMark(chartSpec.markType);
678
-
679
- // Compute axes (skip for radial charts).
680
- // Sparkline mode skips axes by default unless the user explicitly opted into
681
- // an axis on a specific channel.
682
- const skipX = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.xAxis;
683
- const skipY = chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
684
- const axes = isRadial
685
- ? { x: undefined, y: undefined }
686
- : computeAxes(scales, chartArea, strategy, theme, options.measureText, {
687
- data: renderSpec.data,
688
- encoding: renderSpec.encoding as Encoding,
689
- skipX,
690
- skipY,
691
- markType: chartSpec.markType,
692
- totalWidth: options.width,
693
- precomputedYTicks: plan.yTickValues.length > 0 ? plan.yTickValues : undefined,
694
- });
695
-
696
- // Intentional post-hoc mutation: axes must resolve before we know the x-axis extent.
697
- const xAxisExtent = axes.x?.extent ?? 0;
698
- dims.chrome.bottomAnchorY = chartArea.y + chartArea.height + xAxisExtent;
699
-
700
- // INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
701
- // axes.y.gridlines off the same object. Do not introduce a copy-on-write.
702
- if (!isRadial) {
703
- computeGridlines(axes, chartArea);
704
- }
705
-
706
- // Get chart renderer and compute marks (using filtered data).
707
- const rendererKey = resolveRendererKey(
708
- renderSpec.markType,
709
- renderSpec.encoding,
710
- renderSpec.markDef,
711
- );
712
- const renderer = getChartRenderer(rendererKey);
713
- const marks: Mark[] = renderer
714
- ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
715
- : [];
716
-
717
- // Opt-in per-series fill patterns (mark.fillPattern: 'auto'). Mutates
718
- // filled marks in place; assignment is deterministic and theme-aware.
719
- applyFillPatterns(marks, chartSpec.markDef, theme);
720
-
721
- // Resolve "you draw it" geometry (pixel fromX, x-sample positions, target
722
- // line color/key). Reads marks so it must run after they're computed.
723
- const xFieldForYdi =
724
- chartSpec.encoding.x && 'field' in chartSpec.encoding.x
725
- ? (chartSpec.encoding.x.field as string | undefined)
726
- : undefined;
727
- const youDrawIt = resolveYouDrawIt(chartSpec.youDrawIt, marks, scales, chartArea, xFieldForYdi);
728
-
729
- // Compute the right-side endpoint labels column for multi-series line/area
730
- // charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
731
- // dimensions.ts already reserved the right margin via predictEndpointLabelsWidth.
732
- const endpointLabels = computeEndpointLabels(
733
- chartSpec,
734
- marks,
735
- theme,
736
- chartArea,
737
- strategy,
738
- plan.endpointLabelsDemoted,
739
- );
740
-
741
- // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
742
- const obstacles: PlacementObstacle[] = [];
743
- if (finalLegend.bounds.width > 0) {
744
- obstacles.push({ ...finalLegend.bounds, kind: 'legend' });
745
- }
746
- obstacles.push(...computeMarkObstacles(marks, scales));
747
-
748
- // Add visible data label bounds as obstacles so annotations avoid overlapping them
749
- for (const mark of marks) {
750
- if ('label' in mark && mark.label?.visible) {
751
- obstacles.push({ ...computeLabelBounds(mark.label), kind: 'data-label' });
752
- }
753
- }
754
-
755
- // Add brand watermark as an obstacle so annotations avoid overlapping it.
756
- const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
757
- if (watermarkRect) obstacles.push({ ...watermarkRect, kind: 'watermark' });
758
-
759
- // Add endpoint label bounds as obstacles
760
- if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
761
- obstacles.push({ ...endpointLabels.bounds, kind: 'endpoint-label' });
762
- }
763
-
764
- const annotationMeasure: AnnotationMeasureTextFn = options.measureText
765
- ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
766
- : heuristicMeasure;
767
-
768
- let annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
769
- scales,
770
- chartArea,
771
- strategy,
772
- isDark: theme.isDark,
773
- obstacles,
774
- svg: { width: dims.total.width, height: dims.total.height },
775
- measure: annotationMeasure,
776
- fontFamily: theme.fonts.family,
777
- autoThin: chartSpec.autoThin,
778
- });
779
-
780
- // Auto-thinning: demote overlapping text annotations to footnote markers.
781
- // Pass the full spec annotations (not filtered to text-only) so index
782
- // alignment with the resolved array is preserved.
783
- let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined;
784
- if (chartSpec.autoThin && annotations.length > 1) {
785
- const result = thinAnnotations(
786
- annotations,
787
- chartSpec.annotations,
788
- annotationMeasure,
789
- chartArea,
790
- { x: 0, y: 0, width: dims.total.width, height: dims.total.height },
791
- );
792
- annotations = result.annotations;
793
- if (result.footnotes.length > 0) {
794
- footnotes = result.footnotes;
795
-
796
- // The footnote list renders below the plot, but nothing reserved space for
797
- // it — thinning only just now discovered how many lines there are. Recompile
798
- // with that band carved out of the bottom margin so the list can't overrun
799
- // the source row and the brand watermark.
800
- //
801
- // Reserving shrinks the plot, which can push a further label out of bounds
802
- // and grow the list by one, so re-reserve until the count settles. The
803
- // count only ever grows (a smaller plot never keeps more labels inline) and
804
- // is bounded by the annotation count, so this terminates; the explicit cap
805
- // is a belt-and-braces guard against a measurement quirk oscillating.
806
- //
807
- // A frozen area short-circuits all of this. `compileLayer` pins every
808
- // leaf to the primary layout's coordinate space, and `chartArea` above
809
- // takes `frozenChartArea` over anything `computeDimensions` produces --
810
- // so a grown bottom margin is computed and then thrown away. The
811
- // recursion would re-run the whole compile up to MAX_FOOTNOTE_PASSES
812
- // times and return the identical layout. Skip it rather than pay for it.
813
- const reserve = footnoteBandHeight(footnotes.length, theme);
814
- const currentReserve = options.footnoteReserve ?? 0;
815
- if (
816
- !options.frozenChartArea &&
817
- reserve > currentReserve &&
818
- (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES
819
- ) {
820
- return compileChart(spec, {
821
- ...optionsInput,
822
- footnoteReserve: reserve,
823
- footnotePass: (options.footnotePass ?? 0) + 1,
824
- });
825
- }
826
- }
827
- }
828
-
829
- // Compute tooltip descriptors from marks and encoding
830
- const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
831
-
832
- // Compute accessibility. An author-provided description (a11y.description,
833
- // or top-level `description` folded in by the sugar expansion) replaces the
834
- // auto-generated alt text; auto-generation stays the fallback.
835
- const altText =
836
- chartSpec.a11y?.description ??
837
- generateAltText(
838
- {
839
- mark: chartSpec.markType,
840
- data: chartSpec.data,
841
- encoding: chartSpec.encoding,
842
- chrome: chartSpec.chrome,
843
- } as ChartSpec,
844
- chartSpec.data,
845
- );
846
- const dataTableFallback = generateDataTable(
847
- {
848
- mark: chartSpec.markType,
849
- data: chartSpec.data,
850
- encoding: chartSpec.encoding,
851
- } as ChartSpec,
852
- chartSpec.data,
853
- );
854
-
855
- // Assign animationIndex for stagger ordering when animation is enabled
856
- assignAnimationIndices(marks, resolvedAnimation);
857
-
858
- const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
859
-
860
- return {
861
- area: chartArea,
862
- chrome,
863
- metrics: dims.metrics,
864
- ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
865
- ...(youDrawIt ? { youDrawIt } : {}),
866
- axes: {
867
- x: axes.x,
868
- y: axes.y,
869
- },
870
- marks,
871
- annotations,
872
- legend: finalLegend,
873
- legends: allLegends,
874
- ...(endpointLabels.entries.length > 0 ? { endpointLabels } : {}),
875
- tooltipDescriptors,
876
- a11y: {
877
- altText,
878
- dataTableFallback,
879
- role: 'img',
880
- keyboardNavigable: marks.length > 0,
881
- ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
882
- },
883
- theme,
884
- dimensions: {
885
- width: options.width,
886
- height: options.height,
887
- },
888
- animation: resolvedAnimation,
889
- watermark,
890
- display: chartSpec.display,
891
- crosshair,
892
- measureText: options.measureText,
893
- };
894
- }
895
-
896
- // ---------------------------------------------------------------------------
897
- // Faceted (small-multiples) compilation
898
- // ---------------------------------------------------------------------------
899
-
900
- function compileFaceted(
901
- chartSpec: NormalizedChartSpec,
902
- renderSpec: NormalizedChartSpec,
903
- facetChannel: FacetChannel,
904
- options: CompileOptions,
905
- theme: ResolvedTheme,
906
- strategy: LayoutStrategy,
907
- watermark: boolean,
908
- resolvedAnimation: ResolvedAnimation | undefined,
909
- crosshair: boolean,
910
- rawSpec: unknown,
911
- optionsInput: CompileOptions,
912
- ): ChartLayout {
913
- const measure = createMeasureFn(options.measureText);
914
-
915
- // Strip facet from encoding before passing to per-panel pipeline (panels
916
- // don't know they're faceted -- they compile as regular single charts).
917
- const panelEncoding = { ...renderSpec.encoding };
918
- delete (panelEncoding as Record<string, unknown>).facet;
919
- const panelSpec = { ...renderSpec, encoding: panelEncoding };
920
- const panelChartSpec = { ...chartSpec, encoding: panelEncoding };
921
-
922
- // Partition data by facet field
923
- const groups = groupByField(renderSpec.data, facetChannel.field);
924
- const facetValues = Array.from(groups.keys());
925
- if (facetChannel.sort !== null) {
926
- const dir = facetChannel.sort === 'descending' ? -1 : 1;
927
- facetValues.sort((a, b) => dir * a.localeCompare(b));
928
- }
929
-
930
- // Compute figure-level chrome and dimensions using the full dataset
931
- // (no facet channel so it compiles normally). We use a representative
932
- // single-panel spec to get chrome + legend measurements.
933
- const plan = resolveLayoutPlan(
934
- panelChartSpec,
935
- panelSpec,
936
- options,
937
- theme,
938
- strategy,
939
- watermark,
940
- measure,
941
- );
942
- const legendStub = placeLegend(
943
- plan.legendContent,
944
- { x: 0, y: 0, width: options.width, height: options.height },
945
- options.width,
946
- theme,
947
- plan.xAxisExtent,
948
- );
949
- const dims = computeDimensions(
950
- panelChartSpec,
951
- options,
952
- legendStub,
953
- theme,
954
- strategy,
955
- watermark,
956
- plan,
957
- );
958
- const chartArea = dims.chartArea;
959
-
960
- // Same effectiveAxisGap threading as the regular (non-faceted) path.
961
- const finalLegend = placeLegend(
962
- plan.legendContent,
963
- chartArea,
964
- options.width,
965
- theme,
966
- plan.xAxisExtent,
967
- dims.effectiveAxisGap,
968
- );
969
-
970
- // Estimate y-axis gutter width (used as left reservation for outer axes)
971
- const leftReservation = plan.leftGutter;
972
- // Estimate x-axis extent (used as bottom reservation for outer axes)
973
- const bottomReservation = plan.xAxisExtent;
974
-
975
- // Compute facet grid geometry
976
- const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
977
- left: leftReservation,
978
- bottom: bottomReservation,
979
- });
980
-
981
- // If panels are too short, grow the compile height and restart (once).
982
- if (
983
- !optionsInput.facetHeightGrown &&
984
- grid.panels.length > 0 &&
985
- grid.panels[0].area.height < MIN_PANEL_HEIGHT
986
- ) {
987
- const neededChartHeight = facetMinHeight(facetValues.length, grid.columns);
988
- const heightGrowth = neededChartHeight - chartArea.height;
989
- if (heightGrowth > 0) {
990
- return compileChart(rawSpec, {
991
- ...optionsInput,
992
- height: options.height + heightGrowth,
993
- facetHeightGrown: true,
994
- });
995
- }
996
- }
997
-
998
- // Resolve scale for determining if we use 'independent' or 'shared'
999
- const resolveConfig = chartSpec.resolve;
1000
- const yResolve = resolveConfig?.scale?.y ?? 'shared';
1001
- const xResolve = resolveConfig?.scale?.x ?? 'shared';
1002
-
1003
- // Compute shared scale domains from the full dataset (when shared)
1004
- const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
1005
- const sharedXDomain =
1006
- xResolve === 'shared' && fullScales.x ? fullScales.x.scale.domain() : undefined;
1007
- const sharedYDomain =
1008
- yResolve === 'shared' && fullScales.y ? fullScales.y.scale.domain() : undefined;
1009
-
1010
- const isRadial = isAxislessMark(chartSpec.markType);
1011
- const rendererKey = resolveRendererKey(
1012
- renderSpec.markType,
1013
- panelEncoding as Encoding,
1014
- renderSpec.markDef,
1015
- );
1016
- const renderer = getChartRenderer(rendererKey);
1017
-
1018
- const allMarks: Mark[] = [];
1019
- const panelLayouts: FacetPanelLayout[] = [];
1020
- // Footnotes pool across panels into one figure-level list (the renderer draws
1021
- // a single list below the grid from `chrome.footnotes`), so the numbering is
1022
- // continuous and each panel's markers index into it.
1023
- const allFootnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] = [];
1024
- const footnoteNumberBySpecIndex = new Map<number, number>();
1025
-
1026
- for (const gridPanel of grid.panels) {
1027
- const panelData = groups.get(gridPanel.key) ?? [];
1028
- const panelSpecLocal = { ...panelSpec, data: panelData };
1029
-
1030
- // Inject shared domains via scale.domain on the encoding channels
1031
- let panelEnc = { ...panelEncoding } as Record<string, unknown>;
1032
- if (sharedXDomain && panelEnc.x) {
1033
- const xCh = panelEnc.x as Record<string, unknown>;
1034
- panelEnc = {
1035
- ...panelEnc,
1036
- x: { ...xCh, scale: { ...((xCh.scale as object) ?? {}), domain: sharedXDomain } },
1037
- };
1038
- }
1039
- if (sharedYDomain && panelEnc.y) {
1040
- const yCh = panelEnc.y as Record<string, unknown>;
1041
- panelEnc = {
1042
- ...panelEnc,
1043
- y: { ...yCh, scale: { ...((yCh.scale as object) ?? {}), domain: sharedYDomain } },
1044
- };
1045
- }
1046
- const panelSpecWithDomains = {
1047
- ...panelSpecLocal,
1048
- encoding: panelEnc as unknown as typeof panelSpec.encoding,
1049
- };
1050
-
1051
- // Compute scales for this panel's area
1052
- const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
1053
- applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme, renderSpec.highlight);
1054
- panelScales.defaultColor =
1055
- chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
1056
-
1057
- // Dev-mode contrast diagnostics: panels share one palette resolution, so
1058
- // checking the first panel covers the figure without repeating warnings.
1059
- if (options.dev && panelLayouts.length === 0) {
1060
- emitSpecWarnings(
1061
- collectContrastWarnings(panelScales, chartSpec.markType, theme),
1062
- options.onWarn,
1063
- );
1064
- }
1065
-
1066
- const isLeftCol = gridPanel.col === 0;
1067
-
1068
- const panelAxes = isRadial
1069
- ? { x: undefined, y: undefined }
1070
- : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
1071
- data: panelData,
1072
- encoding: panelSpecWithDomains.encoding as Encoding,
1073
- markType: chartSpec.markType,
1074
- totalWidth: gridPanel.area.width,
1075
- });
1076
-
1077
- if (!isRadial) {
1078
- computeGridlines(panelAxes, gridPanel.area);
1079
- }
1080
-
1081
- // Strip y-axis tick labels from non-leftmost panels (gridlines stay).
1082
- // X-axis labels show on every panel for readability.
1083
- if (yResolve === 'shared' && !isLeftCol && panelAxes.y) {
1084
- panelAxes.y = {
1085
- ...panelAxes.y,
1086
- ticks: [],
1087
- label: undefined,
1088
- domainLine: false,
1089
- tickMarks: false,
1090
- };
1091
- }
1092
-
1093
- // Compute marks for this panel
1094
- const panelMarks: Mark[] = renderer
1095
- ? renderer(
1096
- panelSpecWithDomains,
1097
- panelScales,
1098
- gridPanel.area,
1099
- strategy,
1100
- theme,
1101
- gridPanel.area.width,
1102
- )
1103
- : [];
1104
-
1105
- // Compute annotations for this panel
1106
- const annotationMeasure: AnnotationMeasureTextFn = options.measureText
1107
- ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
1108
- : heuristicMeasure;
1109
- let panelAnnotations = computeAnnotations(panelSpecWithDomains, {
1110
- scales: panelScales,
1111
- chartArea: gridPanel.area,
1112
- strategy,
1113
- isDark: theme.isDark,
1114
- obstacles: [],
1115
- svg: { width: options.width, height: options.height },
1116
- measure: annotationMeasure,
1117
- fontFamily: theme.fonts.family,
1118
- autoThin: panelSpecWithDomains.autoThin,
1119
- });
1120
-
1121
- // Auto-thinning, per panel. A panel is its own little chart: the box a label
1122
- // must stay inside is the panel, not the figure. Thinning against the whole
1123
- // SVG (what the single-chart path passes) would let a label from the left
1124
- // column sprawl across its neighbour and still count as "fits".
1125
- //
1126
- // The spec's annotations resolve into *every* panel, so the same annotation
1127
- // can demote in more than one. The footnote list is figure-level and its text
1128
- // is per-spec-annotation, so number by spec index and reuse that number in
1129
- // every panel that demoted it -- otherwise the list repeats itself once per
1130
- // panel.
1131
- if (chartSpec.autoThin && panelAnnotations.length > 1) {
1132
- const thinned = thinAnnotations(
1133
- panelAnnotations,
1134
- chartSpec.annotations,
1135
- annotationMeasure,
1136
- gridPanel.area,
1137
- gridPanel.area,
1138
- );
1139
- panelAnnotations = thinned.annotations;
1140
- // `thinAnnotations` numbers footnotes 1..n *within the panel it was given*,
1141
- // so restamp each demoted annotation with its figure-level number. Take the
1142
- // text from `thinned.footnotes` (the 1-based panel-local index it just
1143
- // assigned) rather than re-reading `label.text`: thinning owns that string,
1144
- // and re-deriving it here would be a second producer to drift out of sync.
1145
- //
1146
- // Key on `specIndex`, NOT on position: with `resolve.scale.x: 'independent'`
1147
- // each panel derives its own domain, so an annotation can resolve in one
1148
- // panel and be dropped in another. Position i then means a different spec
1149
- // annotation in each panel, and the markers point at other panels' text.
1150
- for (const a of panelAnnotations) {
1151
- const panelIndex = a.footnoteIndex;
1152
- if (panelIndex == null || a.specIndex === undefined) continue;
1153
- let num = footnoteNumberBySpecIndex.get(a.specIndex);
1154
- if (num === undefined) {
1155
- num = allFootnotes.length + 1;
1156
- footnoteNumberBySpecIndex.set(a.specIndex, num);
1157
- allFootnotes.push({
1158
- index: num,
1159
- text: thinned.footnotes[panelIndex - 1]?.text ?? '',
1160
- });
1161
- }
1162
- a.footnoteIndex = num;
1163
- }
1164
- }
1165
-
1166
- allMarks.push(...panelMarks);
1167
-
1168
- panelLayouts.push({
1169
- key: gridPanel.key,
1170
- area: gridPanel.area,
1171
- marks: panelMarks,
1172
- axes: { x: panelAxes.x, y: panelAxes.y },
1173
- annotations: panelAnnotations,
1174
- header: {
1175
- text: gridPanel.key,
1176
- x: gridPanel.headerPos.x,
1177
- y: gridPanel.headerPos.y,
1178
- fontSize: theme.fonts.sizes.body,
1179
- fontWeight: theme.fonts.weights.medium,
1180
- },
1181
- });
1182
- }
1183
-
1184
- // The footnote list renders below the grid, but nothing reserved space for it
1185
- // -- thinning only just now discovered how many lines there are. Recompile with
1186
- // that band carved out of the bottom margin, same contract as the single-chart
1187
- // path (see the matching block in compileChart). computeDimensions applies
1188
- // options.footnoteReserve generically, so the facet grid shrinks with it.
1189
- //
1190
- // A frozen area short-circuits this, same as the single-chart path: compileLayer
1191
- // pins every leaf to the primary layout's coordinate space, so the grown bottom
1192
- // margin is computed and then thrown away. Recursing would re-run the whole
1193
- // compile up to MAX_FOOTNOTE_PASSES times for an identical layout.
1194
- if (allFootnotes.length > 0 && !options.frozenChartArea) {
1195
- const reserve = footnoteBandHeight(allFootnotes.length, theme);
1196
- const currentReserve = options.footnoteReserve ?? 0;
1197
- if (reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) {
1198
- return compileChart(rawSpec, {
1199
- ...optionsInput,
1200
- footnoteReserve: reserve,
1201
- footnotePass: (options.footnotePass ?? 0) + 1,
1202
- });
1203
- }
1204
- }
1205
-
1206
- // Compute tooltip descriptors from all marks across panels
1207
- const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
1208
-
1209
- // Opt-in per-series fill patterns across all panels at once so a series
1210
- // gets the same pattern in every panel.
1211
- applyFillPatterns(allMarks, chartSpec.markDef, theme);
1212
-
1213
- // A11y: mention faceting in alt text. An author description is used
1214
- // verbatim (the author already describes the whole figure).
1215
- const panelCount = facetValues.length;
1216
- const baseAltText = generateAltText(
1217
- {
1218
- mark: chartSpec.markType,
1219
- data: chartSpec.data,
1220
- encoding: chartSpec.encoding,
1221
- chrome: chartSpec.chrome,
1222
- } as ChartSpec,
1223
- chartSpec.data,
1224
- );
1225
- const altText =
1226
- chartSpec.a11y?.description ??
1227
- `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
1228
-
1229
- const dataTableFallback = generateDataTable(
1230
- {
1231
- mark: chartSpec.markType,
1232
- data: chartSpec.data,
1233
- encoding: chartSpec.encoding,
1234
- } as ChartSpec,
1235
- chartSpec.data,
1236
- );
1237
-
1238
- // Assign animation indices across all panels
1239
- assignAnimationIndices(allMarks, resolvedAnimation);
1240
-
1241
- // Figure-level axes are null (axes live in panels)
1242
- // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav)
1243
- return {
1244
- area: chartArea,
1245
- chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: allFootnotes } : dims.chrome,
1246
- metrics: dims.metrics,
1247
- ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}),
1248
- axes: { x: undefined, y: undefined },
1249
- marks: allMarks,
1250
- annotations: [],
1251
- legend: finalLegend,
1252
- // Facets share one color legend across the grid; the size channel isn't
1253
- // supported in facet specs, so there is no second legend to carry.
1254
- legends: [finalLegend],
1255
- tooltipDescriptors,
1256
- a11y: {
1257
- altText,
1258
- dataTableFallback,
1259
- role: 'img',
1260
- keyboardNavigable: allMarks.length > 0,
1261
- ...(chartSpec.a11y?.hidden ? { hidden: true } : {}),
1262
- },
1263
- theme,
1264
- dimensions: { width: options.width, height: options.height },
1265
- animation: resolvedAnimation,
1266
- watermark,
1267
- display: chartSpec.display,
1268
- crosshair,
1269
- measureText: options.measureText,
1270
- facet: {
1271
- panels: panelLayouts,
1272
- facetField: facetChannel.field,
1273
- columns: grid.columns,
1274
- sharedScales: yResolve === 'shared' && xResolve === 'shared',
1275
- },
1276
- };
1277
- }
1278
-
1279
- // ---------------------------------------------------------------------------
1280
- // Layer compilation
1281
- // ---------------------------------------------------------------------------
1282
-
1283
- export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLayout {
1284
- // Expand VL-idiom sugar on the layer spec and every child up front. Leaf
1285
- // compiles re-run the (idempotent) expansion after encoding inheritance;
1286
- // warning triggers are stripped or stamped here so each warns exactly once.
1287
- const sugarWarnings: string[] = [];
1288
- const expanded = expandSpecSugar(
1289
- spec as unknown as Record<string, unknown>,
1290
- sugarWarnings,
1291
- ) as unknown as LayerSpec;
1292
- emitSpecWarnings(sugarWarnings, options.onWarn);
1293
- const resolvedOptions = applySpecSize(expanded, options);
1294
- return compileLayerImpl(expanded, resolvedOptions, compileChart);
1295
- }
1296
-
1297
- // ---------------------------------------------------------------------------
1298
- // Table compilation
1299
- // ---------------------------------------------------------------------------
1300
-
1301
- /**
1302
- * Compile a table spec into a TableLayout.
1303
- *
1304
- * Validates and normalizes the spec, resolves the theme, then delegates
1305
- * to compileTableLayout for the full pipeline: column resolution, search,
1306
- * sort, pagination, cell formatting, and visual enhancements.
1307
- *
1308
- * @param spec - Raw table spec.
1309
- * @param options - Compile options with sort, search, pagination state.
1310
- * @returns Fully resolved TableLayout.
1311
- */
1312
- export function compileTable(spec: unknown, options: CompileTableOptions): TableLayout {
1313
- const { spec: normalized } = compileSpec(spec);
1314
-
1315
- const normType =
1316
- 'type' in normalized ? (normalized as unknown as Record<string, unknown>).type : undefined;
1317
- if (normType !== 'table') {
1318
- throw new Error(`compileTable received a non-table spec. Use compileChart instead.`);
1319
- }
1320
-
1321
- const tableSpec = normalized as NormalizedTableSpec;
1322
-
1323
- // Resolve theme: merge spec-level theme with options-level overrides
1324
- const mergedThemeConfig = options.theme
1325
- ? { ...tableSpec.theme, ...options.theme }
1326
- : tableSpec.theme;
1327
- let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
1328
- if (options.darkMode) {
1329
- theme = adaptTheme(theme);
1330
- }
1331
-
1332
- // Resolve watermark: spec-level wins, then options, then default true
1333
- const rawWatermark = (spec as Record<string, unknown>).watermark;
1334
- const watermark = rawWatermark !== undefined ? tableSpec.watermark : (options.watermark ?? true);
1335
-
1336
- return compileTableLayout({ ...tableSpec, watermark }, options, theme);
1337
- }
1338
-
1339
- // ---------------------------------------------------------------------------
1340
- // Graph compilation
1341
- // ---------------------------------------------------------------------------
1342
-
1343
- /**
1344
- * Compile a graph spec into a GraphCompilation.
1345
- *
1346
- * The graph pipeline resolves visual properties (size, color, stroke) for
1347
- * nodes and edges, assigns communities, and builds legend/tooltip/a11y data.
1348
- * Unlike charts, the output does NOT include x/y positions since the force
1349
- * simulation in the adapter handles layout at runtime.
1350
- *
1351
- * @param spec - Raw graph spec (validated and normalized internally).
1352
- * @param options - Compile options (width, height, theme, darkMode).
1353
- * @returns GraphCompilation with resolved visual properties and simulation config.
1354
- * @throws Error if spec is invalid or not a graph type.
1355
- */
1356
- export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
1357
- return compileGraphImpl(spec, options);
1358
- }
1359
-
1360
- // ---------------------------------------------------------------------------
1361
- // Sankey compilation
1362
- // ---------------------------------------------------------------------------
1363
-
1364
- /**
1365
- * Compile a sankey spec into a SankeyLayout.
1366
- *
1367
- * Takes a raw sankey spec, validates, normalizes, resolves theme and chrome,
1368
- * runs the d3-sankey layout algorithm, builds node/link marks with colors and
1369
- * labels, and returns a SankeyLayout ready for rendering.
1370
- *
1371
- * @param spec - Raw sankey spec (validated and normalized internally).
1372
- * @param options - Compile options (width, height, theme, darkMode).
1373
- * @returns SankeyLayout with computed positions and visual properties.
1374
- * @throws Error if spec is invalid or not a sankey type.
1375
- */
1376
- export function compileSankey(
1377
- spec: unknown,
1378
- options: CompileOptions,
1379
- ): import('@opendata-ai/openchart-core').SankeyLayout {
1380
- return compileSankeyImpl(spec, options);
1381
- }
1382
-
1383
- // ---------------------------------------------------------------------------
1384
- // TileMap compilation
1385
- // ---------------------------------------------------------------------------
1386
-
1387
- /**
1388
- * Compile a tilemap spec into a TileMapLayout.
1389
- *
1390
- * Takes a raw tilemap spec, validates, normalizes, resolves theme and chrome,
1391
- * computes tile positions, builds tile marks with colors and labels, and
1392
- * returns a TileMapLayout ready for rendering.
1393
- *
1394
- * @param spec - Raw tilemap spec (validated and normalized internally).
1395
- * @param options - Compile options (width, height, theme, darkMode).
1396
- * @returns TileMapLayout with computed positions and visual properties.
1397
- * @throws Error if spec is invalid or not a tilemap type.
1398
- */
1399
- export function compileTileMap(
1400
- spec: unknown,
1401
- options: CompileOptions,
1402
- ): import('@opendata-ai/openchart-core').TileMapLayout {
1403
- return compileTileMapImpl(spec, options);
1404
- }
1405
-
1406
- // ---------------------------------------------------------------------------
1407
- // Map compilation
1408
- // ---------------------------------------------------------------------------
1409
-
1410
- /**
1411
- * Compile a map spec into a MapLayout.
1412
- *
1413
- * Takes a raw map spec, validates, normalizes, resolves theme and chrome,
1414
- * projects geo features, joins data, builds feature marks with color fills,
1415
- * and returns a MapLayout ready for rendering.
1416
- *
1417
- * @param spec - Raw map spec (validated and normalized internally).
1418
- * @param options - Compile options (width, height, theme, darkMode).
1419
- * @returns MapLayout with computed positions and visual properties.
1420
- * @throws Error if spec is invalid or not a map type.
1421
- */
1422
- export function compileMap(
1423
- spec: unknown,
1424
- options: CompileOptions,
1425
- ): import('@opendata-ai/openchart-core').MapLayout {
1426
- return compileMapImpl(spec, options);
1427
- }
1428
-
1429
- // ---------------------------------------------------------------------------
1430
- // BarList compilation
1431
- // ---------------------------------------------------------------------------
1432
-
1433
- /**
1434
- * Compile a barlist spec into a BarListLayout.
1435
- *
1436
- * Takes a raw barlist spec, validates, normalizes, resolves theme and chrome,
1437
- * computes row layout with proportional bars, builds tooltips, and returns
1438
- * a BarListLayout ready for rendering.
1439
- *
1440
- * @param spec - Raw barlist spec (validated and normalized internally).
1441
- * @param options - Compile options (width, height, theme, darkMode).
1442
- * @returns BarListLayout with computed positions and visual properties.
1443
- * @throws Error if spec is invalid or not a barlist type.
1444
- */
1445
- export function compileBarList(
1446
- spec: unknown,
1447
- options: CompileOptions,
1448
- ): import('@opendata-ai/openchart-core').BarListLayout {
1449
- return compileBarListImpl(spec, options);
1450
- }