@bramas/simulant-ui 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +22 -0
  2. package/dist/SimulatorApp.jsx +782 -175
  3. package/dist/components/AlgorithmViewer.css +268 -74
  4. package/dist/components/AlgorithmViewer.jsx +171 -66
  5. package/dist/components/BranchPicker.css +74 -32
  6. package/dist/components/BranchPicker.jsx +9 -4
  7. package/dist/components/ConfigGraphGenerator.css +180 -38
  8. package/dist/components/ConfigGraphGenerator.jsx +155 -96
  9. package/dist/components/GenericEditor.jsx +18 -44
  10. package/dist/components/Icon.jsx +54 -0
  11. package/dist/components/ModelView.css +15 -15
  12. package/dist/components/SettingsLibrary.jsx +7 -5
  13. package/dist/components/Splitter.jsx +28 -0
  14. package/dist/components/StateInspector.css +59 -33
  15. package/dist/components/StateInspector.jsx +37 -4
  16. package/dist/components/theme.js +28 -0
  17. package/dist/pico.min.css +4 -4
  18. package/dist/renderers/ring.js +19 -10
  19. package/dist/sim/describe.js +10 -8
  20. package/dist/sim/project.js +27 -17
  21. package/dist/simulator.css +1553 -363
  22. package/package.json +6 -6
  23. package/src/SimulatorApp.tsx +888 -220
  24. package/src/components/AlgorithmViewer.css +268 -74
  25. package/src/components/AlgorithmViewer.tsx +188 -75
  26. package/src/components/BranchPicker.css +74 -32
  27. package/src/components/BranchPicker.tsx +9 -4
  28. package/src/components/ConfigGraphGenerator.css +180 -38
  29. package/src/components/ConfigGraphGenerator.tsx +172 -114
  30. package/src/components/GenericEditor.tsx +25 -59
  31. package/src/components/Icon.tsx +76 -0
  32. package/src/components/ModelView.css +15 -15
  33. package/src/components/SettingsLibrary.tsx +7 -5
  34. package/src/components/Splitter.tsx +55 -0
  35. package/src/components/StateInspector.css +59 -33
  36. package/src/components/StateInspector.tsx +35 -4
  37. package/src/components/theme.ts +33 -0
  38. package/src/pico.min.css +4 -4
  39. package/src/renderers/ring.ts +22 -12
  40. package/src/sim/describe.ts +10 -8
  41. package/src/sim/project.ts +27 -19
  42. package/src/simulator.css +1553 -363
@@ -1,12 +1,15 @@
1
1
  /**
2
- * The simulator page, in two views. The simulation: an execution of the selected experiment,
3
- * step by step. The execution toolbar is on top (rounds, the adversary's choices, the path), the
4
- * configuration and its state on the left, the algorithm on the right, the settings and saved
5
- * settings below, then the configuration graph. The model (`#model`): the experiment's semantics
6
- * and the declarations it is built from, as written, to compare with the paper.
2
+ * The simulator page, as a desktop application that fills the window. The top bar picks the
3
+ * project and the view. The simulation: the transport (rounds, playback, the verdict, the
4
+ * adversary's choices, then the path) above a workbench whose center always shows the current
5
+ * configuration; an activity bar shows or hides the panels around it (settings and saved
6
+ * settings on the left, the algorithm and the state on the right), resized by splitters. The
7
+ * graph view puts the configuration graph in the center, beside the same settings. The model
8
+ * (`#model`): the experiment's semantics and the declarations it is built from, as written, to
9
+ * compare with the paper. A status bar sums up the experiment.
7
10
  */
8
- import { batch, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type Component } from 'solid-js';
9
- import type { ExecutionLike, ProjectType, PredefinedSetting } from '@bramas/simulant-core/types/project';
11
+ import { batch, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type Component, type JSX } from 'solid-js';
12
+ import type { AgentRenderInfo, ExecutionLike, ProjectType, PredefinedSetting } from '@bramas/simulant-core/types/project';
10
13
  import { ConfigGraphGenerator } from './components/ConfigGraphGenerator';
11
14
  import { formValues, GenericEditor } from './components/GenericEditor';
12
15
  import { AlgorithmViewer } from './components/AlgorithmViewer';
@@ -14,6 +17,9 @@ import { StateInspector } from './components/StateInspector';
14
17
  import { ModelView } from './components/ModelView';
15
18
  import { SettingsLibrary, type SavedSetting } from './components/SettingsLibrary';
16
19
  import { BranchPicker, type Branch } from './components/BranchPicker';
20
+ import { Icon, Logo, type IconName } from './components/Icon';
21
+ import { Splitter } from './components/Splitter';
22
+ import { createSystemDark, renderTheme } from './components/theme';
17
23
  import { distinguishing, distinguishingChoices } from './sim/branches';
18
24
 
19
25
  export type { PredefinedSetting } from '@bramas/simulant-core/types/project';
