@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21
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-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
- package/dist/chunk-DCODCLWI.js +234 -0
- package/dist/chunk-DCODCLWI.js.map +1 -0
- package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
- package/dist/chunk-EKAUQAAX.js.map +1 -0
- package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
- package/dist/chunk-KNT5AZ2O.js.map +1 -0
- package/dist/chunk-RM5XS6NR.js +113 -0
- package/dist/chunk-RM5XS6NR.js.map +1 -0
- package/dist/chunk-T32UHMZA.js +282 -0
- package/dist/chunk-T32UHMZA.js.map +1 -0
- package/dist/export-gif.d.ts +113 -0
- package/dist/export-gif.js +21 -0
- package/dist/export-gif.js.map +1 -0
- package/dist/index.d.ts +283 -24
- package/dist/index.js +2012 -763
- package/dist/index.js.map +1 -1
- package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
- package/dist/simulation-worker.js +83 -2
- package/dist/static.js +7 -69
- package/dist/static.js.map +1 -1
- package/dist/story/index.d.ts +3 -2
- package/dist/story/index.js +5 -3
- package/dist/story/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/styles.css.map +1 -1
- package/package.json +14 -6
- package/dist/chunk-5EOV5JQZ.js.map +0 -1
- package/dist/chunk-ENXSUXC5.js.map +0 -1
- package/src/__test-fixtures__/dom.ts +0 -70
- package/src/__test-fixtures__/specs.ts +0 -214
- package/src/__tests__/animation.test.ts +0 -358
- package/src/__tests__/annotations-render.test.ts +0 -201
- package/src/__tests__/compound-labels.test.ts +0 -122
- package/src/__tests__/continuous-legend.test.ts +0 -91
- package/src/__tests__/crosshair.test.ts +0 -130
- package/src/__tests__/edit-events.test.ts +0 -902
- package/src/__tests__/events.test.ts +0 -387
- package/src/__tests__/export.test.ts +0 -241
- package/src/__tests__/font-race.test.ts +0 -199
- package/src/__tests__/gradient-ids.test.ts +0 -159
- package/src/__tests__/gradient-utils.test.ts +0 -125
- package/src/__tests__/known-bugs-render.test.ts +0 -183
- package/src/__tests__/map.test.ts +0 -293
- package/src/__tests__/mount.test.ts +0 -357
- package/src/__tests__/pattern-fills.test.ts +0 -105
- package/src/__tests__/resize-timing.test.ts +0 -218
- package/src/__tests__/sankey.test.ts +0 -133
- package/src/__tests__/selection-events.test.ts +0 -869
- package/src/__tests__/series-search.test.ts +0 -311
- package/src/__tests__/static.test.ts +0 -141
- package/src/__tests__/svg-renderer.test.ts +0 -895
- package/src/__tests__/table-keyboard.test.ts +0 -361
- package/src/__tests__/table-mount.test.ts +0 -639
- package/src/__tests__/tilemap.test.ts +0 -158
- package/src/__tests__/tooltip.test.ts +0 -337
- package/src/__tests__/transition.test.ts +0 -1777
- package/src/__tests__/you-draw-it.test.ts +0 -273
- package/src/animation-vars.ts +0 -42
- package/src/animation.ts +0 -76
- package/src/barlist-mount.ts +0 -383
- package/src/barlist-renderer.ts +0 -267
- package/src/dom-helpers.ts +0 -17
- package/src/export.ts +0 -482
- package/src/gradient-utils.ts +0 -138
- package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
- package/src/graph/__tests__/graph-mount.test.ts +0 -213
- package/src/graph/__tests__/interaction.test.ts +0 -205
- package/src/graph/__tests__/keyboard.test.ts +0 -653
- package/src/graph/__tests__/search.test.ts +0 -88
- package/src/graph/__tests__/simulation.test.ts +0 -233
- package/src/graph/__tests__/spatial-index.test.ts +0 -142
- package/src/graph/__tests__/zoom.test.ts +0 -203
- package/src/graph/canvas-renderer.ts +0 -747
- package/src/graph/interaction.ts +0 -367
- package/src/graph/keyboard.ts +0 -208
- package/src/graph/search.ts +0 -50
- package/src/graph/simulation-worker-url.ts +0 -12
- package/src/graph/simulation-worker.ts +0 -278
- package/src/graph/simulation.ts +0 -422
- package/src/graph/spatial-index.ts +0 -121
- package/src/graph/types.ts +0 -47
- package/src/graph/worker-protocol.ts +0 -70
- package/src/graph/zoom.ts +0 -134
- package/src/graph-mount.ts +0 -888
- package/src/index.ts +0 -76
- package/src/interactions/chart-events.ts +0 -139
- package/src/interactions/crosshair.ts +0 -233
- package/src/interactions/drag-handler.ts +0 -175
- package/src/interactions/editing-drags.ts +0 -515
- package/src/interactions/index.ts +0 -25
- package/src/interactions/keyboard-nav.ts +0 -111
- package/src/interactions/legend-interaction.ts +0 -46
- package/src/interactions/selection.ts +0 -280
- package/src/interactions/tooltip-events.ts +0 -72
- package/src/map-camera.ts +0 -88
- package/src/map-mount.ts +0 -701
- package/src/map-renderer.ts +0 -338
- package/src/map-transition.ts +0 -83
- package/src/measure-text.ts +0 -134
- package/src/mount.ts +0 -1382
- package/src/pattern-utils.ts +0 -110
- package/src/renderers/annotations.ts +0 -388
- package/src/renderers/axes.ts +0 -351
- package/src/renderers/brand.ts +0 -87
- package/src/renderers/chrome.ts +0 -196
- package/src/renderers/endpoint-labels.ts +0 -150
- package/src/renderers/legend.ts +0 -311
- package/src/renderers/marks.ts +0 -561
- package/src/renderers/metrics.ts +0 -63
- package/src/renderers/svg-dom.ts +0 -40
- package/src/renderers/table-cells.ts +0 -446
- package/src/resize-observer.ts +0 -46
- package/src/sankey-mount.ts +0 -622
- package/src/sankey-renderer.ts +0 -614
- package/src/series-search.ts +0 -392
- package/src/static.ts +0 -251
- package/src/story/__tests__/camera-math.test.ts +0 -145
- package/src/story/__tests__/create-chart-story.test.ts +0 -317
- package/src/story/__tests__/progress-math.test.ts +0 -95
- package/src/story/__tests__/scroll-driver.test.ts +0 -130
- package/src/story/__tests__/tween.test.ts +0 -159
- package/src/story/camera-math.ts +0 -125
- package/src/story/create-chart-story.ts +0 -353
- package/src/story/crossfade.ts +0 -70
- package/src/story/index.ts +0 -39
- package/src/story/progress-math.ts +0 -85
- package/src/story/resolve-camera-target.ts +0 -119
- package/src/story/scroll-driver.ts +0 -206
- package/src/story/story-camera.ts +0 -49
- package/src/story/tween.ts +0 -112
- package/src/story/types.ts +0 -76
- package/src/svg-ids.ts +0 -23
- package/src/svg-renderer.ts +0 -383
- package/src/table-keyboard.ts +0 -266
- package/src/table-mount.ts +0 -567
- package/src/table-renderer.ts +0 -414
- package/src/text-edit-overlay.ts +0 -255
- package/src/theme-tokens.ts +0 -51
- package/src/tilemap-mount.ts +0 -511
- package/src/tilemap-renderer.ts +0 -396
- package/src/tooltip.ts +0 -177
- package/src/transition.ts +0 -2816
- package/src/you-draw-it.ts +0 -480
- /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
package/src/export.ts
DELETED
|
@@ -1,482 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Export utilities: serialize charts to SVG, PNG, JPG, or CSV.
|
|
3
|
-
*
|
|
4
|
-
* - SVG: serializes the rendered DOM element via XMLSerializer
|
|
5
|
-
* - SVG with fonts: async version that embeds @font-face data URIs
|
|
6
|
-
* - PNG: renders SVG to canvas, then extracts as Blob
|
|
7
|
-
* - JPG: same as PNG but with JPEG compression and background fill
|
|
8
|
-
* - CSV: converts a data array to comma-separated text
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Types
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
export interface SVGExportOptions {
|
|
16
|
-
/** Embed fonts as base64 data URIs in the SVG. Defaults to true. */
|
|
17
|
-
embedFonts?: boolean;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface PNGExportOptions extends SVGExportOptions {
|
|
21
|
-
/** DPI scaling factor. Defaults to 2 for retina-quality output. */
|
|
22
|
-
dpi?: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface JPGExportOptions extends PNGExportOptions {
|
|
26
|
-
/** JPEG quality from 0 to 1. Defaults to 0.92. */
|
|
27
|
-
quality?: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface FontFaceData {
|
|
31
|
-
family: string;
|
|
32
|
-
weight: string;
|
|
33
|
-
style: string;
|
|
34
|
-
base64: string;
|
|
35
|
-
format: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// Dimension parsing
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Extract dimensions from an SVG element, trying width/height attributes
|
|
44
|
-
* first, then falling back to viewBox.
|
|
45
|
-
*/
|
|
46
|
-
function getSVGDimensions(svg: SVGElement): { width: number; height: number } {
|
|
47
|
-
const w = parseFloat(svg.getAttribute('width') || '');
|
|
48
|
-
const h = parseFloat(svg.getAttribute('height') || '');
|
|
49
|
-
if (w && h) return { width: w, height: h };
|
|
50
|
-
|
|
51
|
-
const vb = svg.getAttribute('viewBox');
|
|
52
|
-
if (vb) {
|
|
53
|
-
const parts = vb.split(/[\s,]+/).map(Number);
|
|
54
|
-
if (parts.length >= 4 && parts[2] && parts[3]) {
|
|
55
|
-
return { width: parts[2], height: parts[3] };
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return { width: 600, height: 400 };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Ensure an SVG string has explicit width/height attributes.
|
|
64
|
-
*
|
|
65
|
-
* When an SVG only has a viewBox (no width/height), browsers loading it as
|
|
66
|
-
* an Image blob may use 300x150 as the intrinsic size instead of the viewBox
|
|
67
|
-
* dimensions. This causes clipping at non-1x DPI scaling. Injecting explicit
|
|
68
|
-
* width/height into the root <svg> tag fixes the intrinsic size.
|
|
69
|
-
*/
|
|
70
|
-
function ensureSVGDimensions(svgString: string, width: number, height: number): string {
|
|
71
|
-
// If the <svg> already has a width attribute, leave it alone
|
|
72
|
-
if (/^<svg[^>]*\swidth\s*=/.test(svgString)) return svgString;
|
|
73
|
-
// Inject width and height right after <svg
|
|
74
|
-
return svgString.replace(/^(<svg)/, `$1 width="${width}" height="${height}"`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// Font embedding
|
|
79
|
-
// ---------------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Collect unique font-family + font-weight combos from all <text> elements.
|
|
83
|
-
*/
|
|
84
|
-
function collectUsedFonts(svgElement: SVGElement): Map<string, Set<string>> {
|
|
85
|
-
const fonts = new Map<string, Set<string>>();
|
|
86
|
-
const textElements = svgElement.querySelectorAll('text');
|
|
87
|
-
|
|
88
|
-
for (const el of textElements) {
|
|
89
|
-
const family = el.getAttribute('font-family');
|
|
90
|
-
const weight = el.getAttribute('font-weight') || '400';
|
|
91
|
-
if (family) {
|
|
92
|
-
// Take the first font in the stack (e.g., "Inter, sans-serif" → "Inter")
|
|
93
|
-
const primary = family.split(',')[0].trim().replace(/["']/g, '');
|
|
94
|
-
if (!fonts.has(primary)) fonts.set(primary, new Set());
|
|
95
|
-
fonts.get(primary)!.add(String(weight));
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return fonts;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Find @font-face rules in document stylesheets that match the requested fonts.
|
|
104
|
-
* Returns the src URLs for .woff2 files.
|
|
105
|
-
*/
|
|
106
|
-
function findFontFaceRules(
|
|
107
|
-
usedFonts: Map<string, Set<string>>,
|
|
108
|
-
): Array<{ family: string; weight: string; style: string; url: string; format: string }> {
|
|
109
|
-
const results: Array<{
|
|
110
|
-
family: string;
|
|
111
|
-
weight: string;
|
|
112
|
-
style: string;
|
|
113
|
-
url: string;
|
|
114
|
-
format: string;
|
|
115
|
-
}> = [];
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
for (const sheet of document.styleSheets) {
|
|
119
|
-
let rules: CSSRuleList;
|
|
120
|
-
try {
|
|
121
|
-
rules = sheet.cssRules;
|
|
122
|
-
} catch {
|
|
123
|
-
// Cross-origin stylesheet, skip
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
for (const rule of rules) {
|
|
128
|
-
if (!(rule instanceof CSSFontFaceRule)) continue;
|
|
129
|
-
|
|
130
|
-
const familyRaw = rule.style.getPropertyValue('font-family').replace(/["']/g, '').trim();
|
|
131
|
-
const weight = rule.style.getPropertyValue('font-weight') || '400';
|
|
132
|
-
const style = rule.style.getPropertyValue('font-style') || 'normal';
|
|
133
|
-
const src = rule.style.getPropertyValue('src');
|
|
134
|
-
|
|
135
|
-
const weights = usedFonts.get(familyRaw);
|
|
136
|
-
if (!weights) continue;
|
|
137
|
-
|
|
138
|
-
// Check if this weight is used (handle ranges like "100 900")
|
|
139
|
-
const weightMatch = weights.has(weight) || weight.includes(' ');
|
|
140
|
-
if (!weightMatch) continue;
|
|
141
|
-
|
|
142
|
-
// Extract woff2 URL from src descriptor
|
|
143
|
-
const woff2Match = src.match(/url\(["']?([^"')]+\.woff2[^"')]*?)["']?\)/);
|
|
144
|
-
if (woff2Match) {
|
|
145
|
-
results.push({
|
|
146
|
-
family: familyRaw,
|
|
147
|
-
weight,
|
|
148
|
-
style,
|
|
149
|
-
url: woff2Match[1],
|
|
150
|
-
format: 'woff2',
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
} catch {
|
|
156
|
-
// Stylesheet access failed entirely, return empty
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return results;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Fetch font files and convert to base64 data URIs.
|
|
164
|
-
*/
|
|
165
|
-
async function fetchFontsAsBase64(
|
|
166
|
-
fontRules: Array<{ family: string; weight: string; style: string; url: string; format: string }>,
|
|
167
|
-
): Promise<FontFaceData[]> {
|
|
168
|
-
const results: FontFaceData[] = [];
|
|
169
|
-
|
|
170
|
-
const fetches = fontRules.map(async (rule) => {
|
|
171
|
-
try {
|
|
172
|
-
const response = await fetch(rule.url);
|
|
173
|
-
if (!response.ok) return;
|
|
174
|
-
const buffer = await response.arrayBuffer();
|
|
175
|
-
const base64 = arrayBufferToBase64(buffer);
|
|
176
|
-
results.push({
|
|
177
|
-
family: rule.family,
|
|
178
|
-
weight: rule.weight,
|
|
179
|
-
style: rule.style,
|
|
180
|
-
base64,
|
|
181
|
-
format: rule.format,
|
|
182
|
-
});
|
|
183
|
-
} catch {
|
|
184
|
-
// Font fetch failed (CORS, network, etc.) - skip this font
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
await Promise.all(fetches);
|
|
189
|
-
return results;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
193
|
-
let binary = '';
|
|
194
|
-
const bytes = new Uint8Array(buffer);
|
|
195
|
-
for (let i = 0; i < bytes.byteLength; i++) {
|
|
196
|
-
binary += String.fromCharCode(bytes[i]);
|
|
197
|
-
}
|
|
198
|
-
return btoa(binary);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Inject @font-face rules with base64 data URIs into the SVG's <defs>.
|
|
203
|
-
*/
|
|
204
|
-
function injectFontsIntoSVG(svgElement: SVGElement, fonts: FontFaceData[]): void {
|
|
205
|
-
if (fonts.length === 0) return;
|
|
206
|
-
|
|
207
|
-
const cssRules = fonts
|
|
208
|
-
.map(
|
|
209
|
-
(f) =>
|
|
210
|
-
`@font-face { font-family: '${f.family}'; font-weight: ${f.weight}; font-style: ${f.style}; src: url(data:font/${f.format};base64,${f.base64}) format('${f.format}'); }`,
|
|
211
|
-
)
|
|
212
|
-
.join('\n');
|
|
213
|
-
|
|
214
|
-
const ns = 'http://www.w3.org/2000/svg';
|
|
215
|
-
let defs = svgElement.querySelector('defs');
|
|
216
|
-
if (!defs) {
|
|
217
|
-
defs = document.createElementNS(ns, 'defs');
|
|
218
|
-
svgElement.insertBefore(defs, svgElement.firstChild);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const styleEl = document.createElementNS(ns, 'style');
|
|
222
|
-
styleEl.textContent = cssRules;
|
|
223
|
-
defs.insertBefore(styleEl, defs.firstChild);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Embed fonts into an SVG element by finding matching @font-face rules
|
|
228
|
-
* in the page's stylesheets, fetching the font files, and injecting
|
|
229
|
-
* them as base64 data URIs.
|
|
230
|
-
*
|
|
231
|
-
* Modifies the SVG element in place. Call this before serialization.
|
|
232
|
-
* If font fetching fails for any font, that font is silently skipped
|
|
233
|
-
* and the export proceeds with system font fallback for that face.
|
|
234
|
-
*/
|
|
235
|
-
async function embedFonts(svgElement: SVGElement): Promise<void> {
|
|
236
|
-
const usedFonts = collectUsedFonts(svgElement);
|
|
237
|
-
if (usedFonts.size === 0) return;
|
|
238
|
-
|
|
239
|
-
const fontRules = findFontFaceRules(usedFonts);
|
|
240
|
-
if (fontRules.length === 0) return;
|
|
241
|
-
|
|
242
|
-
const fontData = await fetchFontsAsBase64(fontRules);
|
|
243
|
-
injectFontsIntoSVG(svgElement, fontData);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ---------------------------------------------------------------------------
|
|
247
|
-
// SVG background color
|
|
248
|
-
// ---------------------------------------------------------------------------
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Read the chart's background color from its first rect element.
|
|
252
|
-
*/
|
|
253
|
-
function getSVGBackgroundColor(svgElement: SVGElement): string {
|
|
254
|
-
const firstRect = svgElement.querySelector('rect');
|
|
255
|
-
return firstRect?.getAttribute('fill') || '#ffffff';
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
// SVG export
|
|
260
|
-
// ---------------------------------------------------------------------------
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Serialize an SVG element to an XML string.
|
|
264
|
-
*
|
|
265
|
-
* @param svgElement - The rendered SVG element to serialize.
|
|
266
|
-
* @returns The SVG markup as a string.
|
|
267
|
-
*/
|
|
268
|
-
export function exportSVG(svgElement: SVGElement): string {
|
|
269
|
-
if (!svgElement.getAttribute('xmlns')) {
|
|
270
|
-
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
271
|
-
}
|
|
272
|
-
const serializer = new XMLSerializer();
|
|
273
|
-
return serializer.serializeToString(svgElement);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Serialize an SVG element with embedded fonts to an XML string.
|
|
278
|
-
*
|
|
279
|
-
* Collects font-family declarations from the SVG's text elements,
|
|
280
|
-
* finds matching @font-face rules in the page's stylesheets, fetches
|
|
281
|
-
* the font files, and embeds them as base64 data URIs. The resulting
|
|
282
|
-
* SVG is self-contained and renders correctly without external fonts.
|
|
283
|
-
*
|
|
284
|
-
* @param svgElement - The rendered SVG element to serialize.
|
|
285
|
-
* @param options - Export options.
|
|
286
|
-
* @returns A Promise resolving to the SVG markup as a string.
|
|
287
|
-
*/
|
|
288
|
-
export async function exportSVGWithFonts(
|
|
289
|
-
svgElement: SVGElement,
|
|
290
|
-
options?: SVGExportOptions,
|
|
291
|
-
): Promise<string> {
|
|
292
|
-
const clone = svgElement.cloneNode(true) as SVGElement;
|
|
293
|
-
const shouldEmbed = options?.embedFonts ?? true;
|
|
294
|
-
if (shouldEmbed) {
|
|
295
|
-
await embedFonts(clone);
|
|
296
|
-
}
|
|
297
|
-
return exportSVG(clone);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
// Raster export (PNG / JPG)
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Render an SVG element to a PNG Blob via a canvas.
|
|
306
|
-
*
|
|
307
|
-
* Embeds fonts by default so the exported image matches on-screen rendering.
|
|
308
|
-
* Set `embedFonts: false` to skip font embedding for faster exports.
|
|
309
|
-
*
|
|
310
|
-
* @param svgElement - The rendered SVG element.
|
|
311
|
-
* @param options - Optional DPI scaling and font embedding.
|
|
312
|
-
* @returns A Promise resolving to the PNG Blob.
|
|
313
|
-
*/
|
|
314
|
-
export async function exportPNG(svgElement: SVGElement, options?: PNGExportOptions): Promise<Blob> {
|
|
315
|
-
const dpi = options?.dpi ?? 2;
|
|
316
|
-
const shouldEmbed = options?.embedFonts ?? true;
|
|
317
|
-
const { width, height } = getSVGDimensions(svgElement);
|
|
318
|
-
const clone = svgElement.cloneNode(true) as SVGElement;
|
|
319
|
-
|
|
320
|
-
if (shouldEmbed) {
|
|
321
|
-
await embedFonts(clone);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const svgString = ensureSVGDimensions(exportSVG(clone), width, height);
|
|
325
|
-
|
|
326
|
-
if (!width || !height) {
|
|
327
|
-
throw new Error(`SVG has zero dimensions (width=${width}, height=${height})`);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const canvas = document.createElement('canvas');
|
|
331
|
-
canvas.width = width * dpi;
|
|
332
|
-
canvas.height = height * dpi;
|
|
333
|
-
|
|
334
|
-
const ctx = canvas.getContext('2d');
|
|
335
|
-
if (!ctx) {
|
|
336
|
-
throw new Error('Canvas 2D context not available');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
ctx.scale(dpi, dpi);
|
|
340
|
-
|
|
341
|
-
const img = new Image();
|
|
342
|
-
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
343
|
-
const url = URL.createObjectURL(blob);
|
|
344
|
-
|
|
345
|
-
return new Promise<Blob>((resolve, reject) => {
|
|
346
|
-
img.onload = () => {
|
|
347
|
-
ctx.drawImage(img, 0, 0, width, height);
|
|
348
|
-
URL.revokeObjectURL(url);
|
|
349
|
-
|
|
350
|
-
canvas.toBlob((result) => {
|
|
351
|
-
if (result) {
|
|
352
|
-
resolve(result);
|
|
353
|
-
} else {
|
|
354
|
-
reject(new Error('Canvas toBlob returned null'));
|
|
355
|
-
}
|
|
356
|
-
}, 'image/png');
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
img.onerror = () => {
|
|
360
|
-
URL.revokeObjectURL(url);
|
|
361
|
-
reject(
|
|
362
|
-
new Error(
|
|
363
|
-
`Failed to load SVG as image (width=${width}, height=${height}, svgLength=${svgString.length})`,
|
|
364
|
-
),
|
|
365
|
-
);
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
img.src = url;
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Render an SVG element to a JPEG Blob via a canvas.
|
|
374
|
-
*
|
|
375
|
-
* Same pipeline as exportPNG but outputs JPEG with configurable quality.
|
|
376
|
-
* The canvas is filled with the chart's background color before drawing
|
|
377
|
-
* to avoid transparent backgrounds rendering as black in JPEG format.
|
|
378
|
-
*
|
|
379
|
-
* @param svgElement - The rendered SVG element.
|
|
380
|
-
* @param options - Optional DPI scaling, JPEG quality, and font embedding.
|
|
381
|
-
* @returns A Promise resolving to the JPEG Blob.
|
|
382
|
-
*/
|
|
383
|
-
export async function exportJPG(svgElement: SVGElement, options?: JPGExportOptions): Promise<Blob> {
|
|
384
|
-
const dpi = options?.dpi ?? 2;
|
|
385
|
-
const quality = options?.quality ?? 0.92;
|
|
386
|
-
const shouldEmbed = options?.embedFonts ?? true;
|
|
387
|
-
const { width, height } = getSVGDimensions(svgElement);
|
|
388
|
-
const backgroundColor = getSVGBackgroundColor(svgElement);
|
|
389
|
-
const clone = svgElement.cloneNode(true) as SVGElement;
|
|
390
|
-
|
|
391
|
-
if (shouldEmbed) {
|
|
392
|
-
await embedFonts(clone);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const svgString = ensureSVGDimensions(exportSVG(clone), width, height);
|
|
396
|
-
|
|
397
|
-
if (!width || !height) {
|
|
398
|
-
throw new Error(`SVG has zero dimensions (width=${width}, height=${height})`);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const canvas = document.createElement('canvas');
|
|
402
|
-
canvas.width = width * dpi;
|
|
403
|
-
canvas.height = height * dpi;
|
|
404
|
-
|
|
405
|
-
const ctx = canvas.getContext('2d');
|
|
406
|
-
if (!ctx) {
|
|
407
|
-
throw new Error('Canvas 2D context not available');
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
ctx.fillStyle = backgroundColor;
|
|
411
|
-
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
412
|
-
|
|
413
|
-
ctx.scale(dpi, dpi);
|
|
414
|
-
|
|
415
|
-
const img = new Image();
|
|
416
|
-
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
417
|
-
const url = URL.createObjectURL(blob);
|
|
418
|
-
|
|
419
|
-
return new Promise<Blob>((resolve, reject) => {
|
|
420
|
-
img.onload = () => {
|
|
421
|
-
ctx.drawImage(img, 0, 0, width, height);
|
|
422
|
-
URL.revokeObjectURL(url);
|
|
423
|
-
|
|
424
|
-
canvas.toBlob(
|
|
425
|
-
(result) => {
|
|
426
|
-
if (result) {
|
|
427
|
-
resolve(result);
|
|
428
|
-
} else {
|
|
429
|
-
reject(new Error('Canvas toBlob returned null'));
|
|
430
|
-
}
|
|
431
|
-
},
|
|
432
|
-
'image/jpeg',
|
|
433
|
-
quality,
|
|
434
|
-
);
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
img.onerror = () => {
|
|
438
|
-
URL.revokeObjectURL(url);
|
|
439
|
-
reject(
|
|
440
|
-
new Error(
|
|
441
|
-
`Failed to load SVG as image (width=${width}, height=${height}, svgLength=${svgString.length})`,
|
|
442
|
-
),
|
|
443
|
-
);
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
img.src = url;
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// ---------------------------------------------------------------------------
|
|
451
|
-
// CSV export
|
|
452
|
-
// ---------------------------------------------------------------------------
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Convert an array of data objects to a CSV string.
|
|
456
|
-
*
|
|
457
|
-
* Uses the keys from the first row as column headers.
|
|
458
|
-
* Values are quoted if they contain commas, quotes, or newlines.
|
|
459
|
-
*
|
|
460
|
-
* @param data - Array of row objects.
|
|
461
|
-
* @returns CSV-formatted string.
|
|
462
|
-
*/
|
|
463
|
-
export function exportCSV(data: Record<string, unknown>[]): string {
|
|
464
|
-
if (data.length === 0) return '';
|
|
465
|
-
|
|
466
|
-
const headers = Object.keys(data[0]);
|
|
467
|
-
const rows = [headers.map(csvEscape).join(',')];
|
|
468
|
-
|
|
469
|
-
for (const row of data) {
|
|
470
|
-
const values = headers.map((h) => csvEscape(String(row[h] ?? '')));
|
|
471
|
-
rows.push(values.join(','));
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return rows.join('\n');
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function csvEscape(value: string): string {
|
|
478
|
-
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
|
|
479
|
-
return `"${value.replace(/"/g, '""')}"`;
|
|
480
|
-
}
|
|
481
|
-
return value;
|
|
482
|
-
}
|
package/src/gradient-utils.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SVG gradient utilities: creates <linearGradient> and <radialGradient>
|
|
3
|
-
* elements from GradientDef specs and resolves mark fills to url(#id) refs.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { GradientDef, LinearGradient, RadialGradient } from '@opendata-ai/openchart-core';
|
|
7
|
-
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
8
|
-
import { nextSvgId } from './svg-ids';
|
|
9
|
-
|
|
10
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Produce a stable, deterministic key for a GradientDef.
|
|
14
|
-
* Used for deduplication so identical gradients share one SVG element.
|
|
15
|
-
* Recursively sorts object keys for stability across property order variations.
|
|
16
|
-
*/
|
|
17
|
-
function gradientKey(def: GradientDef): string {
|
|
18
|
-
return sortedStringify(def);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function sortedStringify(value: unknown): string {
|
|
22
|
-
if (value === null || value === undefined) return String(value);
|
|
23
|
-
if (Array.isArray(value)) return `[${value.map(sortedStringify).join(',')}]`;
|
|
24
|
-
if (typeof value === 'object') {
|
|
25
|
-
const sorted = Object.keys(value)
|
|
26
|
-
.sort()
|
|
27
|
-
.map((k) => `${JSON.stringify(k)}:${sortedStringify((value as Record<string, unknown>)[k])}`);
|
|
28
|
-
return `{${sorted.join(',')}}`;
|
|
29
|
-
}
|
|
30
|
-
return JSON.stringify(value);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Create a single SVG gradient element (<linearGradient> or <radialGradient>).
|
|
35
|
-
* Uses gradientUnits="objectBoundingBox" so coordinates are in [0,1] space.
|
|
36
|
-
*/
|
|
37
|
-
function createGradientElement(def: GradientDef, id: string): SVGElement {
|
|
38
|
-
if (def.gradient === 'linear') {
|
|
39
|
-
return createLinearGradient(def, id);
|
|
40
|
-
}
|
|
41
|
-
return createRadialGradient(def, id);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function createLinearGradient(def: LinearGradient, id: string): SVGElement {
|
|
45
|
-
const el = document.createElementNS(SVG_NS, 'linearGradient');
|
|
46
|
-
el.setAttribute('id', id);
|
|
47
|
-
el.setAttribute('gradientUnits', 'objectBoundingBox');
|
|
48
|
-
el.setAttribute('x1', String(def.x1 ?? 0));
|
|
49
|
-
el.setAttribute('y1', String(def.y1 ?? 0));
|
|
50
|
-
el.setAttribute('x2', String(def.x2 ?? 0));
|
|
51
|
-
el.setAttribute('y2', String(def.y2 ?? 1));
|
|
52
|
-
|
|
53
|
-
for (const stop of def.stops) {
|
|
54
|
-
appendStop(el, stop);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return el;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function createRadialGradient(def: RadialGradient, id: string): SVGElement {
|
|
61
|
-
const el = document.createElementNS(SVG_NS, 'radialGradient');
|
|
62
|
-
el.setAttribute('id', id);
|
|
63
|
-
el.setAttribute('gradientUnits', 'objectBoundingBox');
|
|
64
|
-
// SVG radialGradient: cx/cy/r = outer circle, fx/fy/fr = focal (inner) circle
|
|
65
|
-
// Spec: x2/y2/r2 = outer, x1/y1/r1 = inner (focal)
|
|
66
|
-
el.setAttribute('cx', String(def.x2 ?? 0.5));
|
|
67
|
-
el.setAttribute('cy', String(def.y2 ?? 0.5));
|
|
68
|
-
el.setAttribute('r', String(def.r2 ?? 0.5));
|
|
69
|
-
el.setAttribute('fx', String(def.x1 ?? 0.5));
|
|
70
|
-
el.setAttribute('fy', String(def.y1 ?? 0.5));
|
|
71
|
-
el.setAttribute('fr', String(def.r1 ?? 0));
|
|
72
|
-
|
|
73
|
-
for (const stop of def.stops) {
|
|
74
|
-
appendStop(el, stop);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return el;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function appendStop(
|
|
81
|
-
parent: SVGElement,
|
|
82
|
-
stop: { offset: number; color: string; opacity?: number },
|
|
83
|
-
): void {
|
|
84
|
-
const stopEl = document.createElementNS(SVG_NS, 'stop');
|
|
85
|
-
stopEl.setAttribute('offset', String(stop.offset));
|
|
86
|
-
stopEl.setAttribute('stop-color', stop.color);
|
|
87
|
-
if (stop.opacity !== undefined) {
|
|
88
|
-
stopEl.setAttribute('stop-opacity', String(stop.opacity));
|
|
89
|
-
}
|
|
90
|
-
parent.appendChild(stopEl);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Scan all marks for GradientDef fill values, create SVG gradient elements
|
|
95
|
-
* in the provided <defs> node, and return a map from gradient key to element ID.
|
|
96
|
-
*
|
|
97
|
-
* Identical gradients (by key) share a single SVG element within one chart.
|
|
98
|
-
* IDs come from `nextSvgId` so they're globally unique across charts and
|
|
99
|
-
* share the counter with clip-path IDs (see svg-ids.ts). Gradient ID numbers
|
|
100
|
-
* may skip values when clip-paths consume counter slots - still monotonic,
|
|
101
|
-
* still unique.
|
|
102
|
-
*/
|
|
103
|
-
export function buildGradientDefs(
|
|
104
|
-
marks: Array<{ fill?: unknown }>,
|
|
105
|
-
defs: SVGElement,
|
|
106
|
-
): Map<string, string> {
|
|
107
|
-
const map = new Map<string, string>();
|
|
108
|
-
|
|
109
|
-
for (const mark of marks) {
|
|
110
|
-
const fill = mark.fill;
|
|
111
|
-
if (fill && isGradientDef(fill)) {
|
|
112
|
-
const key = gradientKey(fill);
|
|
113
|
-
if (!map.has(key)) {
|
|
114
|
-
const id = nextSvgId('oc-grad');
|
|
115
|
-
const el = createGradientElement(fill, id);
|
|
116
|
-
defs.appendChild(el);
|
|
117
|
-
map.set(key, id);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return map;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Resolve a mark's fill value to a CSS/SVG fill string.
|
|
127
|
-
* Returns the color string directly for plain fills,
|
|
128
|
-
* or "url(#gradientId)" for gradient fills.
|
|
129
|
-
*/
|
|
130
|
-
export function resolveMarkFill(
|
|
131
|
-
fill: string | GradientDef,
|
|
132
|
-
gradientMap: Map<string, string>,
|
|
133
|
-
): string {
|
|
134
|
-
if (typeof fill === 'string') return fill;
|
|
135
|
-
const key = gradientKey(fill);
|
|
136
|
-
const id = gradientMap.get(key);
|
|
137
|
-
return id ? `url(#${id})` : '#000000';
|
|
138
|
-
}
|