svelte-plots-basic 3.3.0 → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2026 Sergey Kucheryavskiy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -15,6 +15,24 @@ These websites and web-applications use `svelte-plots-basic` library:
15
15
 
16
16
  ## News
17
17
 
18
+ ### 3.4.0
19
+
20
+ * Added the `plotActions` property to `Axes` for adding custom buttons to the download panel.
21
+ * Added advanced validation for axis limits, text-label positions, heatmap breaks, and custom plot actions.
22
+ * Fixed text-label positioning and rotation when individual positions are provided.
23
+ * Fixed heatmaps with automatic, custom, or constant-value breaks.
24
+ * Added support for rectangular and constant-height 3D meshes.
25
+ * Improved PNG, SVG, and clipboard export by serializing a clone instead of modifying the visible plot.
26
+ * Fixed the advanced PNG export dialog so a default resolution is always selected, falling back to 300 dpi when necessary.
27
+ * Prevented plot controls from submitting an enclosing HTML form.
28
+ * Added package validation before publishing and synchronized Svelte 5 peer-dependency metadata.
29
+ * Fixed conditionally rendered axes, boxes, and legends so their parent state is cleared when they are removed.
30
+ * Improved heatmap interval processing and added automatically generated colormaps with more than 16 colors.
31
+ * Fixed `TextLegend` positioning at zero and strengthened finite-range validation for 3D axes.
32
+ * Reduced reactive and repeated allocation overhead for axis configuration, text measurement, PNG export, and colormap generation.
33
+ * Added dependency-free regression tests and made them run automatically before packaging.
34
+ * Documented the trust requirements for labels rendered as SVG/HTML markup.
35
+
18
36
  ### 3.3.0
19
37
 
20
38
  * Added advanced "save as PNG" option where user can select size and resolution of the image as well as change the filename.
@@ -57,6 +75,13 @@ In addition to Svelte 5 syntax and functionality, this release also introduces a
57
75
 
58
76
  * **Properites** — from 3.x.x the naming of the properties is more consistent. For example, before the library used properties `borderColor` and `borderWidth` for areas, rectangels, bars and markers, while for lines, segments and multilines the similar properties were named as `lineColor` and `lineWidth`. Now they all have prefix `line*` if it is something about lines or segments and `face*` if it is somthing inside a closed contour. So no more `border*` properties.
59
77
 
78
+
79
+ ## Security and label markup
80
+
81
+ Several title and label properties support SVG/HTML markup and are rendered as raw markup using Svelte's `{@html}` mechanism. This enables formatted labels, HTML entities, subscripts, and superscripts.
82
+
83
+ Only pass trusted content to these properties. Do not pass unsanitized user input or external data directly as a plot title, axis label, tick label, legend label, or text-label value. Escape untrusted text, or sanitize it with an allow-list appropriate for SVG markup, before passing it to a component.
84
+
60
85
  ## Installation
61
86
 
62
87
  The set up process is similar to any other Svelte component library. Just use:
@@ -140,6 +165,7 @@ Here is a table with properties of the component related to saving plot to a fil
140
165
  `pngRes` | `300` | resolution of PNG image (pixels per inch)
141
166
  `clipboardWidth` | `1200` | width of image in clipboard in pixels
142
167
  `clipboardHeight` | `800` | height of image in clipboard in pixels
168
+ `plotActions` | `[]` | array of `{ text, callback }` objects for adding custom buttons to the download panel.
143
169
 
144
170
  Please pay attention that most of the browsers enable clipboard functionality only if website is available via HTTPS.
145
171
 
@@ -841,15 +867,12 @@ Here is an example of simple 3D scatter plot:
841
867
  <Axes limX={[-10, 10]} limY={[-10, 10]} limZ={[-10, 10]} {zoom} {phi} {theta}>
842
868
  <Points {xValues} {yValues} {zValues} />
843
869
 
844
- <XAxis showGrid={true} title="X" />
845
- <YAxis showGrid={true} title="Y" />
846
- <ZAxis showGrid={true} title="Z" />
870
+ <XAxis showGrid={true} label="X" />
871
+ <YAxis showGrid={true} label="Y" />
872
+ <ZAxis showGrid={true} label="Z" />
847
873
  </Axes>
