@bramas/simulant-ui 0.1.1 → 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 +60 -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
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* The configuration graph of the current settings: every execution, round by round, up to a
|
|
3
3
|
* depth, generated by the project's graph worker. Shown as a drawing (by default: each node draws
|
|
4
4
|
* its configuration; drag to pan, scroll to zoom, double-click to open a node in the simulator),
|
|
5
|
-
* as a list by depth, or as a tree. The simulator's current path is highlighted.
|
|
5
|
+
* as a list by depth, or as a tree. The simulator's current path is highlighted. It fills the
|
|
6
|
+
* space it is given; the selected node is shown, drawn, in a card over the graph.
|
|
6
7
|
*/
|
|
7
8
|
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
|
|
8
9
|
import type { GraphNode, GraphStats, WorkerRequest, WorkerResponse } from '@bramas/simulant-core/lib/config-graph';
|
|
9
|
-
import type { ExecutionLike, ExecutionStatus } from '@bramas/simulant-core/types/project';
|
|
10
|
+
import type { ExecutionLike, ExecutionStatus, RenderTheme } from '@bramas/simulant-core/types/project';
|
|
11
|
+
import { Icon } from './Icon';
|
|
10
12
|
import './ConfigGraphGenerator.css';
|
|
11
13
|
|
|
12
14
|
export type { ExecutionStatus } from '@bramas/simulant-core/types/project';
|
|
@@ -31,10 +33,13 @@ export interface ConfigGraphGeneratorProps<T, ConfigType, ModelType> {
|
|
|
31
33
|
currentPath?: number[];
|
|
32
34
|
/** Opens a node in the simulator, with the settings the graph was generated for. */
|
|
33
35
|
onOpen?: (settings: T, path: number[]) => void;
|
|
36
|
+
/** The page's drawing colors: the graph is redrawn when they change. */
|
|
37
|
+
theme?: RenderTheme;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
type View = 'visual' | 'list' | 'tree';
|
|
37
|
-
|
|
41
|
+
/** The preview of the selected node, in CSS pixels. */
|
|
42
|
+
const PREVIEW = 132;
|
|
38
43
|
const EMPTY_STATS: GraphStats = { totalNodes: 0, successNodes: 0, failureNodes: 0, inProgressNodes: 0, maxDepthReached: 0 };
|
|
39
44
|
const STATUS_TEXT = { success: 'success', failure: 'failure', inProgress: 'in progress' } as const;
|
|
40
45
|
const key = (path: number[]) => path.join('-');
|
|
@@ -64,10 +69,13 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
64
69
|
const [pan, setPan] = createSignal({ x: 0, y: 0 });
|
|
65
70
|
const [zoom, setZoom] = createSignal(1);
|
|
66
71
|
const [width, setWidth] = createSignal(1000);
|
|
72
|
+
const [height, setHeight] = createSignal(560);
|
|
67
73
|
|
|
68
74
|
let canvas: HTMLCanvasElement | undefined;
|
|
69
75
|
let frame: HTMLDivElement | undefined;
|
|
70
76
|
let worker: Worker | undefined;
|
|
77
|
+
// Until the user pans or zooms, the graph keeps fitting its frame.
|
|
78
|
+
let moved = false;
|
|
71
79
|
const radius = props.configRadius || 60;
|
|
72
80
|
const horizontalSpacing = props.horizontalSpacing || 150;
|
|
73
81
|
const verticalSpacing = props.verticalSpacing || 180;
|
|
@@ -96,9 +104,9 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
96
104
|
const xs = nodes.map(n => n.x!), ys = nodes.map(n => n.y!);
|
|
97
105
|
const margin = radius + 30;
|
|
98
106
|
const [x0, x1, y0, y1] = [Math.min(...xs) - margin, Math.max(...xs) + margin, Math.min(...ys) - margin, Math.max(...ys) + margin];
|
|
99
|
-
const z = Math.min(2, width() / (x1 - x0),
|
|
107
|
+
const z = Math.min(2, width() / (x1 - x0), height() / (y1 - y0));
|
|
100
108
|
setZoom(z);
|
|
101
|
-
setPan({ x: (width() - (x1 - x0) * z) / 2 - x0 * z, y: (
|
|
109
|
+
setPan({ x: (width() - (x1 - x0) * z) / 2 - x0 * z, y: (height() - (y1 - y0) * z) / 2 - y0 * z });
|
|
102
110
|
};
|
|
103
111
|
|
|
104
112
|
const generate = () => {
|
|
@@ -108,6 +116,7 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
108
116
|
setGraph([]);
|
|
109
117
|
setStats(EMPTY_STATS);
|
|
110
118
|
setGraphSettings({ settings: props.settings });
|
|
119
|
+
moved = false;
|
|
111
120
|
if (!props.projectName || !props.createWorker) {
|
|
112
121
|
setError('This page has no graph worker.');
|
|
113
122
|
return;
|
|
@@ -158,10 +167,17 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
158
167
|
if (s && props.onOpen) props.onOpen(s.settings, node.path);
|
|
159
168
|
};
|
|
160
169
|
|
|
161
|
-
// The canvas
|
|
170
|
+
// The canvas fills its frame.
|
|
162
171
|
onMount(() => {
|
|
163
172
|
if (!frame) return;
|
|
164
|
-
const observer = new ResizeObserver(entries =>
|
|
173
|
+
const observer = new ResizeObserver(entries => {
|
|
174
|
+
const { width: w, height: h } = entries[0].contentRect;
|
|
175
|
+
// A hidden frame (another view) has no size: keep the last one.
|
|
176
|
+
if (w <= 0 || h <= 0) return;
|
|
177
|
+
setWidth(Math.max(300, Math.floor(w)));
|
|
178
|
+
setHeight(Math.max(240, Math.floor(h)));
|
|
179
|
+
if (!moved) fit();
|
|
180
|
+
});
|
|
165
181
|
observer.observe(frame);
|
|
166
182
|
// Scrolling over the graph zooms it instead of scrolling the page: the listener cannot be passive.
|
|
167
183
|
canvas?.addEventListener('wheel', onWheel, { passive: false });
|
|
@@ -188,14 +204,16 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
188
204
|
const { x: px, y: py } = pan();
|
|
189
205
|
const z = zoom();
|
|
190
206
|
const w = width();
|
|
207
|
+
const h = height();
|
|
191
208
|
const highlighted = onPath();
|
|
192
209
|
const sel = selected();
|
|
210
|
+
const theme = props.theme;
|
|
193
211
|
if (!canvas || view() !== 'visual') return;
|
|
194
212
|
const ctx = canvas.getContext('2d');
|
|
195
213
|
if (!ctx) return;
|
|
196
214
|
const ratio = window.devicePixelRatio || 1;
|
|
197
215
|
canvas.width = w * ratio;
|
|
198
|
-
canvas.height =
|
|
216
|
+
canvas.height = h * ratio;
|
|
199
217
|
const css = getComputedStyle(canvas);
|
|
200
218
|
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
|
|
201
219
|
const palette = {
|
|
@@ -206,13 +224,13 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
206
224
|
const edge = color('--sim-border', '#ccc'), text = color('--sim-text', '#222'), accent = color('--sim-highlight', '#f5b301');
|
|
207
225
|
|
|
208
226
|
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
209
|
-
ctx.clearRect(0, 0, w,
|
|
227
|
+
ctx.clearRect(0, 0, w, h);
|
|
210
228
|
ctx.save();
|
|
211
229
|
ctx.translate(px, py);
|
|
212
230
|
ctx.scale(z, z);
|
|
213
231
|
const pad = radius * 2;
|
|
214
232
|
const visible = (n: GraphNode) => n.x !== undefined && n.y !== undefined
|
|
215
|
-
&& n.x >= -px / z - pad && n.x <= (w - px) / z + pad && n.y >= -py / z - pad && n.y <= (
|
|
233
|
+
&& n.x >= -px / z - pad && n.x <= (w - px) / z + pad && n.y >= -py / z - pad && n.y <= (h - py) / z + pad;
|
|
216
234
|
|
|
217
235
|
// Edges, the simulator's path on top.
|
|
218
236
|
for (const pass of [false, true]) {
|
|
@@ -253,7 +271,7 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
253
271
|
ctx.translate(n.x!, n.y!);
|
|
254
272
|
const scale = (radius - 8) / 100;
|
|
255
273
|
ctx.scale(scale, scale);
|
|
256
|
-
props.drawConfig(ctx, config, session.model, { centerX: 0, centerY: 0, radius: 100, nodeRadius: 10, agentRadius: 5, showNodeLabels: false });
|
|
274
|
+
props.drawConfig(ctx, config, session.model, { centerX: 0, centerY: 0, radius: 100, nodeRadius: 10, agentRadius: 5, showNodeLabels: false, theme });
|
|
257
275
|
ctx.restore();
|
|
258
276
|
} catch (e) {
|
|
259
277
|
console.error('Error drawing a configuration of the graph:', e);
|
|
@@ -282,7 +300,7 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
282
300
|
const onMove = (e: MouseEvent) => {
|
|
283
301
|
if (drag) {
|
|
284
302
|
const next = { x: e.clientX - drag.x, y: e.clientY - drag.y };
|
|
285
|
-
if (Math.abs(next.x - pan().x) + Math.abs(next.y - pan().y) > 2) drag.moved = true;
|
|
303
|
+
if (Math.abs(next.x - pan().x) + Math.abs(next.y - pan().y) > 2) drag.moved = moved = true;
|
|
286
304
|
setPan(next);
|
|
287
305
|
} else if (canvas) {
|
|
288
306
|
canvas.style.cursor = nodeAt(e) ? 'pointer' : 'grab';
|
|
@@ -295,6 +313,7 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
295
313
|
const onWheel = (e: WheelEvent) => {
|
|
296
314
|
if (!canvas) return;
|
|
297
315
|
e.preventDefault();
|
|
316
|
+
moved = true;
|
|
298
317
|
const rect = canvas.getBoundingClientRect();
|
|
299
318
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
300
319
|
const z = Math.min(Math.max(0.05, zoom() * Math.exp(-e.deltaY * 0.0015)), 5);
|
|
@@ -303,8 +322,9 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
303
322
|
setPan({ x: mx - wx * z, y: my - wy * z });
|
|
304
323
|
};
|
|
305
324
|
const zoomBy = (factor: number) => {
|
|
325
|
+
moved = true;
|
|
306
326
|
const z = Math.min(Math.max(0.05, zoom() * factor), 5);
|
|
307
|
-
const cx = width() / 2, cy =
|
|
327
|
+
const cx = width() / 2, cy = height() / 2;
|
|
308
328
|
const wx = (cx - pan().x) / zoom(), wy = (cy - pan().y) / zoom();
|
|
309
329
|
setZoom(z);
|
|
310
330
|
setPan({ x: cx - wx * z, y: cy - wy * z });
|
|
@@ -330,119 +350,157 @@ export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGrap
|
|
|
330
350
|
);
|
|
331
351
|
};
|
|
332
352
|
|
|
353
|
+
/** The selected node's configuration, drawn small. */
|
|
354
|
+
const Preview = (p: { node: GraphNode }) => {
|
|
355
|
+
let preview: HTMLCanvasElement | undefined;
|
|
356
|
+
createEffect(() => {
|
|
357
|
+
const session = graphExecution();
|
|
358
|
+
const ctx = preview?.getContext('2d');
|
|
359
|
+
if (!preview || !ctx || !session || !props.drawConfig) return;
|
|
360
|
+
const ratio = window.devicePixelRatio || 1;
|
|
361
|
+
preview.width = preview.height = PREVIEW * ratio;
|
|
362
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
363
|
+
ctx.clearRect(0, 0, PREVIEW, PREVIEW);
|
|
364
|
+
try {
|
|
365
|
+
const config = session.execution.get(p.node.path) as ConfigType;
|
|
366
|
+
props.drawConfig(ctx, config, session.model, { centerX: PREVIEW / 2, centerY: PREVIEW / 2, radius: PREVIEW * 0.34, nodeRadius: 3, agentRadius: 4, showNodeLabels: false, theme: props.theme });
|
|
367
|
+
} catch (e) {
|
|
368
|
+
console.error('Error drawing the selected configuration:', e);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
return <canvas ref={preview} class="cg-preview" style={{ width: `${PREVIEW}px`, height: `${PREVIEW}px` }} />;
|
|
372
|
+
};
|
|
373
|
+
|
|
333
374
|
return (
|
|
334
375
|
<div class="config-graph">
|
|
335
|
-
<div class="cg-
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
<
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
376
|
+
<div class="cg-toolbar">
|
|
377
|
+
<div class="cg-controls">
|
|
378
|
+
<label class="cg-depth">
|
|
379
|
+
<span>Max depth</span>
|
|
380
|
+
<input type="number" value={maxDepth()} min="0" max="1000" aria-label="Max depth (rounds)"
|
|
381
|
+
onInput={e => { const n = e.currentTarget.valueAsNumber; if (Number.isInteger(n) && n >= 0) setMaxDepth(n); }} />
|
|
382
|
+
<span class="sim-muted">rounds</span>
|
|
383
|
+
</label>
|
|
384
|
+
<Show when={!isGenerating()} fallback={<button class="sim-btn" onClick={stop}><Icon name="stop" size={14} /> Stop</button>}>
|
|
385
|
+
<button class="sim-primary" onClick={generate}><Icon name="play" size={14} /> Generate graph</button>
|
|
386
|
+
</Show>
|
|
387
|
+
<Show when={graph().length > 0 && !isGenerating()}>
|
|
388
|
+
<button class="sim-ghost" onClick={exportGraph}><Icon name="download" size={14} /> Export JSON</button>
|
|
389
|
+
</Show>
|
|
390
|
+
<Show when={stats().totalNodes > 0}>
|
|
391
|
+
<span class="cg-divider" />
|
|
392
|
+
<div class="cg-stats" aria-label="Statistics">
|
|
393
|
+
<span class="sim-chip">{stats().totalNodes} nodes{isGenerating() ? '…' : ''}</span>
|
|
394
|
+
<span class="sim-chip sim-success">{stats().successNodes} success</span>
|
|
395
|
+
<span class="sim-chip sim-failure">{stats().failureNodes} failure</span>
|
|
396
|
+
<span class="sim-chip sim-inProgress">{stats().inProgressNodes} in progress</span>
|
|
397
|
+
<span class="sim-chip">depth {stats().maxDepthReached}</span>
|
|
398
|
+
</div>
|
|
399
|
+
</Show>
|
|
400
|
+
<span class="sim-spacer" />
|
|
401
|
+
<Show when={graph().length > 0}>
|
|
402
|
+
<div class="sim-tabs" role="tablist" aria-label="Graph view">
|
|
403
|
+
<Show when={props.drawConfig}>
|
|
404
|
+
<button role="tab" aria-selected={view() === 'visual'} class={view() === 'visual' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => setView('visual')}>Drawing</button>
|
|
405
|
+
</Show>
|
|
406
|
+
<button role="tab" aria-selected={view() === 'list'} class={view() === 'list' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => setView('list')}>List</button>
|
|
407
|
+
<button role="tab" aria-selected={view() === 'tree'} class={view() === 'tree' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => setView('tree')}>Tree</button>
|
|
408
|
+
</div>
|
|
409
|
+
</Show>
|
|
410
|
+
</div>
|
|
411
|
+
<Show when={stats().failureNodes > 0 || stale()}>
|
|
412
|
+
<div class="cg-notes">
|
|
413
|
+
<Show when={stats().failureNodes > 0}><span>The generation stops at the first failure.</span></Show>
|
|
414
|
+
<Show when={stale()}><span class="cg-stale"><Icon name="alert" size={13} /> Generated for other settings: generate again to follow the current ones.</span></Show>
|
|
355
415
|
</div>
|
|
356
416
|
</Show>
|
|
417
|
+
<Show when={error()}>
|
|
418
|
+
<div class="sim-feedback sim-settings-error"><Icon name="alert" /><div>Could not generate the graph: {error()}</div></div>
|
|
419
|
+
</Show>
|
|
357
420
|
</div>
|
|
358
421
|
|
|
359
|
-
<
|
|
360
|
-
<div class="
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
<
|
|
375
|
-
|
|
422
|
+
<div class="cg-body">
|
|
423
|
+
<div ref={frame} class="cg-frame" style={{ display: graph().length > 0 && view() === 'visual' ? undefined : 'none' }}>
|
|
424
|
+
<canvas
|
|
425
|
+
ref={canvas}
|
|
426
|
+
class="cg-canvas"
|
|
427
|
+
style={{ width: `${width()}px`, height: `${height()}px` }}
|
|
428
|
+
onMouseDown={onDown}
|
|
429
|
+
onMouseMove={onMove}
|
|
430
|
+
onMouseUp={onUp}
|
|
431
|
+
onMouseLeave={() => { drag = null; }}
|
|
432
|
+
onDblClick={e => { const n = nodeAt(e); if (n) open(n); }}
|
|
433
|
+
/>
|
|
434
|
+
<div class="cg-zoom">
|
|
435
|
+
<button class="sim-ghost sim-icon sim-sm" onClick={() => zoomBy(1 / 1.25)} aria-label="Zoom out" title="Zoom out"><Icon name="minus" size={14} /></button>
|
|
436
|
+
<span class="cg-zoom-value">{Math.round(zoom() * 100)}%</span>
|
|
437
|
+
<button class="sim-ghost sim-icon sim-sm" onClick={() => zoomBy(1.25)} aria-label="Zoom in" title="Zoom in"><Icon name="plus" size={14} /></button>
|
|
438
|
+
<span class="cg-zoom-sep" />
|
|
439
|
+
<button class="sim-ghost sim-sm" onClick={() => { moved = false; fit(); }} title="Show the whole graph"><Icon name="fit" size={14} /> Fit</button>
|
|
440
|
+
</div>
|
|
441
|
+
<p class="cg-help">Drag to pan, scroll to zoom. Click a node to select it, double-click to open it in the simulator. The simulator's path is highlighted.</p>
|
|
376
442
|
</div>
|
|
377
|
-
</Show>
|
|
378
443
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
444
|
+
<Show when={graph().length > 0 && view() === 'list'}>
|
|
445
|
+
<div class="cg-list">
|
|
446
|
+
<For each={Array.from({ length: stats().maxDepthReached + 1 }, (_, i) => i)}>
|
|
447
|
+
{depth => {
|
|
448
|
+
const nodes = () => graph().filter(n => n.depth === depth);
|
|
449
|
+
return (
|
|
450
|
+
<Show when={nodes().length > 0}>
|
|
451
|
+
<details open={depth < 3}>
|
|
452
|
+
<summary>Round {depth} <span class="sim-muted">({nodes().length} nodes)</span></summary>
|
|
453
|
+
<For each={nodes()}>
|
|
454
|
+
{n => (
|
|
455
|
+
<button class={`cg-list-row cg-${n.status}${onPath().has(key(n.path)) ? ' cg-on-path' : ''}`} onClick={() => setSelected(n)} onDblClick={() => open(n)}>
|
|
456
|
+
<code>[{n.path.join(', ')}]</code>
|
|
457
|
+
<span>{STATUS_TEXT[n.status]}</span>
|
|
458
|
+
<span class="sim-muted">{n.childrenCount} children</span>
|
|
459
|
+
</button>
|
|
460
|
+
)}
|
|
461
|
+
</For>
|
|
462
|
+
</details>
|
|
463
|
+
</Show>
|
|
464
|
+
);
|
|
465
|
+
}}
|
|
466
|
+
</For>
|
|
389
467
|
</div>
|
|
390
|
-
|
|
391
|
-
</Show>
|
|
392
|
-
|
|
393
|
-
<div ref={frame} class="cg-frame" style={{ display: graph().length > 0 && view() === 'visual' ? 'block' : 'none' }}>
|
|
394
|
-
<canvas
|
|
395
|
-
ref={canvas}
|
|
396
|
-
class="cg-canvas"
|
|
397
|
-
style={{ width: `${width()}px`, height: `${HEIGHT}px` }}
|
|
398
|
-
onMouseDown={onDown}
|
|
399
|
-
onMouseMove={onMove}
|
|
400
|
-
onMouseUp={onUp}
|
|
401
|
-
onMouseLeave={() => { drag = null; }}
|
|
402
|
-
onDblClick={e => { const n = nodeAt(e); if (n) open(n); }}
|
|
403
|
-
/>
|
|
404
|
-
<div class="cg-zoom">
|
|
405
|
-
<button class="sim-ghost sim-icon" onClick={() => zoomBy(1 / 1.25)} title="Zoom out">−</button>
|
|
406
|
-
<span class="sim-muted">{Math.round(zoom() * 100)}%</span>
|
|
407
|
-
<button class="sim-ghost sim-icon" onClick={() => zoomBy(1.25)} title="Zoom in">+</button>
|
|
408
|
-
<button class="sim-ghost" onClick={fit} title="Show the whole graph">Fit</button>
|
|
409
|
-
</div>
|
|
410
|
-
<p class="sim-muted cg-help">Drag to pan, scroll to zoom. Click a node to select it, double-click to open it in the simulator. The simulator's path is highlighted.</p>
|
|
411
|
-
</div>
|
|
468
|
+
</Show>
|
|
412
469
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
{depth => {
|
|
417
|
-
const nodes = () => graph().filter(n => n.depth === depth);
|
|
418
|
-
return (
|
|
419
|
-
<Show when={nodes().length > 0}>
|
|
420
|
-
<details open={depth < 3}>
|
|
421
|
-
<summary>Round {depth} <span class="sim-muted">({nodes().length} nodes)</span></summary>
|
|
422
|
-
<For each={nodes()}>
|
|
423
|
-
{n => (
|
|
424
|
-
<button class={`cg-list-row cg-${n.status}${onPath().has(key(n.path)) ? ' cg-on-path' : ''}`} onClick={() => setSelected(n)} onDblClick={() => open(n)}>
|
|
425
|
-
<code>[{n.path.join(', ')}]</code>
|
|
426
|
-
<span>{STATUS_TEXT[n.status]}</span>
|
|
427
|
-
<span class="sim-muted">{n.childrenCount} children</span>
|
|
428
|
-
</button>
|
|
429
|
-
)}
|
|
430
|
-
</For>
|
|
431
|
-
</details>
|
|
432
|
-
</Show>
|
|
433
|
-
);
|
|
434
|
-
}}
|
|
435
|
-
</For>
|
|
436
|
-
</div>
|
|
437
|
-
</Show>
|
|
470
|
+
<Show when={graph().length > 0 && view() === 'tree'}>
|
|
471
|
+
<div class="cg-tree"><Tree path={[0]} /></div>
|
|
472
|
+
</Show>
|
|
438
473
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
474
|
+
<Show when={graph().length === 0 && !isGenerating() && !error()}>
|
|
475
|
+
<div class="cg-empty">
|
|
476
|
+
<Icon name="graph" size={32} />
|
|
477
|
+
<strong>No graph yet</strong>
|
|
478
|
+
<span>Generate the graph to see every execution of these settings up to the depth, with the verdict of each configuration.</span>
|
|
479
|
+
<button class="sim-primary" onClick={generate}><Icon name="play" size={14} /> Generate up to depth {maxDepth()}</button>
|
|
480
|
+
</div>
|
|
481
|
+
</Show>
|
|
442
482
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
483
|
+
<Show when={selected()}>
|
|
484
|
+
{n => (
|
|
485
|
+
<div class="cg-selection" role="region" aria-label="Selected configuration">
|
|
486
|
+
<div class="cg-selection-head">
|
|
487
|
+
<span class={`sim-chip sim-${n().status}`}>{STATUS_TEXT[n().status]}</span>
|
|
488
|
+
<span class="sim-spacer" />
|
|
489
|
+
<button class="sim-ghost sim-icon sim-sm" onClick={() => setSelected(null)} aria-label="Clear the selection" title="Clear the selection"><Icon name="close" size={14} /></button>
|
|
490
|
+
</div>
|
|
491
|
+
<Show when={props.drawConfig}><Preview node={n()} /></Show>
|
|
492
|
+
<dl class="cg-selection-facts">
|
|
493
|
+
<dt>Path</dt><dd><code>[{n().path.join(', ')}]</code></dd>
|
|
494
|
+
<dt>Round</dt><dd>{n().depth}</dd>
|
|
495
|
+
<dt>Explored</dt><dd>{n().childrenCount} {n().childrenCount === 1 ? 'child' : 'children'}</dd>
|
|
496
|
+
</dl>
|
|
497
|
+
<Show when={props.onOpen}>
|
|
498
|
+
<button class="sim-primary sim-sm" onClick={() => open(n())}>Open in simulator</button>
|
|
499
|
+
</Show>
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
502
|
+
</Show>
|
|
503
|
+
</div>
|
|
446
504
|
</div>
|
|
447
505
|
);
|
|
448
506
|
}
|
|
@@ -187,32 +187,8 @@ function CodeEditor(props: {
|
|
|
187
187
|
};
|
|
188
188
|
|
|
189
189
|
return (
|
|
190
|
-
<div
|
|
191
|
-
|
|
192
|
-
display: 'grid',
|
|
193
|
-
'grid-template-columns': '3.5em minmax(0, 1fr)',
|
|
194
|
-
border: '1px solid #c9d2dc',
|
|
195
|
-
'border-radius': '6px',
|
|
196
|
-
overflow: 'hidden',
|
|
197
|
-
'background-color': '#f8fafc'
|
|
198
|
-
}}
|
|
199
|
-
>
|
|
200
|
-
<div
|
|
201
|
-
ref={gutterRef}
|
|
202
|
-
aria-hidden="true"
|
|
203
|
-
style={{
|
|
204
|
-
'font-family': 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
|
|
205
|
-
'font-size': '0.9em',
|
|
206
|
-
'line-height': '1.5',
|
|
207
|
-
padding: '0.75em 0.5em',
|
|
208
|
-
'text-align': 'right',
|
|
209
|
-
color: '#6b7280',
|
|
210
|
-
'background-color': '#eef2f7',
|
|
211
|
-
'border-right': '1px solid #d7dee7',
|
|
212
|
-
overflow: 'hidden',
|
|
213
|
-
'user-select': 'none'
|
|
214
|
-
}}
|
|
215
|
-
>
|
|
190
|
+
<div class="sim-code-editor">
|
|
191
|
+
<div ref={gutterRef} class="sim-code-gutter" aria-hidden="true">
|
|
216
192
|
<For each={lineNumbers()}>
|
|
217
193
|
{(lineNumber) => <div>{lineNumber}</div>}
|
|
218
194
|
</For>
|
|
@@ -226,21 +202,6 @@ function CodeEditor(props: {
|
|
|
226
202
|
placeholder={props.placeholder}
|
|
227
203
|
rows={props.rows ?? 12}
|
|
228
204
|
spellcheck={false}
|
|
229
|
-
style={{
|
|
230
|
-
width: '100%',
|
|
231
|
-
margin: '0',
|
|
232
|
-
border: 'none',
|
|
233
|
-
outline: 'none',
|
|
234
|
-
resize: 'vertical',
|
|
235
|
-
padding: '0.75em',
|
|
236
|
-
'background-color': '#fcfdff',
|
|
237
|
-
color: '#17202a',
|
|
238
|
-
'font-family': 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace',
|
|
239
|
-
'font-size': '0.9em',
|
|
240
|
-
'line-height': '1.5',
|
|
241
|
-
'white-space': 'pre',
|
|
242
|
-
'tab-size': '2'
|
|
243
|
-
}}
|
|
244
205
|
/>
|
|
245
206
|
</div>
|
|
246
207
|
);
|
|
@@ -248,6 +209,17 @@ function CodeEditor(props: {
|
|
|
248
209
|
|
|
249
210
|
type SetField = (key: string, value: any) => void;
|
|
250
211
|
|
|
212
|
+
/** A field's label, with its placeholder as a hint. */
|
|
213
|
+
function FieldLabel(props: { field: EditorField }) {
|
|
214
|
+
const hint = () => 'placeholder' in props.field ? props.field.placeholder : undefined;
|
|
215
|
+
return (
|
|
216
|
+
<span class="sim-field-label">
|
|
217
|
+
{props.field.label}
|
|
218
|
+
{hint() && <span class="sim-field-hint">{hint()}</span>}
|
|
219
|
+
</span>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
251
223
|
function renderTextArea(
|
|
252
224
|
field: Extract<EditorField, { type: 'text' | 'textarea' | 'code' }>,
|
|
253
225
|
fieldStore: { [key: string]: any; },
|
|
@@ -258,10 +230,7 @@ function renderTextArea(
|
|
|
258
230
|
return (
|
|
259
231
|
<div class="sim-field sim-field-wide">
|
|
260
232
|
<label>
|
|
261
|
-
{field
|
|
262
|
-
{'placeholder' in field && field.placeholder && (
|
|
263
|
-
<span class="sim-muted">({field.placeholder})</span>
|
|
264
|
-
)}
|
|
233
|
+
<FieldLabel field={field} />
|
|
265
234
|
{isCodeEditor ? (
|
|
266
235
|
<CodeEditor
|
|
267
236
|
value={fieldStore[field.key] ?? ''}
|
|
@@ -291,7 +260,7 @@ function RenderField(props: {field: EditorField, fieldStore: {[key: string]: any
|
|
|
291
260
|
{(f) => (
|
|
292
261
|
<div class="sim-field">
|
|
293
262
|
<label>
|
|
294
|
-
{f().label}
|
|
263
|
+
<span class="sim-field-label">{f().label}</span>
|
|
295
264
|
<input
|
|
296
265
|
type="number"
|
|
297
266
|
value={props.fieldStore[props.field.key]}
|
|
@@ -312,13 +281,10 @@ function RenderField(props: {field: EditorField, fieldStore: {[key: string]: any
|
|
|
312
281
|
<Match when={props.field.type === 'text' && props.field && !('multiline' in props.field && props.field.multiline)}>
|
|
313
282
|
<div class="sim-field sim-field-wide">
|
|
314
283
|
<label>
|
|
315
|
-
{props.field
|
|
316
|
-
{'placeholder' in props.field && props.field.placeholder && (
|
|
317
|
-
<span class="sim-muted">({props.field.placeholder})</span>
|
|
318
|
-
)}
|
|
284
|
+
<FieldLabel field={props.field} />
|
|
319
285
|
<input
|
|
320
|
-
type="text"
|
|
321
|
-
value={props.fieldStore[props.field.key] ?? ''}
|
|
286
|
+
type="text"
|
|
287
|
+
value={props.fieldStore[props.field.key] ?? ''}
|
|
322
288
|
onInput={(e) => props.setFieldStore(props.field.key, e.currentTarget.value)}
|
|
323
289
|
placeholder={'placeholder' in props.field ? props.field.placeholder : undefined}
|
|
324
290
|
pattern={'pattern' in props.field ? props.field.pattern : undefined}
|
|
@@ -335,17 +301,17 @@ function RenderField(props: {field: EditorField, fieldStore: {[key: string]: any
|
|
|
335
301
|
props.setFieldStore
|
|
336
302
|
)}
|
|
337
303
|
</Match>
|
|
338
|
-
|
|
304
|
+
|
|
339
305
|
<Match when={props.field.type === 'boolean' && props.field}>
|
|
340
306
|
{(f) => (
|
|
341
|
-
<div class="sim-field">
|
|
307
|
+
<div class="sim-field sim-field-check">
|
|
342
308
|
<label>
|
|
343
309
|
<input
|
|
344
310
|
type="checkbox"
|
|
345
311
|
checked={props.fieldStore[props.field.key]}
|
|
346
312
|
onInput={(e) => props.setFieldStore(props.field.key, e.currentTarget.checked)}
|
|
347
313
|
/>
|
|
348
|
-
{
|
|
314
|
+
{f().label}
|
|
349
315
|
</label>
|
|
350
316
|
</div>
|
|
351
317
|
)}
|
|
@@ -355,11 +321,11 @@ function RenderField(props: {field: EditorField, fieldStore: {[key: string]: any
|
|
|
355
321
|
{(f) => (
|
|
356
322
|
<div class="sim-field">
|
|
357
323
|
<label>
|
|
358
|
-
{
|
|
359
|
-
<select
|
|
360
|
-
value={props.fieldStore[props.field.key]}
|
|
324
|
+
<span class="sim-field-label">{f().label}</span>
|
|
325
|
+
<select
|
|
326
|
+
value={props.fieldStore[props.field.key]}
|
|
361
327
|
onInput={(e) => props.setFieldStore(props.field.key,
|
|
362
|
-
typeof props.field.defaultValue === 'number'
|
|
328
|
+
typeof props.field.defaultValue === 'number'
|
|
363
329
|
? parseFloat(e.currentTarget.value)
|
|
364
330
|
: e.currentTarget.value
|
|
365
331
|
)}
|