svelte-plots-basic 3.2.1 → 3.3.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/README.md CHANGED
@@ -15,7 +15,13 @@ These websites and web-applications use `svelte-plots-basic` library:
15
15
 
16
16
  ## News
17
17
 
18
- ### 3.2.0
18
+ ### 3.3.0
19
+
20
+ * Added advanced "save as PNG" option where user can select size and resolution of the image as well as change the filename.
21
+ * several small improvements and bug fixes.
22
+
23
+
24
+ ### 3.2.1
19
25
 
20
26
  * Added mouse click handler option for `Heatmap.svelte` component (returns row and column index of the element).
21
27
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "svelte-plots-basic",
3
3
  "description": "Svelte components for creating simple plots",
4
- "version": "3.2.1",
4
+ "version": "3.3.0",
5
5
  "author": "Sergey Kucheryavskiy",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -37,6 +37,7 @@
37
37
 
38
38
  import AxisLines from './AxisLines.svelte';
39
39
  import AxisTickLabels from './AxisTickLabels.svelte';
40
+ import ExportDialog from '../ExportDialog.svelte';
40
41
 
41
42
 
42
43
  let {
@@ -103,9 +104,14 @@
103
104
  downloadPNG(plotElement, fileName, pngWidth, pngHeight, pngRes);
104
105
  }
105
106
 
107
+ /* Open advanced PNG export dialog. */
108
+ let showExportDialog = $state(false);
109
+ function handleClickPNGAdvanced() { showExportDialog = true; }
110
+ function handleCloseExportDialog() { showExportDialog = false; }
111
+
106
112
 
107
113
  // unique ID for clip path
108
- const clipPathID = 'plottingArea' + Math.round(Math.random() * 10000);
114
+ const clipPathID = 'plottingArea' + Math.random().toString(36).slice(2);
109
115
 
110
116
  // pointers to plot DOM element and its width and height
111
117
  let plotElement = $state(0);
@@ -166,7 +172,7 @@
166
172
 
167
173
  // parameters of plot title
168
174
  const plotTitle = $derived(text2svg(title));
169
- const titleHeight = $derived(plotTitle ? (plotTitle.length === title.left ? 1.0 : 1.4) : 0);
175
+ const titleHeight = $derived(plotTitle ? (plotTitle.length === title.length ? 1.0 : 1.4) : 0);
170
176
 
171
177
  // default parameters of box, axis elements, colomap legend and group legend
172
178
  let box = $state({show: false});
@@ -220,7 +226,7 @@
220
226
 
221
227
  {#if coords.tickCoords.length === 2 && coords.tickLabels.length === coords.tickCoords[1][0].length}
222
228
  <AxisTickLabels las={params.las} pos={params.pos} tickCoords={coords.tickCoords}
223
- tickLabels={coords.tickLabels} tickColor={Colors.DARKGRAY} hideLast={params.hideLast}/>
229
+ tickLabels={coords.tickLabels} textColor={Colors.DARKGRAY} />
224
230
  {/if}
225
231
 
226
232
  <!-- tick factor -->
@@ -262,7 +268,7 @@
262
268
  <text {x} y={coords.ltop} dx="1.2em" dy="-0.25em" dominant-baseline="hanging"
263
269
  fill={params.labelColor}
264
270
  font-size="{params.fontSize}em"
265
- text-anchor="right"
271
+ text-anchor="end"
266
272
  >{@html getTickFactorLabel(params.labelsFactor)}</text>
267
273
  {/if}
268
274
  {/snippet}
@@ -340,11 +346,6 @@
340
346
  user-select: none;
341
347
  }
342
348
 
343
- .series-points text {
344
- /* dominant-baseline: central;
345
- alignment-baseline: central */
346
- }
347
-
348
349
  .tick-labels text,
349
350
  .tick-labels tspan,
350
351
  .series-textlabels text,
@@ -444,27 +445,16 @@
444
445
 
445
446
  <!-- box -->
446
447
  <g class="axes-box">
