@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.
- package/README.md +22 -0
- package/dist/SimulatorApp.jsx +782 -175
- 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/sim/describe.js +10 -8
- package/dist/sim/project.js +27 -17
- package/dist/simulator.css +1553 -363
- package/package.json +6 -6
- package/src/SimulatorApp.tsx +888 -220
- 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/sim/describe.ts +10 -8
- package/src/sim/project.ts +27 -19
- package/src/simulator.css +1553 -363
package/dist/SimulatorApp.jsx
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ()
|
|
271
|
+
const systemDark = createSystemDark();
|
|
123
272
|
const [theme, setTheme] = createSignal(readStorage(THEME_KEY));
|
|
124
273
|
const dark = () => (theme() ?? (systemDark() ? 'dark' : 'light')) === 'dark';
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
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 !==
|
|
325
|
-
canvas.width =
|
|
326
|
-
canvas.height =
|
|
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,
|
|
330
|
-
const
|
|
531
|
+
ctx.clearRect(0, 0, size.w, size.h);
|
|
532
|
+
const { cx, cy, r } = geometry(size);
|
|
331
533
|
try {
|
|
332
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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 =
|
|
347
|
-
|
|
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,
|
|
584
|
+
setExecutionPath(p => [...p, choice]);
|
|
367
585
|
setIndexInPath(i => i + 1);
|
|
368
586
|
});
|
|
369
587
|
}
|
|
370
588
|
};
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
{
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
<
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
<
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
<
|
|
527
|
-
<
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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-
|
|
537
|
-
<
|
|
538
|
-
<
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
<
|
|
552
|
-
<
|
|
553
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
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={
|
|
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"
|
|
1204
|
+
<div class="sim-toast" role="status"><Icon name="check"/>{toast()}</div>
|
|
598
1205
|
</Show>
|
|
599
1206
|
</div>);
|
|
600
1207
|
};
|