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