svelte-plots-basic 3.4.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -14
- package/package.json +4 -5
- package/src/2d/Area.svelte +8 -7
- package/src/2d/Axes.svelte +10 -8
- package/src/2d/AxisLines.svelte +4 -6
- package/src/2d/AxisTickLabels.svelte +1 -3
- package/src/2d/Bars.svelte +30 -9
- package/src/2d/Heatmap.svelte +15 -6
- package/src/2d/Legend.svelte +74 -30
- package/src/2d/Lines.svelte +6 -5
- package/src/2d/Multilines.svelte +9 -8
- package/src/2d/Points.svelte +9 -9
- package/src/2d/Rectangles.svelte +47 -18
- package/src/2d/Segments.svelte +3 -3
- package/src/2d/TextLabels.svelte +12 -10
- package/src/2d/TextLegend.svelte +49 -11
- package/src/2d/YAxis.svelte +1 -1
- package/src/3d/Axes.svelte +5 -7
- package/src/3d/AxisLines.svelte +3 -4
- package/src/3d/AxisTickLabels.svelte +2 -2
- package/src/3d/Lines.svelte +8 -8
- package/src/3d/Mesh.svelte +15 -13
- package/src/3d/Points.svelte +8 -9
- package/src/3d/Segments.svelte +11 -13
- package/src/3d/TextLabels.svelte +27 -14
- package/src/3d/XAxis.svelte +2 -4
- package/src/3d/YAxis.svelte +4 -6
- package/src/3d/ZAxis.svelte +4 -6
- package/src/ExportDialog.svelte +236 -66
- package/src/methods.js +241 -127
package/src/3d/Segments.svelte
CHANGED
|
@@ -11,24 +11,22 @@
|
|
|
11
11
|
- `lineColor` - color of segment lines, default: `Colors.PRIMARY`.
|
|
12
12
|
- `lineType` - type of segment lines (`1` - solid, `2` - dashed, `3` - dotted, `4` - dashdot).
|
|
13
13
|
- `lineWidth` - width (thickness) of segment lines in pixels, default: `1`.
|
|
14
|
-
- `className` - CSS class name of the SVG group, default: `'series-seg'`.
|
|
15
14
|
- `title` - title of the segment series group.
|
|
16
|
-
- `onclick` - function (callback) to be called when user clicks on any segment.
|
|
17
15
|
|
|
18
16
|
Example:
|
|
19
|
-
```
|
|
17
|
+
```svelte
|
|
20
18
|
<script>
|
|
21
19
|
import { Axes, Segments } from 'svelte-plots-basic/3d';
|
|
22
20
|
|
|
23
|
-
const xStart = [1, 2, 3, 4];
|
|
24
|
-
const xEnd = [1, 2, 3, 4];
|
|
21
|
+
const xStart = [1, 2, 3, 4, 5];
|
|
22
|
+
const xEnd = [1, 2, 3, 4, 5];
|
|
25
23
|
const yStart = [-2, -1, 0, -1, -2];
|
|
26
24
|
const yEnd = [2, 1, 0, 1, 2];
|
|
27
|
-
const zStart = [1, 2, 3, 4];
|
|
28
|
-
const zEnd = [1, 2, 3, 4];
|
|
25
|
+
const zStart = [1, 2, 3, 4, 5];
|
|
26
|
+
const zEnd = [1, 2, 3, 4, 5];
|
|
29
27
|
</script>
|
|
30
28
|
|
|
31
|
-
<Axes limX={[0,
|
|
29
|
+
<Axes limX={[0, 6]} limY={[-3, 3]} limZ={[0, 6]}>
|
|
32
30
|
<Segments {xStart} {yStart} {zStart} {xEnd} {yEnd} {zEnd} lineColor="red" lineType={3} />
|
|
33
31
|
</Axes>
|
|
34
32
|
```
|
|
@@ -37,7 +35,7 @@
|
|
|
37
35
|
import { cbind } from 'mdatools/arrays';
|
|
38
36
|
import { getContext } from 'svelte';
|
|
39
37
|
import { Colors, LINE_STYLES } from '../constants';
|
|
40
|
-
import { checkCoords, transform3D } from '../methods';
|
|
38
|
+
import { checkCoords, transform3D, normalizeLineType } from '../methods';
|
|
41
39
|
|
|
42
40
|
|
|
43
41
|
let {
|
|
@@ -62,6 +60,7 @@
|
|
|
62
60
|
const ux2 = $derived(uz1 ? checkCoords(xEnd, 'Segments (3D)', ux1.length) : null);
|
|
63
61
|
const uy2 = $derived(ux2 ? checkCoords(yEnd, 'Segments (3D)', ux1.length) : null);
|
|
64
62
|
const uz2 = $derived(uy2 ? checkCoords(zEnd, 'Segments (3D)', ux1.length) : null);
|
|
63
|
+
const validLineType = $derived(normalizeLineType(lineType, 'Segments (3D)'));
|
|
65
64
|
|
|
66
65
|
// combine world coordinates to matrices
|
|
67
66
|
const uW1 = $derived(ux1 && uy1 && uz1 ? cbind(ux1, uy1, uz1) : null);
|
|
@@ -72,14 +71,13 @@
|
|
|
72
71
|
const s1 = $derived(uW1 ? transform3D(uW1, axes.tM()) : null);
|
|
73
72
|
const s2 = $derived(uW2 ? transform3D(uW2, axes.tM()) : null);
|
|
74
73
|
|
|
75
|
-
const lineStyleStr = $derived(`stroke:${lineColor};stroke-width: ${lineWidth}px;stroke-dasharray:${LINE_STYLES[axes.scale()][
|
|
74
|
+
const lineStyleStr = $derived(`stroke:${lineColor};stroke-width: ${lineWidth}px;stroke-dasharray:${LINE_STYLES[axes.scale()][validLineType - 1]}`);
|
|
76
75
|
</script>
|
|
77
76
|
|
|
78
|
-
<g class="series series-segments" data-title={title} style={lineStyleStr}>
|
|
79
77
|
{#if s1 && s2}
|
|
78
|
+
<g class="series series-segments" data-title={title} style={lineStyleStr}>
|
|
80
79
|
{#each s1.x as v, i}
|
|
81
80
|
<line x1={s1.x[i]} x2={s2.x[i]} y1={s1.y[i]} y2={s2.y[i]} />
|
|
82
81
|
{/each}
|
|
83
|
-
{/if}
|
|
84
82
|
</g>
|
|
85
|
-
|
|
83
|
+
{/if}
|
package/src/3d/TextLabels.svelte
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
@component Adds a series of text labels at given coordinates.
|
|
3
3
|
|
|
4
4
|
Main properties:
|
|
5
|
-
- `xValues` - array
|
|
6
|
-
- `yValues` - array
|
|
7
|
-
- `zValues` - array
|
|
8
|
-
- `labels` - array with
|
|
9
|
-
- `faceColor` -
|
|
10
|
-
- `borderColor` -
|
|
11
|
-
- `borderWidth` -
|
|
12
|
-
- `textSize` - size
|
|
13
|
-
- `
|
|
5
|
+
- `xValues` - array or vector with x-coordinates of the labels.
|
|
6
|
+
- `yValues` - array or vector with y-coordinates of the labels.
|
|
7
|
+
- `zValues` - array or vector with z-coordinates of the labels.
|
|
8
|
+
- `labels` - single string used at every coordinate or array with one string per coordinate.
|
|
9
|
+
- `faceColor` - text fill color, default: `Colors.PRIMARY_TEXT`.
|
|
10
|
+
- `borderColor` - text outline color, default: `'transparent'`.
|
|
11
|
+
- `borderWidth` - text outline width in pixels, default: `0`.
|
|
12
|
+
- `textSize` - text size in em, default: `1`.
|
|
13
|
+
- `fontWeight` - CSS font weight, default: `'normal'`.
|
|
14
|
+
- `className` - CSS class name for the labels group, default: `'series-textlabel'`.
|
|
15
|
+
- `title` - title of the labels group, default: `''`.
|
|
16
|
+
|
|
17
|
+
Values in `labels` are rendered as raw SVG/HTML markup. Only pass trusted content.
|
|
14
18
|
|
|
15
19
|
Example:
|
|
16
|
-
```
|
|
20
|
+
```svelte
|
|
17
21
|
<script>
|
|
18
22
|
import { Axes, TextLabels } from 'svelte-plots-basic/3d';
|
|
19
23
|
|
|
@@ -56,10 +60,19 @@
|
|
|
56
60
|
|
|
57
61
|
const l = $derived.by(() => {
|
|
58
62
|
if (!ux) return null;
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
|
|
64
|
+
if (typeof labels === 'string') return labels;
|
|
65
|
+
|
|
66
|
+
if (!Array.isArray(labels)) {
|
|
67
|
+
console.error('TextLabels (3D): parameter "labels" must be a string or an array.');
|
|
61
68
|
return null;
|
|
62
69
|
}
|
|
70
|
+
|
|
71
|
+
if (labels.length !== ux.length) {
|
|
72
|
+
console.error('TextLabels (3D): labels provided as an array must have the same number of elements as coordinates.');
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
return labels;
|
|
64
77
|
});
|
|
65
78
|
|
|
@@ -75,7 +88,7 @@
|
|
|
75
88
|
{#if s}
|
|
76
89
|
<g class="series {className}" title={title} style={textStyleStr} >
|
|
77
90
|
|
|
78
|
-
{#if typeof
|
|
91
|
+
{#if typeof l === 'string'}
|
|
79
92
|
{#each s.x as v, i}
|
|
80
93
|
<text data-id={i} x={s.x[i]} y={s.y[i]} dx={0} dy={0}>{@html l}</text>
|
|
81
94
|
{/each}
|
|
@@ -91,4 +104,4 @@
|
|
|
91
104
|
text, text > :global(tspan) {
|
|
92
105
|
dominant-baseline: middle;
|
|
93
106
|
}
|
|
94
|
-
</style>
|
|
107
|
+
</style>
|
package/src/3d/XAxis.svelte
CHANGED
|
@@ -11,12 +11,11 @@
|
|
|
11
11
|
- `textColor` - color of axis text labels, default: `Colors.DARKGRAY`.
|
|
12
12
|
|
|
13
13
|
Example:
|
|
14
|
-
```
|
|
14
|
+
```svelte
|
|
15
15
|
<script>
|
|
16
16
|
import {Axes, XAxis} from 'svelte-plots-basic/3d';
|
|
17
17
|
</script>
|
|
18
18
|
<Axes>
|
|
19
|
-
// all other plotting components are here
|
|
20
19
|
<XAxis label="X" showGrid={true} />
|
|
21
20
|
</Axes>
|
|
22
21
|
```
|
|
@@ -36,7 +35,7 @@
|
|
|
36
35
|
} = $props();
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
// get axes context and
|
|
38
|
+
// get axes context and synchronize x-axis settings
|
|
40
39
|
const axes = getContext('axes');
|
|
41
40
|
$effect(() => {
|
|
42
41
|
axes.setXAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
|
|
@@ -46,4 +45,3 @@
|
|
|
46
45
|
axes.setXAxis({show: false});
|
|
47
46
|
});
|
|
48
47
|
</script>
|
|
49
|
-
|
package/src/3d/YAxis.svelte
CHANGED
|
@@ -3,20 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
Main properties:
|
|
5
5
|
- `label` - text label for the axis (optional).
|
|
6
|
-
- `showGrid` - logical, show or
|
|
7
|
-
- `ticks` - array or vector with tick positions (by default will be generated automatically based on `
|
|
6
|
+
- `showGrid` - logical, show or hide grid lines, default: `false`.
|
|
7
|
+
- `ticks` - array or vector with tick positions (by default will be generated automatically based on `limY`).
|
|
8
8
|
- `tickLabels` - array with tick labels to show at each tick (by default will be generated automatically).
|
|
9
9
|
- `lineColor` - color of axis line, default: `Colors.DARKGRAY`.
|
|
10
10
|
- `gridColor` - color of axis grid line, default: `Colors.MIDDLEGRAY`.
|
|
11
11
|
- `textColor` - color of axis text labels, default: `Colors.DARKGRAY`.
|
|
12
12
|
|
|
13
13
|
Example:
|
|
14
|
-
```
|
|
14
|
+
```svelte
|
|
15
15
|
<script>
|
|
16
16
|
import {Axes, YAxis} from 'svelte-plots-basic/3d';
|
|
17
17
|
</script>
|
|
18
18
|
<Axes>
|
|
19
|
-
// all other plotting components are here
|
|
20
19
|
<YAxis label="Y" showGrid={true} />
|
|
21
20
|
</Axes>
|
|
22
21
|
```
|
|
@@ -36,7 +35,7 @@
|
|
|
36
35
|
} = $props();
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
// get axes context and
|
|
38
|
+
// get axes context and synchronize y-axis settings
|
|
40
39
|
const axes = getContext('axes');
|
|
41
40
|
$effect(() => {
|
|
42
41
|
axes.setYAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
|
|
@@ -46,4 +45,3 @@
|
|
|
46
45
|
axes.setYAxis({show: false});
|
|
47
46
|
});
|
|
48
47
|
</script>
|
|
49
|
-
|
package/src/3d/ZAxis.svelte
CHANGED
|
@@ -3,20 +3,19 @@
|
|
|
3
3
|
|
|
4
4
|
Main properties:
|
|
5
5
|
- `label` - text label for the axis (optional).
|
|
6
|
-
- `showGrid` - logical, show or
|
|
7
|
-
- `ticks` - array or vector with tick positions (by default will be generated automatically based on `
|
|
6
|
+
- `showGrid` - logical, show or hide grid lines, default: `false`.
|
|
7
|
+
- `ticks` - array or vector with tick positions (by default will be generated automatically based on `limZ`).
|
|
8
8
|
- `tickLabels` - array with tick labels to show at each tick (by default will be generated automatically).
|
|
9
9
|
- `lineColor` - color of axis line, default: `Colors.DARKGRAY`.
|
|
10
10
|
- `gridColor` - color of axis grid line, default: `Colors.MIDDLEGRAY`.
|
|
11
11
|
- `textColor` - color of axis text labels, default: `Colors.DARKGRAY`.
|
|
12
12
|
|
|
13
13
|
Example:
|
|
14
|
-
```
|
|
14
|
+
```svelte
|
|
15
15
|
<script>
|
|
16
16
|
import {Axes, ZAxis} from 'svelte-plots-basic/3d';
|
|
17
17
|
</script>
|
|
18
18
|
<Axes>
|
|
19
|
-
// all other plotting components are here
|
|
20
19
|
<ZAxis label="Z" showGrid={true} />
|
|
21
20
|
</Axes>
|
|
22
21
|
```
|
|
@@ -36,7 +35,7 @@
|
|
|
36
35
|
} = $props();
|
|
37
36
|
|
|
38
37
|
|
|
39
|
-
// get axes context and
|
|
38
|
+
// get axes context and synchronize z-axis settings
|
|
40
39
|
const axes = getContext('axes');
|
|
41
40
|
$effect(() => {
|
|
42
41
|
axes.setZAxis({show: true, ticks, tickLabels, showGrid, label, lineColor, gridColor, textColor});
|
|
@@ -46,4 +45,3 @@
|
|
|
46
45
|
axes.setZAxis({show: false});
|
|
47
46
|
});
|
|
48
47
|
</script>
|
|
49
|
-
|
package/src/ExportDialog.svelte
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
- `onclose` - callback when dialog is closed.
|
|
10
10
|
-->
|
|
11
11
|
<script>
|
|
12
|
+
import { onDestroy } from 'svelte';
|
|
12
13
|
import { downloadPNG } from './methods.js';
|
|
13
14
|
|
|
14
15
|
let {
|
|
@@ -19,24 +20,40 @@
|
|
|
19
20
|
onclose
|
|
20
21
|
} = $props();
|
|
21
22
|
|
|
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
23
|
const resOptions = [100, 150, 300, 600];
|
|
29
|
-
const parsedInitialRes = Number(initialRes);
|
|
30
|
-
const defaultRes = resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300;
|
|
31
24
|
|
|
32
|
-
|
|
25
|
+
// The dialog intentionally takes a snapshot of its initial props when it opens.
|
|
26
|
+
function getInitialValues() {
|
|
27
|
+
const svgWidth = plotElement ? (plotElement.clientWidth || plotElement.getBoundingClientRect().width) : 1;
|
|
28
|
+
const svgHeight = plotElement ? (plotElement.clientHeight || plotElement.getBoundingClientRect().height) : 1;
|
|
29
|
+
const parsedInitialRes = Number(initialRes);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
svgWidth,
|
|
33
|
+
svgHeight,
|
|
34
|
+
aspectRatio: svgWidth / svgHeight,
|
|
35
|
+
width: initialWidth,
|
|
36
|
+
res: resOptions.includes(parsedInitialRes) ? parsedInitialRes : 300,
|
|
37
|
+
name: typeof fileName === 'string' ? fileName : 'plot'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const initialValues = getInitialValues();
|
|
42
|
+
const svgWidth = initialValues.svgWidth;
|
|
43
|
+
const svgHeight = initialValues.svgHeight;
|
|
44
|
+
const aspectRatio = initialValues.aspectRatio;
|
|
45
|
+
|
|
46
|
+
// editable state — height always derived from width to preserve aspect ratio
|
|
47
|
+
let width = $state(initialValues.width);
|
|
33
48
|
const height = $derived(Math.max(4, Math.min(20, Math.round(width / aspectRatio * 10) / 10)));
|
|
34
|
-
let res = $state(
|
|
35
|
-
let name = $state(
|
|
49
|
+
let res = $state(initialValues.res);
|
|
50
|
+
let name = $state(initialValues.name);
|
|
36
51
|
const nameValid = $derived(name.trim().length > 0);
|
|
52
|
+
let exportError = $state('');
|
|
37
53
|
|
|
38
54
|
// preview container ref
|
|
39
55
|
let previewContainer = $state(null);
|
|
56
|
+
let dialogElement = $state(null);
|
|
40
57
|
|
|
41
58
|
// max preview width in pixels
|
|
42
59
|
const maxPreviewWidth = 400;
|
|
@@ -78,73 +95,206 @@
|
|
|
78
95
|
}
|
|
79
96
|
|
|
80
97
|
let saving = $state(false);
|
|
98
|
+
let activeExportCleanup = null;
|
|
99
|
+
let destroyed = false;
|
|
100
|
+
|
|
101
|
+
onDestroy(() => {
|
|
102
|
+
destroyed = true;
|
|
103
|
+
activeExportCleanup?.();
|
|
104
|
+
if (dialogElement?.open) dialogElement.close();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
$effect(() => {
|
|
108
|
+
if (dialogElement && !dialogElement.open) {
|
|
109
|
+
dialogElement.showModal();
|
|
110
|
+
dialogElement.querySelector('#export-name')?.focus();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
function closeDialog() {
|
|
115
|
+
if (dialogElement?.open) dialogElement.close();
|
|
116
|
+
onclose();
|
|
117
|
+
}
|
|
81
118
|
|
|
82
119
|
function handleSave() {
|
|
83
120
|
if (saving) return;
|
|
84
|
-
|
|
121
|
+
|
|
122
|
+
exportError = '';
|
|
123
|
+
|
|
124
|
+
const exportWidth = Number(width);
|
|
125
|
+
const exportHeight = Number(height);
|
|
126
|
+
const exportRes = Number(res);
|
|
127
|
+
|
|
128
|
+
const hasValidSize = Number.isFinite(exportWidth) && exportWidth >= 1 && exportWidth <= 30 &&
|
|
129
|
+
Number.isFinite(exportHeight) && exportHeight >= 1 && exportHeight <= 30;
|
|
130
|
+
const hasValidResolution = Number.isFinite(exportRes) && exportRes >= 50 && exportRes <= 1200;
|
|
131
|
+
|
|
132
|
+
if (!hasValidSize || !hasValidResolution) {
|
|
133
|
+
exportError = 'Export size or resolution is invalid.';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!plotElement || !(plotElement instanceof SVGElement) || !plotElement.parentElement) {
|
|
138
|
+
exportError = 'The plot is not available for export.';
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
85
141
|
|
|
86
142
|
const container = plotElement.parentElement;
|
|
87
143
|
const origStyle = container.style.cssText;
|
|
144
|
+
let observer = null;
|
|
145
|
+
let fallbackTimer = null;
|
|
146
|
+
let firstFrame = null;
|
|
147
|
+
let secondFrame = null;
|
|
148
|
+
let exportStarted = false;
|
|
149
|
+
let restored = false;
|
|
150
|
+
|
|
151
|
+
function restorePlot() {
|
|
152
|
+
if (restored) return;
|
|
153
|
+
restored = true;
|
|
154
|
+
|
|
155
|
+
observer?.disconnect();
|
|
156
|
+
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
|
157
|
+
if (firstFrame !== null) cancelAnimationFrame(firstFrame);
|
|
158
|
+
if (secondFrame !== null) cancelAnimationFrame(secondFrame);
|
|
159
|
+
|
|
160
|
+
container.style.cssText = origStyle;
|
|
161
|
+
saving = false;
|
|
162
|
+
|
|
163
|
+
if (activeExportCleanup === restorePlot) {
|
|
164
|
+
activeExportCleanup = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function failExport(error) {
|
|
169
|
+
console.error('Failed to prepare the plot for PNG export.', error);
|
|
170
|
+
exportError = 'PNG export could not be started.';
|
|
171
|
+
restorePlot();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function finishExport() {
|
|
175
|
+
restorePlot();
|
|
176
|
+
if (!destroyed) closeDialog();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
activeExportCleanup = restorePlot;
|
|
180
|
+
saving = true;
|
|
88
181
|
|
|
89
182
|
// scale reference width with export width so smaller plots get
|
|
90
183
|
// a smaller scale category → relatively larger fonts and details
|
|
91
184
|
// 4cm → 360px (small), 8cm → 520px (medium), 16cm → 840px (large), 20cm → 1000px (xlarge)
|
|
92
|
-
const refWidth = Math.round(200 +
|
|
185
|
+
const refWidth = Math.round(200 + exportWidth * 40);
|
|
93
186
|
const refHeight = Math.round(refWidth / aspectRatio);
|
|
94
187
|
|
|
95
188
|
function doExport() {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
189
|
+
if (exportStarted || restored) return;
|
|
190
|
+
exportStarted = true;
|
|
191
|
+
|
|
192
|
+
observer?.disconnect();
|
|
193
|
+
if (fallbackTimer !== null) clearTimeout(fallbackTimer);
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
firstFrame = requestAnimationFrame(() => {
|
|
197
|
+
firstFrame = null;
|
|
198
|
+
secondFrame = requestAnimationFrame(() => {
|
|
199
|
+
secondFrame = null;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
// The callback fires after downloadPNG has serialized the SVG,
|
|
203
|
+
// so the live container can be restored before image encoding finishes.
|
|
204
|
+
const result = downloadPNG(
|
|
205
|
+
plotElement,
|
|
206
|
+
name.trim(),
|
|
207
|
+
exportWidth,
|
|
208
|
+
exportHeight,
|
|
209
|
+
exportRes,
|
|
210
|
+
finishExport
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (result === null) {
|
|
214
|
+
failExport(new Error('downloadPNG rejected the export settings.'));
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
failExport(error);
|
|
218
|
+
}
|
|
104
219
|
});
|
|
105
220
|
});
|
|
106
|
-
})
|
|
221
|
+
} catch (error) {
|
|
222
|
+
failExport(error);
|
|
223
|
+
}
|
|
107
224
|
}
|
|
108
225
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
observer
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
226
|
+
try {
|
|
227
|
+
// temporarily resize plot container to a fixed reference size (off-screen,
|
|
228
|
+
// hidden behind the dialog backdrop) so Svelte recomputes the layout
|
|
229
|
+
container.style.cssText = `position:fixed;left:-9999px;top:-9999px;width:${refWidth}px;height:${refHeight}px;overflow:hidden;`;
|
|
230
|
+
|
|
231
|
+
// wait for ResizeObserver → Svelte bindings → derived recomputation → DOM update
|
|
232
|
+
// fallback timeout in case container is already at the target size
|
|
233
|
+
fallbackTimer = setTimeout(doExport, 500);
|
|
234
|
+
observer = new ResizeObserver(doExport);
|
|
235
|
+
observer.observe(plotElement);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
failExport(error);
|
|
238
|
+
}
|
|
122
239
|
}
|
|
123
240
|
|
|
124
|
-
function
|
|
125
|
-
|
|
241
|
+
function handleCancel(e) {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
if (!saving) closeDialog();
|
|
126
244
|
}
|
|
127
245
|
|
|
128
246
|
function handleDialogClick(e) {
|
|
129
247
|
e.stopPropagation();
|
|
248
|
+
if (saving) return;
|
|
249
|
+
|
|
250
|
+
const bounds = e.currentTarget.getBoundingClientRect();
|
|
251
|
+
const clickedOutside = e.clientX < bounds.left || e.clientX > bounds.right ||
|
|
252
|
+
e.clientY < bounds.top || e.clientY > bounds.bottom;
|
|
253
|
+
|
|
254
|
+
if (clickedOutside) closeDialog();
|
|
130
255
|
}
|
|
131
256
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
257
|
+
// Keep keyboard events from activating an enclosing custom modal's handlers.
|
|
258
|
+
// Native dialog behavior still handles Escape; the explicit Tab order avoids
|
|
259
|
+
// browser/OS differences in which form controls participate in navigation.
|
|
260
|
+
function handleKeydown(e) {
|
|
261
|
+
e.stopPropagation();
|
|
262
|
+
|
|
263
|
+
if (e.key !== 'Tab' || !dialogElement) return;
|
|
264
|
+
|
|
265
|
+
const resolutionInput = dialogElement.querySelector(
|
|
266
|
+
'input[name="export-res"]:checked'
|
|
267
|
+
) ?? dialogElement.querySelector('input[name="export-res"]');
|
|
268
|
+
|
|
269
|
+
const controls = [
|
|
270
|
+
dialogElement.querySelector('#export-name'),
|
|
271
|
+
dialogElement.querySelector('#export-width'),
|
|
272
|
+
resolutionInput,
|
|
273
|
+
...dialogElement.querySelectorAll('.export-dialog-actions button:not(:disabled)')
|
|
274
|
+
].filter(Boolean);
|
|
275
|
+
|
|
276
|
+
if (controls.length === 0) return;
|
|
277
|
+
|
|
278
|
+
const currentIndex = controls.indexOf(document.activeElement);
|
|
279
|
+
const nextIndex = currentIndex === -1
|
|
280
|
+
? (e.shiftKey ? controls.length - 1 : 0)
|
|
281
|
+
: (currentIndex + (e.shiftKey ? -1 : 1) + controls.length) % controls.length;
|
|
282
|
+
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
controls[nextIndex].focus();
|
|
285
|
+
}
|
|
142
286
|
</script>
|
|
143
287
|
|
|
144
|
-
<!--
|
|
145
|
-
<!-- svelte-ignore
|
|
146
|
-
<
|
|
147
|
-
|
|
288
|
+
<!-- Native modal events provide backdrop closing and isolate an enclosing custom modal's keyboard handlers. -->
|
|
289
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
290
|
+
<dialog
|
|
291
|
+
class="export-dialog"
|
|
292
|
+
aria-label="Export as PNG"
|
|
293
|
+
bind:this={dialogElement}
|
|
294
|
+
oncancel={handleCancel}
|
|
295
|
+
onclick={handleDialogClick}
|
|
296
|
+
onkeydown={handleKeydown}
|
|
297
|
+
>
|
|
148
298
|
<div class="export-dialog-title">Export as PNG</div>
|
|
149
299
|
|
|
150
300
|
<div class="export-dialog-body">
|
|
@@ -177,37 +327,34 @@
|
|
|
177
327
|
{/each}
|
|
178
328
|
</div>
|
|
179
329
|
</div>
|
|
330
|
+
|
|
331
|
+
{#if exportError}
|
|
332
|
+
<p class="export-dialog-error" role="alert">{exportError}</p>
|
|
333
|
+
{/if}
|
|
180
334
|
</div>
|
|
181
335
|
</div>
|
|
182
336
|
|
|
183
337
|
<div class="export-dialog-actions">
|
|
184
|
-
<button type="button" class="export-dialog-cancel" onclick={
|
|
185
|
-
<button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid}>Save</button>
|
|
338
|
+
<button type="button" class="export-dialog-cancel" onclick={closeDialog} disabled={saving}>Cancel</button>
|
|
339
|
+
<button type="button" class="export-dialog-save" onclick={handleSave} disabled={!nameValid || saving}>Save</button>
|
|
186
340
|
</div>
|
|
187
|
-
|
|
188
|
-
</div>
|
|
341
|
+
</dialog>
|
|
189
342
|
|
|
190
343
|
<style>
|
|
191
|
-
.export-dialog
|
|
192
|
-
position: fixed;
|
|
193
|
-
top: 0;
|
|
194
|
-
left: 0;
|
|
195
|
-
right: 0;
|
|
196
|
-
bottom: 0;
|
|
344
|
+
.export-dialog::backdrop {
|
|
197
345
|
background: rgba(0, 0, 0, 0.4);
|
|
198
|
-
z-index: 1000;
|
|
199
|
-
display: flex;
|
|
200
|
-
align-items: center;
|
|
201
|
-
justify-content: center;
|
|
202
346
|
}
|
|
203
347
|
|
|
204
348
|
.export-dialog {
|
|
349
|
+
box-sizing: border-box;
|
|
205
350
|
background: #fefefe;
|
|
351
|
+
border: none;
|
|
206
352
|
border-radius: 0.5em;
|
|
207
353
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
|
|
208
354
|
padding: 1.25em;
|
|
209
355
|
min-width: 440px;
|
|
210
356
|
max-width: 90vw;
|
|
357
|
+
margin: auto;
|
|
211
358
|
font-family: Arial, Helvetica, sans-serif;
|
|
212
359
|
color: #606060;
|
|
213
360
|
}
|
|
@@ -316,7 +463,30 @@
|
|
|
316
463
|
}
|
|
317
464
|
|
|
318
465
|
.export-dialog-res-btn input {
|
|
319
|
-
|
|
466
|
+
position: absolute;
|
|
467
|
+
width: 1px;
|
|
468
|
+
height: 1px;
|
|
469
|
+
padding: 0;
|
|
470
|
+
margin: -1px;
|
|
471
|
+
overflow: hidden;
|
|
472
|
+
clip: rect(0, 0, 0, 0);
|
|
473
|
+
clip-path: inset(50%);
|
|
474
|
+
white-space: nowrap;
|
|
475
|
+
border: 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.export-dialog-name:focus-visible,
|
|
479
|
+
.export-dialog-control input[type="range"]:focus-visible,
|
|
480
|
+
.export-dialog-actions button:focus-visible,
|
|
481
|
+
.export-dialog-res-btn:focus-within {
|
|
482
|
+
outline: 2px solid #999191;
|
|
483
|
+
outline-offset: 2px;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.export-dialog-error {
|
|
487
|
+
margin: 0;
|
|
488
|
+
color: crimson;
|
|
489
|
+
font-size: 0.85em;
|
|
320
490
|
}
|
|
321
491
|
|
|
322
492
|
.export-dialog-actions {
|