@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.
- package/README.md +5 -0
- package/dist/SimulatorApp.jsx +782 -174
- package/dist/components/AlgorithmViewer.css +268 -74
- package/dist/components/AlgorithmViewer.jsx +171 -66
- package/dist/components/BranchPicker.css +74 -32
- package/dist/components/BranchPicker.jsx +9 -4
- package/dist/components/ConfigGraphGenerator.css +180 -38
- package/dist/components/ConfigGraphGenerator.jsx +155 -96
- package/dist/components/GenericEditor.jsx +18 -44
- package/dist/components/Icon.jsx +54 -0
- package/dist/components/ModelView.css +15 -15
- package/dist/components/SettingsLibrary.jsx +7 -5
- package/dist/components/Splitter.jsx +28 -0
- package/dist/components/StateInspector.css +59 -33
- package/dist/components/StateInspector.jsx +37 -4
- package/dist/components/theme.js +28 -0
- package/dist/pico.min.css +4 -4
- package/dist/renderers/ring.js +19 -10
- package/dist/simulator.css +1553 -363
- package/package.json +6 -6
- package/src/SimulatorApp.tsx +888 -219
- package/src/components/AlgorithmViewer.css +268 -74
- package/src/components/AlgorithmViewer.tsx +188 -75
- package/src/components/BranchPicker.css +74 -32
- package/src/components/BranchPicker.tsx +9 -4
- package/src/components/ConfigGraphGenerator.css +180 -38
- package/src/components/ConfigGraphGenerator.tsx +172 -114
- package/src/components/GenericEditor.tsx +25 -59
- package/src/components/Icon.tsx +76 -0
- package/src/components/ModelView.css +15 -15
- package/src/components/SettingsLibrary.tsx +7 -5
- package/src/components/Splitter.tsx +55 -0
- package/src/components/StateInspector.css +59 -33
- package/src/components/StateInspector.tsx +35 -4
- package/src/components/theme.ts +33 -0
- package/src/pico.min.css +4 -4
- package/src/renderers/ring.ts +22 -12
- package/src/simulator.css +1553 -363
package/src/SimulatorApp.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The simulator page,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const [
|
|
122
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 = ()
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
//
|
|
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 !==
|
|
337
|
-
canvas.width =
|
|
338
|
-
canvas.height =
|
|
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,
|
|
342
|
-
const
|
|
561
|
+
ctx.clearRect(0, 0, size.w, size.h);
|
|
562
|
+
const { cx, cy, r } = geometry(size);
|
|
343
563
|
try {
|
|
344
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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 =
|
|
358
|
-
|
|
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,
|
|
612
|
+
setExecutionPath(p => [...p, choice]);
|
|
376
613
|
setIndexInPath(i => i + 1);
|
|
377
614
|
});
|
|
378
615
|
}
|
|
379
616
|
};
|
|
380
|
-
const
|
|
381
|
-
|
|
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 (
|
|
390
|
-
|
|
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
|
|
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=
|
|
456
|
-
<header class="sim-
|
|
457
|
-
<
|
|
458
|
-
|
|
459
|
-
<
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
<
|
|
469
|
-
{
|
|
470
|
-
|
|
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
|
-
<
|
|
476
|
-
{notice().outOfDate ? '
|
|
477
|
-
|
|
478
|
-
|
|
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-
|
|
486
|
-
<
|
|
487
|
-
{
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
<
|
|
497
|
-
<div class="sim-
|
|
498
|
-
<
|
|
499
|
-
<
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
<
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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={
|
|
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"
|
|
1324
|
+
<div class="sim-toast" role="status"><Icon name="check" />{toast()}</div>
|
|
656
1325
|
</Show>
|
|
657
1326
|
</div>
|
|
658
1327
|
);
|