@opendata-ai/openchart-engine 7.2.4 → 7.4.0
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.d.ts +88 -39
- package/dist/index.js +4134 -2930
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
- package/src/__tests__/axes.test.ts +129 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +70 -2
- package/src/__tests__/helpers/invariants.ts +265 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +358 -0
- package/src/__tests__/layout-invariants.test.ts +535 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/bar/__tests__/compute.test.ts +131 -45
- package/src/charts/bar/compute.ts +44 -4
- package/src/charts/bar/index.ts +9 -2
- package/src/charts/bar/labels.ts +77 -26
- package/src/charts/column/labels.ts +29 -16
- package/src/charts/post-process.ts +21 -7
- package/src/charts/registry.ts +4 -2
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +88 -149
- package/src/index.ts +9 -0
- package/src/layout/axes/rotation.ts +36 -0
- package/src/layout/axes.ts +130 -8
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +550 -0
- package/src/layout/scales.ts +22 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- package/src/tables/heatmap.ts +25 -9
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AxisTick, LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
1
|
+
import type { AxisTick, Encoding, LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
2
2
|
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { scaleLinear, scaleLog } from 'd3-scale';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
@@ -1057,3 +1057,131 @@ describe('axis tickPosition', () => {
|
|
|
1057
1057
|
expect(axes.y!.tickPosition).toBe('gutter');
|
|
1058
1058
|
});
|
|
1059
1059
|
});
|
|
1060
|
+
|
|
1061
|
+
describe('axis extent and titlePosition', () => {
|
|
1062
|
+
it('emits x-axis extent for a basic line chart', () => {
|
|
1063
|
+
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1064
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1065
|
+
|
|
1066
|
+
expect(axes.x!.extent).toBeGreaterThan(0);
|
|
1067
|
+
expect(axes.x!.extent).toBeLessThan(200);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('extent is larger when axis has a title', () => {
|
|
1071
|
+
const specWithTitle: NormalizedChartSpec = {
|
|
1072
|
+
...lineSpec,
|
|
1073
|
+
encoding: {
|
|
1074
|
+
...lineSpec.encoding,
|
|
1075
|
+
x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
|
|
1076
|
+
},
|
|
1077
|
+
};
|
|
1078
|
+
const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
|
|
1079
|
+
const axesWithTitle = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1080
|
+
|
|
1081
|
+
const scalesNoTitle = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
1082
|
+
const axesNoTitle = computeAxes(scalesNoTitle, chartArea, fullStrategy, theme);
|
|
1083
|
+
|
|
1084
|
+
expect(axesWithTitle.x!.extent).toBeGreaterThan(axesNoTitle.x!.extent!);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
it('emits titlePosition for x-axis with title', () => {
|
|
1088
|
+
const specWithTitle: NormalizedChartSpec = {
|
|
1089
|
+
...lineSpec,
|
|
1090
|
+
encoding: {
|
|
1091
|
+
...lineSpec.encoding,
|
|
1092
|
+
x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
|
|
1096
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
1097
|
+
|
|
1098
|
+
expect(axes.x!.titlePosition).toBeDefined();
|
|
1099
|
+
expect(axes.x!.titlePosition!.x).toBeCloseTo(chartArea.x + chartArea.width / 2, 0);
|
|
1100
|
+
expect(axes.x!.titlePosition!.y).toBeGreaterThan(chartArea.y + chartArea.height);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
it('emits titlePosition for y-axis with title', () => {
|
|
1104
|
+
const specWithYTitle: NormalizedChartSpec = {
|
|
1105
|
+
...lineSpec,
|
|
1106
|
+
encoding: {
|
|
1107
|
+
...lineSpec.encoding,
|
|
1108
|
+
y: { ...lineSpec.encoding.y, axis: { title: 'Value' } },
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
const scales = computeScales(specWithYTitle, chartArea, specWithYTitle.data);
|
|
1112
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
|
|
1113
|
+
data: specWithYTitle.data,
|
|
1114
|
+
encoding: specWithYTitle.encoding as Encoding,
|
|
1115
|
+
skipX: false,
|
|
1116
|
+
skipY: false,
|
|
1117
|
+
markType: 'line',
|
|
1118
|
+
totalWidth: 600,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
expect(axes.y!.titlePosition).toBeDefined();
|
|
1122
|
+
expect(axes.y!.titlePosition!.x).toBeLessThan(chartArea.x);
|
|
1123
|
+
expect(axes.y!.titlePosition!.angle).toBe(-90);
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
describe('quantitative axis minimum tick floor', () => {
|
|
1128
|
+
// A tight domain like [73, 88] makes D3's `scale.ticks(2)` collapse to a
|
|
1129
|
+
// single nice value ([80]). Before the floor, this rendered as a lone tick
|
|
1130
|
+
// (a real iPhone blog chart showed only "80"). The floor must hold even at
|
|
1131
|
+
// very narrow widths where the target count drops to 2.
|
|
1132
|
+
const tightDomainSpec: NormalizedChartSpec = {
|
|
1133
|
+
markType: 'bar',
|
|
1134
|
+
markDef: { type: 'bar', orient: 'horizontal' },
|
|
1135
|
+
data: [
|
|
1136
|
+
{ district: 'A', score: 79 },
|
|
1137
|
+
{ district: 'B', score: 73 },
|
|
1138
|
+
{ district: 'C', score: 88 },
|
|
1139
|
+
],
|
|
1140
|
+
encoding: {
|
|
1141
|
+
y: { field: 'district', type: 'nominal' },
|
|
1142
|
+
x: {
|
|
1143
|
+
field: 'score',
|
|
1144
|
+
type: 'quantitative',
|
|
1145
|
+
axis: { format: '.0f', grid: true },
|
|
1146
|
+
scale: { domain: [73, 88], nice: false },
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
chrome: {},
|
|
1150
|
+
annotations: [],
|
|
1151
|
+
responsive: true,
|
|
1152
|
+
theme: {},
|
|
1153
|
+
darkMode: 'off',
|
|
1154
|
+
labels: { density: 'all', format: '.0f' },
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
it('renders >= 2 ticks at a very narrow (320px-ish) chart width', () => {
|
|
1158
|
+
// Narrow chart area forces the minimal density target count down to 2,
|
|
1159
|
+
// where D3 collapses [73, 88] to the single tick "80" without the floor.
|
|
1160
|
+
const narrowArea = { x: 90, y: 50, width: 90, height: 180 };
|
|
1161
|
+
const scales = computeScales(tightDomainSpec, narrowArea, tightDomainSpec.data);
|
|
1162
|
+
const axes = computeAxes(scales, narrowArea, minimalStrategy, theme, undefined, {
|
|
1163
|
+
data: tightDomainSpec.data,
|
|
1164
|
+
encoding: tightDomainSpec.encoding as Encoding,
|
|
1165
|
+
markType: 'bar',
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
expect(axes.x).toBeDefined();
|
|
1169
|
+
expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it('holds the 2-tick floor across a sweep of narrow widths', () => {
|
|
1173
|
+
for (const width of [60, 90, 120, 220, 320]) {
|
|
1174
|
+
const area = { x: 90, y: 50, width, height: 180 };
|
|
1175
|
+
const scales = computeScales(tightDomainSpec, area, tightDomainSpec.data);
|
|
1176
|
+
const axes = computeAxes(scales, area, minimalStrategy, theme, undefined, {
|
|
1177
|
+
data: tightDomainSpec.data,
|
|
1178
|
+
encoding: tightDomainSpec.encoding as Encoding,
|
|
1179
|
+
markType: 'bar',
|
|
1180
|
+
});
|
|
1181
|
+
expect(
|
|
1182
|
+
axes.x!.ticks.length,
|
|
1183
|
+
`expected >= 2 ticks at width ${width}, got ${axes.x!.ticks.length}`,
|
|
1184
|
+
).toBeGreaterThanOrEqual(2);
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
});
|
|
@@ -123,6 +123,12 @@ describe('compileChart', () => {
|
|
|
123
123
|
expect(layout.chrome.source!.style.fontSize).toBeGreaterThan(0);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
it('emits bottomAnchorY below the chart area', () => {
|
|
127
|
+
const layout = compileChart(lineSpec, { width: 600, height: 400 });
|
|
128
|
+
expect(layout.chrome.bottomAnchorY).toBeDefined();
|
|
129
|
+
expect(layout.chrome.bottomAnchorY).toBeGreaterThanOrEqual(layout.area.y + layout.area.height);
|
|
130
|
+
});
|
|
131
|
+
|
|
126
132
|
it('has axes with ticks and valid positions for a line chart', () => {
|
|
127
133
|
const layout = compileChart(lineSpec, { width: 600, height: 400 });
|
|
128
134
|
|
|
@@ -391,6 +391,55 @@ describe('compileLayer', () => {
|
|
|
391
391
|
expect(layout.axes.x).toBeDefined();
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
it('positions y2 axis title on the right side of the chart', () => {
|
|
395
|
+
const spec: LayerSpec = {
|
|
396
|
+
resolve: { scale: { y: 'independent' } },
|
|
397
|
+
layer: [
|
|
398
|
+
{
|
|
399
|
+
mark: 'bar' as const,
|
|
400
|
+
data: [
|
|
401
|
+
{ year: '2025', revenue: 10_000_000 },
|
|
402
|
+
{ year: '2026', revenue: 15_000_000 },
|
|
403
|
+
],
|
|
404
|
+
encoding: {
|
|
405
|
+
x: { field: 'year', type: 'ordinal' as const },
|
|
406
|
+
y: { field: 'revenue', type: 'quantitative' as const, axis: { title: 'Revenue ($)' } },
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
mark: 'line' as const,
|
|
411
|
+
data: [
|
|
412
|
+
{ year: '2025', enrollment: 30_000 },
|
|
413
|
+
{ year: '2026', enrollment: 40_000 },
|
|
414
|
+
],
|
|
415
|
+
encoding: {
|
|
416
|
+
x: { field: 'year', type: 'ordinal' as const },
|
|
417
|
+
y: {
|
|
418
|
+
field: 'enrollment',
|
|
419
|
+
type: 'quantitative' as const,
|
|
420
|
+
axis: { title: 'Enrollment' },
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const layout = compileLayer(spec, compileOpts);
|
|
428
|
+
const y1Title = layout.axes.y!.titlePosition;
|
|
429
|
+
const y2Title = layout.axes.y2!.titlePosition;
|
|
430
|
+
|
|
431
|
+
expect(y1Title).toBeDefined();
|
|
432
|
+
expect(y2Title).toBeDefined();
|
|
433
|
+
|
|
434
|
+
// y1 title should be left of chart area, y2 title should be right
|
|
435
|
+
expect(y1Title!.x).toBeLessThan(layout.area.x);
|
|
436
|
+
expect(y2Title!.x).toBeGreaterThan(layout.area.x + layout.area.width);
|
|
437
|
+
|
|
438
|
+
// y2 title should rotate clockwise (90), y1 counter-clockwise (-90)
|
|
439
|
+
expect(y1Title!.angle).toBe(-90);
|
|
440
|
+
expect(y2Title!.angle).toBe(90);
|
|
441
|
+
});
|
|
442
|
+
|
|
394
443
|
it('tags layer-1 marks with yScale y2', () => {
|
|
395
444
|
const spec: LayerSpec = {
|
|
396
445
|
resolve: { scale: { y: 'independent' } },
|
|
@@ -182,7 +182,11 @@ describe('computeDimensions', () => {
|
|
|
182
182
|
],
|
|
183
183
|
encoding: {
|
|
184
184
|
x: { field: 'date', type: 'temporal' },
|
|
185
|
-
y: {
|
|
185
|
+
y: {
|
|
186
|
+
field: 'value',
|
|
187
|
+
type: 'quantitative',
|
|
188
|
+
axis: { title: 'Revenue ($)', format: ',.0f' },
|
|
189
|
+
},
|
|
186
190
|
},
|
|
187
191
|
};
|
|
188
192
|
|
|
@@ -199,7 +203,8 @@ describe('computeDimensions', () => {
|
|
|
199
203
|
lightTheme,
|
|
200
204
|
);
|
|
201
205
|
|
|
202
|
-
//
|
|
206
|
+
// Explicit format ",.0f" on large values produces "2,000,000" tick labels
|
|
207
|
+
// that are much wider than the abbreviated "0.0" from small values,
|
|
203
208
|
// so the y-axis title margin should grow to keep clearance
|
|
204
209
|
expect(dimsLarge.margins.left).toBeGreaterThan(dimsSmall.margins.left);
|
|
205
210
|
});
|
|
@@ -650,4 +655,67 @@ describe('bottom legend placement (defect-3 regression)', () => {
|
|
|
650
655
|
expect(sourceAbsoluteY).toBeGreaterThan(legendBottom);
|
|
651
656
|
expect(bylineAbsoluteY).toBeGreaterThan(sourceAbsoluteY);
|
|
652
657
|
});
|
|
658
|
+
|
|
659
|
+
// Guards issue 2 (narrow-width space efficiency). The responsive metrics
|
|
660
|
+
// (scaled container padding, halved horizontal padding, collapsed legend/
|
|
661
|
+
// chrome gaps below the compact breakpoint) already keep the plot area's
|
|
662
|
+
// share of a phone-sized container within a couple of points of desktop.
|
|
663
|
+
// These floors lock that in: if a future change stops scaling padding down
|
|
664
|
+
// on narrow viewports, the plot share drops and the test fails.
|
|
665
|
+
describe('plot-area share stays efficient at narrow widths', () => {
|
|
666
|
+
const titledSpec: NormalizedChartSpec = {
|
|
667
|
+
...baseSpec,
|
|
668
|
+
markType: 'bar',
|
|
669
|
+
markDef: { type: 'bar' },
|
|
670
|
+
encoding: {
|
|
671
|
+
x: { field: 'date', type: 'nominal' },
|
|
672
|
+
y: { field: 'value', type: 'quantitative' },
|
|
673
|
+
},
|
|
674
|
+
chrome: {
|
|
675
|
+
title: { text: 'A representative chart title' },
|
|
676
|
+
subtitle: { text: 'A subtitle line' },
|
|
677
|
+
source: { text: 'Source: somewhere' },
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
it('reserves at least 85% of container width for the plot at 375px', () => {
|
|
682
|
+
const dims = computeDimensions(
|
|
683
|
+
titledSpec,
|
|
684
|
+
{ width: 375, height: 500 },
|
|
685
|
+
emptyLegend,
|
|
686
|
+
lightTheme,
|
|
687
|
+
);
|
|
688
|
+
expect(dims.chartArea.width / 375).toBeGreaterThanOrEqual(0.85);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it('reserves at least 55% of container height for the plot at 375px', () => {
|
|
692
|
+
const dims = computeDimensions(
|
|
693
|
+
titledSpec,
|
|
694
|
+
{ width: 375, height: 500 },
|
|
695
|
+
emptyLegend,
|
|
696
|
+
lightTheme,
|
|
697
|
+
);
|
|
698
|
+
expect(dims.chartArea.height / 500).toBeGreaterThanOrEqual(0.55);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('narrow plot width share is no worse than desktop', () => {
|
|
702
|
+
const narrow = computeDimensions(
|
|
703
|
+
titledSpec,
|
|
704
|
+
{ width: 375, height: 500 },
|
|
705
|
+
emptyLegend,
|
|
706
|
+
lightTheme,
|
|
707
|
+
);
|
|
708
|
+
const desktop = computeDimensions(
|
|
709
|
+
titledSpec,
|
|
710
|
+
{ width: 700, height: 500 },
|
|
711
|
+
emptyLegend,
|
|
712
|
+
lightTheme,
|
|
713
|
+
);
|
|
714
|
+
// Narrow viewports must not waste more horizontal space than desktop:
|
|
715
|
+
// the halved horizontal padding keeps the share on par (allow 2pt slack).
|
|
716
|
+
expect(narrow.chartArea.width / 375).toBeGreaterThanOrEqual(
|
|
717
|
+
desktop.chartArea.width / 700 - 0.02,
|
|
718
|
+
);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
653
721
|
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout invariant assertions.
|
|
3
|
+
*
|
|
4
|
+
* Extracts bounding boxes from a compiled ChartLayout and checks that
|
|
5
|
+
* layout elements don't overlap where they shouldn't.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CategoricalLegendLayout,
|
|
10
|
+
ChartLayout,
|
|
11
|
+
LegendLayout,
|
|
12
|
+
Rect,
|
|
13
|
+
} from '@opendata-ai/openchart-core';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Types
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface NamedRect extends Rect {
|
|
20
|
+
name: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Type guards
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function isCategoricalLegend(legend: LegendLayout): legend is CategoricalLegendLayout {
|
|
28
|
+
return 'entries' in legend && (!legend.type || legend.type === 'categorical');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Box extraction
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const CHROME_KEYS = [
|
|
36
|
+
'eyebrow',
|
|
37
|
+
'title',
|
|
38
|
+
'subtitle',
|
|
39
|
+
'source',
|
|
40
|
+
'byline',
|
|
41
|
+
'footer',
|
|
42
|
+
'brand',
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extracts every layout element's bounding box as a NamedRect.
|
|
47
|
+
*/
|
|
48
|
+
export function extractBoxes(layout: ChartLayout): NamedRect[] {
|
|
49
|
+
const boxes: NamedRect[] = [];
|
|
50
|
+
|
|
51
|
+
// Chart area
|
|
52
|
+
boxes.push({ ...layout.area, name: 'chartArea' });
|
|
53
|
+
|
|
54
|
+
// Legend bounds
|
|
55
|
+
boxes.push({ ...layout.legend.bounds, name: 'legend' });
|
|
56
|
+
|
|
57
|
+
// Legend entry positions (categorical only)
|
|
58
|
+
if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
|
|
59
|
+
const rowHeight = layout.legend.rowHeight ?? 16;
|
|
60
|
+
for (let i = 0; i < layout.legend.entryPositions.length; i++) {
|
|
61
|
+
const pos = layout.legend.entryPositions[i];
|
|
62
|
+
boxes.push({
|
|
63
|
+
x: pos.x,
|
|
64
|
+
y: pos.y,
|
|
65
|
+
width: pos.width,
|
|
66
|
+
height: rowHeight,
|
|
67
|
+
name: `legend-entry-${i}`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// X-axis label band
|
|
73
|
+
if (layout.axes.x?.extent && layout.axes.x.extent > 0) {
|
|
74
|
+
boxes.push({
|
|
75
|
+
x: layout.area.x,
|
|
76
|
+
y: layout.area.y + layout.area.height,
|
|
77
|
+
width: layout.area.width,
|
|
78
|
+
height: layout.axes.x.extent,
|
|
79
|
+
name: 'x-axis-labels',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Y-axis label band
|
|
84
|
+
if (layout.axes.y?.extent && layout.axes.y.extent > 0) {
|
|
85
|
+
boxes.push({
|
|
86
|
+
x: layout.area.x - layout.axes.y.extent,
|
|
87
|
+
y: layout.area.y,
|
|
88
|
+
width: layout.axes.y.extent,
|
|
89
|
+
height: layout.area.height,
|
|
90
|
+
name: 'y-axis-labels',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Chrome elements store y as the TOP edge of the text box (renderers convert
|
|
95
|
+
// to the alphabetic baseline via textAscent), so y is the box top directly.
|
|
96
|
+
for (const key of CHROME_KEYS) {
|
|
97
|
+
const el = layout.chrome[key];
|
|
98
|
+
if (el) {
|
|
99
|
+
const h = el.style.fontSize * el.style.lineHeight;
|
|
100
|
+
boxes.push({ x: el.x, y: el.y, width: el.maxWidth, height: h, name: key });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Annotation bounds
|
|
105
|
+
for (let i = 0; i < layout.annotations.length; i++) {
|
|
106
|
+
const annotation = layout.annotations[i];
|
|
107
|
+
if (annotation.bounds) {
|
|
108
|
+
boxes.push({ ...annotation.bounds, name: `annotation-${i}` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Endpoint labels
|
|
113
|
+
if (layout.endpointLabels?.bounds) {
|
|
114
|
+
boxes.push({ ...layout.endpointLabels.bounds, name: 'endpoint-labels' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return boxes;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Overlap check
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Throws if two rects overlap by more than epsilon pixels.
|
|
126
|
+
*/
|
|
127
|
+
export function assertNoOverlap(a: NamedRect, b: NamedRect, epsilon = 0.5): void {
|
|
128
|
+
const overlaps =
|
|
129
|
+
a.x < b.x + b.width - epsilon &&
|
|
130
|
+
a.x + a.width > b.x + epsilon &&
|
|
131
|
+
a.y < b.y + b.height - epsilon &&
|
|
132
|
+
a.y + a.height > b.y + epsilon;
|
|
133
|
+
|
|
134
|
+
if (overlaps) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Overlap detected between "${a.name}" and "${b.name}": ` +
|
|
137
|
+
`a=${JSON.stringify({ x: a.x, y: a.y, width: a.width, height: a.height })}, ` +
|
|
138
|
+
`b=${JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height })}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Containment helper
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function isContained(inner: Rect, outer: Rect, tolerance: number): boolean {
|
|
148
|
+
return (
|
|
149
|
+
inner.x >= outer.x - tolerance &&
|
|
150
|
+
inner.y >= outer.y - tolerance &&
|
|
151
|
+
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
|
152
|
+
inner.y + inner.height <= outer.y + outer.height + tolerance
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function rectsOverlap(a: Rect, b: Rect, epsilon: number): boolean {
|
|
157
|
+
return (
|
|
158
|
+
a.x < b.x + b.width - epsilon &&
|
|
159
|
+
a.x + a.width > b.x + epsilon &&
|
|
160
|
+
a.y < b.y + b.height - epsilon &&
|
|
161
|
+
a.y + a.height > b.y + epsilon
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatRect(r: Rect): string {
|
|
166
|
+
return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Full invariant check
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns a list of violation strings. Empty when clean.
|
|
175
|
+
*/
|
|
176
|
+
export function checkLayoutInvariants(
|
|
177
|
+
layout: ChartLayout,
|
|
178
|
+
opts: { svgWidth: number; svgHeight: number },
|
|
179
|
+
): string[] {
|
|
180
|
+
const violations: string[] = [];
|
|
181
|
+
|
|
182
|
+
// 1. Legend doesn't intrude into chartArea (top/bottom only; right/bottom-right
|
|
183
|
+
// use area.width reduction, so overlap is a horizontal concern checked by rule 4)
|
|
184
|
+
if (isCategoricalLegend(layout.legend)) {
|
|
185
|
+
const pos = layout.legend.position;
|
|
186
|
+
if (pos === 'top' || pos === 'bottom') {
|
|
187
|
+
if (rectsOverlap(layout.legend.bounds, layout.area, 0.5)) {
|
|
188
|
+
violations.push(
|
|
189
|
+
`legend intrudes into chartArea: legend=${formatRect(layout.legend.bounds)} chartArea=${formatRect(layout.area)}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2. Every legend entryPosition box lies inside legend bounds
|
|
196
|
+
if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
|
|
197
|
+
const rowHeight = layout.legend.rowHeight ?? 16;
|
|
198
|
+
// Entry width includes trailing entryGap; the last entry in a row doesn't
|
|
199
|
+
// need that gap to fit inside bounds, so allow entryGap as extra tolerance.
|
|
200
|
+
const gapTolerance = layout.legend.entryGap + 1;
|
|
201
|
+
for (let i = 0; i < layout.legend.entryPositions.length; i++) {
|
|
202
|
+
const pos = layout.legend.entryPositions[i];
|
|
203
|
+
const entryBox: Rect = {
|
|
204
|
+
x: pos.x,
|
|
205
|
+
y: pos.y,
|
|
206
|
+
width: pos.width,
|
|
207
|
+
height: rowHeight,
|
|
208
|
+
};
|
|
209
|
+
if (!isContained(entryBox, layout.legend.bounds, gapTolerance)) {
|
|
210
|
+
violations.push(
|
|
211
|
+
`legend entry ${i} outside legend bounds: entry=${formatRect(entryBox)} bounds=${formatRect(layout.legend.bounds)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 3. X-axis label band doesn't intersect bottom chrome
|
|
218
|
+
const xExtent = layout.axes.x?.extent;
|
|
219
|
+
if (xExtent && xExtent > 0) {
|
|
220
|
+
const xAxisBand: Rect = {
|
|
221
|
+
x: layout.area.x,
|
|
222
|
+
y: layout.area.y + layout.area.height,
|
|
223
|
+
width: layout.area.width,
|
|
224
|
+
height: xExtent,
|
|
225
|
+
};
|
|
226
|
+
const bottomChromeKeys = ['source', 'byline', 'footer'] as const;
|
|
227
|
+
for (const key of bottomChromeKeys) {
|
|
228
|
+
const el = layout.chrome[key];
|
|
229
|
+
if (el) {
|
|
230
|
+
const h = el.style.fontSize * el.style.lineHeight;
|
|
231
|
+
const chromeBox: Rect = { x: el.x, y: el.y, width: el.maxWidth, height: h };
|
|
232
|
+
if (rectsOverlap(xAxisBand, chromeBox, 0.5)) {
|
|
233
|
+
violations.push(
|
|
234
|
+
`x-axis labels intersect ${key}: xAxis=${formatRect(xAxisBand)} ${key}=${formatRect(chromeBox)}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 4. All boxes stay within SVG viewport
|
|
242
|
+
const viewport: Rect = {
|
|
243
|
+
x: 0,
|
|
244
|
+
y: 0,
|
|
245
|
+
width: opts.svgWidth,
|
|
246
|
+
height: opts.svgHeight,
|
|
247
|
+
};
|
|
248
|
+
const allBoxes = extractBoxes(layout);
|
|
249
|
+
for (const box of allBoxes) {
|
|
250
|
+
if (!isContained(box, viewport, 2)) {
|
|
251
|
+
violations.push(
|
|
252
|
+
`${box.name} outside SVG viewport: box=${formatRect(box)} viewport=${formatRect(viewport)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. Reserved margins cover drawn content
|
|
258
|
+
if (layout.area.y < layout.chrome.topHeight - 1) {
|
|
259
|
+
violations.push(
|
|
260
|
+
`top chrome overflows into chart area: area.y=${layout.area.y} chrome.topHeight=${layout.chrome.topHeight}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return violations;
|
|
265
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ChartLayout, LineMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
3
|
+
import { describe, expect, test } from 'vitest';
|
|
4
|
+
import { compileChart } from '../compile';
|
|
5
|
+
|
|
6
|
+
describe('known layout bugs', () => {
|
|
7
|
+
test('annotation label does not overlap the line it annotates', () => {
|
|
8
|
+
// Generate ~25 smooth sine-wave data points over two years
|
|
9
|
+
const data: { date: string; value: number }[] = [];
|
|
10
|
+
const startDate = new Date('2022-01-01');
|
|
11
|
+
for (let i = 0; i < 25; i++) {
|
|
12
|
+
const d = new Date(startDate);
|
|
13
|
+
d.setDate(d.getDate() + i * 30); // ~monthly
|
|
14
|
+
// Sine wave: value ranges ~20-80 with a peak near i=6 (mid-2022)
|
|
15
|
+
const value = 50 + 30 * Math.sin((i / 25) * 2 * Math.PI);
|
|
16
|
+
data.push({ date: d.toISOString().slice(0, 10), value });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The peak is near i=6 (~July 2022), where sin peaks
|
|
20
|
+
const peakIndex = 6;
|
|
21
|
+
const peakDate = data[peakIndex].date;
|
|
22
|
+
const peakValue = data[peakIndex].value;
|
|
23
|
+
|
|
24
|
+
const spec = {
|
|
25
|
+
mark: 'line' as const,
|
|
26
|
+
data,
|
|
27
|
+
encoding: {
|
|
28
|
+
x: { field: 'date' as const, type: 'temporal' as const },
|
|
29
|
+
y: { field: 'value' as const, type: 'quantitative' as const },
|
|
30
|
+
},
|
|
31
|
+
annotations: [
|
|
32
|
+
{
|
|
33
|
+
type: 'text' as const,
|
|
34
|
+
x: peakDate,
|
|
35
|
+
y: peakValue,
|
|
36
|
+
text: 'Peak season',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const layout: ChartLayout = compileChart(spec, { width: 600, height: 400 });
|
|
42
|
+
|
|
43
|
+
// Find the annotation
|
|
44
|
+
const annotation = layout.annotations[0];
|
|
45
|
+
expect(annotation).toBeDefined();
|
|
46
|
+
expect(annotation.label).toBeDefined();
|
|
47
|
+
|
|
48
|
+
const label = annotation.label!;
|
|
49
|
+
const fontSize = label.style.fontSize;
|
|
50
|
+
const fontWeight = label.style.fontWeight;
|
|
51
|
+
|
|
52
|
+
// Estimate the label bounding box
|
|
53
|
+
const labelWidth = estimateTextWidth(label.text, fontSize, fontWeight);
|
|
54
|
+
const labelHeight = fontSize * 1.3;
|
|
55
|
+
|
|
56
|
+
// Label bounding box (y is the baseline, so top of text is y - fontSize)
|
|
57
|
+
const labelLeft =
|
|
58
|
+
label.style.textAnchor === 'middle'
|
|
59
|
+
? label.x - labelWidth / 2
|
|
60
|
+
: label.style.textAnchor === 'end'
|
|
61
|
+
? label.x - labelWidth
|
|
62
|
+
: label.x;
|
|
63
|
+
const labelRight = labelLeft + labelWidth;
|
|
64
|
+
const labelTop = label.y - fontSize;
|
|
65
|
+
const labelBottom = labelTop + labelHeight;
|
|
66
|
+
|
|
67
|
+
// Find the LineMark
|
|
68
|
+
const lineMark = layout.marks.find((m): m is LineMark => m.type === 'line');
|
|
69
|
+
expect(lineMark).toBeDefined();
|
|
70
|
+
|
|
71
|
+
const points = lineMark!.points;
|
|
72
|
+
|
|
73
|
+
// Find line segments that fall within the label's x-range
|
|
74
|
+
let lineMinY = Infinity;
|
|
75
|
+
let lineMaxY = -Infinity;
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
78
|
+
const p1 = points[i];
|
|
79
|
+
const p2 = points[i + 1];
|
|
80
|
+
|
|
81
|
+
// Check if this segment overlaps the label's x-range
|
|
82
|
+
const segLeft = Math.min(p1.x, p2.x);
|
|
83
|
+
const segRight = Math.max(p1.x, p2.x);
|
|
84
|
+
|
|
85
|
+
if (segRight >= labelLeft && segLeft <= labelRight) {
|
|
86
|
+
// This segment is within the label's x-range
|
|
87
|
+
// Find the y-range of the line within the label's x-span
|
|
88
|
+
const overlapLeft = Math.max(segLeft, labelLeft);
|
|
89
|
+
const overlapRight = Math.min(segRight, labelRight);
|
|
90
|
+
|
|
91
|
+
// Interpolate y values at the overlap boundaries
|
|
92
|
+
const segWidth = p2.x - p1.x;
|
|
93
|
+
if (Math.abs(segWidth) < 0.001) {
|
|
94
|
+
lineMinY = Math.min(lineMinY, p1.y, p2.y);
|
|
95
|
+
lineMaxY = Math.max(lineMaxY, p1.y, p2.y);
|
|
96
|
+
} else {
|
|
97
|
+
const t1 = (overlapLeft - p1.x) / segWidth;
|
|
98
|
+
const t2 = (overlapRight - p1.x) / segWidth;
|
|
99
|
+
const y1 = p1.y + t1 * (p2.y - p1.y);
|
|
100
|
+
const y2 = p1.y + t2 * (p2.y - p1.y);
|
|
101
|
+
lineMinY = Math.min(lineMinY, y1, y2);
|
|
102
|
+
lineMaxY = Math.max(lineMaxY, y1, y2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The label bounding box should NOT vertically overlap with the line's y-range.
|
|
108
|
+
// Today the label sits only ANCHOR_OFFSET (8px) from its anchor point ON the line,
|
|
109
|
+
// so the label box almost certainly overlaps the line path.
|
|
110
|
+
const verticallyOverlaps = labelTop < lineMaxY && labelBottom > lineMinY;
|
|
111
|
+
expect(verticallyOverlaps).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|