@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/dist/index.js +55 -35
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +9 -3
- package/src/__tests__/compile-chart.test.ts +26 -0
- package/src/annotations/__tests__/compute.test.ts +83 -0
- package/src/annotations/resolve-range.ts +23 -6
- package/src/annotations/resolve-refline.ts +6 -1
- package/src/compile.ts +19 -9
- package/src/layout/dimensions.ts +21 -13
- 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.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.
|
|
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":
|
|
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":
|
|
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":
|
|
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 =
|
|
32
|
-
const x2px =
|
|
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 =
|
|
42
|
-
const y2px =
|
|
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(
|
|
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(
|
|
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
|
|
557
|
-
//
|
|
558
|
-
// the
|
|
559
|
-
//
|
|
560
|
-
// the
|
|
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 =
|
|
567
|
-
legendArea.width =
|
|
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 =
|
|
573
|
-
legendArea.width =
|
|
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
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
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
|
-
|
|
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
|
}
|
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,
|