@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.
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { UISchema, SupportedTypes, ParameterPreset } from '@selvajs/schemas';
4
- import type { SolveSettlement } from '@selvajs/solve/client';
4
+ import type { LiveSolveState, SolveSettlement } from '@selvajs/solve/client';
5
5
  import type { ActionButton } from '../../types/actionButton';
6
6
  import type { PresetLabels } from '../../types/presetLabels';
7
7
  import { ChevronUp, Download, Ellipsis, Upload } from '@lucide/svelte';
@@ -35,13 +35,19 @@
35
35
  meshes?: any[];
36
36
  isSolving: boolean;
37
37
  /**
38
- * `createSolvingIndicator(...).show`, not `isSolving`: drives the solving chip and the
39
- * viewer's progress bar, which would otherwise flash on every sub-second solve.
38
+ * `createSolvingIndicator(...).show`, not `isSolving`: grows the action row's solve status,
39
+ * which would otherwise flash on every sub-second solve.
40
40
  */
41
41
  showSolvingIndicator?: boolean;
42
- /** `createSolvingIndicator(...).slow`: the viewer dims its result as stale. */
42
+ /** `createSolvingIndicator(...).slow`: the solve status grows into a card with progress. */
43
43
  solveIsSlow?: boolean;
44
- /** `SolvingIndicator`'s `placement`; `inline` puts it in the action row. */
44
+ /** The session's live channel: step, count and bar for that card. */
45
+ live?: LiveSolveState;
46
+ /** Abort in that card. The host's `SolveMessages` should then take `progressShownByHost`. */
47
+ onabort?: () => void;
48
+ /** As passed to `createSolvingIndicator`. */
49
+ freezesDuringSolve?: boolean;
50
+ /** @deprecated Ignored: the solving status always sits in the action row. */
45
51
  solvingIndicatorPlacement?: 'floating' | 'inline';
46
52
  /** Rendered above the action row, for a host that keeps the solve's messages in the flow. */
47
53
  solveMessages?: Snippet;
@@ -75,7 +81,9 @@
75
81
  isSolving,
76
82
  showSolvingIndicator = false,
77
83
  solveIsSlow = false,
78
- solvingIndicatorPlacement = 'floating',
84
+ live,
85
+ onabort,
86
+ freezesDuringSolve = false,
79
87
  solveMessages,
80
88
  hasPendingChanges = false,
81
89
  hasNeverSolved = false,
@@ -100,8 +108,6 @@
100
108
  let presets = $state<ParameterPresetManager | null>(null);
101
109
  const hasMenu = $derived(showSaveButton || showLoadButton || panelActions.length > 0);
102
110
  const resultIsStale = $derived(hasPendingChanges || hasNeverSolved);
103
- // With a Calculate button the button itself says it is solving.
104
- const showSolvingChip = $derived(showSolvingIndicator && schema.instanceSolve !== false);
105
111
 