@@ -28,7 +34,53 @@ export interface SimulatorAppProps {
28
34
 
29
35
  const DEFAULT_STORAGE_KEY = 'project_settings_v2';
30
36
  const THEME_KEY = 'simulant-theme';
31
- const CANVAS = { width: 500, height: 300 };
37
+ const PANELS_KEY = 'simulant-panels';
38
+ const RANDOM_KEY = 'simulant-random-branch';
39
+ const SIZES_KEY = 'simulant-panel-sizes';
40
+
41
+ const DOCKS_KEY = 'simulant-panel-docks';
42
+ const WEIGHTS_KEY = 'simulant-panel-weights';
43
+
44
+ /** The panels around the configuration, each shown or hidden from the activity bar. */
45
+ type PanelId = 'settings' | 'library' | 'algorithm' | 'state';
46
+ const PANEL_IDS: PanelId[] = ['settings', 'library', 'algorithm', 'state'];
47
+ const PANEL_TITLE: Record<PanelId, string> = { settings: 'Settings', library: 'Saved settings', algorithm: 'Algorithm', state: 'State' };
48
+ const DEFAULT_PANELS: Record<PanelId, boolean> = { settings: true, library: true, algorithm: true, state: true };
49
+ /** The panels the graph view shows beside the graph (the graph follows the settings). */
50
+ const GRAPH_PANELS = new Set<PanelId>(['settings', 'library']);
51
+ /** Panels that take only the height they need in a side bar. */
52
+ const COMPACT_PANELS = new Set<PanelId>(['settings']);
53
+
54
+ /**
55
+ * Where panels go: the side bars, or below the configuration. Algorithms are long, so by default
56
+ * the algorithm has the right side bar to itself and the state sits below the configuration.
57
+ */
58
+ export type Dock = 'left' | 'right' | 'bottom';
59
+ const DOCKS: Dock[] = ['left', 'right', 'bottom'];
60
+ const DOCK_LABEL: Record<Dock, string> = { left: 'Left side bar', right: 'Right side bar', bottom: 'Below the configuration' };
61
+ const DOCK_ICON: Record<Dock, IconName> = { left: 'dockLeft', right: 'dockRight', bottom: 'dockBottom' };
62
+ const DEFAULT_DOCKS: Record<Dock, PanelId[]> = { left: ['settings', 'library'], right: ['algorithm'], bottom: ['state'] };
63
+ /** Widths of the side bars, in pixels, and the share of the center below the configuration. */
64
+ const DEFAULT_SIZES = { left: 300, right: 540, bottom: 0.34 };
65
+
66
+ /** Stored docks, with every panel exactly once (panels added later go to their default dock). */
67
+ function normalizeDocks(stored: any): Record<Dock, PanelId[]> {
68
+ const seen = new Set<PanelId>();
69
+ const out = { left: [], right: [], bottom: [] } as Record<Dock, PanelId[]>;
70
+ for (const dock of DOCKS) {
71
+ for (const id of Array.isArray(stored?.[dock]) ? stored[dock] : []) {
72
+ if (PANEL_IDS.includes(id) && !seen.has(id)) { seen.add(id); out[dock].push(id); }
73
+ }
74
+ }
75
+ for (const dock of DOCKS) for (const id of DEFAULT_DOCKS[dock]) if (!seen.has(id)) out[dock].push(id);
76
+ return out;
77
+ }
78
+ type View = 'simulation' | 'graph' | 'model';
79
+ const VIEW_HASH: Record<View, string> = { simulation: '', graph: '#graph', model: '#model' };
80
+ /** Steps per second while playing. */
81
+ const SPEEDS = [1, 2, 4, 8];
82
+ /** Beyond this length, the path's stops are not judged one by one. */
83
+ const JUDGED_PATH = 400;
32
84
 
33
85
  // Browser storage may be unavailable (private windows, blocked site data): the page works without it.
34
86
  function readStorage(key: string): any {
@@ -84,6 +136,94 @@ const typing = (e: KeyboardEvent) => {
84
136
  return !!t && (t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName));
85
137
  };
86
138
 
139
+ /** `N=6 · site=2`, for the status bar. */
140
+ const settingsSummary = (settings: Record<string, unknown>) =>
141
+ Object.entries(settings).map(([k, v]) => {
142
+ const s = typeof v === 'string' ? v : JSON.stringify(v);
143
+ return `${k}=${s.length > 18 ? s.slice(0, 16) + '…' : s}`;
144
+ }).join(' · ');
145
+
146
+ /**
147
+ * Where the stage draws the configuration: centered, with room around the ring for labels and
148
+ * up to four agents stacked on a node (the default ring stacks them outwards).
149
+ */
150
+ const geometry = (size: { w: number; h: number }) => ({
151
+ cx: size.w / 2,
152
+ cy: size.h / 2,
153
+ r: Math.max(40, Math.min(size.w, size.h) / 2 - 68),
154
+ });
155
+
156
+ type PanelControls = { dock?: Dock; onMove?: (dock: Dock) => void; onClose?: () => void; onDragStart?: (e: PointerEvent) => void };
157
+
158
+ /** A panel's dock menu ("Move to…") and the button that hides it. */
159
+ function PanelActions(props: { title: string } & PanelControls) {
160
+ const [open, setOpen] = createSignal(false);
161
+ let root: HTMLDivElement | undefined;
162
+ const onPointer = (e: PointerEvent) => { if (!root?.contains(e.target as Node)) setOpen(false); };
163
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
164
+ createEffect(() => {
165
+ if (!open()) return;
166
+ window.addEventListener('pointerdown', onPointer);
167
+ window.addEventListener('keydown', onKey);
168
+ onCleanup(() => {
169
+ window.removeEventListener('pointerdown', onPointer);
170
+ window.removeEventListener('keydown', onKey);
171
+ });
172
+ });
173
+ return (
174
+ <>
175
+ <Show when={props.onMove}>
176
+ <div class="sim-popover-anchor" ref={root}>
177
+ <button class="sim-ghost sim-icon sim-sm" onClick={() => setOpen(v => !v)} aria-label={`Move ${props.title.toLowerCase()}`} aria-haspopup="menu" aria-expanded={open()} title="Move this panel (or drag its title bar)">
178
+ <Icon name={props.dock ? DOCK_ICON[props.dock] : 'dock'} size={14} />
179
+ </button>
180
+ <Show when={open()}>
181
+ <div class="sim-menu" role="menu" aria-label={`Move ${props.title.toLowerCase()} to`}>
182
+ <div class="sim-menu-label">Move to</div>
183
+ <For each={DOCKS}>
184
+ {dock => (
185
+ <button role="menuitemradio" aria-checked={props.dock === dock} class={`sim-menu-item${props.dock === dock ? ' sim-menu-current' : ''}`} onClick={() => { setOpen(false); props.onMove!(dock); }}>
186
+ <Icon name={DOCK_ICON[dock]} size={15} /> {DOCK_LABEL[dock]}
187
+ <Show when={props.dock === dock}><Icon name="check" size={14} class="sim-menu-check" /></Show>
188
+ </button>
189
+ )}
190
+ </For>
191
+ </div>
192
+ </Show>
193
+ </div>
194
+ </Show>
195
+ <Show when={props.onClose}>
196
+ <button class="sim-ghost sim-icon sim-sm" onClick={() => props.onClose!()} aria-label={`Hide ${props.title.toLowerCase()}`} title="Hide this panel">
197
+ <Icon name="close" size={14} />
198
+ </button>
199
+ </Show>
200
+ </>
201
+ );
202
+ }
203
+
204
+ /** A panel's title bar: its name, what it shows, its actions; it drags the panel to another dock. */
205
+ function PanelHead(props: { icon: IconName; title: string; sub?: string; children?: JSX.Element } & PanelControls) {
206
+ return (
207
+ <div class={`sim-panel-head${props.onDragStart ? ' sim-panel-head-draggable' : ''}`} onPointerDown={e => props.onDragStart?.(e)}>
208
+ <Icon name={props.icon} />
209
+ <h2 class="sim-panel-title">{props.title}</h2>
210
+ <Show when={props.sub}><span class="sim-panel-sub">{props.sub}</span></Show>
211
+ <span class="sim-spacer" />
212
+ {props.children}
213
+ <PanelActions title={props.title} dock={props.dock} onMove={props.onMove} onClose={props.onClose} />
214
+ </div>
215
+ );
216
+ }
217
+
218
+ /** A button of the activity bar: shows or hides a panel. */
219
+ function ActivityButton(props: { icon: IconName; label: string; active: boolean; onClick: () => void }) {
220
+ return (
221
+ <button class={`sim-activity-btn${props.active ? ' sim-active' : ''}`} aria-pressed={props.active} aria-label={props.label} title={props.label} onClick={() => props.onClick()}>
222
+ <Icon name={props.icon} size={18} />
223
+ </button>
224
+ );
225
+ }
226
+
87
227
  const VERDICT_TEXT = { success: 'success', failure: 'failure', inProgress: 'not yet' } as const;
88
228
  /** What a configuration's verdict says about the execution, under each goal of the task. */
89
229
  const VERDICT_TITLE = {
@@ -115,42 +255,77 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
115
255
  // The path in the execution tree: [initial configuration, child index, child index, ...].
116
256
  const [executionPath, setExecutionPath] = createSignal<number[]>([0]);
117
257
  const [indexInPath, setIndexInPath] = createSignal(0);
118
- const [selectedAgent, setSelectedAgent] = createSignal<any>(null);
119
- // The view, in the URL's hash so that a link can open the model.
120
- const viewOfHash = () => window.location.hash === '#model' ? 'model' as const : 'simulation' as const;
121
- const [view, setView] = createSignal(viewOfHash());
122
- const showView = (v: 'simulation' | 'model') => {
258
+ const [selectedAgent, setSelectedAgent] = createSignal<number | null>(null);
259
+ const [playing, setPlaying] = createSignal(false);
260
+ // Past the end of the path, a step takes the first branch, or a random one (remembered).
261
+ const [randomBranch, setRandomBranch] = createSignal<boolean>(readStorage(RANDOM_KEY) === true);
262
+ const toggleRandomBranch = () => {
263
+ setRandomBranch(r => !r);
264
+ writeStorage(RANDOM_KEY, randomBranch());
265
+ };
266
+ const [speed, setSpeed] = createSignal(2);
267
+ // The view, in the URL's hash so that a link can open the graph or the model.
268
+ const viewOfHash = (): View => window.location.hash === '#model' ? 'model' : window.location.hash === '#graph' ? 'graph' : 'simulation';
269
+ const [view, setView] = createSignal<View>(viewOfHash());
270
+ const showView = (v: View) => {
123
271
  setView(v);
124
- history.replaceState(null, '', `${window.location.pathname}${window.location.search}${v === 'model' ? '#model' : ''}`);
272
+ setPlaying(false);
273
+ history.replaceState(null, '', `${window.location.pathname}${window.location.search}${VIEW_HASH[v]}`);
125
274
  };
126
275
  const onHash = () => setView(viewOfHash());
127
276
  window.addEventListener('hashchange', onHash);
128
277
  onCleanup(() => window.removeEventListener('hashchange', onHash));
129
- const [showConfigGraph, setShowConfigGraph] = createSignal(false);
278
+
279
+ // The panels shown, and their sizes (remembered).
280
+ const [panels, setPanels] = createSignal<Record<PanelId, boolean>>({ ...DEFAULT_PANELS, ...readStorage(PANELS_KEY) });
281
+ const togglePanel = (id: PanelId) => setPanels(p => {
282
+ const next = { ...p, [id]: !p[id] };
283
+ writeStorage(PANELS_KEY, next);
284
+ return next;
285
+ });
286
+ const stored = { ...DEFAULT_SIZES, ...readStorage(SIZES_KEY) };
287
+ const [sizes, setSizes] = createSignal(stored.bottom > 0 && stored.bottom < 1 ? stored : { ...stored, bottom: DEFAULT_SIZES.bottom });
288
+ const resize = (key: keyof typeof DEFAULT_SIZES, value: number) => setSizes(s => ({ ...s, [key]: value }));
289
+ const saveSizes = () => writeStorage(SIZES_KEY, sizes());
290
+ const resetSize = (key: keyof typeof DEFAULT_SIZES) => { resize(key, DEFAULT_SIZES[key]); saveSizes(); };
291
+ const [showKeys, setShowKeys] = createSignal(false);
130
292
  const [setupLog, setSetupLog] = createSignal('');
131
293
  const [toast, setToast] = createSignal('');
132
294
  let canvas: HTMLCanvasElement | undefined;
295
+ let stage: HTMLDivElement | undefined;
296
+ let track: HTMLDivElement | undefined;
297
+ let workbench: HTMLDivElement | undefined;
298
+ let center: HTMLElement | undefined;
299
+ let leftBar: HTMLElement | undefined;
300
+ let rightBar: HTMLElement | undefined;
133
301
 
134
302
  const goTo = (path: number[], index = 0) => batch(() => {
135
303
  setExecutionPath(path.length ? path : [0]);
136
304
  setIndexInPath(Math.min(index, Math.max(0, path.length - 1)));
137
305
  setSelectedAgent(null);
306
+ setPlaying(false);
138
307
  });
139
308
 
140
309
  // Theme: the system's, unless the user picked one (remembered).
141
- const systemDark = () => !!window.matchMedia?.('(prefers-color-scheme: dark)').matches;
310
+ const systemDark = createSystemDark();
142
311
  const [theme, setTheme] = createSignal<'light' | 'dark' | null>(readStorage(THEME_KEY));
143
312
  const dark = () => (theme() ?? (systemDark() ? 'dark' : 'light')) === 'dark';
144
- createEffect(() => {
145
- const t = theme();
313
+ // Applied at once, not in an effect: the drawing colors are read from the applied theme.
314
+ const applyTheme = (t: 'light' | 'dark' | null) => {
146
315
  if (t) document.documentElement.setAttribute('data-theme', t);
147
316
  else document.documentElement.removeAttribute('data-theme');
148
- });
317
+ };
318
+ applyTheme(theme());
149
319
  const toggleTheme = () => {
150
320
  const next = dark() ? 'light' : 'dark';
321
+ applyTheme(next);
151
322
  setTheme(next);
152
323
  writeStorage(THEME_KEY, next);
153
324
  };
325
+ /** The colors drawings use, after the theme's tokens apply. */
326
+ const [mounted, setMounted] = createSignal(false);
327
+ onMount(() => setMounted(true));
328
+ const drawTheme = createMemo(() => (mounted(), renderTheme(stage, dark())));
154
329
 
155
330
  let toastTimer: ReturnType<typeof setTimeout> | undefined;
156
331
  const notify = (text: string) => {
@@ -196,7 +371,6 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
196
371
  setSetupLog('');
197
372
  return { baseModel, execution: current.execute(baseModel) };
198
373
  } catch (e) {
199
- console.error('Project setup failed:', e);
200
374
  setSetupLog(e instanceof Error ? e.message : String(e));
201
375
  return { baseModel: null, execution: null };
202
376
  }
@@ -252,6 +426,20 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
252
426
  }
253
427
  });
