@opendata-ai/openchart-engine 7.11.0 → 8.0.0-rc.10

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -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__/facet.test.ts +144 -0
  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 +69 -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 +228 -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 +318 -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 +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  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__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. 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,12 +41,14 @@ 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
53
  import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
48
54
  import { yTickPositionIsInline } from './axes';
@@ -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,6 +88,11 @@ 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
@@ -96,6 +113,11 @@ export interface LayoutDimensions {
96
113
  const MIN_CHART_WIDTH = 60;
97
114
  const MIN_CHART_HEIGHT = 40;
98
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
+
99
121
  /**
100
122
  * Per-display minimum chart dimensions. Sparkline mode allows much smaller
101
123
  * containers (down to ~30x20px) since the entire chart is just the mark.
@@ -191,12 +213,10 @@ export function computeDimensions(
191
213
  // When a layout plan is present, derive legend info from plan.legendContent;
192
214
  // otherwise fall back to the legendLayout parameter (back-compat).
193
215
  const bottomLegendReservation = plan
194
- ? plan.legendContent.entries.length > 0 && plan.legendContent.position === 'bottom'
216
+ ? hasLegendContent(plan.legendContent) && plan.legendContent.position === 'bottom'
195
217
  ? plan.legendContent.height + legendGap(width)
196
218
  : 0
197
- : 'entries' in legendLayout &&
198
- legendLayout.entries.length > 0 &&
199
- legendLayout.position === 'bottom'
219
+ : legendLayoutHasContent(legendLayout) && legendLayout.position === 'bottom'
200
220
  ? legendLayout.bounds.height + legendGap(width)
201
221
  : 0;
202
222
 
@@ -244,7 +264,7 @@ export function computeDimensions(
244
264
  };
245
265
 
246
266
  // Reserve legend space only when user explicitly opted into a legend.
247
- if (userExplicit.legend && 'entries' in legendLayout && legendLayout.entries.length > 0) {
267
+ if (userExplicit.legend && legendLayoutHasContent(legendLayout)) {
248
268
  const gap = legendGap(width);
249
269
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
250
270
  margins.right += legendLayout.bounds.width + 8;
@@ -276,8 +296,8 @@ export function computeDimensions(
276
296
  // Start with the total rect
277
297
  const total: Rect = { x: 0, y: 0, width, height };
278
298
 
279
- // Radial charts (arc) don't have axes, so skip axis space
280
- 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);
281
301
  const encoding = spec.encoding as Encoding;
282
302
 
283
303
  // Estimate x-axis height below chart area: tick labels sit 14px below,
@@ -344,13 +364,17 @@ export function computeDimensions(
344
364
  // is kept; the rollback path subtracts it back when stripped.
345
365
  const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
346
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;
347
371
  // topAxisGap sits between the legend (or chrome, if no legend) and the
348
372
  // chart area. It accounts for the general axis margin plus any inline
349
373
  // tick-label overhang. Placing it after the legend (below) keeps the
350
374
  // subtitle-to-legend gap tight while reserving physical space for ticks
351
375
  // that protrude above the chart area.
352
376
  const margins: Margins = {
353
- top: topPad + chrome.topHeight + tentativeMetricsHeight,
377
+ top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation,
354
378
  right: hPad + (isRadial ? hPad : axisMargin),
355
379
  bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
356
380
  left: hPad + (isRadial ? hPad : axisMargin),
@@ -378,8 +402,10 @@ export function computeDimensions(
378
402
  // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
379
403
  // when the column would be suppressed. `labels.density` is intentionally
380
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).
381
407
  let endpointWidth = 0;
382
- if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
408
+ if (sup.showEndpointLabels) {
383
409
  endpointWidth = predictEndpointLabelsWidth(spec, theme);
384
410
  if (endpointWidth > 0) {
385
411
  // 16px gap between chart area edge and the column.
@@ -421,17 +447,15 @@ export function computeDimensions(
421
447
  }
422
448
  }
423
449
 
424
- // (3) Right-edge text annotations. Stacks ADDITIVELY on top of any
425
- // endpoint-labels reservation so the annotation text lands between the
426
- // chart area's right edge and the endpoint column. When no endpoint column
427
- // 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.
428
453
  if (
429
454
  strategy?.annotationPosition !== 'tooltip-only' &&
430
455
  spec.annotations.length > 0 &&
431
456
  encoding.x
432
457
  ) {
433
458
  const xField = encoding.x.field;
434
- // Find the maximum x value in the data
435
459
  let maxX: string | number | undefined;
436
460
  for (const row of spec.data) {
437
461
  const v = row[xField];
@@ -443,28 +467,12 @@ export function computeDimensions(
443
467
  if (ann.type === 'text' && String(ann.x) === maxXStr) {
444
468
  const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
445
469
  const dx = ann.offset?.dx ?? 0;
446
- // How much text extends right of the anchor point depends on alignment:
447
- // - anchor "right" or "left": text is off to one side, full width extends
448
- // - anchor "top"/"bottom"/"auto"/undefined: text is centered, half extends right
449
470
  const anchor = ann.anchor ?? 'auto';
450
471
  const baseRightExtent =
451
- anchor === 'left'
452
- ? textWidth
453
- : // text is to the right of anchor
454
- anchor === 'right'
455
- ? 0
456
- : // text is to the left of anchor
457
- textWidth / 2; // centered (top/bottom/auto)
472
+ anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2;
458
473
  const rightOverflow = Math.max(0, baseRightExtent + dx);
459
474
  if (rightOverflow > 0) {
460
- if (endpointWidth > 0) {
461
- // Endpoint column already reserved space at the far right; the
462
- // annotation lands BETWEEN the chart edge and the column, so
463
- // stack additively rather than max-of.
464
- margins.right += rightOverflow + 12;
465
- } else {
466
- margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
467
- }
475
+ margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
468
476
  }
469
477
  }
470
478
  }
@@ -538,25 +546,16 @@ export function computeDimensions(
538
546
  if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
539
547
  }
540
548
 
549
+ const ctx = computeFieldFormatContext(spec.data.map((r) => r[yField]));
541
550
  let sampleLabel: string;
542
551
  if (yAxisFormat) {
543
- // Use the actual d3-format to produce a realistic label estimate
544
- try {
545
- const fmt = d3Format(yAxisFormat);
546
- sampleLabel = fmt(maxAbsVal);
547
- } catch {
548
- sampleLabel = String(maxAbsVal);
549
- }
552
+ const fmt = resolveNumberFormatter(yAxisFormat, ctx);
553
+ sampleLabel = fmt ? fmt(maxAbsVal) : String(maxAbsVal);
550
554
  } else {
551
- // Fallback: estimate from magnitude
552
- if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
553
- else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
554
- else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
555
- else if (maxAbsVal >= 100) sampleLabel = '100';
556
- else if (maxAbsVal >= 10) sampleLabel = '10';
557
- else sampleLabel = '0.0';
555
+ sampleLabel = defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(
556
+ maxAbsVal,
557
+ );
558
558
  }
559
- // Account for negative sign
560
559
  const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
561
560
  const labelEst = negPrefix + sampleLabel;
562
561
  const labelWidth = estimateTextWidth(
@@ -595,21 +594,16 @@ export function computeDimensions(
595
594
  const v = Number(row[yFieldForTitle]);
596
595
  if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
597
596
  }
597
+ const ctxForTitle = computeFieldFormatContext(spec.data.map((r) => r[yFieldForTitle!]));
598
598
  let sampleLabelForTitle: string;
599
599
  if (yAxisFormatForTitle) {
600
- try {
601
- const fmt = d3Format(yAxisFormatForTitle);
602
- sampleLabelForTitle = fmt(maxAbsValForTitle);
603
- } catch {
604
- sampleLabelForTitle = String(maxAbsValForTitle);
605
- }
600
+ const fmt = resolveNumberFormatter(yAxisFormatForTitle, ctxForTitle);
601
+ sampleLabelForTitle = fmt ? fmt(maxAbsValForTitle) : String(maxAbsValForTitle);
606
602
  } else {
607
- if (maxAbsValForTitle >= 1_000_000_000) sampleLabelForTitle = '1.5B';
608
- else if (maxAbsValForTitle >= 1_000_000) sampleLabelForTitle = '1.5M';
609
- else if (maxAbsValForTitle >= 1_000) sampleLabelForTitle = '1.5K';
610
- else if (maxAbsValForTitle >= 100) sampleLabelForTitle = '100';
611
- else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
612
- else sampleLabelForTitle = '0.0';
603
+ sampleLabelForTitle = defaultNumberFormatter({
604
+ ...ctxForTitle,
605
+ extent: ctxForTitle.extent ?? [0, maxAbsValForTitle],
606
+ })(maxAbsValForTitle);
613
607
  }
614
608
  const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
615
609
  estTickLabelWidth = estimateTextWidth(
@@ -646,6 +640,13 @@ export function computeDimensions(
646
640
  margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
647
641
  }
648
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
+
649
650
  // Reserve legend space.
650
651
  //
651
652
  // Bottom legend: reservation is already baked into `chrome.bottomHeight`
@@ -656,38 +657,48 @@ export function computeDimensions(
656
657
  // When a plan is present, derive legend position/size from plan.legendContent;
657
658
  // otherwise use the legendLayout parameter (back-compat for non-plan callers).
658
659
  const legendHasEntries = plan
659
- ? plan.legendContent.entries.length > 0
660
- : 'entries' in legendLayout && legendLayout.entries.length > 0;
661
- const legendPos = plan
662
- ? plan.legendContent.position
663
- : 'entries' in legendLayout
664
- ? legendLayout.position
665
- : 'top';
666
- const legendHeight = plan
667
- ? plan.legendContent.height
668
- : 'entries' in legendLayout
669
- ? legendLayout.bounds.height
670
- : 0;
671
- const legendBoundsWidth = plan
672
- ? plan.legendContent.legendWidth
673
- : 'entries' in legendLayout
674
- ? legendLayout.bounds.width
675
- : 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');
676
669
 
677
670
  const hasTopLegend = legendHasEntries && legendPos === 'top';
678
671
  if (legendHasEntries) {
679
672
  const gap = legendGap(width);
680
- if (legendPos === 'right' || legendPos === 'bottom-right') {
681
- margins.right += legendBoundsWidth + 8;
682
- } else if (legendPos === 'top') {
673
+ if (legendPos === 'top') {
683
674
  // TOP_LEGEND_GAP_ABOVE keeps the legend from sitting flush against the
684
675
  // subtitle/metric bar now that the axis gap sits BELOW the legend.
685
676
  margins.top += TOP_LEGEND_GAP_ABOVE + legendHeight + gap;
686
677
  }
687
- // 'bottom' is intentionally not handled here -- see bottomLegendReservation
688
- // 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.
689
681
  }
690
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
+
691
702
  // effectiveAxisGap sits between the legend (or chrome, if no legend) and
692
703
  // the chart area. When a top legend is present, the legendGap already
693
704
  // provides breathing room, so only the inlineTickOverhang is needed (the
@@ -733,7 +744,7 @@ export function computeDimensions(
733
744
  const fallbackTopAxisGap =
734
745
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
735
746
  const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap;
736
- const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
747
+ const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation;
737
748
  const topDelta = margins.top - newTop;
738
749
  const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
739
750
  const bottomDelta = margins.bottom - newBottom;
@@ -788,6 +799,13 @@ export function computeDimensions(
788
799
  margins,
789
800
  theme,
790
801
  metrics: fallbackMetrics,
802
+ seriesSearch: wantsSearch
803
+ ? resolveSeriesSearch(
804
+ spec,
805
+ fallbackMetricsTopY + (fallbackMetrics?.height ?? 0),
806
+ fallbackMetricsArea,
807
+ )
808
+ : undefined,
791
809
  xAxisHeight,
792
810
  effectiveAxisGap: fallbackEffectiveAxisGap,
793
811
  };
@@ -826,11 +844,49 @@ export function computeDimensions(
826
844
  margins,
827
845
  theme,
828
846
  metrics: resolvedMetrics,
847
+ seriesSearch: wantsSearch
848
+ ? resolveSeriesSearch(spec, metricsTopY + (resolvedMetrics?.height ?? 0), metricsArea)
849
+ : undefined,
829
850
  xAxisHeight,
830
851
  effectiveAxisGap,
831
852
  };
832
853
  }
833
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
+
834
890
  /**
835
891
  * Resolve the metric bar layout. The bar spans the full chrome content width
836
892
  * (from hPad to width - hPad), aligning with the title/eyebrow rather than
@@ -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],