@opendata-ai/openchart-vanilla 7.6.1 → 7.8.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/chunk-KYRQV6KR.js +2020 -0
- package/dist/chunk-KYRQV6KR.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +320 -2138
- package/dist/index.js.map +1 -1
- package/dist/static.d.ts +29 -0
- package/dist/static.js +187 -0
- package/dist/static.js.map +1 -0
- package/package.json +15 -3
- package/src/__tests__/static.test.ts +141 -0
- package/src/__tests__/svg-renderer.test.ts +10 -4
- package/src/mount.ts +31 -1
- package/src/renderers/annotations.ts +43 -0
- package/src/renderers/chrome.ts +35 -4
- package/src/renderers/endpoint-labels.ts +44 -38
- package/src/static.ts +246 -0
- package/src/svg-ids.ts +5 -0
- package/src/svg-renderer.ts +157 -98
- package/src/theme-tokens.ts +51 -0
- package/src/tilemap-renderer.ts +9 -5
|
@@ -1,68 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Endpoint-labels rendering:
|
|
3
|
-
* line/area charts.
|
|
4
|
-
*
|
|
5
|
-
* with a colored bar through its midline)
|
|
6
|
-
* - the colored series label (with wrap support via tspans)
|
|
7
|
-
* - a muted formatted value below the label
|
|
8
|
-
* - an optional thin leader line back to the data point's true y
|
|
9
|
-
* - an optional open-ring marker on the line at the chart's right edge
|
|
2
|
+
* Endpoint-labels rendering: per-series label columns for multi-series
|
|
3
|
+
* line/area charts. Supports trailing (right) and optional leading (left)
|
|
4
|
+
* columns when `ends: 'both'` is set.
|
|
10
5
|
*
|
|
11
6
|
* The engine resolves all positions, colors, and styles. This renderer is dumb:
|
|
12
7
|
* it reads `layout.endpointLabels` and stamps SVG. Suppression logic lives in
|
|
13
8
|
* the engine — when entries is empty, the renderer is a no-op.
|
|
14
9
|
*/
|
|
15
10
|
|
|
16
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
ChartLayout,
|
|
13
|
+
EndpointLabelEntry,
|
|
14
|
+
EndpointLabelsLayout,
|
|
15
|
+
Rect,
|
|
16
|
+
} from '@opendata-ai/openchart-core';
|
|
17
17
|
import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom';
|
|
18
18
|
|
|
19
|
-
// Swatch→label and label→value gaps both come from the engine layout
|
|
20
|
-
// (`ep.gap` and `ep.valueGap`), so the renderer never has to keep its own
|
|
21
|
-
// copies in sync. Leader styling is renderer-only — the engine doesn't
|
|
22
|
-
// model stroke width or opacity for the optional connector.
|
|
23
19
|
const LEADER_STROKE_WIDTH = 1;
|
|
24
20
|
const LEADER_OPACITY = 0.45;
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
root.setAttribute('class', 'oc-endpoint-labels');
|
|
35
|
-
root.setAttribute('role', 'list');
|
|
36
|
-
root.setAttribute('aria-label', 'Endpoint labels');
|
|
37
|
-
|
|
22
|
+
function renderColumn(
|
|
23
|
+
root: SVGElement,
|
|
24
|
+
entries: EndpointLabelEntry[],
|
|
25
|
+
bounds: Rect,
|
|
26
|
+
ep: EndpointLabelsLayout,
|
|
27
|
+
leaderAnchorX: number,
|
|
28
|
+
side: 'trailing' | 'leading',
|
|
29
|
+
): void {
|
|
38
30
|
const labelFontSize = ep.labelStyle.fontSize ?? 11;
|
|
39
31
|
const labelLineHeight = labelFontSize * (ep.labelStyle.lineHeight ?? 1.25);
|
|
40
32
|
const valueFontSize = ep.valueStyle.fontSize ?? 11;
|
|
41
33
|
|
|
42
|
-
|
|
43
|
-
// the label/value text starts after the chip + gap.
|
|
44
|
-
const chipX = ep.bounds.x;
|
|
34
|
+
const chipX = bounds.x;
|
|
45
35
|
const chipWidth = ep.swatchSize;
|
|
46
36
|
const textX = chipX + chipWidth + ep.gap;
|
|
47
37
|
|
|
48
|
-
for (let i = 0; i <
|
|
49
|
-
const entry =
|
|
38
|
+
for (let i = 0; i < entries.length; i++) {
|
|
39
|
+
const entry = entries[i];
|
|
50
40
|
|
|
51
41
|
const entryG = createSVGElement('g');
|
|
52
42
|
entryG.setAttribute('class', 'oc-endpoint-label-entry');
|
|
53
43
|
entryG.setAttribute('role', 'listitem');
|
|
54
44
|
entryG.setAttribute('data-endpoint-index', String(i));
|
|
55
45
|
entryG.setAttribute('data-endpoint-key', entry.seriesKey);
|
|
46
|
+
entryG.setAttribute('data-endpoint-side', side);
|
|
56
47
|
entryG.setAttribute('aria-label', `${entry.seriesKey}: ${entry.value}`);
|
|
57
48
|
|
|
58
|
-
// Leader line: drawn first so swatch/text sit on top.
|
|
59
49
|
if (entry.showLeader) {
|
|
60
50
|
const leader = createSVGElement('line');
|
|
61
51
|
leader.setAttribute('class', 'oc-endpoint-leader');
|
|
62
52
|
setAttrs(leader, {
|
|
63
53
|
x1: chipX,
|
|
64
54
|
y1: entry.labelY + labelFontSize / 2,
|
|
65
|
-
x2:
|
|
55
|
+
x2: leaderAnchorX,
|
|
66
56
|
y2: entry.dataY,
|
|
67
57
|
stroke: entry.color,
|
|
68
58
|
'stroke-width': LEADER_STROKE_WIDTH,
|
|
@@ -71,7 +61,6 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
|
|
|
71
61
|
entryG.appendChild(leader);
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
// Swatch: bare colored line segment matching the legend style.
|
|
75
64
|
const rowY = entry.labelY + labelFontSize / 2;
|
|
76
65
|
const line = createSVGElement('line');
|
|
77
66
|
line.setAttribute('class', 'oc-endpoint-swatch-line');
|
|
@@ -86,13 +75,10 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
|
|
|
86
75
|
});
|
|
87
76
|
entryG.appendChild(line);
|
|
88
77
|
|
|
89
|
-
// Label text. Multi-line via tspans when wrapped.
|
|
90
78
|
const label = createSVGElement('text');
|
|
91
79
|
label.setAttribute('class', 'oc-endpoint-label');
|
|
92
80
|
setAttrs(label, { x: textX, y: entry.labelY + labelFontSize });
|
|
93
81
|
applyTextStyle(label, ep.labelStyle);
|
|
94
|
-
// Engine-resolved color always wins so theme overrides at the CSS layer
|
|
95
|
-
// don't fight per-series colors.
|
|
96
82
|
(label as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', entry.color);
|
|
97
83
|
|
|
98
84
|
if (entry.labelLines.length <= 1) {
|
|
@@ -107,7 +93,6 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
|
|
|
107
93
|
}
|
|
108
94
|
entryG.appendChild(label);
|
|
109
95
|
|
|
110
|
-
// Value text directly underneath the last label line.
|
|
111
96
|
const lineCount = Math.max(entry.labelLines.length, 1);
|
|
112
97
|
const valueY =
|
|
113
98
|
entry.labelY +
|
|
@@ -122,7 +107,6 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
|
|
|
122
107
|
value.textContent = entry.value;
|
|
123
108
|
entryG.appendChild(value);
|
|
124
109
|
|
|
125
|
-
// Marker: open-ring circle at the chart's right edge on the line.
|
|
126
110
|
if (entry.marker) {
|
|
127
111
|
const marker = createSVGElement('circle');
|
|
128
112
|
marker.setAttribute('class', 'oc-endpoint-marker');
|
|
@@ -139,6 +123,28 @@ export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): v
|
|
|
139
123
|
|
|
140
124
|
root.appendChild(entryG);
|
|
141
125
|
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderEndpointLabels(parent: SVGElement, layout: ChartLayout): void {
|
|
129
|
+
const ep = layout.endpointLabels;
|
|
130
|
+
if (!ep || ep.entries.length === 0) return;
|
|
131
|
+
|
|
132
|
+
const chartArea = layout.area;
|
|
133
|
+
|
|
134
|
+
const root = createSVGElement('g');
|
|
135
|
+
root.setAttribute('class', 'oc-endpoint-labels');
|
|
136
|
+
root.setAttribute('role', 'list');
|
|
137
|
+
root.setAttribute('aria-label', 'Endpoint labels');
|
|
138
|
+
|
|
139
|
+
// Trailing column (right side).
|
|
140
|
+
const chartRightX = chartArea.x + chartArea.width;
|
|
141
|
+
renderColumn(root, ep.entries, ep.bounds, ep, chartRightX, 'trailing');
|
|
142
|
+
|
|
143
|
+
// Leading column (left side) when `ends: 'both'`.
|
|
144
|
+
if (ep.leading && ep.leading.length > 0 && ep.leadingBounds) {
|
|
145
|
+
const chartLeftX = chartArea.x;
|
|
146
|
+
renderColumn(root, ep.leading, ep.leadingBounds, ep, chartLeftX, 'leading');
|
|
147
|
+
}
|
|
142
148
|
|
|
143
149
|
parent.appendChild(root);
|
|
144
150
|
}
|
package/src/static.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Node.js only — do not import from browser code.
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import type {
|
|
4
|
+
ChartLayout,
|
|
5
|
+
ChartSpec,
|
|
6
|
+
CompileOptions,
|
|
7
|
+
DarkMode,
|
|
8
|
+
LayerSpec,
|
|
9
|
+
ResolvedTheme,
|
|
10
|
+
ThemeConfig,
|
|
11
|
+
TileMapSpec,
|
|
12
|
+
} from '@opendata-ai/openchart-core';
|
|
13
|
+
import { adaptForLightLineStroke, isLayerSpec, isTileMapSpec } from '@opendata-ai/openchart-core';
|
|
14
|
+
import { compileChart, compileLayer, compileTileMap } from '@opendata-ai/openchart-engine';
|
|
15
|
+
import { SVG_NS } from './renderers/svg-dom';
|
|
16
|
+
import { resetSvgIdCounter } from './svg-ids';
|
|
17
|
+
import { renderChartSVG } from './svg-renderer';
|
|
18
|
+
import { renderTileMapSVG } from './tilemap-renderer';
|
|
19
|
+
|
|
20
|
+
const esmRequire = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
let cachedWindow: typeof import('happy-dom').Window | undefined;
|
|
23
|
+
function getHappyDomWindow(): typeof import('happy-dom').Window {
|
|
24
|
+
if (cachedWindow) return cachedWindow;
|
|
25
|
+
try {
|
|
26
|
+
({ Window: cachedWindow } = esmRequire('happy-dom') as typeof import('happy-dom'));
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"renderStaticSVG requires 'happy-dom' as a peer dependency. Install it with: npm add happy-dom",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return cachedWindow;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let rendering = false;
|
|
36
|
+
|
|
37
|
+
export interface StaticRenderOptions {
|
|
38
|
+
width?: number;
|
|
39
|
+
height?: number;
|
|
40
|
+
theme?: ThemeConfig;
|
|
41
|
+
/**
|
|
42
|
+
* Dark mode setting. In static rendering `'auto'` resolves to light mode
|
|
43
|
+
* since there is no `matchMedia` to query. Use `'force'` for dark output.
|
|
44
|
+
*/
|
|
45
|
+
darkMode?: DarkMode;
|
|
46
|
+
watermark?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveStaticDarkMode(mode?: DarkMode): boolean {
|
|
50
|
+
if (mode === 'force') return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildThemeStyleBlock(theme: ResolvedTheme): string {
|
|
55
|
+
const accent = theme.colors.categorical[0] ?? '#06b6d4';
|
|
56
|
+
const bg =
|
|
57
|
+
theme.colors.background === 'transparent'
|
|
58
|
+
? theme.isDark
|
|
59
|
+
? '#09090b'
|
|
60
|
+
: '#ffffff'
|
|
61
|
+
: theme.colors.background;
|
|
62
|
+
|
|
63
|
+
const props = [
|
|
64
|
+
`--oc-font-family: ${theme.fonts.family}`,
|
|
65
|
+
`--oc-font-mono: ${theme.fonts.mono}`,
|
|
66
|
+
`--oc-title-size: ${theme.chrome.title.fontSize}px`,
|
|
67
|
+
`--oc-title-weight: ${theme.chrome.title.fontWeight}`,
|
|
68
|
+
`--oc-title-tracking: -0.022em`, // sync with tokens.css
|
|
69
|
+
`--oc-subtitle-size: ${theme.chrome.subtitle.fontSize}px`,
|
|
70
|
+
`--oc-subtitle-weight: ${theme.chrome.subtitle.fontWeight}`,
|
|
71
|
+
`--oc-source-size: ${theme.chrome.source.fontSize}px`,
|
|
72
|
+
`--oc-source-weight: ${theme.chrome.source.fontWeight}`,
|
|
73
|
+
`--oc-body-size: ${theme.fonts.sizes.body}px`,
|
|
74
|
+
`--oc-eyebrow-size: ${theme.chrome.eyebrow.fontSize}px`,
|
|
75
|
+
`--oc-eyebrow-weight: ${theme.chrome.eyebrow.fontWeight}`,
|
|
76
|
+
`--oc-eyebrow-tracking: 0.08em`, // sync with tokens.css
|
|
77
|
+
`--oc-bg: ${bg}`,
|
|
78
|
+
`--oc-text: ${theme.colors.text}`,
|
|
79
|
+
`--oc-text-muted: ${theme.colors.axis}`,
|
|
80
|
+
`--oc-text-faint: ${theme.isDark ? '#52525b' : '#d4d4d8'}`,
|
|
81
|
+
`--oc-gridline: ${theme.colors.gridline}`,
|
|
82
|
+
`--oc-axis: ${theme.colors.axis}`,
|
|
83
|
+
`--oc-border-radius: ${theme.borderRadius}px`,
|
|
84
|
+
`--oc-accent: ${accent}`,
|
|
85
|
+
`--oc-accent-strong: ${adaptForLightLineStroke(accent)}`,
|
|
86
|
+
`--oc-positive: ${theme.colors.positive}`,
|
|
87
|
+
`--oc-negative: ${theme.colors.negative}`,
|
|
88
|
+
`--oc-legend-text: ${theme.isDark ? '#d0d6e0' : '#3f3f46'}`,
|
|
89
|
+
`--oc-space-2: ${theme.spacing.chromeGap * 2}px`,
|
|
90
|
+
`--oc-space-4: ${theme.spacing.padding}px`,
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const rules = [
|
|
94
|
+
`svg.oc-chart { ${props.join('; ')}; }`,
|
|
95
|
+
`.oc-chrome { font-family: var(--oc-font-family); }`,
|
|
96
|
+
`.oc-eyebrow { font-size: var(--oc-eyebrow-size); font-weight: var(--oc-eyebrow-weight); letter-spacing: var(--oc-eyebrow-tracking); text-transform: uppercase; fill: var(--oc-accent); }`,
|
|
97
|
+
`.oc-title { font-size: var(--oc-title-size); font-weight: var(--oc-title-weight); letter-spacing: var(--oc-title-tracking); fill: var(--oc-text); }`,
|
|
98
|
+
`.oc-subtitle { font-size: var(--oc-subtitle-size); font-weight: var(--oc-subtitle-weight); fill: var(--oc-text-muted); }`,
|
|
99
|
+
`.oc-source, .oc-byline, .oc-footer { font-size: var(--oc-source-size); font-weight: var(--oc-source-weight); fill: var(--oc-text-muted); }`,
|
|
100
|
+
`.oc-brand { font-size: 11px; font-weight: 510; letter-spacing: 0.02em; fill: var(--oc-text-faint); }`,
|
|
101
|
+
`.oc-brand-dot { fill: var(--oc-accent); }`,
|
|
102
|
+
`.oc-eyebrow-dot { fill: var(--oc-accent); }`,
|
|
103
|
+
`.oc-metrics { font-family: var(--oc-font-family); }`,
|
|
104
|
+
`.oc-metric-label { font-size: 10px; font-weight: 510; letter-spacing: 0.08em; text-transform: uppercase; fill: var(--oc-text-muted); }`,
|
|
105
|
+
`.oc-metric-value { font-size: 22px; font-weight: 510; letter-spacing: -0.01em; fill: var(--oc-text); font-variant-numeric: tabular-nums; }`,
|
|
106
|
+
`.oc-metric-delta-up { fill: var(--oc-positive); font-size: 12px; font-weight: 510; }`,
|
|
107
|
+
`.oc-metric-delta-down { fill: var(--oc-negative); font-size: 12px; font-weight: 510; }`,
|
|
108
|
+
`.oc-axis-tick-inline { font-size: 11px; font-weight: 400; fill: var(--oc-text-muted); }`,
|
|
109
|
+
`.oc-endpoint-labels { font-family: var(--oc-font-family); }`,
|
|
110
|
+
`.oc-endpoint-label { fill: var(--oc-endpoint-label-color, var(--oc-text)); }`,
|
|
111
|
+
`.oc-endpoint-value { fill: var(--oc-endpoint-value-color, var(--oc-text-muted)); }`,
|
|
112
|
+
`.oc-endpoint-leader { stroke: var(--oc-endpoint-leader-color, currentColor); }`,
|
|
113
|
+
`.oc-annotation-subtitle { fill: var(--oc-annotation-subtitle-color, var(--oc-text-muted)); }`,
|
|
114
|
+
`.oc-metric-secondary { fill: var(--oc-positive); font-size: 12px; font-weight: 400; }`,
|
|
115
|
+
`.oc-legend { font-family: var(--oc-font-family); font-size: var(--oc-body-size); }`,
|
|
116
|
+
`.oc-legend-entry { cursor: default; }`,
|
|
117
|
+
`.oc-legend text { fill: var(--oc-legend-text); }`,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
return rules.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stripInteractiveElements(svg: Element): void {
|
|
124
|
+
const selectors = ['[data-voronoi-overlay]', '[data-crosshair]', '[data-snap-dots]'];
|
|
125
|
+
for (const selector of selectors) {
|
|
126
|
+
const els = svg.querySelectorAll(selector);
|
|
127
|
+
for (const el of els) {
|
|
128
|
+
el.parentNode?.removeChild(el);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render a chart spec to a standalone SVG string without a browser DOM.
|
|
135
|
+
*
|
|
136
|
+
* Requires `happy-dom` as a peer dependency (`bun add happy-dom`).
|
|
137
|
+
*
|
|
138
|
+
* Not safe for concurrent invocation: the render pipeline relies on a global
|
|
139
|
+
* SVG ID counter and a temporary global `document` swap, both of which are
|
|
140
|
+
* single-threaded. In a server handling parallel requests, serialize calls
|
|
141
|
+
* through a queue or mutex.
|
|
142
|
+
*
|
|
143
|
+
* The entire render pipeline is synchronous; the global swap is safe as long
|
|
144
|
+
* as no code schedules microtasks that outlive the call.
|
|
145
|
+
*/
|
|
146
|
+
export function renderStaticSVG(
|
|
147
|
+
spec: ChartSpec | LayerSpec | TileMapSpec,
|
|
148
|
+
options?: StaticRenderOptions,
|
|
149
|
+
): string {
|
|
150
|
+
if (rendering) {
|
|
151
|
+
throw new Error('renderStaticSVG is not reentrant — serialize calls through a queue or mutex');
|
|
152
|
+
}
|
|
153
|
+
rendering = true;
|
|
154
|
+
|
|
155
|
+
const Window = getHappyDomWindow();
|
|
156
|
+
const win = new Window({ url: 'about:blank' });
|
|
157
|
+
|
|
158
|
+
const prevDocument = globalThis.document;
|
|
159
|
+
const prevWindow = (globalThis as Record<string, unknown>).window;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
(globalThis as Record<string, unknown>).document = win.document;
|
|
163
|
+
(globalThis as Record<string, unknown>).window = win;
|
|
164
|
+
|
|
165
|
+
resetSvgIdCounter();
|
|
166
|
+
|
|
167
|
+
const width = options?.width ?? 640;
|
|
168
|
+
const height = options?.height ?? 420;
|
|
169
|
+
const darkMode = resolveStaticDarkMode(options?.darkMode);
|
|
170
|
+
|
|
171
|
+
const compileOpts: CompileOptions = {
|
|
172
|
+
width,
|
|
173
|
+
height,
|
|
174
|
+
theme: options?.theme,
|
|
175
|
+
darkMode,
|
|
176
|
+
watermark: options?.watermark,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let svg: SVGElement;
|
|
180
|
+
let themeForStyle: ResolvedTheme;
|
|
181
|
+
|
|
182
|
+
if (isTileMapSpec(spec)) {
|
|
183
|
+
const tileMapLayout = compileTileMap(spec, compileOpts);
|
|
184
|
+
svg = renderTileMapSVG(tileMapLayout, { animate: false });
|
|
185
|
+
themeForStyle = tileMapLayout.theme;
|
|
186
|
+
} else {
|
|
187
|
+
let layout: ChartLayout;
|
|
188
|
+
if (isLayerSpec(spec)) {
|
|
189
|
+
layout = compileLayer(spec, compileOpts);
|
|
190
|
+
} else {
|
|
191
|
+
layout = compileChart(spec, compileOpts);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const container = win.document.createElement('div');
|
|
195
|
+
Object.defineProperty(container, 'getBoundingClientRect', {
|
|
196
|
+
value: () => ({
|
|
197
|
+
width,
|
|
198
|
+
height,
|
|
199
|
+
top: 0,
|
|
200
|
+
left: 0,
|
|
201
|
+
right: width,
|
|
202
|
+
bottom: height,
|
|
203
|
+
x: 0,
|
|
204
|
+
y: 0,
|
|
205
|
+
toJSON: () => ({}),
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
svg = renderChartSVG(layout, container as unknown as HTMLElement, {
|
|
210
|
+
animate: false,
|
|
211
|
+
crosshair: false,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
stripInteractiveElements(svg);
|
|
215
|
+
themeForStyle = layout.theme;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const doc = win.document as unknown as Document;
|
|
219
|
+
let defs = svg.querySelector('defs');
|
|
220
|
+
if (!defs) {
|
|
221
|
+
defs = doc.createElementNS(SVG_NS, 'defs');
|
|
222
|
+
svg.insertBefore(defs as unknown as Node, svg.firstChild);
|
|
223
|
+
}
|
|
224
|
+
const styleEl = doc.createElementNS(SVG_NS, 'style');
|
|
225
|
+
styleEl.textContent = buildThemeStyleBlock(themeForStyle);
|
|
226
|
+
defs.insertBefore(styleEl as unknown as Node, defs.firstChild);
|
|
227
|
+
|
|
228
|
+
const serializer = new (
|
|
229
|
+
win as unknown as { XMLSerializer: typeof XMLSerializer }
|
|
230
|
+
).XMLSerializer();
|
|
231
|
+
return serializer.serializeToString(svg as unknown as Node);
|
|
232
|
+
} finally {
|
|
233
|
+
rendering = false;
|
|
234
|
+
if (prevDocument !== undefined) {
|
|
235
|
+
(globalThis as Record<string, unknown>).document = prevDocument;
|
|
236
|
+
} else {
|
|
237
|
+
delete (globalThis as Record<string, unknown>).document;
|
|
238
|
+
}
|
|
239
|
+
if (prevWindow !== undefined) {
|
|
240
|
+
(globalThis as Record<string, unknown>).window = prevWindow;
|
|
241
|
+
} else {
|
|
242
|
+
delete (globalThis as Record<string, unknown>).window;
|
|
243
|
+
}
|
|
244
|
+
win.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
package/src/svg-ids.ts
CHANGED
|
@@ -16,3 +16,8 @@ let counter = 0;
|
|
|
16
16
|
export function nextSvgId(prefix: string): string {
|
|
17
17
|
return `${prefix}-${counter++}`;
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/** @internal Used by static rendering only. Never call from browser-facing code. */
|
|
21
|
+
export function resetSvgIdCounter(): void {
|
|
22
|
+
counter = 0;
|
|
23
|
+
}
|