@bramas/simulant-ui 0.1.2 → 0.2.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 (38) hide show
  1. package/README.md +5 -0
  2. package/dist/SimulatorApp.jsx +782 -174
  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/simulator.css +1553 -363
  20. package/package.json +6 -6
  21. package/src/SimulatorApp.tsx +888 -219
  22. package/src/components/AlgorithmViewer.css +268 -74
  23. package/src/components/AlgorithmViewer.tsx +188 -75
  24. package/src/components/BranchPicker.css +74 -32
  25. package/src/components/BranchPicker.tsx +9 -4
  26. package/src/components/ConfigGraphGenerator.css +180 -38
  27. package/src/components/ConfigGraphGenerator.tsx +172 -114
  28. package/src/components/GenericEditor.tsx +25 -59
  29. package/src/components/Icon.tsx +76 -0
  30. package/src/components/ModelView.css +15 -15
  31. package/src/components/SettingsLibrary.tsx +7 -5
  32. package/src/components/Splitter.tsx +55 -0
  33. package/src/components/StateInspector.css +59 -33
  34. package/src/components/StateInspector.tsx +35 -4
  35. package/src/components/theme.ts +33 -0
  36. package/src/pico.min.css +4 -4
  37. package/src/renderers/ring.ts +22 -12
  38. 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) => {
@@ -252,6 +427,20 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
252
427
  }
253
428
  });
254
429
 
430
+ /** The verdict at each stop of the path, to mark where the task holds or fails. */
431
+ const pathVerdicts = createMemo(() => {
432
+ redrawTick();
433
+ const { execution, baseModel } = startedProject();
434
+ const judge = Projects[project()].verdict;
435
+ const path = executionPath();
436
+ if (!execution || !judge || path.length > JUDGED_PATH) return [];
437
+ try {
438
+ return path.map((_, i) => judge(execution.get(path.slice(0, i + 1)), baseModel).verdict);
439
+ } catch {
440
+ return [];
441
+ }
442
+ });
443
+
255
444
  const childCount = createMemo(() => {
256
445
  const { execution } = startedProject();
257
446
  if (!execution || !config()) return 0;
@@ -278,7 +467,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
278
467
  const verdict = Projects[project()].verdict;
279
468
  return labels.map((label, i) => ({
280
469
  index: i, label, short: shorts[i],
281
- verdict: verdict && withVerdicts && count <= 200 ?verdict(execution.get([...parent, i]), baseModel).verdict : undefined,
470
+ verdict: verdict && withVerdicts && count <= 200 ? verdict(execution.get([...parent, i]), baseModel).verdict : undefined,
282
471
  }));
283
472
  } catch {
284
473
  return [];
@@ -299,7 +488,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
299
488
  const draw = Projects[project()].drawConfig;
300
489
  if (!execution || !draw) return;
301
490
  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 });
491
+ draw(ctx, c, baseModel, { centerX: size / 2, centerY: size / 2, radius: size * 0.34, nodeRadius: 4, agentRadius: 4, showNodeLabels: false, theme: drawTheme() });
303
492
  };
304
493
 
305
494
  const inspected = createMemo(() => {
@@ -324,74 +513,272 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
324
513
  return { ...info, agents: info.agents.map(a => ({ ...a, explanation: a.index !== undefined ? explanations[a.index] : undefined })) };
325
514
  });
326
515
 
327
- // Drawing, at the screen's resolution (renderers draw in CSS pixels).
516
+ // The stage follows the size of its panel.
517
+ const [stageSize, setStageSize] = createSignal({ w: 500, h: 300 });
518
+ onMount(() => {
519
+ if (!stage) return;
520
+ const observer = new ResizeObserver(entries => {
521
+ const { width, height } = entries[0].contentRect;
522
+ // A hidden stage (the model view) keeps its last size.
523
+ if (width > 0 && height > 0) setStageSize({ w: Math.floor(width), h: Math.floor(height) });
524
+ });
525
+ observer.observe(stage);
526
+ onCleanup(() => observer.disconnect());
527
+ });
528
+
529
+ /** Where the agents are drawn, for hit tests and the selection's halo. */
530
+ const agentsAt = (): AgentRenderInfo[] => {
531
+ const c = config();
532
+ const baseModel = startedProject().baseModel;
533
+ const current = Projects[project()];
534
+ if (!c || !baseModel || !current.getAgentsRenderInfo) return [];
535
+ const { cx, cy, r } = geometry(stageSize());
536
+ try {
537
+ return current.getAgentsRenderInfo(c, baseModel, cx, cy, r);
538
+ } catch (err) {
539
+ console.error('Error locating the agents:', err);
540
+ return [];
541
+ }
542
+ };
543
+
544
+ // Drawing, at the screen's resolution (renderers draw in CSS pixels), then the selection.
328
545
  createEffect(() => {
329
546
  redrawTick();
330
547
  const c = config();
331
548
  const baseModel = startedProject().baseModel;
549
+ const size = stageSize();
550
+ const colors = drawTheme();
551
+ const selected = selectedAgent();
332
552
  if (!canvas || !c || !baseModel) return;
333
553
  const ctx = canvas.getContext('2d');
334
554
  if (!ctx) return;
335
555
  const ratio = window.devicePixelRatio || 1;
336
- if (canvas.width !== CANVAS.width * ratio) {
337
- canvas.width = CANVAS.width * ratio;
338
- canvas.height = CANVAS.height * ratio;
556
+ if (canvas.width !== size.w * ratio || canvas.height !== size.h * ratio) {
557
+ canvas.width = size.w * ratio;
558
+ canvas.height = size.h * ratio;
339
559
  }
340
560
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
341
- ctx.clearRect(0, 0, CANVAS.width, CANVAS.height);
342
- const current = Projects[project()];
561
+ ctx.clearRect(0, 0, size.w, size.h);
562
+ const { cx, cy, r } = geometry(size);
343
563
  try {
344
- current.drawConfig?.(ctx, c, baseModel, { centerX: 250, centerY: 150, radius: 100, showNodeLabels: true });
564
+ Projects[project()].drawConfig?.(ctx, c, baseModel, { centerX: cx, centerY: cy, radius: r, showNodeLabels: true, theme: colors });
345
565
  } catch (e) {
346
566
  console.error('Error drawing config:', e);
347
567
  }
568
+ const halo = selected !== null ? agentsAt().find(a => a.agentId === selected) : undefined;
569
+ if (halo) {
570
+ ctx.save();
571
+ ctx.strokeStyle = colors.accent;
572
+ ctx.lineWidth = 2;
573
+ ctx.beginPath();
574
+ ctx.arc(halo.position.x, halo.position.y, 12, 0, 2 * Math.PI);
575
+ ctx.stroke();
576
+ ctx.globalAlpha = 0.25;
577
+ ctx.lineWidth = 6;
578
+ ctx.beginPath();
579
+ ctx.arc(halo.position.x, halo.position.y, 15, 0, 2 * Math.PI);
580
+ ctx.stroke();
581
+ ctx.restore();
582
+ }
348
583
  });
349
584
 
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;
585
+ const hitAt = (e: MouseEvent) => {
586
+ if (!canvas) return undefined;
356
587
  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
- }
588
+ const x = e.clientX - rect.left, y = e.clientY - rect.top;
589
+ return agentsAt().find(a => Math.hypot(x - a.position.x, y - a.position.y) <= 10);
366
590
  };
