@opendata-ai/openchart-vanilla 2.3.5 → 2.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.js +52 -8
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/svg-renderer.ts +66 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"typecheck": "tsc --noEmit"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@opendata-ai/openchart-core": "2.
|
|
50
|
-
"@opendata-ai/openchart-engine": "2.
|
|
49
|
+
"@opendata-ai/openchart-core": "2.4.0",
|
|
50
|
+
"@opendata-ai/openchart-engine": "2.4.0",
|
|
51
51
|
"d3-force": "^3.0.0",
|
|
52
52
|
"d3-quadtree": "^3.0.1"
|
|
53
53
|
},
|
package/src/svg-renderer.ts
CHANGED
|
@@ -28,6 +28,31 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
|
28
28
|
|
|
29
29
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Compute the vertical extent of x-axis labels below the chart area.
|
|
33
|
+
* Accounts for rotated tick labels which need more vertical space.
|
|
34
|
+
*/
|
|
35
|
+
function computeXAxisExtent(layout: ChartLayout): number {
|
|
36
|
+
const xAxis = layout.axes.x;
|
|
37
|
+
if (!xAxis) return 0;
|
|
38
|
+
|
|
39
|
+
if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
|
|
40
|
+
// Rotated labels: estimate height from the longest tick label.
|
|
41
|
+
const fontSize = xAxis.tickLabelStyle.fontSize;
|
|
42
|
+
const fontWeight = xAxis.tickLabelStyle.fontWeight;
|
|
43
|
+
const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
|
|
44
|
+
let maxLabelWidth = 40;
|
|
45
|
+
for (const tick of xAxis.ticks) {
|
|
46
|
+
const w = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
47
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
48
|
+
}
|
|
49
|
+
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
50
|
+
return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return xAxis.label ? 48 : 26;
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
// ---------------------------------------------------------------------------
|
|
32
57
|
// Helpers
|
|
33
58
|
// ---------------------------------------------------------------------------
|
|
@@ -96,9 +121,8 @@ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
|
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
// Bottom chrome starts below x-axis labels/title, not at chart area bottom.
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
|
|
124
|
+
// Accounts for rotated tick labels which need more vertical space.
|
|
125
|
+
const xAxisExtent = computeXAxisExtent(layout);
|
|
102
126
|
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
103
127
|
if (chrome.source) {
|
|
104
128
|
renderChromeElement(
|
|
@@ -168,11 +192,26 @@ function renderAxis(
|
|
|
168
192
|
// Label (no tick marks -- gridlines provide sufficient reference)
|
|
169
193
|
const label = createSVGElement('text');
|
|
170
194
|
label.setAttribute('class', 'viz-axis-tick');
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
195
|
+
|
|
196
|
+
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
197
|
+
// Rotated labels: anchor at the rotation pivot point
|
|
198
|
+
const labelX = tick.position;
|
|
199
|
+
const labelY = area.y + area.height + 6;
|
|
200
|
+
setAttrs(label, {
|
|
201
|
+
x: labelX,
|
|
202
|
+
y: labelY,
|
|
203
|
+
'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
|
|
204
|
+
'dominant-baseline': 'central',
|
|
205
|
+
transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
setAttrs(label, {
|
|
209
|
+
x: tick.position,
|
|
210
|
+
y: area.y + area.height + 14,
|
|
211
|
+
'text-anchor': 'middle',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
176
215
|
applyTextStyle(label, axis.tickLabelStyle);
|
|
177
216
|
label.textContent = tick.label;
|
|
178
217
|
g.appendChild(label);
|
|
@@ -228,9 +267,26 @@ function renderAxis(
|
|
|
228
267
|
axisLabel.textContent = axis.label;
|
|
229
268
|
|
|
230
269
|
if (orientation === 'x') {
|
|
270
|
+
// Position axis title below tick labels. For rotated labels, compute
|
|
271
|
+
// the vertical extent of the rotated ticks and place the title below.
|
|
272
|
+
let titleY = area.y + area.height + 35;
|
|
273
|
+
if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
|
|
274
|
+
const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
|
|
275
|
+
let maxLabelWidth = 40;
|
|
276
|
+
for (const tick of axis.ticks) {
|
|
277
|
+
const w = estimateTextWidth(
|
|
278
|
+
tick.label,
|
|
279
|
+
axis.tickLabelStyle.fontSize,
|
|
280
|
+
axis.tickLabelStyle.fontWeight,
|
|
281
|
+
);
|
|
282
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
283
|
+
}
|
|
284
|
+
const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
|
|
285
|
+
titleY = area.y + area.height + rotatedHeight + 14;
|
|
286
|
+
}
|
|
231
287
|
setAttrs(axisLabel, {
|
|
232
288
|
x: area.x + area.width / 2,
|
|
233
|
-
y:
|
|
289
|
+
y: titleY,
|
|
234
290
|
'text-anchor': 'middle',
|
|
235
291
|
});
|
|
236
292
|
} else {
|
|
@@ -860,7 +916,7 @@ function brandPosition(layout: ChartLayout) {
|
|
|
860
916
|
// This uses the same Y computation as renderChrome so the watermark sits on the
|
|
861
917
|
// same baseline row as the source attribution text.
|
|
862
918
|
const { chrome } = layout;
|
|
863
|
-
const xAxisExtent =
|
|
919
|
+
const xAxisExtent = computeXAxisExtent(layout);
|
|
864
920
|
const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
|
|
865
921
|
const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
|
|
866
922
|
// Chrome text uses dominant-baseline:hanging (Y = top of text) while the
|