@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20

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 +140 -2
  2. package/dist/index.js +1403 -210
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2339
  7. package/src/__tests__/annotation-behavior.test.ts +0 -721
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -370
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -539
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -626
  133. package/src/compile/size-scale.ts +0 -139
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1450
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -747
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. package/src/transforms/window.ts +0 -185
@@ -1,631 +0,0 @@
1
- import type {
2
- CompileOptions,
3
- Encoding,
4
- EncodingChannel,
5
- FieldFormatContext,
6
- LayoutStrategy,
7
- MeasureTextFn,
8
- Rect,
9
- ResolvedChrome,
10
- ResolvedTheme,
11
- } from '@opendata-ai/openchart-core';
12
- import {
13
- AXIS_TITLE_TRAILING_PAD,
14
- axisTitleOffset,
15
- BREAKPOINT_COMPACT_MAX,
16
- COMPACT_WIDTH,
17
- computeChrome,
18
- computeFieldFormatContext,
19
- computeXAxisExtentFromLabels,
20
- defaultNumberFormatter,
21
- estimateTextWidth,
22
- HPAD_COMPACT_FRACTION,
23
- HPAD_COMPACT_MIN,
24
- isAxislessMark,
25
- LABEL_GAP_COMPACT,
26
- LABEL_GAP_DEFAULT,
27
- MAX_LEFT_LABEL_FRACTION_COMPACT,
28
- MAX_LEFT_LABEL_FRACTION_DEFAULT,
29
- MAX_LEFT_LABEL_FRACTION_MEDIUM,
30
- MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
31
- NARROW_VIEWPORT_MAX,
32
- resolveNumberFormatter,
33
- TOP_PAD_EXTRA_NARROW,
34
- } from '@opendata-ai/openchart-core';
35
-
36
- import type { NormalizedChartSpec } from '../compiler/types';
37
- import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
38
- import { computeLegendContent, hasLegendContent, type LegendContent } from '../legend/compute';
39
- import {
40
- computeSizeLegendContent,
41
- SIZE_LEGEND_GAP,
42
- type SizeLegendContent,
43
- sizeLegendScaleFor,
44
- } from '../legend/size';
45
- import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
46
- import { yTickPositionIsInline } from './axes';
47
- import { resolveBandTickAngle } from './axes/rotation';
48
- import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
49
- import { computeScales, estimateBandStep, estimateBandwidth } from './scales';
50
- import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
51
-
52
- // ---------------------------------------------------------------------------
53
- // MeasureFn -- simplified width-only text measurement
54
- // ---------------------------------------------------------------------------
55
-
56
- /** Width-only text measurement function used by the layout planner. */
57
- export type MeasureFn = (text: string, fontSize: number, fontWeight: number) => number;
58
-
59
- // ---------------------------------------------------------------------------
60
- // LayoutPlan -- frozen dimensions from the measure phase
61
- // ---------------------------------------------------------------------------
62
-
63
- /** Frozen layout dimensions produced by the measure phase. */
64
- export interface LayoutPlan {
65
- leftGutter: number;
66
- xAxisExtent: number;
67
- legendContent: LegendContent;
68
- /**
69
- * Size legend content, when a quantitative `size` channel is encoded.
70
- *
71
- * Kept beside `legendContent` rather than inside it: a chart can carry BOTH a
72
- * color legend and a size legend, and folding them into one slot is what made
73
- * the size channel unkeyable in the first place. `dimensions.ts` reserves
74
- * right-margin for this independently of the color legend's position.
75
- */
76
- sizeLegendContent?: SizeLegendContent | null;
77
- chrome: ResolvedChrome;
78
- yTickValues: unknown[];
79
- yTickCount: number;
80
- converged: boolean;
81
- inlineYLabelInset: number;
82
- /** True when endpoint labels were demoted by auto-thinning (don't fit at current width). */
83
- endpointLabelsDemoted?: boolean;
84
- }
85
-
86
- // ---------------------------------------------------------------------------
87
- // createMeasureFn -- adapter from MeasureTextFn to MeasureFn
88
- // ---------------------------------------------------------------------------
89
-
90
- /**
91
- * Wraps an optional adapter-provided `MeasureTextFn` (returns `{ width, height }`)
92
- * into a simpler `MeasureFn` that returns width only. Falls back to the
93
- * heuristic `estimateTextWidth` when no real measurement is available.
94
- */
95
- export function createMeasureFn(measureText?: MeasureTextFn): MeasureFn {
96
- if (measureText) {
97
- return (text: string, fontSize: number, fontWeight: number) =>
98
- measureText(text, fontSize, fontWeight).width;
99
- }
100
- return estimateTextWidth;
101
- }
102
-
103
- // ---------------------------------------------------------------------------
104
- // Helpers
105
- // ---------------------------------------------------------------------------
106
-
107
- function sampleLabelFromContext(
108
- maxAbsVal: number,
109
- yAxisFormat: string | undefined,
110
- ctx: FieldFormatContext,
111
- ): string {
112
- if (yAxisFormat) {
113
- const fmt = resolveNumberFormatter(yAxisFormat, ctx);
114
- if (fmt) return fmt(maxAbsVal);
115
- }
116
- return defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(maxAbsVal);
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // resolveLayoutPlan
121
- // ---------------------------------------------------------------------------
122
-
123
- /**
124
- * Measure real axis labels and legend content, then freeze the chart layout.
125
- *
126
- * Runs up to 2 iterations: build provisional scales at an estimated gutter,
127
- * generate real y-axis tick labels, measure their widths, and check whether
128
- * the gutter changed. If it converged on the first pass, we're done;
129
- * otherwise a second pass locks in the final gutter as max(pass0, pass1).
130
- *
131
- * @param chartSpec - UNFILTERED spec (used for legend -- retains all series)
132
- * @param renderSpec - FILTERED spec (used for measuring data and scales)
133
- * @param options - CompileOptions (width, height, measureText, etc.)
134
- * @param theme - Resolved theme
135
- * @param strategy - Responsive layout strategy
136
- * @param watermark - Whether the brand watermark is shown
137
- * @param measure - Width-only text measurement function
138
- */
139
- export function resolveLayoutPlan(
140
- chartSpec: NormalizedChartSpec,
141
- renderSpec: NormalizedChartSpec,
142
- options: CompileOptions,
143
- theme: ResolvedTheme,
144
- strategy: LayoutStrategy,
145
- watermark: boolean,
146
- measure: MeasureFn,
147
- ): LayoutPlan {
148
- const { width, height } = options;
149
- const padding = scalePadding(theme.spacing.padding, width, height);
150
- const hPad =
151
- width < BREAKPOINT_COMPACT_MAX
152
- ? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
153
- : padding;
154
- const encoding = renderSpec.encoding as Encoding;
155
- const isRadial = isAxislessMark(renderSpec.markType);
156
- const axisMargin = theme.spacing.axisMargin;
157
-
158
- // Resolve chromeMode
159
- let chromeMode = strategy?.chromeMode ?? 'full';
160
- if (renderSpec.display === 'sparkline' && !renderSpec.userExplicit?.chrome) {
161
- chromeMode = 'hidden';
162
- }
163
-
164
- // -----------------------------------------------------------------------
165
- // Sparkline / radial: trivial plan (no y-axis measurement needed)
166
- // -----------------------------------------------------------------------
167
- if (renderSpec.display === 'sparkline' || isRadial) {
168
- const legendContent = computeLegendContent(
169
- chartSpec,
170
- strategy,
171
- theme,
172
- width,
173
- height,
174
- watermark,
175
- measure,
176
- );
177
- const bottomLegendReservation =
178
- hasLegendContent(legendContent) && legendContent.position === 'bottom'
179
- ? legendContent.height + legendGap(width)
180
- : 0;
181
- const chrome = computeChrome(
182
- chromeToInput(renderSpec.chrome),
183
- theme,
184
- width,
185
- options.measureText,
186
- chromeMode,
187
- padding,
188
- watermark,
189
- bottomLegendReservation,
190
- );
191
- return {
192
- leftGutter: hPad,
193
- xAxisExtent: 0,
194
- legendContent,
195
- // Sparkline / radial: no positional axes and no bubbles, so no size key.
196
- sizeLegendContent: null,
197
- chrome,
198
- yTickValues: [],
199
- yTickCount: 0,
200
- converged: true,
201
- inlineYLabelInset: 0,
202
- };
203
- }
204
-
205
- // -----------------------------------------------------------------------
206
- // Resolve y-axis inline status
207
- // -----------------------------------------------------------------------
208
- const yAxisCfg = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
209
- const yIsContinuous = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
210
- // Inline y-tick placement must match computeAxes exactly (shared predicate),
211
- // or the reserved left margin won't line up with where the title is drawn.
212
- // Sparkline display already returned early above, so no sparkline guard needed.
213
- const yIsInline = yTickPositionIsInline(encoding.y, renderSpec.markType);
214
- const yAxisSuppressed = encoding.y?.axis === false;
215
-
216
- // -----------------------------------------------------------------------
217
- // Seed gutter from formatted max |y|
218
- // -----------------------------------------------------------------------
219
- let leftGutter = hPad + axisMargin; // base: pad + axis margin
220
- if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
221
- if (
222
- encoding.y.type === 'nominal' ||
223
- encoding.y.type === 'ordinal' ||
224
- renderSpec.markType === 'bar' ||
225
- renderSpec.markType === 'circle' ||
226
- renderSpec.markType === 'lollipop'
227
- ) {
228
- // Category labels: measure real data values
229
- const yField = encoding.y.field;
230
- const yLabelField = yAxisCfg?.labelField as string | undefined;
231
- let maxLabelWidth = 0;
232
- for (const row of renderSpec.data) {
233
- const label = String(row[yField] ?? '');
234
- let w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
235
- if (yLabelField) {
236
- const subtitle = String(row[yLabelField] ?? '');
237
- if (subtitle) {
238
- const gap = theme.fonts.sizes.axisTick * 0.6;
239
- const subtitleWidth = measure(
240
- subtitle,
241
- theme.fonts.sizes.axisTick,
242
- theme.fonts.weights.normal,
243
- );
244
- w += gap + subtitleWidth;
245
- }
246
- }
247
- if (w > maxLabelWidth) maxLabelWidth = w;
248
- }
249
- if (maxLabelWidth > 0) {
250
- const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
251
- const maxLeftFraction =
252
- width < BREAKPOINT_COMPACT_MAX
253
- ? MAX_LEFT_LABEL_FRACTION_COMPACT
254
- : width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
255
- ? MAX_LEFT_LABEL_FRACTION_MEDIUM
256
- : MAX_LEFT_LABEL_FRACTION_DEFAULT;
257
- const maxLeftReserved = Math.floor(width * maxLeftFraction);
258
- const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
259
- leftGutter = Math.max(leftGutter, reserved);
260
- }
261
- } else {
262
- // Quantitative: seed with a formatted label
263
- const yField = encoding.y.field;
264
- const yAxisFormat = yAxisCfg?.format as string | undefined;
265
- let maxAbsVal = 0;
266
- for (const row of renderSpec.data) {
267
- const v = Number(row[yField]);
268
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
269
- }
270
- const ctx = computeFieldFormatContext(renderSpec.data.map((r) => r[yField]));
271
- const sampleLabel = sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx);
272
- const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
273
- const labelWidth = measure(
274
- negPrefix + sampleLabel,
275
- theme.fonts.sizes.axisTick,
276
- theme.fonts.weights.normal,
277
- );
278
- leftGutter = Math.max(leftGutter, hPad + labelWidth + 10);
279
- }
280
- }
281
-
282
- // -----------------------------------------------------------------------
283
- // X-axis setup (doesn't change between iterations)
284
- // -----------------------------------------------------------------------
285
- const xAxisSuppressed = encoding.x?.axis === false;
286
- const xAxis = (!xAxisSuppressed && encoding.x?.axis) as Record<string, unknown> | undefined;
287
- const hasXAxisTitle = !!xAxis?.title;
288
- const xTickAngle = xAxis?.labelAngle as number | undefined;
289
-
290
- // Collect x labels for extent computation
291
- const xLabels: string[] = [];
292
- if (!isRadial && !xAxisSuppressed && encoding.x) {
293
- const xField = encoding.x.field;
294
- if (encoding.x.type === 'nominal' || encoding.x.type === 'ordinal') {
295
- const seen = new Set<string>();
296
- for (const row of renderSpec.data) {
297
- const label = String(row[xField] ?? '');
298
- if (!seen.has(label)) {
299
- seen.add(label);
300
- xLabels.push(label);
301
- }
302
- }
303
- }
304
- }
305
-
306
- // -----------------------------------------------------------------------
307
- // Iteration loop (max 2)
308
- // -----------------------------------------------------------------------
309
- let prev: { gutter: number; rowCount: number; xAxisExtent: number } | null = null;
310
- let finalPlan: LayoutPlan | null = null;
311
-
312
- // Endpoint-label fit check: when autoThin is on, demote endpoint labels to
313
- // legend if the column would consume more than 30% of the total width.
314
- const MAX_ENDPOINT_FRACTION = 0.3;
315
- let endpointLabelsDemoted = false;
316
- if (chartSpec.autoThin) {
317
- const epWidth = predictEndpointLabelsWidth(chartSpec, theme);
318
- if (epWidth > 0 && epWidth / width > MAX_ENDPOINT_FRACTION) {
319
- endpointLabelsDemoted = true;
320
- }
321
- }
322
-
323
- // Size legend: keys a quantitative `size` channel with graduated circles.
324
- // Independent of the color legend -- a bubble chart keys BOTH continent
325
- // (color) and population (size), so this is computed alongside, not instead.
326
- // Its content is width-independent, so it sits outside the convergence loop.
327
- //
328
- // Dropped below COMPACT_WIDTH. The block is a fixed ~90px (it can't reflow the
329
- // way a categorical legend's entries can), which on a phone-width chart is a
330
- // quarter of the SVG spent on the key and a plot too narrow to read the bubbles
331
- // it explains. The same threshold the color legend goes compact at.
332
- const sizeScaleOpts = sizeLegendScaleFor(renderSpec.markType);
333
- const sizeLegendContent =
334
- sizeScaleOpts && chartSpec.legend?.show !== false && width >= COMPACT_WIDTH
335
- ? computeSizeLegendContent(
336
- renderSpec.encoding.size as EncodingChannel | undefined,
337
- renderSpec.data,
338
- theme,
339
- sizeScaleOpts,
340
- )
341
- : null;
342
-
343
- for (let iter = 0; iter < 2; iter++) {
344
- // Legend content
345
- const legendAvailWidth = width - padding - leftGutter;
346
- const legendContent = computeLegendContent(
347
- chartSpec,
348
- strategy,
349
- theme,
350
- legendAvailWidth,
351
- height,
352
- watermark,
353
- measure,
354
- endpointLabelsDemoted,
355
- );
356
-
357
- // Chrome
358
- const bottomLegendReservation =
359
- hasLegendContent(legendContent) && legendContent.position === 'bottom'
360
- ? legendContent.height + legendGap(width)
361
- : 0;
362
- const chrome = computeChrome(
363
- chromeToInput(renderSpec.chrome),
364
- theme,
365
- width,
366
- options.measureText,
367
- chromeMode,
368
- padding,
369
- watermark,
370
- bottomLegendReservation,
371
- );
372
-
373
- // Effective x tick angle. Band x-axes auto-rotate to -45° when horizontal
374
- // labels don't fit their band; computeAxes makes the same call later. The
375
- // planner must know the angle now so it reserves the rotated-label
376
- // footprint in the bottom margin instead of the (smaller) flat one.
377
- let effectiveXTickAngle = xTickAngle;
378
- if (xTickAngle === undefined && xLabels.length > 1) {
379
- // The right-hand legend column narrows the plot (dimensions.ts reserves
380
- // it), so it has to be in this estimate too. Without it the planner sizes
381
- // bands against a plot up to ~100px wider than the real one, concludes the
382
- // labels fit flat, and computeAxes then rotates them against the true
383
- // bandwidth -- reserving the flat footprint for rotated labels and clipping
384
- // the axis. Bites a nominal-x beeswarm with a size channel.
385
- //
386
- // Mirrors the shared-column reservation in dimensions.ts: the color and
387
- // size legends stack in ONE column, so it is the wider of the two, not the
388
- // sum. These two must agree -- an estimate wider than the real margin
389
- // rotates labels that would have fit.
390
- const colorInGutter =
391
- hasLegendContent(legendContent) &&
392
- (legendContent.position === 'right' || legendContent.position === 'bottom-right');
393
- const colorLegendReserve = colorInGutter ? legendContent.legendWidth + 8 : 0;
394
- const sizeLegendReserve = sizeLegendContent ? sizeLegendContent.width + SIZE_LEGEND_GAP : 0;
395
- const legendReserve = Math.max(colorLegendReserve, sizeLegendReserve);
396
- const rightMarginEst = hPad + (isRadial ? hPad : axisMargin) + legendReserve;
397
- const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
398
- // Mirror d3 scaleBand: step = plotWidth / (n - paddingInner + 2*paddingOuter),
399
- // bandwidth = step * (1 - paddingInner). Uses the same override resolution
400
- // as buildBandScale so paddingInner/paddingOuter are honored here too.
401
- const bandwidth = estimateBandwidth(encoding.x?.scale, plotWidth, xLabels.length);
402
- const step = estimateBandStep(encoding.x?.scale, plotWidth, xLabels.length);
403
- // Mirror categoricalTicks' explicit tickCount cap: retained ticks sit a
404
- // multiple of the band step apart, so the drawn labels and their anchor
405
- // spacing — not the full category set — drive the angle here, matching
406
- // what computeAxes measures from actual tick positions.
407
- const xTickCount = xAxis?.tickCount as number | undefined;
408
- let drawnLabels = xLabels;
409
- let anchorSpacing = step;
410
- if (xTickCount && xLabels.length > xTickCount) {
411
- const capStride = Math.ceil(xLabels.length / xTickCount);
412
- drawnLabels = xLabels.filter((_, i) => i % capStride === 0);
413
- anchorSpacing = step * capStride;
414
- }
415
- let maxXLabelWidth = 0;
416
- for (const label of drawnLabels) {
417
- const w = measure(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
418
- if (w > maxXLabelWidth) maxXLabelWidth = w;
419
- }
420
- effectiveXTickAngle = resolveBandTickAngle(
421
- undefined,
422
- maxXLabelWidth,
423
- bandwidth,
424
- drawnLabels.length,
425
- anchorSpacing,
426
- theme.fonts.sizes.axisTick,
427
- );
428
- }
429
-
430
- // X-axis extent
431
- const xAxisExtent =
432
- isRadial || xAxisSuppressed
433
- ? 0
434
- : computeXAxisExtentFromLabels({
435
- labels: xLabels,
436
- tickAngle: effectiveXTickAngle,
437
- hasTitle: hasXAxisTitle,
438
- tickFontSize: theme.fonts.sizes.axisTick,
439
- tickFontWeight: theme.fonts.weights.normal,
440
- xAxisHeight: theme.spacing.xAxisHeight,
441
- measure,
442
- });
443
-
444
- // Top margin
445
- const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
446
- const hasTopLegend = hasLegendContent(legendContent) && legendContent.position === 'top';
447
- const gap = legendGap(width);
448
- const inlineTickOverhang = yIsInline
449
- ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
450
- : 0;
451
- const topAxisGap = axisMargin + inlineTickOverhang;
452
- const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
453
-
454
- let topMargin = topPad + chrome.topHeight;
455
- // Mirrors the dimensions.ts margin stack: min gap above the top legend.
456
- if (hasTopLegend) topMargin += TOP_LEGEND_GAP_ABOVE + legendContent.height + gap;
457
- topMargin += effectiveTopAxisGap;
458
-
459
- const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);
460
- const chartHeight = Math.max(0, height - topMargin - bMargin);
461
-
462
- // Provisional area
463
- const rightMargin = hPad + (isRadial ? hPad : axisMargin);
464
- const rightAxisReserve = options.rightAxisReserve ?? 0;
465
- const provisionalArea: Rect = {
466
- x: leftGutter,
467
- y: topMargin,
468
- width: Math.max(0, width - leftGutter - Math.max(rightMargin, hPad + rightAxisReserve)),
469
- height: chartHeight,
470
- };
471
-
472
- // Build provisional scales and generate real y ticks
473
- let yTickValues: unknown[] = [];
474
- let yTickCount = 0;
475
- let newGutter = leftGutter;
476
-
477
- if (encoding.y && yIsContinuous && !yAxisSuppressed) {
478
- const scales = computeScales(renderSpec, provisionalArea, renderSpec.data);
479
- if (scales.y && scaleSupportsTickCount(scales.y)) {
480
- const yTargetCount = targetTickCount(chartHeight, 'full', 'y');
481
- const axisTickCount = yAxisCfg?.tickCount as number | undefined;
482
- const effectiveCount = axisTickCount ?? yTargetCount;
483
- const ticks = buildContinuousTicks(scales.y, effectiveCount);
484
- yTickValues = ticks.map((t) => t.value);
485
- yTickCount = ticks.length;
486
-
487
- // Measure real tick labels for gutter
488
- if (!yIsInline) {
489
- let maxLabelWidth = 0;
490
- for (const t of ticks) {
491
- const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
492
- if (w > maxLabelWidth) maxLabelWidth = w;
493
- }
494
- newGutter = hPad + maxLabelWidth + 10;
495
- }
496
- }
497
- }
498
-
499
- // Convergence check
500
- const converged =
501
- prev != null &&
502
- Math.abs(newGutter - prev.gutter) <= 0.5 &&
503
- legendContent.rowCount === prev.rowCount &&
504
- Math.abs(xAxisExtent - prev.xAxisExtent) <= 0.5;
505
-
506
- if (converged || iter === 1) {
507
- // On non-convergence at iter=1: gutter = max(iter0, iter1)
508
- const finalGutter = prev && !converged ? Math.max(prev.gutter, newGutter) : newGutter;
509
-
510
- // If non-convergent, regenerate ticks once at the final height
511
- if (prev && !converged && encoding.y && yIsContinuous && !yAxisSuppressed) {
512
- const finalProvArea: Rect = {
513
- x: finalGutter,
514
- y: topMargin,
515
- width: Math.max(0, width - finalGutter - Math.max(rightMargin, hPad + rightAxisReserve)),
516
- height: chartHeight,
517
- };
518
- const scales = computeScales(renderSpec, finalProvArea, renderSpec.data);
519
- if (scales.y && scaleSupportsTickCount(scales.y)) {
520
- const yTargetCount = targetTickCount(chartHeight, 'full', 'y');
521
- const axisTickCount = yAxisCfg?.tickCount as number | undefined;
522
- const effectiveCount = axisTickCount ?? yTargetCount;
523
- const ticks = buildContinuousTicks(scales.y, effectiveCount);
524
- yTickValues = ticks.map((t) => t.value);
525
- yTickCount = ticks.length;
526
- }
527
- }
528
-
529
- // Compute inline y-label inset
530
- let inlineYLabelInset = 0;
531
- if (yIsInline && encoding.y && yIsContinuous && encoding.y.axis !== false) {
532
- if (yTickValues.length > 0) {
533
- // Build ticks from the final scales to get formatted labels
534
- const finalProvArea: Rect = {
535
- x: finalGutter,
536
- y: topMargin,
537
- width: Math.max(0, width - finalGutter - rightMargin),
538
- height: chartHeight,
539
- };
540
- const scales = computeScales(renderSpec, finalProvArea, renderSpec.data);
541
- if (scales.y && scaleSupportsTickCount(scales.y)) {
542
- const ticks = buildContinuousTicks(scales.y, yTickCount || 5);
543
- let maxLabelWidth = 0;
544
- for (const t of ticks) {
545
- const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
546
- if (w > maxLabelWidth) maxLabelWidth = w;
547
- }
548
- if (maxLabelWidth > 0) {
549
- inlineYLabelInset = Math.ceil(maxLabelWidth + 8);
550
- }
551
- }
552
- } else {
553
- // Fallback: measure from data max
554
- const yField = encoding.y.field;
555
- const yAxisFormat = yAxisCfg?.format as string | undefined;
556
- let maxAbsVal = 0;
557
- for (const row of renderSpec.data) {
558
- const v = Number(row[yField]);
559
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
560
- }
561
- const ctx = computeFieldFormatContext(renderSpec.data.map((r) => r[yField]));
562
- const sample = sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx);
563
- const neg = renderSpec.data.some((r) => Number(r[encoding.y!.field]) < 0) ? '-' : '';
564
- const maxLabelWidth = measure(
565
- neg + sample,
566
- theme.fonts.sizes.axisTick,
567
- theme.fonts.weights.normal,
568
- );
569
- if (maxLabelWidth > 0) {
570
- inlineYLabelInset = Math.ceil(maxLabelWidth + 8);
571
- }
572
- }
573
- }
574
-
575
- // Account for rotated y-axis title in the gutter
576
- let gutterWithTitle = finalGutter;
577
- const yAxisDef = encoding.y?.axis as Record<string, unknown> | undefined;
578
- if (yAxisDef && (yAxisDef.title || yAxisDef.label) && !isRadial) {
579
- let estTickLabelWidth = 0;
580
- // Inline y-tick labels sit above their gridlines inside the chart area,
581
- // not in a left gutter, so they add no width the title must clear.
582
- // Reserving their width here would leave a dead gap between the title
583
- // and the plot. Only gutter (non-inline) tick labels count.
584
- if (encoding.y?.field && yIsContinuous && !yIsInline) {
585
- if (yTickValues.length > 0) {
586
- const titleProvArea: Rect = {
587
- x: finalGutter,
588
- y: topMargin,
589
- width: Math.max(0, width - finalGutter - hPad),
590
- height: chartHeight,
591
- };
592
- const scales = computeScales(renderSpec, titleProvArea, renderSpec.data);
593
- if (scales.y && scaleSupportsTickCount(scales.y)) {
594
- const ticks = buildContinuousTicks(scales.y, yTickCount || 5);
595
- for (const t of ticks) {
596
- const w = measure(t.label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
597
- if (w > estTickLabelWidth) estTickLabelWidth = w;
598
- }
599
- }
600
- }
601
- }
602
- const titleFontSize = theme.fonts.sizes.body;
603
- const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInline);
604
- const halfGlyph = Math.ceil(titleFontSize / 2);
605
- const rotatedLabelMargin =
606
- offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
607
- gutterWithTitle = Math.max(finalGutter, hPad + rotatedLabelMargin);
608
- }
609
-
610
- finalPlan = {
611
- leftGutter: gutterWithTitle,
612
- xAxisExtent,
613
- legendContent,
614
- sizeLegendContent,
615
- chrome,
616
- yTickValues,
617
- yTickCount,
618
- converged: prev === null || converged,
619
- inlineYLabelInset,
620
- endpointLabelsDemoted,
621
- };
622
- break;
623
- }
624
-
625
- prev = { gutter: newGutter, rowCount: legendContent.rowCount, xAxisExtent };
626
- leftGutter = newGutter;
627
- }
628
-
629
- if (!finalPlan) throw new Error('resolveLayoutPlan: loop exited without producing a plan');
630
- return finalPlan;
631
- }