@opendata-ai/openchart-engine 7.2.3 → 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/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",
|
|
@@ -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
|
+
});
|