@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,912 +0,0 @@
1
- /**
2
- * Dimension computation for the chart layout.
3
- *
4
- * Takes the normalized spec + compile options + legend layout and produces
5
- * LayoutDimensions with the total area, chrome layout, chart drawing area,
6
- * and margins. The chart area is what's left after subtracting chrome,
7
- * legend space, and axis margins.
8
- *
9
- * Padding and chrome scale down at smaller container sizes to maximize
10
- * the usable chart area. When the chart area is still too small after
11
- * scaling, chrome is progressively stripped as a fallback.
12
- */
13
-
14
- import type {
15
- CompileOptions,
16
- Encoding,
17
- LayoutStrategy,
18
- LegendLayout,
19
- Margins,
20
- Rect,
21
- ResolvedChrome,
22
- ResolvedMetricBar,
23
- ResolvedSeriesSearch,
24
- ResolvedTheme,
25
- } from '@opendata-ai/openchart-core';
26
- import {
27
- AXIS_TITLE_TRAILING_PAD,
28
- axisTitleOffset,
29
- BREAKPOINT_COMPACT_MAX,
30
- computeChrome,
31
- computeFieldFormatContext,
32
- defaultNumberFormatter,
33
- estimateTextWidth,
34
- HPAD_COMPACT_FRACTION,
35
- HPAD_COMPACT_MIN,
36
- isAxislessMark,
37
- LABEL_GAP_COMPACT,
38
- LABEL_GAP_DEFAULT,
39
- MAX_LEFT_LABEL_FRACTION_COMPACT,
40
- MAX_LEFT_LABEL_FRACTION_DEFAULT,
41
- MAX_LEFT_LABEL_FRACTION_MEDIUM,
42
- MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
43
- NARROW_VIEWPORT_MAX,
44
- resolveNumberFormatter,
45
- TOP_PAD_EXTRA_NARROW,
46
- } from '@opendata-ai/openchart-core';
47
-
48
- import type { NormalizedChartSpec } from '../compiler/types';
49
- import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
50
- import { hasLegendContent } from '../legend/compute';
51
- import { SIZE_LEGEND_GAP } from '../legend/size';
52
- import { countColorSeries, resolveSuppression } from '../legend/suppression';
53
- import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
54
- import { yTickPositionIsInline } from './axes';
55
- import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
56
- import type { LayoutPlan } from './plan';
57
- import { bottomMargin, chromeToInput, INLINE_TICK_OVERHANG_PAD, scalePadding } from './shared';
58
-
59
- /** Pull the metric-row font sizes from the resolved theme. */
60
- function metricFonts(theme: ResolvedTheme): MetricFontSizes {
61
- return { label: theme.fonts.sizes.metricLabel, value: theme.fonts.sizes.metricValue };
62
- }
63
-
64
- /** True when a placed legend layout has visible content (back-compat path). */
65
- function legendLayoutHasContent(legendLayout: LegendLayout): boolean {
66
- if (legendLayout.type === 'continuous') return legendLayout.bounds.height > 0;
67
- return 'entries' in legendLayout && legendLayout.entries.length > 0;
68
- }
69
-
70
- // ---------------------------------------------------------------------------
71
- // Types
72
- // ---------------------------------------------------------------------------
73
-
74
- /** The complete dimension layout for a chart. */
75
- export interface LayoutDimensions {
76
- /** Total available space. */
77
- total: Rect;
78
- /** Resolved chrome (title, subtitle, source, etc.). */
79
- chrome: ResolvedChrome;
80
- /** The chart drawing area (after subtracting chrome, legend, margins). */
81
- chartArea: Rect;
82
- /** Margins around the chart area. */
83
- margins: Margins;
84
- /** Resolved theme used for this layout. */
85
- theme: ResolvedTheme;
86
- /**
87
- * Resolved metric bar (KPI cells above the chart area). Present only when
88
- * spec.metrics is supplied AND the bar fits the container.
89
- */
90
- metrics?: ResolvedMetricBar;
91
- /**
92
- * Reserved series-search band (below chrome and the metric bar, above the
93
- * top legend). Present only when spec.seriesSearch is enabled.
94
- */
95
- seriesSearch?: ResolvedSeriesSearch;
96
- /**
97
- * Height reserved below the chart area for x-axis tick labels and the
98
- * (optional) axis title. Exposed so downstream layout code (e.g. the
99
- * second legend pass) can position elements below the axis row.
100
- */
101
- xAxisHeight: number;
102
- /**
103
- * The axis gap the margin stack placed between the top legend (or chrome,
104
- * when there is no top legend) and the chart area. With a top legend this
105
- * is the inline-tick overhang, and `placeLegend` must receive it as
106
- * `axisGapBelowLegend` so the legend sits above the reserved tick zone
107
- * instead of flush against `chartArea.y` (where inline y-tick labels draw).
108
- */
109
- effectiveAxisGap: number;
110
- }
111
-
112
- /** Minimum chart area dimensions before guardrails kick in. */
113
- const MIN_CHART_WIDTH = 60;
114
- const MIN_CHART_HEIGHT = 40;
115
-
116
- /** Input row height for the series-search band (matches the DOM input's CSS height). */
117
- const SERIES_SEARCH_INPUT_HEIGHT = 32;
118
- /** Gap between the series-search input row and whatever stacks below it. */
119
- const SERIES_SEARCH_GAP = 12;
120
-
121
- /**
122
- * Per-display minimum chart dimensions. Sparkline mode allows much smaller
123
- * containers (down to ~30x20px) since the entire chart is just the mark.
124
- */
125
- function getMinChartDims(display: import('@opendata-ai/openchart-core').Display): {
126
- width: number;
127
- height: number;
128
- } {
129
- return display === 'sparkline'
130
- ? { width: 30, height: 20 }
131
- : { width: MIN_CHART_WIDTH, height: MIN_CHART_HEIGHT };
132
- }
133
-
134
- /**
135
- * Resolve per-side safety padding for sparkline mode. Stroke-based padding
136
- * applies to every side so a thick line doesn't clip at the container edge.
137
- * Endpoint-dot padding applies only to the side that actually carries a dot:
138
- * `point: 'last'` reserves space on the right, `'first'` on the left, and
139
- * `true | 'endpoints' | 'transparent'` on both. This keeps tiny sparklines
140
- * flush left when the endpoint dot only renders at the right edge.
141
- */
142
- function getSparklinePad(spec: NormalizedChartSpec): {
143
- left: number;
144
- right: number;
145
- top: number;
146
- bottom: number;
147
- } {
148
- const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
149
- const point = (spec.markDef as { point?: unknown }).point;
150
- const strokePad = Math.max(strokeWidth / 2 + 1, 2);
151
- const dotPad = 4; // r=3.5 + 0.5 — matches the terminator dot size
152
-
153
- const dotRight =
154
- point === 'last' || point === true || point === 'endpoints' || point === 'transparent';
155
- const dotLeft =
156
- point === 'first' || point === true || point === 'endpoints' || point === 'transparent';
157
- const hasDots = dotRight || dotLeft;
158
-
159
- return {
160
- left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
161
- right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
162
- top: hasDots ? Math.max(strokePad, dotPad) : strokePad,
163
- bottom: hasDots ? Math.max(strokePad, dotPad) : strokePad,
164
- };
165
- }
166
-
167
- // ---------------------------------------------------------------------------
168
- // Public API
169
- // ---------------------------------------------------------------------------
170
-
171
- /**
172
- * Compute chart dimensions, reserving space for chrome, legend, and axes.
173
- *
174
- * @param spec - Normalized chart spec.
175
- * @param options - Compile options (width, height, theme, darkMode).
176
- * @param legendLayout - Pre-computed legend layout (used to reserve space).
177
- * @param theme - Already-resolved theme (resolved once in compileChart).
178
- * @param strategy - Responsive layout strategy (controls chrome mode).
179
- * @returns LayoutDimensions with chart area rect.
180
- */
181
- export function computeDimensions(
182
- spec: NormalizedChartSpec,
183
- options: CompileOptions,
184
- legendLayout: LegendLayout,
185
- theme: ResolvedTheme,
186
- strategy?: LayoutStrategy,
187
- watermark: boolean = true,
188
- plan?: LayoutPlan,
189
- ): LayoutDimensions {
190
- const { width, height } = options;
191
-
192
- const padding = scalePadding(theme.spacing.padding, width, height);
193
- // Horizontal padding can be tighter than the chrome text padding on narrow
194
- // containers because axis titles and tick labels tolerate closer edges.
195
- const hPad =
196
- width < BREAKPOINT_COMPACT_MAX
197
- ? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
198
- : padding;
199
- const axisMargin = theme.spacing.axisMargin;
200
- const userExplicit = spec.userExplicit;
201
- const isSparkline = spec.display === 'sparkline';
202
-
203
- // Sparkline mode forces chrome hidden unless the user opted in explicitly.
204
- // Force-hiding chrome here also short-circuits the watermark (which is
205
- // rendered as part of chrome), so we don't need a separate watermark gate.
206
- let chromeMode = strategy?.chromeMode ?? 'full';
207
- if (isSparkline && !userExplicit.chrome) {
208
- chromeMode = 'hidden';
209
- }
210
-
211
- // Pre-compute the bottom-legend reservation (legend height + gap) so the
212
- // chrome layout can stack source/byline/footer below the legend band.
213
- // When a layout plan is present, derive legend info from plan.legendContent;
214
- // otherwise fall back to the legendLayout parameter (back-compat).
215
- const bottomLegendReservation = plan
216
- ? hasLegendContent(plan.legendContent) && plan.legendContent.position === 'bottom'
217
- ? plan.legendContent.height + legendGap(width)
218
- : 0
219
- : legendLayoutHasContent(legendLayout) && legendLayout.position === 'bottom'
220
- ? legendLayout.bounds.height + legendGap(width)
221
- : 0;
222
-
223
- // When a layout plan is present, use its pre-computed chrome directly.
224
- // Otherwise compute chrome with mode and scaled padding.
225
- //
226
- // Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
227
- // `margins.bottom`. The legend reservation flows like this:
228
- // bottomLegendReservation = legend.height + legendGap(width)
229
- // chrome.bottomHeight >= bottomLegendReservation (via computeChrome)
230
- // margins.bottom = padding + chrome.bottomHeight + xAxisHeight
231
- // So the legend band is implicitly inside margins.bottom exactly once.
232
- // The legend-reservation block further down explicitly skips position
233
- // 'bottom' to avoid double-counting.
234
- const chrome =
235
- plan?.chrome ??
236
- computeChrome(
237
- chromeToInput(spec.chrome),
238
- theme,
239
- width,
240
- options.measureText,
241
- chromeMode,
242
- padding,
243
- watermark,
244
- bottomLegendReservation,
245
- );
246
-
247
- // Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
248
- // safety padding plus chrome (if user-explicit). Skip axis space, label
249
- // reservations, annotation reservations, and legend reservations unless the
250
- // user opted in to those individually.
251
- if (isSparkline) {
252
- const total: Rect = { x: 0, y: 0, width, height };
253
- const sparkPad = getSparklinePad(spec);
254
-
255
- // Axis space only when user explicitly set encoding.x/y.axis.
256
- const xAxisSpace = userExplicit.xAxis ? 26 : 0;
257
- const yAxisSpace = userExplicit.yAxis ? 30 : 0;
258
-
259
- const margins: Margins = {
260
- top: chrome.topHeight + sparkPad.top,
261
- right: sparkPad.right,
262
- bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace,
263
- left: sparkPad.left + yAxisSpace,
264
- };
265
-
266
- // Reserve legend space only when user explicitly opted into a legend.
267
- if (userExplicit.legend && legendLayoutHasContent(legendLayout)) {
268
- const gap = legendGap(width);
269
- if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
270
- margins.right += legendLayout.bounds.width + 8;
271
- } else if (legendLayout.position === 'top') {
272
- margins.top += legendLayout.bounds.height + gap;
273
- } else if (legendLayout.position === 'bottom') {
274
- margins.bottom += legendLayout.bounds.height + gap;
275
- }
276
- }
277
-
278
- const chartArea: Rect = {
279
- x: margins.left,
280
- y: margins.top,
281
- width: Math.max(0, width - margins.left - margins.right),
282
- height: Math.max(0, height - margins.top - margins.bottom),
283
- };
284
-
285
- return {
286
- total,
287
- chrome,
288
- chartArea,
289
- margins,
290
- theme,
291
- xAxisHeight: xAxisSpace,
292
- effectiveAxisGap: 0,
293
- };
294
- }
295
-
296
- // Start with the total rect
297
- const total: Rect = { x: 0, y: 0, width, height };
298
-
299
- // Axisless charts (arc, waffle, calendar) don't have axes, so skip axis space
300
- const isRadial = isAxislessMark(spec.markType);
301
- const encoding = spec.encoding as Encoding;
302
-
303
- // Estimate x-axis height below chart area: tick labels sit 14px below,
304
- // axis title sits 35px below. These extend past the chart area bottom
305
- // and source/footer chrome must be positioned below them.
306
- const xAxisSuppressed = encoding.x?.axis === false;
307
- const xAxis = (!xAxisSuppressed && encoding.x?.axis) as
308
- | (Record<string, unknown> & { labelAngle?: number })
309
- | undefined;
310
- const hasXAxisLabel = !!xAxis?.title;
311
- const xTickAngle = xAxis?.labelAngle;
312
-
313
- let xAxisHeight: number;
314
- if (plan) {
315
- xAxisHeight = plan.xAxisExtent;
316
- } else if (isRadial || xAxisSuppressed) {
317
- xAxisHeight = 0;
318
- } else if (xTickAngle && Math.abs(xTickAngle) > 10) {
319
- // Rotated labels: estimate height from the longest label text.
320
- // At -90 degrees, the label height = text width. At -45, it's width * sin(45).
321
- const angleRad = Math.abs(xTickAngle) * (Math.PI / 180);
322
- const xField = encoding.x?.field;
323
- let maxLabelWidth = 40; // fallback
324
- if (xField) {
325
- for (const row of spec.data) {
326
- const label = String(row[xField] ?? '');
327
- const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
328
- if (w > maxLabelWidth) maxLabelWidth = w;
329
- }
330
- }
331
- // Rotated label height: width * sin(angle), plus a small gap
332
- const rotatedHeight = maxLabelWidth * Math.sin(angleRad) + 6;
333
- // Cap at a reasonable max to avoid absurd margins
334
- const labelHeight = Math.min(rotatedHeight, 120);
335
- xAxisHeight = hasXAxisLabel ? labelHeight + 20 : labelHeight;
336
- } else {
337
- const base = theme.spacing.xAxisHeight;
338
- xAxisHeight = hasXAxisLabel ? base + 22 : base;
339
- }
340
-
341
- // Resolve effective y-axis tickPosition early so margin math can account
342
- // for the inline-tick overhang. Inline y-tick labels render above their
343
- // gridline inside the chart area; the topmost tick text extends roughly
344
- // (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
345
- // would otherwise crowd the chrome→chart gap.
346
- // Inline y-tick placement must match computeAxes exactly (shared predicate),
347
- // or the reserved left margin won't line up with where the title is drawn.
348
- const yIsInlinePre = yTickPositionIsInline(encoding.y, spec.markType);
349
- const inlineTickOverhang = yIsInlinePre
350
- ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
351
- : 0;
352
-
353
- // Build margins: padding + chrome + axis space.
354
- // For radial charts (arc/donut), axes don't exist, so axisMargin is only
355
- // added when there's actual chrome content that needs separation from the
356
- // chart area. When chrome is empty the margin is just padding.
357
- const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
358
- // Extra top padding on narrow viewports prevents iOS Safari from clipping
359
- // the title chrome behind the browser UI.
360
- const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
361
- // Tentative metric-bar reservation. The bar's final inclusion is decided
362
- // below by computeMetricBar, which can strip it on overflow / narrow areas.
363
- // We reserve optimistically so the chart-area math is correct when the bar
364
- // is kept; the rollback path subtracts it back when stripped.
365
- const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
366
- const tentativeMetricsHeight = wantsMetrics ? metricBarHeight(metricFonts(theme)) : 0;
367
- // Series-search band: reserved empty SVG space directly below chrome and
368
- // the metric bar. The vanilla adapter overlays a DOM combobox on this rect.
369
- const wantsSearch = !!spec.seriesSearch && chromeMode !== 'hidden';
370
- const searchReservation = wantsSearch ? SERIES_SEARCH_INPUT_HEIGHT + SERIES_SEARCH_GAP : 0;
371
- // topAxisGap sits between the legend (or chrome, if no legend) and the
372
- // chart area. It accounts for the general axis margin plus any inline
373
- // tick-label overhang. Placing it after the legend (below) keeps the
374
- // subtitle-to-legend gap tight while reserving physical space for ticks
375
- // that protrude above the chart area.
376
- const margins: Margins = {
377
- top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
378
- right: hPad + (isRadial ? hPad : axisMargin),
379
- bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
380
- left: hPad + (isRadial ? hPad : axisMargin),
381
- };
382
-
383
- // Right-margin reservation for the three-way label suppression truth table:
384
- //
385
- // 1. Endpoint-labels column (predicted width, default ON for ≥2-series
386
- // line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width.
387
- // 2. Legacy end-of-line labels — only when the truth table resolves to
388
- // `showEndOfLineLabels: true` (legend hidden AND endpoint column off).
389
- // 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2)
390
- // so a callout at maxX lands between the chart area and any column to
391
- // its right.
392
- const labelDensity = spec.labels.density;
393
- const labelsHiddenByStrategy = strategy?.labelMode === 'none';
394
- const seriesCount = countColorSeries(spec);
395
- const sup = resolveSuppression(spec, {
396
- seriesCount,
397
- labelsHiddenByStrategy,
398
- labelsDensityNone: labelDensity === 'none',
399
- endpointLabelsDemoted: plan?.endpointLabelsDemoted,
400
- });
401
-
402
- // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
403
- // when the column would be suppressed. `labels.density` is intentionally
404
- // not checked here — that switch controls only the legacy end-of-line labels.
405
- // No extra strategy check either: `sup.showEndpointLabels` already accounts
406
- // for the compact strip (and its explicit-opt-in override).
407
- let endpointWidth = 0;
408
- if (sup.showEndpointLabels) {
409
- endpointWidth = predictEndpointLabelsWidth(spec, theme);
410
- if (endpointWidth > 0) {
411
- // 16px gap between chart area edge and the column.
412
- margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
413
- // Both-ends mode: mirror the column on the left for first-value labels.
414
- if (isEndsBoth(spec)) {
415
- margins.left = Math.max(margins.left, hPad) + endpointWidth + 16;
416
- }
417
- }
418
- }
419
-
420
- // (2) Legacy end-of-line label reservation — fires only in the truth-table
421
- // cell where end-of-line labels still render (legend hidden AND endpoint
422
- // column off AND ≥2 series AND labels visible). When the endpoint column
423
- // is on, this reservation is redundant and is skipped.
424
- if (
425
- endpointWidth === 0 &&
426
- sup.showEndOfLineLabels &&
427
- (spec.markType === 'line' || spec.markType === 'area') &&
428
- labelDensity !== 'none' &&
429
- !labelsHiddenByStrategy
430
- ) {
431
- const colorEnc = encoding.color;
432
- const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
433
- if (colorField) {
434
- let maxLabelWidth = 0;
435
- const seen = new Set<string>();
436
- for (const row of spec.data) {
437
- const label = String(row[colorField] ?? '');
438
- if (!seen.has(label)) {
439
- seen.add(label);
440
- const w = estimateTextWidth(label, 11, 600);
441
- if (w > maxLabelWidth) maxLabelWidth = w;
442
- }
443
- }
444
- if (maxLabelWidth > 0) {
445
- margins.right = Math.max(margins.right, hPad + maxLabelWidth + 8);
446
- }
447
- }
448
- }
449
-
450
- // (3) Right-edge text annotations. The annotation text and the endpoint
451
- // column occupy the same right-side space, so take the max rather than
452
- // stacking additively.
453
- if (
454
- strategy?.annotationPosition !== 'tooltip-only' &&
455
- spec.annotations.length > 0 &&
456
- encoding.x
457
- ) {
458
- const xField = encoding.x.field;
459
- let maxX: string | number | undefined;
460
- for (const row of spec.data) {
461
- const v = row[xField];
462
- if (v != null && (maxX == null || String(v) >= String(maxX))) maxX = v as string | number;
463
- }
464
- if (maxX != null) {
465
- const maxXStr = String(maxX);
466
- for (const ann of spec.annotations) {
467
- if (ann.type === 'text' && String(ann.x) === maxXStr) {
468
- const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
469
- const dx = ann.offset?.dx ?? 0;
470
- const anchor = ann.anchor ?? 'auto';
471
- const baseRightExtent =
472
- anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2;
473
- const rightOverflow = Math.max(0, baseRightExtent + dx);
474
- if (rightOverflow > 0) {
475
- margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
476
- }
477
- }
478
- }
479
- }
480
- }
481
-
482
- // Dynamic left margin for y-axis labels (yIsInline already resolved above
483
- // for inline-tick top-margin reservation).
484
- const yAxisSuppressed = encoding.y?.axis === false;
485
- const yIsInline = yIsInlinePre;
486
- if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
487
- if (
488
- !plan &&
489
- (spec.markType === 'bar' ||
490
- spec.markType === 'circle' ||
491
- spec.markType === 'lollipop' ||
492
- encoding.y.type === 'nominal' ||
493
- encoding.y.type === 'ordinal')
494
- ) {
495
- // Category labels on the left for bar/dot charts
496
- const yField = encoding.y.field;
497
- const yLabelField = (encoding.y.axis as Record<string, unknown> | undefined)?.labelField as
498
- | string
499
- | undefined;
500
- let maxLabelWidth = 0;
501
- for (const row of spec.data) {
502
- const label = String(row[yField] ?? '');
503
- let w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
504
- // When labelField is set, add a gap and the subtitle width
505
- if (yLabelField) {
506
- const subtitle = String(row[yLabelField] ?? '');
507
- if (subtitle) {
508
- const gap = theme.fonts.sizes.axisTick * 0.6;
509
- const subtitleWidth = estimateTextWidth(
510
- subtitle,
511
- theme.fonts.sizes.axisTick,
512
- theme.fonts.weights.normal,
513
- );
514
- w += gap + subtitleWidth;
515
- }
516
- }
517
- if (w > maxLabelWidth) maxLabelWidth = w;
518
- }
519
- if (maxLabelWidth > 0) {
520
- // Tighter label-to-chart gap on narrow containers
521
- const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
522
- // Clamp reservation so bars keep at least ~45% of container width on
523
- // narrow viewports. Labels that exceed the cap will be truncated by
524
- // the axis renderer (see axes.ts).
525
- const maxLeftFraction =
526
- width < BREAKPOINT_COMPACT_MAX
527
- ? MAX_LEFT_LABEL_FRACTION_COMPACT
528
- : width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
529
- ? MAX_LEFT_LABEL_FRACTION_MEDIUM
530
- : MAX_LEFT_LABEL_FRACTION_DEFAULT;
531
- const maxLeftReserved = Math.floor(width * maxLeftFraction);
532
- const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
533
- margins.left = Math.max(margins.left, reserved);
534
- }
535
- } else if ((encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') && !plan) {
536
- // Numeric tick labels on the left. Estimate width from the data range.
537
- // Skipped when a layout plan is present -- the plan measured real tick
538
- // labels and provides plan.leftGutter below.
539
- const yField = encoding.y.field;
540
- const yAxisFormat = (encoding.y.axis as Record<string, unknown> | undefined)?.format as
541
- | string
542
- | undefined;
543
- let maxAbsVal = 0;
544
- for (const row of spec.data) {
545
- const v = Number(row[yField]);
546
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
547
- }
548
-
549
- const ctx = computeFieldFormatContext(spec.data.map((r) => r[yField]));
550
- let sampleLabel: string;
551
- if (yAxisFormat) {
552
- const fmt = resolveNumberFormatter(yAxisFormat, ctx);
553
- sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
554
- } else {
555
- sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
556
- maxAbsVal,
557
- );
558
- }
559
- const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
560
- const labelEst = negPrefix + sampleLabel;
561
- const labelWidth = estimateTextWidth(
562
- labelEst,
563
- theme.fonts.sizes.axisTick,
564
- theme.fonts.weights.normal,
565
- );
566
- // 6px gap between label and chart area edge
567
- margins.left = Math.max(margins.left, hPad + labelWidth + 10);
568
- }
569
- }
570
-
571
- // Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
572
- // The renderer computes a dynamic offset that accounts for wide tick labels (e.g.
573
- // "$100,000" is ~62px wide and would overlap a fixed 45px offset). We replicate
574
- // the same formula here so the reserved space matches what the renderer places.
575
- // Skipped when a layout plan is present -- the plan already accounts for the
576
- // y-axis title in its leftGutter calculation.
577
- const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
578
- if (yAxis && (yAxis.title || yAxis.label) && !isRadial && !plan) {
579
- // Estimate the widest y-axis tick label width to mirror the renderer's dynamic offset.
580
- const yFieldForTitle = encoding.y?.field;
581
- const yAxisFormatForTitle = yAxis?.format as string | undefined;
582
- let estTickLabelWidth = 0;
583
- // Inline y-tick labels render inside the chart area (above their gridlines),
584
- // not in a left gutter, so they add no width the rotated title must clear.
585
- // Counting them here would reserve a phantom gutter and leave a dead gap
586
- // between the title and the plot.
587
- if (
588
- yFieldForTitle &&
589
- !yIsInlinePre &&
590
- (encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
591
- ) {
592
- let maxAbsValForTitle = 0;
593
- for (const row of spec.data) {
594
- const v = Number(row[yFieldForTitle]);
595
- if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
596
- }
597
- const ctxForTitle = computeFieldFormatContext(spec.data.map((r) => r[yFieldForTitle!]));
598
- let sampleLabelForTitle: string;
599
- if (yAxisFormatForTitle) {
600
- const fmt = resolveNumberFormatter(yAxisFormatForTitle, ctxForTitle);
601
- sampleLabelForTitle = fmt ? fmt(maxAbsValForTitle) : String(maxAbsValForTitle);
602
- } else {
603
- sampleLabelForTitle = defaultNumberFormatter({
604
- ...ctxForTitle,
605
- extent: ctxForTitle.extent ?? [0, maxAbsValForTitle],
606
- })(maxAbsValForTitle);
607
- }
608
- const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
609
- estTickLabelWidth = estimateTextWidth(
610
- negPrefixForTitle + sampleLabelForTitle,
611
- theme.fonts.sizes.axisTick,
612
- theme.fonts.weights.normal,
613
- );
614
- }
615
- // Mirror the renderer's title placement so the reserved space matches where
616
- // the title is drawn. axisTitleOffset() returns the distance to the title's
617
- // center; it already includes the title half-glyph on the tick-label side.
618
- // We add another halfGlyph here for the title glyph extending the other way,
619
- // toward the container edge, so the margin reaches the title's outer edge.
620
- const titleFontSize = theme.fonts.sizes.body;
621
- const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width, yIsInlinePre);
622
- const halfGlyph = Math.ceil(titleFontSize / 2);
623
- const rotatedLabelMargin =
624
- offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
625
- margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
626
- }
627
-
628
- // When a layout plan is present, its leftGutter was measured from real tick
629
- // labels and already accounts for quantitative guess + y-axis title. Apply
630
- // it as a floor so the plan's measurement wins over the category-label
631
- // reservation above (which stays for bar/dot charts).
632
- if (plan) {
633
- margins.left = Math.max(margins.left, plan.leftGutter);
634
- }
635
-
636
- // Reserve space for a secondary (right) y-axis in dual-axis charts.
637
- // Use Math.max (not +=) to mirror the left-margin pattern: the reserve
638
- // replaces the base axisMargin when it's larger, instead of stacking.
639
- if (options.rightAxisReserve && options.rightAxisReserve > 0) {
640
- margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
641
- }
642
-
643
- // Reserve space for the auto-thinning footnote list. Additive, not Math.max:
644
- // the footnotes stack above the source/byline/footer row that bottomHeight
645
- // already covers, rather than replacing it.
646
- if (options.footnoteReserve && options.footnoteReserve > 0) {
647
- margins.bottom += options.footnoteReserve;
648
- }
649
-
650
- // Reserve legend space.
651
- //
652
- // Bottom legend: reservation is already baked into `chrome.bottomHeight`
653
- // via `bottomLegendReservation`, so no additional bottom margin is needed
654
- // here. The legend lands below the x-axis tick row (which is reserved via
655
- // `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
656
- // stacks underneath the legend band rather than colliding with it.
657
- // When a plan is present, derive legend position/size from plan.legendContent;
658
- // otherwise use the legendLayout parameter (back-compat for non-plan callers).
659
- const legendHasEntries = plan
660
- ? hasLegendContent(plan.legendContent)
661
- : legendLayoutHasContent(legendLayout);
662
- const legendPos = plan ? plan.legendContent.position : legendLayout.position;
663
- const legendHeight = plan ? plan.legendContent.height : legendLayout.bounds.height;
664
- const legendBoundsWidth = plan ? plan.legendContent.legendWidth : legendLayout.bounds.width;
665
-
666
- const sizeLegend = plan?.sizeLegendContent;
667
- const colorLegendInGutter =
668
- legendHasEntries && (legendPos === 'right' || legendPos === 'bottom-right');
669
-
670
- const hasTopLegend = legendHasEntries && legendPos === 'top';
671
- if (legendHasEntries) {
672
- const gap = legendGap(width);
673
- if (legendPos === 'top') {
674
- // TOP_LEGEND_GAP_ABOVE keeps the legend from sitting flush against the
675
- // subtitle/metric bar now that the axis gap sits BELOW the legend.
676
- margins.top += TOP_LEGEND_GAP_ABOVE + legendHeight + gap;
677
- }
678
- // 'right'/'bottom-right' fall through to the shared right-column reservation
679
- // below; 'bottom' is intentionally not handled here -- see
680
- // bottomLegendReservation above.
681
- }
682
-
683
- // Right-hand legend column. The color legend and the size legend SHARE it,
684
- // stacked vertically (see placeSizeLegend), so it is reserved at the width of
685
- // the wider block -- not the sum.
686
- //
687
- // Reserving the sum is what the additive version did, and it charged the plot
688
- // twice: a bubble chart keying continent (67px) and population (92px) gave up
689
- // ~213px of a 934px canvas to render two blocks that are each ~70px tall. The
690
- // column below them was dead space. Taking the max spends that empty vertical
691
- // room instead and hands the difference back to the plot.
692
- //
693
- // Right column, not top: graduated circles are as tall as the largest bubble's
694
- // diameter, and a ~60px band across the top of a 400px chart eats the plot.
695
- //
696
- // Each block keeps its own gap (the color legend's 8, the size legend's 12) so
697
- // a chart with only one of them reserves exactly what it did before.
698
- const colorReserve = colorLegendInGutter ? legendBoundsWidth + 8 : 0;
699
- const sizeReserve = sizeLegend ? sizeLegend.width + SIZE_LEGEND_GAP : 0;
700
- margins.right += Math.max(colorReserve, sizeReserve);
701
-
702
- // effectiveAxisGap sits between the legend (or chrome, if no legend) and
703
- // the chart area. When a top legend is present, the legendGap already
704
- // provides breathing room, so only the inlineTickOverhang is needed (the
705
- // axisMargin component would double up with legendGap). Without a top
706
- // legend, the full topAxisGap (axisMargin + inlineTickOverhang) applies.
707
- // The value is returned so placeLegend can position the top legend above
708
- // this reserved zone (inline y-tick labels draw inside it).
709
- const effectiveAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
710
- margins.top += effectiveAxisGap;
711
-
712
- // Chart area is what's left after margins
713
- let chartArea: Rect = {
714
- x: margins.left,
715
- y: margins.top,
716
- width: Math.max(0, width - margins.left - margins.right),
717
- height: Math.max(0, height - margins.top - margins.bottom),
718
- };
719
-
720
- // Guardrail: if chart area is too small, progressively strip chrome
721
- const minDims = getMinChartDims(spec.display);
722
- if (
723
- (chartArea.width < minDims.width || chartArea.height < minDims.height) &&
724
- chromeMode !== 'hidden'
725
- ) {
726
- // Try compact first, then hidden
727
- const fallbackMode = chromeMode === 'full' ? 'compact' : 'hidden';
728
- const fallbackChrome = computeChrome(
729
- chromeToInput(spec.chrome),
730
- theme,
731
- width,
732
- options.measureText,
733
- fallbackMode as 'compact' | 'hidden',
734
- padding,
735
- watermark,
736
- bottomLegendReservation,
737
- );
738
-
739
- // Recalculate top/bottom margins with stripped chrome.
740
- // Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
741
- // Include the tentative metric reservation so the rollback below mirrors
742
- // the primary path's invariant (margins.top includes tentativeMetricsHeight
743
- // until resolveMetrics decides otherwise).
744
- const fallbackTopAxisGap =
745
- isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
746
- const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
747
- const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation;
748
- const topDelta = margins.top - newTop;
749
- const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
750
- const bottomDelta = margins.bottom - newBottom;
751
-
752
- if (topDelta > 0 || bottomDelta > 0) {
753
- const gap = legendGap(width);
754
- margins.top =
755
- newTop +
756
- (hasTopLegend ? TOP_LEGEND_GAP_ABOVE + legendHeight + gap : 0) +
757
- fallbackEffectiveAxisGap;
758
- margins.bottom = newBottom;
759
-
760
- chartArea = {
761
- x: margins.left,
762
- y: margins.top,
763
- width: Math.max(0, width - margins.left - margins.right),
764
- height: Math.max(0, height - margins.top - margins.bottom),
765
- };
766
-
767
- // Same chrome-anchored positioning as the primary path; see comment
768
- // near the primary `metricsTopY` for the full stacking order.
769
- const fallbackMetricsTopY = topPad + fallbackChrome.topHeight;
770
- const fallbackMetricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
771
- const fallbackMetrics = wantsMetrics
772
- ? resolveMetrics(
773
- spec,
774
- fallbackMetricsTopY,
775
- fallbackMetricsArea,
776
- chartArea.height,
777
- options.measureText,
778
- theme,
779
- )
780
- : undefined;
781
- if (wantsMetrics && !fallbackMetrics) {
782
- // Bar was tentatively reserved but didn't fit — roll back the top margin.
783
- // Clamp at 0 as a defensive guard: even though the reservation was
784
- // additive (margins.top = topPad + chrome + tentative + axisGap [+ legend])
785
- // and so subtraction is mathematically safe, a negative top margin would
786
- // shift the chart above the SVG viewport if any future change ever
787
- // reordered the additions.
788
- margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
789
- chartArea = {
790
- ...chartArea,
791
- y: margins.top,
792
- height: Math.max(0, height - margins.top - margins.bottom),
793
- };
794
- }
795
- return {
796
- total,
797
- chrome: fallbackChrome,
798
- chartArea,
799
- margins,
800
- theme,
801
- metrics: fallbackMetrics,
802
- seriesSearch: wantsSearch
803
- ? resolveSeriesSearch(
804
- spec,
805
- fallbackMetricsTopY + (fallbackMetrics?.height ?? 0),
806
- fallbackMetricsArea,
807
- )
808
- : undefined,
809
- xAxisHeight,
810
- effectiveAxisGap: fallbackEffectiveAxisGap,
811
- };
812
- }
813
- }
814
-
815
- // Vertical stacking order from the SVG top edge:
816
- // topPad
817
- // chrome.topHeight (title / subtitle / eyebrow)
818
- // tentativeMetricsHeight (KPI bar — placed here)
819
- // [optional top legend band (TOP_LEGEND_GAP_ABOVE + legend + legendGap)]
820
- // effectiveAxisGap (inlineTickOverhang with a top legend,
821
- // else axisMargin + inlineTickOverhang)
822
- // chartArea
823
- // The metric bar belongs with chrome, above the legend, so its y is
824
- // computed off chrome.topHeight only — not the full legend-inclusive
825
- // margins.top.
826
- const metricsTopY = topPad + chrome.topHeight;
827
- const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
828
- const resolvedMetrics = wantsMetrics
829
- ? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText, theme)
830
- : undefined;
831
- if (wantsMetrics && !resolvedMetrics) {
832
- // See fallback path above for the clamp rationale.
833
- margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
834
- chartArea = {
835
- ...chartArea,
836
- y: margins.top,
837
- height: Math.max(0, height - margins.top - margins.bottom),
838
- };
839
- }
840
- return {
841
- total,
842
- chrome,
843
- chartArea,
844
- margins,
845
- theme,
846
- metrics: resolvedMetrics,
847
- seriesSearch: wantsSearch
848
- ? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea)
849
- : undefined,
850
- xAxisHeight,
851
- effectiveAxisGap,
852
- };
853
- }
854
-
855
- /**
856
- * Resolve the series-search band. Spans the full chrome content width
857
- * (hPad to width - hPad, same as the metric bar) directly below chrome and
858
- * the metric bar. The band itself is empty SVG space: the vanilla adapter
859
- * absolutely positions a DOM combobox over it, so mounting the input never
860
- * changes the observed container size.
861
- */
862
- function resolveSeriesSearch(
863
- spec: NormalizedChartSpec,
864
- y: number,
865
- area: { x: number; width: number },
866
- ): ResolvedSeriesSearch | undefined {
867
- const config = spec.seriesSearch;
868
- if (!config) return undefined;
869
- const colorEnc = spec.encoding.color;
870
- // Normalization already guarantees a categorical color encoding when
871
- // seriesSearch survives; these guards narrow the union for TypeScript.
872
- if (!colorEnc || 'condition' in colorEnc || !('field' in colorEnc) || !colorEnc.field) {
873
- return undefined;
874
- }
875
- const field = colorEnc.field;
876
- // Same enumeration rule as legend entries: skip rows missing the color field.
877
- const values = [
878
- ...new Set(spec.data.filter((d) => d[field] != null).map((d) => String(d[field]))),
879
- ];
880
- return {
881
- x: area.x,
882
- y,
883
- width: area.width,
884
- height: SERIES_SEARCH_INPUT_HEIGHT,
885
- placeholder: config.placeholder ?? 'Find a series',
886
- values,
887
- };
888
- }
889
-
890
- /**
891
- * Resolve the metric bar layout. The bar spans the full chrome content width
892
- * (from hPad to width - hPad), aligning with the title/eyebrow rather than
893
- * indenting to the chart area's left gutter. Its `y` sits directly below
894
- * chrome and above any top legend.
895
- */
896
- function resolveMetrics(
897
- spec: NormalizedChartSpec,
898
- metricsTopY: number,
899
- metricsArea: { x: number; width: number },
900
- remainingChartHeight: number,
901
- measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
902
- theme: ResolvedTheme,
903
- ): ResolvedMetricBar | undefined {
904
- return computeMetricBar(
905
- spec.metrics,
906
- metricsTopY,
907
- metricsArea,
908
- remainingChartHeight,
909
- measureText,
910
- metricFonts(theme),
911
- );
912
- }