@opendata-ai/openchart-engine 7.2.2 → 7.2.4
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/dist/index.js +34 -18
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/annotations/__tests__/compute.test.ts +19 -0
- package/src/annotations/resolve-refline.ts +6 -1
- package/src/charts/bar/__tests__/compute.test.ts +67 -0
- package/src/charts/bar/compute.ts +8 -1
- package/src/layout/__tests__/metrics.test.ts +97 -0
- package/src/layout/dimensions.ts +11 -3
- package/src/layout/metrics.ts +34 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.4",
|
|
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.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.2.4",
|
|
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,
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -624,6 +624,25 @@ describe('computeAnnotations', () => {
|
|
|
624
624
|
expect(annotations[0].strokeDasharray).toBeDefined();
|
|
625
625
|
});
|
|
626
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
|
+
|
|
627
646
|
it('solid refline has no dasharray', () => {
|
|
628
647
|
const spec = makeSpec([{ type: 'refline', y: 20, style: 'solid' }]);
|
|
629
648
|
const scales = computeScales(spec, chartArea, spec.data);
|
|
@@ -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(
|
|
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 = {
|
|
@@ -490,6 +490,73 @@ describe('computeBarMarks', () => {
|
|
|
490
490
|
});
|
|
491
491
|
});
|
|
492
492
|
|
|
493
|
+
describe('x-domain excludes zero', () => {
|
|
494
|
+
it('simple bars anchor at the domain minimum, not xScale(0)', () => {
|
|
495
|
+
const spec = makeSimpleBarSpec();
|
|
496
|
+
(spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
|
|
497
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
498
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
499
|
+
|
|
500
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
501
|
+
const left = xScale(65);
|
|
502
|
+
|
|
503
|
+
// Every bar starts at the plot-area left edge (xScale of the domain min),
|
|
504
|
+
// not at the far-left extrapolation of xScale(0).
|
|
505
|
+
for (const mark of marks) {
|
|
506
|
+
expect(mark.x).toBeCloseTo(left, 5);
|
|
507
|
+
// Bars never bleed left of the plot area into the y-axis label gutter.
|
|
508
|
+
expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
509
|
+
}
|
|
510
|
+
// Guard against the old behavior: xScale(0) lands well left of the plot area.
|
|
511
|
+
expect(xScale(0)).toBeLessThan(chartArea.x);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('grouped bars anchor at the domain minimum, not xScale(0)', () => {
|
|
515
|
+
const spec = makeGroupedBarSpec();
|
|
516
|
+
(spec.encoding.x as { scale?: { domain: number[] } }).scale = { domain: [65, 95] };
|
|
517
|
+
// Lift values into the [65, 95] window so widths stay positive.
|
|
518
|
+
spec.data = spec.data.map((d) => ({ ...d, value: 70 + (d.value as number) / 10 }));
|
|
519
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
520
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
521
|
+
|
|
522
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
523
|
+
const left = xScale(65);
|
|
524
|
+
|
|
525
|
+
for (const mark of marks) {
|
|
526
|
+
expect(mark.x).toBeCloseTo(left, 5);
|
|
527
|
+
expect(mark.x).toBeGreaterThanOrEqual(chartArea.x);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe('x-domain includes zero (no regression)', () => {
|
|
533
|
+
it('simple bars still anchor at xScale(0) for a default [0, max] domain', () => {
|
|
534
|
+
const spec = makeSimpleBarSpec();
|
|
535
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
536
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
537
|
+
|
|
538
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
539
|
+
for (const mark of marks) {
|
|
540
|
+
expect(mark.x).toBeCloseTo(xScale(0), 5);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('diverging bars (domain crosses zero) still anchor at xScale(0)', () => {
|
|
545
|
+
const spec = makeNegativeBarSpec();
|
|
546
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
547
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
548
|
+
|
|
549
|
+
const xScale = scales.x!.scale as (v: number) => number;
|
|
550
|
+
const zero = xScale(0);
|
|
551
|
+
|
|
552
|
+
const growth = marks.find((m) => m.aria.label.includes('Growth'))!;
|
|
553
|
+
const decline = marks.find((m) => m.aria.label.includes('Decline'))!;
|
|
554
|
+
// Positive bar starts at zero; negative bar ends at zero.
|
|
555
|
+
expect(growth.x).toBeCloseTo(zero, 5);
|
|
556
|
+
expect(decline.x + decline.width).toBeCloseTo(zero, 5);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
493
560
|
describe('edge cases', () => {
|
|
494
561
|
it('returns empty array when no x encoding', () => {
|
|
495
562
|
const spec: NormalizedChartSpec = {
|
|
@@ -88,7 +88,14 @@ export function computeBarMarks(
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const bandwidth = yScale.bandwidth();
|
|
91
|
-
|
|
91
|
+
// Baseline = pixel x where the bar's left edge anchors. When the x-domain
|
|
92
|
+
// includes zero (the common case), this is xScale(0). When the domain
|
|
93
|
+
// excludes zero (explicit non-zero domain or scale.zero: false), xScale(0)
|
|
94
|
+
// would extrapolate outside the plot area and overrun the y-axis labels, so
|
|
95
|
+
// anchor to the domain minimum (the plot-area left edge) instead.
|
|
96
|
+
const xDomain = xScale.domain() as [number, number];
|
|
97
|
+
const xIncludesZero = xDomain[0] <= 0 && xDomain[1] >= 0;
|
|
98
|
+
const baseline = xIncludesZero ? xScale(0) : xScale(xDomain[0]);
|
|
92
99
|
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
93
100
|
const conditionalColor =
|
|
94
101
|
encoding.color && isConditionalValueDef(encoding.color)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Metric } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
computeMetricBar,
|
|
5
|
+
METRIC_BAR_INTERNALS,
|
|
6
|
+
type MetricFontSizes,
|
|
7
|
+
metricBarHeight,
|
|
8
|
+
} from '../metrics';
|
|
9
|
+
|
|
10
|
+
const { TOP_GAP, BOTTOM_GAP, INTER_ROW_GAP, LABEL_LINE_HEIGHT_RATIO, VALUE_LINE_HEIGHT_RATIO } =
|
|
11
|
+
METRIC_BAR_INTERNALS;
|
|
12
|
+
|
|
13
|
+
const defaultFonts: MetricFontSizes = { label: 10, value: 22 };
|
|
14
|
+
const largeFonts: MetricFontSizes = { label: 16, value: 32 };
|
|
15
|
+
|
|
16
|
+
const metrics: Metric[] = [
|
|
17
|
+
{ label: 'Revenue', value: '$1.2B', delta: '+12%', deltaTone: 'up' },
|
|
18
|
+
{ label: 'Users', value: '4.5M' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const area = { x: 20, width: 600 };
|
|
22
|
+
|
|
23
|
+
describe('metricBarHeight', () => {
|
|
24
|
+
it('returns the expected height at default font sizes', () => {
|
|
25
|
+
const expected =
|
|
26
|
+
TOP_GAP +
|
|
27
|
+
defaultFonts.label * LABEL_LINE_HEIGHT_RATIO +
|
|
28
|
+
INTER_ROW_GAP +
|
|
29
|
+
defaultFonts.value * VALUE_LINE_HEIGHT_RATIO +
|
|
30
|
+
BOTTOM_GAP;
|
|
31
|
+
expect(metricBarHeight()).toBe(expected);
|
|
32
|
+
expect(metricBarHeight(defaultFonts)).toBe(expected);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('grows when font sizes increase', () => {
|
|
36
|
+
const large = metricBarHeight(largeFonts);
|
|
37
|
+
expect(large).toBeGreaterThan(metricBarHeight(defaultFonts));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('matches the formula for custom sizes', () => {
|
|
41
|
+
const expected =
|
|
42
|
+
TOP_GAP +
|
|
43
|
+
largeFonts.label * LABEL_LINE_HEIGHT_RATIO +
|
|
44
|
+
INTER_ROW_GAP +
|
|
45
|
+
largeFonts.value * VALUE_LINE_HEIGHT_RATIO +
|
|
46
|
+
BOTTOM_GAP;
|
|
47
|
+
expect(metricBarHeight(largeFonts)).toBe(expected);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('computeMetricBar', () => {
|
|
52
|
+
it('uses custom font sizes for layout positions', () => {
|
|
53
|
+
const topY = 50;
|
|
54
|
+
const defaultBar = computeMetricBar(metrics, topY, area, 400, undefined, defaultFonts)!;
|
|
55
|
+
const largeBar = computeMetricBar(metrics, topY, area, 400, undefined, largeFonts)!;
|
|
56
|
+
|
|
57
|
+
expect(defaultBar).toBeDefined();
|
|
58
|
+
expect(largeBar).toBeDefined();
|
|
59
|
+
|
|
60
|
+
expect(largeBar.height).toBeGreaterThan(defaultBar.height);
|
|
61
|
+
expect(largeBar.height).toBe(metricBarHeight(largeFonts));
|
|
62
|
+
|
|
63
|
+
expect(largeBar.cells[0].labelY).toBeGreaterThan(defaultBar.cells[0].labelY);
|
|
64
|
+
expect(largeBar.cells[0].valueY).toBeGreaterThan(defaultBar.cells[0].valueY);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('computes labelY and valueY from font sizes', () => {
|
|
68
|
+
const topY = 50;
|
|
69
|
+
const bar = computeMetricBar(metrics, topY, area, 400, undefined, largeFonts)!;
|
|
70
|
+
|
|
71
|
+
expect(bar.cells[0].labelY).toBe(topY + TOP_GAP + largeFonts.label);
|
|
72
|
+
expect(bar.cells[0].valueY).toBe(
|
|
73
|
+
topY +
|
|
74
|
+
TOP_GAP +
|
|
75
|
+
largeFonts.label * LABEL_LINE_HEIGHT_RATIO +
|
|
76
|
+
INTER_ROW_GAP +
|
|
77
|
+
largeFonts.value,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses custom font size for overflow detection', () => {
|
|
82
|
+
const tinyCell: Metric[] = [{ label: 'A', value: 'WWWWWWWWWWWWWWWWWWWWWWWWWW' }];
|
|
83
|
+
const narrowArea = { x: 0, width: 500 };
|
|
84
|
+
|
|
85
|
+
const withSmall = computeMetricBar(tinyCell, 0, narrowArea, 400, undefined, {
|
|
86
|
+
label: 10,
|
|
87
|
+
value: 12,
|
|
88
|
+
});
|
|
89
|
+
const withLarge = computeMetricBar(tinyCell, 0, narrowArea, 400, undefined, {
|
|
90
|
+
label: 10,
|
|
91
|
+
value: 40,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(withSmall).toBeDefined();
|
|
95
|
+
expect(withLarge).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -45,7 +45,12 @@ import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
|
|
|
45
45
|
import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
46
46
|
import { countColorSeries, resolveSuppression } from '../legend/suppression';
|
|
47
47
|
import { legendGap } from '../legend/wrap';
|
|
48
|
-
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
|
+
}
|
|
49
54
|
|
|
50
55
|
// ---------------------------------------------------------------------------
|
|
51
56
|
// Types
|
|
@@ -372,7 +377,7 @@ export function computeDimensions(
|
|
|
372
377
|
// We reserve optimistically so the chart-area math is correct when the bar
|
|
373
378
|
// is kept; the rollback path subtracts it back when stripped.
|
|
374
379
|
const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
|
|
375
|
-
const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
|
|
380
|
+
const tentativeMetricsHeight = wantsMetrics ? metricBarHeight(metricFonts(theme)) : 0;
|
|
376
381
|
// topAxisGap sits between the legend (or chrome, if no legend) and the
|
|
377
382
|
// chart area. It accounts for the general axis margin plus any inline
|
|
378
383
|
// tick-label overhang. Placing it after the legend (below) keeps the
|
|
@@ -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
|
}
|
package/src/layout/metrics.ts
CHANGED
|
@@ -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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const LABEL_LINE_HEIGHT_RATIO = 1.4;
|
|
23
|
-
const VALUE_LINE_HEIGHT_RATIO = 1.15;
|
|
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
|
-
*
|
|
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 =
|
|
43
|
-
const valueLine =
|
|
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,
|
|
82
|
-
: estimateTextWidth(text,
|
|
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 =
|
|
87
|
-
const labelY = metricsTopY + TOP_GAP +
|
|
88
|
-
const valueY = metricsTopY + TOP_GAP + labelLine + INTER_ROW_GAP +
|
|
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
|
-
|
|
109
|
-
|
|
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,
|