@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1

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 (159) hide show
  1. package/dist/index.d.ts +142 -7
  2. package/dist/index.js +16182 -10032
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
  6. package/src/__tests__/annotation-behavior.test.ts +721 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/dimensions.test.ts +49 -4
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +120 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +222 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +312 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +117 -21
  91. package/src/compile/size-scale.ts +139 -0
  92. package/src/compile/spec-sugar.ts +758 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +315 -103
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/normalize.test.ts +65 -1
  116. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  117. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  118. package/src/compiler/__tests__/validate.test.ts +156 -0
  119. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  120. package/src/compiler/a11y-warnings.ts +110 -0
  121. package/src/compiler/normalize.ts +150 -7
  122. package/src/compiler/types.ts +11 -0
  123. package/src/compiler/validate.ts +664 -18
  124. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  125. package/src/endpoint-labels/compute.ts +40 -12
  126. package/src/endpoint-labels/format.ts +45 -15
  127. package/src/endpoint-labels/predict.ts +50 -36
  128. package/src/format/__tests__/field-format.test.ts +164 -0
  129. package/src/format/field-format.ts +27 -0
  130. package/src/geo/__tests__/compile-map.test.ts +621 -0
  131. package/src/geo/compile-map.ts +856 -0
  132. package/src/geo/join.ts +115 -0
  133. package/src/geo/projections.ts +45 -0
  134. package/src/geo/types.ts +24 -0
  135. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  136. package/src/graphs/compile-graph.ts +27 -18
  137. package/src/index.ts +28 -2
  138. package/src/layout/axes/ticks.ts +59 -10
  139. package/src/layout/axes.ts +30 -2
  140. package/src/layout/dimensions.ts +178 -95
  141. package/src/layout/facet.ts +14 -3
  142. package/src/layout/plan.ts +86 -35
  143. package/src/layout/scales.ts +132 -15
  144. package/src/legend/__tests__/continuous.test.ts +318 -0
  145. package/src/legend/__tests__/size.test.ts +348 -0
  146. package/src/legend/__tests__/suppression.test.ts +17 -1
  147. package/src/legend/compute.ts +126 -14
  148. package/src/legend/continuous.ts +327 -0
  149. package/src/legend/size.ts +407 -0
  150. package/src/legend/suppression.ts +8 -7
  151. package/src/legend/wrap.ts +8 -0
  152. package/src/sankey/compile-sankey.ts +31 -19
  153. package/src/tables/format-cells.ts +16 -7
  154. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  155. package/src/tilemap/compile-tilemap.ts +24 -7
  156. package/src/tooltips/__tests__/compute.test.ts +2 -2
  157. package/src/tooltips/compute.ts +422 -41
  158. package/src/transforms/__tests__/conditional.test.ts +18 -0
  159. package/src/transforms/aggregate.ts +2 -1
@@ -20,6 +20,7 @@ import type {
20
20
  Rect,
21
21
  ResolvedChrome,
22
22
  ResolvedMetricBar,
23
+ ResolvedSeriesSearch,
23
24
  ResolvedTheme,
24
25
  } from '@opendata-ai/openchart-core';