447
- {#if box.show}
448
- <rect stroke={box.lineColor} stroke-width="{box.lineWidth}px" fill="transparent"
449
- x={cpx[0]} y={cpy[1]} width={cpx[1] - cpx[0]} height={cpy[0] - cpy[1]}
450
- id="axes-box"
451
- onkeydown={handleClick}
452
- onclick={handleClick}
453
- onmousemove={handleMove}
454
- onmousedown={handleDown}
455
- onmouseup={handleUp}
456
- />
457
- {:else}
458
- <rect stroke="transparent" fill="transparent"
448
+ <rect stroke={box.show ? box.lineColor : 'transparent'}
449
+ stroke-width={box.show ? box.lineWidth + 'px' : '0'}
450
+ fill="transparent"
459
451
  x={cpx[0]} y={cpy[1]} width={cpx[1] - cpx[0]} height={cpy[0] - cpy[1]}
460
452
  id="axes-box"
461
- onkeydown={handleClick}
462
453
  onclick={handleClick}
463
454
  onmousemove={handleMove}
464
455
  onmousedown={handleDown}
465
456
  onmouseup={handleUp}
466
457
  />
467
- {/if}
468
458
  </g>
469
459
 
470
460
 
@@ -501,11 +491,18 @@
501
491
  </p>
502
492
  {:else}
503
493
  <div class="download-links">
504
- <button onclick={handleClickSVG} onkeydown={handleClickSVG}>⇩ svg</button>
505
- <button onclick={handleClickPNG} onkeydown={handleClickPNG}>⇩ png</button>
506
- <button onclick={handleClickCopy} onkeydown={handleClickCopy}>⧉ copy</button>
494
+ <button onclick={handleClickSVG}>⇩ svg</button>
495
+ <button onclick={handleClickPNG}>⇩ png</button>
496
+ <button onclick={handleClickPNGAdvanced}>⇩ png+</button>
497
+ <button onclick={handleClickCopy}>⧉ copy</button>
507
498
  </div>
508
499
  {/if}
500
+
501
+ {#if showExportDialog}
502
+ <ExportDialog {plotElement} {fileName}
503
+ initialWidth={pngWidth} initialRes={pngRes}
504
+ onclose={handleCloseExportDialog} />
505
+ {/if}
509
506
  </div>
510
507
 
511
508
  <style>
@@ -534,7 +531,6 @@
534
531
  transition: bottom 0.35s ease;
535
532
  box-shadow: 0 0 0.75em #00000060;
536
533
  margin: 0.6em;
537
- box-sizing: border-box;
538
534
 
539
535
  display: flex;
540
536
  align-items: center;
@@ -68,11 +68,10 @@
68
68
  console.error('Bars: if only one bar must be shown, value for property "barWidthExact" should be provided.');
69
69
  return null;
70
70
  } else {
71
- w = 0;
72
- let lw = 0;
71
+ w = Infinity;
73
72
  for (let i = 1; i < n; i++) {
74
- lw = Math.abs(xv.v[i - 1] - xv.v[i]);
75
- if (lw > w) w = lw;
73
+ const lw = Math.abs(xv.v[i - 1] - xv.v[i]);
74
+ if (lw < w) w = lw;
76
75
  }
77
76
  }
78
77
 
@@ -83,6 +83,8 @@
83
83
  const nb = lb.length; // number of breaks
84
84
  const rl = Array(nb).fill().map(() => []); // coordinates of left for every break
85
85
  const rt = Array(nb).fill().map(() => []); // coordinates of top for every break
86
+ const rr = Array(nb).fill().map(() => []); // row indices for every break
87
+ const rc = Array(nb).fill().map(() => []); // column indices for every break
86
88
 
87
89
  // adjust left side for the first break and right side for the last
88
90
  const llb = lb.slice();
@@ -97,11 +99,13 @@
97
99
  if (vj > llb[i] && vj <= llb[i + 1]) {
98
100
  rl[i].push(left.v[j] - 0.5);
99
101
  rt[i].push(v.nrows - top.v[j] + 1.5);
102
+ rr[i].push(top.v[j]);
103
+ rc[i].push(left.v[j]);
100
104
  break;
101
105
  }
102
106
  }
103
107
  };
104
- return {rl, rt};
108
+ return {rl, rt, rr, rc};
105
109
  });
106
110
 
107
111
 
@@ -119,13 +123,12 @@
119
123
  const el = e.target;
120
124
  if (el.tagName !== 'rect') return;
121
125
 
122
- const pel = el.parentNode.parentNode;
123
- const pr = pel.getBoundingClientRect();
124
- const r = el.getBoundingClientRect();
125
- const x = (r.x - pr.x) + r.width * 0.2;
126
- const y = (r.y - pr.y) + r.height * 0.2;
127
- onclick(Math.abs(Math.round(y / r.height)), Math.abs(Math.round(x / r.width)))
128
- e.stopPropagation();
126
+ const row = parseInt(el.getAttribute('data-row'));
127
+ const col = parseInt(el.getAttribute('data-col'));
128
+ if (!isNaN(row) && !isNaN(col)) {
129
+ onclick(row, col);
130
+ e.stopPropagation();
131
+ }
129
132
  }
130
133
 
131
134
 