848
874
  ```
849
875
 
850
876
  You can also add `Lines`, `Segments` and `Mesh` series to 3D plots.
851
877
 
852
878
  Check more advanced example in Svelte REPL with mouse and keyboard support, and more: [plots-3d](https://svelte.dev/playground/2294eecba7d7477ab9a09c1734d32ac2)
853
-
854
-
855
-
package/package.json CHANGED
@@ -1,10 +1,16 @@
1
1
  {
2
2
  "name": "svelte-plots-basic",
3
3
  "description": "Svelte components for creating simple plots",
4
- "version": "3.3.0",
4
+ "version": "3.4.0",
5
5
  "author": "Sergey Kucheryavskiy",
6
6
  "license": "MIT",
7
7
  "private": false,
8
+ "type": "module",
9
+ "scripts": {
10
+ "check": "node scripts/check.cjs",
11
+ "test": "node --test",
12
+ "prepack": "npm run check && npm test"
13
+ },
8
14
  "exports": {
9
15
  "./constants": "./src/constants.js",
10
16
  "./utils": "./src/methods.js",
@@ -16,6 +22,9 @@
16
22
  "devDependencies": {
17
23
  "svelte": "^5.x.x"
18
24
  },
25
+ "peerDependencies": {
26
+ "svelte": "^5.0.0"
27
+ },
19
28
  "dependencies": {
20
29
  "mdatools": "^1.x.x"
21
30
  },
@@ -32,7 +32,6 @@
32
32
  import { val2p, checkCoords, handleClick, transformCoords } from '../methods';
33
33
  import { LINE_STYLES } from '../constants';
34
34
 
35
- /** @type {Props} */
36
35
  let {
37
36
  xValues, // vector or array with x-coordinates of the points
38
37
  yValues, // vector or array with y-coordinates of the points
@@ -66,4 +65,3 @@
66
65
  <polygon points={pa}/>
67
66
  </g>
68
67
  {/if}
69
-
@@ -30,7 +30,7 @@
30
30
  <script>
31
31
  import { setContext } from 'svelte';
32
32
  import { Colors, PLOT_FONT_SIZE, AXES_MARGIN_FACTORS, TICK_SIZE, LINE_STYLES, MARKER_SYMBOLS } from '../constants.js';
33
- import { downloadSVG, downloadPNG, copyToClipboard, checkArray, getScale, getAxisScale, getXAxisParams, getYAxisParams,
33
+ import { downloadSVG, downloadPNG, copyToClipboard, getScale, getAxisScale, getXAxisParams, getYAxisParams,
34
34
  transformCoords, getColormapLegendParams, getColormapLegendCoords, getTickFactorLabel,
35
35
  getGroupLegendCoords, text2svg,
36
36
  invTransformCoords } from '../methods.js';
@@ -46,6 +46,7 @@
46
46
  limY = [0, 1],
47
47
  margins = [1.0, 1.1, 0.55, 0.60],
48
48
  downloadLinks = 'none',
49
+ plotActions = [],
49
50
  fileName = 'plot',
50
51
  pngWidth = 8,
51
52
  pngHeight = 8,
@@ -60,6 +61,44 @@
60
61
  } = $props();
61
62
 
62
63
 
64
+ const actions = $derived.by(() => {
65
+ if (plotActions == null) return [];
66
+
67
+ if (!Array.isArray(plotActions)) {
68
+ console.error('Axes: "plotActions" must be an array.');
69
+ return [];
70
+ }
71
+
72
+ return plotActions.filter((action, index) => {
73
+ const isValid =
74
+ action &&
75
+ typeof action.text === 'string' &&
76
+ typeof action.callback === 'function';
77
+
78
+ if (!isValid) {
79
+ console.error(
80
+ `Axes: plot action ${index + 1} must contain a text string and callback function.`
81
+ );
82
+ }
83
+
84
+ return isValid;
85
+ });
86
+ });
87
+
88
+
89
+ // Check that axis limits contain a finite, meaningful numeric range.
90
+ function hasUsableRange(lim) {
91
+ if (!Array.isArray(lim) || lim.length !== 2) return false;
92
+ if (!lim.every(v => typeof v === 'number' && Number.isFinite(v))) return false;
93
+
94
+ const span = Math.abs(lim[1] - lim[0]);
95
+ const scale = Math.max(Math.abs(lim[0]), Math.abs(lim[1]));
96
+
97
+ return Number.isFinite(1 / span) &&
98
+ span > Number.EPSILON * scale * 16;
99
+ }
100
+
101
+
63
102
  /* handler for mouse events */
64
103
  function handleMouse(e, f) {
65
104
  if (f && e.target.id == "axes-box") {
@@ -138,8 +177,8 @@
138
177
 
139
178
  // plot status
140
179
  const isOk = $derived(
141
- checkArray(limX, 2) &&
142
- checkArray(limY, 2) &&
180
+ hasUsableRange(limX) &&
181
+ hasUsableRange(limY) &&
143
182
  width > (pxMargins[1] + pxMargins[3]) &&
144
183
  height > (pxMargins[0] + pxMargins[2])
145
184
  );
@@ -175,11 +214,11 @@
175
214
  const titleHeight = $derived(plotTitle ? (plotTitle.length === title.length ? 1.0 : 1.4) : 0);
176
215
 
177
216
  // default parameters of box, axis elements, colomap legend and group legend
178
- let box = $state({show: false});
179
- let xaxis = $state({show: false, error: ''});
180
- let yaxis = $state({show: false, error: ''});
181
- let clg = $state({show: false, colmap: null, breaks: null});
182
- let glg = $state({show: false, items: null, position: null});
217
+ let box = $state.raw({show: false});
218
+ let xaxis = $state.raw({show: false, error: ''});
219
+ let yaxis = $state.raw({show: false, error: ''});
220
+ let clg = $state.raw({show: false, colmap: null, breaks: null});
221
+ let glg = $state.raw({show: false, items: null, position: null});
183
222
 
184
223
  // set colormap legend parameters if requested
185
224
  const clgParams = $derived(isOk && clg && clg.show ? getColormapLegendParams(clg) : null);
@@ -445,6 +484,8 @@
445
484
 
446
485
  <!-- box -->
447
486
  <g class="axes-box">
487
+ <!-- The plot callback requires real pointer coordinates; a keyboard-generated coordinate would be arbitrary. -->
488
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
448
489
  <rect stroke={box.show ? box.lineColor : 'transparent'}
449
490
  stroke-width={box.show ? box.lineWidth + 'px' : '0'}
450
491
  fill="transparent"
@@ -491,10 +532,16 @@
491
532
  </p>
492
533
  {:else}
493
534
  <div class="download-links">
494
- <button onclick={handleClickSVG}>⇩ svg</button>
495
- <button onclick={handleClickPNG}>⇩ png</button>
496
- <button onclick={handleClickPNGAdvanced}>⇩ png+</button>
497
- <button onclick={handleClickCopy}>⧉ copy</button>
535
+ <button type="button" onclick={handleClickSVG}>↓ svg</button>
536
+ <button type="button" onclick={handleClickPNG}>↓ png</button>
537
+ <button type="button" onclick={handleClickPNGAdvanced}>↓ png+</button>
538
+ <button type="button" onclick={handleClickCopy}>⧉ copy</button>
539
+ {#if actions.length > 0}
540
+ <span class="divider">⋮</span>
541
+ {#each actions as ac}
542
+ <button type="button" onclick={ac.callback}>{ac.text}</button>
543
+ {/each}
544
+ {/if}
498
545
  </div>
499
546
  {/if}
500
547
 
@@ -545,6 +592,9 @@
545
592
  background: #fafafa;
546
593
  border-radius: 0.35em;
547
594
  padding: 0.25em 0.5em;
595
+ margin: 0 1px;
596
+
597
+ line-height: 1.25;
548
598
  }
549
599
 
550
600
  .download-links > button:hover{
@@ -560,6 +610,12 @@
560
610
  background: #4488ee;
561
611
  }
562
612
 
613
+ .download-links > .divider {
614
+ display: inline-block;
615
+ width: 1em;
616
+ text-align: center;
617
+ color: #909090;
618
+ }
563
619
 
564
620
  /* Plot container */
565
621
  .plot-container {
@@ -617,4 +673,4 @@
617
673
  min-width: 100%;
618
674
  }
619
675
 
620
- </style>
676
+ </style>
@@ -34,7 +34,6 @@
34
34
  import Rectangles from './Rectangles.svelte';
35
35
 
36
36
 
37
- /** @type {Props} */
38
37
  let {
39
38
  xValues, // vector with x-coordinates of middle points of the bars.
40
39
  yValues, // vector with y-coordinates of top points of the bars.
@@ -116,4 +115,4 @@
116
115
  {#if isOk}
117
116
  <Rectangles className="series series-bar" left={x.left} width={x.width} top={y.top} height={y.height}
118
117
  lineWidth={lineWidth} {lineColor} {faceColor} {onclick} />
119
- {/if}
118
+ {/if}
package/src/2d/Box.svelte CHANGED
@@ -16,7 +16,7 @@
16
16
  ```
17
17
  -->
18
18
  <script>
19
- import { getContext } from 'svelte';
19
+ import { getContext, onDestroy } from 'svelte';
20
20
 
21
21
  let {lineWidth = 1, lineColor = '#606060'} = $props();
22
22
  const axes = getContext('axes');
@@ -24,5 +24,8 @@
24
24
  $effect(() => {
25
25
  axes.setBox({show:true, lineWidth, lineColor});
26
26
  });
27
- </script>
28
27
 