106
112
  const shownTabs = $derived(
107
113
  schema.layout.type === 'tabbed'
@@ -283,13 +289,18 @@
283
289
  {oncalculate}
284
290
  trailing={hasMenu ? actionsMenu : undefined}
285
291
  />
286
- {:else if hasMenu || solvingIndicatorPlacement === 'inline'}
292
+ {:else}
287
293
  <div class="mt-3 pb-3 pt-2 px-2 gap-2 flex items-center border-t border-border">
288
- {#if solvingIndicatorPlacement === 'inline'}
289
- <SolvingIndicator show={showSolvingChip} placement="inline" />
290
- {/if}
294
+ <SolvingIndicator
295
+ {isSolving}
296
+ show={showSolvingIndicator}
297
+ slow={solveIsSlow}
298
+ {live}
299
+ {onabort}
300
+ {freezesDuringSolve}
301
+ />
291
302
  {#if hasMenu}
292
- <div class="ml-auto">{@render actionsMenu()}</div>
303
+ {@render actionsMenu()}
293
304
  {/if}
294
305
  </div>
295
306
  {/if}
@@ -340,8 +351,6 @@
340
351
  <Viewer
341
352
  {meshes}
342
353
  bind:isFullscreen={isViewerFullscreen}
343
- isSolving={showSolvingIndicator}
344
- isSlow={solveIsSlow}
345
354
  drawerOpen={drawer === 'full'}
346
355
  {logoUrl}
347
356
  {onViewerReady}
@@ -486,8 +495,6 @@
486
495
  <Viewer
487
496
  {meshes}
488
497
  bind:isFullscreen={isViewerFullscreen}
489
- isSolving={showSolvingIndicator}
490
- isSlow={solveIsSlow}
491
498
  {logoUrl}
492
499
  {onViewerReady}
493
500
  viewerConfig={{
@@ -548,10 +555,6 @@
548
555
  {/if}
549
556
  </div>
550
557
  {/if}
551
-
552
- {#if solvingIndicatorPlacement === 'floating'}
553
- <SolvingIndicator show={showSolvingChip} />
554
- {/if}
555
558
  </main>
556
559
 
557
560
  <!-- The save, load and validation dialogs; the footer's menu opens them. -->
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { UISchema, SupportedTypes, ParameterPreset } from '@selvajs/schemas';
3
- import type { SolveSettlement } from '@selvajs/solve/client';
3
+ import type { LiveSolveState, SolveSettlement } from '@selvajs/solve/client';
4
4
  import type { ActionButton } from '../../types/actionButton';
5
5
  import type { PresetLabels } from '../../types/presetLabels';
6
6
  import type { ThreeViewer } from '@selvajs/visualization/render';
@@ -15,13 +15,19 @@ interface Props {
15
15
  meshes?: any[];
16
16
  isSolving: boolean;
17
17
  /**
18
- * `createSolvingIndicator(...).show`, not `isSolving`: drives the solving chip and the
19
- * viewer's progress bar, which would otherwise flash on every sub-second solve.
18
+ * `createSolvingIndicator(...).show`, not `isSolving`: grows the action row's solve status,
19
+ * which would otherwise flash on every sub-second solve.
20
20
  */
21
21
  showSolvingIndicator?: boolean;
22
- /** `createSolvingIndicator(...).slow`: the viewer dims its result as stale. */
22
+ /** `createSolvingIndicator(...).slow`: the solve status grows into a card with progress. */
23
23
  solveIsSlow?: boolean;
24
- /** `SolvingIndicator`'s `placement`; `inline` puts it in the action row. */
24
+ /** The session's live channel: step, count and bar for that card. */
25
+ live?: LiveSolveState;
26
+ /** Abort in that card. The host's `SolveMessages` should then take `progressShownByHost`. */
27
+ onabort?: () => void;
28
+ /** As passed to `createSolvingIndicator`. */
29
+ freezesDuringSolve?: boolean;
30
+ /** @deprecated Ignored: the solving status always sits in the action row. */
25
31
  solvingIndicatorPlacement?: 'floating' | 'inline';
26
32
  /** Rendered above the action row, for a host that keeps the solve's messages in the flow. */
27
33
  solveMessages?: Snippet;
@@ -288,6 +288,8 @@
288
288
  isSolving={session.isSolving}
289
289
  showSolvingIndicator={solvingIndicator.show}
290
290
  solveIsSlow={solvingIndicator.slow}
291
+ live={session.live}
292
+ onabort={() => session.abort()}
291
293
  hasPendingChanges={session.hasPendingChanges}
292
294
  hasNeverSolved={session.hasNeverSolved}
293
295
  bind:isViewerFullscreen
@@ -318,4 +320,5 @@
318
320
  onabort={() => session.abort()}
319
321
  onconfirm={() => session.acknowledge()}
320
322
  ondiscard={() => session.discard()}
323
+ progressShownByHost={schema?.instanceSolve !== false}
321
324
  />
@@ -20,6 +20,7 @@
20
20
  import IssueList, { type Issue } from '../primitives/IssueList.svelte';
21
21
  import SeverityIcon from '../primitives/SeverityIcon.svelte';
22
22
  import { getLocaleContext } from '../../i18n/localeContext.svelte.js';
23
+ import { createLiveProgress } from '../../compute/liveProgress.svelte.js';
23
24
 
24
25
  interface Props {
25
26
  /** What the running (or most recent) solve has said over the live channel. */
@@ -38,6 +39,11 @@
38
39
  freezesDuringSolve?: boolean;
39
40
  /** `floating` overlays the bottom centre; `inline` sits in the flow below the host's content. */
40
41
  placement?: 'floating' | 'inline';
42
+ /**
43
+ * The host shows progress and Abort itself (`AppLayout`'s solve status), so the live view
44
+ * keeps only the messages that arrive mid-solve.
45
+ */
46
+ progressShownByHost?: boolean;
41
47
  }
42
48
 
43
49
  let {
@@ -48,7 +54,8 @@
48
54
  onconfirm,
49
55
  ondiscard,
50
56
  freezesDuringSolve = false,
51
- placement = 'floating'
57
+ placement = 'floating',
58
+ progressShownByHost = false
52
59
  }: Props = $props();
53
60
 
54
61
  const locale = getLocaleContext();
@@ -56,50 +63,10 @@
56
63
 
57
64
  const REMARK_LINGER_MS = 6000;
58
65
 
59
- const progress = $derived([...live.progress.values()]);
60
-
61
- // A report only arrives when a step starts, so a bar that just shows `fraction` stands still
62
- // through the step and then jumps. When the plugin knows how long the step took last time, the
63
- // bar runs linearly to where the step will end, over that time. Steps are weighted by time, so
64
- // linear gives one steady speed across the solve; any easing curve visibly speeds up and
65
- // crawls within each step. A slower run waits at the step's end; a faster one snaps ahead.
66
- let bars = $state.raw<Record<string, { width: number; ms: number }>>({});
67
- // Any live event re-runs the effect below. Only a source with a new report may re-snap;
68
- // the rest keep easing where they are.
69
- let seen = new Map<string, unknown>();
70
- let targets: Record<string, { width: number; ms: number }> = {};
71
- $effect(() => {
72
- const snapped: Record<string, { width: number; ms: number }> = {};
73
- const eased: Record<string, { width: number; ms: number }> = {};
74
- const current = untrack(() => bars);
75
- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- bookkeeping for this effect; reactive reads here would re-trigger it
76
- const nextSeen = new Map<string, unknown>();
77
- for (const p of progress) {
78
- if (p.fraction === undefined) continue;
79
- const key = p.source ?? '';
80
- nextSeen.set(key, p);
81
- if (seen.get(key) === p && current[key]) {
82
- snapped[key] = current[key];
83
- // This run's cleanup cancelled the frame that would have started the ease.
84
- if (targets[key] && current[key] !== targets[key]) eased[key] = targets[key];
85
- continue;
86
- }
87
- snapped[key] = { width: p.fraction, ms: 150 };
88
- if (p.nextFraction !== undefined && p.stepMs !== undefined) {
89
- eased[key] = { width: p.nextFraction, ms: p.stepMs };
90
- }
91
- }
92
- seen = nextSeen;
93
- targets = eased;
94
- bars = snapped;
95
- if (Object.keys(eased).length === 0) return;
96
- // Two frames: the snapped width must be painted before the eased one is set, or the
97
- // browser folds both into one style change and there is nothing to transition from.
98
- let frame = requestAnimationFrame(() => {
99
- frame = requestAnimationFrame(() => (bars = { ...snapped, ...eased }));
100
- });
101
- return () => cancelAnimationFrame(frame);
102
- });
66
+ const liveProgress = createLiveProgress(() => live);
67
+ const progress = $derived(liveProgress.progress);
68
+ const bars = $derived(liveProgress.bars);
69
+ const headline = $derived(liveProgress.headline);
103
70
  // Grasshopper's own complaints stay in the footer log; this panel shows what the author wrote.
104
71
  const authored = $derived(diagnostics.filter((d) => d.isGate));
105
72
 
@@ -207,37 +174,11 @@
207
174
  }))
208
175
  );
209
176
 
210
- // Steps a few milliseconds long would swap the headline faster than it can be read. Each one
211
- // stays up for at least this long; steps that end sooner are skipped in the label but still
212
- // count in the bar. The label and its step count move together so they never disagree.
213
- const HEADLINE_MIN_MS = 200;
214
- let headline = $state.raw<(typeof progress)[number] | undefined>(undefined);
215
- let headlineAt = 0;
216
- $effect(() => {
217
- const next = progress[0];
218
- const shown = untrack(() => headline);
219
- if (next?.label === shown?.label && next?.done === shown?.done) {
220
- headline = next;
221
- return;
222
- }
223
- const show = () => {
224
- headline = next;
225
- headlineAt = performance.now();
226
- };
227
- const wait = HEADLINE_MIN_MS - (performance.now() - headlineAt);
228
- if (!shown || wait <= 0) {
229
- show();
230
- return;
231
- }
232
- const timer = setTimeout(show, wait);
233
- return () => clearTimeout(timer);
234
- });
235
-
236
177
  const visible = $derived(
237
178
  view === 'review' || view === 'blocked'
238
179
  ? true
239
180
  : view === 'live'
240
- ? revealed && (rows.length > 0 || progress.length > 0)
181
+ ? revealed && (rows.length > 0 || (!progressShownByHost && progress.length > 0))
241
182
  : rows.length > 0 && lingering
242
183
  );
243
184
 
@@ -297,7 +238,7 @@
297
238
  data-testid="solve-messages"
298
239
  data-view={view}
299
240
  >
300
- {#if view === 'live'}
241
+ {#if view === 'live' && !progressShownByHost}
301
242
  {@const head = headline ?? progress[0]}
302
243
  <div>
303
244
  <div>
@@ -402,7 +343,11 @@
402
343
  {/if}
403
344
 
404
345
  {#if rows.length > 0}
405
- <div class="max-h-40 overflow-y-auto {view === 'live' ? 'mt-3 pt-3 border-t' : ''}">
346
+ <div
347
+ class="max-h-40 overflow-y-auto {view === 'live' && !progressShownByHost
348
+ ? 'mt-3 pt-3 border-t'
349
+ : ''}"
350
+ >
406
351
  <IssueList {issues} variant="plain" />
407
352
  </div>
408
353
  {/if}
@@ -17,6 +17,11 @@ interface Props {
17
17
  freezesDuringSolve?: boolean;
18
18
  /** `floating` overlays the bottom centre; `inline` sits in the flow below the host's content. */
19
19
  placement?: 'floating' | 'inline';
20
+ /**
21
+ * The host shows progress and Abort itself (`AppLayout`'s solve status), so the live view
22
+ * keeps only the messages that arrive mid-solve.
23
+ */
24
+ progressShownByHost?: boolean;
20
25
  }
21
26
  declare const SolveMessages: import("svelte").Component<Props, {}, "">;
22
27
  type SolveMessages = ReturnType<typeof SolveMessages>;
@@ -6,6 +6,7 @@
6
6
  import { loadScript } from '../../utils/loadScript.js';
7
7
  import { getLocaleContext } from '../../i18n/localeContext.svelte.js';
8
8
  import { formatMessage } from '../../i18n/messages.js';
9
+ import { upgradePlotlyFigure, type PlotlyFigure } from '../../features/chart/plotly-compat.js';
9
10
 
10
11
  interface Props {
11
12
  item: OutputChartLayoutItem;
@@ -17,19 +18,18 @@
17
18
  const locale = getLocaleContext();
18
19
  const t = $derived(locale.messages);
19
20
 
20
- const MAP_TYPE_SET = new Set(['scattermap', 'scattermapbox', 'choropleth', 'scattergeo']);
21
-
22
- type PlotlyTrace = Record<string, unknown>;
23
- type PlotlyFigure = {
24
- data: PlotlyTrace[];
25
- layout: Record<string, unknown>;
26
- config?: Record<string, unknown>;
27
- };
21
+ const MAP_TYPE_SET = new Set([
22
+ 'scattermap',
23
+ 'choroplethmap',
24
+ 'densitymap',
25
+ 'choropleth',
26
+ 'scattergeo'
27
+ ]);
28
28
 
29
29
  const figData = $derived.by(() => {
30
30
  if (!value) return null;
31
31
  try {
32
- return JSON.parse(value) as PlotlyFigure;
32
+ return upgradePlotlyFigure(JSON.parse(value) as PlotlyFigure);
33
33
  } catch {
34
34
  return null;
35
35
  }
@@ -47,9 +47,9 @@
47
47
  if (window.Plotly) return window.Plotly;
48
48
 
49
49
  // Pinned by hash: the plugin's web UI runs this on the origin that holds its bridge secret.
50
- await loadScript('https://cdn.plot.ly/plotly-3.4.0.min.js', {
50
+ await loadScript('https://cdn.plot.ly/plotly-4.1.2.min.js', {
51
51
  crossOrigin: 'anonymous',
52
- integrity: 'sha384-pFOQ7loGBChmUyS4Tszy+he8OWeYkwTFjc2xii4aJUWWiGwf2u2OEc04T5iDRpMp'
52
+ integrity: 'sha384-hHv6D3tTUUBpp4tVv6t2Dc3oGuRhphmWOTKUR8jWyN0Pnon1g3iebKCNDNmKmxo8'
53
53
  });
54
54
  // @ts-expect-error — Plotly is loaded dynamically onto window
55
55
  return window.Plotly;
@@ -113,8 +113,13 @@
113
113
  return a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
114
114
  }
115
115
 
116
- function getThemeColors(): { textColor: string; gridColor: string } {
117
- if (!containerEl) return { textColor: '#333', gridColor: '#eee' };
116
+ function getThemeColors(): {
117
+ textColor: string;
118
+ gridColor: string;
119
+ fontFamily: string;
120
+ colorway: string[];
121
+ } {
122
+ if (!containerEl) return { textColor: '#333', gridColor: '#eee', fontFamily: '', colorway: [] };
118
123
  const style = getComputedStyle(containerEl);
119
124
  const textColor = toRgb(style.getPropertyValue('color').trim() || '#333');
120
125
  const gridColor = toRgb(
@@ -122,7 +127,12 @@
122
127
  style.getPropertyValue('--color-border')?.trim() ||
123
128
  'rgba(128,128,128,0.2)'
124
129
  );
125
- return { textColor, gridColor };
130
+ // Figures that leave colours unset take Selva's chart palette rather than Plotly's.
131
+ const colorway = [1, 2, 3, 4, 5]
132
+ .map((i) => style.getPropertyValue(`--chart-${i}`).trim())
133
+ .filter(Boolean)
134
+ .map(toRgb);
135
+ return { textColor, gridColor, fontFamily: style.fontFamily, colorway };
126
136
  }
127
137
 
128
138
  async function renderChart() {
@@ -131,7 +141,7 @@
131
141
  loading = true;
132
142
  try {
133
143
  const Plotly = await loadPlotly();
134
- const { textColor, gridColor } = getThemeColors();
144
+ const { textColor, gridColor, fontFamily, colorway } = getThemeColors();
135
145
  const isMapFigure = figData.data?.some((t) => MAP_TYPE_SET.has(t.type as string));
136
146
 
137
147
  const {
@@ -151,7 +161,8 @@
151
161
  ...layoutRest,
152
162
  autosize: true,
153
163
  margin: { t: 24, r: 8, b: 40, l: 60, pad: 0 },
154
- font: { color: textColor },
164
+ font: { color: textColor, ...(fontFamily ? { family: fontFamily } : {}) },
165
+ ...(colorway.length && !layoutRest.colorway ? { colorway } : {}),
155
166
  paper_bgcolor: layoutRest.paper_bgcolor ?? 'transparent',
156
167
  plot_bgcolor: layoutRest.plot_bgcolor ?? 'transparent',
157
168
  // Plotly's axis tick/label color doesn't inherit the top-level `font.color`, so this
@@ -166,12 +177,20 @@
166
177
  : {})
167
178
  };
168
179
 
169
- Plotly.react(containerEl, figData.data, layout, {
170
- responsive: true,
171
- displayModeBar: 'hover',
172
- displaylogo: false,
173
- modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'],
174
- ...figData.config
180
+ Plotly.react(containerEl, {
181
+ data: figData.data,
182
+ layout,
183
+ // Animated figures (e.g. Canopy's Animate) carry their frames here.
184
+ frames: figData.frames ?? [],
185
+ config: {
186
+ responsive: true,
187
+ displayModeBar: 'hover',
188
+ displaylogo: false,
189
+ // Plotly 4's "Share chart" button uploads the figure to Plotly's cloud.
190
+ showSendToCloud: false,
191
+ modeBarButtonsToRemove: ['sendDataToCloud', 'lasso2d', 'select2d'],
192
+ ...figData.config
193
+ }
175
194
  });
176
195
  } catch (e) {
177
196
  error = e instanceof Error ? e.message : t.chartRenderFailed;