@@ -143,7 +146,7 @@
143
146
  {#if rx[i].length > 0}
144
147
  <!-- loop over elements which fall into the interval -->
145
148
  {#each rx[i] as v, j}
146
- <rect x={rx[i][j]} y={ry[i][j]} width={rw} height={rh}/>
149
+ <rect x={rx[i][j]} y={ry[i][j]} width={rw} height={rh} data-row={wc.rr[i][j]} data-col={wc.rc[i][j]}/>
147
150
  {/each}
148
151
  {/if}
149
152
  </g>
@@ -78,8 +78,9 @@
78
78
  return null;
79
79
  }
80
80
 
81
+ const result = [];
81
82
  for (let i = 0; i < items.length; i++) {
82
- const item = items[i];
83
+ const item = {...items[i]};
83
84
 
84
85
  // check and transform label
85
86
  if (!item.label) {
@@ -88,13 +89,14 @@
88
89
  }
89
90
 
90
91
  const newLabel = text2svg(item.label);
91
- const labelHeight = newLabel.length === item.label ? 1 : 1.4;
92
+ const labelHeight = newLabel.length === item.label.length ? 1 : 1.4;
92
93
  item.label = newLabel;
93
- item['labelHeight'] = labelHeight;
94
+ item.labelHeight = labelHeight;
94
95
 
95
96
  // set default values for lines
96
97
  if (item.line) {
97
- if (!item.line.lineType) item.line['lineType'] = 1
98
+ item.line = {...item.line};
99
+ if (!item.line.lineType) item.line.lineType = 1
98
100
  if (!(item.line.lineType > 0 && item.line.lineType <= 4)) {
99
101
  console.error('Legend: parameter "lineType" for legend item ' + (i + 1) + ' is incorrect.');
100
102
  }
@@ -104,7 +106,8 @@
104
106
 
105
107
  // set default values for markers
106
108
  if (item.point) {
107
- if (!item.point.marker) item.point['marker'] = 1
109
+ item.point = {...item.point};
110
+ if (!item.point.marker) item.point.marker = 1
108
111
  if (!(item.point.marker > 0 && item.point.marker <= MARKER_SYMBOLS.length)) {
109
112
  console.error('Legend: parameter "marker" for legend item ' + (i + 1) + ' is incorrect.');
110
113
  }
@@ -113,8 +116,10 @@
113
116
  item.point.markerSize = item.point.markerSize ? item.point.markerSize : 1;
114
117
  item.point.lineColor = item.point.lineColor ? item.point.lineColor : Colors.PRIMARY;
115
118
  }
119
+
120
+ result.push(item);
116
121
  }
117
- return items;
122
+ return result;
118
123
  }
119
124
 
120
125
  // get context and update group legend parameters reactivey
@@ -124,7 +129,7 @@
124
129
  const newPosition = checkPosition(position);
125
130
  axes.setGroupLegend(
126
131
  newItems && newPosition ?
127
- {show, position, items, lineColor, faceColor, lineWidth, fontSize} :
132
+ {show, position, items: newItems, lineColor, faceColor, lineWidth, fontSize} :
128
133
  {show: false, position: null, items: null}
129
134
  );
130
135
  });
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
3
  import { Colors } from '../constants';
4
+ import { transformCoords } from '../methods';
4
5
 
