svelte-plots-basic 3.3.0 → 4.0.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 +60 -20
- package/package.json +12 -4
- package/src/2d/Area.svelte +8 -9
- package/src/2d/Axes.svelte +78 -20
- package/src/2d/AxisLines.svelte +4 -6
- package/src/2d/AxisTickLabels.svelte +1 -3
- package/src/2d/Bars.svelte +31 -11
- package/src/2d/Box.svelte +5 -2
- package/src/2d/ColormapLegend.svelte +5 -2
- package/src/2d/Heatmap.svelte +98 -25
- package/src/2d/Legend.svelte +83 -31
- package/src/2d/Lines.svelte +6 -7
- package/src/2d/Multilines.svelte +10 -10
- package/src/2d/Points.svelte +9 -10
- package/src/2d/Rectangles.svelte +47 -20
- package/src/2d/Segments.svelte +3 -3
- package/src/2d/TextLabels.svelte +30 -14
- package/src/2d/TextLegend.svelte +52 -6
- package/src/2d/XAxis.svelte +5 -1
- package/src/2d/YAxis.svelte +6 -2
- package/src/3d/Axes.svelte +31 -22
- package/src/3d/AxisLines.svelte +3 -4
- package/src/3d/AxisTickLabels.svelte +2 -2
- package/src/3d/Lines.svelte +8 -8
- package/src/3d/Mesh.svelte +73 -35
- package/src/3d/Points.svelte +8 -9
- package/src/3d/Segments.svelte +11 -13
- package/src/3d/TextLabels.svelte +27 -14
- package/src/3d/XAxis.svelte +7 -6
- package/src/3d/YAxis.svelte +9 -8
- package/src/3d/ZAxis.svelte +9 -8
- package/src/ExportDialog.svelte +237 -65
- package/src/methods.js +342 -176
package/src/ExportDialog.svelte
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
- `onclose` - callback when dialog is closed.
|
|
10
10
|
-->
|
|
11
11
|
<script>
|
|
12
|
+
import { onDestroy } from 'svelte';
|
|
12
13
|
import { downloadPNG } from './methods.js';
|
|
13
14
|
|
|
14
15
|
let {
|
|
@@ -19,20 +20,40 @@
|
|
|
19
20
|
onclose
|
|
20
21
|
} = $props();
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const resOptions = [100, 150, 300, 600];
|
|
24
|
+
|
|
25
|
+
// The dialog intentionally takes a snapshot of its initial props when it opens.
|
|
26
|
+
function getInitialValues() {
|
|
27
|
+
const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
|
|
28
|
+
const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
|
|
29
|
+
const parsedInitialRes = Number(initialRes);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
svgWidth,
|
|
33
|
+
svgHeight,
|
|
34
|
+
aspectRatio: svgWidth / svgHeight,
|
|
35
|
+
width: initialWidth,
|
|
36
|
+
res: resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300,
|
|
37
|
+
name: typeof fileName === 'string' ? fileName : 'plot'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const initialValues = getInitialValues();
|
|
42
|
+
const svgWidth = initialValues.svgWidth;
|
|
43
|
+
const svgHeight = initialValues.svgHeight;
|
|
44
|
+
const aspectRatio = initialValues.aspectRatio;
|
|
26
45
|
|
|
27
46
|
// editable state — height always derived from width to preserve aspect ratio
|
|
28
|
-
let width = $state(
|
|
47
|
+
let width = $state(initialValues.width);
|
|
29
48
|
const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
|
|
30
|
-
let res = $state(
|
|
31
|
-
let name = $state(
|
|
49
|
+
let res = $state(initialValues.res);
|
|
50
|
+
let name = $state(initialValues.name);
|
|
32
51
|
const nameValid = $derived(name.trim().length > 0);
|
|
52
|
+
let exportError = $state('');
|
|
33
53
|
|
|
34
54
|
// preview container ref
|
|
35
55
|
let previewContainer = $state(null);
|
|
56
|
+
let dialogElement = $state(null);
|
|
36
57
|
|
|
37
58
|
// max preview width in pixels
|
|
38
59
|
const maxPreviewWidth = 400;
|
|
@@ -73,75 +94,207 @@
|
|
|
73
94
|
width = parseFloat(e.target.value);
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
const resOptions = [100, 150, 300, 600];
|
|
77
97
|
let saving = $state(false);
|
|
98
|
+
let activeExportCleanup = null;
|
|
99
|
+
let destroyed = false;
|
|
100
|
+
|
|
101
|
+
onDestroy(() => {
|
|
102
|
+
destroyed = true;
|
|
103
|
+
activeExportCleanup?.();
|
|
104
|
+
if (dialogElement?.open) dialogElement.close();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
$effect(() => {
|
|
108
|
+
if (dialogElement && !dialogElement.open) {
|
|
109
|
+
dialogElement.showModal();
|
|
110
|
+
dialogElement.querySelector('#export-name')?.focus();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function closeDialog() {
|
|
115
|
+
if (dialogElement?.open) dialogElement.close();
|
|
116
|
+
onclose();
|
|
117
|
+
}
|
|
78
118
|
|
|
79
119
|
function handleSave() {
|
|
80
120
|
if (saving) return;
|
|
81
|
-
|
|
121
|
+
|
|
122
|
+
exportError = '';
|
|
123
|
+
|
|
124
|
+
const exportWidth = Number(width);
|
|
125
|
+
const exportHeight = Number(height);
|
|
126
|
+
const exportRes = Number(res);
|
|
127
|
+
|
|
128
|
+
const hasValidSize = Number.isFinite(exportWidth) && exportWidth >= 1 && exportWidth <= 30 &&
|
|
129
|
+
Number.isFinite(exportHeight) && exportHeight >= 1 && exportHeight <= 30;
|
|
130
|
+
const hasValidResolution = Number.isFinite(exportRes) && exportRes >= 50 && exportRes <= 1200;
|
|
131
|
+
|
|
132
|
+
if (!hasValidSize || !hasValidResolution) {
|
|
133
|
+
exportError = 'Export size or resolution is invalid.';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!plotElement || !(plotElement instanceof SVGElement) || !plotElement.parentElement) {
|
|
138
|
+
exportError = 'The plot is not available for export.';
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
82
141
|
|
|
83
142
|
const container = plotElement.parentElement;
|
|
84
143
|
const origStyle = container.style.cssText;
|
|
144
|
+
let observer = null;
|
|
145
|
+
let fallbackTimer = null;
|
|
146
|
+
let firstFrame = null;
|
|
147
|
+
let secondFrame = null;
|
|
148
|
+
let exportStarted = false;
|
|
149
|
+
let restored = false;
|
|
150
|
+
|
|
151
|
+
function restorePlot() {
|
|
152
|
+
if (restored) return;
|
|
153
|
+
restored = true;
|
|
154
|
+
|
|
155
|
+
observer?.disconnect();
|
|
156
|
+
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
|
157
|
+
if (firstFrame !== null) cancelAnimationFrame(firstFrame);
|
|
158
|
+
if (secondFrame !== null) cancelAnimationFrame(secondFrame);
|
|
159
|
+
|
|
160
|
+
container.style.cssText = origStyle;
|
|
161
|
+
saving = false;
|
|
162
|
+
|
|
163
|
+
if (activeExportCleanup === restorePlot) {
|
|
164
|
+
activeExportCleanup = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function failExport(error) {
|
|
169
|
+
console.error('Failed to prepare the plot for PNG export.', error);
|
|
170
|
+
exportError = 'PNG export could not be started.';
|
|
171
|
+
restorePlot();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function finishExport() {
|
|
175
|
+
restorePlot();
|
|
176
|
+
if (!destroyed) closeDialog();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
activeExportCleanup = restorePlot;
|
|
180
|
+
saving = true;
|
|
85
181
|
|
|
86
182
|
// scale reference width with export width so smaller plots get
|
|
87
183
|
// a smaller scale category → relatively larger fonts and details
|
|
88
184
|
// 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
|
|
89
|
-
const refWidth = Math.round(200 +
|
|
185
|
+
const refWidth = Math.round(200 + exportWidth * 40);
|
|
90
186
|
const refHeight = Math.round(refWidth / aspectRatio);
|
|
91
187
|
|
|
92
188
|
function doExport() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
189
|
+
if (exportStarted || restored) return;
|
|
190
|
+
exportStarted = true;
|
|
191
|
+
|
|
192
|
+
observer?.disconnect();
|
|
193
|
+
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
firstFrame = requestAnimationFrame(() => {
|
|
197
|
+
firstFrame = null;
|
|
198
|
+
secondFrame = requestAnimationFrame(() => {
|
|
199
|
+
secondFrame = null;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
// The callback fires after downloadPNG has serialized the SVG,
|
|
203
|
+
// so the live container can be restored before image encoding finishes.
|
|
204
|
+
const result = downloadPNG(
|
|
205
|
+
plotElement,
|
|
206
|
+
name.trim(),
|
|
207
|
+
exportWidth,
|
|
208
|
+
exportHeight,
|
|
209
|
+
exportRes,
|
|
210
|
+
finishExport
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (result === null) {
|
|
214
|
+
failExport(new Error('downloadPNG rejected the export settings.'));
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
failExport(error);
|
|
218
|
+
}
|
|
101
219
|
});
|
|
102
220
|
});
|
|
103
|
-
})
|
|
221
|
+
} catch (error) {
|
|
222
|
+
failExport(error);
|
|
223
|
+
}
|
|
104
224
|
}
|
|
105
225
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
observer
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
226
|
+
try {
|
|
227
|
+
// temporarily resize plot container to a fixed reference size (off-screen,
|
|
228
|
+
// hidden behind the dialog backdrop) so Svelte recomputes the layout
|
|
229
|
+
container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
|
|
230
|
+
|
|
231
|
+
// wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
|
|
232
|
+
// fallback timeout in case container is already at the target size
|
|
233
|
+
fallbackTimer = setTimeout(doExport, 500);
|
|
234
|
+
observer = new ResizeObserver(doExport);
|
|
235
|
+
observer.observe(plotElement);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
failExport(error);
|
|
238
|
+
}
|
|
119
239
|
}
|
|
120
240
|
|
|
121
|
-
function
|
|
122
|
-
|
|
241
|
+
function handleCancel(e) {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
if (!saving) closeDialog();
|
|
123
244
|
}
|
|
124
245
|
|
|
125
246
|
function handleDialogClick(e) {
|
|
126
247
|
e.stopPropagation();
|
|
248
|
+
if (saving) return;
|
|
249
|
+
|
|
250
|
+
const bounds = e.currentTarget.getBoundingClientRect();
|
|
251
|
+
const clickedOutside = e.clientX < bounds.left || e.clientX > bounds.right ||
|
|
252
|
+
e.clientY < bounds.top || e.clientY > bounds.bottom;
|
|
253
|
+
|
|
254
|
+
if (clickedOutside) closeDialog();
|
|
127
255
|
}
|
|
128
256
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
257
|
+
// Keep keyboard events from activating an enclosing custom modal's handlers.
|
|
258
|
+
// Native dialog behavior still handles Escape; the explicit Tab order avoids
|
|
259
|
+
// browser/OS differences in which form controls participate in navigation.
|
|
260
|
+
function handleKeydown(e) {
|
|
261
|
+
e.stopPropagation();
|
|
262
|
+
|
|
263
|
+
if (e.key !== 'Tab' || !dialogElement) return;
|
|
264
|
+
|
|
265
|
+
const resolutionInput = dialogElement.querySelector(
|
|
266
|
+
'input[name="export-res"]:checked'
|
|
267
|
+
) ?? dialogElement.querySelector('input[name="export-res"]');
|
|
268
|
+
|
|
269
|
+
const controls = [
|
|
270
|
+
dialogElement.querySelector('#export-name'),
|
|
271
|
+
dialogElement.querySelector('#export-width'),
|
|
272
|
+
resolutionInput,
|
|
273
|
+
...dialogElement.querySelectorAll('.export-dialog-actions button:not(:disabled)')
|
|
274
|
+
].filter(Boolean);
|
|
275
|
+
|
|
276
|
+
if (controls.length === 0) return;
|
|
277
|
+
|
|
278
|
+
const currentIndex = controls.indexOf(document.activeElement);
|
|
279
|
+
const nextIndex = currentIndex === -1
|
|
280
|
+
? (e.shiftKey ? controls.length - 1 : 0)
|
|
281
|
+
: (currentIndex + (e.shiftKey ? -1 : 1) + controls.length) % controls.length;
|
|
282
|
+
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
controls[nextIndex].focus();
|
|
285
|
+
}
|
|
139
286
|
</script>
|
|
140
287
|
|
|
141
|
-
<!--
|
|
142
|
-
<!-- svelte-ignore
|
|
143
|
-
<
|
|
144
|
-
|
|
288
|
+
<!-- Native modal events provide backdrop closing and isolate an enclosing custom modal's keyboard handlers. -->
|
|
289
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
290
|
+
<dialog
|
|
291
|
+
class="export-dialog"
|
|
292
|
+
aria-label="Export as PNG"
|
|
293
|
+
bind:this={dialogElement}
|
|
294
|
+
oncancel={handleCancel}
|
|
295
|
+
onclick={handleDialogClick}
|
|
296
|
+
onkeydown={handleKeydown}
|
|
297
|
+
>
|
|
145
298
|
<div class="export-dialog-title">Export as PNG</div>
|
|
146
299
|
|
|
147
300
|
<div class="export-dialog-body">
|
|
@@ -168,44 +321,40 @@
|
|
|
168
321
|
<div class="export-dialog-res-options">
|
|
169
322
|
{#each resOptions as opt}
|
|
170
323
|
<label class="export-dialog-res-btn" class:selected={res === opt}>
|
|
171
|
-
<input type="radio" name="export-res" value={opt}
|
|
172
|
-
onchange={() => res = opt} />
|
|
324
|
+
<input type="radio" name="export-res" value={opt} bind:group={res} />
|
|
173
325
|
{opt}
|
|
174
326
|
</label>
|
|
175
327
|
{/each}
|
|
176
328
|
</div>
|
|
177
329
|
</div>
|
|
330
|
+
|
|
331
|
+
{#if exportError}
|
|
332
|
+
<p class="export-dialog-error" role="alert">{exportError}</p>
|
|
333
|
+
{/if}
|
|
178
334
|
</div>
|
|
179
335
|
</div>
|
|
180
336
|
|
|
181
337
|
<div class="export-dialog-actions">
|
|
182
|
-
<button class="export-dialog-cancel" onclick={
|
|
183
|
-
<button class="export-dialog-save" onclick={handleSave} disabled={!nameValid}>Save</button>
|
|
338
|
+
<button type="button" class="export-dialog-cancel" onclick={closeDialog} disabled={saving}>Cancel</button>
|
|
339
|
+
<button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid || saving}>Save</button>
|
|
184
340
|
</div>
|
|
185
|
-
|
|
186
|
-
</div>
|
|
341
|
+
</dialog>
|
|
187
342
|
|
|
188
343
|
<style>
|
|
189
|
-
.export-dialog
|
|
190
|
-
position: fixed;
|
|
191
|
-
top: 0;
|
|
192
|
-
left: 0;
|
|
193
|
-
right: 0;
|
|
194
|
-
bottom: 0;
|
|
344
|
+
.export-dialog::backdrop {
|
|
195
345
|
background: rgba(0, 0, 0, 0.4);
|
|
196
|
-
z-index: 1000;
|
|
197
|
-
display: flex;
|
|
198
|
-
align-items: center;
|
|
199
|
-
justify-content: center;
|
|
200
346
|
}
|
|
201
347
|
|
|
202
348
|
.export-dialog {
|
|
349
|
+
box-sizing: border-box;
|
|
203
350
|
background: #fefefe;
|
|
351
|
+
border: none;
|
|
204
352
|
border-radius: 0.5em;
|
|
205
353
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
|
206
354
|
padding: 1.25em;
|
|
207
355
|
min-width: 440px;
|
|
208
356
|
max-width: 90vw;
|
|
357
|
+
margin: auto;
|
|
209
358
|
font-family: Arial, Helvetica, sans-serif;
|
|
210
359
|
color: #606060;
|
|
211
360
|
}
|
|
@@ -314,7 +463,30 @@
|
|
|
314
463
|
}
|
|
315
464
|
|
|
316
465
|
.export-dialog-res-btn input {
|
|
317
|
-
|
|
466
|
+
position: absolute;
|
|
467
|
+
width: 1px;
|
|
468
|
+
height: 1px;
|
|
469
|
+
padding: 0;
|
|
470
|
+
margin: -1px;
|
|
471
|
+
overflow: hidden;
|
|
472
|
+
clip: rect(0, 0, 0, 0);
|
|
473
|
+
clip-path: inset(50%);
|
|
474
|
+
white-space: nowrap;
|
|
475
|
+
border: 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.export-dialog-name:focus-visible,
|
|
479
|
+
.export-dialog-control input[type="range"]:focus-visible,
|
|
480
|
+
.export-dialog-actions button:focus-visible,
|
|
481
|
+
.export-dialog-res-btn:focus-within {
|
|
482
|
+
outline: 2px solid #999191;
|
|
483
|
+
outline-offset: 2px;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.export-dialog-error {
|
|
487
|
+
margin: 0;
|
|
488
|
+
color: crimson;
|
|
489
|
+
font-size: 0.85em;
|
|
318
490
|
}
|
|
319
491
|
|
|
320
492
|
.export-dialog-actions {
|