591
+ // Clicking an agent selects it in the state inspector; hovering names it.
592
+ const handleCanvasClick = (e: MouseEvent) => setSelectedAgent(hitAt(e)?.agentId ?? null);
593
+ const [hover, setHover] = createSignal<AgentRenderInfo | undefined>();
594
+ // Agents move between configurations: the tooltip waits for the pointer to move again.
595
+ createEffect(() => { config(); setHover(undefined); });
596
+ const hovered = createMemo(() => {
597
+ const h = hover();
598
+ const agent = h && inspected()?.agents.find(a => a.id === h.agentId);
599
+ return h && { at: h.position, name: agent?.name ?? `agent ${h.agentId}`, agent };
600
+ });
367
601
 
368
602
  // Navigation
603
+ const atEnd = () => indexInPath() === executionPath().length - 1 && childCount() === 0;
369
604
  const onNext = () => {
370
605
  if (!canExplore()) return;
371
606
  if (indexInPath() < executionPath().length - 1) {
372
607
  setIndexInPath(i => i + 1);
373
608
  } else if (childCount() > 0) {
609
+ const execution = startedProject().execution!;
610
+ const choice = randomBranch() ? Math.min(execution.getRandomChildIndex(currentPath()), childCount() - 1) : 0;
374
611
  batch(() => {
375
- setExecutionPath(p => [...p, 0]);
612
+ setExecutionPath(p => [...p, choice]);
376
613
  setIndexInPath(i => i + 1);
377
614
  });
378
615
  }
379
616
  };
