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.
@@ -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>