254
428
 
429
+ /** The verdict at each stop of the path, to mark where the task holds or fails. */
430
+ const pathVerdicts = createMemo(() => {
431
+ redrawTick();
432
+ const { execution, baseModel } = startedProject();
433
+ const judge = Projects[project()].verdict;
434
+ const path = executionPath();
435
+ if (!execution || !judge || path.length > JUDGED_PATH) return [];
436
+ try {
437
+ return path.map((_, i) => judge(execution.get(path.slice(0, i + 1)), baseModel).verdict);
438
+ } catch {
439
+ return [];
440
+ }
441
+ });
442
+
255
443
  const childCount = createMemo(() => {
256
444
  const { execution } = startedProject();
257
445
  if (!execution || !config()) return 0;
@@ -278,7 +466,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
278
466
  const verdict = Projects[project()].verdict;
279
467
  return labels.map((label, i) => ({
280
468
  index: i, label, short: shorts[i],
281
- verdict: verdict && withVerdicts && count <= 200 ?verdict(execution.get([...parent, i]), baseModel).verdict : undefined,
469
+ verdict: verdict && withVerdicts && count <= 200 ? verdict(execution.get([...parent, i]), baseModel).verdict : undefined,
282
470
  }));
283
471
  } catch {
284
472
  return [];
@@ -299,7 +487,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
299
487
  const draw = Projects[project()].drawConfig;
300
488
  if (!execution || !draw) return;
301
489
  const c = execution.get([...currentPath().slice(0, -1), index]);
302
- draw(ctx, c, baseModel, { centerX: size / 2, centerY: size / 2, radius: size * 0.34, nodeRadius: 4, agentRadius: 4, showNodeLabels: false });
490
+ draw(ctx, c, baseModel, { centerX: size / 2, centerY: size / 2, radius: size * 0.34, nodeRadius: 4, agentRadius: 4, showNodeLabels: false, theme: drawTheme() });
303
491
  };
304
492
 
305
493
  const inspected = createMemo(() => {
@@ -324,74 +512,272 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
324
512
  return { ...info, agents: info.agents.map(a => ({ ...a, explanation: a.index !== undefined ? explanations[a.index] : undefined })) };
325
513
  });
326
514
 
327
- // Drawing, at the screen's resolution (renderers draw in CSS pixels).
515
+ // The stage follows the size of its panel.
516
+ const [stageSize, setStageSize] = createSignal({ w: 500, h: 300 });
517
+ onMount(() => {
518
+ if (!stage) return;
519
+ const observer = new ResizeObserver(entries => {
520
+ const { width, height } = entries[0].contentRect;
521
+ // A hidden stage (the model view) keeps its last size.
522
+ if (width > 0 && height > 0) setStageSize({ w: Math.floor(width), h: Math.floor(height) });
523
+ });
524
+ observer.observe(stage);
525
+ onCleanup(() => observer.disconnect());
526
+ });
527
+
528
+ /** Where the agents are drawn, for hit tests and the selection's halo. */
529
+ const agentsAt = (): AgentRenderInfo[] => {
530
+ const c = config();
531
+ const baseModel = startedProject().baseModel;
532
+ const current = Projects[project()];
533
+ if (!c || !baseModel || !current.getAgentsRenderInfo) return [];
534
+ const { cx, cy, r } = geometry(stageSize());
535
+ try {
536
+ return current.getAgentsRenderInfo(c, baseModel, cx, cy, r);
537
+ } catch (err) {
538
+ console.error('Error locating the agents:', err);
539
+ return [];
540
+ }
541
+ };
542
+
543
+ // Drawing, at the screen's resolution (renderers draw in CSS pixels), then the selection.
328
544
  createEffect(() => {
329
545
  redrawTick();
330
546
  const c = config();
331
547
  const baseModel = startedProject().baseModel;
548
+ const size = stageSize();
549
+ const colors = drawTheme();
550
+ const selected = selectedAgent();
332
551
  if (!canvas || !c || !baseModel) return;
333
552
  const ctx = canvas.getContext('2d');
334
553
  if (!ctx) return;
335
554
  const ratio = window.devicePixelRatio || 1;
336
- if (canvas.width !== CANVAS.width * ratio) {
337
- canvas.width = CANVAS.width * ratio;
338
- canvas.height = CANVAS.height * ratio;
555
+ if (canvas.width !== size.w * ratio || canvas.height !== size.h * ratio) {
556
+ canvas.width = size.w * ratio;
557
+ canvas.height = size.h * ratio;
339
558
  }
340
559
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
341
- ctx.clearRect(0, 0, CANVAS.width, CANVAS.height);
342
- const current = Projects[project()];
560
+ ctx.clearRect(0, 0, size.w, size.h);
561
+ const { cx, cy, r } = geometry(size);
343
562
  try {
344
- current.drawConfig?.(ctx, c, baseModel, { centerX: 250, centerY: 150, radius: 100, showNodeLabels: true });
563
+ Projects[project()].drawConfig?.(ctx, c, baseModel, { centerX: cx, centerY: cy, radius: r, showNodeLabels: true, theme: colors });
345
564
  } catch (e) {
346
565
  console.error('Error drawing config:', e);
347
566
  }
567
+ const halo = selected !== null ? agentsAt().find(a => a.agentId === selected) : undefined;
568
+ if (halo) {
569
+ ctx.save();
570
+ ctx.strokeStyle = colors.accent;
571
+ ctx.lineWidth = 2;
572
+ ctx.beginPath();
573
+ ctx.arc(halo.position.x, halo.position.y, 12, 0, 2 * Math.PI);
574
+ ctx.stroke();
575
+ ctx.globalAlpha = 0.25;
576
+ ctx.lineWidth = 6;
577
+ ctx.beginPath();
578
+ ctx.arc(halo.position.x, halo.position.y, 15, 0, 2 * Math.PI);
579
+ ctx.stroke();
580
+ ctx.restore();
581
+ }
348
582
  });
349
583
 
350
- // Clicking an agent selects it in the state inspector.
351
- const handleCanvasClick = (e: MouseEvent) => {
352
- const c = config();
353
- const baseModel = startedProject().baseModel;
354
- const current = Projects[project()];
355
- if (!canvas || !c || !baseModel || !current.getAgentsRenderInfo) return;
584
+ const hitAt = (e: MouseEvent) => {
585
+ if (!canvas) return undefined;
356
586
  const rect = canvas.getBoundingClientRect();
357
- const x = (e.clientX - rect.left) * CANVAS.width / rect.width;
358
- const y = (e.clientY - rect.top) * CANVAS.height / rect.height;
359
- try {
360
- const hit = current.getAgentsRenderInfo(c, baseModel, 250, 150, 100)
361
- .find(a => Math.hypot(x - a.position.x, y - a.position.y) <= 10);
362
- setSelectedAgent(hit ?? null);
363
- } catch (err) {
364
- console.error('Error handling canvas click:', err);
365
- }
587
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
588
+ return agentsAt().find(a => Math.hypot(x - a.position.x, y - a.position.y) <= 10);
366
589
  };
590
+ // Clicking an agent selects it in the state inspector; hovering names it.
591
+ const handleCanvasClick = (e: MouseEvent) => setSelectedAgent(hitAt(e)?.agentId ?? null);
592
+ const [hover, setHover] = createSignal<AgentRenderInfo | undefined>();
593
+ // Agents move between configurations: the tooltip waits for the pointer to move again.
594
+ createEffect(() => { config(); setHover(undefined); });
595
+ const hovered = createMemo(() => {
596
+ const h = hover();
597
+ const agent = h && inspected()?.agents.find(a => a.id === h.agentId);
598
+ return h && { at: h.position, name: agent?.name ?? `agent ${h.agentId}`, agent };
599
+ });
367
600
 
368
601
  // Navigation
602
+ const atEnd = () => indexInPath() === executionPath().length - 1 && childCount() === 0;
369
603
  const onNext = () => {
370
604
  if (!canExplore()) return;
371
605
  if (indexInPath() < executionPath().length - 1) {
372
606
  setIndexInPath(i => i + 1);
373
607
  } else if (childCount() > 0) {
608
+ const execution = startedProject().execution!;
609
+ const choice = randomBranch() ? Math.min(execution.getRandomChildIndex(currentPath()), childCount() - 1) : 0;
374
610
  batch(() => {
375
- setExecutionPath(p => [...p, 0]);
611
+ setExecutionPath(p => [...p, choice]);
376
612
  setIndexInPath(i => i + 1);
377
613
  });
378
614
  }
379
615
  };
380
- const onPrevious = () => {
381
- if (canExplore() && indexInPath() > 0) setIndexInPath(i => i - 1);
616
+ const step = (index: number) => {
617
+ setPlaying(false);
618
+ if (canExplore()) setIndexInPath(Math.max(0, Math.min(index, executionPath().length - 1)));
382
619
  };
620
+ const onPrevious = () => step(indexInPath() - 1);
383
621
  const setChoice = (index: number) => {
384
622
  if (!canExplore()) return;
623
+ setPlaying(false);
385
624
  // A different choice at this step: the steps after it no longer apply.
386
625
  setExecutionPath([...executionPath().slice(0, indexInPath()), index]);
387
626
  };
627
+ const togglePlay = () => {
628
+ if (playing()) setPlaying(false);
629
+ else if (canExplore() && !atEnd()) setPlaying(true);
630
+ };
631
+ // Playing steps forward (taking the first branch past the path) until the execution ends.
632
+ createEffect(() => {
633
+ if (!playing()) return;
634
+ const timer = setInterval(() => {
635
+ if (!canExplore() || atEnd()) setPlaying(false);
636
+ else onNext();
637
+ }, 1000 / speed());
638
+ onCleanup(() => clearInterval(timer));
639
+ });
640
+
388
641
  const onKey = (e: KeyboardEvent) => {
389
- if (modelShown() || typing(e) || e.altKey || e.ctrlKey || e.metaKey) return;
390
- if (e.key === 'ArrowRight') { onNext(); e.preventDefault(); }
642
+ if (e.key === 'Escape') {
643
+ setShowKeys(false);
644
+ if (!pickingBranch()) setSelectedAgent(null);
645
+ }
646
+ if (shownView() !== 'simulation' || typing(e) || e.altKey || e.ctrlKey || e.metaKey) return;
647
+ if (e.key === 'ArrowRight') { setPlaying(false); onNext(); e.preventDefault(); }
391
648
  else if (e.key === 'ArrowLeft') { onPrevious(); e.preventDefault(); }
649
+ else if (e.key === 'Home') { step(0); e.preventDefault(); }
650
+ else if (e.key === 'End') { step(executionPath().length - 1); e.preventDefault(); }
651
+ else if (e.key === ' ' && !(e.target instanceof HTMLButtonElement)) { togglePlay(); e.preventDefault(); }
652
+ else if (e.key === '?') setShowKeys(v => !v);
653
+ else if (e.key === 'r' || e.key === 'R') toggleRandomBranch();
392
654
  };
393
655
  window.addEventListener('keydown', onKey);
394
656
  onCleanup(() => window.removeEventListener('keydown', onKey));
657
+ // The shortcuts close on a click elsewhere.
658
+ let shortcuts: HTMLDivElement | undefined;
659
+ const onPointer = (e: PointerEvent) => {
660
+ if (showKeys() && !shortcuts?.contains(e.target as Node)) setShowKeys(false);
661
+ };
662
+ window.addEventListener('pointerdown', onPointer);
663
+ onCleanup(() => window.removeEventListener('pointerdown', onPointer));
664
+
665
+ // The current stop of the path stays in view.
666
+ createEffect(() => {
667
+ indexInPath();
668
+ executionPath();
669
+ queueMicrotask(() => {
670
+ const current = track?.querySelector<HTMLElement>('.sim-current');
671
+ if (!track || !current) return;
672
+ const left = current.offsetLeft - track.offsetLeft, right = left + current.offsetWidth;
673
+ if (left < track.scrollLeft + 8) track.scrollLeft = left - 24;
674
+ else if (right > track.scrollLeft + track.clientWidth - 16) track.scrollLeft = right - track.clientWidth + 40;
675
+ });
676
+ });
677
+
678
+ // The side bars leave the configuration at least this wide.
679
+ const STAGE_MIN = 320;
680
+ const workbenchWidth = () => workbench?.getBoundingClientRect().width ?? window.innerWidth;
681
+ /** A side bar's width as shown: narrow windows shrink the side bars below their remembered widths. */
682
+ const barWidth = (bar: HTMLElement | undefined) => bar?.getBoundingClientRect().width ?? 0;
683
+ const resizeLeft = (w: number) => resize('left', Math.round(Math.max(220, Math.min(w, 600, workbenchWidth() - STAGE_MIN - barWidth(rightBar)))));
684
+ const resizeRight = (w: number) => resize('right', Math.round(Math.max(280, Math.min(w, workbenchWidth() - STAGE_MIN - barWidth(leftBar)))));
685
+ const centerHeight = () => center?.getBoundingClientRect().height || 600;
686
+ const resizeBottom = (h: number) => resize('bottom', Math.max(0.15, Math.min(h / centerHeight(), 0.7)));
687
+
688
+ // Where each panel is docked, in order, and how panels sharing a dock divide it (remembered).
689
+ const [docks, setDocks] = createSignal(normalizeDocks(readStorage(DOCKS_KEY)));
690
+ const dockOf = (id: PanelId) => DOCKS.find(d => docks()[d].includes(id))!;
691
+ const movePanel = (id: PanelId, dock: Dock, index?: number) => {
692
+ setDocks(d => {
693
+ const next = { left: d.left.filter(x => x !== id), right: d.right.filter(x => x !== id), bottom: d.bottom.filter(x => x !== id) };
694
+ next[dock].splice(index ?? next[dock].length, 0, id);
695
+ writeStorage(DOCKS_KEY, next);
696
+ return next;
697
+ });
698
+ if (!panels()[id]) togglePanel(id);
699
+ };
700
+ const resetLayout = () => {
701
+ batch(() => {
702
+ setPanels({ ...DEFAULT_PANELS });
703
+ setDocks(normalizeDocks(null));
704
+ setWeights({});
705
+ setSizes({ ...DEFAULT_SIZES });
706
+ });
707
+ writeStorage(PANELS_KEY, null);
708
+ writeStorage(DOCKS_KEY, null);
709
+ writeStorage(WEIGHTS_KEY, null);
710
+ saveSizes();
711
+ };
712
+ const [weights, setWeights] = createSignal<Partial<Record<PanelId, number>>>(readStorage(WEIGHTS_KEY) ?? {});
713
+ const weightOf = (id: PanelId) => weights()[id] ?? 1;
714
+ const saveWeights = () => writeStorage(WEIGHTS_KEY, weights());
715
+ const panelElement = (id: PanelId) => workbench?.querySelector<HTMLElement>(`[data-panel="${id}"]`);
716
+ const extent = (id: PanelId, dock: Dock) => {
717
+ const box = panelElement(id)?.getBoundingClientRect();
718
+ return box ? (dock === 'bottom' ? box.width : box.height) : 0;
719
+ };
720
+ /** Gives `first` (and takes from `second`, its neighbor) so that `first` measures `size`. */
721
+ const splitPanels = (first: PanelId, second: PanelId, dock: Dock, size: number) => {
722
+ const both = extent(first, dock) + extent(second, dock);
723
+ if (both <= 0) return;
724
+ const share = Math.max(100, Math.min(size, both - 100)) / both;
725
+ const total = weightOf(first) + weightOf(second);
726
+ setWeights(w => ({ ...w, [first]: share * total, [second]: (1 - share) * total }));
727
+ };
728
+
729
+ /** Panels the current view shows: the simulation shows them all, the graph only the settings. */
730
+ const panelShown = (id: PanelId) => panels()[id] && (shownView() === 'simulation' || (shownView() === 'graph' && GRAPH_PANELS.has(id)));
731
+ const dockShown = (dock: Dock) => shownView() !== 'model' && docks()[dock].some(panelShown);
732
+
733
+ // Dragging a panel by its title bar onto a dock: the left or right side bar, or below the configuration.
734
+ type Drop = { dock: Dock; index: number };
735
+ const [dragging, setDragging] = createSignal<{ id: PanelId; x: number; y: number; drop?: Drop } | null>(null);
736
+ /** The drop zones, in page coordinates: strips on the sides, and the bottom of the center. */
737
+ const dropZones = (): Record<Dock, { left: number; top: number; width: number; height: number }> | undefined => {
738
+ const box = workbench?.getBoundingClientRect();
739
+ if (!box) return undefined;
740
+ const side = Math.min(320, box.width * 0.24);
741
+ return {
742
+ left: { left: box.left, top: box.top, width: side, height: box.height },
743
+ right: { left: box.right - side, top: box.top, width: side, height: box.height },
744
+ bottom: { left: box.left + side, top: box.top + box.height * 0.5, width: box.width - 2 * side, height: box.height * 0.5 },
745
+ };
746
+ };
747
+ const dropAt = (id: PanelId, x: number, y: number): Drop | undefined => {
748
+ const zones = dropZones();
749
+ const dock = zones && DOCKS.find(d => x >= zones[d].left && x <= zones[d].left + zones[d].width && y >= zones[d].top && y <= zones[d].top + zones[d].height);
750
+ if (!dock) return undefined;
751
+ // Before the first panel of the dock whose middle is past the pointer.
752
+ const others = docks()[dock].filter(p => p !== id);
753
+ const before = others.find(p => {
754
+ const box = panelShown(p) ? panelElement(p)?.getBoundingClientRect() : undefined;
755
+ return box && (dock === 'bottom' ? x < box.left + box.width / 2 : y < box.top + box.height / 2);
756
+ });
757
+ return { dock, index: before ? others.indexOf(before) : others.length };
758
+ };
759
+ const startPanelDrag = (id: PanelId, e: PointerEvent) => {
760
+ if (e.button !== 0 || (e.target as HTMLElement).closest('button, input, select, a')) return;
761
+ e.preventDefault();
762
+ const from = { x: e.clientX, y: e.clientY };
763
+ const move = (ev: PointerEvent) => {
764
+ if (!dragging() && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 6) return;
765
+ document.documentElement.classList.add('sim-dragging-panel');
766
+ setDragging({ id, x: ev.clientX, y: ev.clientY, drop: dropAt(id, ev.clientX, ev.clientY) });
767
+ };
768
+ const end = (ev: PointerEvent) => {
769
+ window.removeEventListener('pointermove', move);
770
+ window.removeEventListener('pointerup', end);
771
+ window.removeEventListener('pointercancel', end);
772
+ document.documentElement.classList.remove('sim-dragging-panel');
773
+ const drop = ev.type === 'pointerup' ? dragging()?.drop : undefined;
774
+ setDragging(null);
775
+ if (drop) movePanel(id, drop.dock, drop.index);
776
+ };
777
+ window.addEventListener('pointermove', move);
778
+ window.addEventListener('pointerup', end);
779
+ window.addEventListener('pointercancel', end);
780
+ };
395
781
 
396
782
  // Settings
397
783
  const [savedSettings, setSavedSettings] = createSignal<SavedSetting[]>([]);
@@ -416,6 +802,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
416
802
  window.prompt('Copy this link:', url);
417
803
  }
418
804
  };
805
+ const share = () => copy(`${shareLink(effectiveSettings(), currentPath())}${VIEW_HASH[shownView()]}`);
419
806
 
420
807
  onMount(() => {
421
808
  const shared = new URLSearchParams(window.location.search).get('shared');
@@ -443,7 +830,13 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
443
830
  }
444
831
  });
445
832
 
446
- const modelShown = () => view() === 'model' && !!description();
833
+ const hasGraph = () => !!Projects[project()].executionStatusChecker;
834
+ /** The view shown: the one asked for, when this project has it. */
835
+ const shownView = (): View => view() === 'model' && description() ? 'model' : view() === 'graph' && hasGraph() ? 'graph' : 'simulation';
836
+ // The graph stays mounted once opened, so that switching views keeps it.
837
+ const [graphOpened, setGraphOpened] = createSignal(false);
838
+ createEffect(() => { if (shownView() === 'graph') setGraphOpened(true); });
839
+
447
840
 
448
841
  const chooseProject = (name: string) => batch(() => {
449
842
  setProjectUserSettings(null);
@@ -451,200 +844,475 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
451
844
  setProject(name);
452
845
  });
453
846
 
847
+ const branchIndex = () => currentPath().at(-1)!;
848
+
849
+ /** Each panel's content; the docks place them. Moving a panel to another dock recreates it. */
850
+ const head = (id: PanelId) => ({ dock: dockOf(id), onMove: (dock: Dock) => movePanel(id, dock), onClose: () => togglePanel(id), onDragStart: (e: PointerEvent) => startPanelDrag(id, e) });
851
+ const panelContent: Record<PanelId, () => JSX.Element> = {
852
+ settings: () => (
853
+ <section class="sim-panel sim-settings">
854
+ <PanelHead icon="sliders" title="Settings" {...head('settings')}>
855
+ <button class="sim-ghost sim-sm" onClick={() => batch(() => { setProjectUserSettings(null); goTo([0]); })} disabled={!projectUserSettings()} title="Reset the settings to the experiment's defaults">
856
+ <Icon name="reset" size={14} /> <span class="sim-btn-label">Reset</span>
857
+ </button>
858
+ </PanelHead>
859
+ <div class="sim-panel-body">
860
+ <Show when={editorSchema()} keyed>
861
+ {schema => (
862
+ <GenericEditor
863
+ schema={schema}
864
+ value={projectUserSettings()}
865
+ onInput={(v: any) => batch(() => { setProjectUserSettings(v); goTo([0]); })}
866
+ />
867
+ )}
868
+ </Show>
869
+ <Show when={setupLog()}>
870
+ <div class="sim-feedback sim-settings-error" role="alert">
871
+ <Icon name="alert" />
872
+ <div>
873
+ <strong>These settings do not start an execution</strong>
874
+ <mark>{setupLog()}</mark>
875
+ </div>
876
+ </div>
877
+ </Show>
878
+ <Show when={!setupLog() && settingsWarnings().length > 0}>
879
+ <div class="sim-feedback sim-settings-warning" role="alert">
880
+ <Icon name="alert" />
881
+ <div>
882
+ <strong>Initial settings warning</strong>
883
+ <For each={settingsWarnings()}>{warning => <span>{warning}</span>}</For>
884
+ <span class="sim-muted">You can still explore this execution. Checks over the declared domains do not cover settings outside them.</span>
885
+ </div>
886
+ </div>
887
+ </Show>
888
+ </div>
889
+ </section>
890
+ ),
891
+ library: () => (
892
+ <section class="sim-panel sim-library-panel">
893
+ <PanelHead icon="bookmark" title="Saved settings" {...head('library')} />
894
+ <div class="sim-panel-body">
895
+ <SettingsLibrary
896
+ presets={predefinedSettings()}
897
+ saved={savedSettings()}
898
+ current={effectiveSettings()}
899
+ onLoad={loadSettings}
900
+ onShare={s => copy(shareLink(loadedSettings(project(), s.settings), s.path))}
901
+ onDelete={name => {
902
+ deleteSettings(storageKey, project(), name);
903
+ setSavedSettings(getSavedSettings(storageKey, project()));
904
+ }}
905
+ onSave={name => {
906
+ saveSettings(storageKey, project(), name, effectiveSettings(), currentPath());
907
+ setSavedSettings(getSavedSettings(storageKey, project()));
908
+ notify(`Saved "${name}"`);
909
+ }}
910
+ />
911
+ </div>
912
+ </section>
913
+ ),
914
+ algorithm: () => (
915
+ <Show when={config() && algorithmViewInfo()} fallback={
916
+ <section class="sim-panel">
917
+ <PanelHead icon="file" title="Algorithm" {...head('algorithm')} />
918
+ <div class="sim-stage-empty"><Icon name="file" size={28} /><span>No algorithm to show.</span></div>
919
+ </section>
920
+ }>
921
+ {info => (
922
+ <AlgorithmViewer
923
+ source={info().source}
924
+ agents={info().agents}
925
+ onHeadPointerDown={e => startPanelDrag('algorithm', e)}
926
+ highlighted={inspected()?.agents.find(a => a.id === selectedAgent())?.name ?? null}
927
+ onSelectAgent={name => setSelectedAgent(inspected()?.agents.find(a => a.name === name)?.id ?? null)}
928
+ actions={<PanelActions title="Algorithm" {...head('algorithm')} />}
929
+ />
930
+ )}
931
+ </Show>
932
+ ),
933
+ state: () => (
934
+ <section class="sim-panel sim-inspector-panel">
935
+ <PanelHead icon="inspect" title="State" sub="world, agents and parameters" {...head('state')} />
936
+ <div class="sim-panel-body">
937
+ <Show when={inspected()} fallback={<p class="sim-muted">No state to inspect.</p>}>
938
+ {state => <StateInspector state={state()} selected={selectedAgent()} onSelect={setSelectedAgent} onClearSelection={() => setSelectedAgent(null)} />}
939
+ </Show>
940
+ </div>
941
+ </section>
942
+ ),
943
+ };
944
+
945
+ /** A dock's panels in order, the hidden ones kept mounted, with splitters between neighbors that share the space. */
946
+ const DockPanels = (p: { dock: Dock }) => (
947
+ <For each={docks()[p.dock]}>
948
+ {(id, i) => {
949
+ const fills = (x: PanelId) => p.dock === 'bottom' || !COMPACT_PANELS.has(x);
950
+ const previous = () => docks()[p.dock].slice(0, i()).filter(panelShown).at(-1);
951
+ return (
952
+ <>
953
+ <Show when={panelShown(id) && previous() && fills(id) && fills(previous()!) && previous()} keyed>
954
+ {prev => (
955
+ <Splitter
956
+ orientation={p.dock === 'bottom' ? 'vertical' : 'horizontal'}
957
+ value={extent(prev, p.dock)}
958
+ onChange={size => splitPanels(prev, id, p.dock, size)}
959
+ onCommit={saveWeights}
960
+ onReset={() => { setWeights(w => ({ ...w, [prev]: 1, [id]: 1 })); saveWeights(); }}
961
+ label={`Resize ${PANEL_TITLE[prev].toLowerCase()} and ${PANEL_TITLE[id].toLowerCase()}`}
962
+ />
963
+ )}
964
+ </Show>
965
+ <div
966
+ class={`sim-slot${dragging()?.id === id ? ' sim-slot-dragged' : ''}`}
967
+ data-panel={id}
968
+ hidden={!panelShown(id)}
969
+ // Grow factors below 1 would leave part of the dock empty (a lone panel of weight 0.6
970
+ // takes 60% of it): scaled up, they keep their ratios and always fill the dock.
971
+ style={{ flex: fills(id) ? `${weightOf(id) * 100} 1 0` : '0 1 auto' }}
972
+ >
973
+ {panelContent[id]()}
974
+ </div>
975
+ </>
976
+ );
977
+ }}
978
+ </For>
979
+ );
980
+
454
981
  return (
455
- <div class="sim-app">
456
- <header class="sim-header">
457
- <h1>{props.title ?? 'Simulant'}</h1>
458
- <select value={project()} onChange={(e) => chooseProject(e.currentTarget.value)} aria-label="Simulation">
459
- <For each={projectKeys}>{(p) => <option value={p}>{p}</option>}</For>
460
- </select>
461
- <Show when={description()}>
462
- <div class="sim-tabs" role="tablist">
463
- <button role="tab" aria-selected={view() === 'simulation'} class={view() === 'simulation' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => showView('simulation')}>Simulation</button>
464
- <button role="tab" aria-selected={view() === 'model'} class={view() === 'model' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => showView('model')} title="The model and the experiment, as defined">Model</button>
465
- </div>
466
- </Show>
982
+ <div class={`sim-app sim-view-${shownView()}`}>
983
+ <header class="sim-topbar">
984
+ <div class="sim-brand">
985
+ <Logo />
986
+ <span class="sim-brand-name">Simulant</span>
987
+ <span class="sim-brand-sep">/</span>
988
+ <h1>{props.title ?? 'Simulator'}</h1>
989
+ </div>
990
+ <div class="sim-project">
991
+ <select value={project()} onChange={(e) => chooseProject(e.currentTarget.value)} aria-label="Simulation" title="Simulation">
992
+ <For each={projectKeys}>{(p) => <option value={p}>{p}</option>}</For>
993
+ </select>
994
+ </div>
995
+ <div class="sim-tabs" role="tablist" aria-label="View">
996
+ <button role="tab" aria-selected={shownView() === 'simulation'} class={shownView() === 'simulation' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => showView('simulation')} title="Step through an execution">
997
+ <Icon name="play" size={13} /> <span class="sim-tab-label">Simulation</span>
998
+ </button>
999
+ <Show when={hasGraph()}>
1000
+ <button role="tab" aria-selected={shownView() === 'graph'} class={shownView() === 'graph' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => showView('graph')} title="Every execution from these settings, round by round">
1001
+ <Icon name="graph" size={13} /> <span class="sim-tab-label">Graph</span>
1002
+ </button>
1003
+ </Show>
1004
+ <Show when={description()}>
1005
+ <button role="tab" aria-selected={shownView() === 'model'} class={shownView() === 'model' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => showView('model')} title="The model and the experiment, as defined">
1006
+ <Icon name="layers" size={13} /> <span class="sim-tab-label">Model</span>
1007
+ </button>
1008
+ </Show>
1009
+ </div>
467
1010
  <span class="sim-spacer" />
468
- <button class="sim-ghost theme-toggle" onClick={toggleTheme} title={dark() ? 'Light theme' : 'Dark theme'}>
469
- {dark() ? '☀' : '🌙'}
470
- </button>
1011
+ <div class="sim-topbar-actions">
1012
+ <button class="sim-btn" onClick={share} title={shownView() === 'simulation' ? 'Copy a link to these settings and this path' : 'Copy a link that opens this view'}>
1013
+ <Icon name="link" /> <span class="sim-share-label">Share</span>
1014
+ </button>
1015
+ <div class="sim-popover-anchor" ref={shortcuts}>
1016
+ <button class="sim-ghost sim-icon" onClick={() => setShowKeys(v => !v)} aria-label="Keyboard shortcuts" aria-expanded={showKeys()} title="Keyboard shortcuts (?)">
1017
+ <Icon name="keyboard" />
1018
+ </button>
1019
+ <Show when={showKeys()}>
1020
+ <div class="sim-shortcuts" role="dialog" aria-label="Keyboard shortcuts">
1021
+ <h4>Keyboard shortcuts</h4>
1022
+ <dl>
1023
+ <dt><kbd>→</kbd></dt><dd>Next step</dd>
1024
+ <dt><kbd>←</kbd></dt><dd>Previous step</dd>
1025
+ <dt><kbd>Home</kbd><kbd>End</kbd></dt><dd>First / last step of the path</dd>
1026
+ <dt><kbd>Space</kbd></dt><dd>Play / pause</dd>
1027
+ <dt><kbd>R</kbd></dt><dd>Random or first branch for new steps</dd>
1028
+ <dt><kbd>Esc</kbd></dt><dd>Clear the selected agent</dd>
1029
+ <dt><kbd>?</kbd></dt><dd>Show these shortcuts</dd>
1030
+ </dl>
1031
+ </div>
1032
+ </Show>
1033
+ </div>
1034
+ <button class="sim-ghost sim-icon theme-toggle" onClick={toggleTheme} aria-label={dark() ? 'Light theme' : 'Dark theme'} title={dark() ? 'Light theme' : 'Dark theme'}>
1035
+ <Icon name={dark() ? 'sun' : 'moon'} />
1036
+ </button>
1037
+ </div>
471
1038
  </header>
472
1039
 
473
1040
  <Show when={updateNotice()}>
474
1041
  {notice => (
475
- <pre class={`sim-notice${notice().outOfDate ? ' sim-error' : ''}`}>
476
- {notice().outOfDate ? 'Out of date: the sources do not compile; showing the last version that did.\n' : 'Sources updated; the execution restarted.\n'}
477
- {notice().text}
478
- </pre>
1042
+ <div class={`sim-banner${notice().outOfDate ? ' sim-error' : ''}`} role="status">
1043
+ <Icon name={notice().outOfDate ? 'alert' : 'info'} />
1044
+ <div class="sim-banner-body">
1045
+ <strong>{notice().outOfDate ? 'Out of date: the sources do not compile; showing the last version that did.' : 'Sources updated; the execution restarted.'}</strong>
1046
+ <Show when={notice().text}><pre>{notice().text}</pre></Show>
1047
+ </div>
1048
+ <Show when={!notice().outOfDate}>
1049
+ <button class="sim-ghost sim-icon sim-sm" onClick={() => setUpdateNotice(null)} aria-label="Dismiss"><Icon name="close" size={14} /></button>
1050
+ </Show>
1051
+ </div>
479
1052
  )}
480
1053
  </Show>
481
- <Show when={modelShown() && description()}>
482
- {d => <ModelView description={d()} simulation={project()} onCopyLink={() => copy(`${shareLink(effectiveSettings(), currentPath())}#model`)} />}
483
- </Show>
484
1054
 
485
- <div class="sim-simulation" style={{ display: modelShown() ? 'none' : undefined }}>
486
- <Show when={description()}>
487
- {d => (
488
- <button class="sim-model-strip" onClick={() => showView('model')} title="Show the model and the experiment as defined">
489
- <span class="sim-model-strip-label">Model</span>
490
- <For each={d().summary}>{c => <span class="sim-model-strip-item">{c}</span>}</For>
491
- <span class="sim-model-strip-more">details →</span>
492
- </button>
493
- )}
494
- </Show>
1055
+ <div class="sim-shell" hidden={shownView() === 'model'}>
1056
+ <nav class="sim-activity" aria-label="Panels">
1057
+ <ActivityButton icon="sliders" label="Settings" active={panels().settings} onClick={() => togglePanel('settings')} />
1058
+ <ActivityButton icon="bookmark" label="Saved settings" active={panels().library} onClick={() => togglePanel('library')} />
1059
+ <Show when={shownView() === 'simulation'}>
1060
+ <span class="sim-activity-sep" />
1061
+ <ActivityButton icon="file" label="Algorithm" active={panels().algorithm} onClick={() => togglePanel('algorithm')} />
1062
+ <ActivityButton icon="inspect" label="State" active={panels().state} onClick={() => togglePanel('state')} />
1063
+ </Show>
1064
+ <span class="sim-spacer" />
1065
+ <ActivityButton icon="dock" label="Reset the panel layout" active={false} onClick={resetLayout} />
1066
+ <Show when={hasGraph()}>
1067
+ <ActivityButton
1068
+ icon="graph"
1069
+ label={shownView() === 'graph' ? 'Back to the simulation' : 'Configuration graph'}
1070
+ active={shownView() === 'graph'}
1071
+ onClick={() => showView(shownView() === 'graph' ? 'simulation' : 'graph')}
1072
+ />
1073
+ </Show>
1074
+ </nav>
495
1075
 
496
- <section class="sim-card">
497
- <div class="sim-toolbar">
498
- <div class="sim-group">
499
- <button class="sim-ghost sim-icon" onClick={() => setIndexInPath(0)} disabled={indexInPath() === 0 || !canExplore()} title="First step">«</button>
500
- <button onClick={onPrevious} disabled={indexInPath() === 0 || !canExplore()} title="Previous step (←)">Previous {'<'}</button>
501
- <span class="sim-round">Round {indexInPath()}</span>
502
- <button onClick={onNext} disabled={!canExplore() || (indexInPath() === executionPath().length - 1 && childCount() === 0)} title="Next step (→)">{'>'} Next</button>
503
- <button class="sim-ghost sim-icon" onClick={() => setIndexInPath(executionPath().length - 1)} disabled={indexInPath() === executionPath().length - 1 || !canExplore()} title="Last step of the path">»</button>
504
- </div>
505
- <Show when={verdict()}>
506
- {v => <span class={`sim-chip sim-${v().status}`} title={v().title}>task: {v().text}</span>}
507
- </Show>
508
- <Show when={config() && childCount() === 0}>
509
- <span class="sim-chip" title="No successor: the execution ends here">end of execution</span>
510
- </Show>
511
- <Show when={branches().length > 1}>
512
- <span class="sim-divider" />
513
- <div class="sim-group sim-choices">
514
- <button class="sim-ghost sim-icon" onClick={() => setChoice((currentPath().at(-1)! + branches().length - 1) % branches().length)} disabled={!canExplore()} title="Previous branch">‹</button>
515
- <span class="sim-branch">Branch {currentPath().at(-1)} ∈ [0, {branches().length - 1}]</span>
516
- <button class="sim-ghost sim-icon" onClick={() => setChoice((currentPath().at(-1)! + 1) % branches().length)} disabled={!canExplore()} title="Next branch">›</button>
517
- <span class="sim-step-label sim-branch-label" title={branches()[currentPath().at(-1)!]?.label}>{branches()[currentPath().at(-1)!]?.short}</span>
518
- <button class="sim-ghost" onClick={() => setPickingBranch(true)} disabled={!canExplore()} title="See every branch of this step and pick one">All branches</button>
1076
+ <div class="sim-main">
1077
+ <div class="sim-transport-bar" hidden={shownView() !== 'simulation'}>
1078
+ <section class="sim-transport" aria-label="Execution">
1079
+ <div class="sim-transport-row">
1080
+ <div class="sim-group">
1081
+ <button class="sim-ghost sim-icon" onClick={() => step(0)} disabled={indexInPath() === 0 || !canExplore()} aria-label="First step" title="First step (Home)"><Icon name="first" /></button>
1082
+ <button class="sim-btn sim-icon" onClick={onPrevious} disabled={indexInPath() === 0 || !canExplore()} aria-label="Previous step" title="Previous step (←)"><Icon name="previous" /></button>
1083
+ <div class="sim-round" title={`Round ${indexInPath()} of the path's ${executionPath().length - 1}`}>
1084
+ <span class="sim-round-label">Round</span>
1085
+ <span class="sim-round-value">{indexInPath()}</span>
1086
+ <span class="sim-round-total">/ {executionPath().length - 1}</span>
1087
+ </div>
1088
+ <button class="sim-btn sim-icon" onClick={() => { setPlaying(false); onNext(); }} disabled={!canExplore() || atEnd()} aria-label="Next step" title="Next step (→)"><Icon name="next" /></button>
1089
+ <button class="sim-ghost sim-icon" onClick={() => step(executionPath().length - 1)} disabled={indexInPath() === executionPath().length - 1 || !canExplore()} aria-label="Last step" title="Last step of the path (End)"><Icon name="last" /></button>
1090
+ </div>
1091
+ <span class="sim-divider" />
1092
+ <div class="sim-group">
1093
+ <button class={`${playing() ? 'sim-btn' : 'sim-primary'} sim-play`} onClick={togglePlay} disabled={!playing() && (!canExplore() || atEnd())} aria-label={playing() ? 'Pause' : 'Play'} title={playing() ? 'Pause (Space)' : 'Play: step forward until the execution ends (Space)'}>
1094
+ <Icon name={playing() ? 'pause' : 'play'} />
1095
+ </button>
1096
+ <label class="sim-speed" title="Playback speed">
1097
+ <select value={speed()} onChange={e => setSpeed(Number(e.currentTarget.value))} aria-label="Playback speed">
1098
+ <For each={SPEEDS}>{s => <option value={s}>{s} step{s > 1 ? 's' : ''}/s</option>}</For>
1099
+ </select>
1100
+ </label>
1101
+ </div>
1102
+ <span class="sim-divider" />
1103
+ <div class="sim-group">
1104
+ <Show when={verdict()}>
1105
+ {v => <span class={`sim-chip sim-verdict sim-${v().status}`} title={v().title}>Task: {v().text}</span>}
1106
+ </Show>
1107
+ <Show when={config() && childCount() === 0}>
1108
+ <span class="sim-chip sim-verdict" title="No successor: the execution ends here">End of execution</span>
1109
+ </Show>
1110
+ </div>
1111
+ <span class="sim-divider" />
1112
+ <div class="sim-group sim-branches">
1113
+ <button
1114
+ class={`sim-ghost sim-icon sim-toggle${randomBranch() ? ' sim-toggle-on' : ''}`}
1115
+ onClick={toggleRandomBranch}
1116
+ aria-pressed={randomBranch()}
1117
+ aria-label="Random branch for new steps"
1118
+ title={randomBranch()
1119
+ ? 'New steps take a random branch (R): click to take the first branch'
1120
+ : 'New steps take the first branch (R): click to take a random branch'}
1121
+ >
1122
+ <Icon name="dice" />
1123
+ </button>
1124
+ <Show when={branches().length > 1}>
1125
+ <Icon name="branch" />
1126
+ <span class="sim-branch">Branch {branchIndex()} <span class="sim-branch-of">of {branches().length}</span></span>
1127
+ <button class="sim-ghost sim-icon sim-sm" onClick={() => setChoice((branchIndex() + branches().length - 1) % branches().length)} disabled={!canExplore()} aria-label="Previous branch" title="Previous branch"><Icon name="previous" size={14} /></button>
1128
+ <button class="sim-ghost sim-icon sim-sm" onClick={() => setChoice((branchIndex() + 1) % branches().length)} disabled={!canExplore()} aria-label="Next branch" title="Next branch"><Icon name="next" size={14} /></button>
1129
+ <span class="sim-step-label sim-branch-label" title={branches()[branchIndex()]?.label}>{branches()[branchIndex()]?.short || '(first options)'}</span>
1130
+ <button class="sim-btn sim-sm" onClick={() => setPickingBranch(true)} disabled={!canExplore()} title="See every branch of this step and pick one">
1131
+ <Icon name="grid" size={14} /> All branches
1132
+ </button>
1133
+ </Show>
1134
+ </div>
519
1135
  </div>
520
- </Show>
521
- <span class="sim-spacer" style={{ flex: 1 }} />
522
- <button class="sim-ghost" onClick={() => copy(shareLink(effectiveSettings(), currentPath()))} title="Copy a link to these settings and this path">Share</button>
1136
+ <div class="sim-timeline">
1137
+ <span class="sim-timeline-label">Path</span>
1138
+ <div class="sim-track" ref={track}>
1139
+ <For each={executionPath()}>
1140
+ {(choice, i) => (
1141
+ <>
1142
+ <Show when={i() > 0}><span class="sim-track-sep" /></Show>
1143
+ <button
1144
+ class={`sim-step${i() === indexInPath() ? ' sim-current' : ''}${i() > indexInPath() ? ' sim-future' : ''}`}
1145
+ data-verdict={pathVerdicts()[i()]}
1146
+ onClick={() => step(i())}
1147
+ title={`Round ${i()}: ${stepLabel(i())}${pathVerdicts()[i()] ? ` (task: ${VERDICT_TEXT[pathVerdicts()[i()]]})` : ''}`}
1148
+ >
1149
+ {choice}
1150
+ </button>
1151
+ </>
1152
+ )}
1153
+ </For>
1154
+ </div>
1155
+ <Show when={indexInPath() > 0 && stepLabel(indexInPath())}>
1156
+ <span class="sim-step-label" title={stepLabel(indexInPath())}>last step: {stepLabel(indexInPath())}</span>
1157
+ </Show>
1158
+ </div>
1159
+ </section>
523
1160
  </div>
524
- <div class="sim-path">
525
- <span class="sim-path-label">Path</span>
526
- <For each={executionPath()}>
527
- {(step, i) => (
528
- <button
529
- class={`sim-step${i() === indexInPath() ? ' sim-current' : ''}${i() > indexInPath() ? ' sim-future' : ''}`}
530
- onClick={() => setIndexInPath(i())}
531
- title={`Round ${i()}: ${stepLabel(i())}`}
532
- >
533
- {step}
534
- </button>
535
- )}
536
- </For>
537
- <Show when={indexInPath() > 0 && stepLabel(indexInPath())}>
538
- <span class="sim-step-label">last step: {stepLabel(indexInPath())}</span>
1161
+
1162
+ <div class="sim-workbench" ref={workbench}>
1163
+ <aside class="sim-sidebar sim-dock" ref={leftBar} hidden={!dockShown('left')} style={{ 'flex-basis': `${sizes().left}px` }} aria-label={DOCK_LABEL.left}>
1164
+ <DockPanels dock="left" />
1165
+ </aside>
1166
+ <Show when={dockShown('left')}>
1167
+ <Splitter orientation="vertical" value={barWidth(leftBar)} onChange={resizeLeft} onCommit={saveSizes} onReset={() => resetSize('left')} label="Resize the left side bar" />
539
1168
  </Show>
540
- </div>
541
- </section>
542
1169
 
543
- <div class="sim-main">
544
- <div class="sim-left">
545
- <canvas
546
- ref={canvas}
547
- class="sim-canvas"
548
- style={{ width: `${CANVAS.width}px`, height: `${CANVAS.height}px` }}
549
- onClick={handleCanvasClick}
550
- />
551
- <Show when={inspected()}>
552
- {state => <StateInspector state={state()} selected={selectedAgent()?.agentId ?? null} onClearSelection={() => setSelectedAgent(null)} />}
1170
+ <main class="sim-center" ref={center}>
1171
+ <section class="sim-panel sim-stage-panel" hidden={shownView() !== 'simulation'}>
1172
+ <PanelHead icon="ring" title="Configuration" sub={`round ${indexInPath()} · ${settingsSummary(effectiveSettings())}`} />
1173
+ <div class="sim-stage" ref={stage}>
1174
+ <canvas
1175
+ ref={canvas}
1176
+ class={`sim-canvas${hover() ? ' sim-over-agent' : ''}`}
1177
+ style={{ width: `${stageSize().w}px`, height: `${stageSize().h}px` }}
1178
+ onClick={handleCanvasClick}
1179
+ onMouseMove={e => setHover(hitAt(e))}
1180
+ onMouseLeave={() => setHover(undefined)}
1181
+ />
1182
+ <Show when={hovered()}>
1183
+ {h => (
1184
+ <div class="sim-stage-tip" style={{ left: `${h().at.x}px`, top: `${h().at.y}px` }}>
1185
+ <strong>{h().name}</strong>
1186
+ <Show when={h().agent}>{a => <span> · node {String(a().value.position)}{a().status !== 'correct' ? ` · ${a().status === 'hidden' ? 'gone' : 'faulty'}` : ''}</span>}</Show>
1187
+ </div>
1188
+ )}
1189
+ </Show>
1190
+ <Show when={!config()}>
1191
+ <div class="sim-stage-empty">
1192
+ <Icon name="ring" size={28} />
1193
+ <span>No configuration to show.</span>
1194
+ <span class="sim-muted">The current settings do not start an execution: see the settings below.</span>
1195
+ </div>
1196
+ </Show>
1197
+ <Show when={config() && Projects[project()].getAgentsRenderInfo}>
1198
+ <div class="sim-stage-overlay">
1199
+ <span class="sim-stage-badge">
1200
+ {selectedAgent() !== null
1201
+ ? `${inspected()?.agents.find(a => a.id === selectedAgent())?.name ?? 'Agent'} selected · Esc to clear`
1202
+ : 'Click an agent to inspect it'}
1203
+ </span>
1204
+ </div>
1205
+ </Show>
1206
+ </div>
1207
+ </section>
1208
+ <Show when={graphOpened() && hasGraph()}>
1209
+ <section class="sim-panel sim-graph-panel" hidden={shownView() !== 'graph'}>
1210
+ <PanelHead icon="graph" title="Configuration graph" sub="every execution from these settings, round by round">
1211
+ <button class="sim-ghost sim-sm" onClick={() => showView('simulation')}><Icon name="previous" size={14} /> Back to the simulation</button>
1212
+ </PanelHead>
1213
+ <Show when={!updateNotice()?.outOfDate && projectRevision() + 1} keyed fallback={
1214
+ <div class="sim-stage-empty"><Icon name="alert" size={28} /><span>The sources do not compile: the graph waits until they do.</span></div>
1215
+ }>
1216
+ <ConfigGraphGenerator
1217
+ revision={Projects[project()].revision?.()}
1218
+ settings={effectiveSettings()}
1219
+ setup={Projects[project()].setup}
1220
+ execute={Projects[project()].execute}
1221
+ executionStatusChecker={Projects[project()].executionStatusChecker!}
1222
+ drawConfig={Projects[project()].drawConfig}
1223
+ projectName={project()}
1224
+ createWorker={props.createGraphWorker}
1225
+ currentPath={currentPath()}
1226
+ theme={drawTheme()}
1227
+ onOpen={(settings, path) => batch(() => {
1228
+ setProjectUserSettings(settings);
1229
+ goTo(path, path.length - 1);
1230
+ showView('simulation');
1231
+ })}
1232
+ configRadius={50}
1233
+ horizontalSpacing={140}
1234
+ verticalSpacing={150}
1235
+ />
1236
+ </Show>
1237
+ </section>
1238
+ </Show>
1239
+ <Show when={dockShown('bottom')}>
1240
+ <Splitter orientation="horizontal" direction={-1} value={sizes().bottom * centerHeight()} onChange={resizeBottom} onCommit={saveSizes} onReset={() => resetSize('bottom')} label="Resize the panels below the configuration" />
1241
+ </Show>
1242
+ <div class="sim-dock sim-dock-bottom" hidden={!dockShown('bottom')} style={{ flex: `0 0 ${sizes().bottom * 100}%` }} aria-label={DOCK_LABEL.bottom}>
1243
+ <DockPanels dock="bottom" />
1244
+ </div>
1245
+ </main>
1246
+
1247
+ <Show when={dockShown('right')}>
1248
+ <Splitter orientation="vertical" direction={-1} value={barWidth(rightBar)} onChange={resizeRight} onCommit={saveSizes} onReset={() => resetSize('right')} label="Resize the right side bar" />
553
1249
  </Show>
554
- </div>
555
- <Show when={config() && algorithmViewInfo()}>
556
- {info => <AlgorithmViewer source={info().source} agents={info().agents} />}
557
- </Show>
558
- </div>
1250
+ <aside class="sim-sidebar sim-dock" ref={rightBar} hidden={!dockShown('right')} style={{ 'flex-basis': `${sizes().right}px` }} aria-label={DOCK_LABEL.right}>
1251
+ <DockPanels dock="right" />
1252
+ </aside>
559
1253
 
560
- <div class="sim-panels">
561
- <section class="sim-card sim-settings">
562
- <h3>
563
- Settings
564
- <span class="sim-spacer" style={{ flex: 1 }} />
565
- <button class="sim-ghost" onClick={() => batch(() => { setProjectUserSettings(null); goTo([0]); })} disabled={!projectUserSettings()}>Reset to defaults</button>
566
- </h3>
567
- <Show when={editorSchema()} keyed>
568
- {schema => (
569
- <GenericEditor
570
- schema={schema}
571
- value={projectUserSettings()}
572
- onInput={(v: any) => batch(() => { setProjectUserSettings(v); goTo([0]); })}
573
- />
1254
+ <Show when={dragging()}>
1255
+ {d => (
1256
+ <>
1257
+ <div class="sim-dropzones" aria-hidden="true">
1258
+ <For each={DOCKS}>
1259
+ {dock => {
1260
+ const zone = () => {
1261
+ const z = dropZones()?.[dock], box = workbench?.getBoundingClientRect();
1262
+ return z && box ? { left: `${z.left - box.left}px`, top: `${z.top - box.top}px`, width: `${z.width}px`, height: `${z.height}px` } : {};
1263
+ };
1264
+ return (
1265
+ <div class={`sim-dropzone${d().drop?.dock === dock ? ' sim-dropzone-active' : ''}`} style={zone()}>
1266
+ <Icon name={DOCK_ICON[dock]} size={20} />
1267
+ <span>{DOCK_LABEL[dock]}</span>
1268
+ </div>
1269
+ );
1270
+ }}
1271
+ </For>
1272
+ </div>
1273
+ <div class="sim-drag-ghost" style={{ left: `${d().x}px`, top: `${d().y}px` }}>
1274
+ <Icon name="grip" size={14} /> {PANEL_TITLE[d().id]}
1275
+ </div>
1276
+ </>
574
1277
  )}
575
1278
  </Show>
576
- <Show when={setupLog()}>
577
- <div class="sim-notice sim-error sim-settings-feedback" role="alert"><mark>{setupLog()}</mark></div>
578
- </Show>
579
- <Show when={!setupLog() && settingsWarnings().length > 0}>
580
- <div class="sim-notice sim-settings-warning sim-settings-feedback" role="alert">
581
- <strong>Initial settings warning</strong>
582
- <For each={settingsWarnings()}>{warning => <div>{warning}</div>}</For>
583
- <div>You can still explore this execution. Checks over the declared domains do not cover settings outside them.</div>
584
- </div>
585
- </Show>
586
- </section>
587
- <section class="sim-card">
588
- <h3>Saved settings</h3>
589
- <SettingsLibrary
590
- presets={predefinedSettings()}
591
- saved={savedSettings()}
592
- current={effectiveSettings()}
593
- onLoad={loadSettings}
594
- onShare={s => copy(shareLink(loadedSettings(project(), s.settings), s.path))}
595
- onDelete={name => {
596
- deleteSettings(storageKey, project(), name);
597
- setSavedSettings(getSavedSettings(storageKey, project()));
598
- }}
599
- onSave={name => {
600
- saveSettings(storageKey, project(), name, effectiveSettings(), currentPath());
601
- setSavedSettings(getSavedSettings(storageKey, project()));
602
- notify(`Saved "${name}"`);
603
- }}
604
- />
605
- </section>
1279
+ </div>
606
1280
  </div>
1281
+ </div>
607
1282
 
608
- <Show when={Projects[project()].executionStatusChecker}>
609
- <section class="sim-card sim-section">
610
- <h3>
611
- Configuration graph
612
- <span class="sim-muted">every execution from these settings, round by round</span>
613
- <span class="sim-spacer" style={{ flex: 1 }} />
614
- <button class={showConfigGraph() ? 'sim-ghost' : ''} onClick={() => setShowConfigGraph(v => !v)}>
615
- {showConfigGraph() ? 'Hide Configuration Graph' : 'Show Configuration Graph'}
616
- </button>
617
- </h3>
618
- <Show when={showConfigGraph() && !updateNotice()?.outOfDate && projectRevision() + 1} keyed>
619
- <ConfigGraphGenerator
620
- revision={Projects[project()].revision?.()}
621
- settings={effectiveSettings()}
622
- setup={Projects[project()].setup}
623
- execute={Projects[project()].execute}
624
- executionStatusChecker={Projects[project()].executionStatusChecker!}
625
- drawConfig={Projects[project()].drawConfig}
626
- projectName={project()}
627
- createWorker={props.createGraphWorker}
628
- currentPath={currentPath()}
629
- onOpen={(settings, path) => batch(() => {
630
- setProjectUserSettings(settings);
631
- goTo(path, path.length - 1);
632
- window.scrollTo({ top: 0, behavior: 'smooth' });
633
- })}
634
- configRadius={50}
635
- horizontalSpacing={140}
636
- verticalSpacing={150}
637
- />
638
- </Show>
639
- </section>
640
- </Show>
1283
+ <Show when={shownView() === 'model' && description()}>
1284
+ {d => (
1285
+ <div class="sim-model-page">
1286
+ <ModelView description={d()} simulation={project()} onCopyLink={() => copy(`${shareLink(effectiveSettings(), currentPath())}#model`)} />
1287
+ </div>
1288
+ )}
1289
+ </Show>
641
1290
 
642
- </div>
1291
+ <footer class="sim-statusbar">
1292
+ <span class="sim-status-item" title={updateNotice()?.outOfDate ? 'The sources do not compile' : 'Up to date'}>
1293
+ <span class={`sim-status-dot${updateNotice()?.outOfDate || setupLog() ? ' sim-stale' : ''}`} />
1294
+ <span class="sim-status-mono">{project()}</span>
1295
+ </span>
1296
+ <span class="sim-status-item sim-status-mono sim-status-hide-sm">{settingsSummary(effectiveSettings())}</span>
1297
+ <span class="sim-status-item sim-status-hide-sm">
1298
+ <Icon name="branch" /> {childCount()} successor{childCount() === 1 ? '' : 's'}
1299
+ </span>
1300
+ <Show when={description()} fallback={<span class="sim-spacer" />}>
1301
+ {d => (
1302
+ <button class="sim-status-model" onClick={() => showView('model')} title="Show the model and the experiment as defined">
1303
+ <Icon name="layers" /> <span>{d().summary.join(' · ')}</span>
1304
+ </button>
1305
+ )}
1306
+ </Show>
1307
+ <span class="sim-status-keys">
1308
+ <kbd>←</kbd><kbd>→</kbd> step · <kbd>Space</kbd> play · <kbd>?</kbd> help
1309
+ </span>
1310
+ </footer>
643
1311
 
644
1312
  <Show when={pickingBranch() && branches().length > 1}>
645
1313
  <BranchPicker
646
1314
  branches={branches()}
647
- current={currentPath().at(-1)!}
1315
+ current={branchIndex()}
648
1316
  draw={Projects[project()].drawConfig ? drawBranch : undefined}
649
1317
  onPick={i => { setChoice(i); setPickingBranch(false); }}
650
1318
  onClose={() => setPickingBranch(false)}
@@ -652,7 +1320,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
652
1320
  </Show>
653
1321
 
654
1322
  <Show when={toast()}>
655
- <div class="sim-toast" role="status">{toast()}</div>
1323
+ <div class="sim-toast" role="status"><Icon name="check" />{toast()}</div>
656
1324
  </Show>
657
1325
  </div>
658
1326
  );