380
- const onPrevious = () => {
381
- if (canExplore() && indexInPath() > 0) setIndexInPath(i => i - 1);
617
+ const step = (index: number) => {
618
+ setPlaying(false);
619
+ if (canExplore()) setIndexInPath(Math.max(0, Math.min(index, executionPath().length - 1)));
382
620
  };
621
+ const onPrevious = () => step(indexInPath() - 1);
383
622
  const setChoice = (index: number) => {
384
623
  if (!canExplore()) return;
624
+ setPlaying(false);
385
625
  // A different choice at this step: the steps after it no longer apply.
386
626
  setExecutionPath([...executionPath().slice(0, indexInPath()), index]);
387
627
  };
628
+ const togglePlay = () => {
629
+ if (playing()) setPlaying(false);
630
+ else if (canExplore() && !atEnd()) setPlaying(true);
631
+ };
632
+ // Playing steps forward (taking the first branch past the path) until the execution ends.
633
+ createEffect(() => {
634
+ if (!playing()) return;
635
+ const timer = setInterval(() => {
636
+ if (!canExplore() || atEnd()) setPlaying(false);
637
+ else onNext();
638
+ }, 1000 / speed());
639
+ onCleanup(() => clearInterval(timer));
640
+ });
641
+
388
642
  const onKey = (e: KeyboardEvent) => {
389
- if (modelShown() || typing(e) || e.altKey || e.ctrlKey || e.metaKey) return;
390
- if (e.key === 'ArrowRight') { onNext(); e.preventDefault(); }
643
+ if (e.key === 'Escape') {
644
+ setShowKeys(false);
645
+ if (!pickingBranch()) setSelectedAgent(null);
646
+ }
647
+ if (shownView() !== 'simulation' || typing(e) || e.altKey || e.ctrlKey || e.metaKey) return;
648
+ if (e.key === 'ArrowRight') { setPlaying(false); onNext(); e.preventDefault(); }
391
649
  else if (e.key === 'ArrowLeft') { onPrevious(); e.preventDefault(); }
650
+ else if (e.key === 'Home') { step(0); e.preventDefault(); }
651
+ else if (e.key === 'End') { step(executionPath().length - 1); e.preventDefault(); }
652
+ else if (e.key === ' ' && !(e.target instanceof HTMLButtonElement)) { togglePlay(); e.preventDefault(); }
653
+ else if (e.key === '?') setShowKeys(v => !v);
654
+ else if (e.key === 'r' || e.key === 'R') toggleRandomBranch();
392
655
  };
393
656
  window.addEventListener('keydown', onKey);
394
657
  onCleanup(() => window.removeEventListener('keydown', onKey));
658
+ // The shortcuts close on a click elsewhere.
659
+ let shortcuts: HTMLDivElement | undefined;
660
+ const onPointer = (e: PointerEvent) => {
661
+ if (showKeys() && !shortcuts?.contains(e.target as Node)) setShowKeys(false);
662
+ };
663
+ window.addEventListener('pointerdown', onPointer);
664
+ onCleanup(() => window.removeEventListener('pointerdown', onPointer));
665
+
666
+ // The current stop of the path stays in view.
667
+ createEffect(() => {
668
+ indexInPath();
669
+ executionPath();
670
+ queueMicrotask(() => {
671
+ const current = track?.querySelector<HTMLElement>('.sim-current');
672
+ if (!track || !current) return;
673
+ const left = current.offsetLeft - track.offsetLeft, right = left + current.offsetWidth;
674
+ if (left < track.scrollLeft + 8) track.scrollLeft = left - 24;
675
+ else if (right > track.scrollLeft + track.clientWidth - 16) track.scrollLeft = right - track.clientWidth + 40;
676
+ });
677
+ });
678
+
679
+ // The side bars leave the configuration at least this wide.
680
+ const STAGE_MIN = 320;
681
+ const workbenchWidth = () => workbench?.getBoundingClientRect().width ?? window.innerWidth;
682
+ /** A side bar's width as shown: narrow windows shrink the side bars below their remembered widths. */
683
+ const barWidth = (bar: HTMLElement | undefined) => bar?.getBoundingClientRect().width ?? 0;
684
+ const resizeLeft = (w: number) => resize('left', Math.round(Math.max(220, Math.min(w, 600, workbenchWidth() - STAGE_MIN - barWidth(rightBar)))));
685
+ const resizeRight = (w: number) => resize('right', Math.round(Math.max(280, Math.min(w, workbenchWidth() - STAGE_MIN - barWidth(leftBar)))));
686
+ const centerHeight = () => center?.getBoundingClientRect().height || 600;
687
+ const resizeBottom = (h: number) => resize('bottom', Math.max(0.15, Math.min(h / centerHeight(), 0.7)));
688
+
689
+ // Where each panel is docked, in order, and how panels sharing a dock divide it (remembered).
690
+ const [docks, setDocks] = createSignal(normalizeDocks(readStorage(DOCKS_KEY)));
691
+ const dockOf = (id: PanelId) => DOCKS.find(d => docks()[d].includes(id))!;
692
+ const movePanel = (id: PanelId, dock: Dock, index?: number) => {
693
+ setDocks(d => {
694
+ const next = { left: d.left.filter(x => x !== id), right: d.right.filter(x => x !== id), bottom: d.bottom.filter(x => x !== id) };
695
+ next[dock].splice(index ?? next[dock].length, 0, id);
696
+ writeStorage(DOCKS_KEY, next);
697
+ return next;
698
+ });
699
+ if (!panels()[id]) togglePanel(id);
700
+ };
701
+ const resetLayout = () => {
702
+ batch(() => {
703
+ setPanels({ ...DEFAULT_PANELS });
704
+ setDocks(normalizeDocks(null));
705
+ setWeights({});
706
+ setSizes({ ...DEFAULT_SIZES });
707
+ });
708
+ writeStorage(PANELS_KEY, null);
709
+ writeStorage(DOCKS_KEY, null);
710
+ writeStorage(WEIGHTS_KEY, null);
711
+ saveSizes();
712
+ };
713
+ const [weights, setWeights] = createSignal<Partial<Record<PanelId, number>>>(readStorage(WEIGHTS_KEY) ?? {});
714
+ const weightOf = (id: PanelId) => weights()[id] ?? 1;
715
+ const saveWeights = () => writeStorage(WEIGHTS_KEY, weights());
716
+ const panelElement = (id: PanelId) => workbench?.querySelector<HTMLElement>(`[data-panel="${id}"]`);
717
+ const extent = (id: PanelId, dock: Dock) => {
718
+ const box = panelElement(id)?.getBoundingClientRect();
719
+ return box ? (dock === 'bottom' ? box.width : box.height) : 0;
720
+ };
721
+ /** Gives `first` (and takes from `second`, its neighbor) so that `first` measures `size`. */
722
+ const splitPanels = (first: PanelId, second: PanelId, dock: Dock, size: number) => {
723
+ const both = extent(first, dock) + extent(second, dock);
724
+ if (both <= 0) return;
725
+ const share = Math.max(100, Math.min(size, both - 100)) / both;
726
+ const total = weightOf(first) + weightOf(second);
727
+ setWeights(w => ({ ...w, [first]: share * total, [second]: (1 - share) * total }));
728
+ };
729
+
730
+ /** Panels the current view shows: the simulation shows them all, the graph only the settings. */
731
+ const panelShown = (id: PanelId) => panels()[id] && (shownView() === 'simulation' || (shownView() === 'graph' && GRAPH_PANELS.has(id)));
732
+ const dockShown = (dock: Dock) => shownView() !== 'model' && docks()[dock].some(panelShown);
733
+
734
+ // Dragging a panel by its title bar onto a dock: the left or right side bar, or below the configuration.
735
+ type Drop = { dock: Dock; index: number };
736
+ const [dragging, setDragging] = createSignal<{ id: PanelId; x: number; y: number; drop?: Drop } | null>(null);
737
+ /** The drop zones, in page coordinates: strips on the sides, and the bottom of the center. */
738
+ const dropZones = (): Record<Dock, { left: number; top: number; width: number; height: number }> | undefined => {
739
+ const box = workbench?.getBoundingClientRect();
740
+ if (!box) return undefined;
741
+ const side = Math.min(320, box.width * 0.24);
742
+ return {
743
+ left: { left: box.left, top: box.top, width: side, height: box.height },
744
+ right: { left: box.right - side, top: box.top, width: side, height: box.height },
745
+ bottom: { left: box.left + side, top: box.top + box.height * 0.5, width: box.width - 2 * side, height: box.height * 0.5 },
746
+ };
747
+ };
748
+ const dropAt = (id: PanelId, x: number, y: number): Drop | undefined => {
749
+ const zones = dropZones();
750
+ 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);
751
+ if (!dock) return undefined;
752
+ // Before the first panel of the dock whose middle is past the pointer.
753
+ const others = docks()[dock].filter(p => p !== id);
754
+ const before = others.find(p => {
755
+ const box = panelShown(p) ? panelElement(p)?.getBoundingClientRect() : undefined;
756
+ return box && (dock === 'bottom' ? x < box.left + box.width / 2 : y < box.top + box.height / 2);
757
+ });
758
+ return { dock, index: before ? others.indexOf(before) : others.length };
759
+ };
760
+ const startPanelDrag = (id: PanelId, e: PointerEvent) => {
761
+ if (e.button !== 0 || (e.target as HTMLElement).closest('button, input, select, a')) return;
762
+ e.preventDefault();
763
+ const from = { x: e.clientX, y: e.clientY };
764
+ const move = (ev: PointerEvent) => {
765
+ if (!dragging() && Math.hypot(ev.clientX - from.x, ev.clientY - from.y) < 6) return;
766
+ document.documentElement.classList.add('sim-dragging-panel');
767
+ setDragging({ id, x: ev.clientX, y: ev.clientY, drop: dropAt(id, ev.clientX, ev.clientY) });
768
+ };
769
+ const end = (ev: PointerEvent) => {
770
+ window.removeEventListener('pointermove', move);
771
+ window.removeEventListener('pointerup', end);
772
+ window.removeEventListener('pointercancel', end);
773
+ document.documentElement.classList.remove('sim-dragging-panel');
774
+ const drop = ev.type === 'pointerup' ? dragging()?.drop : undefined;
775
+ setDragging(null);
776
+ if (drop) movePanel(id, drop.dock, drop.index);
777
+ };
778
+ window.addEventListener('pointermove', move);
779
+ window.addEventListener('pointerup', end);
780
+ window.addEventListener('pointercancel', end);
781
+ };
395
782
 