25
26
  import {
@@ -27,9 +28,12 @@ import {
27
28
  axisTitleOffset,
28
29
  BREAKPOINT_COMPACT_MAX,
29
30
  computeChrome,
31
+ computeFieldFormatContext,
32
+ defaultNumberFormatter,
30
33
  estimateTextWidth,
31
34
  HPAD_COMPACT_FRACTION,
32
35
  HPAD_COMPACT_MIN,
36
+ isAxislessMark,
33
37
  LABEL_GAP_COMPACT,
34
38
  LABEL_GAP_DEFAULT,
35
39
  MAX_LEFT_LABEL_FRACTION_COMPACT,
@@ -37,14 +41,16 @@ import {
37
41
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
38
42
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
39
43
  NARROW_VIEWPORT_MAX,
44
+ resolveNumberFormatter,
40
45
  TOP_PAD_EXTRA_NARROW,
41
46
  } from '@opendata-ai/openchart-core';
42
- import { format as d3Format } from 'd3-format';
43
47
 
44
48
  import type { NormalizedChartSpec } from '../compiler/types';
45
49
  import { isEndsBoth, predictEndpointLabelsWidth } from '../endpoint-labels/predict';
50
+ import { hasLegendContent } from '../legend/compute';
51
+ import { SIZE_LEGEND_GAP } from '../legend/size';
46
52
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
47
- import { legendGap } from '../legend/wrap';
53
+ import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
48
54
  import { yTickPositionIsInline } from './axes';
49
55
  import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
50
56
  import type { LayoutPlan } from './plan';
@@ -55,6 +61,12 @@ function metricFonts(theme: ResolvedTheme): MetricFontSizes {
55
61
  return { label: theme.fonts.sizes.metricLabel, value: theme.fonts.sizes.metricValue };
56
62
  }
57
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
+
58
70
  // ---------------------------------------------------------------------------
59
71
  // Types
60
72
  // ---------------------------------------------------------------------------
@@ -76,18 +88,36 @@ export interface LayoutDimensions {
76
88
  * spec.metrics is supplied AND the bar fits the container.
77
89
  */
78
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;
79
96
  /**
80
97
  * Height reserved below the chart area for x-axis tick labels and the
81
98
  * (optional) axis title. Exposed so downstream layout code (e.g. the
82
99
  * second legend pass) can position elements below the axis row.
83
100
  */
84
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;
85
110
  }
86
111
 
87
112
  /** Minimum chart area dimensions before guardrails kick in. */
88
113
  const MIN_CHART_WIDTH = 60;
89
114
  const MIN_CHART_HEIGHT = 40;
90
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
+
91
121
  /**
92
122
  * Per-display minimum chart dimensions. Sparkline mode allows much smaller
93
123
  * containers (down to ~30x20px) since the entire chart is just the mark.
@@ -183,12 +213,10 @@ export function computeDimensions(
183
213
  // When a layout plan is present, derive legend info from plan.legendContent;
184
214
  // otherwise fall back to the legendLayout parameter (back-compat).
185
215
  const bottomLegendReservation = plan
186
- ? plan.legendContent.entries.length > 0 && plan.legendContent.position === 'bottom'
216
+ ? hasLegendContent(plan.legendContent) && plan.legendContent.position === 'bottom'
187
217
  ? plan.legendContent.height + legendGap(width)
188
218
  : 0
189
- : 'entries' in legendLayout &&
190
- legendLayout.entries.length > 0 &&
191
- legendLayout.position === 'bottom'
219
+ : legendLayoutHasContent(legendLayout) && legendLayout.position === 'bottom'
192
220
  ? legendLayout.bounds.height + legendGap(width)
193
221
  : 0;
194
222
 
@@ -236,7 +264,7 @@ export function computeDimensions(
236
264
  };
237
265
 
238
266
  // Reserve legend space only when user explicitly opted into a legend.
239
- if (userExplicit.legend && 'entries' in legendLayout && legendLayout.entries.length > 0) {
267
+ if (userExplicit.legend && legendLayoutHasContent(legendLayout)) {
240
268
  const gap = legendGap(width);
241
269
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
242
270
  margins.right += legendLayout.bounds.width + 8;
@@ -254,14 +282,22 @@ export function computeDimensions(
254
282
  height: Math.max(0, height - margins.top - margins.bottom),
255
283
  };
256
284
 
257
- return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
285
+ return {
286
+ total,
287
+ chrome,
288
+ chartArea,
289
+ margins,
290
+ theme,
291
+ xAxisHeight: xAxisSpace,
292
+ effectiveAxisGap: 0,
293
+ };
258
294
  }
259
295
 
260
296
  // Start with the total rect
261
297
  const total: Rect = { x: 0, y: 0, width, height };
262
298
 
263
- // Radial charts (arc) don't have axes, so skip axis space
264
- const isRadial = spec.markType === 'arc';
299
+ // Axisless charts (arc, waffle, calendar) don't have axes, so skip axis space
300
+ const isRadial = isAxislessMark(spec.markType);
265
301
  const encoding = spec.encoding as Encoding;
266
302
 
267
303
  // Estimate x-axis height below chart area: tick labels sit 14px below,
@@ -328,13 +364,17 @@ export function computeDimensions(
328
364
  // is kept; the rollback path subtracts it back when stripped.
329
365
  const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
330
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;
331
371
  // topAxisGap sits between the legend (or chrome, if no legend) and the
332
372
  // chart area. It accounts for the general axis margin plus any inline
333
373
  // tick-label overhang. Placing it after the legend (below) keeps the
334
374
  // subtitle-to-legend gap tight while reserving physical space for ticks
335
375
  // that protrude above the chart area.
336
376
  const margins: Margins = {
337
- top: topPad + chrome.topHeight + tentativeMetricsHeight,
377
+ top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
338
378
  right: hPad + (isRadial ? hPad : axisMargin),
339
379
  bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
340
380
  left: hPad + (isRadial ? hPad : axisMargin),
@@ -362,8 +402,10 @@ export function computeDimensions(
362
402
  // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
363
403
  // when the column would be suppressed. `labels.density` is intentionally
364
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).
365
407
  let endpointWidth = 0;
366
- if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
408
+ if (sup.showEndpointLabels) {
367
409
  endpointWidth = predictEndpointLabelsWidth(spec, theme);
368
410
  if (endpointWidth > 0) {
369
411
  // 16px gap between chart area edge and the column.
@@ -405,17 +447,15 @@ export function computeDimensions(
405
447
  }
406
448
  }
407
449
 
408
- // (3) Right-edge text annotations. Stacks ADDITIVELY on top of any
409
- // endpoint-labels reservation so the annotation text lands between the
410
- // chart area's right edge and the endpoint column. When no endpoint column
411
- // is reserved, behaves as before (max-of with the existing margin).
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.
412
453
  if (
413
454
  strategy?.annotationPosition !== 'tooltip-only' &&
414
455
  spec.annotations.length > 0 &&
415
456
  encoding.x
416
457
  ) {
417
458
  const xField = encoding.x.field;
418
- // Find the maximum x value in the data
419
459
  let maxX: string | number | undefined;
420
460
  for (const row of spec.data) {
421
461
  const v = row[xField];
@@ -427,28 +467,12 @@ export function computeDimensions(
427
467
  if (ann.type === 'text' && String(ann.x) === maxXStr) {
428
468
  const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
429
469
  const dx = ann.offset?.dx ?? 0;
430
- // How much text extends right of the anchor point depends on alignment:
431
- // - anchor "right" or "left": text is off to one side, full width extends
432
- // - anchor "top"/"bottom"/"auto"/undefined: text is centered, half extends right
433
470
  const anchor = ann.anchor ?? 'auto';
434
471
  const baseRightExtent =
435
- anchor === 'left'
436
- ? textWidth
437
- : // text is to the right of anchor
438
- anchor === 'right'
439
- ? 0
440
- : // text is to the left of anchor
441
- textWidth / 2; // centered (top/bottom/auto)
472
+ anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2;
442
473
  const rightOverflow = Math.max(0, baseRightExtent + dx);
443
474
  if (rightOverflow > 0) {
444
- if (endpointWidth > 0) {
445
- // Endpoint column already reserved space at the far right; the
446
- // annotation lands BETWEEN the chart edge and the column, so
447
- // stack additively rather than max-of.
448
- margins.right += rightOverflow + 12;
449
- } else {
450
- margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
451
- }
475
+ margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
452
476
  }
453
477
  }
454
478
  }
@@ -522,25 +546,16 @@ export function computeDimensions(
522
546
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
523
547
  }
524
548
 
549
+ const ctx = computeFieldFormatContext(spec.data.map((r) => r[yField]));
525
550
  let sampleLabel: string;
526
551
  if (yAxisFormat) {
527
- // Use the actual d3-format to produce a realistic label estimate
528
- try {
529
- const fmt = d3Format(yAxisFormat);
530
- sampleLabel = fmt(maxAbsVal);
531
- } catch {
532
- sampleLabel = String(maxAbsVal);
533
- }
552
+ const fmt = resolveNumberFormatter(yAxisFormat, ctx);
553
+ sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
534
554
  } else {
535
- // Fallback: estimate from magnitude
536
- if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
537
- else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
538
- else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
539
- else if (maxAbsVal >= 100) sampleLabel = '100';
540
- else if (maxAbsVal >= 10) sampleLabel = '10';
541
- else sampleLabel = '0.0';
555
+ sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
556
+ maxAbsVal,
557
+ );
542
558
  }
543
- // Account for negative sign
544
559
  const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
545
560
  const labelEst = negPrefix + sampleLabel;
546
561
  const labelWidth = estimateTextWidth(
@@ -579,21 +594,16 @@ export function computeDimensions(
579
594
  const v = Number(row[yFieldForTitle]);
580
595
  if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
581
596
  }
597
+ const ctxForTitle = computeFieldFormatContext(spec.data.map((r) => r[yFieldForTitle!]));
582
598
  let sampleLabelForTitle: string;
583
599
  if (yAxisFormatForTitle) {
584
- try {
585
- const fmt = d3Format(yAxisFormatForTitle);
586
- sampleLabelForTitle = fmt(maxAbsValForTitle);
587
- } catch {
588
- sampleLabelForTitle = String(maxAbsValForTitle);
589
- }
600
+ const fmt = resolveNumberFormatter(yAxisFormatForTitle, ctxForTitle);
601
+ sampleLabelForTitle = fmt ? fmt(maxAbsValForTitle) : String(maxAbsValForTitle);
590
602
  } else {
591
- if (maxAbsValForTitle >= 1_000_000_000) sampleLabelForTitle = '1.5B';
592
- else if (maxAbsValForTitle >= 1_000_000) sampleLabelForTitle = '1.5M';
593
- else if (maxAbsValForTitle >= 1_000) sampleLabelForTitle = '1.5K';
594
- else if (maxAbsValForTitle >= 100) sampleLabelForTitle = '100';
595
- else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
596
- else sampleLabelForTitle = '0.0';
603
+ sampleLabelForTitle = defaultNumberFormatter({
604
+ ...ctxForTitle,
605
+ extent: ctxForTitle.extent ?? [0, maxAbsValForTitle],
606
+ })(maxAbsValForTitle);
597
607
  }
598
608
  const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
599
609
  estTickLabelWidth = estimateTextWidth(
@@ -630,6 +640,13 @@ export function computeDimensions(
630
640
  margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
631
641
  }
632
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
+
633
650
  // Reserve legend space.
634
651
  //
635
652
  // Bottom legend: reservation is already baked into `chrome.bottomHeight`
@@ -640,42 +657,57 @@ export function computeDimensions(
640
657
  // When a plan is present, derive legend position/size from plan.legendContent;
641
658
  // otherwise use the legendLayout parameter (back-compat for non-plan callers).
642
659
  const legendHasEntries = plan
643
- ? plan.legendContent.entries.length > 0
644
- : 'entries' in legendLayout && legendLayout.entries.length > 0;
645
- const legendPos = plan
646
- ? plan.legendContent.position
647
- : 'entries' in legendLayout
648
- ? legendLayout.position
649
- : 'top';
650
- const legendHeight = plan
651
- ? plan.legendContent.height
652
- : 'entries' in legendLayout
653
- ? legendLayout.bounds.height
654
- : 0;
655
- const legendBoundsWidth = plan
656
- ? plan.legendContent.legendWidth
657
- : 'entries' in legendLayout
658
- ? legendLayout.bounds.width
659
- : 0;
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');
660
669
 
661
670
  const hasTopLegend = legendHasEntries && legendPos === 'top';
662
671
  if (legendHasEntries) {
663
672
  const gap = legendGap(width);
664
- if (legendPos === 'right' || legendPos === 'bottom-right') {
665
- margins.right += legendBoundsWidth + 8;
666
- } else if (legendPos === 'top') {
667
- margins.top += legendHeight + gap;
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;
668
677
  }
669
- // 'bottom' is intentionally not handled here -- see bottomLegendReservation
670
- // above.
678
+ // 'right'/'bottom-right' fall through to the shared right-column reservation
679
+ // below; 'bottom' is intentionally not handled here -- see
680
+ // bottomLegendReservation above.
671
681
  }
672
682
 
673
- // topAxisGap sits between the legend (or chrome, if no legend) and the
674
- // chart area. When a top legend is present, the legendGap already provides
675
- // breathing room, so only the inlineTickOverhang is needed (the axisMargin
676
- // component would double up with legendGap). Without a top legend, the
677
- // full topAxisGap (axisMargin + inlineTickOverhang) applies.
678
- margins.top += hasTopLegend ? inlineTickOverhang : topAxisGap;
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;
679
711
 
680
712
  // Chart area is what's left after margins
681
713
  let chartArea: Rect = {
@@ -712,14 +744,17 @@ export function computeDimensions(
712
744
  const fallbackTopAxisGap =
713
745
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
714
746
  const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
715
- const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
747
+ const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation;
716
748
  const topDelta = margins.top - newTop;
717
749
  const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
718
750
  const bottomDelta = margins.bottom - newBottom;
719
751
 
720
752
  if (topDelta > 0 || bottomDelta > 0) {
721
753
  const gap = legendGap(width);
722
- margins.top = newTop + (hasTopLegend ? legendHeight + gap : 0) + fallbackEffectiveAxisGap;
754
+ margins.top =
755
+ newTop +
756
+ (hasTopLegend ? TOP_LEGEND_GAP_ABOVE + legendHeight + gap : 0) +
757
+ fallbackEffectiveAxisGap;
723
758
  margins.bottom = newBottom;
724
759
 
725
760
  chartArea = {
@@ -764,7 +799,15 @@ export function computeDimensions(
764
799
  margins,
765
800
  theme,
766
801
  metrics: fallbackMetrics,
802
+ seriesSearch: wantsSearch
803
+ ? resolveSeriesSearch(
804
+ spec,
805
+ fallbackMetricsTopY + (fallbackMetrics?.height ?? 0),
806
+ fallbackMetricsArea,
807
+ )
808
+ : undefined,
767
809
  xAxisHeight,
810
+ effectiveAxisGap: fallbackEffectiveAxisGap,
768
811
  };
769
812
  }
770
813
  }
@@ -773,8 +816,9 @@ export function computeDimensions(
773
816
  // topPad
774
817
  // chrome.topHeight (title / subtitle / eyebrow)
775
818
  // tentativeMetricsHeight (KPI bar — placed here)
776
- // [optional top legend band]
777
- // topAxisGap (axisMargin + inlineTickOverhang)
819
+ // [optional top legend band (TOP_LEGEND_GAP_ABOVE + legend + legendGap)]
820
+ // effectiveAxisGap (inlineTickOverhang with a top legend,
821
+ // else axisMargin + inlineTickOverhang)
778
822
  // chartArea
779
823
  // The metric bar belongs with chrome, above the legend, so its y is
780
824
  // computed off chrome.topHeight only — not the full legend-inclusive
@@ -800,7 +844,46 @@ export function computeDimensions(
800
844
  margins,
801
845
  theme,
802
846
  metrics: resolvedMetrics,
847
+ seriesSearch: wantsSearch
848
+ ? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea)
849
+ : undefined,
803
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,
804
887
  };
805
888
  }
806
889
 
@@ -1,8 +1,10 @@
1
1
  import type { Rect } from '@opendata-ai/openchart-core';
2
2
 
3
3
  const MIN_PANEL_WIDTH = 200;
4
+ export const MIN_PANEL_HEIGHT = 100;
4
5
  const PANEL_GAP = 12;
5
6
  const HEADER_HEIGHT = 20;
7
+ const X_AXIS_HEIGHT = 24;
6
8
 
7
9
  export interface FacetGridPanel {
8
10
  key: string;
@@ -24,6 +26,11 @@ export function autoColumns(panelCount: number, availableWidth: number): number
24
26
  return Math.min(sqrtCols, fitCols, panelCount);
25
27
  }
26
28
 
29
+ export function facetMinHeight(panelCount: number, columns: number): number {
30
+ const rows = Math.ceil(panelCount / columns);
31
+ return rows * (MIN_PANEL_HEIGHT + HEADER_HEIGHT + X_AXIS_HEIGHT) + (rows - 1) * PANEL_GAP;
32
+ }
33
+
27
34
  export function computeFacetGrid(
28
35
  facetValues: string[],
29
36
  requestedColumns: number | undefined,
@@ -45,19 +52,23 @@ export function computeFacetGrid(
45
52
 
46
53
  const rows = Math.ceil(n / columns);
47
54
  const panelWidth = Math.max(0, (usableWidth - (columns - 1) * PANEL_GAP) / columns);
48
- const usableHeight = totalArea.height - outerAxisReservation.bottom;
55
+ // Every row includes its own x-axis labels, so the bottom reservation
56
+ // applies per-row (as overhead), not just at the grid bottom.
57
+ const xAxisPerRow = outerAxisReservation.bottom;
58
+ const usableHeight = totalArea.height;
49
59
  const panelHeight = Math.max(
50
60
  0,
51
- (usableHeight - rows * HEADER_HEIGHT - (rows - 1) * PANEL_GAP) / rows,
61
+ (usableHeight - rows * (HEADER_HEIGHT + xAxisPerRow) - (rows - 1) * PANEL_GAP) / rows,
52
62
  );
53
63
 
64
+ const rowStride = HEADER_HEIGHT + panelHeight + xAxisPerRow + PANEL_GAP;
54
65
  const panels: FacetGridPanel[] = [];
55
66
 
56
67
  for (let i = 0; i < n; i++) {
57
68
  const col = i % columns;
58
69
  const row = Math.floor(i / columns);
59
70
  const x = totalArea.x + outerAxisReservation.left + col * (panelWidth + PANEL_GAP);
60
- const y = totalArea.y + row * (HEADER_HEIGHT + panelHeight + PANEL_GAP);
71
+ const y = totalArea.y + row * rowStride;
61
72
 
62
73
  panels.push({
63
74
  key: facetValues[i],