@opendata-ai/openchart-vanilla 6.23.1 → 6.24.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 +70 -39
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/svg-renderer.test.ts +5 -5
- package/src/measure-text.ts +37 -0
- package/src/mount.ts +1 -33
- package/src/renderers/axes.ts +33 -1
- package/src/renderers/svg-dom.ts +8 -8
- package/src/sankey-mount.ts +4 -0
- package/src/sankey-renderer.ts +16 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-vanilla",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.24.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": "6.
|
|
54
|
-
"@opendata-ai/openchart-engine": "6.
|
|
53
|
+
"@opendata-ai/openchart-core": "6.24.0",
|
|
54
|
+
"@opendata-ai/openchart-engine": "6.24.0",
|
|
55
55
|
"d3-force": "^3.0.0",
|
|
56
56
|
"d3-quadtree": "^3.0.1"
|
|
57
57
|
},
|
|
@@ -346,12 +346,12 @@ describe('chart chrome rendering', () => {
|
|
|
346
346
|
|
|
347
347
|
it('title has font styling applied', () => {
|
|
348
348
|
const { svg } = renderSpec(lineSpec);
|
|
349
|
-
const title = svg.querySelector('.oc-title');
|
|
349
|
+
const title = svg.querySelector('.oc-title') as SVGElement & ElementCSSInlineStyle;
|
|
350
350
|
expect(title).not.toBeNull();
|
|
351
|
-
const fontFamily = title
|
|
352
|
-
const fontSize =
|
|
353
|
-
expect(fontFamily).not.
|
|
354
|
-
expect(fontSize).toBeGreaterThan(0);
|
|
351
|
+
const fontFamily = title.style.getPropertyValue('font-family');
|
|
352
|
+
const fontSize = title.style.getPropertyValue('font-size');
|
|
353
|
+
expect(fontFamily).not.toBe('');
|
|
354
|
+
expect(parseFloat(fontSize)).toBeGreaterThan(0);
|
|
355
355
|
});
|
|
356
356
|
|
|
357
357
|
it('wraps long title text into tspan elements at narrow widths', () => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas-backed text measurement factory.
|
|
3
|
+
*
|
|
4
|
+
* Shared by mount.ts (charts) and sankey-mount.ts (sankey diagrams) so both
|
|
5
|
+
* pipelines get accurate browser-measured text widths instead of the heuristic
|
|
6
|
+
* fallback. Falls back to the heuristic when canvas isn't available (e.g. SSR).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { MeasureTextFn } from '@opendata-ai/openchart-core';
|
|
10
|
+
|
|
11
|
+
export function createMeasureText(): MeasureTextFn {
|
|
12
|
+
let canvas: HTMLCanvasElement | null = null;
|
|
13
|
+
let ctx: CanvasRenderingContext2D | null = null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
text: string,
|
|
17
|
+
fontSize: number,
|
|
18
|
+
fontWeight?: number,
|
|
19
|
+
): { width: number; height: number } => {
|
|
20
|
+
if (!canvas) {
|
|
21
|
+
canvas = document.createElement('canvas');
|
|
22
|
+
ctx = canvas.getContext('2d');
|
|
23
|
+
}
|
|
24
|
+
if (!ctx) {
|
|
25
|
+
// Fallback: heuristic estimation
|
|
26
|
+
return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const weight = fontWeight ?? 400;
|
|
30
|
+
ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
|
|
31
|
+
const metrics = ctx.measureText(text);
|
|
32
|
+
return {
|
|
33
|
+
width: metrics.width,
|
|
34
|
+
height: fontSize * 1.2,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/mount.ts
CHANGED
|
@@ -20,7 +20,6 @@ import type {
|
|
|
20
20
|
ElementRef,
|
|
21
21
|
GraphSpec,
|
|
22
22
|
LayerSpec,
|
|
23
|
-
MeasureTextFn,
|
|
24
23
|
RangeAnnotation,
|
|
25
24
|
RefLineAnnotation,
|
|
26
25
|
TextAnnotation,
|
|
@@ -39,6 +38,7 @@ import {
|
|
|
39
38
|
type JPGExportOptions,
|
|
40
39
|
type SVGExportOptions,
|
|
41
40
|
} from './export';
|
|
41
|
+
import { createMeasureText } from './measure-text';
|
|
42
42
|
import { observeResize } from './resize-observer';
|
|
43
43
|
import { renderChartSVG } from './svg-renderer';
|
|
44
44
|
import { createTextEditOverlay } from './text-edit-overlay';
|
|
@@ -115,38 +115,6 @@ function resolveDarkMode(mode?: DarkMode): boolean {
|
|
|
115
115
|
return false;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
// measureText via hidden canvas
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
function createMeasureText(): MeasureTextFn {
|
|
123
|
-
let canvas: HTMLCanvasElement | null = null;
|
|
124
|
-
let ctx: CanvasRenderingContext2D | null = null;
|
|
125
|
-
|
|
126
|
-
return (
|
|
127
|
-
text: string,
|
|
128
|
-
fontSize: number,
|
|
129
|
-
fontWeight?: number,
|
|
130
|
-
): { width: number; height: number } => {
|
|
131
|
-
if (!canvas) {
|
|
132
|
-
canvas = document.createElement('canvas');
|
|
133
|
-
ctx = canvas.getContext('2d');
|
|
134
|
-
}
|
|
135
|
-
if (!ctx) {
|
|
136
|
-
// Fallback: heuristic estimation
|
|
137
|
-
return { width: text.length * fontSize * 0.6, height: fontSize * 1.2 };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const weight = fontWeight ?? 400;
|
|
141
|
-
ctx.font = `${weight} ${fontSize}px Inter, sans-serif`;
|
|
142
|
-
const metrics = ctx.measureText(text);
|
|
143
|
-
return {
|
|
144
|
-
width: metrics.width,
|
|
145
|
-
height: fontSize * 1.2,
|
|
146
|
-
};
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
118
|
// ---------------------------------------------------------------------------
|
|
151
119
|
// Tooltip event wiring
|
|
152
120
|
// ---------------------------------------------------------------------------
|
package/src/renderers/axes.ts
CHANGED
|
@@ -76,7 +76,39 @@ function renderAxis(
|
|
|
76
76
|
'dominant-baseline': 'central',
|
|
77
77
|
});
|
|
78
78
|
applyTextStyle(label, axis.tickLabelStyle);
|
|
79
|
-
|
|
79
|
+
// Truncate categorical y-axis labels that exceed available space so
|
|
80
|
+
// they don't overflow into the chart area. The engine may clamp
|
|
81
|
+
// margin.left on narrow containers; render what fits with an ellipsis.
|
|
82
|
+
const availableWidth = area.x - 6;
|
|
83
|
+
const fontSize = axis.tickLabelStyle.fontSize;
|
|
84
|
+
const fontWeight = axis.tickLabelStyle.fontWeight;
|
|
85
|
+
const fullWidth = estimateTextWidth(tick.label, fontSize, fontWeight);
|
|
86
|
+
if (fullWidth > availableWidth && availableWidth > 20) {
|
|
87
|
+
// Binary-search the longest prefix that fits with a trailing ellipsis
|
|
88
|
+
const ellipsis = '\u2026';
|
|
89
|
+
const ellipsisWidth = estimateTextWidth(ellipsis, fontSize, fontWeight);
|
|
90
|
+
let lo = 0;
|
|
91
|
+
let hi = tick.label.length;
|
|
92
|
+
while (lo < hi) {
|
|
93
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
94
|
+
const candidate = tick.label.slice(0, mid);
|
|
95
|
+
if (
|
|
96
|
+
estimateTextWidth(candidate, fontSize, fontWeight) + ellipsisWidth <=
|
|
97
|
+
availableWidth
|
|
98
|
+
) {
|
|
99
|
+
lo = mid;
|
|
100
|
+
} else {
|
|
101
|
+
hi = mid - 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
label.textContent = lo > 0 ? tick.label.slice(0, lo).trimEnd() + ellipsis : ellipsis;
|
|
105
|
+
// Preserve the full label for accessibility / tooltips
|
|
106
|
+
const titleEl = createSVGElement('title');
|
|
107
|
+
titleEl.textContent = tick.label;
|
|
108
|
+
label.appendChild(titleEl);
|
|
109
|
+
} else {
|
|
110
|
+
label.textContent = tick.label;
|
|
111
|
+
}
|
|
80
112
|
g.appendChild(label);
|
|
81
113
|
}
|
|
82
114
|
}
|
package/src/renderers/svg-dom.ts
CHANGED
|
@@ -21,14 +21,14 @@ export function setAttrs(el: SVGElement, attrs: Record<string, string | number>)
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function applyTextStyle(el: SVGElement, style: TextStyle): void {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
// Use inline styles so engine-computed values take priority over CSS class
|
|
25
|
+
// defaults (e.g. .oc-title { font-size: var(--oc-title-size) } would otherwise
|
|
26
|
+
// override the responsive scaling applied by the chrome layout).
|
|
27
|
+
const inline = (el as SVGElement & ElementCSSInlineStyle).style;
|
|
28
|
+
inline.setProperty('fill', style.fill);
|
|
29
|
+
inline.setProperty('font-size', `${style.fontSize}px`);
|
|
30
|
+
inline.setProperty('font-weight', String(style.fontWeight));
|
|
31
|
+
inline.setProperty('font-family', style.fontFamily);
|
|
32
32
|
if (style.textAnchor) {
|
|
33
33
|
el.setAttribute('text-anchor', style.textAnchor);
|
|
34
34
|
}
|
package/src/sankey-mount.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type JPGExportOptions,
|
|
24
24
|
type SVGExportOptions,
|
|
25
25
|
} from './export';
|
|
26
|
+
import { createMeasureText } from './measure-text';
|
|
26
27
|
import { observeResize } from './resize-observer';
|
|
27
28
|
import { renderSankeySVG } from './sankey-renderer';
|
|
28
29
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
@@ -121,6 +122,8 @@ export function createSankey(
|
|
|
121
122
|
let animationCleanup: (() => void) | null = null;
|
|
122
123
|
let pendingResize = false;
|
|
123
124
|
|
|
125
|
+
const measureText = createMeasureText();
|
|
126
|
+
|
|
124
127
|
// ---------------------------------------------------------------------------
|
|
125
128
|
// Helpers
|
|
126
129
|
// ---------------------------------------------------------------------------
|
|
@@ -143,6 +146,7 @@ export function createSankey(
|
|
|
143
146
|
theme: options?.theme,
|
|
144
147
|
darkMode,
|
|
145
148
|
watermark: options?.watermark,
|
|
149
|
+
measureText,
|
|
146
150
|
};
|
|
147
151
|
|
|
148
152
|
return compileSankey(currentSpec, compileOpts);
|
package/src/sankey-renderer.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
10
|
LegendLayout,
|
|
11
|
+
MeasureTextFn,
|
|
11
12
|
ResolvedAnimation,
|
|
12
13
|
ResolvedChromeElement,
|
|
13
14
|
SankeyLayout,
|
|
@@ -89,6 +90,7 @@ function renderChromeElement(
|
|
|
89
90
|
element: ResolvedChromeElement,
|
|
90
91
|
className: string,
|
|
91
92
|
chromeKey: string,
|
|
93
|
+
measureText?: MeasureTextFn,
|
|
92
94
|
): void {
|
|
93
95
|
const text = createSVGElement('text');
|
|
94
96
|
setAttrs(text, { x: element.x, y: element.y });
|
|
@@ -101,6 +103,7 @@ function renderChromeElement(
|
|
|
101
103
|
element.style.fontSize,
|
|
102
104
|
element.style.fontWeight,
|
|
103
105
|
element.maxWidth,
|
|
106
|
+
measureText,
|
|
104
107
|
);
|
|
105
108
|
|
|
106
109
|
if (lines.length === 1) {
|
|
@@ -122,13 +125,13 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
|
|
|
122
125
|
const g = createSVGElement('g');
|
|
123
126
|
g.setAttribute('class', 'oc-chrome');
|
|
124
127
|
|
|
125
|
-
const { chrome } = layout;
|
|
128
|
+
const { chrome, measureText } = layout;
|
|
126
129
|
|
|
127
130
|
if (chrome.title) {
|
|
128
|
-
renderChromeElement(g, chrome.title, 'oc-title', 'title');
|
|
131
|
+
renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
|
|
129
132
|
}
|
|
130
133
|
if (chrome.subtitle) {
|
|
131
|
-
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle');
|
|
134
|
+
renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
// Bottom chrome: positioned below the sankey drawing area.
|
|
@@ -140,6 +143,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
|
|
|
140
143
|
{ ...chrome.source, y: bottomOffset + chrome.source.y },
|
|
141
144
|
'oc-source',
|
|
142
145
|
'source',
|
|
146
|
+
measureText,
|
|
143
147
|
);
|
|
144
148
|
}
|
|
145
149
|
if (chrome.byline) {
|
|
@@ -148,6 +152,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
|
|
|
148
152
|
{ ...chrome.byline, y: bottomOffset + chrome.byline.y },
|
|
149
153
|
'oc-byline',
|
|
150
154
|
'byline',
|
|
155
|
+
measureText,
|
|
151
156
|
);
|
|
152
157
|
}
|
|
153
158
|
if (chrome.footer) {
|
|
@@ -156,6 +161,7 @@ function renderChrome(parent: SVGElement, layout: SankeyLayout): void {
|
|
|
156
161
|
{ ...chrome.footer, y: bottomOffset + chrome.footer.y },
|
|
157
162
|
'oc-footer',
|
|
158
163
|
'footer',
|
|
164
|
+
measureText,
|
|
159
165
|
);
|
|
160
166
|
}
|
|
161
167
|
|
|
@@ -476,7 +482,11 @@ function renderNodes(
|
|
|
476
482
|
// Labels rendering
|
|
477
483
|
// ---------------------------------------------------------------------------
|
|
478
484
|
|
|
479
|
-
function renderLabels(
|
|
485
|
+
function renderLabels(
|
|
486
|
+
parent: SVGElement,
|
|
487
|
+
nodes: SankeyNodeMark[],
|
|
488
|
+
measureText?: MeasureTextFn,
|
|
489
|
+
): void {
|
|
480
490
|
const g = createSVGElement('g');
|
|
481
491
|
g.setAttribute('class', 'oc-sankey-labels');
|
|
482
492
|
|
|
@@ -492,7 +502,7 @@ function renderLabels(parent: SVGElement, nodes: SankeyNodeMark[]): void {
|
|
|
492
502
|
if (label.maxWidth !== undefined && label.maxWidth > 0) {
|
|
493
503
|
const fontSize = label.style.fontSize ?? 12;
|
|
494
504
|
const fontWeight = label.style.fontWeight ?? 400;
|
|
495
|
-
const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth);
|
|
505
|
+
const lines = wrapText(label.text, fontSize, fontWeight, label.maxWidth, measureText);
|
|
496
506
|
if (lines.length > 1) {
|
|
497
507
|
const lineHeight = fontSize * (label.style.lineHeight ?? 1.3);
|
|
498
508
|
// Center the multi-line block vertically around the label y position
|
|
@@ -585,7 +595,7 @@ export function renderSankeySVG(
|
|
|
585
595
|
renderNodes(svg, layout.nodes, animation);
|
|
586
596
|
|
|
587
597
|
// Labels
|
|
588
|
-
renderLabels(svg, layout.nodes);
|
|
598
|
+
renderLabels(svg, layout.nodes, layout.measureText);
|
|
589
599
|
|
|
590
600
|
// Legend
|
|
591
601
|
renderLegend(svg, layout.legend);
|