396
783
  // Settings
397
784
  const [savedSettings, setSavedSettings] = createSignal<SavedSetting[]>([]);
@@ -416,6 +803,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
416
803
  window.prompt('Copy this link:', url);
417
804
  }
418
805
  };
806
+ const share = () => copy(`${shareLink(effectiveSettings(), currentPath())}${VIEW_HASH[shownView()]}`);
419
807
 
420
808
  onMount(() => {
421
809
  const shared = new URLSearchParams(window.location.search).get('shared');
@@ -443,7 +831,13 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
443
831
  }
444
832
  });
445
833
 
446
- const modelShown = () => view() === 'model' && !!description();
834
+ const hasGraph = () => !!Projects[project()].executionStatusChecker;
835
+ /** The view shown: the one asked for, when this project has it. */
836
+ const shownView = (): View => view() === 'model' && description() ? 'model' : view() === 'graph' && hasGraph() ? 'graph' : 'simulation';
837
+ // The graph stays mounted once opened, so that switching views keeps it.
838
+ const [graphOpened, setGraphOpened] = createSignal(false);
839
+ createEffect(() => { if (shownView() === 'graph') setGraphOpened(true); });
840
+
447
841
 
448
842
  const chooseProject = (name: string) => batch(() => {
449
843
  setProjectUserSettings(null);
@@ -451,200 +845,475 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
451
845
  setProject(name);
452
846
  });
453
847
 
848
+ const branchIndex = () => currentPath().at(-1)!;
849
+
850
+ /** Each panel's content; the docks place them. Moving a panel to another dock recreates it. */
851
+ const head = (id: PanelId) => ({ dock: dockOf(id), onMove: (dock: Dock) => movePanel(id, dock), onClose: () => togglePanel(id), onDragStart: (e: PointerEvent) => startPanelDrag(id, e) });
852
+ const panelContent: Record<PanelId, () => JSX.Element> = {
853
+ settings: () => (
854
+ <section class="sim-panel sim-settings">
855
+ <PanelHead icon="sliders" title="Settings" {...head('settings')}>
856
+ <button class="sim-ghost sim-sm" onClick={() => batch(() => { setProjectUserSettings(null); goTo([0]); })} disabled={!projectUserSettings()} title="Reset the settings to the experiment's defaults">
857
+ <Icon name="reset" size={14} /> <span class="sim-btn-label">Reset</span>
858
+ </button>
859
+ </PanelHead>
860
+ <div class="sim-panel-body">
861
+ <Show when={editorSchema()} keyed>
862
+ {schema => (
863
+ <GenericEditor
864
+ schema={schema}
865
+ value={projectUserSettings()}
866
+ onInput={(v: any) => batch(() => { setProjectUserSettings(v); goTo([0]); })}
867
+ />
868
+ )}
869
+ </Show>
870
+ <Show when={setupLog()}>
871
+ <div class="sim-feedback sim-settings-error" role="alert">
872
+ <Icon name="alert" />
873
+ <div>
874
+ <strong>These settings do not start an execution</strong>
875
+ <mark>{setupLog()}</mark>
876
+ </div>
877
+ </div>
878
+ </Show>
879
+ <Show when={!setupLog() && settingsWarnings().length > 0}>
880
+ <div class="sim-feedback sim-settings-warning" role="alert">
881
+ <Icon name="alert" />
882
+ <div>
883
+ <strong>Initial settings warning</strong>
884
+ <For each={settingsWarnings()}>{warning => <span>{warning}</span>}</For>
885
+ <span class="sim-muted">You can still explore this execution. Checks over the declared domains do not cover settings outside them.</span>
886
+ </div>
887
+ </div>
888
+ </Show>
889
+ </div>
890
+ </section>
891
+ ),
892
+ library: () => (
893
+ <section class="sim-panel sim-library-panel">
894
+ <PanelHead icon="bookmark" title="Saved settings" {...head('library')} />
895
+ <div class="sim-panel-body">
896
+ <SettingsLibrary
897
+ presets={predefinedSettings()}
898
+ saved={savedSettings()}
899
+ current={effectiveSettings()}
900
+ onLoad={loadSettings}
901
+ onShare={s => copy(shareLink(loadedSettings(project(), s.settings), s.path))}
902
+ onDelete={name => {
903
+ deleteSettings(storageKey, project(), name);
904
+ setSavedSettings(getSavedSettings(storageKey, project()));
905
+ }}
906
+ onSave={name => {
907
+ saveSettings(storageKey, project(), name, effectiveSettings(), currentPath());
908
+ setSavedSettings(getSavedSettings(storageKey, project()));
909
+ notify(`Saved "${name}"`);
910
+ }}
911
+ />
912
+ </div>
913
+ </section>
914
+ ),
915
+ algorithm: () => (
916
+ <Show when={config() && algorithmViewInfo()} fallback={
917
+ <section class="sim-panel">
918
+ <PanelHead icon="file" title="Algorithm" {...head('algorithm')} />
919
+ <div class="sim-stage-empty"><Icon name="file" size={28} /><span>No algorithm to show.</span></div>
920
+ </section>
921
+ }>
922
+ {info => (
923
+ <AlgorithmViewer
924
+ source={info().source}
925
+ agents={info().agents}
926
+ onHeadPointerDown={e => startPanelDrag('algorithm', e)}
927
+ highlighted={inspected()?.agents.find(a => a.id === selectedAgent())?.name ?? null}
928
+ onSelectAgent={name => setSelectedAgent(inspected()?.agents.find(a => a.name === name)?.id ?? null)}
929
+ actions={<PanelActions title="Algorithm" {...head('algorithm')} />}
930
+ />
931
+ )}
932
+ </Show>
933
+ ),
934
+ state: () => (
935
+ <section class="sim-panel sim-inspector-panel">
936
+ <PanelHead icon="inspect" title="State" sub="world, agents and parameters" {...head('state')} />
937
+ <div class="sim-panel-body">
938
+ <Show when={inspected()} fallback={<p class="sim-muted">No state to inspect.</p>}>
939
+ {state => <StateInspector state={state()} selected={selectedAgent()} onSelect={setSelectedAgent} onClearSelection={() => setSelectedAgent(null)} />}
940
+ </Show>
941
+ </div>
942
+ </section>
943
+ ),
944
+ };
945
+
946
+ /** A dock's panels in order, the hidden ones kept mounted, with splitters between neighbors that share the space. */
947
+ const DockPanels = (p: { dock: Dock }) => (
948
+ <For each={docks()[p.dock]}>
949
+ {(id, i) => {
950
+ const fills = (x: PanelId) => p.dock === 'bottom' || !COMPACT_PANELS.has(x);
951
+ const previous = () => docks()[p.dock].slice(0, i()).filter(panelShown).at(-1);
952
+ return (
953
+ <>
954
+ <Show when={panelShown(id) && previous() && fills(id) && fills(previous()!) && previous()} keyed>
955
+ {prev => (
956
+ <Splitter
957
+ orientation={p.dock === 'bottom' ? 'vertical' : 'horizontal'}
958
+ value={extent(prev, p.dock)}
959
+ onChange={size => splitPanels(prev, id, p.dock, size)}
960
+ onCommit={saveWeights}
961
+ onReset={() => { setWeights(w => ({ ...w, [prev]: 1, [id]: 1 })); saveWeights(); }}
962
+ label={`Resize ${PANEL_TITLE[prev].toLowerCase()} and ${PANEL_TITLE[id].toLowerCase()}`}
963
+ />
964
+ )}
965
+ </Show>
966
+ <div
967
+ class={`sim-slot${dragging()?.id === id ? ' sim-slot-dragged' : ''}`}
968
+ data-panel={id}
969
+ hidden={!panelShown(id)}
970
+ // Grow factors below 1 would leave part of the dock empty (a lone panel of weight 0.6
971
+ // takes 60% of it): scaled up, they keep their ratios and always fill the dock.
972
+ style={{ flex: fills(id) ? `${weightOf(id) * 100} 1 0` : '0 1 auto' }}
973
+ >
974
+ {panelContent[id]()}
975
+ </div>
976
+ </>
977
+ );
978
+ }}
979
+ </For>
980
+ );
981
+
454
982
  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>
