@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,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
- }