5
6
  let {
6
7
  left, // vector/array with coordinates of lef side of each text box
@@ -5,8 +5,8 @@
5
5
  - `limX` - array with limits for x-axis (in plot units), default: `[0, 1]`.
6
6
  - `limY` - array with limits for y-axis (in plot units), default: `[0, 1]`.
7
7
  - `limZ` - array with limits for y-axis (in plot units), default: `[0, 1]`.
8
- - `theta` - angle (in degrees) for orientation of projection plane, default: `-10`.
9
- - `phi` - angle (in degrees) for orientation of projection plane, default: `-10`.
8
+ - `theta` - angle (in radians) for orientation of projection plane, default: `-10`.
9
+ - `phi` - angle (in radians) for orientation of projection plane, default: `-10`.
10
10
  - `zoom` - zooming factor, default: `0.5`.
11
11
 
12
12
  Properties for downloading the plot as PNG or SVG file or copying it to clipboard:
@@ -48,7 +48,7 @@
48
48
  theta = -10,
49
49
  phi = -10,
50
50
  zoom = 0.5,
51
- downloadLinks = 'hover',
51
+ downloadLinks = 'none',
52
52
  fileName = 'plot',
53
53
  pngWidth = 8,
54
54
  pngHeight = 8,
@@ -290,9 +290,9 @@
290
290
  </p>
291
291
  {:else}
292
292
  <div class="download-links">
293
- <button onclick={handleClickSVG} onkeydown={handleClickSVG}>⇩ svg</button>
294
- <button onclick={handleClickPNG} onkeydown={handleClickPNG}>⇩ png</button>
295
- <button onclick={handleClickCopy} onkeydown={handleClickCopy}>⧉ copy</button>
293
+ <button onclick={handleClickSVG}>⇩ svg</button>
294
+ <button onclick={handleClickPNG}>⇩ png</button>
295
+ <button onclick={handleClickCopy}>⧉ copy</button>
296
296
  </div>
297
297
  {/if}
298
298
  </div>
@@ -370,7 +370,6 @@
370
370
  transition: bottom 0.35s ease;
371
371
  box-shadow: 0 0 0.75em #00000060;
372
372
  margin: 0.6em;
373
- box-sizing: border-box;
374
373
 
375
374
  display: flex;
376
375
  align-items: center;
@@ -101,14 +101,14 @@
101
101
  // x-direction - start
102
102
  x1s.v[n1] = ux.v[x];
103
103
  z1s.v[n1] = uz.v[z];
104
- y1s.v[n1] = Uy.v[z * nz + x];
104
+ y1s.v[n1] = Uy.v[z * nx + x];
105
105
 
106
106
  // x-direction - end
107
107
  x1e.v[n1] = ux.v[x + 1];
108
108
  z1e.v[n1] = uz.v[z];
109
- y1e.v[n1] = Uy.v[z * nz + x + 1];
109
+ y1e.v[n1] = Uy.v[z * nx + x + 1];
110
110
 
111
- const colInd = Math.round(((Uy.v[z * nz + x] + Uy.v[z * nz + x + 1]) / 2 - left) / span * (nbins - 1));
111
+ const colInd = Math.round(((Uy.v[z * nx + x] + Uy.v[z * nx + x + 1]) / 2 - left) / span * (nbins - 1));
112
112
  color1.v[n1] = colInd;
113
113
  n1 = n1 + 1;
114
114
  }
@@ -118,14 +118,14 @@
118
118
  // z-direction - start
119
119
  x2s.v[n2] = ux.v[x];
120
120
  z2s.v[n2] = uz.v[z];
121
- y2s.v[n2] = Uy.v[z * nz + x];
121
+ y2s.v[n2] = Uy.v[z * nx + x];
122
122
 
123
123
  // z-direction - end
124
124
  x2e.v[n2] = ux.v[x];
125
125
  z2e.v[n2] = uz.v[z + 1];
126
- y2e.v[n2] = Uy.v[(z + 1) * nz + x];
126
+ y2e.v[n2] = Uy.v[(z + 1) * nx + x];
127
127
 
128
- const colInd = Math.round(((Uy.v[z * nz + x] + Uy.v[(z + 1) * nz + x]) / 2 - left) / span * (nbins - 1));
128
+ const colInd = Math.round(((Uy.v[z * nx + x] + Uy.v[(z + 1) * nx + x]) / 2 - left) / span * (nbins - 1));
129
129
  color2.v[n2] = colInd;
130
130
  n2 = n2 + 1;
131
131
  }
@@ -60,8 +60,8 @@
60
60
  const uz1 = $derived(uy1 ? checkCoords(zStart, 'Segments (3D)', ux1.length) : null);
61
61
 
62
62
  const ux2 = $derived(uz1 ? checkCoords(xEnd, 'Segments (3D)', ux1.length) : null);
63
- const uy2 = $derived(ux1 ? checkCoords(yEnd, 'Segments (3D)', ux1.length) : null);
64
- const uz2 = $derived(uy1 ? checkCoords(zEnd, 'Segments (3D)', ux1.length) : null);
63
+ const uy2 = $derived(ux2 ? checkCoords(yEnd, 'Segments (3D)', ux1.length) : null);
64
+ const uz2 = $derived(uy2 ? checkCoords(zEnd, 'Segments (3D)', ux1.length) : null);
65
65
 
66
66
  // combine world coordinates to matrices
67
67
  const uW1 = $derived(ux1 && uy1 && uz1 ? cbind(ux1, uy1, uz1) : null);
@@ -55,6 +55,7 @@
55
55
  const U = $derived(ux && uy && uz ? cbind(ux, uy, uz) : null);
56
56
 