983
+ <div class={`sim-app sim-view-${shownView()}`}>
984
+ <header class="sim-topbar">
985
+ <div class="sim-brand">
986
+ <Logo />
987
+ <span class="sim-brand-name">Simulant</span>
988
+ <span class="sim-brand-sep">/</span>
989
+ <h1>{props.title ?? 'Simulator'}</h1>
990
+ </div>
991
+ <div class="sim-project">
992
+ <select value={project()} onChange={(e) => chooseProject(e.currentTarget.value)} aria-label="Simulation" title="Simulation">
993
+ <For each={projectKeys}>{(p) => <option value={p}>{p}</option>}</For>
994
+ </select>
995
+ </div>
996
+ <div class="sim-tabs" role="tablist" aria-label="View">
997
+ <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">
998
+ <Icon name="play" size={13} /> <span class="sim-tab-label">Simulation</span>
999
+ </button>
1000
+ <Show when={hasGraph()}>
1001
+ <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">
1002
+ <Icon name="graph" size={13} /> <span class="sim-tab-label">Graph</span>
1003
+ </button>
1004
+ </Show>
1005
+ <Show when={description()}>
1006
+ <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">
1007
+ <Icon name="layers" size={13} /> <span class="sim-tab-label">Model</span>
1008
+ </button>
1009
+ </Show>
1010
+ </div>
467
1011
  <span class="sim-spacer" />
468
- <button class="sim-ghost theme-toggle" onClick={toggleTheme} title={dark() ? 'Light theme' : 'Dark theme'}>
469
- {dark() ? '☀' : '🌙'}
470
- </button>
1012
+ <div class="sim-topbar-actions">
1013
+ <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'}>
1014
+ <Icon name="link" /> <span class="sim-share-label">Share</span>
1015
+ </button>
1016
+ <div class="sim-popover-anchor" ref={shortcuts}>
1017
+ <button class="sim-ghost sim-icon" onClick={() => setShowKeys(v => !v)} aria-label="Keyboard shortcuts" aria-expanded={showKeys()} title="Keyboard shortcuts (?)">
1018
+ <Icon name="keyboard" />
1019
+ </button>
1020
+ <Show when={showKeys()}>
1021
+ <div class="sim-shortcuts" role="dialog" aria-label="Keyboard shortcuts">
1022
+ <h4>Keyboard shortcuts</h4>
1023
+ <dl>
1024
+ <dt><kbd>→</kbd></dt><dd>Next step</dd>
1025
+ <dt><kbd>←</kbd></dt><dd>Previous step</dd>
1026
+ <dt><kbd>Home</kbd><kbd>End</kbd></dt><dd>First / last step of the path</dd>
1027
+ <dt><kbd>Space</kbd></dt><dd>Play / pause</dd>
1028
+ <dt><kbd>R</kbd></dt><dd>Random or first branch for new steps</dd>
1029
+ <dt><kbd>Esc</kbd></dt><dd>Clear the selected agent</dd>
1030
+ <dt><kbd>?</kbd></dt><dd>Show these shortcuts</dd>
1031
+ </dl>
1032
+ </div>
1033
+ </Show>
1034
+ </div>
1035
+ <button class="sim-ghost sim-icon theme-toggle" onClick={toggleTheme} aria-label={dark() ? 'Light theme' : 'Dark theme'} title={dark() ? 'Light theme' : 'Dark theme'}>
1036
+ <Icon name={dark() ? 'sun' : 'moon'} />
1037
+ </button>
1038
+ </div>
471
1039
  </header>