28
+ onDestroy(() => {
29
+ axes.setBox({show: false});
30
+ });
31
+ </script>
@@ -34,7 +34,7 @@
34
34
  ```
35
35
  -->
36
36
  <script>
37
- import { getContext } from 'svelte';
37
+ import { getContext, onDestroy } from 'svelte';
38
38
  import { Colors } from '../constants';
39
39
 
40
40
 
@@ -52,7 +52,10 @@
52
52
  $effect(() => {
53
53
  axes.setColmapLegend({show, breaks, colmap, labels, labelColor, fontSize})
54
54
  });
55
- </script>
56
55
 
56
+ onDestroy(() => {
57
+ axes.setColmapLegend({show: false});
58
+ });
59
+ </script>
57
60
 
58
61
 
@@ -27,13 +27,12 @@
27
27
  import { getContext } from 'svelte';
28
28
  import { ismatrix, isvector } from 'mdatools/arrays';
29
29
  import { expandgrid } from 'mdatools/misc';
30
- import { split } from 'mdatools/stat';
30
+ import { range, split } from 'mdatools/stat';
31
31
 
32
32
  import { getcolmap, transformObjects, transformCoords, seq } from '../methods';
33
33
  import { HEATMAP_NUM_SPLITS } from '../constants';
34
34
 
35
35
 
36
- /** @type {Props} */
37
36
  let {
38
37
  values, // Matrix with values to show heatmap for
39
38
  breaks, // vector with breaks to distribute the values in
@@ -42,6 +41,54 @@
42
41
  } = $props();
43
42
 
44
43
 
44
+ function validateBreaks(values) {
45
+ if (!values || values.length < 2) {
46
+ console.error('Heatmap: "breaks" must contain at least two values.');
47
+ return null;
48
+ }
49
+
50
+ const items = Array.from(values);
51
+
52
+ if (items.some(value =>
53
+ typeof value !== 'number' || Number.isNaN(value)
54
+ )) {
55
+ console.error('Heatmap: values in "breaks" must be numeric.');
56
+ return null;
57
+ }
58
+
59
+ if (items.some((value, index) =>
60
+ index > 0 && value <= items[index - 1]
61
+ )) {
62
+ console.error('Heatmap: values in "breaks" must be strictly increasing.');
63
+ return null;
64
+ }
65
+
66
+ return values;
67
+ }
68
+
69
+
70
+ function findInterval(value, breaks) {
71
+ let low = 1;
72
+ let high = breaks.length - 1;
73
+
74
+ while (low < high) {
75
+ const middle = Math.floor((low + high) / 2);
76
+
77
+ if (value <= breaks[middle]) {
78
+ high = middle;
79
+ } else {
80
+ low = middle + 1;
81
+ }
82
+ }
83
+
84
+ const index = low - 1;
85
+
86
+ return value > breaks[index] && value <= breaks[index + 1]
87
+ ? index
88
+ : -1;
89
+ }
90
+
91
+
45
92
 
46
93
  // check that values are provided as a matrix
47
94
  let v = $derived(ismatrix(values) && values.nrows > 0 && values.ncols > 0 ? values : null);
@@ -49,19 +96,37 @@
49
96
  // check and process left values for breaks
50
97
  let lb = $derived.by(() => {
51
98
  if (!v) return null;
52
- if (breaks && Array.isArray(breaks)) return breaks;
53
- if (breaks && isvector(breaks)) return breaks.v;
54
- return split(v.v, HEATMAP_NUM_SPLITS).v;
99
+
100
+ if (breaks !== undefined && breaks !== null) {
101
+ if (Array.isArray(breaks)) return validateBreaks(breaks);
102
+ if (isvector(breaks)) return validateBreaks(breaks.v);
103
+
104
+ console.error('Heatmap: "breaks" must be an array or Vector.');
105
+ return null;
106
+ }
107
+
108
+ const valueRange = range(v.v);
109
+ if (valueRange[0] === valueRange[1]) {
110
+ const value = valueRange[0];
111
+ const delta = Math.abs(value) > 0 ? Math.abs(value) * 0.01 : 1;
112
+ const lower = Number.isFinite(value - delta) ? value - delta : value;
113
+ const upper = Number.isFinite(value + delta) ? value + delta : value;
114
+ return validateBreaks([lower, upper]);
115
+ }
116
+
117
+ return validateBreaks(split(v.v, HEATMAP_NUM_SPLITS).v);
55
118
  });
56
119
 
57
120
  // check and process colormap values
58
121
  let lc = $derived.by(() => {
122
+ if (!lb) return null;
123
+
59
124
  if (colmap) {
60
125
  if (!Array.isArray(colmap)) {
61
126
  console.error('Heatmap: parameter "colmap" must be array with colors.');
62
127
  return null;
63
128
  }
64
- if (colmap.length !== breaks.length - 1) {
129
+ if (colmap.length !== lb.length - 1) {
65
130
  console.error('Heatmap: number of color values in colormap does not match number of intervals defined by breaks.');
66
131
  return null;
67
132
  }
@@ -81,10 +146,11 @@
81
146
  const [top, left] = lt;
82
147
  const nv = left.length; // number of values in matrix
83
148
  const nb = lb.length; // number of breaks
84
- const rl = Array(nb).fill().map(() => []); // coordinates of left for every break
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
149
+ const intervalCount = nb - 1;
150
+ const rl = Array.from({length: intervalCount}, () => []); // coordinates of left for every interval
151
+ const rt = Array.from({length: intervalCount}, () => []); // coordinates of top for every interval
152
+ const rr = Array.from({length: intervalCount}, () => []); // row indices for every interval
153
+ const rc = Array.from({length: intervalCount}, () => []); // column indices for every interval
88
154
 
89
155
  // adjust left side for the first break and right side for the last
90
156
  const llb = lb.slice();
@@ -95,15 +161,13 @@
95
161
  // loop over all breaks
96
162
  for (let j = 0; j < nv; j++) {
97
163
  const vj = v.v[j];
98
- for (let i = 0; i < nb - 1; i++) {
99
- if (vj > llb[i] && vj <= llb[i + 1]) {
100
- rl[i].push(left.v[j] - 0.5);
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]);
104
- break;
105
- }
106
- }
164
+ const i = findInterval(vj, llb);
165
+ if (i === -1) continue;
166
+
167
+ rl[i].push(left.v[j] - 0.5);
168
+ rt[i].push(v.nrows - top.v[j] + 1.5);
169
+ rr[i].push(top.v[j]);
170
+ rc[i].push(left.v[j]);
107
171
  };
108
172
  return {rl, rt, rr, rc};
109
173
  });
@@ -40,7 +40,7 @@
40
40
  </Axes>
41
41
  -->
42
42
  <script>
43
- import { getContext } from 'svelte';
43
+ import { getContext, onDestroy } from 'svelte';
44
44
  import { Colors, MARKER_SYMBOLS } from '../constants';
45
45
  import { text2svg } from '../methods';
46
46
 
@@ -133,4 +133,12 @@
133
133
  {show: false, position: null, items: null}
134
134
  );
135
135
  });
136
+
137
+ onDestroy(() => {
138
+ axes.setGroupLegend({
139
+ show: false,
140
+ position: null,
141
+ items: null
142
+ });
143
+ });
136
144
  </script>
@@ -30,7 +30,6 @@
30
30
  import { val2p, checkCoords, handleClick } from '../methods';
31
31
  import { LINE_STYLES } from '../constants';
32
32
 
33
- /** @type {Props} */
34
33
  let {
35
34
  xValues, // vector or array with x-coordinates of the points
36
35
  yValues, // vector or array with y-coordinates of the points
@@ -60,4 +59,3 @@
60
59
  <polyline class="line" points={p}/>
61
60
  </g>
62
61
  {/if}
63
-
@@ -38,7 +38,6 @@
38
38
  import { val2p, checkCoords, handleClick } from '../methods';
39
39
  import { LINE_STYLES } from '../constants';
40
40
 
41
- /** @type {Props} */
42
41
  let {
43
42
  xValues, // vector or array with x-coordinates of the points
44
43
  yValues, // vector or array with y-coordinates of the points
@@ -61,6 +60,7 @@
61
60
 
62
61
  if (xv.length !== yValues.nrows) {
63
62
  console.error('Multilines: parameter "xValues" must have the same number of values as number of rows in "yValues".');
63
+ return null;
64
64
  }
65
65
 
66
66
  return yValues;
@@ -91,4 +91,3 @@
91
91
  {/each}
92
92
  </g>
93
93
  {/if}
94
-
@@ -31,7 +31,6 @@
31
31
  import { Colors, MARKER_SYMBOLS } from '../constants';
32
32
  import { checkCoords, transformCoords, handleClick } from '../methods';
33
33
 
34
- /** @type {Props} */
35
34
  let {
36
35
  xValues, // array of vector with x-coordinates of points
37
36
  yValues, // array of vector with y-coordinates of points
@@ -36,7 +36,6 @@
36
36
  import { Colors } from '../constants';
37
37
  import { checkCoords, transformCoords, transformObjects, handleClick } from '../methods';
38
38
 
39
- /** @type {Props} */
40
39
  let {
41
40
  left, // array of vector with coordinates of left sides of the bars
42
41
  top, // array of vector with coordinates of top sides of the bars
@@ -80,4 +79,3 @@
80
79
  </g>
81
80
  {/if}
82
81
 
83
-
@@ -71,8 +71,22 @@
71
71
  const p = $derived.by(() => {
72
72
  if (!l) return null;
73
73
 
74
- if (typeof labels === 'string' && typeof pos !== 'number' ) {
75
- console.error('TextLabels: parameter "labels" is provided as single string, so "pos" must be a single number.');
74
+ const hasIndividualPositions =
75
+ Array.isArray(pos) || ArrayBuffer.isView(pos);
76
+
77
+ if (typeof labels === 'string' && hasIndividualPositions) {
78
+ console.error('TextLabels: when "labels" is a single string, "pos" must be a single number.');
79
+ return null;
80
+ }
81
+
82
+ if (hasIndividualPositions && pos.length !== yv.length) {
83
+ console.error('TextLabels: the number of values in "pos" must match the number of coordinates.');
84
+ return null;
85
+ }
86
+
87
+ const positions = hasIndividualPositions ? Array.from(pos) : [pos];
88
+ if (positions.some(v => !Number.isInteger(v) || v < 0 || v > 4)) {
89
+ console.error('TextLabels: values in "pos" must be whole numbers from 0 to 4.');
76
90
  return null;
77
91
  }
78
92
 
@@ -106,14 +120,14 @@
106
120
  {#each x as v, i}
107
121
  <text data-id={i} x={x[i]} y={y[i]} dx={dx[pos]} dy={dy[pos]}
108
122
  transform={rotateAngle !== undefined ? `rotate(${rotateAngle}, ${x[i] + dx[pos]}, ${y[i] + dy[pos]})` : ''}
109
- text-anchor={textAnchors[pos[i]]}>{@html labels}</text>
123
+ text-anchor={textAnchors[pos]}>{@html labels}</text>
110
124
  {/each}
111
125
 
112
126
  {:else if (Array.isArray(pos) || ArrayBuffer.isView(pos))}
113
127
 
114
128
  {#each x as v, i}
115
129
  <text data-id={i} x={x[i]} y={y[i]} dx={dx[pos[i]]} dy={dy[pos[i]]}
116
- transform={rotateAngle !== undefined ? `rotate(${rotateAngle}, ${x[i] + dx[pos]}, ${y[i] + dy[pos]})` : ''}
130
+ transform={rotateAngle !== undefined ? `rotate(${rotateAngle}, ${x[i] + dx[pos[i]]}, ${y[i] + dy[pos[i]]})` : ''}
117
131
  text-anchor={textAnchors[pos[i]]}>{@html labels[i]}</text>
118
132
  {/each}
119
133
 
@@ -16,8 +16,16 @@
16
16
  } = $props()
17
17
 
18
18
  const axes = getContext('axes');
19
- const x = $derived(left ? transformCoords([left], axes.tX()) : null);
20
- const y = $derived(top ? transformCoords([top], axes.tY()) : null );
19
+ const x = $derived(
20
+ left !== undefined && left !== null
21
+ ? transformCoords([left], axes.tX())
22
+ : null
23
+ );
24
+ const y = $derived(
25
+ top !== undefined && top !== null
26
+ ? transformCoords([top], axes.tY())
27
+ : null
28
+ );
21
29
 
22
30
  // styles for bars and labels
23
31
  const textStyleStr = $derived(`fill:${faceColor};stroke-width:${lineWidth}px;stroke:${lineColor};font-size:${textSize}em;`);
@@ -29,4 +37,4 @@
29
37
  <tspan {x} {dx} dy={i === 0 ? 0 : dy}>{@html el}</tspan>
30
38
  {/each}
31
39
  </text>
32
- {/if}
40
+ {/if}
@@ -21,7 +21,7 @@
21
21
  -->
22
22
  <script>
23
23
 
24
- import { getContext } from 'svelte';
24
+ import { getContext, onDestroy } from 'svelte';
25
25
  import { text2svg, validateTicks, validateTickLabels } from '../methods';
26
26
 
27
27
  let {
@@ -60,4 +60,8 @@
60
60
  axes.setXAxis({show, error: error, label: axisLabel, ticks: ticksProcessed,
61
61
  labelHeight, showGrid, las, whole, tickLabels: tickLabelsProcessed});
62
62
  });
63
+
64
+ onDestroy(() => {
65
+ axes.setXAxis({show: false, error: ''});
66
+ });
63
67
  </script>
@@ -21,7 +21,7 @@
21
21
  -->
22
22
  <script>
23
23
 
24
- import { getContext } from 'svelte';
24
+ import { getContext, onDestroy } from 'svelte';
25
25
  import { text2svg, validateTicks, validateTickLabels } from '../methods';
26
26
 
27
27
 
@@ -63,4 +63,8 @@
63
63
  axes.setYAxis({show, error: error, label: axisLabel, ticks: ticksProcessed,
64
64
  labelHeight, showGrid, las, whole, tickLabels: tickLabelsProcessed, pos: 4});
65
65
  });
66
+
67
+ onDestroy(() => {
68
+ axes.setYAxis({show: false, error: ''});
69
+ });
66
70
  </script>
@@ -33,14 +33,13 @@
33
33
  import { matrix, Matrix } from 'mdatools/arrays';
34
34
  import { setContext } from 'svelte';
35
35
  import { PLOT_FONT_SIZE } from '../constants.js';
36
- import { checkArray, getScale, getXAxisCoords3D, getYAxisCoords3D, getZAxisCoords3D,
36
+ import { getScale, getXAxisCoords3D, getYAxisCoords3D, getZAxisCoords3D,
37
37
  downloadPNG, downloadSVG, copyToClipboard} from '../methods.js';
38
38
 
39
39
  import AxisLines from './AxisLines.svelte';
40
40
  import AxisTickLabels from './AxisTickLabels.svelte';
41
41
  import TextLabels from './TextLabels.svelte';
42
42
 
43
- /** @type {Props} */
44
43
  let {
45
44
  limX = [0, 1],
46
45
  limY = [0, 1],
@@ -59,6 +58,21 @@
59
58
  } = $props();
60
59
 
61
60
 
61
+ function hasUsableRange(lim) {
62
+ if (!Array.isArray(lim) || lim.length !== 2) return false;
63
+ if (!lim.every(value =>
64
+ typeof value === 'number' && Number.isFinite(value)
65
+ )) return false;
66
+
67
+ const span = lim[1] - lim[0];
68
+ const scale = Math.max(Math.abs(lim[0]), Math.abs(lim[1]));
69
+
70
+ return span > 0 &&
71
+ Number.isFinite(1 / span) &&
72
+ span > Number.EPSILON * scale * 16;
73
+ }
74
+
75
+
62
76
 
63
77
  /* Copy plot to clipboard as PNG image. */
64
78
  async function handleClickCopy(e) {
@@ -105,12 +119,9 @@
105
119
 
106
120
  // check main plot parameters and set status
107
121
  const isOk = $derived(
108
- checkArray(limX, 2) &&
109
- checkArray(limY, 2) &&
110
- checkArray(limZ, 2) &&
111
- limX[0] < limX[1] &&
112
- limY[0] < limY[1] &&
113
- limZ[0] < limZ[1] &&
122
+ hasUsableRange(limX) &&
123
+ hasUsableRange(limY) &&
124
+ hasUsableRange(limZ) &&
114
125
  width > 50 &&
115
126
  height > 50
116
127
  );
@@ -174,9 +185,9 @@
174
185
  );
175
186
 
176
187
  // containers for axis properties
177
- let xaxis = $state({show: false, label: ""});
178
- let yaxis = $state({show: false, label: ""});
179
- let zaxis = $state({show: false, label: ""});
188
+ let xaxis = $state.raw({show: false, label: ""});
189
+ let yaxis = $state.raw({show: false, label: ""});
190
+ let zaxis = $state.raw({show: false, label: ""});
180
191
 
181
192
  // axes context to share with children
182
193
  setContext('axes', {
@@ -290,9 +301,9 @@
290
301
  </p>
291
302
  {:else}
292
303
  <div class="download-links">
293
- <button onclick={handleClickSVG}>⇩ svg</button>
294
- <button onclick={handleClickPNG}>⇩ png</button>
295
- <button onclick={handleClickCopy}>⧉ copy</button>
304
+ <button type="button" onclick={handleClickSVG}>⇩ svg</button>
305
+ <button type="button" onclick={handleClickPNG}>⇩ png</button>
306
+ <button type="button" onclick={handleClickCopy}>⧉ copy</button>
296
307
  </div>
297
308
  {/if}
298
309
  </div>
@@ -399,4 +410,4 @@
399
410
  background: #4488ee;
400
411
  }
401
412
 
402
- </style>
413
+ </style>
@@ -45,25 +45,50 @@
45
45
 
46
46
  // check user defined coordinates
47
47
  const ux = $derived(checkCoords(xValues, 'Mesh (3D)'));
48
- const uz = $derived(ux ? checkCoords(zValues, 'Mesh (3D)', ux.length) : null);
48
+ const uz = $derived(ux ? checkCoords(zValues, 'Mesh (3D)') : null);
49
49
  const Uy = $derived.by(() => {
50
50
  if (!ux || !uz) return null;
51
51
  if (!ismatrix(yValues)) {
52
52
  console.error('Mesh: parameter "yValues" must be a matrix with height values of mesh points.');
53
53
  return null;
54
54
  }
55
+ if (yValues.nrows !== ux.length || yValues.ncols !== uz.length) {
56
+ console.error('Mesh: dimensions of "yValues" must match the lengths of "xValues" and "zValues".');
57
+ return null;
58
+ }
55
59
  return yValues;
56
60
  });
57
61
 
58
62
 
63
+ const colors = $derived.by(() => {
64
+ const isValid =
65
+ Array.isArray(colmap) &&
66
+ colmap.length > 0 &&
67
+ colmap.every(color =>
68
+ typeof color === 'string' && color.length > 0
69
+ );
70
+
71
+ if (!isValid) {
72
+ console.error(
73
+ 'Mesh: parameter "colmap" must be a non-empty array of color strings.'
74
+ );
75
+ return null;
76
+ }
77
+
78
+ return colmap;
79
+ });
80
+
81
+
59
82
  // compute coordinates of mesh elements
60
83
  const mesh = $derived.by(() => {
61
84
 
62
- if (!Uy) return null;
85
+ if (!Uy || !colors) return null;
63
86
 
64
- const nbins = colmap.length;
87
+ const nbins = colors.length;
65
88
  const nx = Uy.nrows;
66
89
  const nz = Uy.ncols;
90
+ const nsegmentsX = (nx - 1) * nz;
91
+ const nsegmentsZ = nx * (nz - 1);
67
92
 
68
93
  const nr = range(Uy.v);
69
94
  const dnr = (nr[1] - nr[0]) / 100;
@@ -71,26 +96,36 @@
71
96
  const right = nr[1] + dnr;
72
97
  const span = right - left;
73
98
 
99
+ function getColorIndex(value) {
100
+ if (!(span > 0)) return Math.floor(nbins / 2);
101
+
102
+ const index = Math.round(
103
+ (value - left) / span * (nbins - 1)
104
+ );
105
+
106
+ return Math.max(0, Math.min(nbins - 1, index));
107
+ }
108
+
74
109
  // compute coordinates for mesh segments lines
75
- const x1s = Vector.zeros((nx - 1) * nz);
76
- const z1s = Vector.zeros((nx - 1) * nz);
77
- const y1s = Vector.zeros((nx - 1) * nz);
110
+ const x1s = Vector.zeros(nsegmentsX);
111
+ const z1s = Vector.zeros(nsegmentsX);
112
+ const y1s = Vector.zeros(nsegmentsX);
78
113
 
79
- const x2s = Vector.zeros((nx - 1) * nz);
80
- const z2s = Vector.zeros((nx - 1) * nz);
81
- const y2s = Vector.zeros((nx - 1) * nz);
114
+ const x2s = Vector.zeros(nsegmentsZ);
115
+ const z2s = Vector.zeros(nsegmentsZ);
116
+ const y2s = Vector.zeros(nsegmentsZ);
82
117
 
83
- const color1 = Vector.zeros((nx - 1) * nz);
118
+ const color1 = Vector.zeros(nsegmentsX);
84
119
 
85
- const x1e = Vector.zeros((nx - 1) * nz);
86
- const z1e = Vector.zeros((nx - 1) * nz);
87
- const y1e = Vector.zeros((nx - 1) * nz);
120
+ const x1e = Vector.zeros(nsegmentsX);
121
+ const z1e = Vector.zeros(nsegmentsX);
122
+ const y1e = Vector.zeros(nsegmentsX);
88
123
 
89
- const x2e = Vector.zeros((nx - 1) * nz);
90
- const z2e = Vector.zeros((nx - 1) * nz);
91
- const y2e = Vector.zeros((nx - 1) * nz);
124
+ const x2e = Vector.zeros(nsegmentsZ);
125
+ const z2e = Vector.zeros(nsegmentsZ);
126
+ const y2e = Vector.zeros(nsegmentsZ);
92
127
 
93
- const color2 = Vector.zeros((nx - 1) * nz);
128
+ const color2 = Vector.zeros(nsegmentsZ);
94
129
 
95
130
  let n1 = 0, n2 = 0;
96
131
  for (let z = 0; z < nz; z++) {
@@ -108,7 +143,8 @@
108
143
  z1e.v[n1] = uz.v[z];
109
144
  y1e.v[n1] = Uy.v[z * nx + x + 1];
110
145
 
111
- const colInd = Math.round(((Uy.v[z * nx + x] + Uy.v[z * nx + x + 1]) / 2 - left) / span * (nbins - 1));
146
+ const meanHeight = (Uy.v[z * nx + x] + Uy.v[z * nx + x + 1]) / 2;
147
+ const colInd = getColorIndex(meanHeight);
112
148
  color1.v[n1] = colInd;
113
149
  n1 = n1 + 1;
114
150
  }
@@ -125,7 +161,8 @@
125
161
  z2e.v[n2] = uz.v[z + 1];
126
162
  y2e.v[n2] = Uy.v[(z + 1) * nx + x];
127
163
 
128
- const colInd = Math.round(((Uy.v[z * nx + x] + Uy.v[(z + 1) * nx + x]) / 2 - left) / span * (nbins - 1));
164
+ const meanHeight = (Uy.v[z * nx + x] + Uy.v[(z + 1) * nx + x]) / 2;
165
+ const colInd = getColorIndex(meanHeight);
129
166
  color2.v[n2] = colInd;
130
167
  n2 = n2 + 1;
131
168
  }
@@ -153,11 +190,10 @@
153
190
  <g class="series series_mesh" style={lineStyleStr} data-title={title}>
154
191
  {#if s1s && s1e && s2s && s2e }
155
192
  {#each s1s.x as v, i}
156
- <line x1={s1s.x[i]} x2={s1e.x[i]} y1={s1s.y[i]} y2={s1e.y[i]} style={`stroke:${colmap[mesh.color1.v[i]]};`} />
193
+ <line x1={s1s.x[i]} x2={s1e.x[i]} y1={s1s.y[i]} y2={s1e.y[i]} style={`stroke:${colors[mesh.color1.v[i]]};`} />
157
194
  {/each}
158
195
  {#each s2s.x as v, i}
159
- <line x1={s2s.x[i]} x2={s2e.x[i]} y1={s2s.y[i]} y2={s2e.y[i]} style={`stroke:${colmap[mesh.color2.v[i]]};`} />
196
+ <line x1={s2s.x[i]} x2={s2e.x[i]} y1={s2s.y[i]} y2={s2e.y[i]} style={`stroke:${colors[mesh.color2.v[i]]};`} />
160
197
  {/each}
161
198
  {/if}
162
199
  </g>
163
-
@@ -22,7 +22,7 @@
22
22
  ```