57
57
  const l = $derived.by(() => {
58
+ if (!ux) return null;
58
59
  if(Array.isArray(labels) && labels.length !== ux.length ) {
59
60
  console.error('TextLabels: labels provided as an array should have the same number of elements as number of coordinates.');
60
61
  return null;
@@ -79,7 +80,7 @@
79
80
  <text data-id={i} x={s.x[i]} y={s.y[i]} dx={0} dy={0}>{@html l}</text>
80
81
  {/each}
81
82
  {:else}
82
- {#each x as v, i}
83
+ {#each s.x as v, i}
83
84
  <text data-id={i} x={s.x[i]} y={s.y[i]} dx={0} dy={0}>{@html l[i]}</text>
84
85
  {/each}
85
86
  {/if}
@@ -0,0 +1,359 @@
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
+ let width = $state(initialWidth);
29
+ const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
30
+ let res = $state(initialRes);
31
+ let name = $state(fileName);
32
+ const nameValid = $derived(name.trim().length > 0);
33
+
34
+ // preview container ref
35
+ let previewContainer = $state(null);
36
+
37
+ // max preview width in pixels
38
+ const maxPreviewWidth = 400;
39
+ const previewHeight = $derived(Math.round(maxPreviewWidth / aspectRatio));
40
+
41
+ // clone SVG into preview container on mount
42
+ $effect(() => {
43
+ if (!previewContainer || !plotElement) return;
44
+
45
+ // clear previous content
46
+ previewContainer.innerHTML = '';
47
+
48
+ // clone the SVG
49
+ const clone = plotElement.cloneNode(true);
50
+
51
+ clone.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
52
+ clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
53
+ clone.style.width = '100%';
54
+ clone.style.height = '100%';
55
+ clone.style.position = 'static';
56
+
57
+ // fixes duplicate clipPath IDs
58
+ const clipPaths = clone.querySelectorAll('clipPath');
59
+ clipPaths.forEach((cp) => {
60
+ const oldId = cp.getAttribute('id');
61
+ if (oldId) {
62
+ const newId = 'preview_' + oldId;
63
+ cp.setAttribute('id', newId);
64
+ const refs = clone.querySelectorAll(`[clip-path="url(#${oldId})"]`);
65
+ refs.forEach((ref) => ref.setAttribute('clip-path', `url(#${newId})`));
66
+ }
67
+ });
68
+
69
+ previewContainer.appendChild(clone);
70
+ });
71
+
72
+ function handleWidthChange(e) {
73
+ width = parseFloat(e.target.value);
74
+ }
75
+
76
+ const resOptions = [100, 150, 300, 600];
77
+ let saving = $state(false);
78
+
79
+ function handleSave() {
80
+ if (saving) return;
81
+ saving = true;
82
+
83
+ const container = plotElement.parentElement;
84
+ const origStyle = container.style.cssText;
85
+
86
+ // scale reference width with export width so smaller plots get
87
+ // a smaller scale category → relatively larger fonts and details
88
+ // 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
89
+ const refWidth = Math.round(200 + width * 40);
90
+ const refHeight = Math.round(refWidth / aspectRatio);
91
+
92
+ function doExport() {
93
+ requestAnimationFrame(() => {
94
+ requestAnimationFrame(() => {
95
+ // onSerialize callback fires after downloadPNG has serialized the SVG,
96
+ // so we can safely restore the container and close the dialog
97
+ downloadPNG(plotElement, name.trim(), width, height, res, () => {
98
+ container.style.cssText = origStyle;
99
+ saving = false;
100
+ onclose();
101
+ });
102
+ });
103
+ });
104
+ }
105
+
106
+ // temporarily resize plot container to a fixed reference size (off-screen,
107
+ // hidden behind the dialog backdrop) so Svelte recomputes the layout
108
+ container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
109
+
110
+ // wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
111
+ // fallback timeout in case container is already at the target size
112
+ const fallbackTimer = setTimeout(() => { observer.disconnect(); doExport(); }, 500);
113
+ const observer = new ResizeObserver(() => {
114
+ observer.disconnect();
115
+ clearTimeout(fallbackTimer);
116
+ doExport();
117
+ });
118
+ observer.observe(plotElement);
119
+ }
120
+
121
+ function handleBackdropClick() {
122
+ if (!saving) onclose();
123
+ }
124
+
125
+ function handleDialogClick(e) {
126
+ e.stopPropagation();
127
+ }
128
+
129
+ // close on Escape key
130
+ $effect(() => {
131
+ function handleKeydown(e) {
132
+ if (e.key === 'Escape' && !saving) {
133
+ onclose();
134
+ }
135
+ }
136
+ document.addEventListener('keydown', handleKeydown);
137
+ return () => document.removeEventListener('keydown', handleKeydown);
138
+ });
139
+ </script>
140
+
141
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
142
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
143
+ <div class="export-dialog-backdrop" onclick={handleBackdropClick}>
144
+ <div class="export-dialog" onclick={handleDialogClick}>
145
+ <div class="export-dialog-title">Export as PNG</div>
146
+
147
+ <div class="export-dialog-body">
148
+ <!-- Preview -->
149
+ <div class="export-dialog-preview"
150
+ style="width:{maxPreviewWidth}px;height:{previewHeight}px;"
151
+ bind:this={previewContainer}
152
+ ></div>
153
+
154
+ <!-- Controls -->
155
+ <div class="export-dialog-controls">
156
+ <div class="export-dialog-control">
157
+ <label for="export-name">File name:</label>
158
+ <input id="export-name" class="export-dialog-name" class:invalid={!nameValid} type="text" bind:value={name} />
159
+ </div>
160
+
161
+ <div class="export-dialog-control">
162
+ <label for="export-width">Size, cm: ({width} × {height})</label>
163
+ <input id="export-width" type="range" min="4" max="20" step="1" value={width} oninput={handleWidthChange} />
164
+ </div>
165
+
166
+ <div class="export-dialog-res">
167
+ <span class="export-dialog-res-label">Resolution, dpi:</span>
168
+ <div class="export-dialog-res-options">
169
+ {#each resOptions as opt}
170
+ <label class="export-dialog-res-btn" class:selected={res === opt}>
171
+ <input type="radio" name="export-res" value={opt} checked={res === opt}
172
+ onchange={() => res = opt} />
173
+ {opt}
174
+ </label>
175
+ {/each}
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+
181
+ <div class="export-dialog-actions">
182
+ <button class="export-dialog-cancel" onclick={onclose} disabled={saving}>Cancel</button>
183
+ <button class="export-dialog-save" onclick={handleSave} disabled={!nameValid}>Save</button>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <style>
189
+ .export-dialog-backdrop {
190
+ position: fixed;
191
+ top: 0;
192
+ left: 0;
193
+ right: 0;
194
+ bottom: 0;
195
+ background: rgba(0, 0, 0, 0.4);
196
+ z-index: 1000;
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ }
201
+
202
+ .export-dialog {
203
+ background: #fefefe;
204
+ border-radius: 0.5em;
205
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
206
+ padding: 1.25em;
207
+ min-width: 440px;
208
+ max-width: 90vw;
209
+ font-family: Arial, Helvetica, sans-serif;
210
+ color: #606060;
211
+ }
212
+
213
+ .export-dialog-title {
214
+ font-size: 1.1em;
215
+ font-weight: bold;
216
+ margin-bottom: 1em;
217
+ color: #303030;
218
+ }
219
+
220
+ .export-dialog-body {
221
+ display: flex;
222
+ flex-direction: column;
223
+ align-items: center;
224
+ gap: 1em;
225
+ }
226
+
227
+ .export-dialog-preview {
228
+ border: 1px solid #dadada;
229
+ background: #fff;
230
+ overflow: hidden;
231
+ border-radius: 0.25em;
232
+ }
233
+
234
+ .export-dialog-controls {
235
+ width: 100%;
236
+ display: flex;
237
+ flex-direction: column;
238
+ gap: 0.5em;
239
+ }
240
+
241
+ .export-dialog-control {
242
+ display: flex;
243
+ align-items: center;
244
+ gap: 0.5em;
245
+ }
246
+
247
+ .export-dialog-control label {
248
+ min-width: 110px;
249
+ font-size: 0.85em;
250
+ white-space: nowrap;
251
+ }
252
+
253
+ .export-dialog-control input[type="range"] {
254
+ flex: 1;
255
+ accent-color: #443333;
256
+ }
257
+
258
+ .export-dialog-name {
259
+ flex: 1;
260
+ padding: 0.25em 0.4em;
261
+ border: 1px solid #dadada;
262
+ border-radius: 0.35em;
263
+ font-size: 0.85em;
264
+ color: #606060;
265
+ background: #fafafa;
266
+ outline: none;
267
+ }
268
+
269
+ .export-dialog-name:focus {
270
+ border-color: #909090;
271
+ }
272
+
273
+ .export-dialog-name.invalid {
274
+ border-color: crimson;
275
+ }
276
+
277
+ .export-dialog-res {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 0.5em;
281
+ }
282
+
283
+ .export-dialog-res-label {
284
+ min-width: 110px;
285
+ font-size: 0.85em;
286
+ white-space: nowrap;
287
+ }
288
+
289
+ .export-dialog-res-options {
290
+ display: flex;
291
+ gap: 0.25em;
292
+ }
293
+
294
+ .export-dialog-res-btn {
295
+ box-sizing: border-box;
296
+ color: #606060;
297
+ border: none;
298
+ box-shadow: none;
299
+ background: #fafafa;
300
+ border-radius: 0.35em;
301
+ padding: 0.25em 0.5em;
302
+ font-size: 0.85em;
303
+ cursor: pointer;
304
+ user-select: none;
305
+ }
306
+
307
+ .export-dialog-res-btn:hover {
308
+ background: #dadada;
309
+ }
310
+
311
+ .export-dialog-res-btn.selected {
312
+ background: #443333;
313
+ color: #fafafa;
314
+ }
315
+
316
+ .export-dialog-res-btn input {
317
+ display: none;
318
+ }
319
+
320
+ .export-dialog-actions {
321
+ display: flex;
322
+ justify-content: flex-end;
323
+ gap: 0.5em;
324
+ margin-top: 1em;
325
+ }
326
+
327
+ .export-dialog-actions button {
328
+ padding: 0.4em 1.2em;
329
+ border-radius: 0.35em;
330
+ border: none;
331
+ cursor: pointer;
332
+ font-size: 0.85em;
333
+ }
334
+
335
+ .export-dialog-cancel {
336
+ background: #fafafa;
337
+ color: #606060;
338
+ }
339
+
340
+ .export-dialog-cancel:hover {
341
+ background: #dadada;
342
+ }
343
+
344
+ .export-dialog-save {
345
+ background: #443333;
346
+ color: #fafafa;
347
+ }
348
+
349
+ .export-dialog-save:hover {
350
+ background: #665555;
351
+ color: #fafafa;
352
+ }
353
+
354
+ .export-dialog-save:disabled {
355
+ background: #dadada;
356
+ color: #909090;
357
+ cursor: default;
358
+ }
359
+ </style>
package/src/methods.js CHANGED
@@ -290,7 +290,7 @@ export function validateTicks(ticks, lim) {
290
290
  const right = lim[0] < lim[1] ? lim[1] : lim[0];
291
291
 
292
292
  // check if provided ticks values are outside the axis limit range continue with automatic ticks
293
- const newTicks = ticks.filter(x => x >= left & x <= right);
293
+ const newTicks = ticks.filter(x => x >= left && x <= right);
294
294
  if (newTicks.length !== ticks.length) {
295
295
  return [null, 'some of ticks values are outside axis limits.'];
296
296
  }
@@ -381,7 +381,7 @@ export function getAxisTicks(ticks, limIn, maxTickNum, round, whole, deltaFactor
381
381
  }
382
382
 
383
383
  // make sure the ticks are not aligned with axes limits
384
- return ticks.filter(x => x >= lim[0] & x <= lim[1]);
384
+ return ticks.filter(x => x >= lim[0] && x <= lim[1]);
385
385
  }
386
386
 
387
387
 
@@ -456,7 +456,7 @@ export function getScale(width, height) {
456
456
  * @returns {text} the scale level ("small", "medium" or "large").
457
457
  *
458
458
  */
459
- export function getAxisScale(width, size) {
459
+ export function getAxisScale(width) {
460
460
  if (width < 400.2) return "small";
461
461
  if (width < 700.2) return "medium";
462
462
  if (width < 900.2) return "large";
@@ -588,7 +588,7 @@ function changeDPI(base64Image, dpi) {
588
588
  * @param {number} res - resolution (pixels per inch)
589
589
  *
590
590
  */
591
- export function downloadPNG (svg, fileName, width, height, res) {
591
+ export function downloadPNG (svg, fileName, width, height, res, onSerialize) {
592
592
 
593
593
  if (!width) width = 10;
594
594
  if (!height) height = 10;
@@ -645,7 +645,9 @@ export function downloadPNG (svg, fileName, width, height, res) {
645
645
  canvas.width = svgWidth * (specialScale < 1 ? specialScale : scaleFactor);
646
646
  canvas.height = svgHeight * (specialScale < 1 ? specialScale : scaleFactor);
647
647
 
648
- // set corresponding attributes for SVG element
648
+ // save and set corresponding attributes for SVG element
649
+ const origWidth = svg.getAttribute('width');
650
+ const origHeight = svg.getAttribute('height');
649
651
  svg.setAttribute('width', svgWidth * scaleFactor);
650
652
  svg.setAttribute('height', svgHeight * scaleFactor);
651
653
 
@@ -659,12 +661,19 @@ export function downloadPNG (svg, fileName, width, height, res) {
659
661
  const blob = new Blob([svgXml], { type: 'image/svg+xml;charset=utf-8' });
660
662
  const url = URL.createObjectURL(blob);
661
663
 
664
+ // restore original attributes
665
+ origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
666
+ origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
667
+
668
+ // notify caller that serialization is done (used by ExportDialog to restore container)
669
+ if (onSerialize) onSerialize();
670
+
662
671
  const img = new Image();
663
672
 
664
673
  img.onload = function () {
665
674
 
666
675
  // downscale image if it must be smaller than the current SVG element
667
- if (specialScale < 0) {
676
+ if (specialScale < 1) {
668
677
  img.width = img.width * specialScale;
669
678
  img.height = img.height * specialScale;
670
679
  }
@@ -708,6 +717,12 @@ export function downloadSVG(svg, fileName) {
708
717
 
709
718
  const svgHeight = svg.clientHeight;
710
719
  const svgWidth = svg.clientWidth;
720
+
721
+ // save original attributes
722
+ const origWidth = svg.getAttribute('width');
723
+ const origHeight = svg.getAttribute('height');
724
+ const origViewBox = svg.getAttribute('viewBox');
725
+
711
726
  svg.setAttribute('width', svgWidth + 'px');
712
727
  svg.setAttribute('height', svgHeight + 'px');
713
728
  svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
@@ -718,6 +733,11 @@ export function downloadSVG(svg, fileName) {
718
733
  a.setAttribute('href', url)
719
734
  a.setAttribute('download', `${fileName}.svg`);
720
735
  a.click()
736
+
737
+ // restore original attributes
738
+ origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
739
+ origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
740
+ origViewBox ? svg.setAttribute('viewBox', origViewBox) : svg.removeAttribute('viewBox');
721
741
  }
722
742
 
723
743
  /**
@@ -793,15 +813,21 @@ export async function createPngBlob(svg, width, height) {
793
813
  // scale the context
794
814
  context.scale(scaleFactor, scaleFactor)
795
815
 
796
- // set corresponding attributes for SVG element
816
+ // save and set corresponding attributes for SVG element
817
+ const origWidth = svg.getAttribute('width');
818
+ const origHeight = svg.getAttribute('height');
797
819
  svg.setAttribute('width', canvas.width);
798
820
  svg.setAttribute('height', canvas.height);
799
821
 
822
+ // serialize SVG with export dimensions, then restore original attributes
823
+ const svgData = new XMLSerializer().serializeToString(svg);
824
+ origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
825
+ origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
826
+
800
827
  // create promise
801
828
  const promise = new Promise((resolve, reject) => {
802
829
 
803
- // serialzie SVG, create blob and URL for it
804
- const svgData = new XMLSerializer().serializeToString(svg);
830
+ // create blob and URL for serialized SVG
805
831
  const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
806
832
  const DOMURL = window.URL || window.webkitURL || window;
807
833
  const url = DOMURL.createObjectURL(svgBlob);
@@ -1035,10 +1061,6 @@ export function getYAxisParams(limX, limY, scales, tX, axis) {
1035
1061
  tickLabels[pos] = ' ';
1036
1062
  }
1037
1063
 
1038
- // old version of the code above - remove after testing
1039
- // if (tfLabel.length > 0 && (limY[1] - ticksY.v[ticksY.length - 1]) < ((limY[1] - limY[0]) * 0.05)) {
1040
- // tickLabels[tickLabels.length - 1] = ' ';
1041
- // }
1042
1064
 
1043
1065
  return {grid, axisLine, tickCoords, tfCoords, ticks, tickLabels, tickFactor, tfLabel};
1044
1066
  }
@@ -1248,8 +1270,6 @@ export function text2svg(text) {
1248
1270
  }
1249
1271
 
1250
1272
  let i = 0;
1251
- const dy = 0.5;
1252
- const fs = 0.8;
1253
1273
  let svgText = '';
1254
1274
  while (i < text.length) {
1255
1275
 
@@ -1445,7 +1465,7 @@ export function getYAxisCoords3D(yaxis, limX, limY, limZ, scale) {
1445
1465
 
1446
1466
  /**
1447
1467
  * Computes coordinates of all elements (lines, labels, etc) for z-axis ticks.
1448
- * @param {Object} yaxis - JSON with z-axis paramaters.
1468
+ * @param {Object} zaxis - JSON with z-axis paramaters.
1449
1469
  * @param {Array} limX - array with x-axis limits.
1450
1470
  * @param {Array} limY - array with y-axis limits.
1451
1471
  * @param {Array} limZ - array with z-axis limits.
@@ -1469,8 +1489,8 @@ export function getZAxisCoords3D(zaxis, limX, limY, limZ, scale) {
1469
1489
 
1470
1490
  // tick labels
1471
1491
  const tickLabels = (!zaxis.ticks || !zaxis.tickLabels) ? ticksZ.v : zaxis.tickLabels;
1472
- if (tickLabels.length !== ticksX.length) {
1473
- console.error('XAxis (3D): "tickLabels" must be a array of the same size as ticks.');
1492
+ if (tickLabels.length !== ticksZ.length) {
1493
+ console.error('ZAxis (3D): "tickLabels" must be a array of the same size as ticks.');
1474
1494
  }
1475
1495
 
1476
1496
  // combine all coordinates together