472
1040
 
473
1041
  <Show when={updateNotice()}>
474
1042
  {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>
1043
+ <div class={`sim-banner${notice().outOfDate ? ' sim-error' : ''}`} role="status">
1044
+ <Icon name={notice().outOfDate ? 'alert' : 'info'} />
1045
+ <div class="sim-banner-body">
1046
+ <strong>{notice().outOfDate ? 'Out of date: the sources do not compile; showing the last version that did.' : 'Sources updated; the execution restarted.'}</strong>
1047
+ <Show when={notice().text}><pre>{notice().text}</pre></Show>
1048
+ </div>
1049
+ <Show when={!notice().outOfDate}>
1050
+ <button class="sim-ghost sim-icon sim-sm" onClick={() => setUpdateNotice(null)} aria-label="Dismiss"><Icon name="close" size={14} /></button>
1051
+ </Show>
1052
+ </div>
479
1053
  )}
480
1054
  </Show>
481
- <Show when={modelShown() && description()}>
482
- {d => <ModelView description={d()} simulation={project()} onCopyLink={() => copy(`${shareLink(effectiveSettings(), currentPath())}#model`)} />}
483
- </Show>
484
1055
 
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>
1056
+ <div class="sim-shell" hidden={shownView() === 'model'}>
1057
+ <nav class="sim-activity" aria-label="Panels">
1058
+ <ActivityButton icon="sliders" label="Settings" active={panels().settings} onClick={() => togglePanel('settings')} />
1059
+ <ActivityButton icon="bookmark" label="Saved settings" active={panels().library} onClick={() => togglePanel('library')} />
1060
+ <Show when={shownView() === 'simulation'}>
1061
+ <span class="sim-activity-sep" />
1062
+ <ActivityButton icon="file" label="Algorithm" active={panels().algorithm} onClick={() => togglePanel('algorithm')} />
1063
+ <ActivityButton icon="inspect" label="State" active={panels().state} onClick={() => togglePanel('state')} />
1064
+ </Show>
1065
+ <span class="sim-spacer" />
1066
+ <ActivityButton icon="dock" label="Reset the panel layout" active={false} onClick={resetLayout} />
1067
+ <Show when={hasGraph()}>
1068
+ <ActivityButton
1069
+ icon="graph"
1070
+ label={shownView() === 'graph' ? 'Back to the simulation' : 'Configuration graph'}
1071
+ active={shownView() === 'graph'}
1072
+ onClick={() => showView(shownView() === 'graph' ? 'simulation' : 'graph')}
1073
+ />
1074
+ </Show>
1075
+ </nav>
495
1076
 
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>
1077
+ <div class="sim-main">
1078
+ <div class="sim-transport-bar" hidden={shownView() !== 'simulation'}>
1079
+ <section class="sim-transport" aria-label="Execution">
1080
+ <div class="sim-transport-row">
1081
+ <div class="sim-group">
1082
+ <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>
1083
+ <button class="sim-btn sim-icon" onClick={onPrevious} disabled={indexInPath() === 0 || !canExplore()} aria-label="Previous step" title="Previous step (←)"><Icon name="previous" /></button>
1084
+ <div class="sim-round" title={`Round ${indexInPath()} of the path's ${executionPath().length - 1}`}>
1085
+ <span class="sim-round-label">Round</span>
1086
+ <span class="sim-round-value">{indexInPath()}</span>
1087
+ <span class="sim-round-total">/ {executionPath().length - 1}</span>
1088
+ </div>
1089
+ <button class="sim-btn sim-icon" onClick={() => { setPlaying(false); onNext(); }} disabled={!canExplore() || atEnd()} aria-label="Next step" title="Next step (→)"><Icon name="next" /></button>
1090
+ <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>
1091
+ </div>
1092
+ <span class="sim-divider" />
1093
+ <div class="sim-group">
1094
+ <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)'}>
1095
+ <Icon name={playing() ? 'pause' : 'play'} />
1096
+ </button>
1097
+ <label class="sim-speed" title="Playback speed">
1098
+ <select value={speed()} onChange={e => setSpeed(Number(e.currentTarget.value))} aria-label="Playback speed">
1099
+ <For each={SPEEDS}>{s => <option value={s}>{s} step{s > 1 ? 's' : ''}/s</option>}</For>
1100
+ </select>
1101
+ </label>
1102
+ </div>
1103
+ <span class="sim-divider" />
1104
+ <div class="sim-group">
1105
+ <Show when={verdict()}>
1106
+ {v => <span class={`sim-chip sim-verdict sim-${v().status}`} title={v().title}>Task: {v().text}</span>}
1107
+ </Show>
1108
+ <Show when={config() && childCount() === 0}>
1109
+ <span class="sim-chip sim-verdict" title="No successor: the execution ends here">End of execution</span>
1110
+ </Show>
1111
+ </div>
1112
+ <span class="sim-divider" />
1113
+ <div class="sim-group sim-branches">
1114
+ <button
1115
+ class={`sim-ghost sim-icon sim-toggle${randomBranch() ? ' sim-toggle-on' : ''}`}
1116
+ onClick={toggleRandomBranch}
1117
+ aria-pressed={randomBranch()}
1118
+ aria-label="Random branch for new steps"
1119
+ title={randomBranch()
1120
+ ? 'New steps take a random branch (R): click to take the first branch'
1121
+ : 'New steps take the first branch (R): click to take a random branch'}
1122
+ >
1123
+ <Icon name="dice" />
1124
+ </button>
1125
+ <Show when={branches().length > 1}>
1126
+ <Icon name="branch" />
1127
+ <span class="sim-branch">Branch {branchIndex()} <span class="sim-branch-of">of {branches().length}</span></span>
1128
+ <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>
1129
+ <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>
1130
+ <span class="sim-step-label sim-branch-label" title={branches()[branchIndex()]?.label}>{branches()[branchIndex()]?.short || '(first options)'}</span>
1131
+ <button class="sim-btn sim-sm" onClick={() => setPickingBranch(true)} disabled={!canExplore()} title="See every branch of this step and pick one">
1132
+ <Icon name="grid" size={14} /> All branches
1133
+ </button>
1134
+ </Show>
1135
+ </div>
519
1136
  </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>
