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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/index.js +2 -3
  2. package/dist/index.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/__test-fixtures__/specs.ts +0 -182
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  6. package/src/__tests__/annotation-behavior.test.ts +0 -852
  7. package/src/__tests__/axes.test.ts +0 -1579
  8. package/src/__tests__/compile-animation.test.ts +0 -161
  9. package/src/__tests__/compile-chart.test.ts +0 -1303
  10. package/src/__tests__/compile-footnotes.test.ts +0 -277
  11. package/src/__tests__/compile-layer.test.ts +0 -969
  12. package/src/__tests__/compile-snapshot.test.ts +0 -156
  13. package/src/__tests__/compound-labels.test.ts +0 -147
  14. package/src/__tests__/dimensions.test.ts +0 -918
  15. package/src/__tests__/encoding-sugar.test.ts +0 -390
  16. package/src/__tests__/facet.test.ts +0 -359
  17. package/src/__tests__/helpers/invariants.ts +0 -299
  18. package/src/__tests__/known-bugs.test.ts +0 -566
  19. package/src/__tests__/layout-fuzz.test.ts +0 -358
  20. package/src/__tests__/layout-invariants.test.ts +0 -535
  21. package/src/__tests__/legend.test.ts +0 -721
  22. package/src/__tests__/mark-keys.test.ts +0 -294
  23. package/src/__tests__/ranking-recipes.test.ts +0 -260
  24. package/src/__tests__/rotation.test.ts +0 -83
  25. package/src/__tests__/scales.test.ts +0 -323
  26. package/src/__tests__/series-search.test.ts +0 -92
  27. package/src/__tests__/spec-sugar.test.ts +0 -801
  28. package/src/__tests__/you-draw-it.test.ts +0 -160
  29. package/src/annotations/__tests__/compute.test.ts +0 -1672
  30. package/src/annotations/__tests__/geometry.test.ts +0 -66
  31. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  32. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  33. package/src/annotations/__tests__/placement.test.ts +0 -316
  34. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  35. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  36. package/src/annotations/__tests__/thinning.test.ts +0 -285
  37. package/src/annotations/collisions.ts +0 -326
  38. package/src/annotations/compute.ts +0 -333
  39. package/src/annotations/constants.ts +0 -162
  40. package/src/annotations/geometry.ts +0 -376
  41. package/src/annotations/move.ts +0 -135
  42. package/src/annotations/placement.ts +0 -636
  43. package/src/annotations/position.ts +0 -131
  44. package/src/annotations/resolve-range.ts +0 -117
  45. package/src/annotations/resolve-refline.ts +0 -157
  46. package/src/annotations/resolve-text.ts +0 -545
  47. package/src/annotations/rich-text.ts +0 -65
  48. package/src/annotations/thinning.ts +0 -190
  49. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  50. package/src/barlist/compile-barlist.ts +0 -391
  51. package/src/barlist/types.ts +0 -28
  52. package/src/charts/__tests__/post-process.test.ts +0 -237
  53. package/src/charts/__tests__/registry.test.ts +0 -116
  54. package/src/charts/__tests__/utils.test.ts +0 -195
  55. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  56. package/src/charts/_shared/density-filter.ts +0 -26
  57. package/src/charts/_shared/format-label-value.ts +0 -16
  58. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  59. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  60. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  61. package/src/charts/bar/compute.ts +0 -539
  62. package/src/charts/bar/index.ts +0 -60
  63. package/src/charts/bar/labels.ts +0 -311
  64. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  65. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  66. package/src/charts/beeswarm/compute.ts +0 -203
  67. package/src/charts/beeswarm/dodge.ts +0 -155
  68. package/src/charts/beeswarm/index.ts +0 -32
  69. package/src/charts/builtin.ts +0 -75
  70. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  71. package/src/charts/calendar/compute.ts +0 -319
  72. package/src/charts/calendar/index.ts +0 -32
  73. package/src/charts/column/__tests__/compute.test.ts +0 -540
  74. package/src/charts/column/__tests__/labels.test.ts +0 -179
  75. package/src/charts/column/compute.ts +0 -493
  76. package/src/charts/column/index.ts +0 -53
  77. package/src/charts/column/labels.ts +0 -229
  78. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  79. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  80. package/src/charts/dot/compute.ts +0 -285
  81. package/src/charts/dot/index.ts +0 -62
  82. package/src/charts/dot/labels.ts +0 -121
  83. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  84. package/src/charts/line/__tests__/curves.test.ts +0 -51
  85. package/src/charts/line/__tests__/labels.test.ts +0 -94
  86. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  87. package/src/charts/line/area.ts +0 -484
  88. package/src/charts/line/compute.ts +0 -356
  89. package/src/charts/line/curves.ts +0 -85
  90. package/src/charts/line/index.ts +0 -176
  91. package/src/charts/line/labels.ts +0 -177
  92. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  93. package/src/charts/parliament/compute.ts +0 -313
  94. package/src/charts/parliament/index.ts +0 -32
  95. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  96. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  97. package/src/charts/pie/compute.ts +0 -324
  98. package/src/charts/pie/index.ts +0 -49
  99. package/src/charts/pie/labels.ts +0 -143
  100. package/src/charts/post-process.ts +0 -229
  101. package/src/charts/range/__tests__/compute.test.ts +0 -486
  102. package/src/charts/range/compute.ts +0 -273
  103. package/src/charts/range/index.ts +0 -82
  104. package/src/charts/range/labels.ts +0 -169
  105. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  106. package/src/charts/rect/index.ts +0 -127
  107. package/src/charts/registry.ts +0 -72
  108. package/src/charts/rule/index.ts +0 -139
  109. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  110. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  111. package/src/charts/scatter/compute.ts +0 -192
  112. package/src/charts/scatter/index.ts +0 -46
  113. package/src/charts/scatter/trendline.ts +0 -100
  114. package/src/charts/text/__tests__/compute.test.ts +0 -307
  115. package/src/charts/text/index.ts +0 -141
  116. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  117. package/src/charts/tick/index.ts +0 -99
  118. package/src/charts/utils.ts +0 -170
  119. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  120. package/src/charts/waffle/compute.ts +0 -197
  121. package/src/charts/waffle/index.ts +0 -31
  122. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  123. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  124. package/src/compile/__tests__/data-clip.test.ts +0 -127
  125. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  126. package/src/compile/__tests__/size-scale.test.ts +0 -108
  127. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  128. package/src/compile/color-scale-range.ts +0 -101
  129. package/src/compile/data-clip.ts +0 -48
  130. package/src/compile/fill-patterns.ts +0 -74
  131. package/src/compile/layer.ts +0 -630
  132. package/src/compile/size-scale.ts +0 -141
  133. package/src/compile/spec-sugar.ts +0 -767
  134. package/src/compile/watermark-obstacle.ts +0 -54
  135. package/src/compile/you-draw-it.ts +0 -123
  136. package/src/compile.ts +0 -1548
  137. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  138. package/src/compiler/__tests__/animation.test.ts +0 -177
  139. package/src/compiler/__tests__/compile.test.ts +0 -87
  140. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  141. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  142. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  143. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  144. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  153. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  154. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  155. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  156. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  158. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  159. package/src/compiler/__tests__/keys.test.ts +0 -83
  160. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  161. package/src/compiler/__tests__/normalize.test.ts +0 -399
  162. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  163. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  164. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  165. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  166. package/src/compiler/__tests__/validate.test.ts +0 -731
  167. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  168. package/src/compiler/a11y-warnings.ts +0 -110
  169. package/src/compiler/animation.ts +0 -142
  170. package/src/compiler/index.ts +0 -47
  171. package/src/compiler/keys.ts +0 -55
  172. package/src/compiler/normalize.ts +0 -734
  173. package/src/compiler/sparkline-defaults.ts +0 -138
  174. package/src/compiler/types.ts +0 -241
  175. package/src/compiler/validate.ts +0 -2093
  176. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  177. package/src/endpoint-labels/compute.ts +0 -621
  178. package/src/endpoint-labels/constants.ts +0 -54
  179. package/src/endpoint-labels/format.ts +0 -60
  180. package/src/endpoint-labels/predict.ts +0 -139
  181. package/src/format/__tests__/field-format.test.ts +0 -164
  182. package/src/format/field-format.ts +0 -27
  183. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  184. package/src/geo/compile-map.ts +0 -1376
  185. package/src/geo/join.ts +0 -115
  186. package/src/geo/projections.ts +0 -65
  187. package/src/geo/types.ts +0 -26
  188. package/src/graphs/__tests__/community.test.ts +0 -228
  189. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  190. package/src/graphs/__tests__/encoding.test.ts +0 -415
  191. package/src/graphs/community.ts +0 -92
  192. package/src/graphs/compile-graph.ts +0 -329
  193. package/src/graphs/encoding.ts +0 -356
  194. package/src/graphs/types.ts +0 -106
  195. package/src/index.ts +0 -177
  196. package/src/layout/__tests__/metrics.test.ts +0 -97
  197. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  198. package/src/layout/axes/rotation.ts +0 -152
  199. package/src/layout/axes/thinning.ts +0 -98
  200. package/src/layout/axes/ticks.ts +0 -409
  201. package/src/layout/axes.ts +0 -757
  202. package/src/layout/dimensions.ts +0 -912
  203. package/src/layout/facet.ts +0 -91
  204. package/src/layout/gridlines.ts +0 -84
  205. package/src/layout/metrics.ts +0 -132
  206. package/src/layout/plan.ts +0 -631
  207. package/src/layout/scales.ts +0 -1053
  208. package/src/layout/shared.ts +0 -44
  209. package/src/legend/__tests__/continuous.test.ts +0 -318
  210. package/src/legend/__tests__/redundancy.test.ts +0 -108
  211. package/src/legend/__tests__/size.test.ts +0 -348
  212. package/src/legend/__tests__/suppression.test.ts +0 -382
  213. package/src/legend/compute.ts +0 -748
  214. package/src/legend/continuous.ts +0 -327
  215. package/src/legend/size.ts +0 -407
  216. package/src/legend/suppression.ts +0 -226
  217. package/src/legend/wrap.ts +0 -117
  218. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  219. package/src/sankey/__tests__/layout.test.ts +0 -165
  220. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  221. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  222. package/src/sankey/compile-sankey.ts +0 -706
  223. package/src/sankey/layout.ts +0 -184
  224. package/src/sankey/types.ts +0 -41
  225. package/src/tables/__tests__/bar-column.test.ts +0 -147
  226. package/src/tables/__tests__/category-colors.test.ts +0 -230
  227. package/src/tables/__tests__/compile-table.test.ts +0 -236
  228. package/src/tables/__tests__/format-cells.test.ts +0 -143
  229. package/src/tables/__tests__/heatmap.test.ts +0 -148
  230. package/src/tables/__tests__/pagination.test.ts +0 -78
  231. package/src/tables/__tests__/search.test.ts +0 -94
  232. package/src/tables/__tests__/sort.test.ts +0 -107
  233. package/src/tables/__tests__/sparkline.test.ts +0 -122
  234. package/src/tables/bar-column.ts +0 -94
  235. package/src/tables/category-colors.ts +0 -78
  236. package/src/tables/compile-table.ts +0 -427
  237. package/src/tables/format-cells.ts +0 -115
  238. package/src/tables/heatmap.ts +0 -141
  239. package/src/tables/pagination.ts +0 -46
  240. package/src/tables/search.ts +0 -66
  241. package/src/tables/sort.ts +0 -69
  242. package/src/tables/sparkline.ts +0 -113
  243. package/src/tables/utils.ts +0 -16
  244. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  245. package/src/tilemap/compile-tilemap.ts +0 -755
  246. package/src/tilemap/layout.ts +0 -172
  247. package/src/tilemap/types.ts +0 -33
  248. package/src/tooltips/__tests__/compute.test.ts +0 -582
  249. package/src/tooltips/compute.ts +0 -700
  250. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  251. package/src/transforms/__tests__/bin.test.ts +0 -88
  252. package/src/transforms/__tests__/calculate.test.ts +0 -146
  253. package/src/transforms/__tests__/conditional.test.ts +0 -127
  254. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  255. package/src/transforms/__tests__/filter.test.ts +0 -59
  256. package/src/transforms/__tests__/fold.test.ts +0 -79
  257. package/src/transforms/__tests__/index.test.ts +0 -93
  258. package/src/transforms/__tests__/predicates.test.ts +0 -193
  259. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  260. package/src/transforms/__tests__/window.test.ts +0 -286
  261. package/src/transforms/aggregate.ts +0 -131
  262. package/src/transforms/bin.ts +0 -87
  263. package/src/transforms/calculate.ts +0 -60
  264. package/src/transforms/conditional.ts +0 -46
  265. package/src/transforms/filter.ts +0 -122
  266. package/src/transforms/fold.ts +0 -49
  267. package/src/transforms/index.ts +0 -60
  268. package/src/transforms/predicates.ts +0 -123
  269. package/src/transforms/timeunit.ts +0 -88
  270. package/src/transforms/window.ts +0 -185
