@bramas/simulant-ui 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +22 -0
  2. package/dist/SimulatorApp.jsx +782 -175
  3. package/dist/components/AlgorithmViewer.css +268 -74
  4. package/dist/components/AlgorithmViewer.jsx +171 -66
  5. package/dist/components/BranchPicker.css +74 -32
  6. package/dist/components/BranchPicker.jsx +9 -4
  7. package/dist/components/ConfigGraphGenerator.css +180 -38
  8. package/dist/components/ConfigGraphGenerator.jsx +155 -96
  9. package/dist/components/GenericEditor.jsx +18 -44
  10. package/dist/components/Icon.jsx +54 -0
  11. package/dist/components/ModelView.css +15 -15
  12. package/dist/components/SettingsLibrary.jsx +7 -5
  13. package/dist/components/Splitter.jsx +28 -0
  14. package/dist/components/StateInspector.css +59 -33
  15. package/dist/components/StateInspector.jsx +37 -4
  16. package/dist/components/theme.js +28 -0
  17. package/dist/pico.min.css +4 -4
  18. package/dist/renderers/ring.js +19 -10
  19. package/dist/sim/describe.js +10 -8
  20. package/dist/sim/project.js +27 -17
  21. package/dist/simulator.css +1553 -363
  22. package/package.json +6 -6
  23. package/src/SimulatorApp.tsx +888 -220
  24. package/src/components/AlgorithmViewer.css +268 -74
  25. package/src/components/AlgorithmViewer.tsx +188 -75
  26. package/src/components/BranchPicker.css +74 -32
  27. package/src/components/BranchPicker.tsx +9 -4
  28. package/src/components/ConfigGraphGenerator.css +180 -38
  29. package/src/components/ConfigGraphGenerator.tsx +172 -114
  30. package/src/components/GenericEditor.tsx +25 -59
  31. package/src/components/Icon.tsx +76 -0
  32. package/src/components/ModelView.css +15 -15
  33. package/src/components/SettingsLibrary.tsx +7 -5
  34. package/src/components/Splitter.tsx +55 -0
  35. package/src/components/StateInspector.css +59 -33
  36. package/src/components/StateInspector.tsx +35 -4
  37. package/src/components/theme.ts +33 -0
  38. package/src/pico.min.css +4 -4
  39. package/src/renderers/ring.ts +22 -12
  40. package/src/sim/describe.ts +10 -8
  41. package/src/sim/project.ts +27 -19
  42. package/src/simulator.css +1553 -363
@@ -1,101 +1,243 @@
1
- /* The configuration graph (tokens in simulator.css). */
1
+ /* The configuration graph, filling its container (tokens in simulator.css). */
2
+
3
+ .config-graph {
4
+ display: flex;
5
+ flex-direction: column;
6
+ height: 100%;
7
+ min-height: 0;
8
+ }
9
+
10
+ .cg-toolbar {
11
+ flex: none;
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: 8px;
15
+ padding: 10px 12px;
16
+ border-bottom: 1px solid var(--sim-border);
17
+ background: var(--sim-surface);
18
+ }
2
19
 
3
20
  .cg-controls {
4
21
  display: flex;
5
- align-items: flex-end;
6
- gap: 0.6rem;
22
+ align-items: center;
23
+ gap: 8px;
7
24
  flex-wrap: wrap;
8
25
  }
9
26
 
10
27
  .cg-depth {
11
28
  display: flex;
12
29
  align-items: center;
13
- gap: 0.5rem;
30
+ gap: 8px;
14
31
  white-space: nowrap;
32
+ font-size: 13px;
33
+ font-weight: 500;
34
+ color: var(--sim-text-2);
35
+ margin-right: 4px;
15
36
  }
16
37
 
17
38
  .cg-depth input {
18
- width: 5.5rem;
39
+ width: 5rem !important;
19
40
  }
20
41
 