1137
+ <div class="sim-timeline">
1138
+ <span class="sim-timeline-label">Path</span>
1139
+ <div class="sim-track" ref={track}>
1140
+ <For each={executionPath()}>
1141
+ {(choice, i) => (
1142
+ <>
1143
+ <Show when={i() > 0}><span class="sim-track-sep" /></Show>
1144
+ <button
1145
+ class={`sim-step${i() === indexInPath() ? ' sim-current' : ''}${i() > indexInPath() ? ' sim-future' : ''}`}
1146
+ data-verdict={pathVerdicts()[i()]}
1147
+ onClick={() => step(i())}
1148
+ title={`Round ${i()}: ${stepLabel(i())}${pathVerdicts()[i()] ? ` (task: ${VERDICT_TEXT[pathVerdicts()[i()]]})` : ''}`}
1149
+ >
1150
+ {choice}
1151
+ </button>
1152
+ </>
1153
+ )}
1154
+ </For>
1155
+ </div>
1156
+ <Show when={indexInPath() > 0 && stepLabel(indexInPath())}>
1157
+ <span class="sim-step-label" title={stepLabel(indexInPath())}>last step: {stepLabel(indexInPath())}</span>
1158
+ </Show>
1159
+ </div>
1160
+ </section>
523
1161
  </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>
1162
+
1163
+ <div class="sim-workbench" ref={workbench}>
1164
+ <aside class="sim-sidebar sim-dock" ref={leftBar} hidden={!dockShown('left')} style={{ 'flex-basis': `${sizes().left}px` }} aria-label={DOCK_LABEL.left}>
1165
+ <DockPanels dock="left" />
1166
+ </aside>
1167
+ <Show when={dockShown('left')}>
1168
+ <Splitter orientation="vertical" value={barWidth(leftBar)} onChange={resizeLeft} onCommit={saveSizes} onReset={() => resetSize('left')} label="Resize the left side bar" />
539
1169
  </Show>
540
- </div>
541
- </section>
542
1170
 
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)} />}
1171
+ <main class="sim-center" ref={center}>
1172
+ <section class="sim-panel sim-stage-panel" hidden={shownView() !== 'simulation'}>
1173
+ <PanelHead icon="ring" title="Configuration" sub={`round ${indexInPath()} · ${settingsSummary(effectiveSettings())}`} />
1174
+ <div class="sim-stage" ref={stage}>
1175
+ <canvas
1176
+ ref={canvas}
1177
+ class={`sim-canvas${hover() ? ' sim-over-agent' : ''}`}
1178
+ style={{ width: `${stageSize().w}px`, height: `${stageSize().h}px` }}
1179
+ onClick={handleCanvasClick}
1180
+ onMouseMove={e => setHover(hitAt(e))}
1181
+ onMouseLeave={() => setHover(undefined)}
1182
+ />
1183
+ <Show when={hovered()}>
1184
+ {h => (
1185
+ <div class="sim-stage-tip" style={{ left: `${h().at.x}px`, top: `${h().at.y}px` }}>
1186
+ <strong>{h().name}</strong>
1187
+ <Show when={h().agent}>{a => <span> · node {String(a().value.position)}{a().status !== 'correct' ? ` · ${a().status === 'hidden' ? 'gone' : 'faulty'}` : ''}</span>}</Show>
1188
+ </div>
1189
+ )}
1190
+ </Show>
1191
+ <Show when={!config()}>
1192
+ <div class="sim-stage-empty">
1193
+ <Icon name="ring" size={28} />
1194
+ <span>No configuration to show.</span>
1195
+ <span class="sim-muted">The current settings do not start an execution: see the settings below.</span>
1196
+ </div>
1197
+ </Show>
1198
+ <Show when={config() && Projects[project()].getAgentsRenderInfo}>
1199
+ <div class="sim-stage-overlay">
1200
+ <span class="sim-stage-badge">
1201
+ {selectedAgent() !== null
1202
+ ? `${inspected()?.agents.find(a => a.id === selectedAgent())?.name ?? 'Agent'} selected · Esc to clear`
1203
+ : 'Click an agent to inspect it'}
1204
+ </span>
1205
+ </div>
1206
+ </Show>
1207
+ </div>
1208
+ </section>
1209
+ <Show when={graphOpened() && hasGraph()}>
1210
+ <section class="sim-panel sim-graph-panel" hidden={shownView() !== 'graph'}>
1211
+ <PanelHead icon="graph" title="Configuration graph" sub="every execution from these settings, round by round">
1212
+ <button class="sim-ghost sim-sm" onClick={() => showView('simulation')}><Icon name="previous" size={14} /> Back to the simulation</button>
1213
+ </PanelHead>
1214
+ <Show when={!updateNotice()?.outOfDate && projectRevision() + 1} keyed fallback={
1215
+ <div class="sim-stage-empty"><Icon name="alert" size={28} /><span>The sources do not compile: the graph waits until they do.</span></div>
1216
+ }>
1217
+ <ConfigGraphGenerator
1218
+ revision={Projects[project()].revision?.()}
1219
+ settings={effectiveSettings()}
1220
+ setup={Projects[project()].setup}
1221
+ execute={Projects[project()].execute}
1222
+ executionStatusChecker={Projects[project()].executionStatusChecker!}
1223
+ drawConfig={Projects[project()].drawConfig}
1224
+ projectName={project()}
1225
+ createWorker={props.createGraphWorker}
1226
+ currentPath={currentPath()}
1227
+ theme={drawTheme()}
1228
+ onOpen={(settings, path) => batch(() => {
1229
+ setProjectUserSettings(settings);
1230
+ goTo(path, path.length - 1);
1231
+ showView('simulation');
1232
+ })}
1233
+ configRadius={50}
1234
+ horizontalSpacing={140}
1235
+ verticalSpacing={150}
1236
+ />
1237
+ </Show>
1238
+ </section>
1239
+ </Show>
1240
+ <Show when={dockShown('bottom')}>
1241
+ <Splitter orientation="horizontal" direction={-1} value={sizes().bottom * centerHeight()} onChange={resizeBottom} onCommit={saveSizes} onReset={() => resetSize('bottom')} label="Resize the panels below the configuration" />
1242
+ </Show>
1243
+ <div class="sim-dock sim-dock-bottom" hidden={!dockShown('bottom')} style={{ flex: `0 0 ${sizes().bottom * 100}%` }} aria-label={DOCK_LABEL.bottom}>
1244
+ <DockPanels dock="bottom" />
1245
+ </div>
1246
+ </main>
1247
+
1248
+ <Show when={dockShown('right')}>
1249
+ <Splitter orientation="vertical" direction={-1} value={barWidth(rightBar)} onChange={resizeRight} onCommit={saveSizes} onReset={() => resetSize('right')} label="Resize the right side bar" />
553
1250
  </Show>