@@ -1,767 +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 = [
453
- 'x',
454
- 'y',
455
- 'color',
456
- 'detail',
457
- 'strokeDash',
458
- 'facet',
459
- 'row',
460
- 'column',
461
- ] as const;
462
-
463
- /**
464
- * Desugar `{ aggregate: 'count' }` (no field) on x/y into an explicit
465
- * aggregate transform grouped by the other encoded fields, rewriting the
466
- * channel to the `__count` output field (VL's own convention). Runs after
467
- * bin/timeUnit expansion so binned fields participate in the groupby, and the
468
- * transform is appended after user transforms so it counts filtered rows.
469
- */
470
- function expandCountAggregate(spec: Record<string, unknown>): Record<string, unknown> {
471
- const encoding = spec.encoding as Record<string, unknown> | undefined;
472
- if (!encoding || typeof encoding !== 'object') return spec;
473
-
474
- const updated: Record<string, unknown> = { ...encoding };
475
- let countTransform: AggregateTransform | undefined;
476
-
477
- for (const channel of ['x', 'y'] as const) {
478
- const ch = updated[channel] as Record<string, unknown> | undefined;
479
- if (!ch || typeof ch !== 'object' || Array.isArray(ch)) continue;
480
- if (ch.aggregate !== 'count' || ch.field != null) continue;
481
-
482
- const groupby: string[] = [];
483
- for (const other of COUNT_GROUP_CHANNELS) {
484
- if (other === channel) continue;
485
- const otherCh = updated[other] as Record<string, unknown> | undefined;
486
- if (otherCh && typeof otherCh === 'object' && typeof otherCh.field === 'string') {
487
- if (!groupby.includes(otherCh.field)) groupby.push(otherCh.field);
488
- }
489
- }
490
-
491
- countTransform = { aggregate: [{ op: 'count', field: '__count', as: '__count' }], groupby };
492
- const { aggregate: _aggregate, ...rest } = ch;
493
- updated[channel] = {
494
- ...rest,
495
- field: '__count',
496
- type: ch.type ?? 'quantitative',
497
- title: ch.title ?? 'Count',
498
- };
499
- }
500
-
501
- if (!countTransform) return spec;
502
- const existing = (spec.transform as Transform[] | undefined) ?? [];
503
- return { ...spec, encoding: updated, transform: [...existing, countTransform] };
504
- }
505
-
506
- // ---------------------------------------------------------------------------
507
- // Sort-by-value resolution (VL '-y', value arrays, { field, op, order })
508
- // ---------------------------------------------------------------------------
509
-
510
- /**
511
- * Resolve VL sort forms on categorical channels into an explicit
512
- * `scale.domain`, which the scale builders already honor. Canonical
513
- * 'ascending' / 'descending' / null pass through untouched. Skipped (and
514
- * retried at leaf compile) when the data is not yet known, e.g. layer
515
- * children inheriting parent data.
516
- */
517
- function resolveSortSugar(
518
- spec: Record<string, unknown>,
519
- inheritedData?: DataRow[],
520
- ): Record<string, unknown> {
521
- const encoding = spec.encoding as Record<string, unknown> | undefined;
522
- if (!encoding || typeof encoding !== 'object') return spec;
523
- const data = (Array.isArray(spec.data) ? spec.data : inheritedData) as DataRow[] | undefined;
524
-
525
- const updated: Record<string, unknown> = { ...encoding };
526
- let changed = false;
527
-
528
- for (const channel of ['x', 'y', 'color'] as const) {
529
- const ch = updated[channel] as Record<string, unknown> | undefined;
530
- if (!ch || typeof ch !== 'object' || Array.isArray(ch) || !('sort' in ch)) continue;
531
- const sort = ch.sort;
532
- if (sort === undefined || sort === null || sort === 'ascending' || sort === 'descending') {
533
- continue;
534
- }
535
-
536
- const field = ch.field as string | undefined;
537
- const scale = (ch.scale ?? {}) as Record<string, unknown>;
538
-
539
- // An explicit domain wins over sort (matches canonical sort behavior)
540
- if (scale.domain) {
541
- const { sort: _sort, ...rest } = ch;
542
- updated[channel] = rest;
543
- changed = true;
544
- continue;
545
- }
546
- if (!field || !data || data.length === 0) continue;
547
-
548
- let domain: string[] | undefined;
549
- if (Array.isArray(sort)) {
550
- // Value-array sort: listed values first, remaining values in data order
551
- const present = uniqueFieldStrings(data, field);
552
- const listed = sort.map(String).filter((v) => present.includes(v));
553
- const rest = present.filter((v) => !listed.includes(v));
554
- domain = [...listed, ...rest];
555
- } else {
556
- // 'x' | '-x' | 'y' | '-y' or { field, op, order }
557
- let targetField: string | undefined;
558
- let op: AggregateOp = 'sum';
559
- let descending = false;
560
- let isCount = false;
561
- if (typeof sort === 'string' && /^-?[xy]$/.test(sort)) {
562
- descending = sort.startsWith('-');
563
- const target = updated[sort.replace('-', '')] as Record<string, unknown> | undefined;
564
- if (!target || typeof target !== 'object') continue;
565
- targetField = target.field as string | undefined;
566
- if (target.aggregate === 'count') isCount = true;
567
- else if (typeof target.aggregate === 'string') op = target.aggregate as AggregateOp;
568
- } else if (typeof sort === 'object' && 'field' in (sort as object)) {
569
- const s = sort as { field: string; op?: AggregateOp; order?: string };
570
- targetField = s.field;
571
- if (s.op === 'count') isCount = true;
572
- else if (s.op) op = s.op;
573
- descending = s.order === 'descending';
574
- } else {
575
- // Unknown sort shape: fall back to data order
576
- const { sort: _sort, ...rest } = ch;
577
- updated[channel] = rest;
578
- changed = true;
579
- continue;
580
- }
581
- if (!targetField && !isCount) continue;
582
-
583
- // Aggregate the target values per category (default op: sum, VL aligned)
584
- const groups = new Map<string, number[]>();
585
- for (const row of data) {
586
- const raw = row[field];
587
- if (raw == null) continue;
588
- const key = String(raw);
589
- let bucket = groups.get(key);
590
- if (!bucket) {
591
- bucket = [];
592
- groups.set(key, bucket);
593
- }
594
- if (isCount) {
595
- bucket.push(1);
596
- } else {
597
- const value = Number(row[targetField as string]);
598
- if (Number.isFinite(value)) bucket.push(value);
599
- }
600
- }
601
- const entries = [...groups.entries()].map(
602
- ([key, values]) => [key, computeAggregate(isCount ? 'count' : op, values)] as const,
603
- );
604
- entries.sort((a, b) => (descending ? b[1] - a[1] : a[1] - b[1]));
605
- domain = entries.map(([key]) => key);
606
- }
607
-
608
- if (!domain) continue;
609
- const { sort: _sort, ...rest } = ch;
610
- updated[channel] = { ...rest, scale: { ...scale, domain } };
611
- changed = true;
612
- }
613
-
614
- return changed ? { ...spec, encoding: updated } : spec;
615
- }
616
-
617
- // ---------------------------------------------------------------------------
618
- // Composition: chart and layer expansion
619
- // ---------------------------------------------------------------------------
620
-
621
- function expandChartSugar(
622
- spec: Record<string, unknown>,
623
- warnings: string[],
624
- inheritedData?: DataRow[],
625
- ): Record<string, unknown> {
626
- let out = unwrapDataValues(spec);
627
- out = expandTopLevelTitle(out);
628
- out = expandDescriptionSugar(out);
629
- out = stripIgnoredKeys(out, warnings);
630
- out = applyFixedSizeDefault(out);
631
- out = expandAnnotationSugar(out, warnings);
632
- out = expandChannelSugar(out, warnings);
633
- out = expandEncodingSugar(out);
634
- out = resolveSortSugar(out, inheritedData);
635
- out = expandCountAggregate(out);
636
- return out;
637
- }
638
-
639
- function expandLayerSugar(
640
- spec: Record<string, unknown>,
641
- warnings: string[],
642
- inheritedData?: DataRow[],
643
- parentEncoding?: Record<string, unknown>,
644
- ): Record<string, unknown> {
645
- let out = unwrapDataValues(spec);
646
- out = expandTopLevelTitle(out);
647
- out = stripIgnoredKeys(out, warnings);
648
- out = expandAnnotationSugar(out, warnings);
649
- // Shared encoding gets the mark-independent channel sugar (axis, scheme,
650
- // legend, dead channels) and sort resolution; mark-dependent sugar (value
651
- // defs, theta) resolves per leaf after encoding inheritance.
652
- out = expandChannelSugar(out, warnings);
653
- out = resolveSortSugar(out, inheritedData);
654
-
655
- const layerData = Array.isArray(out.data) ? (out.data as DataRow[]) : inheritedData;
656
- const ownEncoding = out.encoding as Record<string, unknown> | undefined;
657
- const mergedEncoding =
658
- parentEncoding && ownEncoding
659
- ? { ...parentEncoding, ...ownEncoding }
660
- : (ownEncoding ?? parentEncoding);
661
-
662
- const layer = out.layer as unknown[];
663
- const expandedChildren = layer.map((child) => {
664
- if (!child || typeof child !== 'object' || Array.isArray(child)) return child;
665
- const childObj = child as Record<string, unknown>;
666
- if (Array.isArray(childObj.layer)) {
667
- return expandLayerSugar(childObj, warnings, layerData, mergedEncoding);
668
- }
669
- if ('mark' in childObj) {
670
- return expandChartSugar(childObj, warnings, layerData);
671
- }
672
- return child;
673
- });
674
- return { ...out, layer: expandedChildren };
675
- }
676
-
677
- // ---------------------------------------------------------------------------
678
- // Public API
679
- // ---------------------------------------------------------------------------
680
-
681
- /**
682
- * Expand deprecated `valueFormat` into `encoding.value.format` for
683
- * sankey, tilemap, and barlist specs. Encoding-level format wins on conflict.
684
- */
685
- function expandValueFormatSugar(
686
- spec: Record<string, unknown>,
687
- warnings: string[],
688
- ): Record<string, unknown> {
689
- const type = spec.type as string | undefined;
690
- if (type !== 'sankey' && type !== 'tilemap' && type !== 'barlist') return spec;
691
- if (typeof spec.valueFormat !== 'string') return spec;
692
-
693
- const encoding = spec.encoding as Record<string, unknown> | undefined;
694
- const valueCh = encoding?.value as Record<string, unknown> | undefined;
695
-
696
- // Only expand into encoding when encoding.value already exists (the shorthand
697
- // tilemap/barlist API may not have explicit encoding — the normalizer infers it).
698
- if (!valueCh || typeof valueCh !== 'object') {
699
- warnings.push(
700
- '[openchart] valueFormat is deprecated; set format on the encoding value channel instead (e.g. encoding.value.format).',
701
- );
702
- return spec;
703
- }
704
-
705
- if (valueCh.format !== undefined) {
706
- const { valueFormat: _vf, ...rest } = spec;
707
- warnings.push(
708
- '[openchart] valueFormat is deprecated; the encoding.value.format field takes precedence and valueFormat was ignored.',
709
- );
710
- return rest;
711
- }
712
-
713
- warnings.push(
714
- '[openchart] valueFormat is deprecated; set format on the encoding value channel instead (e.g. encoding.value.format).',
715
- );
716
- const { valueFormat, ...rest } = spec;
717
- return {
718
- ...rest,
719
- encoding: { ...encoding, value: { ...valueCh, format: valueFormat } },
720
- };
721
- }
722
-
723
- /**
724
- * Expand VL-idiom and encoding-level sugar on a raw chart or layer spec.
725
- * Non-chart specs (tables, graphs, sankey, tilemap, barlist) get valueFormat
726
- * expansion only. Deprecation warnings are pushed onto `warnings`; callers
727
- * surface them (compileChart/compileLayer console.warn each unique message
728
- * once per compile).
729
- */
730
- export function expandSpecSugar(
731
- spec: Record<string, unknown>,
732
- warnings: string[] = [],
733
- ): Record<string, unknown> {
734
- if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return spec;
735
- if (Array.isArray(spec.layer) && typeof spec.type !== 'string') {
736
- return expandLayerSugar(spec, warnings);
737
- }
738
- if (!('mark' in spec)) {
739
- return expandValueFormatSugar(spec, warnings);
740
- }
741
- return expandChartSugar(spec, warnings);
742
- }
743
-
744
- /** Advisory-warning sink: a host callback, or the console.warn default. */
745
- export type WarnSink = (message: string) => void;
746
-
747
- const defaultWarnSink: WarnSink = (message) => console.warn(message);
748
-
749
- /**
750
- * Surface sugar/deprecation warnings: emit each unique message once per compile.
751
- * Duplicates within a compile (e.g. the same dead channel on two layer children)
752
- * collapse to one warning.
753
- *
754
- * The engine is isomorphic and never touches the global `console` on its own:
755
- * `sink` is the host-provided `CompileOptions.onWarn` when present, so a host can
756
- * collect, reroute, or silence warnings (SSR, tests, dev overlays). It falls back
757
- * to `console.warn` only when no sink is passed, preserving the prior behavior.
758
- */
759
- export function emitSpecWarnings(warnings: string[], sink: WarnSink = defaultWarnSink): void {
760
- if (warnings.length === 0) return;
761
- const seen = new Set<string>();
762
- for (const warning of warnings) {
763
- if (seen.has(warning)) continue;
764
- seen.add(warning);
765
- sink(warning);
766
- }
767
- }