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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/index.d.ts +7 -0
  2. package/dist/index.js +123 -54
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  7. package/src/__tests__/annotation-behavior.test.ts +0 -852
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -359
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -376
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -545
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -630
  133. package/src/compile/size-scale.ts +0 -141
  134. package/src/compile/spec-sugar.ts +0 -767
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1548
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  167. package/src/compiler/__tests__/validate.test.ts +0 -731
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -734
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -2093
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  185. package/src/geo/compile-map.ts +0 -1376
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -65
  188. package/src/geo/types.ts +0 -26
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -748
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. package/src/transforms/window.ts +0 -185
@@ -1,1053 +0,0 @@
1
- /**
2
- * Scale computation from encoding spec + data.
3
- *
4
- * Creates D3 scales that map data values to pixel positions.
5
- * Temporal -> scaleTime(), quantitative -> scaleLinear(),
6
- * nominal/ordinal -> scaleBand() or scaleOrdinal(), depending on context.
7
- */
8
-
9
- import type {
10
- DataRow,
11
- Encoding,
12
- EncodingChannel,
13
- FieldFormatContext,
14
- Rect,
15
- ScaleType,
16
- } from '@opendata-ai/openchart-core';
17
- import { computeFieldFormatContext } from '@opendata-ai/openchart-core';
18
- import { extent, max, min } from 'd3-array';
19
- import type {
20
- ScaleBand,
21
- ScaleLinear,
22
- ScaleLogarithmic,
23
- ScaleOrdinal,
24
- ScalePoint,
25
- ScalePower,
26
- ScaleQuantile,
27
- ScaleQuantize,
28
- ScaleSymLog,
29
- ScaleThreshold,
30
- ScaleTime,
31
- } from 'd3-scale';
32
- import {
33
- scaleBand,
34
- scaleLinear,
35
- scaleLog,
36
- scaleOrdinal,
37
- scalePoint,
38
- scalePow,
39
- scaleQuantile,
40
- scaleQuantize,
41
- scaleSqrt,
42
- scaleSymlog,
43
- scaleThreshold,
44
- scaleTime,
45
- scaleUtc,
46
- } from 'd3-scale';
47
-
48
- import type { NormalizedChartSpec } from '../compiler/types';
49
- import { DEFAULT_BIN_COUNT, sampleRampColors } from '../legend/continuous';
50
-
51
- // ---------------------------------------------------------------------------
52
- // Types
53
- // ---------------------------------------------------------------------------
54
-
55
- /** Continuous D3 scales (linear, time, log, pow, sqrt, symlog) that support .ticks() and .nice(). */
56
- export type D3ContinuousScale =
57
- | ScaleLinear<number, number>
58
- | ScaleTime<number, number>
59
- | ScaleLogarithmic<number, number>
60
- | ScalePower<number, number>
61
- | ScaleSymLog<number, number>;
62
-
63
- /** Discretizing D3 scales (quantile, quantize, threshold). */
64
- export type D3DiscretizingScale =
65
- | ScaleQuantile<number>
66
- | ScaleQuantize<number>
67
- | ScaleThreshold<number, number>;
68
-
69
- /** Categorical D3 scales (band, point, ordinal) that support .domain() as string[]. */
70
- export type D3CategoricalScale =
71
- | ScaleBand<string>
72
- | ScalePoint<string>
73
- | ScaleOrdinal<string, string>;
74
-
75
- /** Union of all D3 scale types used by the engine. */
76
- export type D3Scale = D3ContinuousScale | D3CategoricalScale | D3DiscretizingScale;
77
-
78
- /** A sequential color scale mapping numbers to color strings. */
79
- export type D3SequentialColorScale = ScaleLinear<string, string>;
80
-
81
- /** All resolved scale type identifiers. */
82
- export type ResolvedScaleType = ScaleType | 'sequential';
83
-
84
- /**
85
- * A resolved scale wrapping a d3 scale with type metadata.
86
- * We need to carry the scale type around so axes and marks know
87
- * how to interpret the domain/range. Consumers use the `type` discriminant
88
- * to determine which D3 methods are available on the scale.
89
- */
90
- export interface ResolvedScale {
91
- /** The d3 scale function. Maps domain value -> pixel position or color. */
92
- scale: D3Scale;
93
- /** The scale type for downstream use. */
94
- type: ResolvedScaleType;
95
- /** The encoding channel this scale was derived from. */
96
- channel: EncodingChannel;
97
- /** Per-field formatting context (extent, allIntegers) from the ORIGINAL data. */
98
- formatContext?: FieldFormatContext;
99
- }
100
-
101
- /** All resolved scales for a chart. */
102
- export interface ResolvedScales {
103
- x?: ResolvedScale;
104
- y?: ResolvedScale;
105
- color?: ResolvedScale;
106
- size?: ResolvedScale;
107
- /** Default color for single-series charts (first categorical palette color or markDef.fill gradient). */
108
- defaultColor?: string | import('@opendata-ai/openchart-core').GradientDef;
109
- }
110
-
111
- // ---------------------------------------------------------------------------
112
- // Helpers
113
- // ---------------------------------------------------------------------------
114
-
115
- /** Extract all non-null values for a field from data. */
116
- function fieldValues(data: DataRow[], field: string): unknown[] {
117
- return data.map((d) => d[field]).filter((v) => v != null);
118
- }
119
-
120
- /** Parse values to dates. */
121
- function parseDates(values: unknown[]): Date[] {
122
- return values
123
- .map((v) => (v instanceof Date ? v : new Date(String(v))))
124
- .filter((d) => !Number.isNaN(d.getTime()));
125
- }
126
-
127
- /** Parse values to numbers. */
128
- function parseNumbers(values: unknown[]): number[] {
129
- return values
130
- .map((v) => (typeof v === 'number' ? v : Number(v)))
131
- .filter((n) => Number.isFinite(n));
132
- }
133
-
134
- /** Get unique string values preserving order. */
135
- function uniqueStrings(values: unknown[]): string[] {
136
- const seen = new Set<string>();
137
- const result: string[] = [];
138
- for (const v of values) {
139
- const s = String(v);
140
- if (!seen.has(s)) {
141
- seen.add(s);
142
- result.push(s);
143
- }
144
- }
145
- return result;
146
- }
147
-
148
- /**
149
- * Apply sort order to categorical domain values.
150
- * - 'ascending': sort alphabetically/numerically ascending
151
- * - 'descending': sort descending
152
- * - null | undefined: preserve data order (no sorting)
153
- *
154
- * VL sort forms ('-y', value arrays, { field, op, order }) are resolved into
155
- * an explicit scale.domain by the pre-validation sugar expansion; any that
156
- * reach this point fall back to data order.
157
- */
158
- export function applyCategoricalSort(values: string[], sort: EncodingChannel['sort']): string[] {
159
- if (sort !== 'ascending' && sort !== 'descending') return values;
160
-
161
- const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
162
- if (sort === 'descending') sorted.reverse();
163
- return sorted;
164
- }
165
-
166
- // ---------------------------------------------------------------------------
167
- // Helpers: apply common scale config
168
- // ---------------------------------------------------------------------------
169
-
170
- /** Apply clamp and reverse config to a continuous scale. */
171
- function applyContinuousConfig(
172
- scale: { clamp(v: boolean): unknown; range(): number[]; range(r: number[]): unknown },
173
- channel: EncodingChannel,
174
- ): void {
175
- if (channel.scale?.clamp) {
176
- scale.clamp(true);
177
- }
178
- if (channel.scale?.reverse) {
179
- const [r0, r1] = scale.range() as number[];
180
- scale.range([r1, r0]);
181
- }
182
- }
183
-
184
- // ---------------------------------------------------------------------------
185
- // Scale builders
186
- // ---------------------------------------------------------------------------
187
-
188
- function buildTimeScale(
189
- channel: EncodingChannel,
190
- data: DataRow[],
191
- rangeStart: number,
192
- rangeEnd: number,
193
- ): ResolvedScale {
194
- const values = parseDates(fieldValues(data, channel.field));
195
- const domain = channel.scale?.domain
196
- ? [new Date(channel.scale.domain[0] as string), new Date(channel.scale.domain[1] as string)]
197
- : (extent(values) as [Date, Date]);
198
-
199
- const scale = scaleTime().domain(domain).range([rangeStart, rangeEnd]);
200
-
201
- // Temporal scales default to nice: false because date data typically starts
202
- // at clean boundaries and nice() rounds the domain outward, creating visible
203
- // gaps (e.g. data starting 2018-01-01 gets rounded to 2017-01-01).
204
- // Users can opt in with scale: { nice: true }.
205
- if (!channel.scale?.domain && channel.scale?.nice === true) {
206
- scale.nice();
207
- }
208
- applyContinuousConfig(scale, channel);
209
-
210
- return { scale, type: 'time', channel };
211
- }
212
-
213
- function buildUtcScale(
214
- channel: EncodingChannel,
215
- data: DataRow[],
216
- rangeStart: number,
217
- rangeEnd: number,
218
- ): ResolvedScale {
219
- const values = parseDates(fieldValues(data, channel.field));
220
- const domain = channel.scale?.domain
221
- ? [new Date(channel.scale.domain[0] as string), new Date(channel.scale.domain[1] as string)]
222
- : (extent(values) as [Date, Date]);
223
-
224
- const scale = scaleUtc().domain(domain).range([rangeStart, rangeEnd]);
225
-
226
- // Temporal scales default to nice: false (see buildTimeScale comment).
227
- if (!channel.scale?.domain && channel.scale?.nice === true) {
228
- scale.nice();
229
- }
230
- applyContinuousConfig(scale, channel);
231
-
232
- return { scale, type: 'utc', channel };
233
- }
234
-
235
- function buildLinearScale(
236
- channel: EncodingChannel,
237
- data: DataRow[],
238
- rangeStart: number,
239
- rangeEnd: number,
240
- ): ResolvedScale {
241
- const values = parseNumbers(fieldValues(data, channel.field));
242
-
243
- let domainMin: number;
244
- let domainMax: number;
245
-
246
- if (channel.scale?.domain) {
247
- const [d0, d1] = channel.scale.domain as [number, number];
248
- domainMin = d0;
249
- domainMax = d1;
250
- } else {
251
- domainMin = min(values) ?? 0;
252
- domainMax = max(values) ?? 1;
253
-
254
- // Include zero by default for quantitative scales
255
- if (channel.scale?.zero !== false) {
256
- domainMin = Math.min(0, domainMin);
257
- domainMax = Math.max(0, domainMax);
258
- }
259
- }
260
-
261
- const scale = scaleLinear().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
262
-
263
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
264
- scale.nice();
265
-
266
- // nice() can round the domain min down to 0 even when zero: false.
267
- // Re-nice with more ticks to tighten the domain around the data range.
268
- if (channel.scale?.zero === false) {
269
- const [nicedMin, nicedMax] = scale.domain();
270
- if (nicedMin < domainMin || nicedMax > domainMax) {
271
- scale.domain([domainMin, domainMax]);
272
- scale.nice(20);
273
- }
274
- }
275
- }
276
- applyContinuousConfig(scale, channel);
277
-
278
- return { scale, type: 'linear', channel };
279
- }
280
-
281
- function buildLogScale(
282
- channel: EncodingChannel,
283
- data: DataRow[],
284
- rangeStart: number,
285
- rangeEnd: number,
286
- ): ResolvedScale {
287
- const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
288
- const domainMin = channel.scale?.domain
289
- ? (channel.scale.domain as [number, number])[0]
290
- : (min(values) ?? 1);
291
- const domainMax = channel.scale?.domain
292
- ? (channel.scale.domain as [number, number])[1]
293
- : (max(values) ?? 10);
294
-
295
- const scale = scaleLog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
296
-
297
- if (channel.scale?.base !== undefined) {
298
- scale.base(channel.scale.base);
299
- }
300
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
301
- scale.nice();
302
- }
303
- applyContinuousConfig(scale, channel);
304
-
305
- return { scale, type: 'log', channel };
306
- }
307
-
308
- function buildPowScale(
309
- channel: EncodingChannel,
310
- data: DataRow[],
311
- rangeStart: number,
312
- rangeEnd: number,
313
- ): ResolvedScale {
314
- const values = parseNumbers(fieldValues(data, channel.field));
315
-
316
- let domainMin: number;
317
- let domainMax: number;
318
-
319
- if (channel.scale?.domain) {
320
- [domainMin, domainMax] = channel.scale.domain as [number, number];
321
- } else {
322
- domainMin = min(values) ?? 0;
323
- domainMax = max(values) ?? 1;
324
- if (channel.scale?.zero !== false) {
325
- domainMin = Math.min(0, domainMin);
326
- domainMax = Math.max(0, domainMax);
327
- }
328
- }
329
-
330
- const scale = scalePow().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
331
-
332
- if (channel.scale?.exponent !== undefined) {
333
- scale.exponent(channel.scale.exponent);
334
- }
335
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
336
- scale.nice();
337
- }
338
- applyContinuousConfig(scale, channel);
339
-
340
- return { scale, type: 'pow', channel };
341
- }
342
-
343
- function buildSqrtScale(
344
- channel: EncodingChannel,
345
- data: DataRow[],
346
- rangeStart: number,
347
- rangeEnd: number,
348
- ): ResolvedScale {
349
- const values = parseNumbers(fieldValues(data, channel.field));
350
-
351
- let domainMin: number;
352
- let domainMax: number;
353
-
354
- if (channel.scale?.domain) {
355
- [domainMin, domainMax] = channel.scale.domain as [number, number];
356
- } else {
357
- domainMin = min(values) ?? 0;
358
- domainMax = max(values) ?? 1;
359
- if (channel.scale?.zero !== false) {
360
- domainMin = Math.min(0, domainMin);
361
- domainMax = Math.max(0, domainMax);
362
- }
363
- }
364
-
365
- const scale = scaleSqrt().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
366
-
367
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
368
- scale.nice();
369
- }
370
- applyContinuousConfig(scale, channel);
371
-
372
- return { scale, type: 'sqrt', channel };
373
- }
374
-
375
- function buildSymlogScale(
376
- channel: EncodingChannel,
377
- data: DataRow[],
378
- rangeStart: number,
379
- rangeEnd: number,
380
- ): ResolvedScale {
381
- const values = parseNumbers(fieldValues(data, channel.field));
382
-
383
- let domainMin: number;
384
- let domainMax: number;
385
-
386
- if (channel.scale?.domain) {
387
- [domainMin, domainMax] = channel.scale.domain as [number, number];
388
- } else {
389
- domainMin = min(values) ?? 0;
390
- domainMax = max(values) ?? 1;
391
- if (channel.scale?.zero !== false) {
392
- domainMin = Math.min(0, domainMin);
393
- domainMax = Math.max(0, domainMax);
394
- }
395
- }
396
-
397
- const scale = scaleSymlog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
398
-
399
- if (channel.scale?.constant !== undefined) {
400
- scale.constant(channel.scale.constant);
401
- }
402
- if (!channel.scale?.domain && channel.scale?.nice !== false) {
403
- scale.nice();
404
- }
405
- applyContinuousConfig(scale, channel);
406
-
407
- return { scale, type: 'symlog', channel };
408
- }
409
-
410
- function buildQuantileScale(
411
- channel: EncodingChannel,
412
- data: DataRow[],
413
- rangeStart: number,
414
- rangeEnd: number,
415
- ): ResolvedScale {
416
- const values = parseNumbers(fieldValues(data, channel.field));
417
- const range = channel.scale?.range
418
- ? (channel.scale.range as number[])
419
- : evenRange(rangeStart, rangeEnd, 4);
420
-
421
- const scale = scaleQuantile<number>().domain(values).range(range);
422
-
423
- return { scale: scale as unknown as D3Scale, type: 'quantile', channel };
424
- }
425
-
426
- function buildQuantizeScale(
427
- channel: EncodingChannel,
428
- data: DataRow[],
429
- rangeStart: number,
430
- rangeEnd: number,
431
- ): ResolvedScale {
432
- const values = parseNumbers(fieldValues(data, channel.field));
433
- const domainMin = channel.scale?.domain
434
- ? (channel.scale.domain as [number, number])[0]
435
- : (min(values) ?? 0);
436
- const domainMax = channel.scale?.domain
437
- ? (channel.scale.domain as [number, number])[1]
438
- : (max(values) ?? 1);
439
- const range = channel.scale?.range
440
- ? (channel.scale.range as number[])
441
- : evenRange(rangeStart, rangeEnd, 4);
442
-
443
- const scale = scaleQuantize<number>().domain([domainMin, domainMax]).range(range);
444
-
445
- return { scale: scale as unknown as D3Scale, type: 'quantize', channel };
446
- }
447
-
448
- function buildThresholdScale(
449
- channel: EncodingChannel,
450
- _data: DataRow[],
451
- rangeStart: number,
452
- rangeEnd: number,
453
- ): ResolvedScale {
454
- // Threshold scales require explicit domain breakpoints
455
- const domainBreaks = channel.scale?.domain ? (channel.scale.domain as number[]) : [0.5];
456
- const range = channel.scale?.range
457
- ? (channel.scale.range as number[])
458
- : evenRange(rangeStart, rangeEnd, domainBreaks.length + 1);
459
-
460
- const scale = scaleThreshold<number, number>().domain(domainBreaks).range(range);
461
-
462
- return { scale: scale as unknown as D3Scale, type: 'threshold', channel };
463
- }
464
-
465
- /** Generate an evenly-spaced range of `count` values between start and end. */
466
- function evenRange(start: number, end: number, count: number): number[] {
467
- if (count <= 1) return [start];
468
- const step = (end - start) / (count - 1);
469
- return Array.from({ length: count }, (_, i) => start + step * i);
470
- }
471
-
472
- /**
473
- * Estimate d3 scaleBand bandwidth for a given plot width and category count,
474
- * mirroring buildBandScale's padding resolution. d3 sets both paddingInner and
475
- * paddingOuter to `padding`, then paddingInner/paddingOuter overrides adjust
476
- * each independently. step = width / (n - paddingInner + 2*paddingOuter);
477
- * bandwidth = step * (1 - paddingInner).
478
- */
479
- /** d3 scaleBand padding resolution: `padding` sets both, overrides win. */
480
- function resolveBandPadding(scaleConfig: EncodingChannel['scale'] | undefined): {
481
- paddingInner: number;
482
- paddingOuter: number;
483
- } {
484
- const padding = scaleConfig?.padding ?? 0.35;
485
- return {
486
- paddingInner: scaleConfig?.paddingInner ?? padding,
487
- paddingOuter: scaleConfig?.paddingOuter ?? padding,
488
- };
489
- }
490
-
491
- export function estimateBandwidth(
492
- scaleConfig: EncodingChannel['scale'] | undefined,
493
- plotWidth: number,
494
- n: number,
495
- ): number {
496
- const { paddingInner } = resolveBandPadding(scaleConfig);
497
- return estimateBandStep(scaleConfig, plotWidth, n) * (1 - paddingInner);
498
- }
499
-
500
- /**
501
- * Estimate d3 scaleBand step (center-to-center band distance) for a given
502
- * plot width and category count, using the same padding resolution as
503
- * estimateBandwidth. The step — not the bandwidth — is the anchor spacing
504
- * that drives rotated-label collision checks.
505
- */
506
- export function estimateBandStep(
507
- scaleConfig: EncodingChannel['scale'] | undefined,
508
- plotWidth: number,
509
- n: number,
510
- ): number {
511
- if (n <= 0) return 0;
512
- const { paddingInner, paddingOuter } = resolveBandPadding(scaleConfig);
513
- const denom = n - paddingInner + 2 * paddingOuter;
514
- if (denom <= 0) return 0;
515
- return plotWidth / denom;
516
- }
517
-
518
- /**
519
- * Gap between bands, as a fraction of the step. The 0.35 default is a *bar*
520
- * value: bars need air between them to read as separate quantities. Heatmap
521
- * cells are the opposite -- they tile, and the gap is a hairline gutter that
522
- * only exists to keep adjacent fills from bleeding into each other.
523
- */
524
- const DEFAULT_BAND_PADDING = 0.35;
525
- const HEATMAP_BAND_PADDING = 0.04;
526
-
527
- function buildBandScale(
528
- channel: EncodingChannel,
529
- data: DataRow[],
530
- rangeStart: number,
531
- rangeEnd: number,
532
- defaultPadding: number = DEFAULT_BAND_PADDING,
533
- ): ResolvedScale {
534
- const values = channel.scale?.domain
535
- ? (channel.scale.domain as string[])
536
- : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
537
-
538
- const padding = channel.scale?.padding ?? defaultPadding;
539
- const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
540
-
541
- if (channel.scale?.paddingInner !== undefined) {
542
- scale.paddingInner(channel.scale.paddingInner);
543
- }
544
- if (channel.scale?.paddingOuter !== undefined) {
545
- scale.paddingOuter(channel.scale.paddingOuter);
546
- }
547
- if (channel.scale?.reverse) {
548
- const [r0, r1] = scale.range();
549
- scale.range([r1, r0]);
550
- }
551
-
552
- return { scale, type: 'band', channel };
553
- }
554
-
555
- function buildPointScale(
556
- channel: EncodingChannel,
557
- data: DataRow[],
558
- rangeStart: number,
559
- rangeEnd: number,
560
- ): ResolvedScale {
561
- const values = channel.scale?.domain
562
- ? (channel.scale.domain as string[])
563
- : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
564
-
565
- // Point scales have a single padding knob (outer padding only -- there are no
566
- // bands, so paddingInner is meaningless). Accept `paddingOuter` as an alias so
567
- // a spec written for a band scale doesn't silently no-op when the mark is a
568
- // line; explicit `padding` wins if both are set.
569
- const padding = channel.scale?.padding ?? channel.scale?.paddingOuter ?? 0.5;
570
- const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
571
-
572
- if (channel.scale?.reverse) {
573
- const [r0, r1] = scale.range();
574
- scale.range([r1, r0]);
575
- }
576
-
577
- return { scale, type: 'point', channel };
578
- }
579
-
580
- function buildOrdinalColorScale(
581
- channel: EncodingChannel,
582
- data: DataRow[],
583
- palette: string[],
584
- ): ResolvedScale {
585
- // Use explicit domain if provided, otherwise derive from data
586
- const explicitDomain = channel.scale?.domain as string[] | undefined;
587
- const values = explicitDomain
588
- ? explicitDomain.map(String)
589
- : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
590
-
591
- // Use explicit range if provided, otherwise fall back to theme palette
592
- const explicitRange = channel.scale?.range as string[] | undefined;
593
- const colors = explicitRange ?? palette;
594
-
595
- const scale = scaleOrdinal<string>().domain(values).range(colors);
596
-
597
- return { scale, type: 'ordinal', channel };
598
- }
599
-
600
- function buildSequentialColorScale(
601
- channel: EncodingChannel,
602
- data: DataRow[],
603
- palette: string[],
604
- ): ResolvedScale {
605
- const values = parseNumbers(fieldValues(data, channel.field));
606
- const domainMin = min(values) ?? 0;
607
- const domainMax = max(values) ?? 1;
608
-
609
- // Use explicit range if provided, otherwise fall back to theme palette endpoints
610
- const explicitRange = channel.scale?.range as string[] | undefined;
611
- const colors = explicitRange ?? palette;
612
-
613
- // Explicit multi-stop ranges (e.g. a diverging scheme) interpolate piecewise
614
- // through every stop, with evenly-spaced domain pivots to match. Without an
615
- // explicit range the endpoints-only form is kept so applyColorScaleRange can
616
- // swap in the theme ramp endpoints post-hoc.
617
- const stops =
618
- explicitRange && explicitRange.length > 2
619
- ? explicitRange
620
- : [colors[0], colors[colors.length - 1]];
621
- const domain =
622
- stops.length > 2
623
- ? stops.map((_, i) => domainMin + ((domainMax - domainMin) * i) / (stops.length - 1))
624
- : [domainMin, domainMax];
625
-
626
- const scale = scaleLinear<string>().domain(domain).range(stops).clamp(true);
627
-
628
- // Cast: sequential color scale (number -> string) is structurally incompatible
629
- // with D3Scale (number -> number), but is only ever accessed via scales.color
630
- // where consumers already cast appropriately.
631
- return { scale: scale as unknown as D3Scale, type: 'sequential', channel };
632
- }
633
-
634
- /**
635
- * Build a binned color scale (quantile/quantize/threshold) for a quantitative
636
- * color encoding with an explicit `scale.type`. The range holds colors: an
637
- * explicit `scale.range` wins; otherwise a placeholder sampled from the
638
- * default palette that `applyColorScaleRange` replaces with the theme's
639
- * sequential ramp.
640
- */
641
- function buildBinnedColorScale(
642
- channel: EncodingChannel,
643
- data: DataRow[],
644
- palette: string[],
645
- ): ResolvedScale {
646
- const values = parseNumbers(fieldValues(data, channel.field));
647
- const explicitRange = channel.scale?.range as string[] | undefined;
648
- const scaleType = channel.scale?.type as 'quantile' | 'quantize' | 'threshold';
649
-
650
- if (scaleType === 'threshold') {
651
- // Threshold scales require explicit domain breakpoints; colors = breaks + 1.
652
- const breaks = (channel.scale?.domain as number[] | undefined) ?? [0.5];
653
- const colors = sampleRampColors(explicitRange ?? palette, breaks.length + 1);
654
- const scale = scaleThreshold<number, string>().domain(breaks).range(colors);
655
- return { scale: scale as unknown as D3Scale, type: 'threshold', channel };
656
- }
657
-
658
- const binCount = explicitRange?.length ?? DEFAULT_BIN_COUNT;
659
- const colors = explicitRange ?? sampleRampColors(palette, binCount);
660
-
661
- if (scaleType === 'quantile') {
662
- const scale = scaleQuantile<string>().domain(values).range(colors);
663
- return { scale: scale as unknown as D3Scale, type: 'quantile', channel };
664
- }
665
-
666
- const explicitDomain = channel.scale?.domain as [number, number] | undefined;
667
- const domainMin = explicitDomain?.[0] ?? min(values) ?? 0;
668
- const domainMax = explicitDomain?.[1] ?? max(values) ?? 1;
669
- const scale = scaleQuantize<string>().domain([domainMin, domainMax]).range(colors);
670
- return { scale: scale as unknown as D3Scale, type: 'quantize', channel };
671
- }
672
-
673
- // ---------------------------------------------------------------------------
674
- // Positional scale selection
675
- // ---------------------------------------------------------------------------
676
-
677
- /**
678
- * Choose the right scale type for a positional channel (x or y).
679
- * Respects explicit scale.type overrides from the spec.
680
- */
681
- function buildPositionalScale(
682
- channel: EncodingChannel,
683
- data: DataRow[],
684
- rangeStart: number,
685
- rangeEnd: number,
686
- chartType: string,
687
- axis: 'x' | 'y',
688
- ): ResolvedScale {
689
- // Explicit scale type override
690
- if (channel.scale?.type) {
691
- switch (channel.scale.type) {
692
- case 'time':
693
- return buildTimeScale(channel, data, rangeStart, rangeEnd);
694
- case 'utc':
695
- return buildUtcScale(channel, data, rangeStart, rangeEnd);
696
- case 'linear':
697
- return buildLinearScale(channel, data, rangeStart, rangeEnd);
698
- case 'log':
699
- return buildLogScale(channel, data, rangeStart, rangeEnd);
700
- case 'pow':
701
- return buildPowScale(channel, data, rangeStart, rangeEnd);
702
- case 'sqrt':
703
- return buildSqrtScale(channel, data, rangeStart, rangeEnd);
704
- case 'symlog':
705
- return buildSymlogScale(channel, data, rangeStart, rangeEnd);
706
- case 'quantile':
707
- return buildQuantileScale(channel, data, rangeStart, rangeEnd);
708
- case 'quantize':
709
- return buildQuantizeScale(channel, data, rangeStart, rangeEnd);
710
- case 'threshold':
711
- return buildThresholdScale(channel, data, rangeStart, rangeEnd);
712
- case 'band':
713
- return buildBandScale(channel, data, rangeStart, rangeEnd);
714
- case 'point':
715
- return buildPointScale(channel, data, rangeStart, rangeEnd);
716
- case 'ordinal':
717
- return buildBandScale(channel, data, rangeStart, rangeEnd);
718
- }
719
- }
720
-
721
- // Infer from field type
722
- switch (channel.type) {
723
- case 'temporal':
724
- return buildTimeScale(channel, data, rangeStart, rangeEnd);
725
- case 'quantitative':
726
- return buildLinearScale(channel, data, rangeStart, rangeEnd);
727
- case 'nominal':
728
- case 'ordinal':
729
- // Bar and range charts use band scales for their categorical axis (both
730
- // orientations). Beeswarm lanes are band scales too: each category gets
731
- // a band whose center anchors one swarm, on whichever axis carries the
732
- // nominal channel.
733
- //
734
- // `rect` is the only mark that needs a band on *both* axes: a heatmap cell
735
- // is sized by the bandwidth of each axis, so it takes no `axis` guard. A
736
- // point scale would give it zero width and height (and it did: `rect` cells
737
- // rendered as invisible zero-area marks). It also tiles, so it takes the
738
- // hairline gutter rather than the bar-sized gap.
739
- if (chartType === 'rect') {
740
- return buildBandScale(channel, data, rangeStart, rangeEnd, HEATMAP_BAND_PADDING);
741
- }
742
- if (
743
- chartType === 'bar' ||
744
- chartType === 'beeswarm' ||
745
- chartType === 'range' ||
746
- ((chartType === 'circle' || chartType === 'lollipop') && axis === 'y')
747
- ) {
748
- return buildBandScale(channel, data, rangeStart, rangeEnd);
749
- }
750
- return buildPointScale(channel, data, rangeStart, rangeEnd);
751
- default:
752
- return buildLinearScale(channel, data, rangeStart, rangeEnd);
753
- }
754
- }
755
-
756
- // ---------------------------------------------------------------------------
757
- // Public API
758
- // ---------------------------------------------------------------------------
759
-
760
- /**
761
- * Compute D3 scales from encoding channels and data.
762
- *
763
- * @param spec - Normalized chart spec.
764
- * @param chartArea - The computed chart drawing area.
765
- * @param data - Data rows.
766
- * @returns ResolvedScales with d3 scale instances.
767
- */
768
- export function computeScales(
769
- spec: NormalizedChartSpec,
770
- chartArea: Rect,
771
- data: DataRow[],
772
- ): ResolvedScales {
773
- const result: ResolvedScales = {};
774
- const encoding = spec.encoding as Encoding;
775
-
776
- // Marks that encode position (not length) default to zero: false so the
777
- // domain fits the data range. Line charts show trends — anchoring at zero
778
- // wastes space. Scatter/beeswarm encode raw observations whose spread is
779
- // the story. Text marks must match the point layer they label.
780
- if (
781
- spec.markType === 'point' ||
782
- spec.markType === 'line' ||
783
- spec.markType === 'beeswarm' ||
784
- spec.markType === 'range' ||
785
- spec.markType === 'text'
786
- ) {
787
- if (encoding.x?.type === 'quantitative' && encoding.x.scale?.zero === undefined) {
788
- if (!encoding.x.scale) {
789
- (encoding.x as { scale?: Record<string, unknown> }).scale = { zero: false };
790
- } else {
791
- (encoding.x.scale as Record<string, unknown>).zero = false;
792
- }
793
- }
794
- if (encoding.y?.type === 'quantitative' && encoding.y.scale?.zero === undefined) {
795
- if (!encoding.y.scale) {
796
- (encoding.y as { scale?: Record<string, unknown> }).scale = { zero: false };
797
- } else {
798
- (encoding.y.scale as Record<string, unknown>).zero = false;
799
- }
800
- }
801
- }
802
-
803
- if (encoding.x) {
804
- // For stacked bars, the x-domain needs the max category sum, not max individual value.
805
- // Without this, stacked bars would clip past the chart area.
806
- let xData = data;
807
- let xChannel = encoding.x;
808
- // Range charts span x to x2: the x-domain must cover both endpoint fields.
809
- // Synthetic rows map x2 values into the x field so buildLinearScale sees them.
810
- if (spec.markType === 'range' && encoding.x2 && encoding.x.type === 'quantitative') {
811
- const xField = encoding.x.field;
812
- const x2Field = encoding.x2.field;
813
- xData = [...data, ...data.map((row) => ({ [xField]: row[x2Field] }) as DataRow)];
814
- }
815
- const xStackEnabled =
816
- encoding.x.stack === true ||
817
- encoding.x.stack === 'zero' ||
818
- encoding.x.stack === 'normalize' ||
819
- encoding.x.stack === 'center';
820
- if (
821
- spec.markType === 'bar' &&
822
- encoding.color &&
823
- encoding.x.type === 'quantitative' &&
824
- xStackEnabled
825
- ) {
826
- if (encoding.x.stack === 'normalize') {
827
- // Normalize: domain is [0, 1], default to percentage axis
828
- const existingAxis = encoding.x.axis;
829
- const axis =
830
- existingAxis === false || existingAxis?.format
831
- ? existingAxis
832
- : { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
833
- xChannel = {
834
- ...encoding.x,
835
- scale: { ...encoding.x.scale, domain: [0, 1], nice: false },
836
- axis,
837
- };
838
- } else if (encoding.x.stack === 'center') {
839
- // Center: compute max half-sum for symmetric domain
840
- const yField = encoding.y?.field;
841
- const xField = encoding.x.field;
842
- if (yField) {
843
- const sums = new Map<string, number>();
844
- for (const row of data) {
845
- const cat = String(row[yField] ?? '');
846
- const val = Number(row[xField] ?? 0);
847
- if (Number.isFinite(val) && val > 0) {
848
- sums.set(cat, (sums.get(cat) ?? 0) + val);
849
- }
850
- }
851
- const maxSum = Math.max(...sums.values(), 0);
852
- const half = maxSum / 2;
853
- xChannel = {
854
- ...encoding.x,
855
- scale: { ...encoding.x.scale, domain: [-half, half], zero: true },
856
- };
857
- }
858
- } else {
859
- // Zero (default): domain extends to max category sum
860
- const yField = encoding.y?.field;
861
- const xField = encoding.x.field;
862
- if (yField) {
863
- const sums = new Map<string, number>();
864
- for (const row of data) {
865
- const cat = String(row[yField] ?? '');
866
- const val = Number(row[xField] ?? 0);
867
- if (Number.isFinite(val) && val > 0) {
868
- sums.set(cat, (sums.get(cat) ?? 0) + val);
869
- }
870
- }
871
- const maxSum = Math.max(...sums.values(), 0);
872
- // Create a synthetic row with the max stack sum so buildLinearScale sees it
873
- xData = [...data, { [xField]: maxSum } as DataRow];
874
- }
875
- }
876
- }
877
-
878
- result.x = buildPositionalScale(
879
- xChannel,
880
- xData,
881
- chartArea.x,
882
- chartArea.x + chartArea.width,
883
- spec.markType,
884
- 'x',
885
- );
886
- if (result.x && encoding.x.type === 'quantitative' && encoding.x.field) {
887
- result.x.formatContext = computeFieldFormatContext(data.map((r) => r[encoding.x!.field]));
888
- }
889
- }
890
-
891
- if (encoding.y) {
892
- // For stacked vertical bars and stacked areas, the y-domain needs the max
893
- // category sum, not the max individual value. Without this, stacked marks
894
- // would clip above the chart area.
895
- // Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
896
- let yData = data;
897
- let yChannel = encoding.y;
898
- // Range charts span y to y2 (vertical form): cover both endpoint fields.
899
- if (spec.markType === 'range' && encoding.y2 && encoding.y.type === 'quantitative') {
900
- const yField = encoding.y.field;
901
- const y2Field = encoding.y2.field;
902
- yData = [...data, ...data.map((row) => ({ [yField]: row[y2Field] }) as DataRow)];
903
- }
904
- const isVerticalBar =
905
- spec.markType === 'bar' &&
906
- (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
907
- encoding.y.type === 'quantitative';
908
- // Both bar and area require explicit opt-in for stacked domain expansion.
909
- const stackProp = encoding.y.stack;
910
- const isExplicitlyStacked =
911
- stackProp === true ||
912
- stackProp === 'zero' ||
913
- stackProp === 'normalize' ||
914
- stackProp === 'center';
915
- const isAreaStacked = spec.markType === 'area' && isExplicitlyStacked;
916
- const isBarStacked = isVerticalBar && isExplicitlyStacked;
917
-
918
- // Sparkline tightening: drop the default `zero: true` baseline so the
919
- // y-domain hugs the actual data range. Without this, a series with
920
- // values in the 4000s renders as a near-flat line because most of the
921
- // chart area gets reserved for the gap between zero and the data.
922
- //
923
- // Applies to:
924
- // - Line / area sparklines (always — variation is the whole point)
925
- // - Vertical bar sparklines, but ONLY when no real stacking is in
926
- // play. Two ways to opt OUT of bar tightening:
927
- // 1. Real stacking — color/group encoding plus a non-disabled
928
- // stack — needs the zero baseline to keep segment arithmetic
929
- // summing.
930
- // 2. Any explicit `encoding.y.stack` value signals the user
931
- // wants stack semantics even on a single series; respect that.
932
- const hasStackingGroup = isBarStacked && encoding.color !== undefined;
933
- const userRequestedStack = isExplicitlyStacked;
934
- const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
935
- const sparklineTightenBar =
936
- isVerticalBar && !hasStackingGroup && !userRequestedStack && !isAreaStacked;
937
- const sparklineTightenLineArea = isLineOrArea && !isAreaStacked;
938
- if (
939
- spec.display === 'sparkline' &&
940
- (sparklineTightenBar || sparklineTightenLineArea) &&
941
- encoding.y.type === 'quantitative' &&
942
- encoding.y.scale?.zero === undefined
943
- ) {
944
- yChannel = {
945
- ...encoding.y,
946
- scale: { ...encoding.y.scale, zero: false },
947
- };
948
- }
949
- if ((isBarStacked || isAreaStacked) && encoding.color && encoding.y.type === 'quantitative') {
950
- if (encoding.y.stack === 'normalize') {
951
- // Normalize: domain is [0, 1] (VL convention), default to percentage axis
952
- const existingAxis = encoding.y.axis;
953
- const axis =
954
- existingAxis === false || existingAxis?.format
955
- ? existingAxis
956
- : { ...(typeof existingAxis === 'object' ? existingAxis : {}), format: '.0%' };
957
- yChannel = {
958
- ...encoding.y,
959
- scale: { ...encoding.y.scale, domain: [0, 1], nice: false },
960
- axis,
961
- };
962
- } else if (encoding.y.stack === 'center') {
963
- // Center: compute max half-sum for symmetric domain
964
- const xField = encoding.x?.field;
965
- const yField = encoding.y.field;
966
- if (xField) {
967
- const sums = new Map<string, number>();
968
- for (const row of data) {
969
- const cat = String(row[xField] ?? '');
970
- const val = Number(row[yField] ?? 0);
971
- if (Number.isFinite(val) && val > 0) {
972
- sums.set(cat, (sums.get(cat) ?? 0) + val);
973
- }
974
- }
975
- const maxSum = Math.max(...sums.values(), 0);
976
- const half = maxSum / 2;
977
- yChannel = {
978
- ...encoding.y,
979
- scale: { ...encoding.y.scale, domain: [-half, half], zero: true },
980
- };
981
- }
982
- } else {
983
- // Zero (default): domain extends to max category sum
984
- const xField = encoding.x?.field;
985
- const yField = encoding.y.field;
986
- if (xField) {
987
- const sums = new Map<string, number>();
988
- for (const row of data) {
989
- const cat = String(row[xField] ?? '');
990
- const val = Number(row[yField] ?? 0);
991
- if (Number.isFinite(val) && val > 0) {
992
- sums.set(cat, (sums.get(cat) ?? 0) + val);
993
- }
994
- }
995
- const maxSum = Math.max(...sums.values(), 0);
996
- // Create a synthetic row with the max stack sum so buildLinearScale sees it
997
- yData = [...data, { [yField]: maxSum } as DataRow];
998
- }
999
- }
1000
- }
1001
-
1002
- // Y axis: range is inverted (SVG y goes down, data y goes up)
1003
- result.y = buildPositionalScale(
1004
- yChannel,
1005
- yData,
1006
- chartArea.y + chartArea.height,
1007
- chartArea.y,
1008
- spec.markType,
1009
- 'y',
1010
- );
1011
- if (result.y && encoding.y.type === 'quantitative' && encoding.y.field) {
1012
- result.y.formatContext = computeFieldFormatContext(data.map((r) => r[encoding.y!.field]));
1013
- }
1014
- }
1015
-
1016
- if (encoding.color) {
1017
- const defaultPalette = [
1018
- '#1b7fa3',
1019
- '#c44e52',
1020
- '#6a9f58',
1021
- '#d47215',
1022
- '#507e79',
1023
- '#9a6a8d',
1024
- '#c4636b',
1025
- '#9c755f',
1026
- '#a88f22',
1027
- '#858078',
1028
- ];
1029
-
1030
- // Only build color scales for field-based encodings, not conditional value defs
1031
- if ('field' in encoding.color) {
1032
- if (encoding.color.type === 'quantitative') {
1033
- const colorScaleType = encoding.color.scale?.type;
1034
- if (
1035
- colorScaleType === 'quantile' ||
1036
- colorScaleType === 'quantize' ||
1037
- colorScaleType === 'threshold'
1038
- ) {
1039
- // Binned color scale: discrete classes for value-based coloring
1040
- result.color = buildBinnedColorScale(encoding.color, data, defaultPalette);
1041
- } else {
1042
- // Sequential color scale for value-based coloring
1043
- result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
1044
- }
1045
- } else {
1046
- // Categorical color scale for nominal/ordinal grouping
1047
- result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);
1048
- }
1049
- }
1050
- }
1051
-
1052
- return result;
1053
- }