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

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 +0 -7
  2. package/dist/index.js +161 -579
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/__test-fixtures__/specs.ts +182 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +2339 -0
  7. package/src/__tests__/annotation-behavior.test.ts +721 -0
  8. package/src/__tests__/axes.test.ts +1579 -0
  9. package/src/__tests__/compile-animation.test.ts +161 -0
  10. package/src/__tests__/compile-chart.test.ts +1303 -0
  11. package/src/__tests__/compile-footnotes.test.ts +277 -0
  12. package/src/__tests__/compile-layer.test.ts +969 -0
  13. package/src/__tests__/compile-snapshot.test.ts +156 -0
  14. package/src/__tests__/compound-labels.test.ts +147 -0
  15. package/src/__tests__/dimensions.test.ts +918 -0
  16. package/src/__tests__/encoding-sugar.test.ts +390 -0
  17. package/src/__tests__/facet.test.ts +215 -0
  18. package/src/__tests__/helpers/invariants.ts +299 -0
  19. package/src/__tests__/known-bugs.test.ts +566 -0
  20. package/src/__tests__/layout-fuzz.test.ts +358 -0
  21. package/src/__tests__/layout-invariants.test.ts +535 -0
  22. package/src/__tests__/legend.test.ts +721 -0
  23. package/src/__tests__/mark-keys.test.ts +294 -0
  24. package/src/__tests__/ranking-recipes.test.ts +260 -0
  25. package/src/__tests__/rotation.test.ts +83 -0
  26. package/src/__tests__/scales.test.ts +323 -0
  27. package/src/__tests__/series-search.test.ts +92 -0
  28. package/src/__tests__/spec-sugar.test.ts +801 -0
  29. package/src/__tests__/you-draw-it.test.ts +160 -0
  30. package/src/annotations/__tests__/compute.test.ts +1672 -0
  31. package/src/annotations/__tests__/geometry.test.ts +66 -0
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  33. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  34. package/src/annotations/__tests__/placement.test.ts +316 -0
  35. package/src/annotations/__tests__/resolve-text.test.ts +788 -0
  36. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  37. package/src/annotations/__tests__/thinning.test.ts +285 -0
  38. package/src/annotations/collisions.ts +326 -0
  39. package/src/annotations/compute.ts +333 -0
  40. package/src/annotations/constants.ts +162 -0
  41. package/src/annotations/geometry.ts +370 -0
  42. package/src/annotations/move.ts +135 -0
  43. package/src/annotations/placement.ts +636 -0
  44. package/src/annotations/position.ts +131 -0
  45. package/src/annotations/resolve-range.ts +117 -0
  46. package/src/annotations/resolve-refline.ts +157 -0
  47. package/src/annotations/resolve-text.ts +539 -0
  48. package/src/annotations/rich-text.ts +65 -0
  49. package/src/annotations/thinning.ts +190 -0
  50. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  51. package/src/barlist/compile-barlist.ts +391 -0
  52. package/src/barlist/types.ts +28 -0
  53. package/src/charts/__tests__/post-process.test.ts +237 -0
  54. package/src/charts/__tests__/registry.test.ts +116 -0
  55. package/src/charts/__tests__/utils.test.ts +195 -0
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  57. package/src/charts/_shared/density-filter.ts +26 -0
  58. package/src/charts/_shared/format-label-value.ts +16 -0
  59. package/src/charts/bar/__tests__/compute.test.ts +934 -0
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  61. package/src/charts/bar/__tests__/labels.test.ts +292 -0
  62. package/src/charts/bar/compute.ts +539 -0
  63. package/src/charts/bar/index.ts +60 -0
  64. package/src/charts/bar/labels.ts +311 -0
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  67. package/src/charts/beeswarm/compute.ts +203 -0
  68. package/src/charts/beeswarm/dodge.ts +155 -0
  69. package/src/charts/beeswarm/index.ts +32 -0
  70. package/src/charts/builtin.ts +75 -0
  71. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  72. package/src/charts/calendar/compute.ts +319 -0
  73. package/src/charts/calendar/index.ts +32 -0
  74. package/src/charts/column/__tests__/compute.test.ts +540 -0
  75. package/src/charts/column/__tests__/labels.test.ts +179 -0
  76. package/src/charts/column/compute.ts +493 -0
  77. package/src/charts/column/index.ts +53 -0
  78. package/src/charts/column/labels.ts +229 -0
  79. package/src/charts/dot/__tests__/compute.test.ts +380 -0
  80. package/src/charts/dot/__tests__/labels.test.ts +98 -0
  81. package/src/charts/dot/compute.ts +285 -0
  82. package/src/charts/dot/index.ts +62 -0
  83. package/src/charts/dot/labels.ts +121 -0
  84. package/src/charts/line/__tests__/compute.test.ts +1194 -0
  85. package/src/charts/line/__tests__/curves.test.ts +51 -0
  86. package/src/charts/line/__tests__/labels.test.ts +94 -0
  87. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  88. package/src/charts/line/area.ts +484 -0
  89. package/src/charts/line/compute.ts +356 -0
  90. package/src/charts/line/curves.ts +85 -0
  91. package/src/charts/line/index.ts +176 -0
  92. package/src/charts/line/labels.ts +177 -0
  93. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  94. package/src/charts/parliament/compute.ts +313 -0
  95. package/src/charts/parliament/index.ts +32 -0
  96. package/src/charts/pie/__tests__/compute.test.ts +384 -0
  97. package/src/charts/pie/__tests__/labels.test.ts +132 -0
  98. package/src/charts/pie/compute.ts +324 -0
  99. package/src/charts/pie/index.ts +49 -0
  100. package/src/charts/pie/labels.ts +143 -0
  101. package/src/charts/post-process.ts +229 -0
  102. package/src/charts/range/__tests__/compute.test.ts +486 -0
  103. package/src/charts/range/compute.ts +273 -0
  104. package/src/charts/range/index.ts +82 -0
  105. package/src/charts/range/labels.ts +169 -0
  106. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  107. package/src/charts/rect/index.ts +127 -0
  108. package/src/charts/registry.ts +72 -0
  109. package/src/charts/rule/index.ts +139 -0
  110. package/src/charts/scatter/__tests__/compute.test.ts +488 -0
  111. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  112. package/src/charts/scatter/compute.ts +192 -0
  113. package/src/charts/scatter/index.ts +46 -0
  114. package/src/charts/scatter/trendline.ts +100 -0
  115. package/src/charts/text/__tests__/compute.test.ts +307 -0
  116. package/src/charts/text/index.ts +141 -0
  117. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  118. package/src/charts/tick/index.ts +99 -0
  119. package/src/charts/utils.ts +170 -0
  120. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  121. package/src/charts/waffle/compute.ts +197 -0
  122. package/src/charts/waffle/index.ts +31 -0
  123. package/src/compile/__tests__/color-scale-range.test.ts +158 -0
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  125. package/src/compile/__tests__/data-clip.test.ts +127 -0
  126. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  127. package/src/compile/__tests__/size-scale.test.ts +108 -0
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  129. package/src/compile/color-scale-range.ts +101 -0
  130. package/src/compile/data-clip.ts +48 -0
  131. package/src/compile/fill-patterns.ts +74 -0
  132. package/src/compile/layer.ts +626 -0
  133. package/src/compile/size-scale.ts +141 -0
  134. package/src/compile/spec-sugar.ts +758 -0
  135. package/src/compile/watermark-obstacle.ts +54 -0
  136. package/src/compile/you-draw-it.ts +123 -0
  137. package/src/compile.ts +1450 -0
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  139. package/src/compiler/__tests__/animation.test.ts +177 -0
  140. package/src/compiler/__tests__/compile.test.ts +87 -0
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  160. package/src/compiler/__tests__/keys.test.ts +83 -0
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  162. package/src/compiler/__tests__/normalize.test.ts +399 -0
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  165. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  167. package/src/compiler/__tests__/validate.test.ts +629 -0
  168. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  169. package/src/compiler/a11y-warnings.ts +110 -0
  170. package/src/compiler/animation.ts +142 -0
  171. package/src/compiler/index.ts +47 -0
  172. package/src/compiler/keys.ts +55 -0
  173. package/src/compiler/normalize.ts +734 -0
  174. package/src/compiler/sparkline-defaults.ts +138 -0
  175. package/src/compiler/types.ts +241 -0
  176. package/src/compiler/validate.ts +1871 -0
  177. package/src/endpoint-labels/__tests__/compute.test.ts +587 -0
  178. package/src/endpoint-labels/compute.ts +621 -0
  179. package/src/endpoint-labels/constants.ts +54 -0
  180. package/src/endpoint-labels/format.ts +60 -0
  181. package/src/endpoint-labels/predict.ts +139 -0
  182. package/src/format/__tests__/field-format.test.ts +164 -0
  183. package/src/format/field-format.ts +27 -0
  184. package/src/geo/__tests__/compile-map.test.ts +832 -0
  185. package/src/geo/compile-map.ts +1243 -0
  186. package/src/geo/join.ts +115 -0
  187. package/src/geo/projections.ts +45 -0
  188. package/src/geo/types.ts +26 -0
  189. package/src/graphs/__tests__/community.test.ts +228 -0
  190. package/src/graphs/__tests__/compile-graph.test.ts +396 -0
  191. package/src/graphs/__tests__/encoding.test.ts +415 -0
  192. package/src/graphs/community.ts +92 -0
  193. package/src/graphs/compile-graph.ts +329 -0
  194. package/src/graphs/encoding.ts +356 -0
  195. package/src/graphs/types.ts +106 -0
  196. package/src/index.ts +177 -0
  197. package/src/layout/__tests__/metrics.test.ts +97 -0
  198. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  199. package/src/layout/axes/rotation.ts +152 -0
  200. package/src/layout/axes/thinning.ts +98 -0
  201. package/src/layout/axes/ticks.ts +409 -0
  202. package/src/layout/axes.ts +757 -0
  203. package/src/layout/dimensions.ts +912 -0
  204. package/src/layout/facet.ts +91 -0
  205. package/src/layout/gridlines.ts +84 -0
  206. package/src/layout/metrics.ts +132 -0
  207. package/src/layout/plan.ts +631 -0
  208. package/src/layout/scales.ts +1053 -0
  209. package/src/layout/shared.ts +44 -0
  210. package/src/legend/__tests__/continuous.test.ts +318 -0
  211. package/src/legend/__tests__/redundancy.test.ts +108 -0
  212. package/src/legend/__tests__/size.test.ts +348 -0
  213. package/src/legend/__tests__/suppression.test.ts +382 -0
  214. package/src/legend/compute.ts +747 -0
  215. package/src/legend/continuous.ts +327 -0
  216. package/src/legend/size.ts +407 -0
  217. package/src/legend/suppression.ts +226 -0
  218. package/src/legend/wrap.ts +117 -0
  219. package/src/sankey/__tests__/compile-sankey.test.ts +458 -0
  220. package/src/sankey/__tests__/layout.test.ts +165 -0
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  222. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  223. package/src/sankey/compile-sankey.ts +706 -0
  224. package/src/sankey/layout.ts +184 -0
  225. package/src/sankey/types.ts +41 -0
  226. package/src/tables/__tests__/bar-column.test.ts +147 -0
  227. package/src/tables/__tests__/category-colors.test.ts +230 -0
  228. package/src/tables/__tests__/compile-table.test.ts +236 -0
  229. package/src/tables/__tests__/format-cells.test.ts +143 -0
  230. package/src/tables/__tests__/heatmap.test.ts +148 -0
  231. package/src/tables/__tests__/pagination.test.ts +78 -0
  232. package/src/tables/__tests__/search.test.ts +94 -0
  233. package/src/tables/__tests__/sort.test.ts +107 -0
  234. package/src/tables/__tests__/sparkline.test.ts +122 -0
  235. package/src/tables/bar-column.ts +94 -0
  236. package/src/tables/category-colors.ts +78 -0
  237. package/src/tables/compile-table.ts +427 -0
  238. package/src/tables/format-cells.ts +115 -0
  239. package/src/tables/heatmap.ts +141 -0
  240. package/src/tables/pagination.ts +46 -0
  241. package/src/tables/search.ts +66 -0
  242. package/src/tables/sort.ts +69 -0
  243. package/src/tables/sparkline.ts +113 -0
  244. package/src/tables/utils.ts +16 -0
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +576 -0
  246. package/src/tilemap/compile-tilemap.ts +755 -0
  247. package/src/tilemap/layout.ts +172 -0
  248. package/src/tilemap/types.ts +33 -0
  249. package/src/tooltips/__tests__/compute.test.ts +582 -0
  250. package/src/tooltips/compute.ts +700 -0
  251. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  252. package/src/transforms/__tests__/bin.test.ts +88 -0
  253. package/src/transforms/__tests__/calculate.test.ts +146 -0
  254. package/src/transforms/__tests__/conditional.test.ts +127 -0
  255. package/src/transforms/__tests__/filter-relative.test.ts +202 -0
  256. package/src/transforms/__tests__/filter.test.ts +59 -0
  257. package/src/transforms/__tests__/fold.test.ts +79 -0
  258. package/src/transforms/__tests__/index.test.ts +93 -0
  259. package/src/transforms/__tests__/predicates.test.ts +193 -0
  260. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  261. package/src/transforms/__tests__/window.test.ts +286 -0
  262. package/src/transforms/aggregate.ts +131 -0
  263. package/src/transforms/bin.ts +87 -0
  264. package/src/transforms/calculate.ts +60 -0
  265. package/src/transforms/conditional.ts +46 -0
  266. package/src/transforms/filter.ts +122 -0
  267. package/src/transforms/fold.ts +49 -0
  268. package/src/transforms/index.ts +60 -0
  269. package/src/transforms/predicates.ts +123 -0
  270. package/src/transforms/timeunit.ts +88 -0
  271. package/src/transforms/window.ts +185 -0
@@ -0,0 +1,1053 @@
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
+ }