@opendata-ai/openchart-engine 7.2.1 → 7.2.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.2.1",
3
+ "version": "7.2.3",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.2.1",
51
+ "@opendata-ai/openchart-core": "7.2.3",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -443,6 +443,8 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
443
443
  "sizes": {
444
444
  "axisTick": 11,
445
445
  "body": 13,
446
+ "metricLabel": 10,
447
+ "metricValue": 22,
446
448
  "small": 11,
447
449
  "subtitle": 14,
448
450
  "title": 26,
@@ -462,7 +464,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
462
464
  "chromeToChart": 8,
463
465
  "padding": 20,
464
466
  "xAxisHeight": 26,
465
- "xAxisLabelPadding": 14,
467
+ "xAxisLabelPadding": 4,
466
468
  },
467
469
  },
468
470
  "tooltipDescriptors": [
@@ -1419,6 +1421,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1419
1421
  "sizes": {
1420
1422
  "axisTick": 11,
1421
1423
  "body": 13,
1424
+ "metricLabel": 10,
1425
+ "metricValue": 22,
1422
1426
  "small": 11,
1423
1427
  "subtitle": 14,
1424
1428
  "title": 26,
@@ -1438,7 +1442,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1438
1442
  "chromeToChart": 8,
1439
1443
  "padding": 20,
1440
1444
  "xAxisHeight": 26,
1441
- "xAxisLabelPadding": 14,
1445
+ "xAxisLabelPadding": 4,
1442
1446
  },
1443
1447
  },
1444
1448
  "tooltipDescriptors": [],
@@ -2010,6 +2014,8 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2010
2014
  "sizes": {
2011
2015
  "axisTick": 11,
2012
2016
  "body": 13,
2017
+ "metricLabel": 10,
2018
+ "metricValue": 22,
2013
2019
  "small": 11,
2014
2020
  "subtitle": 14,
2015
2021
  "title": 26,
@@ -2029,7 +2035,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2029
2035
  "chromeToChart": 8,
2030
2036
  "padding": 20,
2031
2037
  "xAxisHeight": 26,
2032
- "xAxisLabelPadding": 14,
2038
+ "xAxisLabelPadding": 4,
2033
2039
  },
2034
2040
  },
