@opendata-ai/openchart-engine 8.0.0-rc.8 → 8.0.0

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 +149 -12
  2. package/dist/index.js +894 -171
  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 -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 -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 -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 -626
  133. package/src/compile/size-scale.ts +0 -141
  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 -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 -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 -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 -2041
  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 -1004
  185. package/src/geo/compile-map.ts +0 -1341
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  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 -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
@@ -1,758 +0,0 @@
1
- /**
2
- * Pre-validation spec sugar expansion.
3
- *
4
- * Accepts common Vega-Lite idioms alongside the canonical openchart forms and
5
- * rewrites them into canonical shape BEFORE validateSpec runs (validation
6
- * hard-rejects the VL forms, so sugar placed later would be dead code):
7
- *
8
- * - `data: { values: [...] }` unwraps to the bare array
9
- * - top-level `title` / `subtitle` expand into `chrome` (chrome wins on conflict)
10
- * - top-level `description` folds into `a11y.description` (a11y wins on conflict)
11
- * - bare value defs (`color/size/opacity: { value }`) move to mark-level props
12
- * - channel-level `legend: null | config` on color merges into the top-level legend
13
- * - `axis: null` becomes `axis: false`
14
- * - `scale: { scheme }` resolves to `scale.range` via the core palette registry
15
- * - `theta` is the canonical arc/waffle/parliament value channel in v8; `y` on
16
- * those marks is a deprecated alias, expanded into `theta` (and vice versa,
17
- * since the engine still reads `y` internally) with a warning
18
- * - annotation `type: 'rule'` is a deprecated alias for `'refline'`
19
- * - `aggregate: 'count'` without a field desugars to an aggregate transform
20
- * - VL sort forms (`'-y'`, value arrays, `{ field, op, order }`) resolve to an
21
- * explicit categorical `scale.domain`
22
- * - encoding-level `bin` / `timeUnit` desugar to transforms (expandEncodingSugar)
23
- *
24
- * It also emits deprecation warnings for spec surface removed in v8
25
- * (`radius`, `shape`, `href`, `order`) and strips the triggering forms so
26
- * each one warns exactly once per compile, even when layer leaves are
27
- * re-expanded.
28
- *
29
- * Applied to top-level chart specs (compileChart) and to LayerSpec children
30
- * (compileLayer) so every sugar works inside layers too.
31
- */
32
-
33
- import type {
34
- AggregateOp,
35
- AggregateTransform,
36
- BinParams,
37
- BinTransform,
38
- DataRow,
39
- EncodingChannel,
40
- TimeUnit,
41
- TimeUnitTransform,
42
- Transform,
43
- } from '@opendata-ai/openchart-core';
44
- import { resolveSchemeName } from '@opendata-ai/openchart-core';
45
- import { computeAggregate } from '../transforms/aggregate';
46
-
47
- // ---------------------------------------------------------------------------
48
- // Helpers
49
- // ---------------------------------------------------------------------------
50
-
51
- /** Resolve the mark type string from a raw (unvalidated) spec. */
52
- function markTypeOf(spec: Record<string, unknown>): string | undefined {
53
- const mark = spec.mark;
54
- if (typeof mark === 'string') return mark;
55
- if (mark && typeof mark === 'object' && !Array.isArray(mark)) {
56
- return (mark as Record<string, unknown>).type as string | undefined;
57
- }
58
- return undefined;
59
- }
60
-
61
- /** Unique string values of a data column, in data order, skipping nulls. */
62
- function uniqueFieldStrings(data: DataRow[], field: string): string[] {
63
- const seen = new Set<string>();
64
- const out: string[] = [];
65
- for (const row of data) {
66
- const raw = row[field];
67
- if (raw == null) continue;
68
- const key = String(raw);
69
- if (!seen.has(key)) {
70
- seen.add(key);
71
- out.push(key);
72
- }
73
- }
74
- return out;
75
- }
76
-
77
- // ---------------------------------------------------------------------------
78
- // Encoding sugar expansion (bin, timeUnit on encoding channels)
79
- // ---------------------------------------------------------------------------
80
-
81
- /**
82
- * Expand encoding-level `bin` and `timeUnit` shorthand into explicit transforms.
83
- *
84
- * Vega-Lite allows `encoding.x.bin: true` as sugar for a BinTransform.
85
- * This function detects those shorthands, generates the corresponding transforms,
86
- * updates encoding field references to the output field names, and prepends the
87
- * transforms to the spec's transform array.
88
- *
89
- * Mutates nothing; returns a new spec object (shallow copy).
90
- */
91
- export function expandEncodingSugar(spec: Record<string, unknown>): Record<string, unknown> {
92
- const encoding = spec.encoding as Record<string, EncodingChannel | undefined> | undefined;
93
- if (!encoding) return spec;
94
-
95
- const generatedTransforms: Transform[] = [];
96
- const updatedEncoding = { ...encoding };
97
- let changed = false;
98
-
99
- for (const channel of Object.keys(encoding)) {
100
- const ch = encoding[channel];
101
- if (!ch || !ch.field) continue;
102
-
103
- // Expand bin shorthand
104
- if (ch.bin != null && ch.bin !== false) {
105
- const field = ch.field;
106
- const outputField = `bin_${field}`;
107
- const binTransform: BinTransform = {
108
- bin: ch.bin === true ? true : (ch.bin as BinParams),
109
- field,
110
- as: outputField,
111
- };
112
- generatedTransforms.push(binTransform);
113
-
114
- // Update encoding to reference binned output field, remove bin property
115
- const { bin: _bin, ...rest } = ch;
116
- updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
117
- changed = true;
118
- }
119
-
120
- // Expand timeUnit shorthand (read from updated encoding in case bin already ran)
121
- const current = updatedEncoding[channel] ?? ch;
122
- if (current.timeUnit) {
123
- const field = current.field;
124
- const unit = current.timeUnit as TimeUnit;
125
- const outputField = `${unit}_${field}`;
126
- const timeUnitTransform: TimeUnitTransform = {
127
- timeUnit: unit,
128
- field,
129
- as: outputField,
130
- };
131
- generatedTransforms.push(timeUnitTransform);
132
-
133
- // Update encoding to reference timeUnit output field, remove timeUnit property
134
- const { timeUnit: _tu, ...rest } = current;
135
- updatedEncoding[channel] = { ...rest, field: outputField } as EncodingChannel;
136
- changed = true;
137
- }
138
- }
139
-
140
- if (!changed) return spec;
141
-
142
- // Prepend generated transforms before any user-defined transforms
143
- const existingTransforms = (spec.transform as Transform[] | undefined) ?? [];
144
- return {
145
- ...spec,
146
- encoding: updatedEncoding,
147
- transform: [...generatedTransforms, ...existingTransforms],
148
- };
149
- }
150
-
151
- // ---------------------------------------------------------------------------
152
- // Top-level sugar (data wrapper, title, ignored keys, fixed size)
153
- // ---------------------------------------------------------------------------
154
-
155
- /** Unwrap the VL `data: { values: [...] }` object form to a bare array. */
156
- function unwrapDataValues(spec: Record<string, unknown>): Record<string, unknown> {
157
- const data = spec.data;
158
- if (data && typeof data === 'object' && !Array.isArray(data)) {
159
- const values = (data as Record<string, unknown>).values;
160
- if (Array.isArray(values)) {
161
- return { ...spec, data: values };
162
- }
163
- }
164
- return spec;
165
- }
166
-
167
- /**
168
- * Expand the VL top-level `title` (string or `{ text, subtitle }`) and
169
- * `subtitle` into `chrome`. Authored chrome keys win on conflict. Runs before
170
- * breakpoint overrides are applied, so a per-breakpoint `overrides.<bp>.chrome`
171
- * replaces the expanded chrome exactly as it replaces authored chrome.
172
- */
173
- function expandTopLevelTitle(spec: Record<string, unknown>): Record<string, unknown> {
174
- if (spec.title === undefined && spec.subtitle === undefined) return spec;
175
-
176
- const expanded: Record<string, unknown> = {};
177
- const title = spec.title;
178
- if (typeof title === 'string') {
179
- expanded.title = title;
180
- } else if (title && typeof title === 'object' && !Array.isArray(title)) {
181
- const t = title as { text?: unknown; subtitle?: unknown };
182
- if (typeof t.text === 'string') expanded.title = t.text;
183
- if (typeof t.subtitle === 'string') expanded.subtitle = t.subtitle;
184
- }
185
- if (typeof spec.subtitle === 'string') expanded.subtitle = spec.subtitle;
186
-
187
- const { title: _title, subtitle: _subtitle, ...rest } = spec;
188
- const chrome =
189
- spec.chrome && typeof spec.chrome === 'object' ? (spec.chrome as Record<string, unknown>) : {};
190
- return { ...rest, chrome: { ...expanded, ...chrome } };
191
- }
192
-
193
- /**
194
- * Fold the top-level `description` (VL's alt-text field) into
195
- * `a11y.description`. An authored `a11y.description` wins on conflict.
196
- */
197
- function expandDescriptionSugar(spec: Record<string, unknown>): Record<string, unknown> {
198
- if (typeof spec.description !== 'string') return spec;
199
- const { description, ...rest } = spec;
200
- const a11y =
201
- spec.a11y && typeof spec.a11y === 'object' ? (spec.a11y as Record<string, unknown>) : {};
202
- return { ...rest, a11y: { description, ...a11y } };
203
- }
204
-
205
- /** Keys accepted for VL compatibility but ignored; warned once and stripped. */
206
- function stripIgnoredKeys(
207
- spec: Record<string, unknown>,
208
- warnings: string[],
209
- ): Record<string, unknown> {
210
- if (!('$schema' in spec)) return spec;
211
- warnings.push(
212
- '[openchart] "$schema" is accepted for Vega-Lite compatibility but ignored; it can be removed.',
213
- );
214
- const { $schema: _schema, ...rest } = spec;
215
- return rest;
216
- }
217
-
218
- /**
219
- * Fixed-size specs (both `width` and `height` set) imply `responsive: false`
220
- * unless the user set `responsive` explicitly. The width/height override
221
- * itself is applied by compileChart/compileLayer on the compile options.
222
- */
223
- function applyFixedSizeDefault(spec: Record<string, unknown>): Record<string, unknown> {
224
- if (
225
- typeof spec.width === 'number' &&
226
- typeof spec.height === 'number' &&
227
- spec.responsive === undefined
228
- ) {
229
- return { ...spec, responsive: false };
230
- }
231
- return spec;
232
- }
233
-
234
- /**
235
- * Rewrite the deprecated annotation `type: 'rule'` to the canonical
236
- * `'refline'` (they collide with the `rule` mark type otherwise). Runs before
237
- * validation so the canonical form is all validation/normalization ever sees.
238
- */
239
- function expandAnnotationSugar(
240
- spec: Record<string, unknown>,
241
- warnings: string[],
242
- ): Record<string, unknown> {
243
- const annotations = spec.annotations;
244
- if (!Array.isArray(annotations) || annotations.length === 0) return spec;
245
-
246
- let changed = false;
247
- const updated = annotations.map((ann) => {
248
- if (ann && typeof ann === 'object' && (ann as Record<string, unknown>).type === 'rule') {
249
- warnings.push("[openchart] annotation type 'rule' is deprecated; use 'refline'.");
250
- changed = true;
251
- return { ...(ann as Record<string, unknown>), type: 'refline' };
252
- }
253
- return ann;
254
- });
255
-
256
- return changed ? { ...spec, annotations: updated } : spec;
257
- }
258
-
259
- // ---------------------------------------------------------------------------
260
- // Channel-level sugar (value defs, legend, axis null, scheme, theta, dead channels)
261
- // ---------------------------------------------------------------------------
262
-
263
- /** Channels declared in the spec types with zero engine implementation, warned and stripped. */
264
- const DEAD_CHANNEL_MESSAGES: Record<string, string> = {
265
- radius:
266
- '[openchart] encoding.radius was removed in v8. This channel is stripped for backward compatibility. Use mark.innerRadius / mark.outerRadius to control donut radii.',
267
- shape:
268
- '[openchart] encoding.shape was removed in v8. This channel is stripped for backward compatibility. Differentiate series with encoding.color or encoding.strokeDash instead.',
269
- href: '[openchart] encoding.href was removed in v8. This channel is stripped for backward compatibility. Handle link navigation in the host application instead.',
270
- order:
271
- '[openchart] encoding.order was removed in v8. This channel is stripped for backward compatibility. Use encoding.<channel>.sort or pre-sorted data order instead.',
272
- };
273
-
274
- /**
275
- * Expand channel-level VL sugar. Mark-dependent sugar (value defs, theta) only
276
- * runs when the spec has a mark, so the same function works on the shared
277
- * encoding of a LayerSpec (which resolves those per leaf instead).
278
- */
279
- function expandChannelSugar(
280
- spec: Record<string, unknown>,
281
- warnings: string[],
282
- ): Record<string, unknown> {
283
- const encoding = spec.encoding as Record<string, unknown> | undefined;
284
- if (!encoding || typeof encoding !== 'object') return spec;
285
-
286
- const markType = markTypeOf(spec);
287
- const updated: Record<string, unknown> = { ...encoding };
288
- let changed = false;
289
- let markExtra: Record<string, unknown> | undefined;
290
- let legendMerge: Record<string, unknown> | undefined;
291
-
292
- // Bare value defs (VL `color: { value }`) -> mark-level properties
293
- if (markType) {
294
- for (const channel of ['color', 'size', 'opacity'] as const) {
295
- const ch = updated[channel] as Record<string, unknown> | undefined;
296
- if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
297
- if (!('value' in ch) || 'condition' in ch || 'field' in ch) continue;
298
- const value = ch.value;
299
- markExtra = markExtra ?? {};
300
- if (channel === 'color') {
301
- // Lines carry their color on stroke; every other mark family fills.
302
- if (markType === 'line' && typeof value === 'string') markExtra.stroke = value;
303
- else markExtra.fill = value;
304
- } else if (typeof value === 'number') {
305
- markExtra[channel] = value;
306
- }
307
- delete updated[channel];
308
- changed = true;
309
- }
310
- }
311
-
312
- // Channel-level legend on color: `null` hides, a config merges (VL idiom)
313
- const colorCh = updated.color as Record<string, unknown> | undefined;
314
- if (
315
- colorCh &&
316
- typeof colorCh === 'object' &&
317
- 'legend' in colorCh &&
318
- colorCh.legend !== undefined
319
- ) {
320
- legendMerge =
321
- colorCh.legend === null ? { show: false } : (colorCh.legend as Record<string, unknown>);
322
- const { legend: _legend, ...rest } = colorCh;
323
- updated.color = rest;
324
- changed = true;
325
- }
326
-
327
- for (const channel of Object.keys(updated)) {
328
- const ch = updated[channel] as Record<string, unknown> | undefined;
329
- if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
330
-
331
- // axis: null -> false (VL uses null to hide an axis)
332
- if (ch.axis === null) {
333
- updated[channel] = { ...ch, axis: false };
334
- changed = true;
335
- }
336
-
337
- // scale.scheme -> scale.range via the core palette registry. Unknown
338
- // names stay in place for validateSpec to reject with the supported
339
- // list. An explicit range wins over scheme.
340
- const current = updated[channel] as Record<string, unknown>;
341
- const scale = current.scale as Record<string, unknown> | undefined;
342
- if (scale && typeof scale.scheme === 'string') {
343
- const stops = resolveSchemeName(scale.scheme);
344
- if (stops) {
345
- const { scheme: _scheme, ...scaleRest } = scale;
346
- updated[channel] = { ...current, scale: { ...scaleRest, range: scale.range ?? stops } };
347
- changed = true;
348
- }
349
- }
350
-
351
- // scale.reverse on the color channel flips the ramp here, at the one place
352
- // the stops are known. The color scale builders read scale.range directly
353
- // and never look at reverse (only the positional builders do), so reversing
354
- // the range is what makes `reverse` mean anything on color -- and it keeps
355
- // the legend, which resolves its ramp from the same scale.range, in step.
356
- // Positional channels keep their reverse flag for buildBand/Point/Continuous.
357
- if (channel === 'color') {
358
- const colorDef = updated[channel] as Record<string, unknown>;
359
- const colorScale = colorDef.scale as Record<string, unknown> | undefined;
360
- if (colorScale?.reverse && Array.isArray(colorScale.range)) {
361
- const { reverse: _reverse, ...scaleRest } = colorScale;
362
- updated[channel] = {
363
- ...colorDef,
364
- scale: { ...scaleRest, range: [...(colorScale.range as string[])].reverse() },
365
- };
366
- changed = true;
367
- }
368
- }
369
- }
370
-
371
- // theta: the canonical arc value channel in v8, shared by waffle and
372
- // parliament marks (the same part-to-whole value). The engine still reads
373
- // encoding.y internally, so both directions of the alias converge on y:
374
- // - theta present -> y is populated from theta (theta wins if both are set,
375
- // since it's canonical now)
376
- // - only y present -> y is kept and theta is populated from it, with a
377
- // deprecation warning nudging authors toward theta
378
- if (markType && (updated.theta || updated.y)) {
379
- const thetaMark = markType === 'arc' || markType === 'waffle' || markType === 'parliament';
380
- const hasTheta = updated.theta && typeof updated.theta === 'object';
381
- const hasY = updated.y && typeof updated.y === 'object';
382
- if (thetaMark && hasTheta) {
383
- if (hasY && JSON.stringify(updated.y) !== JSON.stringify(updated.theta)) {
384
- warnings.push(
385
- `[openchart] encoding.theta and encoding.y are both set on ${markType}; theta takes precedence and y was dropped.`,
386
- );
387
- }
388
- updated.y = updated.theta;
389
- changed = true;
390
- } else if (thetaMark && hasY && !hasTheta) {
391
- updated.theta = updated.y;
392
- warnings.push(
393
- `[openchart] encoding.y on ${markType} marks is deprecated in v8; use encoding.theta for the value channel.`,
394
- );
395
- changed = true;
396
- } else if (hasTheta && !thetaMark) {
397
- warnings.push(
398
- '[openchart] encoding.theta is only meaningful on arc, waffle, and parliament marks and was ignored.',
399
- );
400
- delete updated.theta;
401
- changed = true;
402
- }
403
- }
404
-
405
- // Parliament: party colors carry real-world meaning (red/blue for US parties)
406
- // and should never be left to the auto-cycling categorical palette. Nudge the
407
- // author toward an explicit color scale range when none is set. Warning only,
408
- // so the chart still renders (with palette colors) if they ignore it.
409
- if (markType === 'parliament') {
410
- const parliamentColor = updated.color as Record<string, unknown> | undefined;
411
- const scale = parliamentColor?.scale as Record<string, unknown> | undefined;
412
- const hasExplicitColors = Array.isArray(scale?.range) && (scale.range as unknown[]).length > 0;
413
- if (parliamentColor && 'field' in parliamentColor && !hasExplicitColors) {
414
- warnings.push(
415
- '[openchart] parliament chart is using auto-palette colors. Party colors carry meaning; set them explicitly via encoding.color.scale.range (in party order), e.g. color: { field: "party", scale: { range: ["#1b7fa3", "#c44e52"] } }.',
416
- );
417
- }
418
- }
419
-
420
- // Dead channels: warn once and strip (behavior-identical; the engine ignores them)
421
- for (const channel of Object.keys(DEAD_CHANNEL_MESSAGES)) {
422
- if (updated[channel] !== undefined) {
423
- warnings.push(DEAD_CHANNEL_MESSAGES[channel]);
424
- delete updated[channel];
425
- changed = true;
426
- }
427
- }
428
-
429
- if (!changed) return spec;
430
-
431
- const result: Record<string, unknown> = { ...spec, encoding: updated };
432
- if (markExtra) {
433
- const markDef =
434
- typeof spec.mark === 'string'
435
- ? { type: spec.mark }
436
- : { ...(spec.mark as Record<string, unknown>) };
437
- // Explicit mark-level values win over expanded channel constants
438
- result.mark = { ...markExtra, ...markDef };
439
- }
440
- if (legendMerge) {
441
- // Top-level legend keys win over the channel-level config
442
- result.legend = { ...legendMerge, ...(spec.legend as Record<string, unknown> | undefined) };
443
- }
444
- return result;
445
- }
446
-
447
- // ---------------------------------------------------------------------------
448
- // Count aggregate without a field (VL histogram idiom)
449
- // ---------------------------------------------------------------------------
450
-
451
- /** Channels whose fields group the count aggregate (mirrors VL's implicit groupby). */
452
- const COUNT_GROUP_CHANNELS = ['x', 'y', 'color', 'detail', 'strokeDash', 'facet'] as const;
453
-
454
- /**
455
- * Desugar `{ aggregate: 'count' }` (no field) on x/y into an explicit
456
- * aggregate transform grouped by the other encoded fields, rewriting the
457
- * channel to the `__count` output field (VL's own convention). Runs after
458
- * bin/timeUnit expansion so binned fields participate in the groupby, and the
459
- * transform is appended after user transforms so it counts filtered rows.
460
- */
461
- function expandCountAggregate(spec: Record<string, unknown>): Record<string, unknown> {
462
- const encoding = spec.encoding as Record<string, unknown> | undefined;
463
- if (!encoding || typeof encoding !== 'object') return spec;
464
-
465
- const updated: Record<string, unknown> = { ...encoding };
466
- let countTransform: AggregateTransform | undefined;
467
-
468
- for (const channel of ['x', 'y'] as const) {
469
- const ch = updated[channel] as Record<string, unknown> | undefined;
470
- if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
471
- if (ch.aggregate !== 'count' || ch.field != null) continue;
472
-
473
- const groupby: string[] = [];
474
- for (const other of COUNT_GROUP_CHANNELS) {
475
- if (other === channel) continue;
476
- const otherCh = updated[other] as Record<string, unknown> | undefined;
477
- if (otherCh && typeof otherCh === 'object' && typeof otherCh.field === 'string') {
478
- if (!groupby.includes(otherCh.field)) groupby.push(otherCh.field);
479
- }
480
- }
481
-
482
- countTransform = { aggregate: [{ op: 'count', field: '__count', as: '__count' }], groupby };
483
- const { aggregate: _aggregate, ...rest } = ch;
484
- updated[channel] = {
485
- ...rest,
486
- field: '__count',
487
- type: ch.type ?? 'quantitative',
488
- title: ch.title ?? 'Count',
489
- };
490
- }
491
-
492
- if (!countTransform) return spec;
493
- const existing = (spec.transform as Transform[] | undefined) ?? [];
494
- return { ...spec, encoding: updated, transform: [...existing, countTransform] };
495
- }
496
-
497
- // ---------------------------------------------------------------------------
498
- // Sort-by-value resolution (VL '-y', value arrays, { field, op, order })
499
- // ---------------------------------------------------------------------------
500
-
501
- /**
502
- * Resolve VL sort forms on categorical channels into an explicit
503
- * `scale.domain`, which the scale builders already honor. Canonical
504
- * 'ascending' / 'descending' / null pass through untouched. Skipped (and
505
- * retried at leaf compile) when the data is not yet known, e.g. layer
506
- * children inheriting parent data.
507
- */
508
- function resolveSortSugar(
509
- spec: Record<string, unknown>,
510
- inheritedData?: DataRow[],
511
- ): Record<string, unknown> {
512
- const encoding = spec.encoding as Record<string, unknown> | undefined;
513
- if (!encoding || typeof encoding !== 'object') return spec;
514
- const data = (Array.isArray(spec.data) ? spec.data : inheritedData) as DataRow[] | undefined;
515
-
516
- const updated: Record<string, unknown> = { ...encoding };
517
- let changed = false;
518
-
519
- for (const channel of ['x', 'y', 'color'] as const) {
520
- const ch = updated[channel] as Record<string, unknown> | undefined;
521
- if (!ch || typeof ch !== 'object' || Array.isArray(ch) || !('sort' in ch)) continue;
522
- const sort = ch.sort;
523
- if (sort === undefined || sort === null || sort === 'ascending' || sort === 'descending') {
524
- continue;
525
- }
526
-
527
- const field = ch.field as string | undefined;
528
- const scale = (ch.scale ?? {}) as Record<string, unknown>;
529
-
530
- // An explicit domain wins over sort (matches canonical sort behavior)
531
- if (scale.domain) {
532
- const { sort: _sort, ...rest } = ch;
533
- updated[channel] = rest;
534
- changed = true;
535
- continue;
536
- }
537
- if (!field || !data || data.length === 0) continue;
538
-
539
- let domain: string[] | undefined;
540
- if (Array.isArray(sort)) {
541
- // Value-array sort: listed values first, remaining values in data order
542
- const present = uniqueFieldStrings(data, field);
543
- const listed = sort.map(String).filter((v) => present.includes(v));
544
- const rest = present.filter((v) => !listed.includes(v));
545
- domain = [...listed, ...rest];
546
- } else {
547
- // 'x' | '-x' | 'y' | '-y' or { field, op, order }
548
- let targetField: string | undefined;
549
- let op: AggregateOp = 'sum';
550
- let descending = false;
551
- let isCount = false;
552
- if (typeof sort === 'string' && /^-?[xy]$/.test(sort)) {
553
- descending = sort.startsWith('-');
554
- const target = updated[sort.replace('-', '')] as Record<string, unknown> | undefined;
555
- if (!target || typeof target !== 'object') continue;
556
- targetField = target.field as string | undefined;
557
- if (target.aggregate === 'count') isCount = true;
558
- else if (typeof target.aggregate === 'string') op = target.aggregate as AggregateOp;
559
- } else if (typeof sort === 'object' && 'field' in (sort as object)) {
560
- const s = sort as { field: string; op?: AggregateOp; order?: string };
561
- targetField = s.field;
562
- if (s.op === 'count') isCount = true;
563
- else if (s.op) op = s.op;
564
- descending = s.order === 'descending';
565
- } else {
566
- // Unknown sort shape: fall back to data order
567
- const { sort: _sort, ...rest } = ch;
568
- updated[channel] = rest;
569
- changed = true;
570
- continue;
571
- }
572
- if (!targetField && !isCount) continue;
573
-
574
- // Aggregate the target values per category (default op: sum, VL aligned)
575
- const groups = new Map<string, number[]>();
576
- for (const row of data) {
577
- const raw = row[field];
578
- if (raw == null) continue;
579
- const key = String(raw);
580
- let bucket = groups.get(key);
581
- if (!bucket) {
582
- bucket = [];
583
- groups.set(key, bucket);
584
- }
585
- if (isCount) {
586
- bucket.push(1);
587
- } else {
588
- const value = Number(row[targetField as string]);
589
- if (Number.isFinite(value)) bucket.push(value);
590
- }
591
- }
592
- const entries = [...groups.entries()].map(
593
- ([key, values]) => [key, computeAggregate(isCount ? 'count' : op, values)] as const,
594
- );
595
- entries.sort((a, b) => (descending ? b[1] - a[1] : a[1] - b[1]));
596
- domain = entries.map(([key]) => key);
597
- }
598
-
599
- if (!domain) continue;
600
- const { sort: _sort, ...rest } = ch;
601
- updated[channel] = { ...rest, scale: { ...scale, domain } };
602
- changed = true;
603
- }
604
-
605
- return changed ? { ...spec, encoding: updated } : spec;
606
- }
607
-
608
- // ---------------------------------------------------------------------------
609
- // Composition: chart and layer expansion
610
- // ---------------------------------------------------------------------------
611
-
612
- function expandChartSugar(
613
- spec: Record<string, unknown>,
614
- warnings: string[],
615
- inheritedData?: DataRow[],
616
- ): Record<string, unknown> {
617
- let out = unwrapDataValues(spec);
618
- out = expandTopLevelTitle(out);
619
- out = expandDescriptionSugar(out);
620
- out = stripIgnoredKeys(out, warnings);
621
- out = applyFixedSizeDefault(out);
622
- out = expandAnnotationSugar(out, warnings);
623
- out = expandChannelSugar(out, warnings);
624
- out = expandEncodingSugar(out);
625
- out = resolveSortSugar(out, inheritedData);
626
- out = expandCountAggregate(out);
627
- return out;
628
- }
629
-
630
- function expandLayerSugar(
631
- spec: Record<string, unknown>,
632
- warnings: string[],
633
- inheritedData?: DataRow[],
634
- parentEncoding?: Record<string, unknown>,
635
- ): Record<string, unknown> {
636
- let out = unwrapDataValues(spec);
637
- out = expandTopLevelTitle(out);
638
- out = stripIgnoredKeys(out, warnings);
639
- out = expandAnnotationSugar(out, warnings);
640
- // Shared encoding gets the mark-independent channel sugar (axis, scheme,
641
- // legend, dead channels) and sort resolution; mark-dependent sugar (value
642
- // defs, theta) resolves per leaf after encoding inheritance.
643
- out = expandChannelSugar(out, warnings);
644
- out = resolveSortSugar(out, inheritedData);
645
-
646
- const layerData = Array.isArray(out.data) ? (out.data as DataRow[]) : inheritedData;
647
- const ownEncoding = out.encoding as Record<string, unknown> | undefined;
648
- const mergedEncoding =
649
- parentEncoding && ownEncoding
650
- ? { ...parentEncoding, ...ownEncoding }
651
- : (ownEncoding ?? parentEncoding);
652
-
653
- const layer = out.layer as unknown[];
654
- const expandedChildren = layer.map((child) => {
655
- if (!child || typeof child !== 'object' || Array.isArray(child)) return child;
656
- const childObj = child as Record<string, unknown>;
657
- if (Array.isArray(childObj.layer)) {
658
- return expandLayerSugar(childObj, warnings, layerData, mergedEncoding);
659
- }
660
- if ('mark' in childObj) {
661
- return expandChartSugar(childObj, warnings, layerData);
662
- }
663
- return child;
664
- });
665
- return { ...out, layer: expandedChildren };
666
- }
667
-
668
- // ---------------------------------------------------------------------------
669
- // Public API
670
- // ---------------------------------------------------------------------------
671
-
672
- /**
673
- * Expand deprecated `valueFormat` into `encoding.value.format` for
674
- * sankey, tilemap, and barlist specs. Encoding-level format wins on conflict.
675
- */
676
- function expandValueFormatSugar(
677
- spec: Record<string, unknown>,
678
- warnings: string[],
679
- ): Record<string, unknown> {
680
- const type = spec.type as string | undefined;
681
- if (type !== 'sankey' && type !== 'tilemap' && type !== 'barlist') return spec;
682
- if (typeof spec.valueFormat !== 'string') return spec;
683
-
684
- const encoding = spec.encoding as Record<string, unknown> | undefined;
685
- const valueCh = encoding?.value as Record<string, unknown> | undefined;
686
-
687
- // Only expand into encoding when encoding.value already exists (the shorthand
688
- // tilemap/barlist API may not have explicit encoding — the normalizer infers it).
689
- if (!valueCh || typeof valueCh !== 'object') {
690
- warnings.push(
691
- '[openchart] valueFormat is deprecated; set format on the encoding value channel instead (e.g. encoding.value.format).',
692
- );
693
- return spec;
694
- }
695
-
696
- if (valueCh.format !== undefined) {
697
- const { valueFormat: _vf, ...rest } = spec;
698
- warnings.push(
699
- '[openchart] valueFormat is deprecated; the encoding.value.format field takes precedence and valueFormat was ignored.',
700
- );
701
- return rest;
702
- }
703
-
704
- warnings.push(
705
- '[openchart] valueFormat is deprecated; set format on the encoding value channel instead (e.g. encoding.value.format).',
706
- );
707
- const { valueFormat, ...rest } = spec;
708
- return {
709
- ...rest,
710
- encoding: { ...encoding, value: { ...valueCh, format: valueFormat } },
711
- };
712
- }
713
-
714
- /**
715
- * Expand VL-idiom and encoding-level sugar on a raw chart or layer spec.
716
- * Non-chart specs (tables, graphs, sankey, tilemap, barlist) get valueFormat
717
- * expansion only. Deprecation warnings are pushed onto `warnings`; callers
718
- * surface them (compileChart/compileLayer console.warn each unique message
719
- * once per compile).
720
- */
721
- export function expandSpecSugar(
722
- spec: Record<string, unknown>,
723
- warnings: string[] = [],
724
- ): Record<string, unknown> {
725
- if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return spec;
726
- if (Array.isArray(spec.layer) && typeof spec.type !== 'string') {
727
- return expandLayerSugar(spec, warnings);
728
- }
729
- if (!('mark' in spec)) {
730
- return expandValueFormatSugar(spec, warnings);
731
- }
732
- return expandChartSugar(spec, warnings);
733
- }
734
-
735
- /** Advisory-warning sink: a host callback, or the console.warn default. */
736
- export type WarnSink = (message: string) => void;
737
-
738
- const defaultWarnSink: WarnSink = (message) => console.warn(message);
739
-
740
- /**
741
- * Surface sugar/deprecation warnings: emit each unique message once per compile.
742
- * Duplicates within a compile (e.g. the same dead channel on two layer children)
743
- * collapse to one warning.
744
- *
745
- * The engine is isomorphic and never touches the global `console` on its own:
746
- * `sink` is the host-provided `CompileOptions.onWarn` when present, so a host can
747
- * collect, reroute, or silence warnings (SSR, tests, dev overlays). It falls back
748
- * to `console.warn` only when no sink is passed, preserving the prior behavior.
749
- */
750
- export function emitSpecWarnings(warnings: string[], sink: WarnSink = defaultWarnSink): void {
751
- if (warnings.length === 0) return;
752
- const seen = new Set<string>();
753
- for (const warning of warnings) {
754
- if (seen.has(warning)) continue;
755
- seen.add(warning);
756
- sink(warning);
757
- }
758
- }