23
23
  -->
24
24
  <script>
25
- import { getContext } from 'svelte';
25
+ import { getContext, onDestroy } from 'svelte';
26
26
  import { Colors } from '../constants';
27
27
 
28
28
  let {
@@ -41,6 +41,9 @@
41
41
  $effect(() => {
42
42
  axes.setXAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
43
43
  });
44
- </script>
45
44
 
45
+ onDestroy(() => {
46
+ axes.setXAxis({show: false});
47
+ });
48
+ </script>
46
49
 
@@ -22,7 +22,7 @@
22
22
  ```
23
23
  -->
24
24
  <script>
25
- import { getContext } from 'svelte';
25
+ import { getContext, onDestroy } from 'svelte';
26
26
  import { Colors } from '../constants';
27
27
 
28
28
  let {
@@ -41,6 +41,9 @@
41
41
  $effect(() => {
42
42
  axes.setYAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
43
43
  });
44
- </script>
45
44
 
45
+ onDestroy(() => {
46
+ axes.setYAxis({show: false});
47
+ });
48
+ </script>
46
49
 
@@ -22,7 +22,7 @@
22
22
  ```
23
23
  -->
24
24
  <script>
25
- import { getContext } from 'svelte';
25
+ import { getContext, onDestroy } from 'svelte';
26
26
  import { Colors } from '../constants';
27
27
 
28
28
  let {
@@ -41,6 +41,9 @@
41
41
  $effect(() => {
42
42
  axes.setZAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
43
43
  });
44
- </script>
45
44
 
45
+ onDestroy(() => {
46
+ axes.setZAxis({show: false});
47
+ });
48
+ </script>
46
49
 
@@ -25,9 +25,13 @@
25
25
  const aspectRatio = svgWidth / svgHeight;
26
26
 
27
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
+
28
32
  let width = $state(initialWidth);
29
33
  const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
30
- let res = $state(initialRes);
34
+ let res = $state(defaultRes);
31
35
  let name = $state(fileName);
32
36
  const nameValid = $derived(name.trim().length > 0);
33
37
 
@@ -73,7 +77,6 @@
73
77
  width = parseFloat(e.target.value);
74
78
  }
75
79
 
76
- const resOptions = [100, 150, 300, 600];
77
80
  let saving = $state(false);
78
81
 
79
82
  function handleSave() {
@@ -168,8 +171,7 @@
168
171
  <div class="export-dialog-res-options">
169
172
  {#each resOptions as opt}
170
173
  <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} />
174
+ <input type="radio" name="export-res" value={opt} bind:group={res} />
173
175
  {opt}
174
176
  </label>
175
177
  {/each}
@@ -179,8 +181,8 @@
179
181
  </div>
180
182
 
181
183
  <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
+ <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>
184
186
  </div>
185
187
  </div>
186
188
  </div>
package/src/methods.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { cbind, vector, isvector, Vector, matrix } from 'mdatools/arrays';
2
2
  import { min, max, diff } from 'mdatools/stat';
3
- import { TICK_NUM, XTICK_NUM, YTICK_NUM, TICK_SIZE, COLMAP_WIDTH_RATIO, COLMAP_EL_HEIGHTS_PX, COLMAP_TOP_MARGIN } from './constants';
3
+ import { TICK_NUM, XTICK_NUM, YTICK_NUM, TICK_SIZE, COLMAP_WIDTH_RATIO, COLMAP_EL_HEIGHTS_PX, COLMAP_TOP_MARGIN } from './constants.js';
4
4
 
5
5
 
6
6
  /**
@@ -486,13 +486,20 @@ function createPngDataTable() {
486
486
  }
487
487
 
488
488
 
489
+ let pngDataTable = null;
490
+
491
+
489
492
  /**
490
493
  * Calculate CRC for PNG image
491
494
  * @param buf image data
492
495
  */
493
496
  function calcCrc(buf) {
494
497
  let c = -1;
495
- let pngDataTable = createPngDataTable();
498
+
499
+ if (!pngDataTable) {
500
+ pngDataTable = createPngDataTable();
501
+ }
502
+
496
503
  for (let n = 0; n < buf.length; n++) {
497
504
  c = pngDataTable[(c ^ buf[n]) & 0xFF] ^ (c >>> 8);
498
505
  }
@@ -645,11 +652,10 @@ export function downloadPNG (svg, fileName, width, height, res, onSerialize) {
645
652
  canvas.width = svgWidth * (specialScale < 1 ? specialScale : scaleFactor);
646
653
  canvas.height = svgHeight * (specialScale < 1 ? specialScale : scaleFactor);
647
654
 
648
- // save and set corresponding attributes for SVG element
649
- const origWidth = svg.getAttribute('width');
650
- const origHeight = svg.getAttribute('height');
651
- svg.setAttribute('width', svgWidth * scaleFactor);
652
- svg.setAttribute('height', svgHeight * scaleFactor);
655
+ // clone the SVG and set export dimensions without changing the live plot
656
+ const exportSvg = svg.cloneNode(true);
657
+ exportSvg.setAttribute('width', svgWidth * scaleFactor);
658
+ exportSvg.setAttribute('height', svgHeight * scaleFactor);
653
659
 
654
660
  // draw a white background
655
661
  context.fillStyle = 'white';
@@ -657,38 +663,43 @@ export function downloadPNG (svg, fileName, width, height, res, onSerialize) {
657
663
  context.scale(scaleFactor, scaleFactor)
658
664
 
659
665
  // draw the SVG onto the canvas
660
- const svgXml = new XMLSerializer().serializeToString(svg);
666
+ const svgXml = new XMLSerializer().serializeToString(exportSvg);
661
667
  const blob = new Blob([svgXml], { type: 'image/svg+xml;charset=utf-8' });
662
668
  const url = URL.createObjectURL(blob);
663
669
 
664
- // restore original attributes
665
- origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
666
- origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
667
-
668
670
  // notify caller that serialization is done (used by ExportDialog to restore container)
669
671
  if (onSerialize) onSerialize();
670
672
 
671
673
  const img = new Image();
672
674
 
673
675
  img.onload = function () {
676
+ try {
674
677
 
675
- // downscale image if it must be smaller than the current SVG element
676
- if (specialScale < 1) {
677
- img.width = img.width * specialScale;
678
- img.height = img.height * specialScale;
679
- }
678
+ // downscale image if it must be smaller than the current SVG element
679
+ if (specialScale < 1) {
680
+ img.width = img.width * specialScale;
681
+ img.height = img.height * specialScale;
682
+ }
680
683
 
681
- context.drawImage(img, 0, 0, canvas.width, canvas.height);
684
+ context.drawImage(img, 0, 0, canvas.width, canvas.height);
685
+
686
+ // create a data URL for the canvas and adjust its DPI
687
+ const dataURL = canvas.toDataURL('image/png');
688
+ const dataURL2 = changeDPI(dataURL, res);
682
689
 
683
- // create a data URL for the canvas and adjust its DPI
684
- const dataURL = canvas.toDataURL('image/png');
685
- const dataURL2 = changeDPI(dataURL, res);
690
+ // create a temporary link and trigger the download
691
+ const downloadLink = document.createElement('a');
692
+ downloadLink.href = dataURL2;
693
+ downloadLink.download = `${fileName}.png`;
694
+ downloadLink.click();
695
+ } finally {
696
+ URL.revokeObjectURL(url);
697
+ }
698
+ };
686
699
 
687
- // create a temporary link and trigger the download
688
- const downloadLink = document.createElement('a');
689
- downloadLink.href = dataURL2;
690
- downloadLink.download = `${fileName}.png`;
691
- downloadLink.click();
700
+ img.onerror = function (error) {
701
+ URL.revokeObjectURL(url);
702
+ console.error('Failed to load the SVG for PNG export.', error);
692
703
  };
693
704
 
694
705
  // set the image source
@@ -718,26 +729,19 @@ export function downloadSVG(svg, fileName) {
718
729
  const svgHeight = svg.clientHeight;
719
730
  const svgWidth = svg.clientWidth;
720
731
 
721
- // save original attributes
722
- const origWidth = svg.getAttribute('width');
723
- const origHeight = svg.getAttribute('height');
724
- const origViewBox = svg.getAttribute('viewBox');
725
-
726
- svg.setAttribute('width', svgWidth + 'px');
727
- svg.setAttribute('height', svgHeight + 'px');
728
- svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
732
+ // clone the SVG and set export dimensions without changing the live plot
733
+ const exportSvg = svg.cloneNode(true);
734
+ exportSvg.setAttribute('width', svgWidth + 'px');
735
+ exportSvg.setAttribute('height', svgHeight + 'px');
736
+ exportSvg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
729
737
 
730
- const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });
738
+ const blob = new Blob([exportSvg.outerHTML], { type: 'image/svg+xml' });
731
739
  const url = window.URL.createObjectURL(blob)
732
740
  const a = document.createElement('a')
733
741
  a.setAttribute('href', url)
734
742
  a.setAttribute('download', `${fileName}.svg`);
735
743
  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');
744
+ setTimeout(() => window.URL.revokeObjectURL(url), 0);
741
745
  }
742
746
 
743
747
  /**
@@ -813,16 +817,11 @@ export async function createPngBlob(svg, width, height) {
813
817
  // scale the context
814
818
  context.scale(scaleFactor, scaleFactor)
815
819
 
816
- // save and set corresponding attributes for SVG element
817
- const origWidth = svg.getAttribute('width');
818
- const origHeight = svg.getAttribute('height');
819
- svg.setAttribute('width', canvas.width);
820
- svg.setAttribute('height', canvas.height);
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');
820
+ // clone and serialize the SVG with export dimensions without changing the live plot
821
+ const exportSvg = svg.cloneNode(true);
822
+ exportSvg.setAttribute('width', canvas.width);
823
+ exportSvg.setAttribute('height', canvas.height);
824
+ const svgData = new XMLSerializer().serializeToString(exportSvg);
826
825
 
827
826
  // create promise
828
827
  const promise = new Promise((resolve, reject) => {
@@ -852,6 +851,7 @@ export async function createPngBlob(svg, width, height) {
852
851
 
853
852
  // add error callback
854
853
  img.onerror = function (err) {
854
+ DOMURL.revokeObjectURL(url);
855
855
  reject(err);
856
856
  };
857
857
 
@@ -864,6 +864,66 @@ export async function createPngBlob(svg, width, height) {
864
864
  }
865
865
 
866
866
 
867
+ let colmaps = null;
868
+
869
+
870
+ function getColmaps() {
871
+ if (!colmaps) {
872
+ colmaps = [
873
+ ["#2679B2"],
874
+ ["#2679B2", "#D22C2F"],
875
+ ["#2679B2", "#92B42A", "#D22C2F"],
876
+ ["#2679B2", "#2E9658", "#F2B825", "#D22C2F"],
877
+ ["#2679B2", "#22988A", "#92B42A", "#F79426", "#D22C2F"],
878
+ ["#2679B2", "#1C9AA8", "#379531", "#EED524", "#FB7F28", "#D22C2F"],
879
+ ["#2679B2", "#1D94A9", "#2E9658", "#92B42A", "#F2B825", "#F47129", "#D22C2F"],
880
+ ["#2679B2", "#1E90AA", "#279775", "#519E2F", "#D3CB25", "#F5A326", "#EF672A", "#D22C2F"],
881
+ ["#2679B2", "#1F8DAB", "#22988A", "#33953F", "#92B42A", "#EFCA24", "#F79426", "#EB5F2A", "#D22C2F"],
882
+ ["#2679B2", "#208BAC", "#1E999A", "#2E9658", "#5FA32E", "#C5C626", "#F2B825", "#F98827", "#E85A2B", "#D22C2F"],
883
+ ["#2679B2", "#2089AD", "#1C9AA8", "#29976C", "#379531", "#92B42A", "#EED524", "#F4AA26", "#FB7F28", "#E6552B", "#D22C2F"],
884
+ ["#2679B2", "#2188AD", "#1C97A8", "#25987C", "#329546", "#68A62D", "#BCC327", "#F0C524", "#F69E26", "#F77728", "#E4512B", "#D22C2F"],
885
+ ["#2679B2", "#2186AD", "#1D94A9", "#22988A", "#2E9658", "#469A2F", "#92B42A", "#DECF25", "#F2B825", "#F79426", "#F47129", "#E34E2C", "#D22C2F"],
886
+ ["#2679B2", "#2285AE", "#1E92AA", "#209995", "#2A9767", "#34953A", "#6FA82D", "#B5C128", "#EFCE24", "#F3AD25", "#F98C27", "#F16B29", "#E14B2C", "#D22C2F"],
887
+ ["#2679B2", "#2284AE", "#1E90AA", "#1D999F", "#279775", "#31964A", "#519E2F", "#92B42A", "#D3CB25", "#F0C224", "#F5A326", "#FA8527", "#EF672A", "#E0492C", "#D22C2F"],
888
+ ["#2679B2", "#2283AE", "#1F8FAB", "#1C9AA8", "#249880", "#2E9658", "#379531", "#73AA2C", "#B0BF28", "#EDD424", "#F2B825", "#F69B26", "#FB7F28", "#ED632A", "#DF472C", "#D22C2F"]
889
+ ];
890
+ }
891
+
892
+ return colmaps;
893
+ }
894
+
895
+
896
+ function interpolateColor(start, end, amount) {
897
+ const channels = [1, 3, 5].map(offset => {
898
+ const from = parseInt(start.slice(offset, offset + 2), 16);
899
+ const to = parseInt(end.slice(offset, offset + 2), 16);
900
+
901
+ return Math.round(from + (to - from) * amount)
902
+ .toString(16)
903
+ .padStart(2, '0');
904
+ });
905
+
906
+ return `#${channels.join('')}`.toUpperCase();
907
+ }
908
+
909
+
910
+ function expandColmap(colors, length) {
911
+ const last = colors.length - 1;
912
+
913
+ return Array.from({length}, (_, index) => {
914
+ const position = index * last / (length - 1);
915
+ const start = Math.floor(position);
916
+ const end = Math.min(start + 1, last);
917
+
918
+ return interpolateColor(
919
+ colors[start],
920
+ colors[end],
921
+ position - start
922
+ );
923
+ });
924
+ }
925
+
926
+
867
927
  /**
868
928
  * Returns an array with 'n' colors.
869
929
  *
@@ -874,27 +934,14 @@ export async function createPngBlob(svg, width, height) {
874
934
  *
875
935
  */
876
936
  export function getcolmap(n, alpha) {
877
- if (n > 16) n = 16;
878
- const colmap = [
879
- ["#2679B2"],
880
- ["#2679B2", "#D22C2F"],
881
- ["#2679B2", "#92B42A", "#D22C2F"],
882
- ["#2679B2", "#2E9658", "#F2B825", "#D22C2F"],
883
- ["#2679B2", "#22988A", "#92B42A", "#F79426", "#D22C2F"],
884
- ["#2679B2", "#1C9AA8", "#379531", "#EED524", "#FB7F28", "#D22C2F"],
885
- ["#2679B2", "#1D94A9", "#2E9658", "#92B42A", "#F2B825", "#F47129", "#D22C2F"],
886
- ["#2679B2", "#1E90AA", "#279775", "#519E2F", "#D3CB25", "#F5A326", "#EF672A", "#D22C2F"],
887
- ["#2679B2", "#1F8DAB", "#22988A", "#33953F", "#92B42A", "#EFCA24", "#F79426", "#EB5F2A", "#D22C2F"],
888
- ["#2679B2", "#208BAC", "#1E999A", "#2E9658", "#5FA32E", "#C5C626", "#F2B825", "#F98827", "#E85A2B", "#D22C2F"],
889
- ["#2679B2", "#2089AD", "#1C9AA8", "#29976C", "#379531", "#92B42A", "#EED524", "#F4AA26", "#FB7F28", "#E6552B", "#D22C2F"],
890
- ["#2679B2", "#2188AD", "#1C97A8", "#25987C", "#329546", "#68A62D", "#BCC327", "#F0C524", "#F69E26", "#F77728", "#E4512B", "#D22C2F"],
891
- ["#2679B2", "#2186AD", "#1D94A9", "#22988A", "#2E9658", "#469A2F", "#92B42A", "#DECF25", "#F2B825", "#F79426", "#F47129", "#E34E2C", "#D22C2F"],
892
- ["#2679B2", "#2285AE", "#1E92AA", "#209995", "#2A9767", "#34953A", "#6FA82D", "#B5C128", "#EFCE24", "#F3AD25", "#F98C27", "#F16B29", "#E14B2C", "#D22C2F"],
893
- ["#2679B2", "#2284AE", "#1E90AA", "#1D999F", "#279775", "#31964A", "#519E2F", "#92B42A", "#D3CB25", "#F0C224", "#F5A326", "#FA8527", "#EF672A", "#E0492C", "#D22C2F"],
894
- ["#2679B2", "#2283AE", "#1F8FAB", "#1C9AA8", "#249880", "#2E9658", "#379531", "#73AA2C", "#B0BF28", "#EDD424", "#F2B825", "#F69B26", "#FB7F28", "#ED632A", "#DF472C", "#D22C2F"]
895
- ]
937
+ const palettes = getColmaps();
938
+ const colors = n <= 16
939
+ ? palettes[n - 1]?.slice()
940
+ : expandColmap(palettes[15], n);
941
+
942
+ if (!colors) return colors;
896
943
 
897
- return alpha ? colmap[n - 1].map(v => v + alpha) : colmap[n - 1];
944
+ return alpha ? colors.map(v => v + alpha) : colors;
898
945
  }
899
946
 
900
947
 
@@ -1175,11 +1222,16 @@ export function handleClick(e, tagName, onclickCallback) {
1175
1222
  *
1176
1223
  * @return {number} size of element in pixels
1177
1224
  */
1225
+ let textMeasureContext = null;
1226
+
1178
1227
  export function getTextWidth(text, font) {
1179
- const element = document.createElement('canvas');
1180
- const context = element.getContext('2d');
1181
- context.font = font;
1182
- return context.measureText(text.replace(/<(.|\n)*?>/g, '')).width;
1228
+ if (!textMeasureContext) {
1229
+ const canvas = document.createElement('canvas');
1230
+ textMeasureContext = canvas.getContext('2d');
1231
+ }
1232
+
1233
+ textMeasureContext.font = font;
1234
+ return textMeasureContext.measureText(text.replace(/<(.|\n)*?>/g, '')).width;
1183
1235
  }
1184
1236
 
1185
1237
 
@@ -1523,4 +1575,4 @@ export function getZAxisCoords3D(zaxis, limX, limY, limZ, scale) {
1523
1575
  ];
1524
1576
 
1525
1577
  return {grid1, grid2, axisLine, tickCoords, titleCoords, tickLabels};
1526
- }
1578
+ }