21
- .cg-views {
22
- display: inline-flex;
23
- gap: 0.25rem;
42
+ .cg-divider {
43
+ width: 1px;
44
+ height: 22px;
45
+ margin: 0 4px;
46
+ background: var(--sim-border);
24
47
  }
25
48
 
26
49
  .cg-stats {
27
50
  display: flex;
28
51
  align-items: center;
29
52
  flex-wrap: wrap;
30
- gap: 0.4rem;
31
- margin-top: 0.75rem;
53
+ gap: 6px;
32
54
  }
33
55
 
34
- .cg-selection {
56
+ .cg-notes {
35
57
  display: flex;
36
- align-items: center;
37
- gap: 0.6rem;
38
58
  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;
59
+ gap: 4px 16px;
60
+ font-size: 12px;
61
+ color: var(--sim-muted);
62
+ }
63
+
64
+ .cg-stale {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: 5px;
68
+ color: var(--sim-progress);
69
+ }
70
+
71
+ .cg-toolbar .sim-feedback {
72
+ margin: 0;
73
+ }
74
+
75
+ .cg-body {
76
+ position: relative;
77
+ flex: 1;
78
+ min-height: 0;
79
+ display: flex;
80
+ flex-direction: column;
44
81
  }
45
82
 
46
83
  .cg-frame {
47
84
  position: relative;
48
- margin-top: 0.75rem;
85
+ flex: 1;
86
+ min-height: 0;
87
+ overflow: hidden;
49
88
  }
50
89
 
51
90
  .cg-canvas {
91
+ position: absolute;
92
+ inset: 0;
52
93
  display: block;
53
- background: var(--sim-surface-2);
54
- border: 1px solid var(--sim-border);
55
- border-radius: 8px;
94
+ background-color: var(--sim-stage);
95
+ background-image: radial-gradient(var(--sim-stage-dot) 1px, transparent 1.2px);
96
+ background-size: 18px 18px;
56
97
  cursor: grab;
57
98
  touch-action: none;
58
99
  }
59
100
 
60
101
  .cg-zoom {
61
102
  position: absolute;
62
- top: 0.5rem;
63
- right: 0.5rem;
103
+ top: 12px;
104
+ right: 12px;
64
105
  display: flex;
65
106
  align-items: center;
66
- gap: 0.3rem;
107
+ gap: 2px;
67
108
  background: var(--sim-surface);
68
109
  border: 1px solid var(--sim-border);
69
110
  border-radius: 8px;
70
- padding: 0.2rem 0.3rem;
111
+ padding: 3px;
112
+ box-shadow: var(--sim-shadow-md);
113
+ }
114
+
115
+ .cg-zoom-value {
116
+ min-width: 3.2em;
117
+ text-align: center;
118
+ font-size: 12px;
119
+ font-variant-numeric: tabular-nums;
120
+ color: var(--sim-muted);
121
+ }
122
+
123
+ .cg-zoom-sep {
124
+ width: 1px;
125
+ height: 16px;
126
+ margin: 0 2px;
127
+ background: var(--sim-border);
71
128
  }
72
129
 
73
130
  .cg-help {
74
- margin: 0.4rem 0 0;
75
- font-size: 0.78rem;
131
+ position: absolute;
132
+ right: 12px;
133
+ bottom: 10px;
134
+ margin: 0;
135
+ padding: 3px 8px;
136
+ border-radius: 6px;
137
+ font-size: 11.5px;
138
+ color: var(--sim-muted);
139
+ background: color-mix(in srgb, var(--sim-surface) 85%, transparent);
140
+ pointer-events: none;
141
+ }
142
+
143
+ /* The selected node, in a card over the graph */
144
+
145
+ .cg-selection {
146
+ position: absolute;
147
+ left: 12px;
148
+ top: 12px;
149
+ z-index: 2;
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 8px;
153
+ width: 196px;
154
+ padding: 10px;
155
+ border-radius: 10px;
156
+ background: var(--sim-surface);
157
+ border: 1px solid var(--sim-border);
158
+ box-shadow: var(--sim-shadow-lg);
159
+ font-size: 12.5px;
160
+ }
161
+
162
+ .cg-selection-head {
163
+ display: flex;
164
+ align-items: center;
165
+ }
166
+
167
+ .cg-preview {
168
+ align-self: center;
169
+ border-radius: 8px;
170
+ border: 1px solid var(--sim-border);
171
+ background-color: var(--sim-stage);
172
+ background-image: radial-gradient(var(--sim-stage-dot) 1px, transparent 1.2px);
173
+ background-size: 14px 14px;
174
+ }
175
+
176
+ .cg-selection-facts {
177
+ display: grid;
178
+ grid-template-columns: auto 1fr;
179
+ gap: 3px 10px;
180
+ }
181
+
182
+ .cg-selection-facts dt {
183
+ color: var(--sim-muted);
184
+ }
185
+
186
+ .cg-selection-facts dd {
187
+ margin: 0;
188
+ min-width: 0;
189
+ overflow-wrap: anywhere;
190
+ }
191
+
192
+ .cg-empty {
193
+ flex: 1;
194
+ display: flex;
195
+ flex-direction: column;
196
+ align-items: center;
197
+ justify-content: center;
198
+ gap: 10px;
199
+ padding: 28px 16px;
200
+ color: var(--sim-muted);
201
+ font-size: 13px;
202
+ text-align: center;
203
+ background-color: var(--sim-stage);
204
+ background-image: radial-gradient(var(--sim-stage-dot) 1px, transparent 1.2px);
205
+ background-size: 18px 18px;
206
+ }
207
+
208
+ .cg-empty strong {
209
+ color: var(--sim-text);
210
+ font-size: 14px;
211
+ }
212
+
213
+ .cg-empty span {
214
+ max-width: 28rem;
76
215
  }
77
216
 
78
217
  .cg-list,
79
218
  .cg-tree {
80
- margin-top: 0.75rem;
81
- max-height: 480px;
219
+ flex: 1;
220
+ min-height: 0;
82
221
  overflow: auto;
83
- border: 1px solid var(--sim-border);
84
- border-radius: 8px;
85
- padding: 0.5rem 0.75rem;
86
- font-size: 0.85rem;
222
+ padding: 8px 12px;
223
+ font-size: 13px;
224
+ }
225
+
226
+ /* The selection card covers the top left: lists start beside it. */
227
+ .cg-body:has(.cg-selection) :is(.cg-list, .cg-tree) {
228
+ padding-left: 224px;
87
229
  }
88
230
 
89
231
  .cg-list summary {
90
232
  cursor: pointer;
91
233
  font-weight: 600;
92
- margin: 0.3rem 0;
234
+ margin: 4px 0;
93
235
  }
94
236
 
95
237
  .config-graph .cg-list-row,
96
238
  .config-graph .cg-node-link {
97
239
  display: flex;
98
- gap: 0.8rem;
240
+ gap: 12px;
99
241
  align-items: baseline;
100
242
  width: 100%;
101
243
  text-align: left;
@@ -104,8 +246,8 @@
104
246
  border-left: 3px solid transparent;
105
247
  border-radius: 4px;
106
248
  color: var(--sim-text);
107
- padding: 0.15rem 0.5rem;
108
- font-size: 0.82rem;
249
+ padding: 2px 8px;
250
+ font-size: 12.5px;
109
251
  }
110
252
 
111
253
  .config-graph .cg-node-link {
@@ -2,11 +2,14 @@
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";
9
+ import { Icon } from "./Icon.jsx";
8
10
  import "./ConfigGraphGenerator.css";
9
- const HEIGHT = 560;
11
+ /** The preview of the selected node, in CSS pixels. */
12
+ const PREVIEW = 132;
10
13
  const EMPTY_STATS = { totalNodes: 0, successNodes: 0, failureNodes: 0, inProgressNodes: 0, maxDepthReached: 0 };
11
14
  const STATUS_TEXT = { success: 'success', failure: 'failure', inProgress: 'in progress' };
12
15
  const key = (path) => path.join('-');
@@ -35,9 +38,12 @@ export function ConfigGraphGenerator(props) {
35
38
  const [pan, setPan] = createSignal({ x: 0, y: 0 });
36
39
  const [zoom, setZoom] = createSignal(1);
37
40
  const [width, setWidth] = createSignal(1000);
41
+ const [height, setHeight] = createSignal(560);
38
42
  let canvas;
39
43
  let frame;
40
44
  let worker;
45
+ // Until the user pans or zooms, the graph keeps fitting its frame.
46
+ let moved = false;
41
47
  const radius = props.configRadius || 60;
42
48
  const horizontalSpacing = props.horizontalSpacing || 150;
43
49
  const verticalSpacing = props.verticalSpacing || 180;
@@ -66,9 +72,9 @@ export function ConfigGraphGenerator(props) {
66
72
  const xs = nodes.map(n => n.x), ys = nodes.map(n => n.y);
67
73
  const margin = radius + 30;
68
74
  const [x0, x1, y0, y1] = [Math.min(...xs) - margin, Math.max(...xs) + margin, Math.min(...ys) - margin, Math.max(...ys) + margin];
69
- const z = Math.min(2, width() / (x1 - x0), HEIGHT / (y1 - y0));
75
+ const z = Math.min(2, width() / (x1 - x0), height() / (y1 - y0));
70
76
  setZoom(z);
71
- setPan({ x: (width() - (x1 - x0) * z) / 2 - x0 * z, y: (HEIGHT - (y1 - y0) * z) / 2 - y0 * z });
77
+ setPan({ x: (width() - (x1 - x0) * z) / 2 - x0 * z, y: (height() - (y1 - y0) * z) / 2 - y0 * z });
72
78
  };
73
79
  const generate = () => {
74
80
  stop();
@@ -77,6 +83,7 @@ export function ConfigGraphGenerator(props) {
77
83
  setGraph([]);
78
84
  setStats(EMPTY_STATS);
79
85
  setGraphSettings({ settings: props.settings });
86
+ moved = false;
80
87
  if (!props.projectName || !props.createWorker) {
81
88
  setError('This page has no graph worker.');
82
89
  return;
@@ -132,11 +139,20 @@ export function ConfigGraphGenerator(props) {
132
139
  if (s && props.onOpen)
133
140
  props.onOpen(s.settings, node.path);
134
141
  };
135
- // The canvas follows the width of its frame.
142
+ // The canvas fills its frame.
136
143
  onMount(() => {
137
144
  if (!frame)
138
145
  return;
139
- const observer = new ResizeObserver(entries => setWidth(Math.max(300, Math.floor(entries[0].contentRect.width))));
146
+ const observer = new ResizeObserver(entries => {
147
+ const { width: w, height: h } = entries[0].contentRect;
148
+ // A hidden frame (another view) has no size: keep the last one.
149
+ if (w <= 0 || h <= 0)
150
+ return;
151
+ setWidth(Math.max(300, Math.floor(w)));
152
+ setHeight(Math.max(240, Math.floor(h)));
153
+ if (!moved)
154
+ fit();
155
+ });
140
156
  observer.observe(frame);
141
157
  // Scrolling over the graph zooms it instead of scrolling the page: the listener cannot be passive.
142
158
  canvas?.addEventListener('wheel', onWheel, { passive: false });
@@ -163,8 +179,10 @@ export function ConfigGraphGenerator(props) {
163
179
  const { x: px, y: py } = pan();
164
180
  const z = zoom();
165
181
  const w = width();
182
+ const h = height();
166
183
  const highlighted = onPath();
167
184
  const sel = selected();
185
+ const theme = props.theme;
168
186
  if (!canvas || view() !== 'visual')
169
187
  return;
170
188
  const ctx = canvas.getContext('2d');
@@ -172,7 +190,7 @@ export function ConfigGraphGenerator(props) {
172
190
  return;
173
191
  const ratio = window.devicePixelRatio || 1;
174
192
  canvas.width = w * ratio;
175
- canvas.height = HEIGHT * ratio;
193
+ canvas.height = h * ratio;
176
194
  const css = getComputedStyle(canvas);
177
195
  const color = (name, fallback) => css.getPropertyValue(name).trim() || fallback;
178
196
  const palette = {
@@ -182,13 +200,13 @@ export function ConfigGraphGenerator(props) {
182
200
  };
183
201
  const edge = color('--sim-border', '#ccc'), text = color('--sim-text', '#222'), accent = color('--sim-highlight', '#f5b301');
184
202
  ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
185
- ctx.clearRect(0, 0, w, HEIGHT);
203
+ ctx.clearRect(0, 0, w, h);
186
204
  ctx.save();
187
205
  ctx.translate(px, py);
188
206
  ctx.scale(z, z);
189
207
  const pad = radius * 2;
190
208
  const visible = (n) => n.x !== undefined && n.y !== undefined
191
- && n.x >= -px / z - pad && n.x <= (w - px) / z + pad && n.y >= -py / z - pad && n.y <= (HEIGHT - py) / z + pad;
209
+ && n.x >= -px / z - pad && n.x <= (w - px) / z + pad && n.y >= -py / z - pad && n.y <= (h - py) / z + pad;
192
210
  // Edges, the simulator's path on top.
193
211
  for (const pass of [false, true]) {
194
212
  ctx.strokeStyle = pass ? accent : edge;
@@ -231,7 +249,7 @@ export function ConfigGraphGenerator(props) {
231
249
  ctx.translate(n.x, n.y);
232
250
  const scale = (radius - 8) / 100;
233
251
  ctx.scale(scale, scale);
234
- props.drawConfig(ctx, config, session.model, { centerX: 0, centerY: 0, radius: 100, nodeRadius: 10, agentRadius: 5, showNodeLabels: false });
252
+ props.drawConfig(ctx, config, session.model, { centerX: 0, centerY: 0, radius: 100, nodeRadius: 10, agentRadius: 5, showNodeLabels: false, theme });
235
253
  ctx.restore();
236
254
  }
237
255
  catch (e) {
@@ -261,7 +279,7 @@ export function ConfigGraphGenerator(props) {
261
279
  if (drag) {
262
280
  const next = { x: e.clientX - drag.x, y: e.clientY - drag.y };
263
281
  if (Math.abs(next.x - pan().x) + Math.abs(next.y - pan().y) > 2)
264
- drag.moved = true;
282
+ drag.moved = moved = true;
265
283
  setPan(next);
266
284
  }
267
285
  else if (canvas) {
@@ -277,6 +295,7 @@ export function ConfigGraphGenerator(props) {
277
295
  if (!canvas)
278
296
  return;
279
297
  e.preventDefault();
298
+ moved = true;
280
299
  const rect = canvas.getBoundingClientRect();
281
300
  const mx = e.clientX - rect.left, my = e.clientY - rect.top;
282
301
  const z = Math.min(Math.max(0.05, zoom() * Math.exp(-e.deltaY * 0.0015)), 5);
@@ -285,8 +304,9 @@ export function ConfigGraphGenerator(props) {
285
304
  setPan({ x: mx - wx * z, y: my - wy * z });
286
305
  };
287
306
  const zoomBy = (factor) => {
307
+ moved = true;
288
308
  const z = Math.min(Math.max(0.05, zoom() * factor), 5);
289
- const cx = width() / 2, cy = HEIGHT / 2;
309
+ const cx = width() / 2, cy = height() / 2;
290
310
  const wx = (cx - pan().x) / zoom(), wy = (cy - pan().y) / zoom();
291
311
  setZoom(z);
292
312
  setPan({ x: cx - wx * z, y: cy - wy * z });
@@ -306,103 +326,142 @@ export function ConfigGraphGenerator(props) {
306
326
  </div>)}
307
327
  </Show>);
308
328
  };
329
+ /** The selected node's configuration, drawn small. */
330
+ const Preview = (p) => {
331
+ let preview;
332
+ createEffect(() => {
333
+ const session = graphExecution();
334
+ const ctx = preview?.getContext('2d');
335
+ if (!preview || !ctx || !session || !props.drawConfig)
336
+ return;
337
+ const ratio = window.devicePixelRatio || 1;
338
+ preview.width = preview.height = PREVIEW * ratio;
339
+ ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
340
+ ctx.clearRect(0, 0, PREVIEW, PREVIEW);
341
+ try {
342
+ const config = session.execution.get(p.node.path);
343
+ 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 });
344
+ }
345
+ catch (e) {
346
+ console.error('Error drawing the selected configuration:', e);
347
+ }
348
+ });
349
+ return <canvas ref={preview} class="cg-preview" style={{ width: `${PREVIEW}px`, height: `${PREVIEW}px` }}/>;
350
+ };
309
351
  return (<div class="config-graph">
310
- <div class="cg-controls">
311
- <label class="cg-depth">
312
- Max Depth (rounds):
313
- <input type="number" value={maxDepth()} min="0" max="1000" onInput={e => { const n = e.currentTarget.valueAsNumber; if (Number.isInteger(n) && n >= 0)
352
+ <div class="cg-toolbar">
353
+ <div class="cg-controls">
354
+ <label class="cg-depth">
355
+ <span>Max depth</span>
356
+ <input type="number" value={maxDepth()} min="0" max="1000" aria-label="Max depth (rounds)" onInput={e => { const n = e.currentTarget.valueAsNumber; if (Number.isInteger(n) && n >= 0)
314
357
  setMaxDepth(n); }}/>
315
- </label>
316
- <Show when={!isGenerating()} fallback={<button class="sim-ghost" onClick={stop}>Stop</button>}>
317
- <button onClick={generate}>Generate Graph</button>
318
- </Show>
319
- <Show when={graph().length > 0 && !isGenerating()}>
320
- <button class="sim-ghost" onClick={exportGraph}>Export JSON</button>
321
- </Show>
322
- <span class="sim-spacer" style={{ flex: 1 }}/>
323
- <Show when={graph().length > 0}>
324
- <div class="cg-views" role="tablist" aria-label="Graph view">
325
- <Show when={props.drawConfig}>
326
- <button role="tab" aria-selected={view() === 'visual'} class={view() === 'visual' ? '' : 'sim-ghost'} onClick={() => setView('visual')}>Drawing</button>
327
- </Show>
328
- <button role="tab" aria-selected={view() === 'list'} class={view() === 'list' ? '' : 'sim-ghost'} onClick={() => setView('list')}>List</button>
329
- <button role="tab" aria-selected={view() === 'tree'} class={view() === 'tree' ? '' : 'sim-ghost'} onClick={() => setView('tree')}>Tree</button>
330
- </div>
331
- </Show>
332
- </div>
333
-
334
- <Show when={error()}>
335
- <div class="sim-notice sim-error">Could not generate the graph: {error()}</div>
336
- </Show>
337
-
338
- <Show when={stats().totalNodes > 0}>
339
- <div class="cg-stats" aria-label="Statistics">
340
- <span class="sim-chip">{stats().totalNodes} nodes{isGenerating() ? '…' : ''}</span>
341
- <span class="sim-chip sim-success">{stats().successNodes} success</span>
342
- <span class="sim-chip sim-failure">{stats().failureNodes} failure</span>
343
- <span class="sim-chip sim-inProgress">{stats().inProgressNodes} in progress</span>
344
- <span class="sim-chip">depth {stats().maxDepthReached}</span>
345
- <Show when={stats().failureNodes > 0}>
346
- <span class="sim-muted">The generation stops at the first failure.</span>
358
+ <span class="sim-muted">rounds</span>
359
+ </label>
360
+ <Show when={!isGenerating()} fallback={<button class="sim-btn" onClick={stop}><Icon name="stop" size={14}/> Stop</button>}>
361
+ <button class="sim-primary" onClick={generate}><Icon name="play" size={14}/> Generate graph</button>
362
+ </Show>
363
+ <Show when={graph().length > 0 && !isGenerating()}>
364
+ <button class="sim-ghost" onClick={exportGraph}><Icon name="download" size={14}/> Export JSON</button>
347
365
  </Show>
348
- <Show when={stale()}>
349
- <span class="sim-muted">Generated for other settings: generate again to follow the current ones.</span>
366
+ <Show when={stats().totalNodes > 0}>
367
+ <span class="cg-divider"/>
368
+ <div class="cg-stats" aria-label="Statistics">
369
+ <span class="sim-chip">{stats().totalNodes} nodes{isGenerating() ? '…' : ''}</span>
370
+ <span class="sim-chip sim-success">{stats().successNodes} success</span>
371
+ <span class="sim-chip sim-failure">{stats().failureNodes} failure</span>
372
+ <span class="sim-chip sim-inProgress">{stats().inProgressNodes} in progress</span>
373
+ <span class="sim-chip">depth {stats().maxDepthReached}</span>
374
+ </div>
375
+ </Show>
376
+ <span class="sim-spacer"/>
377
+ <Show when={graph().length > 0}>
378
+ <div class="sim-tabs" role="tablist" aria-label="Graph view">
379
+ <Show when={props.drawConfig}>
380
+ <button role="tab" aria-selected={view() === 'visual'} class={view() === 'visual' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => setView('visual')}>Drawing</button>
381
+ </Show>
382
+ <button role="tab" aria-selected={view() === 'list'} class={view() === 'list' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => setView('list')}>List</button>
383
+ <button role="tab" aria-selected={view() === 'tree'} class={view() === 'tree' ? 'sim-tab sim-tab-current' : 'sim-tab'} onClick={() => setView('tree')}>Tree</button>
384
+ </div>
350
385
  </Show>
351
386
  </div>
352
- </Show>
353
-
354
- <Show when={selected()}>
355
- {n => (<div class="cg-selection">
356
- <span class={`sim-chip sim-${n().status}`}>{STATUS_TEXT[n().status]}</span>
357
- <span>path <code>[{n().path.join(', ')}]</code> · round {n().depth} · {n().childrenCount} {n().childrenCount === 1 ? 'child' : 'children'} explored</span>
358
- <span class="sim-spacer" style={{ flex: 1 }}/>
359
- <Show when={props.onOpen}>
360
- <button onClick={() => open(n())}>Open in simulator</button>
361
- </Show>
362
- <button class="sim-ghost sim-icon" onClick={() => setSelected(null)} title="Clear the selection">✕</button>
363
- </div>)}
364
- </Show>
387
+ <Show when={stats().failureNodes > 0 || stale()}>
388
+ <div class="cg-notes">
389
+ <Show when={stats().failureNodes > 0}><span>The generation stops at the first failure.</span></Show>
390
+ <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>
391
+ </div>
392
+ </Show>
393
+ <Show when={error()}>
394
+ <div class="sim-feedback sim-settings-error"><Icon name="alert"/><div>Could not generate the graph: {error()}</div></div>
395
+ </Show>
396
+ </div>
365
397
 
366
- <div ref={frame} class="cg-frame" style={{ display: graph().length > 0 && view() === 'visual' ? 'block' : 'none' }}>
367
- <canvas ref={canvas} class="cg-canvas" style={{ width: `${width()}px`, height: `${HEIGHT}px` }} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={() => { drag = null; }} onDblClick={e => { const n = nodeAt(e); if (n)
398
+ <div class="cg-body">
399
+ <div ref={frame} class="cg-frame" style={{ display: graph().length > 0 && view() === 'visual' ? undefined : 'none' }}>
400
+ <canvas ref={canvas} class="cg-canvas" style={{ width: `${width()}px`, height: `${height()}px` }} onMouseDown={onDown} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={() => { drag = null; }} onDblClick={e => { const n = nodeAt(e); if (n)
368
401
  open(n); }}/>
369
- <div class="cg-zoom">
370
- <button class="sim-ghost sim-icon" onClick={() => zoomBy(1 / 1.25)} title="Zoom out">−</button>
371
- <span class="sim-muted">{Math.round(zoom() * 100)}%</span>
372
- <button class="sim-ghost sim-icon" onClick={() => zoomBy(1.25)} title="Zoom in">+</button>
373
- <button class="sim-ghost" onClick={fit} title="Show the whole graph">Fit</button>
402
+ <div class="cg-zoom">
403
+ <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>
404
+ <span class="cg-zoom-value">{Math.round(zoom() * 100)}%</span>
405
+ <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>
406
+ <span class="cg-zoom-sep"/>
407
+ <button class="sim-ghost sim-sm" onClick={() => { moved = false; fit(); }} title="Show the whole graph"><Icon name="fit" size={14}/> Fit</button>
408
+ </div>
409
+ <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>
374
410
  </div>
375
- <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>
376
- </div>
377
411
 
378
- <Show when={graph().length > 0 && view() === 'list'}>
379
- <div class="cg-list">
380
- <For each={Array.from({ length: stats().maxDepthReached + 1 }, (_, i) => i)}>
381
- {depth => {
412
+ <Show when={graph().length > 0 && view() === 'list'}>
413
+ <div class="cg-list">
414
+ <For each={Array.from({ length: stats().maxDepthReached + 1 }, (_, i) => i)}>
415
+ {depth => {
382
416
  const nodes = () => graph().filter(n => n.depth === depth);
383
417
  return (<Show when={nodes().length > 0}>
384
- <details open={depth < 3}>
385
- <summary>Round {depth} <span class="sim-muted">({nodes().length} nodes)</span></summary>
386
- <For each={nodes()}>
387
- {n => (<button class={`cg-list-row cg-${n.status}${onPath().has(key(n.path)) ? ' cg-on-path' : ''}`} onClick={() => setSelected(n)} onDblClick={() => open(n)}>
388
- <code>[{n.path.join(', ')}]</code>
389
- <span>{STATUS_TEXT[n.status]}</span>
390
- <span class="sim-muted">{n.childrenCount} children</span>
391
- </button>)}
392
- </For>
393
- </details>
394
- </Show>);
418
+ <details open={depth < 3}>
419
+ <summary>Round {depth} <span class="sim-muted">({nodes().length} nodes)</span></summary>
420
+ <For each={nodes()}>
421
+ {n => (<button class={`cg-list-row cg-${n.status}${onPath().has(key(n.path)) ? ' cg-on-path' : ''}`} onClick={() => setSelected(n)} onDblClick={() => open(n)}>
422
+ <code>[{n.path.join(', ')}]</code>
423
+ <span>{STATUS_TEXT[n.status]}</span>
424
+ <span class="sim-muted">{n.childrenCount} children</span>
425
+ </button>)}
426
+ </For>
427
+ </details>
428
+ </Show>);
395
429
  }}
396
- </For>
397
- </div>
398
- </Show>
430
+ </For>
431
+ </div>
432
+ </Show>
399
433
 
400
- <Show when={graph().length > 0 && view() === 'tree'}>
401
- <div class="cg-tree"><Tree path={[0]}/></div>
402
- </Show>
434
+ <Show when={graph().length > 0 && view() === 'tree'}>
435
+ <div class="cg-tree"><Tree path={[0]}/></div>
436
+ </Show>
403
437
 
404
- <Show when={graph().length === 0 && !isGenerating() && !error()}>
405
- <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>
406
- </Show>
438
+ <Show when={graph().length === 0 && !isGenerating() && !error()}>
439
+ <div class="cg-empty">
440
+ <Icon name="graph" size={32}/>
441
+ <strong>No graph yet</strong>
442
+ <span>Generate the graph to see every execution of these settings up to the depth, with the verdict of each configuration.</span>
443
+ <button class="sim-primary" onClick={generate}><Icon name="play" size={14}/> Generate up to depth {maxDepth()}</button>
444
+ </div>
445
+ </Show>
446
+
447
+ <Show when={selected()}>
448
+ {n => (<div class="cg-selection" role="region" aria-label="Selected configuration">
449
+ <div class="cg-selection-head">
450
+ <span class={`sim-chip sim-${n().status}`}>{STATUS_TEXT[n().status]}</span>
451
+ <span class="sim-spacer"/>
452
+ <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>
453
+ </div>
454
+ <Show when={props.drawConfig}><Preview node={n()}/></Show>
455
+ <dl class="cg-selection-facts">
456
+ <dt>Path</dt><dd><code>[{n().path.join(', ')}]</code></dd>
457
+ <dt>Round</dt><dd>{n().depth}</dd>
458
+ <dt>Explored</dt><dd>{n().childrenCount} {n().childrenCount === 1 ? 'child' : 'children'}</dd>
459
+ </dl>
460
+ <Show when={props.onOpen}>
461
+ <button class="sim-primary sim-sm" onClick={() => open(n())}>Open in simulator</button>
462
+ </Show>
463
+ </div>)}
464
+ </Show>
465
+ </div>
407
466
  </div>);
408
467
  }