@opendata-ai/openchart-engine 6.28.6 → 7.0.2

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 (46) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +8 -11
  3. package/dist/index.js +12307 -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 +498 -0
  28. package/src/compile.ts +221 -586
  29. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  30. package/src/compiler/normalize.ts +12 -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 +282 -34
  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
@@ -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,36 @@ 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
+ const hasDots = dotRight || dotLeft;
157
+
158
+ return {
159
+ left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
160
+ right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
161
+ top: hasDots ? Math.max(strokePad, dotPad) : strokePad,
162
+ bottom: hasDots ? Math.max(strokePad, dotPad) : strokePad,
163
+ };
120
164
  }
121
165
 
122
166
  // ---------------------------------------------------------------------------
@@ -162,7 +206,35 @@ export function computeDimensions(
162
206
  chromeMode = 'hidden';
163
207
  }
164
208
 
165
- // Compute chrome with mode and scaled padding
209
+ // Pre-compute the bottom-legend reservation (legend height + gap) so the
210
+ // chrome layout can stack source/byline/footer below the legend band.
211
+ // Chart-side bottom legends only — right/top/bottom-right legends don't
212
+ // share vertical space with bottom chrome.
213
+ //
214
+ // We narrow on `'entries' in` (a structural brand) rather than the
215
+ // `legendLayout.type` discriminator because `type` is optional on
216
+ // CategoricalLegendLayout for back-compat with older legend payloads.
217
+ // Once the discriminator is required everywhere, this can collapse to
218
+ // `legendLayout.type !== 'gradient'`.
219
+ const bottomLegendReservation =
220
+ 'entries' in legendLayout &&
221
+ legendLayout.entries.length > 0 &&
222
+ legendLayout.position === 'bottom'
223
+ ? legendLayout.bounds.height + legendGap(width)
224
+ : 0;
225
+
226
+ // Compute chrome with mode and scaled padding. `bottomLegendReservation`
227
+ // pushes bottom chrome below the legend band; the returned bottomHeight
228
+ // already accounts for it, so margin math below must not re-add it.
229
+ //
230
+ // Invariant: bottom-legend space is owned by `chrome.bottomHeight`, not
231
+ // `margins.bottom`. The legend reservation flows like this:
232
+ // bottomLegendReservation = legend.height + legendGap(width)
233
+ // chrome.bottomHeight ⊇ bottomLegendReservation (via computeChrome)
234
+ // margins.bottom = padding + chrome.bottomHeight + xAxisHeight
235
+ // So the legend band is implicitly inside margins.bottom exactly once.
236
+ // The legend-reservation block further down explicitly skips position
237
+ // 'bottom' to avoid double-counting.
166
238
  const chrome = computeChrome(
167
239
  chromeToInput(spec.chrome),
168
240
  theme,
@@ -171,6 +243,7 @@ export function computeDimensions(
171
243
  chromeMode,
172
244
  padding,
173
245
  watermark,
246
+ bottomLegendReservation,
174
247
  );
175
248
 
176
249
  // Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
@@ -186,10 +259,10 @@ export function computeDimensions(
186
259
  const yAxisSpace = userExplicit.yAxis ? 30 : 0;
187
260
 
188
261
  const margins: Margins = {
189
- top: chrome.topHeight + sparkPad,
190
- right: sparkPad,
191
- bottom: chrome.bottomHeight + sparkPad + xAxisSpace,
192
- left: sparkPad + yAxisSpace,
262
+ top: chrome.topHeight + sparkPad.top,
263
+ right: sparkPad.right,
264
+ bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace,
265
+ left: sparkPad.left + yAxisSpace,
193
266
  };
194
267
 
195
268
  // Reserve legend space only when user explicitly opted into a legend.
@@ -211,7 +284,7 @@ export function computeDimensions(
211
284
  height: Math.max(0, height - margins.top - margins.bottom),
212
285
  };
213
286
 
214
- return { total, chrome, chartArea, margins, theme };
287
+ return { total, chrome, chartArea, margins, theme, xAxisHeight: xAxisSpace };
215
288
  }
216
289
 
217
290
  // Start with the total rect
@@ -256,31 +329,92 @@ export function computeDimensions(
256
329
  xAxisHeight = hasXAxisLabel ? 48 : 26;
257
330
  }
258
331
 
332
+ // Resolve effective y-axis tickPosition early so margin math can account
333
+ // for the inline-tick overhang. Inline y-tick labels render above their
334
+ // gridline inside the chart area; the topmost tick text extends roughly
335
+ // (tickFontSize + INLINE_TICK_OVERHANG_PAD) pixels above area.y, which
336
+ // would otherwise crowd the chrome→chart gap.
337
+ const yAxisCfgPre = (encoding.y?.axis as Record<string, unknown> | undefined) ?? undefined;
338
+ const yTickPositionExplicitPre = yAxisCfgPre?.tickPosition as 'inline' | 'gutter' | undefined;
339
+ const yIsContinuousPre = encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal';
340
+ const yIsLineOrAreaPre = spec.markType === 'line' || spec.markType === 'area';
341
+ const yAxisOrientPre = yAxisCfgPre?.orient as 'left' | 'right' | 'top' | 'bottom' | undefined;
342
+ const yIsInlinePre =
343
+ (yTickPositionExplicitPre ??
344
+ (yIsLineOrAreaPre && yIsContinuousPre && yAxisOrientPre !== 'right'
345
+ ? 'inline'
346
+ : 'gutter')) === 'inline';
347
+ const inlineTickOverhang = yIsInlinePre
348
+ ? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
349
+ : 0;
350
+
259
351
  // Build margins: padding + chrome + axis space.
260
352
  // For radial charts (arc/donut), axes don't exist, so axisMargin is only
261
353
  // added when there's actual chrome content that needs separation from the
262
354
  // chart area. When chrome is empty the margin is just padding.
263
- const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
355
+ const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
264
356
  // Extra top padding on narrow viewports prevents iOS Safari from clipping
265
357
  // the title chrome behind the browser UI.
266
358
  const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
359
+ // Tentative metric-bar reservation. The bar's final inclusion is decided
360
+ // below by computeMetricBar, which can strip it on overflow / narrow areas.
361
+ // We reserve optimistically so the chart-area math is correct when the bar
362
+ // is kept; the rollback path subtracts it back when stripped.
363
+ const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
364
+ const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
365
+ // topAxisGap sits between the legend (or chrome, if no legend) and the
366
+ // chart area. It accounts for the general axis margin plus any inline
367
+ // tick-label overhang. Placing it after the legend (below) keeps the
368
+ // subtitle-to-legend gap tight while reserving physical space for ticks
369
+ // that protrude above the chart area.
267
370
  const margins: Margins = {
268
- top: topPad + chrome.topHeight + topAxisGap,
371
+ top: topPad + chrome.topHeight + tentativeMetricsHeight,
269
372
  right: hPad + (isRadial ? hPad : axisMargin),
270
373
  bottom: padding + chrome.bottomHeight + xAxisHeight,
271
374
  left: hPad + (isRadial ? hPad : axisMargin),
272
375
  };
273
376
 
274
- // Dynamic right margin for line/area end-of-line labels.
275
- // Only reserve space when labels will actually render.
377
+ // Right-margin reservation for the three-way label suppression truth table:
378
+ //
379
+ // 1. Endpoint-labels column (predicted width, default ON for ≥2-series
380
+ // line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width.
381
+ // 2. Legacy end-of-line labels — only when the truth table resolves to
382
+ // `showEndOfLineLabels: true` (legend hidden AND endpoint column off).
383
+ // 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2)
384
+ // so a callout at maxX lands between the chart area and any column to
385
+ // its right.
276
386
  const labelDensity = spec.labels.density;
277
387
  const labelsHiddenByStrategy = strategy?.labelMode === 'none';
388
+ const seriesCount = countColorSeries(spec);
389
+ const sup = resolveSuppression(spec, {
390
+ seriesCount,
391
+ labelsHiddenByStrategy,
392
+ labelsDensityNone: labelDensity === 'none',
393
+ });
394
+
395
+ // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0
396
+ // when the column would be suppressed. `labels.density` is intentionally
397
+ // not checked here — that switch controls only the legacy end-of-line labels.
398
+ let endpointWidth = 0;
399
+ if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
400
+ endpointWidth = predictEndpointLabelsWidth(spec, theme);
401
+ if (endpointWidth > 0) {
402
+ // 16px gap between chart area edge and the column.
403
+ margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
404
+ }
405
+ }
406
+
407
+ // (2) Legacy end-of-line label reservation — fires only in the truth-table
408
+ // cell where end-of-line labels still render (legend hidden AND endpoint
409
+ // column off AND ≥2 series AND labels visible). When the endpoint column
410
+ // is on, this reservation is redundant and is skipped.
278
411
  if (
412
+ endpointWidth === 0 &&
413
+ sup.showEndOfLineLabels &&
279
414
  (spec.markType === 'line' || spec.markType === 'area') &&
280
415
  labelDensity !== 'none' &&
281
416
  !labelsHiddenByStrategy
282
417
  ) {
283
- // Estimate label width from longest series name (color encoding domain)
284
418
  const colorEnc = encoding.color;
285
419
  const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
286
420
  if (colorField) {
@@ -300,10 +434,10 @@ export function computeDimensions(
300
434
  }
301
435
  }
302
436
 
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).
437
+ // (3) Right-edge text annotations. Stacks ADDITIVELY on top of any
438
+ // endpoint-labels reservation so the annotation text lands between the
439
+ // chart area's right edge and the endpoint column. When no endpoint column
440
+ // is reserved, behaves as before (max-of with the existing margin).
307
441
  if (
308
442
  strategy?.annotationPosition !== 'tooltip-only' &&
309
443
  spec.annotations.length > 0 &&
@@ -336,16 +470,25 @@ export function computeDimensions(
336
470
  textWidth / 2; // centered (top/bottom/auto)
337
471
  const rightOverflow = Math.max(0, baseRightExtent + dx);
338
472
  if (rightOverflow > 0) {
339
- margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
473
+ if (endpointWidth > 0) {
474
+ // Endpoint column already reserved space at the far right; the
475
+ // annotation lands BETWEEN the chart edge and the column, so
476
+ // stack additively rather than max-of.
477
+ margins.right += rightOverflow + 12;
478
+ } else {
479
+ margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
480
+ }
340
481
  }
341
482
  }
342
483
  }
