@opendata-ai/openchart-engine 6.28.5 → 7.0.0

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 (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12297 -11338
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/__test-fixtures__/specs.ts +3 -0
  7. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +452 -377
  8. package/src/__tests__/axes.test.ts +75 -0
  9. package/src/__tests__/compile-chart.test.ts +304 -0
  10. package/src/__tests__/dimensions.test.ts +224 -0
  11. package/src/__tests__/legend.test.ts +44 -3
  12. package/src/annotations/__tests__/compute.test.ts +111 -0
  13. package/src/annotations/__tests__/resolve-text.test.ts +288 -0
  14. package/src/annotations/constants.ts +20 -0
  15. package/src/annotations/resolve-text.ts +161 -7
  16. package/src/charts/bar/compute.ts +24 -0
  17. package/src/charts/bar/labels.ts +1 -0
  18. package/src/charts/column/compute.ts +33 -1
  19. package/src/charts/column/labels.ts +1 -0
  20. package/src/charts/dot/labels.ts +1 -0
  21. package/src/charts/line/__tests__/compute.test.ts +153 -3
  22. package/src/charts/line/area.ts +111 -23
  23. package/src/charts/line/compute.ts +40 -10
  24. package/src/charts/line/index.ts +34 -7
  25. package/src/charts/line/labels.ts +29 -0
  26. package/src/charts/pie/labels.ts +1 -0
  27. package/src/compile/layer.ts +497 -0
  28. package/src/compile.ts +211 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +6 -1
  31. package/src/compiler/sparkline-defaults.ts +138 -0
  32. package/src/compiler/types.ts +8 -0
  33. package/src/endpoint-labels/__tests__/compute.test.ts +438 -0
  34. package/src/endpoint-labels/compute.ts +417 -0
  35. package/src/endpoint-labels/constants.ts +54 -0
  36. package/src/endpoint-labels/format.ts +30 -0
  37. package/src/endpoint-labels/predict.ts +108 -0
  38. package/src/graphs/compile-graph.ts +1 -0
  39. package/src/layout/axes.ts +27 -2
  40. package/src/layout/dimensions.ts +270 -33
  41. package/src/layout/metrics.ts +118 -0
  42. package/src/layout/scales.ts +41 -4
  43. package/src/legend/__tests__/suppression.test.ts +294 -0
  44. package/src/legend/compute.ts +50 -40
  45. package/src/legend/suppression.ts +204 -0
  46. package/src/sankey/compile-sankey.ts +2 -0
  47. package/src/tables/__tests__/heatmap.test.ts +4 -27
  48. package/src/tables/heatmap.ts +6 -2
@@ -19,6 +19,7 @@ import type {
19
19
  Margins,
20
20
  Rect,
21
21
  ResolvedChrome,
22
+ ResolvedMetricBar,
22
23
  ResolvedTheme,
23
24
  } from '@opendata-ai/openchart-core';
24
25
  import {
@@ -41,7 +42,10 @@ import {
41
42
  import { format as d3Format } from 'd3-format';
42
43
 
43
44
  import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
45
+ import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
46
+ import { countColorSeries, resolveSuppression } from '../legend/suppression';
44
47
  import { legendGap } from '../legend/wrap';
48
+ import { computeMetricBar, metricBarHeight } from './metrics';
45
49
 
46
50
  // ---------------------------------------------------------------------------
47
51
  // Types
@@ -59,6 +63,17 @@ export interface LayoutDimensions {
59
63
  margins: Margins;
60
64
  /** Resolved theme used for this layout. */
61
65
  theme: ResolvedTheme;
66
+ /**
67
+ * Resolved metric bar (KPI cells above the chart area). Present only when
68
+ * spec.metrics is supplied AND the bar fits the container.
69
+ */
70
+ metrics?: ResolvedMetricBar;
71
+ /**
72
+ * Height reserved below the chart area for x-axis tick labels and the
73
+ * (optional) axis title. Exposed so downstream layout code (e.g. the
74
+ * second legend pass) can position elements below the axis row.
75
+ */
76
+ xAxisHeight: number;
62
77
  }
63
78
 
64
79
  // ---------------------------------------------------------------------------
@@ -68,11 +83,13 @@ export interface LayoutDimensions {
68
83
  /** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
69
84
  function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart-core').Chrome {
70
85
  return {
86
+ eyebrow: chrome.eyebrow,
71
87
  title: chrome.title,
72
88
  subtitle: chrome.subtitle,
73
89
  source: chrome.source,
74
90
  byline: chrome.byline,
75
91
  footer: chrome.footer,
92
+ brand: chrome.brand,
76
93
  };
77
94
  }
78
95
 
@@ -93,6 +110,13 @@ function scalePadding(basePadding: number, width: number, height: number): numbe
93
110
  const MIN_CHART_WIDTH = 60;
94
111
  const MIN_CHART_HEIGHT = 40;
95
112
 
113
+ /**
114
+ * Vertical breathing room added to the inline-tick label height so the
115
+ * topmost tick has clearance from chrome. Inline ticks sit above their
116
+ * gridline by `axisTick + INLINE_TICK_OVERHANG_PAD` pixels.
117
+ */
118
+ const INLINE_TICK_OVERHANG_PAD = 6;
119
+
96
120
  /**
97
121
  * Per-display minimum chart dimensions. Sparkline mode allows much smaller
98
122
  * containers (down to ~30x20px) since the entire chart is just the mark.
@@ -107,16 +131,35 @@ function getMinChartDims(display: import('@opendata-ai/openchart-core').Display)
107
131
  }
108
132
 
109
133
  /**
110
- * Resolve the per-side safety padding for sparkline mode. Padding scales with
111
- * the user-configured mark stroke width so a thick line doesn't clip at the
112
- * container edge. Per-side padding = max(strokeWidth/2 + 1, 2) so even a 1px
113
- * stroke gets at least 2px breathing room.
134
+ * Resolve per-side safety padding for sparkline mode. Stroke-based padding
135
+ * applies to every side so a thick line doesn't clip at the container edge.
136
+ * Endpoint-dot padding applies only to the side that actually carries a dot:
137
+ * `point: 'last'` reserves space on the right, `'first'` on the left, and
138
+ * `true | 'endpoints' | 'transparent'` on both. This keeps tiny sparklines
139
+ * flush left when the endpoint dot only renders at the right edge.
114
140
  */
115
- function getSparklinePad(spec: NormalizedChartSpec): number {
141
+ function getSparklinePad(spec: NormalizedChartSpec): {
142
+ left: number;
143
+ right: number;
144
+ top: number;
145
+ bottom: number;
146
+ } {
116
147
  const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
117
- const hasPoints = !!(spec.markDef as { point?: unknown }).point;
118
- const pointRadius = hasPoints ? 3 : 0;
119
- return Math.max(strokeWidth / 2 + 1, pointRadius + 1, 2);
148
+ const point = (spec.markDef as { point?: unknown }).point;
149
+ const strokePad = Math.max(strokeWidth / 2 + 1, 2);
150
+ const dotPad = 4; // r=3.5 + 0.5 matches the terminator dot size
151
+
152
+ const dotRight =
153
+ point === 'last' || point === true || point === 'endpoints' || point === 'transparent';
154
+ const dotLeft =
155
+ point === 'first' || point === true || point === 'endpoints' || point === 'transparent';
156
+
157
+ return {
158
+ left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
159
+ right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
160
+ top: strokePad,
161
+ bottom: strokePad,
162
+ };
120
163
  }
121
164
 
122
165
  // ---------------------------------------------------------------------------
@@ -162,7 +205,35 @@ export function computeDimensions(
162
205
  chromeMode = 'hidden';
163
206
  }
164
207
 
165
- // Compute chrome with mode and scaled padding
208
+ // Pre-compute the bottom-legend reservation (legend height + gap) so the
209
+ // chrome layout can stack source/byline/footer below the legend band.
210
+ // Chart-side bottom legends only — right/top/bottom-right legends don't
211
+ // share vertical space with bottom chrome.
212
+ //
213
+ // We narrow on `'entries' in` (a structural brand) rather than the
214
+ // `legendLayout.type` discriminator because `type` is optional on
215
+ // CategoricalLegendLayout for back-compat with older legend payloads.
216
+ // Once the discriminator is required everywhere, this can collapse to
217
+ // `legendLayout.type !== 'gradient'`.
218
+ const bottomLegendReservation =
219
+ 'entries' in legendLayout &&
220
+ legendLayout.entries.length > 0 &&
221
+ legendLayout.position === 'bottom'
222
+ ? legendLayout.bounds.height + legendGap(width)
223
+ : 0;
224
+
225
+ // Compute chrome with mode and scaled padding. `bottomLegendReservation`
226
+ // pushes bottom chrome below the legend band; the returned bottomHeight
227
+ // already accounts for it, so margin math below must not re-add it.
228
+ //
229
+ // Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
230
+ // `margins.bottom`. The legend reservation flows like this:
231
+ // bottomLegendReservation = legend.height + legendGap(width)
232
+ // chrome.bottomHeight ⊇ bottomLegendReservation (via computeChrome)
233
+ // margins.bottom = padding + chrome.bottomHeight + xAxisHeight
234
+ // So the legend band is implicitly inside margins.bottom exactly once.
235
+ // The legend-reservation block further down explicitly skips position
236
+ // 'bottom' to avoid double-counting.
166
237
  const chrome = computeChrome(
167
238
  chromeToInput(spec.chrome),
168
239
  theme,
@@ -171,6 +242,7 @@ export function computeDimensions(
171
242
  chromeMode,
172
243
  padding,
173
244
  watermark,
245
+ bottomLegendReservation,
174
246
  );
175
247
 
176
248
  // Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
@@ -186,10 +258,10 @@ export function computeDimensions(
186
258
  const yAxisSpace = userExplicit.yAxis ? 30 : 0;
187
259
 
188
260
  const margins: Margins = {
189
- top: chrome.topHeight + sparkPad,
190
- right: sparkPad,
191
- bottom: chrome.bottomHeight + sparkPad + xAxisSpace,
192
- left: sparkPad + yAxisSpace,
261
+ top: chrome.topHeight + sparkPad.top,
262
+ right: sparkPad.right,
263
+ bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace,
264
+ left: sparkPad.left + yAxisSpace,
193
265
  };
194
266
 
195
267
  // Reserve legend space only when user explicitly opted into a legend.
@@ -211,7 +283,7 @@ export function computeDimensions(
211
283
  height: Math.max(0, height - margins.top - margins.bottom),
212
284
  };
213
285
 
214
- return { total, chrome, chartArea, margins, theme };
286
+ return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
215
287
  }
216
288
 
217
289
  // Start with the total rect
@@ -256,31 +328,87 @@ export function computeDimensions(
256
328
  xAxisHeight = hasXAxisLabel ? 48 : 26;
257
329
  }
258
330
 
331
+ // Resolve effective y-axis tickPosition early so margin math can account
332
+ // for the inline-tick overhang. Inline y-tick labels render above their
333
+ // gridline inside the chart area; the topmost tick text extends roughly
334
+ // (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
335
+ // would otherwise crowd the chrome→chart gap.
336
+ const yAxisCfgPre = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
337
+ const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition as 'inline' | 'gutter' | undefined;
338
+ const yIsContinuousPre = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
339
+ const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
340
+ const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
341
+ const yIsInlinePre =
342
+ (yTickPositionExplicitPre ??
343
+ (yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
344
+ ? 'inline'
345
+ : 'gutter')) === 'inline';
346
+ const inlineTickOverhang = yIsInlinePre
347
+ ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
348
+ : 0;
349
+
259
350
  // Build margins: padding + chrome + axis space.
260
351
  // For radial charts (arc/donut), axes don't exist, so axisMargin is only
261
352
  // added when there's actual chrome content that needs separation from the
262
353
  // chart area. When chrome is empty the margin is just padding.
263
- const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
354
+ const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
264
355
  // Extra top padding on narrow viewports prevents iOS Safari from clipping
265
356
  // the title chrome behind the browser UI.
266
357
  const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
358
+ // Tentative metric-bar reservation. The bar's final inclusion is decided
359
+ // below by computeMetricBar, which can strip it on overflow / narrow areas.
360
+ // We reserve optimistically so the chart-area math is correct when the bar
361
+ // is kept; the rollback path subtracts it back when stripped.
362
+ const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
363
+ const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
267
364
  const margins: Margins = {
268
- top: topPad + chrome.topHeight + topAxisGap,
365
+ top: topPad + chrome.topHeight + tentativeMetricsHeight + topAxisGap,
269
366
  right: hPad + (isRadial ? hPad : axisMargin),
270
367
  bottom: padding + chrome.bottomHeight + xAxisHeight,
271
368
  left: hPad + (isRadial ? hPad : axisMargin),
272
369
  };
273
370
 
274
- // Dynamic right margin for line/area end-of-line labels.
275
- // Only reserve space when labels will actually render.
371
+ // Right-margin reservation for the three-way label suppression truth table:
372
+ //
373
+ // 1. Endpoint-labels column (predicted width, default ON for ≥2-series
374
+ // line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width.
375
+ // 2. Legacy end-of-line labels — only when the truth table resolves to
376
+ // `showEndOfLineLabels: true` (legend hidden AND endpoint column off).
377
+ // 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2)
378
+ // so a callout at maxX lands between the chart area and any column to
379
+ // its right.
276
380
  const labelDensity = spec.labels.density;
277
381
  const labelsHiddenByStrategy = strategy?.labelMode === 'none';
382
+ const seriesCount = countColorSeries(spec);
383
+ const sup = resolveSuppression(spec, {
384
+ seriesCount,
385
+ labelsHiddenByStrategy,
386
+ labelsDensityNone: labelDensity === 'none',
387
+ });
388
+
389
+ // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
390
+ // when the column would be suppressed. `labels.density` is intentionally
391
+ // not checked here — that switch controls only the legacy end-of-line labels.
392
+ let endpointWidth = 0;
393
+ if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
394
+ endpointWidth = predictEndpointLabelsWidth(spec, theme);
395
+ if (endpointWidth > 0) {
396
+ // 16px gap between chart area edge and the column.
397
+ margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
398
+ }
399
+ }
400
+
401
+ // (2) Legacy end-of-line label reservation — fires only in the truth-table
402
+ // cell where end-of-line labels still render (legend hidden AND endpoint
403
+ // column off AND ≥2 series AND labels visible). When the endpoint column
404
+ // is on, this reservation is redundant and is skipped.
278
405
  if (
406
+ endpointWidth === 0 &&
407
+ sup.showEndOfLineLabels &&
279
408
  (spec.markType === 'line' || spec.markType === 'area') &&
280
409
  labelDensity !== 'none' &&
281
410
  !labelsHiddenByStrategy
282
411
  ) {
283
- // Estimate label width from longest series name (color encoding domain)
284
412
  const colorEnc = encoding.color;
285
413
  const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
286
414
  if (colorField) {
@@ -300,10 +428,10 @@ export function computeDimensions(
300
428
  }
301
429
  }
302
430
 
303
- // Reserve right margin for text annotations near the chart's right edge.
304
- // Without this, annotation text at the last data point clips outside the SVG.
305
- // Account for anchor direction and offset.dx to avoid over-reserving space.
306
- // Skip when annotations are hidden (tooltip-only at compact breakpoints).
431
+ // (3) Right-edge text annotations. Stacks ADDITIVELY on top of any
432
+ // endpoint-labels reservation so the annotation text lands between the
433
+ // chart area's right edge and the endpoint column. When no endpoint column
434
+ // is reserved, behaves as before (max-of with the existing margin).
307
435
  if (
308
436
  strategy?.annotationPosition !== 'tooltip-only' &&
309
437
  spec.annotations.length > 0 &&
@@ -336,16 +464,25 @@ export function computeDimensions(
336
464
  textWidth / 2; // centered (top/bottom/auto)
337
465
  const rightOverflow = Math.max(0, baseRightExtent + dx);
338
466
  if (rightOverflow > 0) {
339
- margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
467
+ if (endpointWidth > 0) {
468
+ // Endpoint column already reserved space at the far right; the
469
+ // annotation lands BETWEEN the chart edge and the column, so
470
+ // stack additively rather than max-of.
471
+ margins.right += rightOverflow + 12;
472
+ } else {
473
+ margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
474
+ }
340
475
  }
341
476
  }
342
477
  }
343
478
  }
344
479
  }
345
480
 
346
- // Dynamic left margin for y-axis labels
481
+ // Dynamic left margin for y-axis labels (yIsInline already resolved above
482
+ // for inline-tick top-margin reservation).
347
483
  const yAxisSuppressed = encoding.y?.axis === false;
348
- if (encoding.y && !isRadial && !yAxisSuppressed) {
484
+ const yIsInline = yIsInlinePre;
485
+ if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
349
486
  if (
350
487
  spec.markType === 'bar' ||
351
488
  spec.markType === 'circle' ||
@@ -454,16 +591,22 @@ export function computeDimensions(
454
591
  margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
455
592
  }
456
593
 
457
- // Reserve legend space
594
+ // Reserve legend space.
595
+ //
596
+ // Bottom legend: reservation is already baked into `chrome.bottomHeight`
597
+ // via `bottomLegendReservation`, so no additional bottom margin is needed
598
+ // here. The legend lands below the x-axis tick row (which is reserved via
599
+ // `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
600
+ // stacks underneath the legend band rather than colliding with it.
458
601
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
459
602
  const gap = legendGap(width);
460
603
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
461
604
  margins.right += legendLayout.bounds.width + 8;
462
605
  } else if (legendLayout.position === 'top') {
463
606
  margins.top += legendLayout.bounds.height + gap;
464
- } else if (legendLayout.position === 'bottom') {
465
- margins.bottom += legendLayout.bounds.height + gap;
466
607
  }
608
+ // 'bottom' is intentionally not handled here — see bottomLegendReservation
609
+ // above.
467
610
  }
468
611
 
469
612
  // Chart area is what's left after margins
@@ -490,12 +633,17 @@ export function computeDimensions(
490
633
  fallbackMode as 'compact' | 'hidden',
491
634
  padding,
492
635
  watermark,
636
+ bottomLegendReservation,
493
637
  );
494
638
 
495
639
  // Recalculate top/bottom margins with stripped chrome.
496
640
  // Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
497
- const fallbackTopAxisGap = isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin;
498
- const newTop = topPad + fallbackChrome.topHeight + fallbackTopAxisGap;
641
+ // Include the tentative metric reservation so the rollback below mirrors
642
+ // the primary path's invariant (margins.top includes tentativeMetricsHeight
643
+ // until resolveMetrics decides otherwise).
644
+ const fallbackTopAxisGap =
645
+ isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
646
+ const newTop = topPad + fallbackChrome.topHeight + fallbackTopAxisGap + tentativeMetricsHeight;
499
647
  const topDelta = margins.top - newTop;
500
648
  const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
501
649
  const bottomDelta = margins.bottom - newBottom;
@@ -518,9 +666,98 @@ export function computeDimensions(
518
666
  height: Math.max(0, height - margins.top - margins.bottom),
519
667
  };
520
668
 
521
- return { total, chrome: fallbackChrome, chartArea, margins, theme };
669
+ // Same chrome-anchored positioning as the primary path; see comment
670
+ // near the primary `metricsTopY` for the full stacking order.
671
+ const fallbackMetricsTopY = topPad + fallbackChrome.topHeight;
672
+ const fallbackMetricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
673
+ const fallbackMetrics = wantsMetrics
674
+ ? resolveMetrics(
675
+ spec,
676
+ fallbackMetricsTopY,
677
+ fallbackMetricsArea,
678
+ chartArea.height,
679
+ options.measureText,
680
+ )
681
+ : undefined;
682
+ if (wantsMetrics && !fallbackMetrics) {
683
+ // Bar was tentatively reserved but didn't fit — roll back the top margin.
684
+ // Clamp at 0 as a defensive guard: even though the reservation was
685
+ // additive (margins.top = topPad + chrome + tentative + axisGap [+ legend])
686
+ // and so subtraction is mathematically safe, a negative top margin would
687
+ // shift the chart above the SVG viewport if any future change ever
688
+ // reordered the additions.
689
+ margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
690
+ chartArea = {
691
+ ...chartArea,
692
+ y: margins.top,
693
+ height: Math.max(0, height - margins.top - margins.bottom),
694
+ };
695
+ }
696
+ return {
697
+ total,
698
+ chrome: fallbackChrome,
699
+ chartArea,
700
+ margins,
701
+ theme,
702
+ metrics: fallbackMetrics,
703
+ xAxisHeight,
704
+ };
522
705
  }
523
706
  }
524
707
 
525
- return { total, chrome, chartArea, margins, theme };
708
+ // Vertical stacking order from the SVG top edge:
709
+ // topPad
710
+ // chrome.topHeight (title / subtitle / eyebrow)
711
+ // tentativeMetricsHeight (KPI bar — placed here)
712
+ // topAxisGap (axisMargin + inlineTickOverhang)
713
+ // [optional top legend band]
714
+ // chartArea
715
+ // The metric bar belongs with chrome, above the legend, so its y is
716
+ // computed off chrome.topHeight only — not the full legend-inclusive
717
+ // margins.top.
718
+ const metricsTopY = topPad + chrome.topHeight;
719
+ const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
720
+ const resolvedMetrics = wantsMetrics
721
+ ? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText)
722
+ : undefined;
723
+ if (wantsMetrics && !resolvedMetrics) {
724
+ // See fallback path above for the clamp rationale.
725
+ margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
726
+ chartArea = {
727
+ ...chartArea,
728
+ y: margins.top,
729
+ height: Math.max(0, height - margins.top - margins.bottom),
730
+ };
731
+ }
732
+ return {
733
+ total,
734
+ chrome,
735
+ chartArea,
736
+ margins,
737
+ theme,
738
+ metrics: resolvedMetrics,
739
+ xAxisHeight,
740
+ };
741
+ }
742
+
743
+ /**
744
+ * Resolve the metric bar layout. The bar spans the full chrome content width
745
+ * (from hPad to width - hPad), aligning with the title/eyebrow rather than
746
+ * indenting to the chart area's left gutter. Its `y` sits directly below
747
+ * chrome and above any top legend.
748
+ */
749
+ function resolveMetrics(
750
+ spec: NormalizedChartSpec,
751
+ metricsTopY: number,
752
+ metricsArea: { x: number; width: number },
753
+ remainingChartHeight: number,
754
+ measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
755
+ ): ResolvedMetricBar | undefined {
756
+ return computeMetricBar(
757
+ spec.metrics,
758
+ metricsTopY,
759
+ metricsArea,
760
+ remainingChartHeight,
761
+ measureText,
762
+ );
526
763
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Metric bar layout: a row of KPI cells rendered above the chart area.
3
+ *
4
+ * Cells lay out evenly across the chart width. The bar is auto-stripped when
5
+ * the container is too narrow, when the chart area would be left too short,
6
+ * or when value text would overflow its allotted cell width. Sparkline mode
7
+ * never reserves space for metrics.
8
+ */
9
+ import type {
10
+ MeasureTextFn,
11
+ Metric,
12
+ ResolvedMetricBar,
13
+ ResolvedMetricCell,
14
+ } from '@opendata-ai/openchart-core';
15
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
16
+
17
+ // Visual constants. Sized to match the editorial KPI mock: a 10px uppercase
18
+ // label sits above a 22px primary value, with breathing room below before the
19
+ // chart area starts. Derived from the 4px grid.
20
+ const LABEL_FONT_SIZE = 10;
21
+ const VALUE_FONT_SIZE = 22;
22
+ const LABEL_LINE_HEIGHT_RATIO = 1.4; // 14px
23
+ const VALUE_LINE_HEIGHT_RATIO = 1.15; // ~25.3px
24
+ const INTER_ROW_GAP = 4;
25
+ // Breathing room above labels (separates metric row from the subtitle).
26
+ const TOP_GAP = 16;
27
+ // Breathing room below values (separates metric row from legend / chart top).
28
+ const BOTTOM_GAP = 20;
29
+
30
+ /** Minimum container width that can fit a metric bar. */
31
+ const MIN_BAR_WIDTH = 480;
32
+ /** Minimum chart-area height after metric reservation. */
33
+ const MIN_CHART_HEIGHT = 150;
34
+ /** Inner cell gutter so adjacent cells don't visually touch. */
35
+ const CELL_INNER_PAD = 8;
36
+
37
+ /**
38
+ * Total height the metric bar reserves above the chart area.
39
+ * Always derived from the constants above; never hardcoded at the call site.
40
+ */
41
+ export function metricBarHeight(): number {
42
+ const labelLine = LABEL_FONT_SIZE * LABEL_LINE_HEIGHT_RATIO;
43
+ const valueLine = VALUE_FONT_SIZE * VALUE_LINE_HEIGHT_RATIO;
44
+ return TOP_GAP + labelLine + INTER_ROW_GAP + valueLine + BOTTOM_GAP;
45
+ }
46
+
47
+ /**
48
+ * Concatenate the visible value text used for overflow detection. Joined with
49
+ * single spaces because the renderer separates the spans with `dx` attributes
50
+ * that consume roughly a space's worth of width.
51
+ */
52
+ function valueRunText(metric: Metric): string {
53
+ const parts = [metric.value];
54
+ if (metric.delta) parts.push(metric.delta);
55
+ if (metric.secondary) parts.push(metric.secondary);
56
+ return parts.join(' ');
57
+ }
58
+
59
+ /**
60
+ * Compute the metric bar layout. Returns `undefined` when the bar should be
61
+ * stripped (too narrow, chart too short, or any cell would overflow).
62
+ */
63
+ export function computeMetricBar(
64
+ metrics: Metric[] | undefined,
65
+ metricsTopY: number,
66
+ metricsArea: { x: number; width: number },
67
+ remainingChartHeight: number,
68
+ measureText?: MeasureTextFn,
69
+ ): ResolvedMetricBar | undefined {
70
+ if (!metrics || metrics.length === 0) return undefined;
71
+ if (metricsArea.width < MIN_BAR_WIDTH) return undefined;
72
+ if (remainingChartHeight < MIN_CHART_HEIGHT) return undefined;
73
+
74
+ const cellWidth = metricsArea.width / metrics.length;
75
+
76
+ // Bail if any cell's value run can't fit. Half-rendered metric rows look
77
+ // worse than no row at all on small viewports.
78
+ for (const metric of metrics) {
79
+ const text = valueRunText(metric);
80
+ const measured = measureText
81
+ ? measureText(text, VALUE_FONT_SIZE, 510).width
82
+ : estimateTextWidth(text, VALUE_FONT_SIZE, 510);
83
+ if (measured > cellWidth - CELL_INNER_PAD) return undefined;
84
+ }
85
+
86
+ const labelLine = LABEL_FONT_SIZE * LABEL_LINE_HEIGHT_RATIO;
87
+ const labelY = metricsTopY + TOP_GAP + LABEL_FONT_SIZE; // baseline for uppercase label
88
+ const valueY = metricsTopY + TOP_GAP + labelLine + INTER_ROW_GAP + VALUE_FONT_SIZE;
89
+
90
+ const cells: ResolvedMetricCell[] = metrics.map((metric, i) => ({
91
+ x: metricsArea.x + i * cellWidth,
92
+ cellWidth,
93
+ labelY,
94
+ valueY,
95
+ metric,
96
+ overflowed: false,
97
+ }));
98
+
99
+ return {
100
+ y: metricsTopY,
101
+ height: metricBarHeight(),
102
+ cells,
103
+ };
104
+ }
105
+
106
+ // Exposed for tests and consumers needing the same constants.
107
+ export const METRIC_BAR_INTERNALS = {
108
+ LABEL_FONT_SIZE,
109
+ VALUE_FONT_SIZE,
110
+ LABEL_LINE_HEIGHT_RATIO,
111
+ VALUE_LINE_HEIGHT_RATIO,
112
+ INTER_ROW_GAP,
113
+ TOP_GAP,
114
+ BOTTOM_GAP,
115
+ MIN_BAR_WIDTH,
116
+ MIN_CHART_HEIGHT,
117
+ CELL_INNER_PAD,
118
+ };
@@ -738,13 +738,50 @@ export function computeScales(
738
738
  spec.markType === 'bar' &&
739
739
  (encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
740
740
  encoding.y.type === 'quantitative';
741
- const yStackDisabled = encoding.y.stack === null || encoding.y.stack === false;
741
+ // Bar default is stacked, so undefined counts as stacked. Area default is
742
+ // overlap (v6), so the stacked-domain expansion only applies when the user
743
+ // explicitly opts into stacking.
744
+ const stackProp = encoding.y.stack;
745
+ const isExplicitlyStacked =
746
+ stackProp === true ||
747
+ stackProp === 'zero' ||
748
+ stackProp === 'normalize' ||
749
+ stackProp === 'center';
750
+ const isAreaStacked = spec.markType === 'area' && isExplicitlyStacked;
751
+ const isBarStacked = isVerticalBar && stackProp !== null && stackProp !== false;
752
+
753
+ // Sparkline tightening: drop the default `zero: true` baseline so the
754
+ // y-domain hugs the actual data range. Without this, a series with
755
+ // values in the 4000s renders as a near-flat line because most of the
756
+ // chart area gets reserved for the gap between zero and the data.
757
+ //
758
+ // Applies to:
759
+ // - Line / area sparklines (always — variation is the whole point)
760
+ // - Vertical bar sparklines, but ONLY when no real stacking is in
761
+ // play. Two ways to opt OUT of bar tightening:
762
+ // 1. Real stacking — color/group encoding plus a non-disabled
763
+ // stack — needs the zero baseline to keep segment arithmetic
764
+ // summing.
765
+ // 2. Any explicit `encoding.y.stack` value signals the user
766
+ // wants stack semantics even on a single series; respect that.
767
+ const hasStackingGroup = isBarStacked && encoding.color !== undefined;
768
+ const userRequestedStack = isExplicitlyStacked;
769
+ const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
770
+ const sparklineTightenBar =
771
+ isVerticalBar && !hasStackingGroup && !userRequestedStack && !isAreaStacked;
772
+ const sparklineTightenLineArea = isLineOrArea && !isAreaStacked;
742
773
  if (
743
- (isVerticalBar || spec.markType === 'area') &&
744
- encoding.color &&
774
+ spec.display === 'sparkline' &&
775
+ (sparklineTightenBar || sparklineTightenLineArea) &&
745
776
  encoding.y.type === 'quantitative' &&
746
- !yStackDisabled
777
+ encoding.y.scale?.zero === undefined
747
778
  ) {
779
+ yChannel = {
780
+ ...encoding.y,
781
+ scale: { ...encoding.y.scale, zero: false },
782
+ };
783
+ }
784
+ if ((isBarStacked || isAreaStacked) && encoding.color && encoding.y.type === 'quantitative') {
748
785
  if (encoding.y.stack === 'normalize') {
749
786
  // Normalize: domain is [0, 1] (VL convention)
750
787
  yChannel = { ...encoding.y, scale: { ...encoding.y.scale, domain: [0, 1], nice: false } };