svelte-plots-basic 3.2.1 → 3.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/LICENSE +21 -0
- package/README.md +36 -7
- package/package.json +10 -1
- package/src/2d/Area.svelte +0 -2
- package/src/2d/Axes.svelte +88 -36
- package/src/2d/Bars.svelte +4 -6
- package/src/2d/Box.svelte +5 -2
- package/src/2d/ColormapLegend.svelte +5 -2
- package/src/2d/Heatmap.svelte +91 -24
- package/src/2d/Legend.svelte +21 -8
- package/src/2d/Lines.svelte +0 -2
- package/src/2d/Multilines.svelte +1 -2
- package/src/2d/Points.svelte +0 -1
- package/src/2d/Rectangles.svelte +0 -2
- package/src/2d/TextLabels.svelte +18 -4
- package/src/2d/TextLegend.svelte +12 -3
- package/src/2d/XAxis.svelte +5 -1
- package/src/2d/YAxis.svelte +5 -1
- package/src/3d/Axes.svelte +29 -19
- package/src/3d/Mesh.svelte +62 -26
- package/src/3d/Segments.svelte +2 -2
- package/src/3d/TextLabels.svelte +2 -1
- package/src/3d/XAxis.svelte +5 -2
- package/src/3d/YAxis.svelte +5 -2
- package/src/3d/ZAxis.svelte +5 -2
- package/src/ExportDialog.svelte +361 -0
- package/src/methods.js +139 -67
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Modal dialog for advanced PNG export with live preview and dimension/resolution controls.
|
|
3
|
+
|
|
4
|
+
Properties:
|
|
5
|
+
- `plotElement` - reference to the SVG element to export.
|
|
6
|
+
- `fileName` - file name for download (without extension).
|
|
7
|
+
- `initialWidth` - initial width in cm (default: 8).
|
|
8
|
+
- `initialRes` - initial resolution in DPI (default: 300).
|
|
9
|
+
- `onclose` - callback when dialog is closed.
|
|
10
|
+
-->
|
|
11
|
+
<script>
|
|
12
|
+
import { downloadPNG } from './methods.js';
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
plotElement,
|
|
16
|
+
fileName = 'plot',
|
|
17
|
+
initialWidth = 8,
|
|
18
|
+
initialRes = 300,
|
|
19
|
+
onclose
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
// aspect ratio from the actual SVG element
|
|
23
|
+
const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
|
|
24
|
+
const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
|
|
25
|
+
const aspectRatio = svgWidth / svgHeight;
|
|
26
|
+
|
|
27
|
+
// editable state — height always derived from width to preserve aspect ratio
|
|
28
|
+
const resOptions = [100, 150, 300, 600];
|
|
29
|
+
const parsedInitialRes = Number(initialRes);
|
|
30
|
+
const defaultRes = resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300;
|
|
31
|
+
|
|
32
|
+
let width = $state(initialWidth);
|
|
33
|
+
const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
|
|
34
|
+
let res = $state(defaultRes);
|
|
35
|
+
let name = $state(fileName);
|
|
36
|
+
const nameValid = $derived(name.trim().length > 0);
|
|
37
|
+
|
|
38
|
+
// preview container ref
|
|
39
|
+
let previewContainer = $state(null);
|
|
40
|
+
|
|
41
|
+
// max preview width in pixels
|
|
42
|
+
const maxPreviewWidth = 400;
|
|
43
|
+
const previewHeight = $derived(Math.round(maxPreviewWidth / aspectRatio));
|
|
44
|
+
|
|
45
|
+
// clone SVG into preview container on mount
|
|
46
|
+
$effect(() => {
|
|
47
|
+
if (!previewContainer || !plotElement) return;
|
|
48
|
+
|
|
49
|
+
// clear previous content
|
|
50
|
+
previewContainer.innerHTML = '';
|
|
51
|
+
|
|
52
|
+
// clone the SVG
|
|
53
|
+
const clone = plotElement.cloneNode(true);
|
|
54
|
+
|
|
55
|
+
clone.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
|
|
56
|
+
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
57
|
+
clone.style.width = '100%';
|
|
58
|
+
clone.style.height = '100%';
|
|
59
|
+
clone.style.position = 'static';
|
|
60
|
+
|
|
61
|
+
// fixes duplicate clipPath IDs
|
|
62
|
+
const clipPaths = clone.querySelectorAll('clipPath');
|
|
63
|
+
clipPaths.forEach((cp) => {
|
|
64
|
+
const oldId = cp.getAttribute('id');
|
|
65
|
+
if (oldId) {
|
|
66
|
+
const newId = 'preview_' + oldId;
|
|
67
|
+
cp.setAttribute('id', newId);
|
|
68
|
+
const refs = clone.querySelectorAll(`[clip-path="url(#${oldId})"]`);
|
|
69
|
+
refs.forEach((ref) => ref.setAttribute('clip-path', `url(#${newId})`));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
previewContainer.appendChild(clone);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function handleWidthChange(e) {
|
|
77
|
+
width = parseFloat(e.target.value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let saving = $state(false);
|
|
81
|
+
|
|
82
|
+
function handleSave() {
|
|
83
|
+
if (saving) return;
|
|
84
|
+
saving = true;
|
|
85
|
+
|
|
86
|
+
const container = plotElement.parentElement;
|
|
87
|
+
const origStyle = container.style.cssText;
|
|
88
|
+
|
|
89
|
+
// scale reference width with export width so smaller plots get
|
|
90
|
+
// a smaller scale category → relatively larger fonts and details
|
|
91
|
+
// 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
|
|
92
|
+
const refWidth = Math.round(200 + width * 40);
|
|
93
|
+
const refHeight = Math.round(refWidth / aspectRatio);
|
|
94
|
+
|
|
95
|
+
function doExport() {
|
|
96
|
+
requestAnimationFrame(() => {
|
|
97
|
+
requestAnimationFrame(() => {
|
|
98
|
+
// onSerialize callback fires after downloadPNG has serialized the SVG,
|
|
99
|
+
// so we can safely restore the container and close the dialog
|
|
100
|
+
downloadPNG(plotElement, name.trim(), width, height, res, () => {
|
|
101
|
+
container.style.cssText = origStyle;
|
|
102
|
+
saving = false;
|
|
103
|
+
onclose();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// temporarily resize plot container to a fixed reference size (off-screen,
|
|
110
|
+
// hidden behind the dialog backdrop) so Svelte recomputes the layout
|
|
111
|
+
container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
|
|
112
|
+
|
|
113
|
+
// wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
|
|
114
|
+
// fallback timeout in case container is already at the target size
|
|
115
|
+
const fallbackTimer = setTimeout(() => { observer.disconnect(); doExport(); }, 500);
|
|
116
|
+
const observer = new ResizeObserver(() => {
|
|
117
|
+
observer.disconnect();
|
|
118
|
+
clearTimeout(fallbackTimer);
|
|
119
|
+
doExport();
|
|
120
|
+
});
|
|
121
|
+
observer.observe(plotElement);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function handleBackdropClick() {
|
|
125
|
+
if (!saving) onclose();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleDialogClick(e) {
|
|
129
|
+
e.stopPropagation();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// close on Escape key
|
|
133
|
+
$effect(() => {
|
|
134
|
+
function handleKeydown(e) {
|
|
135
|
+
if (e.key === 'Escape' && !saving) {
|
|
136
|
+
onclose();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
document.addEventListener('keydown', handleKeydown);
|
|
140
|
+
return () => document.removeEventListener('keydown', handleKeydown);
|
|
141
|
+
});
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
145
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
146
|
+
<div class="export-dialog-backdrop" onclick={handleBackdropClick}>
|
|
147
|
+
<div class="export-dialog" onclick={handleDialogClick}>
|
|
148
|
+
<div class="export-dialog-title">Export as PNG</div>
|
|
149
|
+
|
|
150
|
+
<div class="export-dialog-body">
|
|
151
|
+
<!-- Preview -->
|
|
152
|
+
<div class="export-dialog-preview"
|
|
153
|
+
style="width:{maxPreviewWidth}px;height:{previewHeight}px;"
|
|
154
|
+
bind:this={previewContainer}
|
|
155
|
+
></div>
|
|
156
|
+
|
|
157
|
+
<!-- Controls -->
|
|
158
|
+
<div class="export-dialog-controls">
|
|
159
|
+
<div class="export-dialog-control">
|
|
160
|
+
<label for="export-name">File name:</label>
|
|
161
|
+
<input id="export-name" class="export-dialog-name" class:invalid={!nameValid} type="text" bind:value={name} />
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div class="export-dialog-control">
|
|
165
|
+
<label for="export-width">Size, cm: ({width} × {height})</label>
|
|
166
|
+
<input id="export-width" type="range" min="4" max="20" step="1" value={width} oninput={handleWidthChange} />
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="export-dialog-res">
|
|
170
|
+
<span class="export-dialog-res-label">Resolution, dpi:</span>
|
|
171
|
+
<div class="export-dialog-res-options">
|
|
172
|
+
{#each resOptions as opt}
|
|
173
|
+
<label class="export-dialog-res-btn" class:selected={res === opt}>
|
|
174
|
+
<input type="radio" name="export-res" value={opt} bind:group={res} />
|
|
175
|
+
{opt}
|
|
176
|
+
</label>
|
|
177
|
+
{/each}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="export-dialog-actions">
|
|
184
|
+
<button type="button" class="export-dialog-cancel" onclick={onclose} disabled={saving}>Cancel</button>
|
|
185
|
+
<button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid}>Save</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<style>
|
|
191
|
+
.export-dialog-backdrop {
|
|
192
|
+
position: fixed;
|
|
193
|
+
top: 0;
|
|
194
|
+
left: 0;
|
|
195
|
+
right: 0;
|
|
196
|
+
bottom: 0;
|
|
197
|
+
background: rgba(0, 0, 0, 0.4);
|
|
198
|
+
z-index: 1000;
|
|
199
|
+
display: flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
justify-content: center;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.export-dialog {
|
|
205
|
+
background: #fefefe;
|
|
206
|
+
border-radius: 0.5em;
|
|
207
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
|
208
|
+
padding: 1.25em;
|
|
209
|
+
min-width: 440px;
|
|
210
|
+
max-width: 90vw;
|
|
211
|
+
font-family: Arial, Helvetica, sans-serif;
|
|
212
|
+
color: #606060;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.export-dialog-title {
|
|
216
|
+
font-size: 1.1em;
|
|
217
|
+
font-weight: bold;
|
|
218
|
+
margin-bottom: 1em;
|
|
219
|
+
color: #303030;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.export-dialog-body {
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: column;
|
|
225
|
+
align-items: center;
|
|
226
|
+
gap: 1em;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.export-dialog-preview {
|
|
230
|
+
border: 1px solid #dadada;
|
|
231
|
+
background: #fff;
|
|
232
|
+
overflow: hidden;
|
|
233
|
+
border-radius: 0.25em;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.export-dialog-controls {
|
|
237
|
+
width: 100%;
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
gap: 0.5em;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.export-dialog-control {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
gap: 0.5em;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.export-dialog-control label {
|
|
250
|
+
min-width: 110px;
|
|
251
|
+
font-size: 0.85em;
|
|
252
|
+
white-space: nowrap;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.export-dialog-control input[type="range"] {
|
|
256
|
+
flex: 1;
|
|
257
|
+
accent-color: #443333;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.export-dialog-name {
|
|
261
|
+
flex: 1;
|
|
262
|
+
padding: 0.25em 0.4em;
|
|
263
|
+
border: 1px solid #dadada;
|
|
264
|
+
border-radius: 0.35em;
|
|
265
|
+
font-size: 0.85em;
|
|
266
|
+
color: #606060;
|
|
267
|
+
background: #fafafa;
|
|
268
|
+
outline: none;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.export-dialog-name:focus {
|
|
272
|
+
border-color: #909090;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.export-dialog-name.invalid {
|
|
276
|
+
border-color: crimson;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.export-dialog-res {
|
|
280
|
+
display: flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 0.5em;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.export-dialog-res-label {
|
|
286
|
+
min-width: 110px;
|
|
287
|
+
font-size: 0.85em;
|
|
288
|
+
white-space: nowrap;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.export-dialog-res-options {
|
|
292
|
+
display: flex;
|
|
293
|
+
gap: 0.25em;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.export-dialog-res-btn {
|
|
297
|
+
box-sizing: border-box;
|
|
298
|
+
color: #606060;
|
|
299
|
+
border: none;
|
|
300
|
+
box-shadow: none;
|
|
301
|
+
background: #fafafa;
|
|
302
|
+
border-radius: 0.35em;
|
|
303
|
+
padding: 0.25em 0.5em;
|
|
304
|
+
font-size: 0.85em;
|
|
305
|
+
cursor: pointer;
|
|
306
|
+
user-select: none;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.export-dialog-res-btn:hover {
|
|
310
|
+
background: #dadada;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.export-dialog-res-btn.selected {
|
|
314
|
+
background: #443333;
|
|
315
|
+
color: #fafafa;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.export-dialog-res-btn input {
|
|
319
|
+
display: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.export-dialog-actions {
|
|
323
|
+
display: flex;
|
|
324
|
+
justify-content: flex-end;
|
|
325
|
+
gap: 0.5em;
|
|
326
|
+
margin-top: 1em;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.export-dialog-actions button {
|
|
330
|
+
padding: 0.4em 1.2em;
|
|
331
|
+
border-radius: 0.35em;
|
|
332
|
+
border: none;
|
|
333
|
+
cursor: pointer;
|
|
334
|
+
font-size: 0.85em;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.export-dialog-cancel {
|
|
338
|
+
background: #fafafa;
|
|
339
|
+
color: #606060;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.export-dialog-cancel:hover {
|
|
343
|
+
background: #dadada;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.export-dialog-save {
|
|
347
|
+
background: #443333;
|
|
348
|
+
color: #fafafa;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.export-dialog-save:hover {
|
|
352
|
+
background: #665555;
|
|
353
|
+
color: #fafafa;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.export-dialog-save:disabled {
|
|
357
|
+
background: #dadada;
|
|
358
|
+
color: #909090;
|
|
359
|
+
cursor: default;
|
|
360
|
+
}
|
|
361
|
+
</style>
|