@bramas/simulant-ui 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +5 -0
  2. package/dist/SimulatorApp.jsx +782 -174
  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/simulator.css +1553 -363
  20. package/package.json +6 -6
  21. package/src/SimulatorApp.tsx +888 -219
  22. package/src/components/AlgorithmViewer.css +268 -74
  23. package/src/components/AlgorithmViewer.tsx +188 -75
  24. package/src/components/BranchPicker.css +74 -32
  25. package/src/components/BranchPicker.tsx +9 -4
  26. package/src/components/ConfigGraphGenerator.css +180 -38
  27. package/src/components/ConfigGraphGenerator.tsx +172 -114
  28. package/src/components/GenericEditor.tsx +25 -59
  29. package/src/components/Icon.tsx +76 -0
  30. package/src/components/ModelView.css +15 -15
  31. package/src/components/SettingsLibrary.tsx +7 -5
  32. package/src/components/Splitter.tsx +55 -0
  33. package/src/components/StateInspector.css +59 -33
  34. package/src/components/StateInspector.tsx +35 -4
  35. package/src/components/theme.ts +33 -0
  36. package/src/pico.min.css +4 -4
  37. package/src/renderers/ring.ts +22 -12
  38. 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
- const HEIGHT = 560;
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), HEIGHT / (y1 - y0));
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: (HEIGHT - (y1 - y0) * z) / 2 - y0 * z });
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 follows the width of its frame.
170
+ // The canvas fills its frame.
162
171
  onMount(() => {
163
172
  if (!frame) return;
164
- const observer = new ResizeObserver(entries => setWidth(Math.max(300, Math.floor(entries[0].contentRect.width))));
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 = HEIGHT * ratio;
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, HEIGHT);
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 <= (HEIGHT - py) / z + pad;
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 = HEIGHT / 2;
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-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>
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
- <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>
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
- <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>
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
- <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>
470
+ <Show when={graph().length > 0 && view() === 'tree'}>
471
+ <div class="cg-tree"><Tree path={[0]} /></div>
472
+ </Show>
438
473
 
439
- <Show when={graph().length > 0 && view() === 'tree'}>
440
- <div class="cg-tree"><Tree path={[0]} /></div>
441
- </Show>
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
- <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>
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
- style={{
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.label}:{' '}
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.label}:{' '}
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
- {' '}{props.field.label}
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
- {props.field.label}:{' '}
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
  )}