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