343
484
  }
344
485
  }
345
486
 
346
- // Dynamic left margin for y-axis labels
487
+ // Dynamic left margin for y-axis labels (yIsInline already resolved above
488
+ // for inline-tick top-margin reservation).
347
489
  const yAxisSuppressed = encoding.y?.axis === false;
348
- if (encoding.y && !isRadial && !yAxisSuppressed) {
490
+ const yIsInline = yIsInlinePre;
491
+ if (encoding.y && !isRadial && !yAxisSuppressed && !yIsInline) {
349
492
  if (
350
493
  spec.markType === 'bar' ||
351
494
  spec.markType === 'circle' ||
@@ -454,18 +597,28 @@ export function computeDimensions(
454
597
  margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
455
598
  }
456
599
 
457
- // Reserve legend space
600
+ // Reserve legend space.
601
+ //
602
+ // Bottom legend: reservation is already baked into `chrome.bottomHeight`
603
+ // via `bottomLegendReservation`, so no additional bottom margin is needed
604
+ // here. The legend lands below the x-axis tick row (which is reserved via
605
+ // `xAxisHeight` in the base bottom margin) and source/byline/footer chrome
606
+ // stacks underneath the legend band rather than colliding with it.
458
607
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
459
608
  const gap = legendGap(width);
460
609
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
461
610
  margins.right += legendLayout.bounds.width + 8;
462
611
  } else if (legendLayout.position === 'top') {
463
612
  margins.top += legendLayout.bounds.height + gap;
464
- } else if (legendLayout.position === 'bottom') {
465
- margins.bottom += legendLayout.bounds.height + gap;
466
613
  }
614
+ // 'bottom' is intentionally not handled here — see bottomLegendReservation
615
+ // above.
467
616
  }
468
617
 
618
+ // Add topAxisGap after legend so it sits between the legend (or chrome
619
+ // when there's no legend) and the chart area.
620
+ margins.top += topAxisGap;
621
+
469
622
  // Chart area is what's left after margins
470
623
  let chartArea: Rect = {
471
624
  x: margins.left,
@@ -490,12 +643,17 @@ export function computeDimensions(
490
643
  fallbackMode as 'compact' | 'hidden',
491
644
  padding,
492
645
  watermark,
646
+ bottomLegendReservation,
493
647
  );
494
648
 
495
649
  // Recalculate top/bottom margins with stripped chrome.
496
650
  // 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;
651
+ // Include the tentative metric reservation so the rollback below mirrors
652
+ // the primary path's invariant (margins.top includes tentativeMetricsHeight
653
+ // until resolveMetrics decides otherwise).
654
+ const fallbackTopAxisGap =
655
+ isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
656
+ const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
499
657
  const topDelta = margins.top - newTop;
500
658
  const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
501
659
  const bottomDelta = margins.bottom - newBottom;
@@ -508,7 +666,8 @@ export function computeDimensions(
508
666
  legendLayout.entries.length > 0 &&
509
667
  legendLayout.position === 'top'
510
668
  ? legendLayout.bounds.height + gap
511
- : 0);
669
+ : 0) +
670
+ fallbackTopAxisGap;
512
671
  margins.bottom = newBottom;
513
672
 
514
673
  chartArea = {
@@ -518,9 +677,98 @@ export function computeDimensions(
518
677
  height: Math.max(0, height - margins.top - margins.bottom),
519
678
  };
520
679
 
521
- return { total, chrome: fallbackChrome, chartArea, margins, theme };
680
+ // Same chrome-anchored positioning as the primary path; see comment
681
+ // near the primary `metricsTopY` for the full stacking order.
682
+ const fallbackMetricsTopY = topPad + fallbackChrome.topHeight;
683
+ const fallbackMetricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
684
+ const fallbackMetrics = wantsMetrics
685
+ ? resolveMetrics(
686
+ spec,
687
+ fallbackMetricsTopY,
688
+ fallbackMetricsArea,
689
+ chartArea.height,
690
+ options.measureText,
691
+ )
692
+ : undefined;
693
+ if (wantsMetrics && !fallbackMetrics) {
694
+ // Bar was tentatively reserved but didn't fit — roll back the top margin.
695
+ // Clamp at 0 as a defensive guard: even though the reservation was
696
+ // additive (margins.top = topPad + chrome + tentative + axisGap [+ legend])
697
+ // and so subtraction is mathematically safe, a negative top margin would
698
+ // shift the chart above the SVG viewport if any future change ever
699
+ // reordered the additions.
700
+ margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
701
+ chartArea = {
702
+ ...chartArea,
703
+ y: margins.top,
704
+ height: Math.max(0, height - margins.top - margins.bottom),
705
+ };
706
+ }
707
+ return {
708
+ total,
709
+ chrome: fallbackChrome,
710
+ chartArea,
711
+ margins,
712
+ theme,
713
+ metrics: fallbackMetrics,
714
+ xAxisHeight,
715
+ };
522
716
  }
523
717
  }