2035
2041
  "tooltipDescriptors": [
@@ -175,6 +175,32 @@ describe('compileChart', () => {
175
175
  expect(layout.legend.position).toBe('top');
176
176
  });
177
177
 
178
+ it('aligns the top legend left edge to the plot area, not the container edge', () => {
179
+ // A y-axis title + numeric ticks reserves a left gutter, pushing the plot
180
+ // area right of the container padding. The top legend should start at the
181
+ // plot's left edge (above the y-axis guide), not flush against the container.
182
+ const layout = compileChart(
183
+ {
184
+ ...lineSpec,
185
+ encoding: {
186
+ ...lineSpec.encoding,
187
+ y: { field: 'value', type: 'quantitative' as const, axis: { title: 'GDP ($B)' } },
188
+ },
189
+ legend: { position: 'top' as const, show: true },
190
+ },
191
+ { width: 360, height: 400 },
192
+ );
193
+ expect(layout.legend.position).toBe('top');
194
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
195
+ // Plot area is inset from the container by the y-axis gutter.
196
+ expect(layout.area.x).toBeGreaterThan(0);
197
+ // Legend left edge now matches the plot left edge (within a pixel).
198
+ expect(layout.legend.bounds.x).toBeCloseTo(layout.area.x, 0);
199
+ // And the legend never paints past the container's right padding edge.
200
+ // Default theme spacing.padding is 20.
201
+ expect(layout.legend.bounds.x + layout.legend.bounds.width).toBeLessThanOrEqual(360 - 20 + 0.5);
202
+ });
203
+
178
204
  it('produces line marks with dataPoints (no PointMarks by default)', () => {
179
205
  const layout = compileChart(lineSpec, { width: 600, height: 400 });
180
206
  expect(layout.marks.length).toBeGreaterThan(0);
@@ -225,6 +225,35 @@ describe('computeAnnotations', () => {
225
225
  expect(annotations[0].label!.text).toBe('Recession');
226
226
  });
227
227
 
228
+ it('range label defaults to 11px / weight 500', () => {
229
+ const spec = makeSpec([
230
+ { type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
231
+ ]);
232
+ const scales = computeScales(spec, chartArea, spec.data);
233
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
234
+
235
+ expect(annotations[0].label!.style.fontSize).toBe(11);
236
+ expect(annotations[0].label!.style.fontWeight).toBe(500);
237
+ });
238
+
239
+ it('range label honors fontSize and fontWeight overrides', () => {
240
+ const spec = makeSpec([
241
+ {
242
+ type: 'range',
243
+ x1: '2020-01-01',
244
+ x2: '2021-01-01',
245
+ label: 'Period',
246
+ fontSize: 19,
247
+ fontWeight: 600,
248
+ },
249
+ ]);
250
+ const scales = computeScales(spec, chartArea, spec.data);
251
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
252
+
253
+ expect(annotations[0].label!.style.fontSize).toBe(19);
254
+ expect(annotations[0].label!.style.fontWeight).toBe(600);
255
+ });
256
+
228
257
  it('range has fill and opacity', () => {
229
258
  const spec = makeSpec([
230
259
  {
@@ -463,6 +492,41 @@ describe('computeAnnotations', () => {
463
492
  expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
464
493
  });
465
494
 
495
+ it('extendToEdges:false anchors a point-scale range at data point centers', () => {
496
+ // With extendToEdges:false the band starts/ends exactly at the first/last
497
+ // data point centers instead of extending half a step to the plot edge,
498
+ // so the range is inset from the axis.
499
+ const domainValues = ['2006', '2008', '2010', '2012'];
500
+ const ordinalSpec: NormalizedChartSpec = {
501
+ markType: 'line',
502
+ markDef: { type: 'line' },
503
+ data: domainValues.map((year, i) => ({ year, value: i * 10 })),
504
+ encoding: {
505
+ x: { field: 'year', type: 'ordinal' },
506
+ y: { field: 'value', type: 'quantitative' },
507
+ },
508
+ chrome: {},
509
+ annotations: [{ type: 'range', x1: '2006', x2: '2012', extendToEdges: false }],
510
+ responsive: true,
511
+ theme: {},
512
+ darkMode: 'off',
513
+ labels: { density: 'auto', format: '' },
514
+ };
515
+ const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
516
+ const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
517
+
518
+ expect(annotations).toHaveLength(1);
519
+ const rect = annotations[0].rect!;
520
+
521
+ const xScale = scales.x!.scale as ScalePoint<string>;
522
+ // Left/right edges land at the point centers, not extended past them.
523
+ expect(rect.x).toBeCloseTo(xScale('2006')!, 1);
524
+ expect(rect.x + rect.width).toBeCloseTo(xScale('2012')!, 1);
525
+ // And the band is strictly inside the plot (inset from both edges).
526
+ expect(rect.x).toBeGreaterThan(chartArea.x);
527
+ expect(rect.x + rect.width).toBeLessThan(chartArea.x + chartArea.width);
528
+ });
529
+
466
530
  it('linear-scale range is unaffected by edge extension', () => {
467
531
  // For linear scales, resolvePositionEdge is identical to resolvePosition.
468
532
  // This ensures the fix doesn't introduce any drift on continuous axes.
@@ -560,6 +624,25 @@ describe('computeAnnotations', () => {
560
624
  expect(annotations[0].strokeDasharray).toBeDefined();
561
625
  });
562
626
 
627
+ it('refline label defaults to fontSize 11', () => {
628
+ const spec = makeSpec([{ type: 'refline', x: '2020-06-01', label: 'Event' }]);
629
+ const scales = computeScales(spec, chartArea, spec.data);
630
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
631
+
632
+ expect(annotations[0].label!.style.fontSize).toBe(11);
633
+ });
634
+
635
+ it('refline label honors fontSize and fontWeight overrides', () => {
636
+ const spec = makeSpec([
637
+ { type: 'refline', x: '2020-06-01', label: 'Event', fontSize: 24, fontWeight: 600 },
638
+ ]);
639
+ const scales = computeScales(spec, chartArea, spec.data);
640
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
641
+
642
+ expect(annotations[0].label!.style.fontSize).toBe(24);
643
+ expect(annotations[0].label!.style.fontWeight).toBe(600);
644
+ });
645
+
563
646
  it('solid refline has no dasharray', () => {
564
647
  const spec = makeSpec([{ type: 'refline', y: 20, style: 'solid' }]);
565
648
  const scales = computeScales(spec, chartArea, spec.data);
@@ -12,7 +12,7 @@ import type {
12
12
  import type { ResolvedScales } from '../layout/scales';
13
13
  import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants';
14
14
  import { applyOffset } from './geometry';
15
- import { resolvePositionEdge } from './position';
15
+ import { resolvePosition, resolvePositionEdge } from './position';
16
16
  import { makeAnnotationLabelStyle } from './resolve-text';
17
17
 
18
18
  export function resolveRangeAnnotation(
@@ -26,10 +26,22 @@ export function resolveRangeAnnotation(
26
26
  let width = chartArea.width;
27
27
  let height = chartArea.height;
28
28
 
29
+ // When extendToEdges is false, anchor at the data point's exact position
30
+ // (band/point center) instead of extending to the band/step edge. This insets
31
+ // the range from the axis so it starts at the first data point rather than
32
+ // flush against the axis guide. No effect on linear/time scales.
33
+ const extend = annotation.extendToEdges !== false;
34
+ const resolveEdge = (
35
+ value: string | number,
36
+ scale: typeof scales.x,
37
+ edge: 'start' | 'end',
38
+ ): number | null =>
39
+ extend ? resolvePositionEdge(value, scale, edge) : resolvePosition(value, scale);
40
+
29
41
  // X-range (vertical band)
30
42
  if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
31
- const x1px = resolvePositionEdge(annotation.x1, scales.x, 'start');
32
- const x2px = resolvePositionEdge(annotation.x2, scales.x, 'end');
43
+ const x1px = resolveEdge(annotation.x1, scales.x, 'start');
44
+ const x2px = resolveEdge(annotation.x2, scales.x, 'end');
33
45
  if (x1px === null || x2px === null) return null;
34
46
 
35
47
  x = Math.min(x1px, x2px);
@@ -38,8 +50,8 @@ export function resolveRangeAnnotation(
38
50
 
39
51
  // Y-range (horizontal band)
40
52
  if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
41
- const y1px = resolvePositionEdge(annotation.y1, scales.y, 'end');
42
- const y2px = resolvePositionEdge(annotation.y2, scales.y, 'start');
53
+ const y1px = resolveEdge(annotation.y1, scales.y, 'end');
54
+ const y2px = resolveEdge(annotation.y2, scales.y, 'start');
43
55
  if (y1px === null || y2px === null) return null;
44
56
 
45
57
  y = Math.min(y1px, y2px);
@@ -62,7 +74,12 @@ export function resolveRangeAnnotation(
62
74
  const baseDy = 14;
63
75
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
64
76
 
65
- const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
77
+ const style = makeAnnotationLabelStyle(
78
+ annotation.fontSize ?? 11,
79
+ annotation.fontWeight ?? 500,
80
+ undefined,
81
+ isDark,
82
+ );
66
83
  if (centered) {
67
84
  style.textAnchor = 'middle';
68
85
  } else if (anchor === 'right') {
@@ -123,7 +123,12 @@ export function resolveRefLineAnnotation(
123
123
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
124
124
 
125
125
  const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
126
- const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke, isDark);
126
+ const style = makeAnnotationLabelStyle(
127
+ annotation.fontSize ?? 11,
128
+ annotation.fontWeight ?? 400,
129
+ annotation.stroke ?? defaultStroke,
130
+ isDark,
131
+ );
127
132
  style.textAnchor = textAnchor;
128
133
 
129
134
  label = {
package/src/compile.ts CHANGED
@@ -553,24 +553,34 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
553
553
  // the reserved margin. This way computeLegend positions the legend outside
554
554
  // the data area (in the margin) instead of overlapping data marks.
555
555
  //
556
- // Top/bottom legends sit above/below the chart and aren't constrained by
557
- // y-axis labels. Use the full container width so wrapping decisions match
558
- // the first pass (which uses options.width). Without this, charts with wide
559
- // y-axis labels (horizontal bars, long category names) artificially narrow
560
- // the legend and trigger premature wrapping.
556
+ // Top/bottom legends align their left edge to the plot area (chartArea.x),
557
+ // so the legend sits above/below the y-axis guide rather than flush against
558
+ // the container edge.
559
+ //
560
+ // Width runs from the new left origin (chartArea.x) to the container's right
561
+ // padding, so the rendered legend never paints past the right edge / over the
562
+ // endpoint-label column. This is narrower than the first pass (which used the
563
+ // full container at preliminaryArea.width = options.width), so on a chart with
564
+ // BOTH a wide y-axis gutter AND a legend that nearly fills its row, the second
565
+ // pass can wrap to one more row than the first pass reserved in margins.top —
566
+ // the extra row protrudes slightly into the chrome gap. That graceful failure
567
+ // (a few px of vertical crowding, still on-canvas) is preferable to the
568
+ // alternative (chips clipped off the right edge of the SVG). The maxRows cap
569
+ // (default 2 for top legends) bounds the worst case to a single extra row.
561
570
  const legendArea: Rect = { ...chartArea };
562
571
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
572
+ const legendInnerWidth = options.width - theme.spacing.padding - chartArea.x;
563
573
  const gap = legendGap(options.width);
564
574
  switch (legendLayout.position) {
565
575
  case 'top':
566
- legendArea.x = theme.spacing.padding;
567
- legendArea.width = options.width - theme.spacing.padding * 2;
576
+ legendArea.x = chartArea.x;
577
+ legendArea.width = legendInnerWidth;
568
578
  legendArea.y -= legendLayout.bounds.height + gap;
569
579
  legendArea.height += legendLayout.bounds.height + gap;
570
580
  break;
571
581
  case 'bottom':
572
- legendArea.x = theme.spacing.padding;
573
- legendArea.width = options.width - theme.spacing.padding * 2;
582
+ legendArea.x = chartArea.x;
583
+ legendArea.width = legendInnerWidth;
574
584
  // Bottom legend sits below the x-axis tick row, not over it. Expand
575
585
  // legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
576
586
  // legend lands beneath the axis. Mirrors dimensions.ts which reserved
@@ -23,12 +23,11 @@ import type {
23
23
  ResolvedTheme,
24
24
  } from '@opendata-ai/openchart-core';
25
25
  import {
26
- AXIS_TITLE_GAP,
27
26
  AXIS_TITLE_TRAILING_PAD,
27
+ axisTitleOffset,
28
28
  BREAKPOINT_COMPACT_MAX,
29
29
  computeChrome,
30
30
  estimateTextWidth,
31
- getAxisTitleOffset,
32
31
  HPAD_COMPACT_FRACTION,
33
32
  HPAD_COMPACT_MIN,
34
33
  LABEL_GAP_COMPACT,
@@ -38,7 +37,6 @@ import {
38
37
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
39
38
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
40
39
  NARROW_VIEWPORT_MAX,
41
- TICK_LABEL_OFFSET,
42
40
  TOP_PAD_EXTRA_NARROW,
43
41
  } from '@opendata-ai/openchart-core';
44
42
  import { format as d3Format } from 'd3-format';
@@ -47,7 +45,12 @@ import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
47
45
  import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
48
46
  import { countColorSeries, resolveSuppression } from '../legend/suppression';
49
47
  import { legendGap } from '../legend/wrap';
50
- import { computeMetricBar, metricBarHeight } from './metrics';
48
+ import { computeMetricBar, type MetricFontSizes, metricBarHeight } from './metrics';
49
+
50
+ /** Pull the metric-row font sizes from the resolved theme. */
51
+ function metricFonts(theme: ResolvedTheme): MetricFontSizes {
52
+ return { label: theme.fonts.sizes.metricLabel, value: theme.fonts.sizes.metricValue };
53
+ }
51
54
 
52
55
  // ---------------------------------------------------------------------------
53
56
  // Types
@@ -374,7 +377,7 @@ export function computeDimensions(
374
377
  // We reserve optimistically so the chart-area math is correct when the bar
375
378
  // is kept; the rollback path subtracts it back when stripped.
376
379
  const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
377
- const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
380
+ const tentativeMetricsHeight = wantsMetrics ? metricBarHeight(metricFonts(theme)) : 0;
378
381
  // topAxisGap sits between the legend (or chrome, if no legend) and the
379
382
  // chart area. It accounts for the general axis margin plus any inline
380
383
  // tick-label overhang. Placing it after the legend (below) keeps the
@@ -634,14 +637,16 @@ export function computeDimensions(
634
637
  theme.fonts.weights.normal,
635
638
  );
636
639
  }
637
- // Mirror the renderer's dynamic offset formula:
638
- // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + AXIS_TITLE_GAP(14)
639
- // titleOffset = max(dynamicOffset, getAxisTitleOffset(width))
640
- const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
641
- const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
642
- const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
640
+ // Mirror the renderer's title placement so the reserved space matches where
641
+ // the title is drawn. axisTitleOffset() returns the distance to the title's
642
+ // center; it already includes the title half-glyph on the tick-label side.
643
+ // We add another halfGlyph here for the title glyph extending the other way,
644
+ // toward the container edge, so the margin reaches the title's outer edge.
645
+ const titleFontSize = theme.fonts.sizes.body;
646
+ const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
647
+ const halfGlyph = Math.ceil(titleFontSize / 2);
643
648
  const rotatedLabelMargin =
644
- axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
649
+ offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
645
650
  margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
646
651
  }
647
652
 
@@ -749,6 +754,7 @@ export function computeDimensions(
749
754
  fallbackMetricsArea,
750
755
  chartArea.height,
751
756
  options.measureText,
757
+ theme,
752
758
  )
753
759
  : undefined;
754
760
  if (wantsMetrics && !fallbackMetrics) {
@@ -790,7 +796,7 @@ export function computeDimensions(
790
796
  const metricsTopY = topPad + chrome.topHeight;
791
797
  const metricsArea = { x: hPad, width: Math.max(0, width - hPad * 2) };
792
798
  const resolvedMetrics = wantsMetrics
793
- ? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText)
799
+ ? resolveMetrics(spec, metricsTopY, metricsArea, chartArea.height, options.measureText, theme)
794
800
  : undefined;
795
801
  if (wantsMetrics && !resolvedMetrics) {
796
802
  // See fallback path above for the clamp rationale.
@@ -824,6 +830,7 @@ function resolveMetrics(
824
830
  metricsArea: { x: number; width: number },
825
831
  remainingChartHeight: number,
826
832
  measureText: import('@opendata-ai/openchart-core').MeasureTextFn | undefined,
833
+ theme: ResolvedTheme,
827
834
  ): ResolvedMetricBar | undefined {
828
835
  return computeMetricBar(
829
836
  spec.metrics,
@@ -831,5 +838,6 @@ function resolveMetrics(
831
838
  metricsArea,
832
839
  remainingChartHeight,
833
840
  measureText,
841
+ metricFonts(theme),
834
842
  );
835
843
  }
@@ -14,19 +14,32 @@ import type {
14
14
  } from '@opendata-ai/openchart-core';
15
15
  import { estimateTextWidth } from '@opendata-ai/openchart-core';
16
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
17
+ // Font sizes default to the editorial KPI mock (10px uppercase label above a
18
+ // 22px primary value) but are theme-driven so charts can scale the row. The
19
+ // label/value sizes flow in from `theme.fonts.sizes.metricLabel/metricValue`.
20
+ const DEFAULT_LABEL_FONT_SIZE = 10;
21
+ const DEFAULT_VALUE_FONT_SIZE = 22;
22
+ const LABEL_LINE_HEIGHT_RATIO = 1.4;
23
+ const VALUE_LINE_HEIGHT_RATIO = 1.15;
24
24
  const INTER_ROW_GAP = 4;
25
25
  // Breathing room above labels (separates metric row from the subtitle).
26
26
  const TOP_GAP = 16;
27
27
  // Breathing room below values (separates metric row from legend / chart top).
28
28
  const BOTTOM_GAP = 20;
29
29
 
30
+ /** Font sizes the metric layout reserves space for. */
31
+ export interface MetricFontSizes {
32
+ /** Uppercase label size (px). */
33
+ label: number;
34
+ /** Primary value size (px). */
35
+ value: number;
36
+ }
37
+
38
+ const DEFAULT_METRIC_FONT_SIZES: MetricFontSizes = {
39
+ label: DEFAULT_LABEL_FONT_SIZE,
40
+ value: DEFAULT_VALUE_FONT_SIZE,
41
+ };
42
+
30
43
  /** Minimum container width that can fit a metric bar. */
31
44
  const MIN_BAR_WIDTH = 480;
32
45
  /** Minimum chart-area height after metric reservation. */
@@ -35,12 +48,12 @@ const MIN_CHART_HEIGHT = 150;
35
48
  const CELL_INNER_PAD = 8;
36
49
 
37
50
  /**
38
- * Total height the metric bar reserves above the chart area.
39
- * Always derived from the constants above; never hardcoded at the call site.
51
+ * Total height the metric bar reserves above the chart area. Derived from the
52
+ * font sizes (theme-driven) plus the fixed gaps; never hardcoded at the call site.
40
53
  */
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;
54
+ export function metricBarHeight(fonts: MetricFontSizes = DEFAULT_METRIC_FONT_SIZES): number {
55
+ const labelLine = fonts.label * LABEL_LINE_HEIGHT_RATIO;
56
+ const valueLine = fonts.value * VALUE_LINE_HEIGHT_RATIO;
44
57
  return TOP_GAP + labelLine + INTER_ROW_GAP + valueLine + BOTTOM_GAP;
45
58
  }
46
59
 
@@ -66,6 +79,7 @@ export function computeMetricBar(
66
79
  metricsArea: { x: number; width: number },
67
80
  remainingChartHeight: number,
68
81
  measureText?: MeasureTextFn,
82
+ fonts: MetricFontSizes = DEFAULT_METRIC_FONT_SIZES,
69
83
  ): ResolvedMetricBar | undefined {
70
84
  if (!metrics || metrics.length === 0) return undefined;
71
85
  if (metricsArea.width < MIN_BAR_WIDTH) return undefined;
@@ -78,14 +92,14 @@ export function computeMetricBar(
78
92
  for (const metric of metrics) {
79
93
  const text = valueRunText(metric);
80
94
  const measured = measureText
81
- ? measureText(text, VALUE_FONT_SIZE, 510).width
82
- : estimateTextWidth(text, VALUE_FONT_SIZE, 510);
95
+ ? measureText(text, fonts.value, 510).width
96
+ : estimateTextWidth(text, fonts.value, 510);
83
97
  if (measured > cellWidth - CELL_INNER_PAD) return undefined;
84
98
  }
85
99
 
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;
100
+ const labelLine = fonts.label * LABEL_LINE_HEIGHT_RATIO;
101
+ const labelY = metricsTopY + TOP_GAP + fonts.label; // baseline for uppercase label
102
+ const valueY = metricsTopY + TOP_GAP + labelLine + INTER_ROW_GAP + fonts.value;
89
103
 
90
104
  const cells: ResolvedMetricCell[] = metrics.map((metric, i) => ({
91
105
  x: metricsArea.x + i * cellWidth,
@@ -98,15 +112,15 @@ export function computeMetricBar(
98
112
 
99
113
  return {
100
114
  y: metricsTopY,
101
- height: metricBarHeight(),
115
+ height: metricBarHeight(fonts),
102
116
  cells,
103
117
  };
104
118
  }
105
119
 
106
120
  // Exposed for tests and consumers needing the same constants.
107
121
  export const METRIC_BAR_INTERNALS = {
108
- LABEL_FONT_SIZE,
109
- VALUE_FONT_SIZE,
122
+ DEFAULT_LABEL_FONT_SIZE,
123
+ DEFAULT_VALUE_FONT_SIZE,
110
124
  LABEL_LINE_HEIGHT_RATIO,
111
125
  VALUE_LINE_HEIGHT_RATIO,
112
126
  INTER_ROW_GAP,