@opendata-ai/openchart-engine 7.2.3 → 7.3.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 +84 -36
- package/dist/index.js +4013 -2872
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
- package/src/__tests__/axes.test.ts +67 -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 +7 -2
- package/src/__tests__/helpers/invariants.ts +267 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +342 -0
- package/src/__tests__/layout-invariants.test.ts +533 -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 +67 -0
- package/src/charts/bar/compute.ts +8 -1
- package/src/charts/post-process.ts +21 -7
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +85 -148
- package/src/index.ts +9 -0
- package/src/layout/__tests__/metrics.test.ts +97 -0
- package/src/layout/axes.ts +93 -1
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +524 -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
|
@@ -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
|
});
|
|
@@ -0,0 +1,267 @@
|
|
|
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: y interpretation depends on dominantBaseline.
|
|
95
|
+
// 'hanging' = y is top of text; 'auto' = y is baseline (ascender ~80% of fontSize).
|
|
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
|
+
const top = el.style.dominantBaseline === 'hanging' ? el.y : el.y - el.style.fontSize * 0.8;
|
|
101
|
+
boxes.push({ x: el.x, y: top, width: el.maxWidth, height: h, name: key });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Annotation bounds
|
|
106
|
+
for (let i = 0; i < layout.annotations.length; i++) {
|
|
107
|
+
const annotation = layout.annotations[i];
|
|
108
|
+
if (annotation.bounds) {
|
|
109
|
+
boxes.push({ ...annotation.bounds, name: `annotation-${i}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Endpoint labels
|
|
114
|
+
if (layout.endpointLabels?.bounds) {
|
|
115
|
+
boxes.push({ ...layout.endpointLabels.bounds, name: 'endpoint-labels' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return boxes;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Overlap check
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Throws if two rects overlap by more than epsilon pixels.
|
|
127
|
+
*/
|
|
128
|
+
export function assertNoOverlap(a: NamedRect, b: NamedRect, epsilon = 0.5): void {
|
|
129
|
+
const overlaps =
|
|
130
|
+
a.x < b.x + b.width - epsilon &&
|
|
131
|
+
a.x + a.width > b.x + epsilon &&
|
|
132
|
+
a.y < b.y + b.height - epsilon &&
|
|
133
|
+
a.y + a.height > b.y + epsilon;
|
|
134
|
+
|
|
135
|
+
if (overlaps) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Overlap detected between "${a.name}" and "${b.name}": ` +
|
|
138
|
+
`a=${JSON.stringify({ x: a.x, y: a.y, width: a.width, height: a.height })}, ` +
|
|
139
|
+
`b=${JSON.stringify({ x: b.x, y: b.y, width: b.width, height: b.height })}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Containment helper
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
function isContained(inner: Rect, outer: Rect, tolerance: number): boolean {
|
|
149
|
+
return (
|
|
150
|
+
inner.x >= outer.x - tolerance &&
|
|
151
|
+
inner.y >= outer.y - tolerance &&
|
|
152
|
+
inner.x + inner.width <= outer.x + outer.width + tolerance &&
|
|
153
|
+
inner.y + inner.height <= outer.y + outer.height + tolerance
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rectsOverlap(a: Rect, b: Rect, epsilon: number): boolean {
|
|
158
|
+
return (
|
|
159
|
+
a.x < b.x + b.width - epsilon &&
|
|
160
|
+
a.x + a.width > b.x + epsilon &&
|
|
161
|
+
a.y < b.y + b.height - epsilon &&
|
|
162
|
+
a.y + a.height > b.y + epsilon
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatRect(r: Rect): string {
|
|
167
|
+
return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Full invariant check
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Returns a list of violation strings. Empty when clean.
|
|
176
|
+
*/
|
|
177
|
+
export function checkLayoutInvariants(
|
|
178
|
+
layout: ChartLayout,
|
|
179
|
+
opts: { svgWidth: number; svgHeight: number },
|
|
180
|
+
): string[] {
|
|
181
|
+
const violations: string[] = [];
|
|
182
|
+
|
|
183
|
+
// 1. Legend doesn't intrude into chartArea (top/bottom only; right/bottom-right
|
|
184
|
+
// use area.width reduction, so overlap is a horizontal concern checked by rule 4)
|
|
185
|
+
if (isCategoricalLegend(layout.legend)) {
|
|
186
|
+
const pos = layout.legend.position;
|
|
187
|
+
if (pos === 'top' || pos === 'bottom') {
|
|
188
|
+
if (rectsOverlap(layout.legend.bounds, layout.area, 0.5)) {
|
|
189
|
+
violations.push(
|
|
190
|
+
`legend intrudes into chartArea: legend=${formatRect(layout.legend.bounds)} chartArea=${formatRect(layout.area)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2. Every legend entryPosition box lies inside legend bounds
|
|
197
|
+
if (isCategoricalLegend(layout.legend) && layout.legend.entryPositions) {
|
|
198
|
+
const rowHeight = layout.legend.rowHeight ?? 16;
|
|
199
|
+
// Entry width includes trailing entryGap; the last entry in a row doesn't
|
|
200
|
+
// need that gap to fit inside bounds, so allow entryGap as extra tolerance.
|
|
201
|
+
const gapTolerance = layout.legend.entryGap + 1;
|
|
202
|
+
for (let i = 0; i < layout.legend.entryPositions.length; i++) {
|
|
203
|
+
const pos = layout.legend.entryPositions[i];
|
|
204
|
+
const entryBox: Rect = {
|
|
205
|
+
x: pos.x,
|
|
206
|
+
y: pos.y,
|
|
207
|
+
width: pos.width,
|
|
208
|
+
height: rowHeight,
|
|
209
|
+
};
|
|
210
|
+
if (!isContained(entryBox, layout.legend.bounds, gapTolerance)) {
|
|
211
|
+
violations.push(
|
|
212
|
+
`legend entry ${i} outside legend bounds: entry=${formatRect(entryBox)} bounds=${formatRect(layout.legend.bounds)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. X-axis label band doesn't intersect bottom chrome
|
|
219
|
+
const xExtent = layout.axes.x?.extent;
|
|
220
|
+
if (xExtent && xExtent > 0) {
|
|
221
|
+
const xAxisBand: Rect = {
|
|
222
|
+
x: layout.area.x,
|
|
223
|
+
y: layout.area.y + layout.area.height,
|
|
224
|
+
width: layout.area.width,
|
|
225
|
+
height: xExtent,
|
|
226
|
+
};
|
|
227
|
+
const bottomChromeKeys = ['source', 'byline', 'footer'] as const;
|
|
228
|
+
for (const key of bottomChromeKeys) {
|
|
229
|
+
const el = layout.chrome[key];
|
|
230
|
+
if (el) {
|
|
231
|
+
const h = el.style.fontSize * el.style.lineHeight;
|
|
232
|
+
const top = el.style.dominantBaseline === 'hanging' ? el.y : el.y - el.style.fontSize * 0.8;
|
|
233
|
+
const chromeBox: Rect = { x: el.x, y: top, width: el.maxWidth, height: h };
|
|
234
|
+
if (rectsOverlap(xAxisBand, chromeBox, 0.5)) {
|
|
235
|
+
violations.push(
|
|
236
|
+
`x-axis labels intersect ${key}: xAxis=${formatRect(xAxisBand)} ${key}=${formatRect(chromeBox)}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 4. All boxes stay within SVG viewport
|
|
244
|
+
const viewport: Rect = {
|
|
245
|
+
x: 0,
|
|
246
|
+
y: 0,
|
|
247
|
+
width: opts.svgWidth,
|
|
248
|
+
height: opts.svgHeight,
|
|
249
|
+
};
|
|
250
|
+
const allBoxes = extractBoxes(layout);
|
|
251
|
+
for (const box of allBoxes) {
|
|
252
|
+
if (!isContained(box, viewport, 2)) {
|
|
253
|
+
violations.push(
|
|
254
|
+
`${box.name} outside SVG viewport: box=${formatRect(box)} viewport=${formatRect(viewport)}`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 5. Reserved margins cover drawn content
|
|
260
|
+
if (layout.area.y < layout.chrome.topHeight - 1) {
|
|
261
|
+
violations.push(
|
|
262
|
+
`top chrome overflows into chart area: area.y=${layout.area.y} chrome.topHeight=${layout.chrome.topHeight}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return violations;
|
|
267
|
+
}
|
|
@@ -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
|
+
});
|