@opendata-ai/openchart-vanilla 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.js +59 -46
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/known-bugs-render.test.ts +183 -0
- package/src/measure-text.ts +11 -1
- package/src/renderers/annotations.ts +24 -16
- package/src/renderers/axes.ts +12 -9
- package/src/renderers/brand.ts +2 -3
- package/src/renderers/chrome.ts +2 -5
- package/src/renderers/legend.ts +21 -13
- package/src/renderers/svg-dom.ts +1 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@floating-ui/dom": "^1.7.6",
|
|
53
|
-
"@opendata-ai/openchart-core": "7.
|
|
54
|
-
"@opendata-ai/openchart-engine": "7.
|
|
53
|
+
"@opendata-ai/openchart-core": "7.3.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "7.3.0",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Red-locked tests for known layout bugs.
|
|
3
|
+
*
|
|
4
|
+
* These use `test.fails(...)` so they pass today (the assertion is inverted).
|
|
5
|
+
* When the underlying bug is fixed, the test will start failing, signaling
|
|
6
|
+
* that the red-lock can be converted to a normal passing test.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CategoricalLegendLayout } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
11
|
+
import { compileChart } from '@opendata-ai/openchart-engine';
|
|
12
|
+
import { describe, expect, test } from 'vitest';
|
|
13
|
+
import { renderLegend } from '../renderers/legend';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helpers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/** Collect distinct y-attribute values from <text> elements, sorted ascending. */
|
|
20
|
+
function distinctLabelYValues(svg: SVGElement): number[] {
|
|
21
|
+
const texts = svg.querySelectorAll('text');
|
|
22
|
+
const ySet = new Set<number>();
|
|
23
|
+
for (const t of texts) {
|
|
24
|
+
const y = t.getAttribute('y');
|
|
25
|
+
if (y != null) ySet.add(Number(y));
|
|
26
|
+
}
|
|
27
|
+
return [...ySet].sort((a, b) => a - b);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Constants (mirrors packages/engine/src/legend/wrap.ts)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
const SWATCH_SIZE = 12;
|
|
35
|
+
const SWATCH_GAP = 6;
|
|
36
|
+
const ENTRY_GAP = 16;
|
|
37
|
+
const ENGINE_ROW_HEIGHT = SWATCH_SIZE + 4; // 16 - what the engine reserves
|
|
38
|
+
const LEGEND_PADDING = 8;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Fixtures
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const entries: CategoricalLegendLayout['entries'] = [
|
|
45
|
+
{ label: 'United States of America', color: '#1f77b4', shape: 'square', active: true },
|
|
46
|
+
{ label: 'United Kingdom', color: '#ff7f0e', shape: 'square', active: true },
|
|
47
|
+
{ label: 'Federal Republic of Germany', color: '#2ca02c', shape: 'square', active: true },
|
|
48
|
+
{ label: 'French Republic', color: '#d62728', shape: 'square', active: true },
|
|
49
|
+
{ label: 'Kingdom of Spain', color: '#9467bd', shape: 'square', active: true },
|
|
50
|
+
{ label: 'Republic of Italy', color: '#8c564b', shape: 'square', active: true },
|
|
51
|
+
{ label: 'Kingdom of Netherlands', color: '#e377c2', shape: 'square', active: true },
|
|
52
|
+
{ label: 'Swiss Confederation', color: '#7f7f7f', shape: 'square', active: true },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const labelStyle = {
|
|
56
|
+
fontSize: 11,
|
|
57
|
+
lineHeight: 1.3,
|
|
58
|
+
fontWeight: 400 as const,
|
|
59
|
+
fontFamily: 'sans-serif',
|
|
60
|
+
fill: '#333',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compute row count by simulating the same wrap logic the engine uses
|
|
65
|
+
* (measureLegendWrap in packages/engine/src/legend/wrap.ts).
|
|
66
|
+
*/
|
|
67
|
+
function computeRowCount(maxWidth: number): number {
|
|
68
|
+
let rowCount = 1;
|
|
69
|
+
let rowWidth = 0;
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
72
|
+
const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
|
|
73
|
+
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
74
|
+
rowCount++;
|
|
75
|
+
rowWidth = entryWidth;
|
|
76
|
+
} else {
|
|
77
|
+
rowWidth += entryWidth;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return rowCount;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Tests
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('known layout bugs', () => {
|
|
88
|
+
// Red-locked: fixed by docs/plans/04-resolved-layout-contract.md
|
|
89
|
+
test.fails('5a.1: legend row advancement matches engine row height', () => {
|
|
90
|
+
const boundsWidth = 400;
|
|
91
|
+
const rowCount = computeRowCount(boundsWidth);
|
|
92
|
+
const boundsHeight = rowCount * ENGINE_ROW_HEIGHT + LEGEND_PADDING * 2;
|
|
93
|
+
|
|
94
|
+
const legendFixture: CategoricalLegendLayout = {
|
|
95
|
+
position: 'top',
|
|
96
|
+
entries,
|
|
97
|
+
swatchSize: SWATCH_SIZE,
|
|
98
|
+
swatchGap: SWATCH_GAP,
|
|
99
|
+
entryGap: ENTRY_GAP,
|
|
100
|
+
swatchChipFill: '#f0f0f0',
|
|
101
|
+
labelStyle,
|
|
102
|
+
bounds: { x: 0, y: 0, width: boundsWidth, height: boundsHeight },
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
106
|
+
renderLegend(svg, legendFixture);
|
|
107
|
+
|
|
108
|
+
const yValues = distinctLabelYValues(svg);
|
|
109
|
+
// With 4 rows we expect 4 distinct y-values. Consecutive rows should
|
|
110
|
+
// differ by ENGINE_ROW_HEIGHT (16). The renderer uses swatchSize + 6 = 18,
|
|
111
|
+
// so this assertion will fail until the drift is fixed.
|
|
112
|
+
expect(yValues.length).toBeGreaterThanOrEqual(2);
|
|
113
|
+
for (let i = 1; i < yValues.length; i++) {
|
|
114
|
+
const gap = yValues[i] - yValues[i - 1];
|
|
115
|
+
expect(gap).toBe(ENGINE_ROW_HEIGHT);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Red-locked: fixed by docs/plans/04-resolved-layout-contract.md and docs/plans/03-measure-then-freeze-layout.md
|
|
120
|
+
test.fails('5a.2: rows drawn by renderer match rows reserved by engine', () => {
|
|
121
|
+
// Build a line spec with 8 series whose labels vary in length.
|
|
122
|
+
const countries = [
|
|
123
|
+
'United States of America',
|
|
124
|
+
'United Kingdom',
|
|
125
|
+
'Federal Republic of Germany',
|
|
126
|
+
'French Republic',
|
|
127
|
+
'Kingdom of Spain',
|
|
128
|
+
'Republic of Italy',
|
|
129
|
+
'Kingdom of Netherlands',
|
|
130
|
+
'Swiss Confederation',
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const data = countries.flatMap((country) => [
|
|
134
|
+
{ date: '2020-01-01', value: 10, country },
|
|
135
|
+
{ date: '2021-01-01', value: 20, country },
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
const spec = {
|
|
139
|
+
mark: 'line' as const,
|
|
140
|
+
data,
|
|
141
|
+
encoding: {
|
|
142
|
+
x: { field: 'date', type: 'temporal' as const },
|
|
143
|
+
y: { field: 'value', type: 'quantitative' as const },
|
|
144
|
+
color: { field: 'country', type: 'nominal' as const },
|
|
145
|
+
},
|
|
146
|
+
legend: { position: 'top' as const },
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Try widths 500-700 to find one where the legend wraps AND the
|
|
150
|
+
// bounds.width narrowing causes extra rows in the renderer.
|
|
151
|
+
let foundMismatch = false;
|
|
152
|
+
|
|
153
|
+
for (let width = 500; width <= 700; width += 10) {
|
|
154
|
+
const layout = compileChart(spec, { width, height: 400 });
|
|
155
|
+
const legend = layout.legend as CategoricalLegendLayout;
|
|
156
|
+
if (!legend || !legend.entries || legend.entries.length === 0) continue;
|
|
157
|
+
|
|
158
|
+
// Rows reserved by the engine
|
|
159
|
+
const effectivePadding = width < 420 ? 2 : LEGEND_PADDING;
|
|
160
|
+
const rowsReserved = Math.round(
|
|
161
|
+
(legend.bounds.height - effectivePadding * 2) / ENGINE_ROW_HEIGHT,
|
|
162
|
+
);
|
|
163
|
+
if (rowsReserved <= 1) continue; // only interesting when wrapping occurs
|
|
164
|
+
|
|
165
|
+
// Render and count rows drawn
|
|
166
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
167
|
+
renderLegend(svg, legend);
|
|
168
|
+
const yValues = distinctLabelYValues(svg);
|
|
169
|
+
const rowsDrawn = yValues.length;
|
|
170
|
+
|
|
171
|
+
if (rowsDrawn !== rowsReserved) {
|
|
172
|
+
foundMismatch = true;
|
|
173
|
+
// This is the bug: the renderer drew more rows than the engine reserved.
|
|
174
|
+
expect(rowsDrawn).toBe(rowsReserved);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// If no width triggered a mismatch, fail so test.fails still inverts.
|
|
180
|
+
// If this path fires, widen the width range above.
|
|
181
|
+
expect(foundMismatch).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/src/measure-text.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { MeasureTextFn } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
10
11
|
|
|
11
12
|
export function createMeasureText(): MeasureTextFn {
|
|
12
13
|
let canvas: HTMLCanvasElement | null = null;
|
|
@@ -23,7 +24,10 @@ export function createMeasureText(): MeasureTextFn {
|
|
|
23
24
|
}
|
|
24
25
|
if (!ctx) {
|
|
25
26
|
// Fallback: heuristic estimation
|
|
26
|
-
return {
|
|
27
|
+
return {
|
|
28
|
+
width: estimateTextWidth(text, fontSize, fontWeight ?? 400),
|
|
29
|
+
height: fontSize * 1.2,
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
const weight = fontWeight ?? 400;
|
|
@@ -35,3 +39,9 @@ export function createMeasureText(): MeasureTextFn {
|
|
|
35
39
|
};
|
|
36
40
|
};
|
|
37
41
|
}
|
|
42
|
+
|
|
43
|
+
let shared: MeasureTextFn | null = null;
|
|
44
|
+
export function sharedMeasureText(): MeasureTextFn {
|
|
45
|
+
if (!shared) shared = createMeasureText();
|
|
46
|
+
return shared;
|
|
47
|
+
}
|
|
@@ -214,14 +214,7 @@ function renderAnnotation(
|
|
|
214
214
|
const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
|
|
215
215
|
const isMultiLine = lines.length > 1;
|
|
216
216
|
|
|
217
|
-
// Multi-line text: drop-line connectors keep the resolved side anchor so
|
|
218
|
-
// the label hugs the vertical line. Other connectors center the text for
|
|
219
|
-
// a cleaner look.
|
|
220
217
|
if (isMultiLine) {
|
|
221
|
-
const isDropLine = annotation.label.connector?.style === 'drop-line';
|
|
222
|
-
if (!isDropLine) {
|
|
223
|
-
text.setAttribute('text-anchor', 'middle');
|
|
224
|
-
}
|
|
225
218
|
for (let i = 0; i < lines.length; i++) {
|
|
226
219
|
const tspan = createSVGElement('tspan');
|
|
227
220
|
setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
|
|
@@ -235,20 +228,35 @@ function renderAnnotation(
|
|
|
235
228
|
// Render background rect behind text if specified, otherwise use
|
|
236
229
|
// paint-order stroke halo to knock out lines behind text
|
|
237
230
|
if (annotation.label.background) {
|
|
238
|
-
const charWidth = fontSize * 0.55;
|
|
239
|
-
const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
|
|
240
|
-
const totalHeight = lines.length * lineHeight;
|
|
241
231
|
const pad = 3;
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
232
|
+
let bgX: number;
|
|
233
|
+
let bgY: number;
|
|
234
|
+
let bgW: number;
|
|
235
|
+
let bgH: number;
|
|
236
|
+
|
|
237
|
+
if (annotation.label.bounds) {
|
|
238
|
+
const b = annotation.label.bounds;
|
|
239
|
+
bgX = b.x - pad;
|
|
240
|
+
bgY = b.y - pad;
|
|
241
|
+
bgW = b.width + pad * 2;
|
|
242
|
+
bgH = b.height + pad * 2;
|
|
243
|
+
} else {
|
|
244
|
+
const charWidth = fontSize * 0.55;
|
|
245
|
+
const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
|
|
246
|
+
const totalHeight = lines.length * lineHeight;
|
|
247
|
+
bgX = isMultiLine ? annotation.label.x - maxLineWidth / 2 - pad : annotation.label.x - pad;
|
|
248
|
+
bgY = annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad;
|
|
249
|
+
bgW = maxLineWidth + pad * 2;
|
|
250
|
+
bgH = totalHeight + pad * 2;
|
|
251
|
+
}
|
|
252
|
+
|
|
245
253
|
const bgRect = createSVGElement('rect');
|
|
246
254
|
bgRect.setAttribute('class', 'oc-annotation-bg');
|
|
247
255
|
setAttrs(bgRect, {
|
|
248
256
|
x: bgX,
|
|
249
|
-
y:
|
|
250
|
-
width:
|
|
251
|
-
height:
|
|
257
|
+
y: bgY,
|
|
258
|
+
width: bgW,
|
|
259
|
+
height: bgH,
|
|
252
260
|
fill: annotation.label.background,
|
|
253
261
|
rx: 2,
|
|
254
262
|
});
|
package/src/renderers/axes.ts
CHANGED
|
@@ -242,9 +242,18 @@ function renderAxis(
|
|
|
242
242
|
applyTextStyle(axisLabel, axis.labelStyle);
|
|
243
243
|
axisLabel.textContent = axis.label;
|
|
244
244
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
const tp = axis.titlePosition;
|
|
246
|
+
if (tp) {
|
|
247
|
+
const attrs: Record<string, string | number> = {
|
|
248
|
+
x: tp.x,
|
|
249
|
+
y: tp.y,
|
|
250
|
+
'text-anchor': 'middle',
|
|
251
|
+
};
|
|
252
|
+
if (tp.angle) {
|
|
253
|
+
attrs.transform = `rotate(${tp.angle}, ${tp.x}, ${tp.y})`;
|
|
254
|
+
}
|
|
255
|
+
setAttrs(axisLabel, attrs);
|
|
256
|
+
} else if (orientation === 'x') {
|
|
248
257
|
let titleY = area.y + area.height + 35;
|
|
249
258
|
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
250
259
|
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
|
|
@@ -266,7 +275,6 @@ function renderAxis(
|
|
|
266
275
|
'text-anchor': 'middle',
|
|
267
276
|
});
|
|
268
277
|
} else if (isRight) {
|
|
269
|
-
// Rotated right y-axis label (tighter offset on compact viewports)
|
|
270
278
|
const titleOffset = getAxisTitleOffset(layout.dimensions.width);
|
|
271
279
|
const titleX = area.x + area.width + titleOffset;
|
|
272
280
|
setAttrs(axisLabel, {
|
|
@@ -276,11 +284,6 @@ function renderAxis(
|
|
|
276
284
|
transform: `rotate(90, ${titleX}, ${area.y + area.height / 2})`,
|
|
277
285
|
});
|
|
278
286
|
} else {
|
|
279
|
-
// Rotated left y-axis label.
|
|
280
|
-
// Compute a dynamic offset so the title clears the widest tick label.
|
|
281
|
-
// The title is rotated and centered, so axisTitleOffset() adds its own
|
|
282
|
-
// half-glyph height on top of the gap (otherwise large title fonts overlap
|
|
283
|
-
// the tick labels — the gap is visible clearance, not center-to-edge).
|
|
284
287
|
const maxTickLabelWidth = axis.ticks.reduce((max, t) => {
|
|
285
288
|
const w = estimateTextWidth(
|
|
286
289
|
t.label,
|
package/src/renderers/brand.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ChartLayout } from '@opendata-ai/openchart-core';
|
|
6
6
|
import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH } from '@opendata-ai/openchart-core';
|
|
7
|
-
import {
|
|
7
|
+
import { createSVGElement, setAttrs, XLINK_NS } from './svg-dom';
|
|
8
8
|
|
|
9
9
|
const BRAND_URL = 'https://tryopendata.ai';
|
|
10
10
|
|
|
@@ -24,8 +24,7 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void {
|
|
|
24
24
|
|
|
25
25
|
// Vertically align with the first bottom chrome element.
|
|
26
26
|
const { chrome } = layout;
|
|
27
|
-
const
|
|
28
|
-
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
27
|
+
const bottomOffset = chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
|
|
29
28
|
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
|
|
30
29
|
// When no bottom chrome items exist, derive a fallback offset that still
|
|
31
30
|
// clears any bottom-positioned legend so the brand watermark doesn't
|
package/src/renderers/chrome.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
ResolvedChromeElement,
|
|
9
9
|
} from '@opendata-ai/openchart-core';
|
|
10
10
|
import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core';
|
|
11
|
-
import { applyTextStyle,
|
|
11
|
+
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
12
12
|
|
|
13
13
|
function renderChromeElement(
|
|
14
14
|
parent: SVGElement,
|
|
@@ -87,10 +87,7 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
87
87
|
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
// Accounts for rotated tick labels which need more vertical space.
|
|
92
|
-
const xAxisExtent = computeXAxisExtent(layout);
|
|
93
|
-
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
90
|
+
const bottomOffset = layout.chrome.bottomAnchorY ?? layout.area.y + layout.area.height;
|
|
94
91
|
if (chrome.source) {
|
|
95
92
|
renderChromeElement(
|
|
96
93
|
g,
|
package/src/renderers/legend.ts
CHANGED
|
@@ -19,14 +19,18 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
19
19
|
g.setAttribute('aria-label', 'Chart legend');
|
|
20
20
|
|
|
21
21
|
const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
|
|
22
|
+
const positions = 'entryPositions' in legend ? legend.entryPositions : undefined;
|
|
22
23
|
let offsetX = legend.bounds.x;
|
|
23
24
|
let offsetY = legend.bounds.y;
|
|
24
25
|
|
|
25
26
|
for (let i = 0; i < legend.entries.length; i++) {
|
|
26
27
|
const entry = legend.entries[i];
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
if (
|
|
29
|
+
const pos = positions?.[i];
|
|
30
|
+
if (pos) {
|
|
31
|
+
offsetX = pos.x;
|
|
32
|
+
offsetY = pos.y;
|
|
33
|
+
} else if (isHorizontal && i > 0) {
|
|
30
34
|
const labelWidth = estimateTextWidth(
|
|
31
35
|
entry.label,
|
|
32
36
|
legend.labelStyle.fontSize,
|
|
@@ -116,17 +120,21 @@ export function renderLegend(parent: SVGElement, legend: LegendLayout): void {
|
|
|
116
120
|
|
|
117
121
|
g.appendChild(entryG);
|
|
118
122
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
if (!pos) {
|
|
124
|
+
if (isHorizontal) {
|
|
125
|
+
const labelWidth = estimateTextWidth(
|
|
126
|
+
entry.label,
|
|
127
|
+
legend.labelStyle.fontSize,
|
|
128
|
+
legend.labelStyle.fontWeight,
|
|
129
|
+
);
|
|
130
|
+
const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
|
|
131
|
+
offsetX += entryWidth;
|
|
132
|
+
} else {
|
|
133
|
+
offsetY +=
|
|
134
|
+
'rowHeight' in legend && legend.rowHeight
|
|
135
|
+
? legend.rowHeight
|
|
136
|
+
: legend.swatchSize + legend.entryGap;
|
|
137
|
+
}
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
package/src/renderers/svg-dom.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* Pure, stateless utilities. No layout/theme knowledge.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
7
|
+
import type { TextStyle } from '@opendata-ai/openchart-core';
|
|
9
8
|
|
|
10
9
|
export const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11
10
|
export const XLINK_NS = 'http://www.w3.org/1999/xlink';
|
|
@@ -39,28 +38,3 @@ export function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
|
39
38
|
el.setAttribute('font-variant', style.fontVariant);
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Compute the vertical extent of x-axis labels below the chart area.
|
|
45
|
-
* Accounts for rotated tick labels which need more vertical space.
|
|
46
|
-
*/
|
|
47
|
-
export function computeXAxisExtent(layout: ChartLayout): number {
|
|
48
|
-
const xAxis = layout.axes.x;
|
|
49
|
-
if (!xAxis) return 0;
|
|
50
|
-
|
|
51
|
-
if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
|
|
52
|
-
// Rotated labels: estimate height from the longest tick label.
|
|
53
|
-
const fontSize = xAxis.tickLabelStyle.fontSize;
|
|
54
|
-
const fontWeight = xAxis.tickLabelStyle.fontWeight;
|
|
55
|
-
const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
|
|
56
|
-
let maxLabelWidth = 40;
|
|
57
|
-
for (const tick of xAxis.ticks) {
|
|
58
|
-
const w = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
59
|
-
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
60
|
-
}
|
|
61
|
-
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
62
|
-
return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return xAxis.label ? 48 : 26;
|
|
66
|
-
}
|