@opendata-ai/openchart-engine 2.10.0 → 2.12.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.js +182 -48
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +179 -2
- package/src/annotations/__tests__/compute.test.ts +173 -4
- package/src/annotations/compute.ts +158 -41
- package/src/charts/column/__tests__/labels.test.ts +104 -0
- package/src/charts/dot/__tests__/labels.test.ts +98 -0
- package/src/charts/pie/__tests__/labels.test.ts +132 -0
- package/src/compile.ts +58 -10
- package/src/layout/axes.ts +114 -15
- package/src/legend/compute.ts +5 -4
- package/src/tooltips/compute.ts +5 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { RectMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computeColumnLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 300 };
|
|
10
|
+
|
|
11
|
+
function makeMark(index: number, value: number): RectMark {
|
|
12
|
+
const height = Math.abs(value) * 5;
|
|
13
|
+
const y = value >= 0 ? 300 - height : 300;
|
|
14
|
+
return {
|
|
15
|
+
type: 'rect',
|
|
16
|
+
x: index * 80,
|
|
17
|
+
y,
|
|
18
|
+
width: 60,
|
|
19
|
+
height,
|
|
20
|
+
fill: '#4e79a7',
|
|
21
|
+
data: { category: `Cat${index}`, value },
|
|
22
|
+
aria: { label: `Cat${index}: ${value}` },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const marks: RectMark[] = [
|
|
27
|
+
makeMark(0, 10),
|
|
28
|
+
makeMark(1, 20),
|
|
29
|
+
makeMark(2, 30),
|
|
30
|
+
makeMark(3, 40),
|
|
31
|
+
makeMark(4, 50),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Tests
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
describe('computeColumnLabels density modes', () => {
|
|
39
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
40
|
+
const labels = computeColumnLabels(marks, chartArea, 'auto');
|
|
41
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
42
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('density "all" shows every label as visible', () => {
|
|
46
|
+
const labels = computeColumnLabels(marks, chartArea, 'all');
|
|
47
|
+
expect(labels).toHaveLength(marks.length);
|
|
48
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('density "none" returns empty array', () => {
|
|
52
|
+
const labels = computeColumnLabels(marks, chartArea, 'none');
|
|
53
|
+
expect(labels).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
57
|
+
const labels = computeColumnLabels(marks, chartArea, 'endpoints');
|
|
58
|
+
expect(labels).toHaveLength(2);
|
|
59
|
+
expect(labels[0].text).toBe('10');
|
|
60
|
+
expect(labels[1].text).toBe('50');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
64
|
+
const labels = computeColumnLabels([marks[0]], chartArea, 'endpoints');
|
|
65
|
+
expect(labels).toHaveLength(1);
|
|
66
|
+
expect(labels[0].text).toBe('10');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('default density is "auto"', () => {
|
|
70
|
+
const withAuto = computeColumnLabels(marks, chartArea, 'auto');
|
|
71
|
+
const withDefault = computeColumnLabels(marks, chartArea);
|
|
72
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('computeColumnLabels positioning', () => {
|
|
77
|
+
it('places positive value labels above the column', () => {
|
|
78
|
+
const labels = computeColumnLabels([makeMark(0, 20)], chartArea, 'all');
|
|
79
|
+
expect(labels).toHaveLength(1);
|
|
80
|
+
const mark = makeMark(0, 20);
|
|
81
|
+
// Label y should be above the column top
|
|
82
|
+
expect(labels[0].y).toBeLessThan(mark.y);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('places negative value labels below the column', () => {
|
|
86
|
+
const negativeMark = makeMark(0, -15);
|
|
87
|
+
const labels = computeColumnLabels([negativeMark], chartArea, 'all');
|
|
88
|
+
expect(labels).toHaveLength(1);
|
|
89
|
+
// Label y should be below the column bottom
|
|
90
|
+
expect(labels[0].y).toBeGreaterThan(negativeMark.y + negativeMark.height);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('centers labels horizontally on the column', () => {
|
|
94
|
+
const labels = computeColumnLabels([makeMark(0, 20)], chartArea, 'all');
|
|
95
|
+
const mark = makeMark(0, 20);
|
|
96
|
+
const markCenter = mark.x + mark.width / 2;
|
|
97
|
+
expect(labels[0].x).toBe(markCenter);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('applies labelFormat to numeric values', () => {
|
|
101
|
+
const labels = computeColumnLabels([makeMark(0, 1234)], chartArea, 'all', ',.0f');
|
|
102
|
+
expect(labels[0].text).toBe('1,234');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { PointMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computeDotLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 300 };
|
|
10
|
+
|
|
11
|
+
function makeMark(index: number, value: number): PointMark {
|
|
12
|
+
return {
|
|
13
|
+
type: 'point',
|
|
14
|
+
cx: value * 5,
|
|
15
|
+
cy: index * 40 + 20,
|
|
16
|
+
r: 6,
|
|
17
|
+
fill: '#4e79a7',
|
|
18
|
+
stroke: '#4e79a7',
|
|
19
|
+
strokeWidth: 1,
|
|
20
|
+
data: { category: `Cat${index}`, value },
|
|
21
|
+
aria: { label: `Cat${index}: ${value}` },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const marks: PointMark[] = [
|
|
26
|
+
makeMark(0, 10),
|
|
27
|
+
makeMark(1, 20),
|
|
28
|
+
makeMark(2, 30),
|
|
29
|
+
makeMark(3, 40),
|
|
30
|
+
makeMark(4, 50),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tests
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
describe('computeDotLabels density modes', () => {
|
|
38
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
39
|
+
const labels = computeDotLabels(marks, chartArea, 'auto');
|
|
40
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
41
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('density "all" shows every label as visible', () => {
|
|
45
|
+
const labels = computeDotLabels(marks, chartArea, 'all');
|
|
46
|
+
expect(labels).toHaveLength(marks.length);
|
|
47
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('density "none" returns empty array', () => {
|
|
51
|
+
const labels = computeDotLabels(marks, chartArea, 'none');
|
|
52
|
+
expect(labels).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
56
|
+
const labels = computeDotLabels(marks, chartArea, 'endpoints');
|
|
57
|
+
expect(labels).toHaveLength(2);
|
|
58
|
+
expect(labels[0].text).toBe('10');
|
|
59
|
+
expect(labels[1].text).toBe('50');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
63
|
+
const labels = computeDotLabels([marks[0]], chartArea, 'endpoints');
|
|
64
|
+
expect(labels).toHaveLength(1);
|
|
65
|
+
expect(labels[0].text).toBe('10');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('default density is "auto"', () => {
|
|
69
|
+
const withAuto = computeDotLabels(marks, chartArea, 'auto');
|
|
70
|
+
const withDefault = computeDotLabels(marks, chartArea);
|
|
71
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('computeDotLabels positioning', () => {
|
|
76
|
+
it('places labels to the right of the dot', () => {
|
|
77
|
+
const labels = computeDotLabels([marks[0]], chartArea, 'all');
|
|
78
|
+
expect(labels).toHaveLength(1);
|
|
79
|
+
// Label x should be to the right of the dot center + radius
|
|
80
|
+
expect(labels[0].x).toBeGreaterThan(marks[0].cx + marks[0].r);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('vertically centers labels on the dot', () => {
|
|
84
|
+
const labels = computeDotLabels([marks[0]], chartArea, 'all');
|
|
85
|
+
const textHeight = 11 * 1.2; // LABEL_FONT_SIZE * 1.2
|
|
86
|
+
// Label y should be roughly centered on the dot's cy
|
|
87
|
+
expect(labels[0].y).toBeCloseTo(marks[0].cy - textHeight / 2, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns empty for marks with no parseable value', () => {
|
|
91
|
+
const badMark: PointMark = {
|
|
92
|
+
...marks[0],
|
|
93
|
+
aria: { label: 'no-colon-here' },
|
|
94
|
+
};
|
|
95
|
+
const labels = computeDotLabels([badMark], chartArea, 'all');
|
|
96
|
+
expect(labels).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ArcMark } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { computePieLabels } from '../labels';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const chartArea = { x: 0, y: 0, width: 400, height: 400 };
|
|
10
|
+
const center = { x: 200, y: 200 };
|
|
11
|
+
const outerRadius = 150;
|
|
12
|
+
|
|
13
|
+
function makeArc(category: string, value: number, startAngle: number, endAngle: number): ArcMark {
|
|
14
|
+
const midAngle = (startAngle + endAngle) / 2;
|
|
15
|
+
const centroidRadius = outerRadius * 0.6;
|
|
16
|
+
return {
|
|
17
|
+
type: 'arc',
|
|
18
|
+
path: '', // SVG path not needed for label computation
|
|
19
|
+
centroid: {
|
|
20
|
+
x: center.x + Math.sin(midAngle) * centroidRadius,
|
|
21
|
+
y: center.y - Math.cos(midAngle) * centroidRadius,
|
|
22
|
+
},
|
|
23
|
+
center,
|
|
24
|
+
innerRadius: 0,
|
|
25
|
+
outerRadius,
|
|
26
|
+
startAngle,
|
|
27
|
+
endAngle,
|
|
28
|
+
fill: '#4e79a7',
|
|
29
|
+
stroke: '#ffffff',
|
|
30
|
+
strokeWidth: 2,
|
|
31
|
+
data: { category, value },
|
|
32
|
+
aria: { label: `${category}: ${value} (${Math.round(value)}%)` },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Three slices: top-right, bottom-right, left
|
|
37
|
+
const marks: ArcMark[] = [
|
|
38
|
+
makeArc('Alpha', 50, 0, Math.PI * 0.8),
|
|
39
|
+
makeArc('Beta', 30, Math.PI * 0.8, Math.PI * 1.4),
|
|
40
|
+
makeArc('Gamma', 20, Math.PI * 1.4, Math.PI * 2),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe('computePieLabels density modes', () => {
|
|
48
|
+
it('density "auto" runs collision detection and produces labels', () => {
|
|
49
|
+
const labels = computePieLabels(marks, chartArea, 'auto');
|
|
50
|
+
expect(labels.length).toBeGreaterThan(0);
|
|
51
|
+
expect(labels.every((l) => typeof l.visible === 'boolean')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('density "all" shows every label as visible', () => {
|
|
55
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
56
|
+
expect(labels).toHaveLength(marks.length);
|
|
57
|
+
expect(labels.every((l) => l.visible === true)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('density "none" returns empty array', () => {
|
|
61
|
+
const labels = computePieLabels(marks, chartArea, 'none');
|
|
62
|
+
expect(labels).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('density "endpoints" returns only first and last labels', () => {
|
|
66
|
+
const labels = computePieLabels(marks, chartArea, 'endpoints');
|
|
67
|
+
expect(labels).toHaveLength(2);
|
|
68
|
+
expect(labels[0].text).toBe('Alpha');
|
|
69
|
+
expect(labels[1].text).toBe('Gamma');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('density "endpoints" with single mark returns that mark', () => {
|
|
73
|
+
const labels = computePieLabels([marks[0]], chartArea, 'endpoints');
|
|
74
|
+
expect(labels).toHaveLength(1);
|
|
75
|
+
expect(labels[0].text).toBe('Alpha');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('default density is "auto"', () => {
|
|
79
|
+
const withAuto = computePieLabels(marks, chartArea, 'auto');
|
|
80
|
+
const withDefault = computePieLabels(marks, chartArea);
|
|
81
|
+
expect(withDefault.length).toBe(withAuto.length);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns empty for empty marks array', () => {
|
|
85
|
+
const labels = computePieLabels([], chartArea, 'all');
|
|
86
|
+
expect(labels).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('computePieLabels positioning', () => {
|
|
91
|
+
it('labels use category name (not value) as text', () => {
|
|
92
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
93
|
+
expect(labels[0].text).toBe('Alpha');
|
|
94
|
+
expect(labels[1].text).toBe('Beta');
|
|
95
|
+
expect(labels[2].text).toBe('Gamma');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('labels are positioned outside the outer radius', () => {
|
|
99
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
100
|
+
for (const label of labels) {
|
|
101
|
+
const dx = label.x - center.x;
|
|
102
|
+
const dy = label.y - center.y;
|
|
103
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
104
|
+
// Label should be at least at the outer radius distance from center
|
|
105
|
+
// (accounting for text width offset, the anchor point may vary)
|
|
106
|
+
expect(dist).toBeGreaterThan(outerRadius * 0.5);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('visible labels have connector lines to centroid', () => {
|
|
111
|
+
const labels = computePieLabels(marks, chartArea, 'all');
|
|
112
|
+
const visibleLabels = labels.filter((l) => l.visible);
|
|
113
|
+
for (const label of visibleLabels) {
|
|
114
|
+
expect(label.connector).toBeDefined();
|
|
115
|
+
expect(label.connector!.from).toEqual({ x: label.x, y: label.y });
|
|
116
|
+
expect(label.connector!.to).toBeDefined();
|
|
117
|
+
expect(label.connector!.stroke).toBeDefined();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('right-side labels use "start" text anchor', () => {
|
|
122
|
+
// First mark (0 to 0.8*PI) has midAngle ~0.4*PI, sin > 0 => right side
|
|
123
|
+
const labels = computePieLabels([marks[0]], chartArea, 'all');
|
|
124
|
+
expect(labels[0].style.textAnchor).toBe('start');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('left-side labels use "end" text anchor', () => {
|
|
128
|
+
// Third mark (1.4*PI to 2*PI) has midAngle ~1.7*PI, sin(1.7*PI) < 0 => left side
|
|
129
|
+
const labels = computePieLabels([marks[2]], chartArea, 'all');
|
|
130
|
+
expect(labels[0].style.textAnchor).toBe('end');
|
|
131
|
+
});
|
|
132
|
+
});
|
package/src/compile.ts
CHANGED
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
} from '@opendata-ai/openchart-core';
|
|
26
26
|
import {
|
|
27
27
|
adaptTheme,
|
|
28
|
+
BRAND_RESERVE_WIDTH,
|
|
29
|
+
computeLabelBounds,
|
|
28
30
|
generateAltText,
|
|
29
31
|
generateDataTable,
|
|
30
32
|
getBreakpoint,
|
|
@@ -75,14 +77,41 @@ import { computeTooltipDescriptors } from './tooltips/compute';
|
|
|
75
77
|
// ---------------------------------------------------------------------------
|
|
76
78
|
|
|
77
79
|
/**
|
|
78
|
-
* Compute
|
|
79
|
-
*
|
|
80
|
-
*
|
|
80
|
+
* Compute bounding rects from marks to use as obstacles for annotation nudging.
|
|
81
|
+
*
|
|
82
|
+
* For band-scale charts (bar, dot): groups marks by band row and returns
|
|
83
|
+
* a single obstacle per row spanning the full band height and x-range.
|
|
84
|
+
*
|
|
85
|
+
* For other charts (column, scatter): returns individual mark bounds so
|
|
86
|
+
* annotations avoid overlapping any visible data mark.
|
|
81
87
|
*/
|
|
82
|
-
function
|
|
83
|
-
|
|
88
|
+
function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
89
|
+
// Band-scale y-axis: group marks by row for efficient obstacle computation
|
|
90
|
+
if (scales.y?.type === 'band') {
|
|
91
|
+
return computeBandRowObstacles(marks, scales);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// All other charts: use individual rect/point mark bounds as obstacles
|
|
95
|
+
const obstacles: Rect[] = [];
|
|
96
|
+
for (const mark of marks) {
|
|
97
|
+
if (mark.type === 'rect') {
|
|
98
|
+
const rm = mark as RectMark;
|
|
99
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
100
|
+
} else if (mark.type === 'point') {
|
|
101
|
+
const pm = mark as PointMark;
|
|
102
|
+
obstacles.push({
|
|
103
|
+
x: pm.cx - pm.r,
|
|
104
|
+
y: pm.cy - pm.r,
|
|
105
|
+
width: pm.r * 2,
|
|
106
|
+
height: pm.r * 2,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return obstacles;
|
|
111
|
+
}
|
|
84
112
|
|
|
85
|
-
|
|
113
|
+
/** Group band-scale marks by row, returning one obstacle per band. */
|
|
114
|
+
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
86
115
|
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
87
116
|
|
|
88
117
|
for (const mark of marks) {
|
|
@@ -116,7 +145,7 @@ function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
|
116
145
|
}
|
|
117
146
|
|
|
118
147
|
// Get bandwidth from the band scale
|
|
119
|
-
const bandScale = scales.y
|
|
148
|
+
const bandScale = scales.y!.scale as { bandwidth?: () => number };
|
|
120
149
|
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
121
150
|
if (bandwidth === 0) return [];
|
|
122
151
|
|
|
@@ -315,7 +344,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
315
344
|
// Compute axes (skip for radial charts)
|
|
316
345
|
const axes = isRadial
|
|
317
346
|
? { x: undefined, y: undefined }
|
|
318
|
-
: computeAxes(scales, chartArea, strategy, theme);
|
|
347
|
+
: computeAxes(scales, chartArea, strategy, theme, options.measureText);
|
|
319
348
|
|
|
320
349
|
// Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
|
|
321
350
|
if (!isRadial) {
|
|
@@ -326,12 +355,31 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
326
355
|
const renderer = getChartRenderer(renderSpec.type);
|
|
327
356
|
const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
328
357
|
|
|
329
|
-
// Compute annotations from spec, passing legend + mark bounds as obstacles
|
|
358
|
+
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
330
359
|
const obstacles: Rect[] = [];
|
|
331
360
|
if (finalLegend.bounds.width > 0) {
|
|
332
361
|
obstacles.push(finalLegend.bounds);
|
|
333
362
|
}
|
|
334
|
-
obstacles.push(...
|
|
363
|
+
obstacles.push(...computeMarkObstacles(marks, scales));
|
|
364
|
+
|
|
365
|
+
// Add visible data label bounds as obstacles so annotations avoid overlapping them
|
|
366
|
+
for (const mark of marks) {
|
|
367
|
+
if (mark.type !== 'area' && mark.label?.visible) {
|
|
368
|
+
obstacles.push(computeLabelBounds(mark.label));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Add brand watermark as an obstacle so annotations avoid overlapping it.
|
|
373
|
+
// The brand is right-aligned on the same baseline as the first bottom chrome element,
|
|
374
|
+
// offset below the chart area by x-axis extent (tick labels + axis title).
|
|
375
|
+
const brandPadding = theme.spacing.padding;
|
|
376
|
+
const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH;
|
|
377
|
+
const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
|
|
378
|
+
const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
|
|
379
|
+
const brandY = firstBottomChrome
|
|
380
|
+
? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y
|
|
381
|
+
: chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
|
|
382
|
+
obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH, height: 30 });
|
|
335
383
|
const annotations: ResolvedAnnotation[] = computeAnnotations(
|
|
336
384
|
chartSpec,
|
|
337
385
|
scales,
|
package/src/layout/axes.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
AxisTick,
|
|
12
12
|
Gridline,
|
|
13
13
|
LayoutStrategy,
|
|
14
|
+
MeasureTextFn,
|
|
14
15
|
Rect,
|
|
15
16
|
ResolvedTheme,
|
|
16
17
|
TextStyle,
|
|
@@ -57,6 +58,15 @@ const HEIGHT_REDUCED_THRESHOLD = 200;
|
|
|
57
58
|
const WIDTH_MINIMAL_THRESHOLD = 150;
|
|
58
59
|
const WIDTH_REDUCED_THRESHOLD = 300;
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Minimum gap between adjacent tick labels as a multiple of font size.
|
|
63
|
+
* At the default 12px axis font, this yields ~12px of breathing room.
|
|
64
|
+
*/
|
|
65
|
+
const MIN_TICK_GAP_FACTOR = 1.0;
|
|
66
|
+
|
|
67
|
+
/** Always show at least this many ticks, even if they overlap. */
|
|
68
|
+
const MIN_TICK_COUNT = 2;
|
|
69
|
+
|
|
60
70
|
/** Ordered densities from most to fewest ticks. */
|
|
61
71
|
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
|
|
62
72
|
|
|
@@ -95,25 +105,106 @@ export function effectiveDensity(
|
|
|
95
105
|
return density;
|
|
96
106
|
}
|
|
97
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Label overlap detection and thinning
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/** Measure a single label's width using real measurement or heuristic fallback. */
|
|
113
|
+
function measureLabel(
|
|
114
|
+
text: string,
|
|
115
|
+
fontSize: number,
|
|
116
|
+
fontWeight: number,
|
|
117
|
+
measureText?: MeasureTextFn,
|
|
118
|
+
): number {
|
|
119
|
+
return measureText
|
|
120
|
+
? measureText(text, fontSize, fontWeight).width
|
|
121
|
+
: estimateTextWidth(text, fontSize, fontWeight);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Check whether any adjacent tick labels overlap horizontally. */
|
|
125
|
+
export function ticksOverlap(
|
|
126
|
+
ticks: AxisTick[],
|
|
127
|
+
fontSize: number,
|
|
128
|
+
fontWeight: number,
|
|
129
|
+
measureText?: MeasureTextFn,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (ticks.length < 2) return false;
|
|
132
|
+
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
133
|
+
for (let i = 0; i < ticks.length - 1; i++) {
|
|
134
|
+
const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
|
|
135
|
+
const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
|
|
136
|
+
const aRight = ticks[i].position + aWidth / 2;
|
|
137
|
+
const bLeft = ticks[i + 1].position - bWidth / 2;
|
|
138
|
+
if (aRight + minGap > bLeft) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Thin a tick array by removing every other tick until labels don't overlap.
|
|
145
|
+
* Always keeps first and last tick. O(log n) iterations max.
|
|
146
|
+
* Returns the original array if no thinning is needed.
|
|
147
|
+
*/
|
|
148
|
+
export function thinTicksUntilFit(
|
|
149
|
+
ticks: AxisTick[],
|
|
150
|
+
fontSize: number,
|
|
151
|
+
fontWeight: number,
|
|
152
|
+
measureText?: MeasureTextFn,
|
|
153
|
+
): AxisTick[] {
|
|
154
|
+
if (!ticksOverlap(ticks, fontSize, fontWeight, measureText)) return ticks;
|
|
155
|
+
|
|
156
|
+
let current = ticks;
|
|
157
|
+
while (current.length > MIN_TICK_COUNT) {
|
|
158
|
+
// Keep first, last, and every other tick in between
|
|
159
|
+
const thinned = [current[0]];
|
|
160
|
+
for (let i = 2; i < current.length - 1; i += 2) {
|
|
161
|
+
thinned.push(current[i]);
|
|
162
|
+
}
|
|
163
|
+
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
164
|
+
current = thinned;
|
|
165
|
+
|
|
166
|
+
if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
|
|
167
|
+
}
|
|
168
|
+
return current;
|
|
169
|
+
}
|
|
170
|
+
|
|
98
171
|
// ---------------------------------------------------------------------------
|
|
99
172
|
// Tick generation
|
|
100
173
|
// ---------------------------------------------------------------------------
|
|
101
174
|
|
|
102
175
|
/** Generate ticks for a continuous scale (linear, time, log). */
|
|
103
|
-
function continuousTicks(
|
|
176
|
+
function continuousTicks(
|
|
177
|
+
resolvedScale: ResolvedScale,
|
|
178
|
+
density: AxisLabelDensity,
|
|
179
|
+
fontSize: number,
|
|
180
|
+
fontWeight: number,
|
|
181
|
+
measureText?: MeasureTextFn,
|
|
182
|
+
): AxisTick[] {
|
|
104
183
|
const scale = resolvedScale.scale as D3ContinuousScale;
|
|
105
|
-
const
|
|
106
|
-
const
|
|
184
|
+
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
185
|
+
const count = explicitCount ?? TICK_COUNTS[density];
|
|
186
|
+
const rawTicks: unknown[] = scale.ticks(count);
|
|
107
187
|
|
|
108
|
-
|
|
188
|
+
const ticks = rawTicks.map((value: unknown) => ({
|
|
109
189
|
value,
|
|
110
190
|
position: scale(value as number & Date) as number,
|
|
111
191
|
label: formatTickLabel(value, resolvedScale),
|
|
112
192
|
}));
|
|
193
|
+
|
|
194
|
+
// Respect explicit tickCount: user asked for this many, don't override
|
|
195
|
+
if (explicitCount) return ticks;
|
|
196
|
+
|
|
197
|
+
return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
|
|
113
198
|
}
|
|
114
199
|
|
|
115
200
|
/** Generate ticks for a band/point/ordinal scale. */
|
|
116
|
-
function categoricalTicks(
|
|
201
|
+
function categoricalTicks(
|
|
202
|
+
resolvedScale: ResolvedScale,
|
|
203
|
+
density: AxisLabelDensity,
|
|
204
|
+
fontSize: number,
|
|
205
|
+
fontWeight: number,
|
|
206
|
+
measureText?: MeasureTextFn,
|
|
207
|
+
): AxisTick[] {
|
|
117
208
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
118
209
|
const domain: string[] = scale.domain();
|
|
119
210
|
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
@@ -127,7 +218,7 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
|
|
|
127
218
|
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
128
219
|
}
|
|
129
220
|
|
|
130
|
-
|
|
221
|
+
const ticks = selectedValues.map((value: string) => {
|
|
131
222
|
// Band scales: use the center of the band
|
|
132
223
|
const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
|
|
133
224
|
const pos = bandScale
|
|
@@ -140,6 +231,13 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
|
|
|
140
231
|
label: value,
|
|
141
232
|
};
|
|
142
233
|
});
|
|
234
|
+
|
|
235
|
+
// For non-band scales without explicit tickCount, thin based on label width
|
|
236
|
+
if (resolvedScale.type !== 'band' && !explicitTickCount) {
|
|
237
|
+
return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return ticks;
|
|
143
241
|
}
|
|
144
242
|
|
|
145
243
|
/** Format a tick value based on the scale type. */
|
|
@@ -182,12 +280,14 @@ export interface AxesResult {
|
|
|
182
280
|
* @param chartArea - The chart drawing area.
|
|
183
281
|
* @param strategy - Responsive layout strategy.
|
|
184
282
|
* @param theme - Resolved theme for styling.
|
|
283
|
+
* @param measureText - Optional real text measurement from the adapter.
|
|
185
284
|
*/
|
|
186
285
|
export function computeAxes(
|
|
187
286
|
scales: ResolvedScales,
|
|
188
287
|
chartArea: Rect,
|
|
189
288
|
strategy: LayoutStrategy,
|
|
190
289
|
theme: ResolvedTheme,
|
|
290
|
+
measureText?: MeasureTextFn,
|
|
191
291
|
): AxesResult {
|
|
192
292
|
const result: AxesResult = {};
|
|
193
293
|
const baseDensity = strategy.axisLabelDensity;
|
|
@@ -224,11 +324,14 @@ export function computeAxes(
|
|
|
224
324
|
lineHeight: 1.3,
|
|
225
325
|
};
|
|
226
326
|
|
|
327
|
+
const { fontSize } = tickLabelStyle;
|
|
328
|
+
const { fontWeight } = tickLabelStyle;
|
|
329
|
+
|
|
227
330
|
if (scales.x) {
|
|
228
331
|
const ticks =
|
|
229
332
|
scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
|
|
230
|
-
? categoricalTicks(scales.x, xDensity)
|
|
231
|
-
: continuousTicks(scales.x, xDensity);
|
|
333
|
+
? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText)
|
|
334
|
+
: continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
|
|
232
335
|
|
|
233
336
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
234
337
|
position: t.position,
|
|
@@ -242,11 +345,7 @@ export function computeAxes(
|
|
|
242
345
|
const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
|
|
243
346
|
let maxLabelWidth = 0;
|
|
244
347
|
for (const t of ticks) {
|
|
245
|
-
const w =
|
|
246
|
-
t.label,
|
|
247
|
-
theme.fonts.sizes.axisTick,
|
|
248
|
-
theme.fonts.weights.normal,
|
|
249
|
-
);
|
|
348
|
+
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
250
349
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
251
350
|
}
|
|
252
351
|
// If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
|
|
@@ -270,8 +369,8 @@ export function computeAxes(
|
|
|
270
369
|
if (scales.y) {
|
|
271
370
|
const ticks =
|
|
272
371
|
scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
|
|
273
|
-
? categoricalTicks(scales.y, yDensity)
|
|
274
|
-
: continuousTicks(scales.y, yDensity);
|
|
372
|
+
? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText)
|
|
373
|
+
: continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
|
|
275
374
|
|
|
276
375
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
277
376
|
position: t.position,
|
package/src/legend/compute.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
ResolvedTheme,
|
|
21
21
|
TextStyle,
|
|
22
22
|
} from '@opendata-ai/openchart-core';
|
|
23
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
23
|
+
import { BRAND_RESERVE_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
24
24
|
|
|
25
25
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
26
26
|
|
|
@@ -248,8 +248,9 @@ export function computeLegend(
|
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
// Top/bottom-positioned legend: horizontal flow with overflow protection
|
|
252
|
-
|
|
251
|
+
// Top/bottom-positioned legend: horizontal flow with overflow protection.
|
|
252
|
+
// Reserve space on the right so legend entries don't overlap the brand watermark.
|
|
253
|
+
const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
|
|
253
254
|
const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
|
|
254
255
|
|
|
255
256
|
if (maxFit < entries.length) {
|
|
@@ -291,7 +292,7 @@ export function computeLegend(
|
|
|
291
292
|
(resolvedPosition === 'bottom'
|
|
292
293
|
? chartArea.y + chartArea.height - legendHeight
|
|
293
294
|
: chartArea.y) + offsetDy,
|
|
294
|
-
width: Math.min(totalWidth,
|
|
295
|
+
width: Math.min(totalWidth, availableWidth),
|
|
295
296
|
height: legendHeight,
|
|
296
297
|
},
|
|
297
298
|
labelStyle,
|