554
- </div>
555
- <Show when={config() && algorithmViewInfo()}>
556
- {info => <AlgorithmViewer source={info().source} agents={info().agents} />}
557
- </Show>
558
- </div>
1251
+ <aside class="sim-sidebar sim-dock" ref={rightBar} hidden={!dockShown('right')} style={{ 'flex-basis': `${sizes().right}px` }} aria-label={DOCK_LABEL.right}>
1252
+ <DockPanels dock="right" />
1253
+ </aside>
559
1254
 
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
- />
1255
+ <Show when={dragging()}>
1256
+ {d => (
1257
+ <>
1258
+ <div class="sim-dropzones" aria-hidden="true">
1259
+ <For each={DOCKS}>
1260
+ {dock => {
1261
+ const zone = () => {
1262
+ const z = dropZones()?.[dock], box = workbench?.getBoundingClientRect();
1263
+ return z && box ? { left: `${z.left - box.left}px`, top: `${z.top - box.top}px`, width: `${z.width}px`, height: `${z.height}px` } : {};
1264
+ };
1265
+ return (
1266
+ <div class={`sim-dropzone${d().drop?.dock === dock ? ' sim-dropzone-active' : ''}`} style={zone()}>
1267
+ <Icon name={DOCK_ICON[dock]} size={20} />
1268
+ <span>{DOCK_LABEL[dock]}</span>
1269
+ </div>
1270
+ );
1271
+ }}
1272
+ </For>
1273
+ </div>
1274
+ <div class="sim-drag-ghost" style={{ left: `${d().x}px`, top: `${d().y}px` }}>
1275
+ <Icon name="grip" size={14} /> {PANEL_TITLE[d().id]}
1276
+ </div>
1277
+ </>
574
1278
  )}
575
1279
  </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>
1280
+ </div>
606
1281
  </div>
1282
+ </div>
607
1283
 
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>
1284
+ <Show when={shownView() === 'model' && description()}>
1285
+ {d => (
1286
+ <div class="sim-model-page">
1287
+ <ModelView description={d()} simulation={project()} onCopyLink={() => copy(`${shareLink(effectiveSettings(), currentPath())}#model`)} />
1288
+ </div>
1289
+ )}
1290
+ </Show>
641
1291
 
642
- </div>
1292
+ <footer class="sim-statusbar">
1293
+ <span class="sim-status-item" title={updateNotice()?.outOfDate ? 'The sources do not compile' : 'Up to date'}>
1294
+ <span class={`sim-status-dot${updateNotice()?.outOfDate || setupLog() ? ' sim-stale' : ''}`} />
1295
+ <span class="sim-status-mono">{project()}</span>
1296
+ </span>
1297
+ <span class="sim-status-item sim-status-mono sim-status-hide-sm">{settingsSummary(effectiveSettings())}</span>
1298
+ <span class="sim-status-item sim-status-hide-sm">
1299
+ <Icon name="branch" /> {childCount()} successor{childCount() === 1 ? '' : 's'}
1300
+ </span>
1301
+ <Show when={description()} fallback={<span class="sim-spacer" />}>
1302
+ {d => (
1303
+ <button class="sim-status-model" onClick={() => showView('model')} title="Show the model and the experiment as defined">
1304
+ <Icon name="layers" /> <span>{d().summary.join(' · ')}</span>
1305
+ </button>
1306
+ )}
1307
+ </Show>
1308
+ <span class="sim-status-keys">
1309
+ <kbd>←</kbd><kbd>→</kbd> step · <kbd>Space</kbd> play · <kbd>?</kbd> help
1310
+ </span>
1311
+ </footer>
643
1312
 
644
1313
  <Show when={pickingBranch() && branches().length > 1}>
645
1314
  <BranchPicker
646
1315
  branches={branches()}
647
- current={currentPath().at(-1)!}
1316
+ current={branchIndex()}
648
1317
  draw={Projects[project()].drawConfig ? drawBranch : undefined}
649
1318
  onPick={i => { setChoice(i); setPickingBranch(false); }}
650
1319
  onClose={() => setPickingBranch(false)}
@@ -652,7 +1321,7 @@ export const SimulatorApp: Component<SimulatorAppProps> = (props) => {
652
1321
  </Show>
653
1322
 
654
1323
  <Show when={toast()}>
655
- <div class="sim-toast" role="status">{toast()}</div>
1324
+ <div class="sim-toast" role="status"><Icon name="check" />{toast()}</div>
656
1325
  </Show>
657
1326
  </div>
658
1327
  );