@bramas/simulant-ui 0.0.0-stage → 0.1.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/LICENCE.txt +515 -0
- package/dist/SimulatorApp.jsx +586 -0
- package/dist/components/AlgorithmViewer.css +270 -0
- package/dist/components/AlgorithmViewer.jsx +218 -0
- package/dist/components/BranchPicker.css +96 -0
- package/dist/components/BranchPicker.jsx +65 -0
- package/dist/components/ConfigGraphGenerator.css +129 -0
- package/dist/components/ConfigGraphGenerator.jsx +408 -0
- package/dist/components/GenericEditor.jsx +228 -0
- package/dist/components/LeanView.jsx +133 -0
- package/dist/components/ModelView.css +520 -0
- package/dist/components/ModelView.jsx +272 -0
- package/dist/components/SettingsLibrary.jsx +61 -0
- package/dist/components/StateInspector.css +136 -0
- package/dist/components/StateInspector.jsx +103 -0
- package/dist/components/highlight.js +99 -0
- package/dist/pico.min.css +4 -0
- package/dist/renderers/define.js +28 -0
- package/dist/renderers/ring.js +103 -0
- package/dist/sim/branches.js +28 -0
- package/dist/sim/describe.js +403 -0
- package/dist/sim/execution.js +42 -0
- package/dist/sim/project.js +344 -0
- package/dist/sim/snapshot.js +29 -0
- package/dist/sim/types.js +1 -0
- package/dist/simulator.css +584 -0
- package/package.json +50 -3
- package/src/SimulatorApp.tsx +646 -0
- package/src/components/AlgorithmViewer.css +270 -0
- package/src/components/AlgorithmViewer.tsx +256 -0
- package/src/components/BranchPicker.css +96 -0
- package/src/components/BranchPicker.tsx +90 -0
- package/src/components/ConfigGraphGenerator.css +129 -0
- package/src/components/ConfigGraphGenerator.tsx +448 -0
- package/src/components/GenericEditor.tsx +413 -0
- package/src/components/LeanView.tsx +162 -0
- package/src/components/ModelView.css +520 -0
- package/src/components/ModelView.tsx +317 -0
- package/src/components/SettingsLibrary.tsx +84 -0
- package/src/components/StateInspector.css +136 -0
- package/src/components/StateInspector.tsx +125 -0
- package/src/components/highlight.ts +100 -0
- package/src/pico.min.css +4 -0
- package/src/renderers/define.ts +54 -0
- package/src/renderers/ring.ts +112 -0
- package/src/sim/branches.ts +30 -0
- package/src/sim/describe.ts +411 -0
- package/src/sim/execution.ts +65 -0
- package/src/sim/project.ts +372 -0
- package/src/sim/snapshot.ts +60 -0
- package/src/sim/types.ts +28 -0
- package/src/simulator.css +584 -0
- package/README.md +0 -3
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The branches of a step: the configurations the adversary's choices lead to. A window lists
|
|
3
|
+
* them all, each with a drawing of its configuration, its verdict and what distinguishes it, to
|
|
4
|
+
* pick one.
|
|
5
|
+
*/
|
|
6
|
+
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
|
|
7
|
+
import type { ExecutionStatus } from '@bramas/simulant-core/types/project';
|
|
8
|
+
import './BranchPicker.css';
|
|
9
|
+
|
|
10
|
+
export type Branch = {
|
|
11
|
+
index: number;
|
|
12
|
+
/** The step's witness, every choice of the round ("a0: as is; a1: mirrored; no crash"). */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Only the choices that differ between the branches ("a1: mirrored"). */
|
|
15
|
+
short: string;
|
|
16
|
+
verdict?: ExecutionStatus;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export { distinguishing } from '../sim/branches';
|
|
20
|
+
|
|
21
|
+
const VERDICT_TEXT = { success: 'success', failure: 'failure', inProgress: 'not yet' } as const;
|
|
22
|
+
|
|
23
|
+
export interface BranchPickerProps {
|
|
24
|
+
branches: Branch[];
|
|
25
|
+
current: number;
|
|
26
|
+
onPick: (index: number) => void;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
/** Draws the configuration of a branch in a canvas of the given size (CSS pixels). */
|
|
29
|
+
draw?: (index: number, ctx: CanvasRenderingContext2D, size: number) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const THUMB = 150;
|
|
33
|
+
|
|
34
|
+
export function BranchPicker(props: BranchPickerProps) {
|
|
35
|
+
const [filter, setFilter] = createSignal('');
|
|
36
|
+
const shown = createMemo(() => {
|
|
37
|
+
const f = filter().trim().toLowerCase();
|
|
38
|
+
return f ? props.branches.filter(b => `${b.index} ${b.label}`.toLowerCase().includes(f)) : props.branches;
|
|
39
|
+
});
|
|
40
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') props.onClose(); };
|
|
41
|
+
onMount(() => window.addEventListener('keydown', onKey));
|
|
42
|
+
onCleanup(() => window.removeEventListener('keydown', onKey));
|
|
43
|
+
|
|
44
|
+
const Thumb = (p: { index: number }) => {
|
|
45
|
+
let canvas: HTMLCanvasElement | undefined;
|
|
46
|
+
onMount(() => {
|
|
47
|
+
const ctx = canvas?.getContext('2d');
|
|
48
|
+
if (!canvas || !ctx || !props.draw) return;
|
|
49
|
+
const ratio = window.devicePixelRatio || 1;
|
|
50
|
+
canvas.width = canvas.height = THUMB * ratio;
|
|
51
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
52
|
+
try {
|
|
53
|
+
props.draw(p.index, ctx, THUMB);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error('Error drawing a branch:', e);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
return <canvas ref={canvas} class="bp-thumb" style={{ width: `${THUMB}px`, height: `${THUMB}px` }} />;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div class="bp-backdrop" onClick={e => { if (e.target === e.currentTarget) props.onClose(); }}>
|
|
63
|
+
<div class="bp-window" role="dialog" aria-modal="true" aria-label="Branches">
|
|
64
|
+
<div class="bp-header">
|
|
65
|
+
<h3>{props.branches.length} branches at this step</h3>
|
|
66
|
+
<Show when={props.branches.length > 8}>
|
|
67
|
+
<input type="search" placeholder="Filter (index or choice)" value={filter()} onInput={e => setFilter(e.currentTarget.value)} aria-label="Filter the branches" />
|
|
68
|
+
</Show>
|
|
69
|
+
<span style={{ flex: 1 }} />
|
|
70
|
+
<button class="sim-ghost sim-icon" onClick={props.onClose} title="Close (Esc)">✕</button>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="sim-muted bp-help">Each branch is one way the adversary can play this round; only the choices that differ between branches and are not the first option are listed.</p>
|
|
73
|
+
<div class="bp-grid">
|
|
74
|
+
<For each={shown()} fallback={<p class="sim-muted">No branch matches.</p>}>
|
|
75
|
+
{b => (
|
|
76
|
+
<button class={`bp-branch${b.index === props.current ? ' bp-current' : ''}`} onClick={() => props.onPick(b.index)} title={b.label}>
|
|
77
|
+
<Show when={props.draw}><Thumb index={b.index} /></Show>
|
|
78
|
+
<span class="bp-caption">
|
|
79
|
+
<strong>Branch {b.index}</strong>
|
|
80
|
+
<Show when={b.verdict}>{v => <span class={`sim-chip sim-${v()}`}>{VERDICT_TEXT[v()]}</span>}</Show>
|
|
81
|
+
</span>
|
|
82
|
+
<span class="bp-label">{b.short || b.label || '(no choice)'}</span>
|
|
83
|
+
</button>
|
|
84
|
+
)}
|
|
85
|
+
</For>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* The configuration graph (tokens in simulator.css). */
|
|
2
|
+
|
|
3
|
+
.cg-controls {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: flex-end;
|
|
6
|
+
gap: 0.6rem;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.cg-depth {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 0.5rem;
|
|
14
|
+
white-space: nowrap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.cg-depth input {
|
|
18
|
+
width: 5.5rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.cg-views {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
gap: 0.25rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.cg-stats {
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
flex-wrap: wrap;
|
|
30
|
+
gap: 0.4rem;
|
|
31
|
+
margin-top: 0.75rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.cg-selection {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 0.6rem;
|
|
38
|
+
flex-wrap: wrap;
|
|
39
|
+
margin-top: 0.75rem;
|
|
40
|
+
padding: 0.45rem 0.6rem;
|
|
41
|
+
border-radius: 8px;
|
|
42
|
+
background: var(--sim-surface-2);
|
|
43
|
+
font-size: 0.85rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.cg-frame {
|
|
47
|
+
position: relative;
|
|
48
|
+
margin-top: 0.75rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.cg-canvas {
|
|
52
|
+
display: block;
|
|
53
|
+
background: var(--sim-surface-2);
|
|
54
|
+
border: 1px solid var(--sim-border);
|
|
55
|
+
border-radius: 8px;
|
|
56
|
+
cursor: grab;
|
|
57
|
+
touch-action: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.cg-zoom {
|
|
61
|
+
position: absolute;
|
|
62
|
+
top: 0.5rem;
|
|
63
|
+
right: 0.5rem;
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 0.3rem;
|
|
67
|
+
background: var(--sim-surface);
|
|
68
|
+
border: 1px solid var(--sim-border);
|
|
69
|
+
border-radius: 8px;
|
|
70
|
+
padding: 0.2rem 0.3rem;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.cg-help {
|
|
74
|
+
margin: 0.4rem 0 0;
|
|
75
|
+
font-size: 0.78rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.cg-list,
|
|
79
|
+
.cg-tree {
|
|
80
|
+
margin-top: 0.75rem;
|
|
81
|
+
max-height: 480px;
|
|
82
|
+
overflow: auto;
|
|
83
|
+
border: 1px solid var(--sim-border);
|
|
84
|
+
border-radius: 8px;
|
|
85
|
+
padding: 0.5rem 0.75rem;
|
|
86
|
+
font-size: 0.85rem;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.cg-list summary {
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
margin: 0.3rem 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.config-graph .cg-list-row,
|
|
96
|
+
.config-graph .cg-node-link {
|
|
97
|
+
display: flex;
|
|
98
|
+
gap: 0.8rem;
|
|
99
|
+
align-items: baseline;
|
|
100
|
+
width: 100%;
|
|
101
|
+
text-align: left;
|
|
102
|
+
background: transparent;
|
|
103
|
+
border: none;
|
|
104
|
+
border-left: 3px solid transparent;
|
|
105
|
+
border-radius: 4px;
|
|
106
|
+
color: var(--sim-text);
|
|
107
|
+
padding: 0.15rem 0.5rem;
|
|
108
|
+
font-size: 0.82rem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.config-graph .cg-node-link {
|
|
112
|
+
width: auto;
|
|
113
|
+
font-family: var(--sim-mono);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.config-graph .cg-list-row:hover,
|
|
117
|
+
.config-graph .cg-node-link:hover {
|
|
118
|
+
background: var(--sim-surface-2);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.cg-success { border-left-color: var(--sim-success) !important; }
|
|
122
|
+
.cg-failure { border-left-color: var(--sim-failure) !important; }
|
|
123
|
+
.cg-inProgress { border-left-color: var(--sim-progress) !important; }
|
|
124
|
+
.config-graph .cg-on-path { background: var(--sim-highlight-soft); }
|
|
125
|
+
|
|
126
|
+
.cg-tree-children {
|
|
127
|
+
margin-left: 1.2rem;
|
|
128
|
+
border-left: 1px dotted var(--sim-border);
|
|
129
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The configuration graph of the current settings: every execution, round by round, up to a
|
|
3
|
+
* depth, generated by the project's graph worker. Shown as a drawing (by default: each node draws
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
|
|
8
|
+
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 './ConfigGraphGenerator.css';
|
|
11
|
+
|
|
12
|
+
export type { ExecutionStatus } from '@bramas/simulant-core/types/project';
|
|
13
|
+
export type { GraphNode, WorkerRequest, WorkerResponse } from '@bramas/simulant-core/lib/config-graph';
|
|
14
|
+
|
|
15
|
+
export interface ConfigGraphGeneratorProps<T, ConfigType, ModelType> {
|
|
16
|
+
settings: T;
|
|
17
|
+
setup: (settings: T) => ModelType;
|
|
18
|
+
executionStatusChecker: (config: ConfigType, model: ModelType, configs: ConfigType[]) => ExecutionStatus;
|
|
19
|
+
drawConfig?: (ctx: CanvasRenderingContext2D, config: ConfigType, model: ModelType, options: any) => any;
|
|
20
|
+
configRadius?: number;
|
|
21
|
+
horizontalSpacing?: number;
|
|
22
|
+
verticalSpacing?: number;
|
|
23
|
+
/** Name of the project for the worker. */
|
|
24
|
+
projectName?: string;
|
|
25
|
+
createWorker?: () => Worker;
|
|
26
|
+
/** The project's tree of executions. */
|
|
27
|
+
execute: (model: any) => ExecutionLike;
|
|
28
|
+
/** The revision of the project's sources: the worker refuses to answer for another one. */
|
|
29
|
+
revision?: string;
|
|
30
|
+
/** The simulator's current path, highlighted in the graph. */
|
|
31
|
+
currentPath?: number[];
|
|
32
|
+
/** Opens a node in the simulator, with the settings the graph was generated for. */
|
|
33
|
+
onOpen?: (settings: T, path: number[]) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type View = 'visual' | 'list' | 'tree';
|
|
37
|
+
const HEIGHT = 560;
|
|
38
|
+
const EMPTY_STATS: GraphStats = { totalNodes: 0, successNodes: 0, failureNodes: 0, inProgressNodes: 0, maxDepthReached: 0 };
|
|
39
|
+
const STATUS_TEXT = { success: 'success', failure: 'failure', inProgress: 'in progress' } as const;
|
|
40
|
+
const key = (path: number[]) => path.join('-');
|
|
41
|
+
|
|
42
|
+
/** Each round's row centered under the first one (the worker lays rows out from the left). */
|
|
43
|
+
function centered(nodes: GraphNode[]): GraphNode[] {
|
|
44
|
+
const rows = new Map<number, GraphNode[]>();
|
|
45
|
+
for (const n of nodes) rows.set(n.depth, [...(rows.get(n.depth) ?? []), n]);
|
|
46
|
+
const xs = nodes.map(n => n.x ?? 0);
|
|
47
|
+
const middle = (Math.min(...xs) + Math.max(...xs)) / 2;
|
|
48
|
+
return nodes.map(n => {
|
|
49
|
+
const row = rows.get(n.depth)!;
|
|
50
|
+
const rx = row.map(m => m.x ?? 0);
|
|
51
|
+
return { ...n, x: (n.x ?? 0) - (Math.min(...rx) + Math.max(...rx)) / 2 + middle };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ConfigGraphGenerator<T, ConfigType, ModelType>(props: ConfigGraphGeneratorProps<T, ConfigType, ModelType>) {
|
|
56
|
+
const [maxDepth, setMaxDepth] = createSignal(10);
|
|
57
|
+
const [isGenerating, setIsGenerating] = createSignal(false);
|
|
58
|
+
const [error, setError] = createSignal('');
|
|
59
|
+
const [graph, setGraph] = createSignal<GraphNode[]>([]);
|
|
60
|
+
const [graphSettings, setGraphSettings] = createSignal<{ settings: T } | null>(null);
|
|
61
|
+
const [stats, setStats] = createSignal<GraphStats>(EMPTY_STATS);
|
|
62
|
+
const [view, setView] = createSignal<View>(props.drawConfig ? 'visual' : 'list');
|
|
63
|
+
const [selected, setSelected] = createSignal<GraphNode | null>(null);
|
|
64
|
+
const [pan, setPan] = createSignal({ x: 0, y: 0 });
|
|
65
|
+
const [zoom, setZoom] = createSignal(1);
|
|
66
|
+
const [width, setWidth] = createSignal(1000);
|
|
67
|
+
|
|
68
|
+
let canvas: HTMLCanvasElement | undefined;
|
|
69
|
+
let frame: HTMLDivElement | undefined;
|
|
70
|
+
let worker: Worker | undefined;
|
|
71
|
+
const radius = props.configRadius || 60;
|
|
72
|
+
const horizontalSpacing = props.horizontalSpacing || 150;
|
|
73
|
+
const verticalSpacing = props.verticalSpacing || 180;
|
|
74
|
+
|
|
75
|
+
const stop = () => {
|
|
76
|
+
worker?.terminate();
|
|
77
|
+
worker = undefined;
|
|
78
|
+
setIsGenerating(false);
|
|
79
|
+
};
|
|
80
|
+
onCleanup(stop);
|
|
81
|
+
|
|
82
|
+
const nodesByPath = createMemo(() => new Map(graph().map(n => [key(n.path), n])));
|
|
83
|
+
const stale = createMemo(() => !!graphSettings() && JSON.stringify(graphSettings()!.settings) !== JSON.stringify(props.settings));
|
|
84
|
+
/** The simulator's path, when the graph shows the same settings. */
|
|
85
|
+
const onPath = createMemo(() => {
|
|
86
|
+
const out = new Set<string>();
|
|
87
|
+
if (stale() || !props.currentPath) return out;
|
|
88
|
+
for (let i = 1; i <= props.currentPath.length; i++) out.add(key(props.currentPath.slice(0, i)));
|
|
89
|
+
return out;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** Pan and zoom so that the whole graph fits the canvas. */
|
|
93
|
+
const fit = () => {
|
|
94
|
+
const nodes = graph().filter(n => n.x !== undefined && n.y !== undefined);
|
|
95
|
+
if (!nodes.length) return;
|
|
96
|
+
const xs = nodes.map(n => n.x!), ys = nodes.map(n => n.y!);
|
|
97
|
+
const margin = radius + 30;
|
|
98
|
+
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), HEIGHT / (y1 - y0));
|
|
100
|
+
setZoom(z);
|
|
101
|
+
setPan({ x: (width() - (x1 - x0) * z) / 2 - x0 * z, y: (HEIGHT - (y1 - y0) * z) / 2 - y0 * z });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const generate = () => {
|
|
105
|
+
stop();
|
|
106
|
+
setError('');
|
|
107
|
+
setSelected(null);
|
|
108
|
+
setGraph([]);
|
|
109
|
+
setStats(EMPTY_STATS);
|
|
110
|
+
setGraphSettings({ settings: props.settings });
|
|
111
|
+
if (!props.projectName || !props.createWorker) {
|
|
112
|
+
setError('This page has no graph worker.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
worker = props.createWorker();
|
|
117
|
+
} catch (e) {
|
|
118
|
+
setError(`The graph worker could not start: ${e instanceof Error ? e.message : String(e)}`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setIsGenerating(true);
|
|
122
|
+
let fitted = false;
|
|
123
|
+
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
|
124
|
+
const r = e.data;
|
|
125
|
+
if (r.type === 'error') {
|
|
126
|
+
setError(r.error ?? 'unknown error');
|
|
127
|
+
stop();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (r.nodes) setGraph(centered(r.nodes));
|
|
131
|
+
if (r.stats) setStats(r.stats);
|
|
132
|
+
if (!fitted || r.type === 'complete') { fit(); fitted = true; }
|
|
133
|
+
if (r.type === 'complete') stop();
|
|
134
|
+
};
|
|
135
|
+
worker.onerror = (e) => {
|
|
136
|
+
setError(`Graph worker error: ${e.message}`);
|
|
137
|
+
stop();
|
|
138
|
+
};
|
|
139
|
+
const request: WorkerRequest = {
|
|
140
|
+
type: 'generate', maxDepth: maxDepth(), horizontalSpacing, verticalSpacing,
|
|
141
|
+
settings: props.settings, projectName: props.projectName, revision: props.revision,
|
|
142
|
+
};
|
|
143
|
+
worker.postMessage(request);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const exportGraph = () => {
|
|
147
|
+
const data = { settings: graphSettings()?.settings, maxDepth: maxDepth(), stats: stats(), nodes: graph() };
|
|
148
|
+
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
|
|
149
|
+
const a = document.createElement('a');
|
|
150
|
+
a.href = url;
|
|
151
|
+
a.download = `config-graph-${Date.now()}.json`;
|
|
152
|
+
a.click();
|
|
153
|
+
URL.revokeObjectURL(url);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const open = (node: GraphNode) => {
|
|
157
|
+
const s = graphSettings();
|
|
158
|
+
if (s && props.onOpen) props.onOpen(s.settings, node.path);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// The canvas follows the width of its frame.
|
|
162
|
+
onMount(() => {
|
|
163
|
+
if (!frame) return;
|
|
164
|
+
const observer = new ResizeObserver(entries => setWidth(Math.max(300, Math.floor(entries[0].contentRect.width))));
|
|
165
|
+
observer.observe(frame);
|
|
166
|
+
// Scrolling over the graph zooms it instead of scrolling the page: the listener cannot be passive.
|
|
167
|
+
canvas?.addEventListener('wheel', onWheel, { passive: false });
|
|
168
|
+
onCleanup(() => {
|
|
169
|
+
observer.disconnect();
|
|
170
|
+
canvas?.removeEventListener('wheel', onWheel);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Keep the execution cache across redraws; panning and zooming only change canvas coordinates.
|
|
175
|
+
const graphExecution = createMemo(() => {
|
|
176
|
+
const saved = graphSettings();
|
|
177
|
+
if (!saved || !props.drawConfig) return null;
|
|
178
|
+
try {
|
|
179
|
+
const model = props.setup(saved.settings);
|
|
180
|
+
return { model, execution: props.execute(model) };
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
createEffect(() => {
|
|
187
|
+
const nodes = graph();
|
|
188
|
+
const { x: px, y: py } = pan();
|
|
189
|
+
const z = zoom();
|
|
190
|
+
const w = width();
|
|
191
|
+
const highlighted = onPath();
|
|
192
|
+
const sel = selected();
|
|
193
|
+
if (!canvas || view() !== 'visual') return;
|
|
194
|
+
const ctx = canvas.getContext('2d');
|
|
195
|
+
if (!ctx) return;
|
|
196
|
+
const ratio = window.devicePixelRatio || 1;
|
|
197
|
+
canvas.width = w * ratio;
|
|
198
|
+
canvas.height = HEIGHT * ratio;
|
|
199
|
+
const css = getComputedStyle(canvas);
|
|
200
|
+
const color = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
|
|
201
|
+
const palette = {
|
|
202
|
+
success: [color('--sim-success', 'green'), color('--sim-success-soft', '#dcf5e3')],
|
|
203
|
+
failure: [color('--sim-failure', 'red'), color('--sim-failure-soft', '#fde2e1')],
|
|
204
|
+
inProgress: [color('--sim-progress', 'orange'), color('--sim-progress-soft', '#fff4d6')],
|
|
205
|
+
} as const;
|
|
206
|
+
const edge = color('--sim-border', '#ccc'), text = color('--sim-text', '#222'), accent = color('--sim-highlight', '#f5b301');
|
|
207
|
+
|
|
208
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
209
|
+
ctx.clearRect(0, 0, w, HEIGHT);
|
|
210
|
+
ctx.save();
|
|
211
|
+
ctx.translate(px, py);
|
|
212
|
+
ctx.scale(z, z);
|
|
213
|
+
const pad = radius * 2;
|
|
214
|
+
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 <= (HEIGHT - py) / z + pad;
|
|
216
|
+
|
|
217
|
+
// Edges, the simulator's path on top.
|
|
218
|
+
for (const pass of [false, true]) {
|
|
219
|
+
ctx.strokeStyle = pass ? accent : edge;
|
|
220
|
+
ctx.lineWidth = (pass ? 4 : 1.5) / z;
|
|
221
|
+
for (const n of nodes) {
|
|
222
|
+
if (n.path.length === 1 || highlighted.has(key(n.path)) !== pass) continue;
|
|
223
|
+
const parent = nodesByPath().get(key(n.path.slice(0, -1)));
|
|
224
|
+
if (!parent || parent.x === undefined || parent.y === undefined || n.x === undefined || n.y === undefined) continue;
|
|
225
|
+
if (!visible(parent) && !visible(n)) continue;
|
|
226
|
+
ctx.beginPath();
|
|
227
|
+
ctx.moveTo(parent.x, parent.y + radius + 5);
|
|
228
|
+
ctx.lineTo(n.x, n.y - radius - 5);
|
|
229
|
+
ctx.stroke();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const session = graphExecution();
|
|
234
|
+
for (const n of nodes) {
|
|
235
|
+
if (!visible(n)) continue;
|
|
236
|
+
const [stroke, fill] = palette[n.status];
|
|
237
|
+
ctx.beginPath();
|
|
238
|
+
ctx.arc(n.x!, n.y!, radius + 5, 0, 2 * Math.PI);
|
|
239
|
+
ctx.fillStyle = fill;
|
|
240
|
+
ctx.fill();
|
|
241
|
+
const isSelected = sel && key(sel.path) === key(n.path);
|
|
242
|
+
ctx.strokeStyle = highlighted.has(key(n.path)) ? accent : stroke;
|
|
243
|
+
ctx.lineWidth = (isSelected ? 5 : highlighted.has(key(n.path)) ? 3.5 : 2) / z;
|
|
244
|
+
ctx.stroke();
|
|
245
|
+
// The configuration itself, small, when the graph is close enough to read it.
|
|
246
|
+
if (session && props.drawConfig && radius * z > 18) {
|
|
247
|
+
try {
|
|
248
|
+
const config = session.execution.get(n.path) as ConfigType;
|
|
249
|
+
ctx.save();
|
|
250
|
+
ctx.beginPath();
|
|
251
|
+
ctx.arc(n.x!, n.y!, radius + 4, 0, 2 * Math.PI);
|
|
252
|
+
ctx.clip();
|
|
253
|
+
ctx.translate(n.x!, n.y!);
|
|
254
|
+
const scale = (radius - 8) / 100;
|
|
255
|
+
ctx.scale(scale, scale);
|
|
256
|
+
props.drawConfig(ctx, config, session.model, { centerX: 0, centerY: 0, radius: 100, nodeRadius: 10, agentRadius: 5, showNodeLabels: false });
|
|
257
|
+
ctx.restore();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.error('Error drawing a configuration of the graph:', e);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Path labels only when they fit between nodes.
|
|
263
|
+
if (horizontalSpacing * z > 6.5 * (2 * n.path.length + 1)) {
|
|
264
|
+
ctx.fillStyle = text;
|
|
265
|
+
ctx.font = `${11 / z}px system-ui, sans-serif`;
|
|
266
|
+
ctx.textAlign = 'center';
|
|
267
|
+
ctx.fillText(`[${n.path.join(',')}]`, n.x!, n.y! + radius + 18 / z);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
ctx.restore();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Pointer: drag to pan, click to select, double-click to open; wheel to zoom.
|
|
274
|
+
let drag: { x: number; y: number; moved: boolean } | null = null;
|
|
275
|
+
const nodeAt = (e: MouseEvent) => {
|
|
276
|
+
const rect = canvas!.getBoundingClientRect();
|
|
277
|
+
const x = (e.clientX - rect.left - pan().x) / zoom();
|
|
278
|
+
const y = (e.clientY - rect.top - pan().y) / zoom();
|
|
279
|
+
return graph().find(n => n.x !== undefined && n.y !== undefined && Math.hypot(x - n.x, y - n.y) <= radius + 5) ?? null;
|
|
280
|
+
};
|
|
281
|
+
const onDown = (e: MouseEvent) => { drag = { x: e.clientX - pan().x, y: e.clientY - pan().y, moved: false }; };
|
|
282
|
+
const onMove = (e: MouseEvent) => {
|
|
283
|
+
if (drag) {
|
|
284
|
+
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;
|
|
286
|
+
setPan(next);
|
|
287
|
+
} else if (canvas) {
|
|
288
|
+
canvas.style.cursor = nodeAt(e) ? 'pointer' : 'grab';
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
const onUp = (e: MouseEvent) => {
|
|
292
|
+
if (drag && !drag.moved) setSelected(nodeAt(e));
|
|
293
|
+
drag = null;
|
|
294
|
+
};
|
|
295
|
+
const onWheel = (e: WheelEvent) => {
|
|
296
|
+
if (!canvas) return;
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
const rect = canvas.getBoundingClientRect();
|
|
299
|
+
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
300
|
+
const z = Math.min(Math.max(0.05, zoom() * Math.exp(-e.deltaY * 0.0015)), 5);
|
|
301
|
+
const wx = (mx - pan().x) / zoom(), wy = (my - pan().y) / zoom();
|
|
302
|
+
setZoom(z);
|
|
303
|
+
setPan({ x: mx - wx * z, y: my - wy * z });
|
|
304
|
+
};
|
|
305
|
+
const zoomBy = (factor: number) => {
|
|
306
|
+
const z = Math.min(Math.max(0.05, zoom() * factor), 5);
|
|
307
|
+
const cx = width() / 2, cy = HEIGHT / 2;
|
|
308
|
+
const wx = (cx - pan().x) / zoom(), wy = (cy - pan().y) / zoom();
|
|
309
|
+
setZoom(z);
|
|
310
|
+
setPan({ x: cx - wx * z, y: cy - wy * z });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const Tree = (p: { path: number[] }) => {
|
|
314
|
+
const node = () => nodesByPath().get(key(p.path));
|
|
315
|
+
return (
|
|
316
|
+
<Show when={node()}>
|
|
317
|
+
{n => (
|
|
318
|
+
<div class="cg-tree-node">
|
|
319
|
+
<button class={`cg-node-link cg-${n().status}${onPath().has(key(p.path)) ? ' cg-on-path' : ''}`} onClick={() => setSelected(n())} onDblClick={() => open(n())}>
|
|
320
|
+
{n().status === 'success' ? '✓' : n().status === 'failure' ? '✗' : '○'} [{p.path.join(', ')}]
|
|
321
|
+
</button>
|
|
322
|
+
<Show when={n().childrenCount > 0}>
|
|
323
|
+
<div class="cg-tree-children">
|
|
324
|
+
<For each={Array.from({ length: n().childrenCount }, (_, i) => i)}>{i => <Tree path={[...p.path, i]} />}</For>
|
|
325
|
+
</div>
|
|
326
|
+
</Show>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</Show>
|
|
330
|
+
);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return (
|
|
334
|
+
<div class="config-graph">
|
|
335
|
+
<div class="cg-controls">
|
|
336
|
+
<label class="cg-depth">
|
|
337
|
+
Max Depth (rounds):
|
|
338
|
+
<input type="number" value={maxDepth()} min="0" max="1000"
|
|
339
|
+
onInput={e => { const n = e.currentTarget.valueAsNumber; if (Number.isInteger(n) && n >= 0) setMaxDepth(n); }} />
|
|
340
|
+
</label>
|
|
341
|
+
<Show when={!isGenerating()} fallback={<button class="sim-ghost" onClick={stop}>Stop</button>}>
|
|
342
|
+
<button onClick={generate}>Generate Graph</button>
|
|
343
|
+
</Show>
|
|
344
|
+
<Show when={graph().length > 0 && !isGenerating()}>
|
|
345
|
+
<button class="sim-ghost" onClick={exportGraph}>Export JSON</button>
|
|
346
|
+
</Show>
|
|
347
|
+
<span class="sim-spacer" style={{ flex: 1 }} />
|
|
348
|
+
<Show when={graph().length > 0}>
|
|
349
|
+
<div class="cg-views" role="tablist" aria-label="Graph view">
|
|
350
|
+
<Show when={props.drawConfig}>
|
|
351
|
+
<button role="tab" aria-selected={view() === 'visual'} class={view() === 'visual' ? '' : 'sim-ghost'} onClick={() => setView('visual')}>Drawing</button>
|
|
352
|
+
</Show>
|
|
353
|
+
<button role="tab" aria-selected={view() === 'list'} class={view() === 'list' ? '' : 'sim-ghost'} onClick={() => setView('list')}>List</button>
|
|
354
|
+
<button role="tab" aria-selected={view() === 'tree'} class={view() === 'tree' ? '' : 'sim-ghost'} onClick={() => setView('tree')}>Tree</button>
|
|
355
|
+
</div>
|
|
356
|
+
</Show>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<Show when={error()}>
|
|
360
|
+
<div class="sim-notice sim-error">Could not generate the graph: {error()}</div>
|
|
361
|
+
</Show>
|
|
362
|
+
|
|
363
|
+
<Show when={stats().totalNodes > 0}>
|
|
364
|
+
<div class="cg-stats" aria-label="Statistics">
|
|
365
|
+
<span class="sim-chip">{stats().totalNodes} nodes{isGenerating() ? '…' : ''}</span>
|
|
366
|
+
<span class="sim-chip sim-success">{stats().successNodes} success</span>
|
|
367
|
+
<span class="sim-chip sim-failure">{stats().failureNodes} failure</span>
|
|
368
|
+
<span class="sim-chip sim-inProgress">{stats().inProgressNodes} in progress</span>
|
|
369
|
+
<span class="sim-chip">depth {stats().maxDepthReached}</span>
|
|
370
|
+
<Show when={stats().failureNodes > 0}>
|
|
371
|
+
<span class="sim-muted">The generation stops at the first failure.</span>
|
|
372
|
+
</Show>
|
|
373
|
+
<Show when={stale()}>
|
|
374
|
+
<span class="sim-muted">Generated for other settings: generate again to follow the current ones.</span>
|
|
375
|
+
</Show>
|
|
376
|
+
</div>
|
|
377
|
+
</Show>
|
|
378
|
+
|
|
379
|
+
<Show when={selected()}>
|
|
380
|
+
{n => (
|
|
381
|
+
<div class="cg-selection">
|
|
382
|
+
<span class={`sim-chip sim-${n().status}`}>{STATUS_TEXT[n().status]}</span>
|
|
383
|
+
<span>path <code>[{n().path.join(', ')}]</code> · round {n().depth} · {n().childrenCount} {n().childrenCount === 1 ? 'child' : 'children'} explored</span>
|
|
384
|
+
<span class="sim-spacer" style={{ flex: 1 }} />
|
|
385
|
+
<Show when={props.onOpen}>
|
|
386
|
+
<button onClick={() => open(n())}>Open in simulator</button>
|
|
387
|
+
</Show>
|
|
388
|
+
<button class="sim-ghost sim-icon" onClick={() => setSelected(null)} title="Clear the selection">✕</button>
|
|
389
|
+
</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>
|
|
412
|
+
|
|
413
|
+
<Show when={graph().length > 0 && view() === 'list'}>
|
|
414
|
+
<div class="cg-list">
|
|
415
|
+
<For each={Array.from({ length: stats().maxDepthReached + 1 }, (_, i) => i)}>
|
|
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>
|
|
438
|
+
|
|
439
|
+
<Show when={graph().length > 0 && view() === 'tree'}>
|
|
440
|
+
<div class="cg-tree"><Tree path={[0]} /></div>
|
|
441
|
+
</Show>
|
|
442
|
+
|
|
443
|
+
<Show when={graph().length === 0 && !isGenerating() && !error()}>
|
|
444
|
+
<p class="sim-muted">Generate the graph to see every execution of these settings up to the depth, with the verdict of each configuration.</p>
|
|
445
|
+
</Show>
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
}
|