524
718
 
525
- return { total, chrome, chartArea, margins, theme };
719
+ // Vertical stacking order from the SVG top edge:
720
+ // topPad
721
+ // chrome.topHeight (title / subtitle / eyebrow)
722
+ // tentativeMetricsHeight (KPI bar — placed here)
723
+ // [optional top legend band]
724
+ // topAxisGap (axisMargin + inlineTickOverhang)
725
+ // chartArea
726
+ // The metric bar belongs with chrome, above the legend, so its y is
727
+ // computed off chrome.topHeight only — not the full legend-inclusive
728
+ // margins.top.
729
+ const metricsTopY = topPad + chrome.topHeight;
730
+ const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
731
+ const resolvedMetrics = wantsMetrics
732
+ ? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText)
733
+ : undefined;
734
+ if (wantsMetrics && !resolvedMetrics) {
735
+ // See fallback path above for the clamp rationale.
736
+ margins.top = Math.max(0, margins.top - tentativeMetricsHeight);
737
+ chartArea = {
738
+ ...chartArea,
739
+ y: margins.top,
740
+ height: Math.max(0, height - margins.top - margins.bottom),
741
+ };
742
+ }
743
+ return {
744
+ total,
745
+ chrome,
746
+ chartArea,
747
+ margins,
748
+ theme,
749
+ metrics: resolvedMetrics,
750
+ xAxisHeight,
751
+ };
752
+ }
753
+
754
+ /**
755
+ * Resolve the metric bar layout. The bar spans the full chrome content width
756
+ * (from hPad to width - hPad), aligning with the title/eyebrow rather than
757
+ * indenting to the chart area's left gutter. Its `y` sits directly below
758
+ * chrome and above any top legend.
759
+ */
760
+ function resolveMetrics(
761
+ spec: NormalizedChartSpec,
762
+ metricsTopY: number,
763
+ metricsArea: { x: number; width: number },
764
+ remainingChartHeight: number,
765
+ measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
766
+ ): ResolvedMetricBar | undefined {
767
+ return computeMetricBar(
768
+ spec.metrics,
769
+ metricsTopY,
770
+ metricsArea,
771
+ remainingChartHeight,
772
+ measureText,
773
+ );
526
774
  }
@@ -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 } };