@selvajs/ui 6.6.0-beta.19 → 6.6.0-beta.20
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/dist/components/compute/AppLayout.svelte +24 -21
- package/dist/components/compute/AppLayout.svelte.d.ts +11 -5
- package/dist/components/compute/ComputeApp.svelte +3 -0
- package/dist/components/compute/SolveMessages.svelte +19 -74
- package/dist/components/compute/SolveMessages.svelte.d.ts +5 -0
- package/dist/components/preview/ChartOutput.svelte +41 -22
- package/dist/components/primitives/SolvingIndicator.svelte +242 -41
- package/dist/components/primitives/SolvingIndicator.svelte.d.ts +13 -4
- package/dist/components/viewer/Viewer.svelte +3 -9
- package/dist/components/viewer/Viewer.svelte.d.ts +2 -2
- package/dist/compute/liveProgress.svelte.d.ts +16 -0
- package/dist/compute/liveProgress.svelte.js +90 -0
- package/dist/features/chart/plotly-compat.d.ts +15 -0
- package/dist/features/chart/plotly-compat.js +124 -0
- package/dist/i18n/messages.d.ts +4 -0
- package/dist/i18n/messages.js +4 -0
- package/package.json +6 -6
- package/src/lib/components/compute/AppLayout.svelte +24 -21
- package/src/lib/components/compute/ComputeApp.svelte +3 -0
- package/src/lib/components/compute/SolveMessages.svelte +19 -74
- package/src/lib/components/preview/ChartOutput.svelte +41 -22
- package/src/lib/components/primitives/SolvingIndicator.svelte +242 -41
- package/src/lib/components/viewer/Viewer.svelte +3 -9
- package/src/lib/compute/liveProgress.svelte.ts +103 -0
- package/src/lib/features/chart/plotly-compat.ts +141 -0
- package/src/lib/i18n/messages.ts +8 -0
|
@@ -1,60 +1,261 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import { fade } from 'svelte/transition';
|
|
4
|
+
import { Check, LoaderCircle } from '@lucide/svelte';
|
|
5
|
+
import { EMPTY_LIVE_SOLVE, type LiveSolveState } from '@selvajs/solve/client';
|
|
6
|
+
import { Button } from '#lib/components/primitives/button/index.js';
|
|
7
|
+
import { createLiveProgress } from '#lib/compute/liveProgress.svelte.js';
|
|
2
8
|
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
3
9
|
|
|
10
|
+
// The solve status in a panel's action row. It grows only as far as the solve needs: a quiet
|
|
11
|
+
// "up to date" row, a pill once a solve is visible, a card with progress and Abort once it is
|
|
12
|
+
// slow. It grows upward over the fields, so the footer never changes height.
|
|
4
13
|
interface Props {
|
|
14
|
+
/** The raw flag: times the solve, and acknowledges one too short to show. */
|
|
15
|
+
isSolving: boolean;
|
|
16
|
+
/** `createSolvingIndicator(...).show`. */
|
|
5
17
|
show: boolean;
|
|
18
|
+
/** `createSolvingIndicator(...).slow`. */
|
|
19
|
+
slow?: boolean;
|
|
20
|
+
/** Live progress for the card. Without it the card's bar is indeterminate. */
|
|
21
|
+
live?: LiveSolveState;
|
|
22
|
+
/** Without it the card has no Abort button; Esc still works where the host handles it. */
|
|
23
|
+
onabort?: () => void;
|
|
6
24
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* already reserves the chip's place. Both say Esc aborts, as every host does.
|
|
25
|
+
* The page can't repaint mid-solve (see `createSolvingIndicator`): the pill says Esc aborts
|
|
26
|
+
* instead of counting seconds, and never grows into the card.
|
|
10
27
|
*/
|
|
11
|
-
|
|
28
|
+
freezesDuringSolve?: boolean;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
|
-
let {
|
|
31
|
+
let {
|
|
32
|
+
isSolving,
|
|
33
|
+
show,
|
|
34
|
+
slow = false,
|
|
35
|
+
live,
|
|
36
|
+
onabort,
|
|
37
|
+
freezesDuringSolve = false
|
|
38
|
+
}: Props = $props();
|
|
15
39
|
|
|
16
40
|
const locale = getLocaleContext();
|
|
17
41
|
const t = $derived(locale.messages);
|
|
42
|
+
|
|
43
|
+
const DONE_VISIBLE_MS = 1400;
|
|
44
|
+
// A drag fires a solve per step; one acknowledgement per this many ms is enough.
|
|
45
|
+
const PING_EVERY_MS = 500;
|
|
46
|
+
|
|
47
|
+
const progress = createLiveProgress(() => live ?? EMPTY_LIVE_SOLVE);
|
|
48
|
+
const head = $derived(progress.headline ?? progress.progress[0]);
|
|
49
|
+
const bar = $derived(
|
|
50
|
+
head?.fraction === undefined
|
|
51
|
+
? undefined
|
|
52
|
+
: (progress.bars[head.source ?? ''] ?? { width: head.fraction, ms: 150 })
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
let startedAt = 0;
|
|
56
|
+
let elapsed = $state(0);
|
|
57
|
+
let finished = $state<{ ms: number; aborted: boolean } | null>(null);
|
|
58
|
+
let ping = $state(0);
|
|
59
|
+
let lastPingAt = 0;
|
|
60
|
+
// Not `show` itself at the solve's end: the host's indicator may already have dropped it.
|
|
61
|
+
let shownThisSolve = false;
|
|
62
|
+
|
|
63
|
+
$effect(() => {
|
|
64
|
+
if (show) shownThisSolve = true;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
const solving = isSolving;
|
|
69
|
+
untrack(() => {
|
|
70
|
+
if (solving) {
|
|
71
|
+
startedAt = performance.now();
|
|
72
|
+
shownThisSolve = show;
|
|
73
|
+
elapsed = 0;
|
|
74
|
+
finished = null;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!startedAt) return;
|
|
78
|
+
const ms = performance.now() - startedAt;
|
|
79
|
+
startedAt = 0;
|
|
80
|
+
if (shownThisSolve) {
|
|
81
|
+
finished = { ms, aborted: live?.ended?.kind === 'aborted' };
|
|
82
|
+
} else if (performance.now() - lastPingAt > PING_EVERY_MS) {
|
|
83
|
+
lastPingAt = performance.now();
|
|
84
|
+
ping++;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
$effect(() => {
|
|
90
|
+
if (!finished) return;
|
|
91
|
+
const timer = setTimeout(() => (finished = null), DONE_VISIBLE_MS);
|
|
92
|
+
return () => clearTimeout(timer);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
$effect(() => {
|
|
96
|
+
if (!isSolving || !show || freezesDuringSolve) return;
|
|
97
|
+
const tick = () => (elapsed = performance.now() - startedAt);
|
|
98
|
+
tick();
|
|
99
|
+
const id = setInterval(tick, 100);
|
|
100
|
+
return () => clearInterval(id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Done wins over a held `show`: the indicator outlives the solve by up to its minimum time,
|
|
104
|
+
// and saying "Solving" over a result that has already landed would be wrong.
|
|
105
|
+
const mode = $derived<'idle' | 'work' | 'card' | 'done'>(
|
|
106
|
+
finished ? 'done' : show ? (slow && !freezesDuringSolve ? 'card' : 'work') : 'idle'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const seconds = $derived(
|
|
110
|
+
new Intl.NumberFormat(locale.locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
|
111
|
+
);
|
|
112
|
+
const fmt = (ms: number) => `${seconds.format(ms / 1000)} s`;
|
|
113
|
+
|
|
114
|
+
const announcement = $derived(
|
|
115
|
+
mode === 'work' || mode === 'card'
|
|
116
|
+
? t.solvingEllipsis
|
|
117
|
+
: mode === 'done'
|
|
118
|
+
? finished?.aborted
|
|
119
|
+
? t.solveStopped
|
|
120
|
+
: t.solveDone
|
|
121
|
+
: ''
|
|
122
|
+
);
|
|
18
123
|
</script>
|
|
19
124
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
role="status"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
125
|
+
<div class="h-9 min-w-0 relative flex-1">
|
|
126
|
+
<!-- The live region stays mounted: a screen reader only announces text that changes inside a
|
|
127
|
+
region it already knows about, so one created together with its text says nothing. -->
|
|
128
|
+
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>
|
|
129
|
+
|
|
130
|
+
<div
|
|
131
|
+
class="inset-x-0 bottom-0 backdrop-blur-md ease-out absolute z-10 overflow-hidden border border-border-strong transition-all duration-300 motion-reduce:transition-none {mode ===
|
|
132
|
+
'card'
|
|
133
|
+
? 'h-24 shadow-md rounded-lg bg-card/75'
|
|
134
|
+
: mode === 'idle'
|
|
135
|
+
? 'h-9 shadow-sm rounded-md bg-card/90'
|
|
136
|
+
: 'h-9 shadow-md rounded-md bg-card/75'}"
|
|
137
|
+
data-testid="solve-status"
|
|
138
|
+
data-state={mode}
|
|
139
|
+
>
|
|
140
|
+
{#if mode === 'card'}
|
|
32
141
|
<div
|
|
33
|
-
class="
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
142
|
+
class="px-3 gap-2 flex h-full flex-col justify-center"
|
|
143
|
+
in:fade={{ duration: 150, delay: 80 }}
|
|
144
|
+
>
|
|
145
|
+
<div class="gap-2 min-w-0 flex items-center">
|
|
146
|
+
<LoaderCircle
|
|
147
|
+
class="size-4 animate-spin shrink-0 text-muted-foreground"
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
/>
|
|
150
|
+
<span class="text-sm font-medium min-w-0 flex-1 truncate">{head?.label ?? t.solving}</span
|
|
151
|
+
>
|
|
152
|
+
{#if head?.done !== undefined && head?.total !== undefined}
|
|
153
|
+
<span class="text-xs shrink-0 text-muted-foreground tabular-nums">
|
|
154
|
+
{head.done} / {head.total}
|
|
155
|
+
</span>
|
|
156
|
+
{/if}
|
|
157
|
+
</div>
|
|
46
158
|
<div
|
|
47
|
-
class="
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<div class="leading-tight flex flex-col">
|
|
54
|
-
<span class="text-sm font-semibold">{t.solving}</span>
|
|
55
|
-
<span class="text-xs opacity-70" data-testid="solve-esc-hint">{t.solveMessagesEscHint}</span
|
|
159
|
+
class="h-1 overflow-hidden rounded-full bg-muted"
|
|
160
|
+
role="progressbar"
|
|
161
|
+
aria-label={head?.label ?? t.solving}
|
|
162
|
+
aria-valuemin={0}
|
|
163
|
+
aria-valuemax={100}
|
|
164
|
+
aria-valuenow={bar ? Math.round(bar.width * 100) : undefined}
|
|
56
165
|
>
|
|
166
|
+
{#if bar}
|
|
167
|
+
<div
|
|
168
|
+
class="h-full w-(--bar-width) rounded-full bg-primary transition-all duration-(--bar-ms) ease-linear"
|
|
169
|
+
style:--bar-width="{bar.width * 100}%"
|
|
170
|
+
style:--bar-ms="{bar.ms}ms"
|
|
171
|
+
></div>
|
|
172
|
+
{:else}
|
|
173
|
+
<div class="animate-pulse h-full w-1/3 rounded-full bg-primary/60"></div>
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
176
|
+
<div class="gap-2 flex items-center">
|
|
177
|
+
<span class="text-xs text-muted-foreground tabular-nums">{fmt(elapsed)}</span>
|
|
178
|
+
{#if onabort}
|
|
179
|
+
<Button
|
|
180
|
+
variant="outline"
|
|
181
|
+
size="xs"
|
|
182
|
+
class="ml-auto shrink-0"
|
|
183
|
+
onclick={onabort}
|
|
184
|
+
aria-keyshortcuts="Escape"
|
|
185
|
+
title={t.solveMessagesEscHint}
|
|
186
|
+
>
|
|
187
|
+
{t.solveMessagesAbort}
|
|
188
|
+
<kbd
|
|
189
|
+
class="px-1 rounded-sm border border-border font-sans text-2xs text-muted-foreground"
|
|
190
|
+
>
|
|
191
|
+
Esc
|
|
192
|
+
</kbd>
|
|
193
|
+
</Button>
|
|
194
|
+
{/if}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
{:else if mode === 'work'}
|
|
198
|
+
<div class="px-3 gap-2 flex h-full items-center" in:fade={{ duration: 150, delay: 80 }}>
|
|
199
|
+
<LoaderCircle
|
|
200
|
+
class="size-4 animate-spin shrink-0 text-muted-foreground"
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
/>
|
|
203
|
+
{#if freezesDuringSolve}
|
|
204
|
+
<span class="text-sm font-medium shrink-0">{t.solvingEllipsis}</span>
|
|
205
|
+
<span class="text-xs min-w-0 truncate text-muted-foreground" data-testid="solve-esc-hint">
|
|
206
|
+
{t.solveMessagesEscHint}
|
|
207
|
+
</span>
|
|
208
|
+
{:else}
|
|
209
|
+
<span class="text-sm font-medium shrink-0">{t.solving}</span>
|
|
210
|
+
<span class="text-xs text-muted-foreground tabular-nums">{fmt(elapsed)}</span>
|
|
211
|
+
{/if}
|
|
57
212
|
</div>
|
|
58
|
-
|
|
59
|
-
|
|
213
|
+
{:else if mode === 'done' && finished}
|
|
214
|
+
<div class="px-3 gap-2 flex h-full items-center" in:fade={{ duration: 150, delay: 80 }}>
|
|
215
|
+
{#if finished.aborted}
|
|
216
|
+
<span class="text-sm font-medium min-w-0 truncate">{t.solveStopped}</span>
|
|
217
|
+
{:else}
|
|
218
|
+
<Check class="size-4 shrink-0 text-success" aria-hidden="true" />
|
|
219
|
+
<span class="text-sm font-medium shrink-0">{t.solveDone}</span>
|
|
220
|
+
<span class="text-xs text-muted-foreground tabular-nums">{fmt(finished.ms)}</span>
|
|
221
|
+
{/if}
|
|
222
|
+
</div>
|
|
223
|
+
{:else}
|
|
224
|
+
<div class="px-3 gap-2 flex h-full items-center" in:fade={{ duration: 150 }}>
|
|
225
|
+
<span class="size-1.5 relative shrink-0 rounded-full bg-success">
|
|
226
|
+
{#key ping}
|
|
227
|
+
{#if ping > 0}
|
|
228
|
+
<span class="solve-ping inset-0 absolute rounded-full border-2 border-success"></span>
|
|
229
|
+
{/if}
|
|
230
|
+
{/key}
|
|
231
|
+
</span>
|
|
232
|
+
<span class="text-sm min-w-0 truncate text-muted-foreground">{t.upToDate}</span>
|
|
233
|
+
</div>
|
|
234
|
+
{/if}
|
|
235
|
+
</div>
|
|
60
236
|
</div>
|
|
237
|
+
|
|
238
|
+
<style>
|
|
239
|
+
/* One ring that grows and fades: the result landed faster than an indicator could show. */
|
|
240
|
+
.solve-ping {
|
|
241
|
+
animation: solve-ping 0.6s ease-out forwards;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@keyframes solve-ping {
|
|
245
|
+
from {
|
|
246
|
+
transform: scale(1);
|
|
247
|
+
opacity: 0.9;
|
|
248
|
+
}
|
|
249
|
+
to {
|
|
250
|
+
transform: scale(3.2);
|
|
251
|
+
opacity: 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@media (prefers-reduced-motion: reduce) {
|
|
256
|
+
.solve-ping {
|
|
257
|
+
animation: none;
|
|
258
|
+
opacity: 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
</style>
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
interface Props {
|
|
47
47
|
meshes: any[];
|
|
48
48
|
isFullscreen?: boolean;
|
|
49
|
-
/**
|
|
49
|
+
/** @deprecated Ignored: the panel's solve status shows a running solve. */
|
|
50
50
|
isSolving?: boolean;
|
|
51
|
-
/**
|
|
51
|
+
/** @deprecated Ignored, like `isSolving`. */
|
|
52
52
|
isSlow?: boolean;
|
|
53
53
|
isBlurred?: boolean;
|
|
54
54
|
drawerOpen?: boolean;
|
|
@@ -85,7 +85,6 @@
|
|
|
85
85
|
let {
|
|
86
86
|
meshes,
|
|
87
87
|
isFullscreen = $bindable(false),
|
|
88
|
-
isSlow = false,
|
|
89
88
|
isBlurred = false,
|
|
90
89
|
drawerOpen = false,
|
|
91
90
|
viewerConfig = {},
|
|
@@ -166,6 +165,7 @@
|
|
|
166
165
|
}))
|
|
167
166
|
);
|
|
168
167
|
const nightOn = $derived(renderStyle === 'night');
|
|
168
|
+
|
|
169
169
|
// What the night toggle returns to.
|
|
170
170
|
let dayLook: Look = $state(openingLook === 'night' ? defaultViewerConfig.look : openingLook);
|
|
171
171
|
|
|
@@ -524,12 +524,6 @@
|
|
|
524
524
|
: 'blur-overlay-inactive'}"
|
|
525
525
|
></div>
|
|
526
526
|
|
|
527
|
-
{#if isSlow}
|
|
528
|
-
<div
|
|
529
|
-
class="inset-0 animate-in fade-in pointer-events-none absolute z-10 bg-background/25 duration-500"
|
|
530
|
-
></div>
|
|
531
|
-
{/if}
|
|
532
|
-
|
|
533
527
|
{#if logoUrl}
|
|
534
528
|
<!-- pointer-events:none so it never intercepts canvas drags. -->
|
|
535
529
|
<div
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import type { ProgressPayload } from '@selvajs/schemas';
|
|
3
|
+
import type { LiveSolveState } from '@selvajs/solve/client';
|
|
4
|
+
|
|
5
|
+
/** A progress bar's width (0–1) and how long its transition to that width runs. */
|
|
6
|
+
export interface ProgressBar {
|
|
7
|
+
width: number;
|
|
8
|
+
ms: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The live channel's progress reports, smoothed for display. `headline` is the first source's
|
|
13
|
+
* report, held long enough to read; `bars` is the width each source's bar should animate to.
|
|
14
|
+
*/
|
|
15
|
+
export function createLiveProgress(getLive: () => LiveSolveState): {
|
|
16
|
+
readonly progress: ProgressPayload[];
|
|
17
|
+
readonly headline: ProgressPayload | undefined;
|
|
18
|
+
readonly bars: Readonly<Record<string, ProgressBar>>;
|
|
19
|
+
} {
|
|
20
|
+
const progress = $derived([...getLive().progress.values()]);
|
|
21
|
+
|
|
22
|
+
// A report only arrives when a step starts, so a bar that just shows `fraction` stands still
|
|
23
|
+
// through the step and then jumps. When the plugin knows how long the step took last time, the
|
|
24
|
+
// bar runs linearly to where the step will end, over that time. Steps are weighted by time, so
|
|
25
|
+
// linear gives one steady speed across the solve; any easing curve visibly speeds up and
|
|
26
|
+
// crawls within each step. A slower run waits at the step's end; a faster one snaps ahead.
|
|
27
|
+
let bars = $state.raw<Record<string, ProgressBar>>({});
|
|
28
|
+
// Any live event re-runs the effect below. Only a source with a new report may re-snap;
|
|
29
|
+
// the rest keep easing where they are.
|
|
30
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping for the effect below, never rendered
|
|
31
|
+
let seen = new Map<string, unknown>();
|
|
32
|
+
let targets: Record<string, ProgressBar> = {};
|
|
33
|
+
$effect(() => {
|
|
34
|
+
const snapped: Record<string, ProgressBar> = {};
|
|
35
|
+
const eased: Record<string, ProgressBar> = {};
|
|
36
|
+
const current = untrack(() => bars);
|
|
37
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping for this effect; reactive reads here would re-trigger it
|
|
38
|
+
const nextSeen = new Map<string, unknown>();
|
|
39
|
+
for (const p of progress) {
|
|
40
|
+
if (p.fraction === undefined) continue;
|
|
41
|
+
const key = p.source ?? '';
|
|
42
|
+
nextSeen.set(key, p);
|
|
43
|
+
if (seen.get(key) === p && current[key]) {
|
|
44
|
+
snapped[key] = current[key];
|
|
45
|
+
// This run's cleanup cancelled the frame that would have started the ease.
|
|
46
|
+
if (targets[key] && current[key] !== targets[key]) eased[key] = targets[key];
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
snapped[key] = { width: p.fraction, ms: 150 };
|
|
50
|
+
if (p.nextFraction !== undefined && p.stepMs !== undefined) {
|
|
51
|
+
eased[key] = { width: p.nextFraction, ms: p.stepMs };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
seen = nextSeen;
|
|
55
|
+
targets = eased;
|
|
56
|
+
bars = snapped;
|
|
57
|
+
if (Object.keys(eased).length === 0) return;
|
|
58
|
+
// Two frames: the snapped width must be painted before the eased one is set, or the
|
|
59
|
+
// browser folds both into one style change and there is nothing to transition from.
|
|
60
|
+
let frame = requestAnimationFrame(() => {
|
|
61
|
+
frame = requestAnimationFrame(() => (bars = { ...snapped, ...eased }));
|
|
62
|
+
});
|
|
63
|
+
return () => cancelAnimationFrame(frame);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Steps a few milliseconds long would swap the headline faster than it can be read. Each one
|
|
67
|
+
// stays up for at least this long; steps that end sooner are skipped in the label but still
|
|
68
|
+
// count in the bar. The label and its step count move together so they never disagree.
|
|
69
|
+
const HEADLINE_MIN_MS = 200;
|
|
70
|
+
let headline = $state.raw<ProgressPayload | undefined>(undefined);
|
|
71
|
+
let headlineAt = 0;
|
|
72
|
+
$effect(() => {
|
|
73
|
+
const next = progress[0];
|
|
74
|
+
const shown = untrack(() => headline);
|
|
75
|
+
if (next?.label === shown?.label && next?.done === shown?.done) {
|
|
76
|
+
headline = next;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const show = () => {
|
|
80
|
+
headline = next;
|
|
81
|
+
headlineAt = performance.now();
|
|
82
|
+
};
|
|
83
|
+
const wait = HEADLINE_MIN_MS - (performance.now() - headlineAt);
|
|
84
|
+
if (!shown || wait <= 0) {
|
|
85
|
+
show();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const timer = setTimeout(show, wait);
|
|
89
|
+
return () => clearTimeout(timer);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
get progress() {
|
|
94
|
+
return progress;
|
|
95
|
+
},
|
|
96
|
+
get headline() {
|
|
97
|
+
return headline;
|
|
98
|
+
},
|
|
99
|
+
get bars() {
|
|
100
|
+
return bars;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { hsvToRgb } from '#lib/utils/color-space.js';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Plotly 3 → 4 figure compatibility
|
|
5
|
+
// ============================================================================
|
|
6
|
+
//
|
|
7
|
+
// Chart outputs come from whatever plugin version solved them, often one written against
|
|
8
|
+
// Plotly 3. This rewrites the parts Plotly 4 draws differently or rejects, so those figures
|
|
9
|
+
// keep their look. Figures that are already compatible pass through unchanged.
|
|
10
|
+
|
|
11
|
+
type Json = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export interface PlotlyFigure {
|
|
14
|
+
data: Json[];
|
|
15
|
+
layout?: Json;
|
|
16
|
+
config?: Json;
|
|
17
|
+
frames?: Json[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const REMOVED_MAP_TRACES: Record<string, string> = {
|
|
21
|
+
scattermapbox: 'scattermap',
|
|
22
|
+
choroplethmapbox: 'choroplethmap',
|
|
23
|
+
densitymapbox: 'densitymap'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const MAPBOX_SUBPLOT = /^mapbox(\d*)$/;
|
|
27
|
+
const BARE_HEX = /^(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
28
|
+
const HSV_FUNCTION =
|
|
29
|
+
/^hsva?[\s(]+([-+\d.]+)%?[\s,]+([\d.]+)(%?)[\s,]+([\d.]+)(%?)(?:[\s,/]+([\d.]+)(%?))?[\s)]*$/i;
|
|
30
|
+
|
|
31
|
+
const isObject = (v: unknown): v is Json =>
|
|
32
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
33
|
+
|
|
34
|
+
/** Returns a rewritten copy; the input is not modified. */
|
|
35
|
+
export function upgradePlotlyFigure(figure: PlotlyFigure): PlotlyFigure {
|
|
36
|
+
const out = structuredClone(figure);
|
|
37
|
+
const layout = out.layout ?? {};
|
|
38
|
+
normalizeColors(layout);
|
|
39
|
+
|
|
40
|
+
let migratedMaps = false;
|
|
41
|
+
for (const trace of out.data ?? []) {
|
|
42
|
+
if (!isObject(trace)) continue;
|
|
43
|
+
normalizeColors(trace);
|
|
44
|
+
|
|
45
|
+
const replacement = REMOVED_MAP_TRACES[trace.type as string];
|
|
46
|
+
if (replacement) {
|
|
47
|
+
trace.type = replacement;
|
|
48
|
+
if (typeof trace.subplot === 'string')
|
|
49
|
+
trace.subplot = trace.subplot.replace(MAPBOX_SUBPLOT, 'map$1');
|
|
50
|
+
migratedMaps = true;
|
|
51
|
+
} else if (trace.type === 'splom') {
|
|
52
|
+
keepSplomAxesUnlinked(trace);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (migratedMaps) migrateMapLayout(layout);
|
|
57
|
+
// Frame data replaces trace data during playback, so it needs the same colour fixes.
|
|
58
|
+
for (const frame of out.frames ?? []) if (isObject(frame)) normalizeColors(frame);
|
|
59
|
+
return { ...out, layout };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Plotly 4 links each dimension's x and y axes by default.
|
|
63
|
+
function keepSplomAxesUnlinked(trace: Json) {
|
|
64
|
+
if (!Array.isArray(trace.dimensions)) return;
|
|
65
|
+
for (const dim of trace.dimensions) {
|
|
66
|
+
if (!isObject(dim)) continue;
|
|
67
|
+
const axis = isObject(dim.axis) ? dim.axis : (dim.axis = {});
|
|
68
|
+
if (!('matches' in axis)) axis.matches = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function migrateMapLayout(layout: Json) {
|
|
73
|
+
for (const key of Object.keys(layout)) {
|
|
74
|
+
const match = MAPBOX_SUBPLOT.exec(key);
|
|
75
|
+
if (!match) continue;
|
|
76
|
+
|
|
77
|
+
const map = layout[key];
|
|
78
|
+
delete layout[key];
|
|
79
|
+
const newKey = `map${match[1]}`;
|
|
80
|
+
if (newKey in layout || !isObject(map)) continue;
|
|
81
|
+
|
|
82
|
+
delete map.accesstoken;
|
|
83
|
+
// Stamen and mapbox:// styles have no map equivalent.
|
|
84
|
+
if (typeof map.style === 'string' && /^(stamen|mapbox:\/\/)/.test(map.style))
|
|
85
|
+
map.style = 'open-street-map';
|
|
86
|
+
layout[newKey] = map;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Plotly attributes that hold colours: *color, colorway, colorscale and pie's marker.colors.
|
|
91
|
+
const isColorKey = (key: string) =>
|
|
92
|
+
key.toLowerCase().endsWith('color') ||
|
|
93
|
+
key === 'colors' ||
|
|
94
|
+
key === 'colorway' ||
|
|
95
|
+
key === 'colorscale';
|
|
96
|
+
|
|
97
|
+
function normalizeColors(props: Json) {
|
|
98
|
+
for (const [key, value] of Object.entries(props)) {
|
|
99
|
+
if (isColorKey(key)) props[key] = normalizeColorValue(value, false);
|
|
100
|
+
else if (isObject(value)) normalizeColors(value);
|
|
101
|
+
else if (Array.isArray(value))
|
|
102
|
+
for (const item of value) if (isObject(item)) normalizeColors(item);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Covers a single colour, a per-point list and colorscale stops ([[0, "ff0000"], ...]).
|
|
107
|
+
function normalizeColorValue(value: unknown, inList: boolean): unknown {
|
|
108
|
+
if (Array.isArray(value)) return value.map((v) => normalizeColorValue(v, true));
|
|
109
|
+
if (typeof value !== 'string') return value;
|
|
110
|
+
// Plotly reads numeric strings in a per-point list as numbers for the colorscale.
|
|
111
|
+
if (inList && /^\s*\d+\s*$/.test(value)) return value;
|
|
112
|
+
return normalizePlotlyColor(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Rewrites colour spellings Plotly 3 accepted but Plotly 4 rejects (hex without `#`, `hsv()`)
|
|
117
|
+
* into valid CSS. Anything else is returned unchanged.
|
|
118
|
+
*/
|
|
119
|
+
export function normalizePlotlyColor(color: string): string {
|
|
120
|
+
const value = color.trim();
|
|
121
|
+
if (BARE_HEX.test(value)) return `#${value}`;
|
|
122
|
+
|
|
123
|
+
const hsv = HSV_FUNCTION.exec(value);
|
|
124
|
+
if (!hsv) return color;
|
|
125
|
+
|
|
126
|
+
const { r, g, b } = hsvToRgb({
|
|
127
|
+
h: Number(hsv[1]),
|
|
128
|
+
s: unit(hsv[2], hsv[3] === '%'),
|
|
129
|
+
v: unit(hsv[4], hsv[5] === '%')
|
|
130
|
+
});
|
|
131
|
+
const rgb = `${Math.round(r)},${Math.round(g)},${Math.round(b)}`;
|
|
132
|
+
if (hsv[6] === undefined) return `rgb(${rgb})`;
|
|
133
|
+
return `rgba(${rgb},${+unit(hsv[6], hsv[7] === '%').toFixed(3)})`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Plotly 3 (TinyColor) read 0..1 as a fraction and anything above 1 as a percentage.
|
|
137
|
+
function unit(s: string, percent: boolean): number {
|
|
138
|
+
let x = Number(s);
|
|
139
|
+
if (percent || x > 1) x /= 100;
|
|
140
|
+
return Math.min(1, Math.max(0, x));
|
|
141
|
+
}
|
package/src/lib/i18n/messages.ts
CHANGED
|
@@ -79,6 +79,10 @@ export interface ViewerMessages {
|
|
|
79
79
|
solveMessagesEscHint: string;
|
|
80
80
|
solveMessagesDismiss: string;
|
|
81
81
|
solveSolving: string;
|
|
82
|
+
/** A visible solve has landed; followed by how long it took. */
|
|
83
|
+
solveDone: string;
|
|
84
|
+
/** A visible solve was aborted; the previous result stays. */
|
|
85
|
+
solveStopped: string;
|
|
82
86
|
parametersTab: string;
|
|
83
87
|
moreTab: string;
|
|
84
88
|
scrollTabsBack: string;
|
|
@@ -314,6 +318,8 @@ const en: ViewerMessages = {
|
|
|
314
318
|
solveMessagesEscHint: 'Esc aborts the solve',
|
|
315
319
|
solveMessagesDismiss: 'Dismiss',
|
|
316
320
|
solveSolving: 'Solving…',
|
|
321
|
+
solveDone: 'Done',
|
|
322
|
+
solveStopped: 'Stopped',
|
|
317
323
|
parametersTab: 'Parameters',
|
|
318
324
|
moreTab: 'More',
|
|
319
325
|
scrollTabsBack: 'Previous tabs',
|
|
@@ -518,6 +524,8 @@ const de: ViewerMessages = {
|
|
|
518
524
|
solveMessagesEscHint: 'Esc bricht die Berechnung ab',
|
|
519
525
|
solveMessagesDismiss: 'Schliessen',
|
|
520
526
|
solveSolving: 'Berechnung läuft…',
|
|
527
|
+
solveDone: 'Fertig',
|
|
528
|
+
solveStopped: 'Abgebrochen',
|
|
521
529
|
parametersTab: 'Parameter',
|
|
522
530
|
moreTab: 'Mehr',
|
|
523
531
|
scrollTabsBack: 'Vorherige Tabs',
|