create-vizcraft-playground 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vizcraft-playground",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new VizCraft interactive visualization playground",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,7 @@ let pluginName = null;
12
12
  let categoryName = null;
13
13
  let isSandbox = false;
14
14
  let isTimeline = false;
15
+ let isComparison = false;
15
16
 
16
17
  for (let i = 0; i < args.length; i++) {
17
18
  if (args[i] === "--category" || args[i] === "-c") {
@@ -20,6 +21,8 @@ for (let i = 0; i < args.length; i++) {
20
21
  isSandbox = true;
21
22
  } else if (args[i] === "--timeline" || args[i] === "-t") {
22
23
  isTimeline = true;
24
+ } else if (args[i] === "--comparison" || args[i] === "-l") {
25
+ isComparison = true;
23
26
  } else if (!args[i].startsWith("-")) {
24
27
  pluginName = args[i];
25
28
  }
@@ -27,19 +30,22 @@ for (let i = 0; i < args.length; i++) {
27
30
 
28
31
  if (!pluginName) {
29
32
  console.error(
30
- "Usage: npm run generate <plugin-name> [--category \"Category Name\"] [--sandbox | --timeline]\n" +
33
+ "Usage: npm run generate <plugin-name> [--category \"Category Name\"] [--sandbox | --timeline | --comparison]\n" +
31
34
  " Name must be kebab-case, e.g. npm run generate api-gateway\n" +
32
- " --category / -c Existing or new category to place the plugin in\n" +
33
- " --sandbox / -s Generate a sandbox plugin with declarative flow engine,\n" +
34
- " Controls panel, and dynamic component toggling\n" +
35
- " --timeline / -t Generate a timeline plugin with progressive reveal,\n" +
36
- " animated nodes, progress bar, and declarative steps",
35
+ " --category / -c Existing or new category to place the plugin in\n" +
36
+ " --sandbox / -s Generate a sandbox plugin with declarative flow engine,\n" +
37
+ " Controls panel, and dynamic component toggling\n" +
38
+ " --timeline / -t Generate a timeline plugin with progressive reveal,\n" +
39
+ " animated nodes, progress bar, and declarative steps\n" +
40
+ " --comparison / -l Generate a comparison-lab plugin using the shared\n" +
41
+ " lab-engine (variant selection, strategy profiles,\n" +
42
+ " declarative flow + animation via useLabAnimation)",
37
43
  );
38
44
  process.exit(1);
39
45
  }
40
46
 
41
- if (isSandbox && isTimeline) {
42
- console.error("Error: --sandbox and --timeline are mutually exclusive.");
47
+ if ([isSandbox, isTimeline, isComparison].filter(Boolean).length > 1) {
48
+ console.error("Error: --sandbox, --timeline, and --comparison are mutually exclusive.");
43
49
  process.exit(1);
44
50
  }
45
51
 
@@ -272,6 +278,111 @@ const ${camelName}Slice = createSlice({
272
278
  export const { patchState, reset } = ${camelName}Slice.actions;
273
279
  export default ${camelName}Slice.reducer;
274
280
  `;
281
+ } else if (isComparison) {
282
+ sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
283
+ import type { LabState } from "../../lib/lab-engine";
284
+
285
+ /* ── Variant identifiers ─────────────────────────────── */
286
+ export type VariantKey = "variant-a" | "variant-b";
287
+ // TODO: add more variant keys as needed
288
+
289
+ /* ── Per-variant profile ─────────────────────────────── */
290
+ export interface VariantProfile {
291
+ key: VariantKey;
292
+ label: string;
293
+ color: string;
294
+ description: string;
295
+ }
296
+
297
+ export const VARIANT_PROFILES: Record<VariantKey, VariantProfile> = {
298
+ "variant-a": {
299
+ key: "variant-a",
300
+ label: "Variant A",
301
+ color: "#3b82f6",
302
+ description: "Describe variant A's approach.",
303
+ },
304
+ "variant-b": {
305
+ key: "variant-b",
306
+ label: "Variant B",
307
+ color: "#22c55e",
308
+ description: "Describe variant B's approach.",
309
+ },
310
+ };
311
+
312
+ /* ── State shape ─────────────────────────────────────── */
313
+ export interface ${pascalName}State extends LabState {
314
+ variant: VariantKey;
315
+
316
+ /* derived metrics (recomputed by computeMetrics) */
317
+ latencyMs: number;
318
+ throughput: number;
319
+ }
320
+
321
+ /* ── Metrics model ───────────────────────────────────── */
322
+ export function computeMetrics(state: ${pascalName}State) {
323
+ // TODO: compute metrics based on active variant
324
+ if (state.variant === "variant-a") {
325
+ state.latencyMs = 50;
326
+ state.throughput = 1000;
327
+ } else {
328
+ state.latencyMs = 120;
329
+ state.throughput = 2000;
330
+ }
331
+ }
332
+
333
+ export const initialState: ${pascalName}State = {
334
+ variant: "variant-a",
335
+ latencyMs: 50,
336
+ throughput: 1000,
337
+
338
+ hotZones: [],
339
+ explanation: "Welcome — select a variant and step through to compare.",
340
+ phase: "overview",
341
+ };
342
+
343
+ computeMetrics(initialState);
344
+
345
+ /* ── Slice ───────────────────────────────────────────── */
346
+ const ${camelName}Slice = createSlice({
347
+ name: "${camelName}",
348
+ initialState,
349
+ reducers: {
350
+ reset: () => {
351
+ const s = { ...initialState };
352
+ computeMetrics(s);
353
+ return s;
354
+ },
355
+ softResetRun: (state) => {
356
+ state.hotZones = [];
357
+ state.explanation = VARIANT_PROFILES[state.variant].description;
358
+ state.phase = "overview";
359
+ computeMetrics(state);
360
+ },
361
+ patchState(state, action: PayloadAction<Partial<${pascalName}State>>) {
362
+ Object.assign(state, action.payload);
363
+ },
364
+ recalcMetrics(state) {
365
+ computeMetrics(state);
366
+ },
367
+ setVariant(state, action: PayloadAction<VariantKey>) {
368
+ state.variant = action.payload;
369
+ state.hotZones = [];
370
+ state.explanation = VARIANT_PROFILES[action.payload].description;
371
+ state.phase = "overview";
372
+ computeMetrics(state);
373
+ },
374
+ },
375
+ });
376
+
377
+ export const {
378
+ reset,
379
+ softResetRun,
380
+ patchState,
381
+ recalcMetrics,
382
+ setVariant,
383
+ } = ${camelName}Slice.actions;
384
+ export default ${camelName}Slice.reducer;
385
+ `;
275
386
  } else {
276
387
  sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
277
388
 
@@ -626,6 +737,38 @@ export const use${pascalName}Animation = (
626
737
  return { runtime, currentStep };
627
738
  };
628
739
  `;
740
+ } else if (isComparison) {
741
+ hookContent = `import {
742
+ patchState,
743
+ softResetRun,
744
+ recalcMetrics,
745
+ type ${pascalName}State,
746
+ } from "./${camelName}Slice";
747
+ import { STEPS, buildSteps, expandToken, type StepKey } from "./flow-engine";
748
+ import {
749
+ useLabAnimation,
750
+ type Signal,
751
+ type UseLabAnimationConfig,
752
+ } from "../../lib/lab-engine";
753
+
754
+ export type { Signal };
755
+
756
+ const labConfig: UseLabAnimationConfig<${pascalName}State, StepKey> = {
757
+ selector: (root) => root.${camelName} as ${pascalName}State,
758
+ allSteps: STEPS,
759
+ buildSteps,
760
+ expandToken,
761
+ actions: () => ({
762
+ resetRun: { create: () => softResetRun(), terminal: true },
763
+ // TODO: add more action mappings as needed
764
+ }),
765
+ recalcMetrics: () => recalcMetrics(),
766
+ patchState: (p) => patchState(p),
767
+ };
768
+
769
+ export const use${pascalName}Animation = (onAnimationComplete?: () => void) =>
770
+ useLabAnimation(labConfig, onAnimationComplete);
771
+ `;
629
772
  } else {
630
773
  hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
631
774
  import { useDispatch, useSelector } from "react-redux";
@@ -818,6 +961,7 @@ const ${pascalName}Visualization: React.FC<Props> = ({
818
961
  const containerRef = useRef<HTMLDivElement>(null!);
819
962
  const builderRef = useRef<ReturnType<typeof viz> | null>(null);
820
963
  const pzRef = useRef<PanZoomController | null>(null);
964
+ const viewportRef = useRef<{ zoom: number; pan: { x: number; y: number } } | null>(null);
821
965
  const lastAnimatedItemRef = useRef<string | null>(null);
822
966
  const isFirstMountRef = useRef(true);
823
967
 
@@ -1117,7 +1261,7 @@ const ${pascalName}Visualization: React.FC<Props> = ({
1117
1261
  panZoom: true,
1118
1262
  }) ?? null;
1119
1263
  } else {
1120
- const saved = pzRef.current?.getState() ?? null;
1264
+ const saved = viewportRef.current;
1121
1265
  builderRef.current?.destroy();
1122
1266
  builderRef.current = scene;
1123
1267
  pzRef.current =
@@ -1128,6 +1272,10 @@ const ${pascalName}Visualization: React.FC<Props> = ({
1128
1272
  initialPan: saved?.pan ?? { x: 0, y: 0 },
1129
1273
  }) ?? null;
1130
1274
  }
1275
+ const unsub = pzRef.current?.onChange((s) => {
1276
+ viewportRef.current = s;
1277
+ });
1278
+ return () => { unsub?.(); };
1131
1279
  }, [scene]);
1132
1280
 
1133
1281
  useEffect(() => {
@@ -1220,6 +1368,168 @@ const ${pascalName}Visualization: React.FC<Props> = ({
1220
1368
  );
1221
1369
  };
1222
1370
 
1371
+ export default ${pascalName}Visualization;
1372
+ `;
1373
+ } else if (isComparison) {
1374
+ mainContent = `import React, { useLayoutEffect, useRef, useEffect } from "react";
1375
+ import {
1376
+ viz,
1377
+ type PanZoomController,
1378
+ type SignalOverlayParams,
1379
+ } from "vizcraft";
1380
+ import {
1381
+ useConceptModal,
1382
+ ConceptPills,
1383
+ PluginLayout,
1384
+ StageHeader,
1385
+ StatBadge,
1386
+ SidePanel,
1387
+ SideCard,
1388
+ CanvasStage,
1389
+ } from "../../components/plugin-kit";
1390
+ import { concepts, type ConceptKey } from "./concepts";
1391
+ import { use${pascalName}Animation, type Signal } from "./use${pascalName}Animation";
1392
+ import { VARIANT_PROFILES, type ${pascalName}State } from "./${camelName}Slice";
1393
+ import { buildSteps } from "./flow-engine";
1394
+ import "./main.scss";
1395
+
1396
+ interface Props {
1397
+ onAnimationComplete?: () => void;
1398
+ }
1399
+
1400
+ const W = 900;
1401
+ const H = 600;
1402
+
1403
+ const ${pascalName}Visualization: React.FC<Props> = ({ onAnimationComplete }) => {
1404
+ const { runtime, signals } =
1405
+ use${pascalName}Animation(onAnimationComplete);
1406
+ const { openConcept, ConceptModal } = useConceptModal<ConceptKey>(concepts);
1407
+ const containerRef = useRef<HTMLDivElement>(null!);
1408
+ const builderRef = useRef<ReturnType<typeof viz> | null>(null);
1409
+ const pzRef = useRef<PanZoomController | null>(null);
1410
+ const viewportRef = useRef<{
1411
+ zoom: number;
1412
+ pan: { x: number; y: number };
1413
+ } | null>(null);
1414
+
1415
+ const st = runtime as ${pascalName}State;
1416
+ const { explanation, hotZones, phase, variant } = st;
1417
+ const profile = VARIANT_PROFILES[variant];
1418
+ const hot = (zone: string) => hotZones.includes(zone);
1419
+
1420
+ /* ── Build VizCraft scene ─────────────────────────────── */
1421
+ const scene = (() => {
1422
+ const b = viz().view(W, H);
1423
+
1424
+ // TODO: build your nodes / edges dynamically based on \`variant\`
1425
+ b.node("node-a")
1426
+ .at(200, 300)
1427
+ .rect(140, 60, 12)
1428
+ .fill(hot("node-a") ? "#1e40af" : "#0f172a")
1429
+ .stroke(hot("node-a") ? "#60a5fa" : "#334155", 2)
1430
+ .label("Node A", { fill: "#fff", fontSize: 13, fontWeight: "bold" });
1431
+
1432
+ b.node("node-b")
1433
+ .at(650, 300)
1434
+ .rect(140, 60, 12)
1435
+ .fill(hot("node-b") ? "#065f46" : "#0f172a")
1436
+ .stroke(hot("node-b") ? "#34d399" : "#334155", 2)
1437
+ .label("Node B", { fill: "#fff", fontSize: 13, fontWeight: "bold" });
1438
+
1439
+ b.edge("node-a", "node-b", "edge-ab")
1440
+ .stroke("#475569", 2)
1441
+ .animate("flow", { duration: "3s" });
1442
+
1443
+ // ── Signals ──────────────────────────────────────────
1444
+ if (signals.length > 0) {
1445
+ b.overlay((o) => {
1446
+ signals.forEach((sig: Signal) => {
1447
+ const { id, colorClass, ...params } = sig;
1448
+ o.add(
1449
+ "signal",
1450
+ params as SignalOverlayParams,
1451
+ { key: id, className: colorClass },
1452
+ );
1453
+ });
1454
+ });
1455
+ }
1456
+
1457
+ return b;
1458
+ })();
1459
+
1460
+ /* ── Mount / destroy VizCraft scene ─────────────────── */
1461
+ useLayoutEffect(() => {
1462
+ if (!containerRef.current) return;
1463
+ const saved = viewportRef.current;
1464
+ builderRef.current?.destroy();
1465
+ builderRef.current = scene;
1466
+ pzRef.current =
1467
+ scene.mount(containerRef.current, {
1468
+ autoplay: true,
1469
+ panZoom: true,
1470
+ initialZoom: saved?.zoom ?? 1,
1471
+ initialPan: saved?.pan ?? { x: 0, y: 0 },
1472
+ }) ?? null;
1473
+ const unsub = pzRef.current?.onChange((s) => {
1474
+ viewportRef.current = s;
1475
+ });
1476
+ return () => { unsub?.(); };
1477
+ }, [scene]);
1478
+
1479
+ useEffect(() => {
1480
+ return () => {
1481
+ builderRef.current?.destroy();
1482
+ builderRef.current = null;
1483
+ pzRef.current = null;
1484
+ };
1485
+ }, []);
1486
+
1487
+ /* ── Pill definitions ───────────────────────────────── */
1488
+ const pills = [
1489
+ { key: "overview", label: "${pascalName}", color: "#93c5fd", borderColor: "#3b82f6" },
1490
+ ];
1491
+
1492
+ /* ── Render ─────────────────────────────────────────── */
1493
+ return (
1494
+ <div className="${pluginName}-root ${pluginName}-phase--\${phase}">
1495
+ <PluginLayout
1496
+ toolbar={<ConceptPills pills={pills} onOpen={openConcept} />}
1497
+ canvas={
1498
+ <div className="${pluginName}-stage">
1499
+ <StageHeader
1500
+ title="${pascalName}"
1501
+ subtitle={\`Comparing: \${profile.label}\`}
1502
+ >
1503
+ <StatBadge
1504
+ label="Variant"
1505
+ value={profile.label}
1506
+ className={\`${pluginName}-phase ${pluginName}-phase--\${phase}\`}
1507
+ />
1508
+ <StatBadge label="Latency" value={\`\${st.latencyMs}ms\`} />
1509
+ <StatBadge label="Throughput" value={\`\${st.throughput} rps\`} />
1510
+ </StageHeader>
1511
+ <CanvasStage canvasRef={containerRef} />
1512
+ </div>
1513
+ }
1514
+ sidebar={
1515
+ <SidePanel>
1516
+ <SideCard label="What's happening" variant="explanation">
1517
+ <p>{explanation}</p>
1518
+ </SideCard>
1519
+ <SideCard label="Active Variant" variant="info">
1520
+ <p style={{ color: profile.color, fontWeight: 600 }}>
1521
+ {profile.label}
1522
+ </p>
1523
+ <p>{profile.description}</p>
1524
+ </SideCard>
1525
+ </SidePanel>
1526
+ }
1527
+ />
1528
+ <ConceptModal />
1529
+ </div>
1530
+ );
1531
+ };
1532
+
1223
1533
  export default ${pascalName}Visualization;
1224
1534
  `;
1225
1535
  } else {
@@ -1257,6 +1567,7 @@ const ${pascalName}Visualization: React.FC<Props> = ({ onAnimationComplete }) =>
1257
1567
  const containerRef = useRef<HTMLDivElement>(null!);
1258
1568
  const builderRef = useRef<ReturnType<typeof viz> | null>(null);
1259
1569
  const pzRef = useRef<PanZoomController | null>(null);
1570
+ const viewportRef = useRef<{ zoom: number; pan: { x: number; y: number } } | null>(null);
1260
1571
 
1261
1572
  const { explanation, hotZones } = runtime;
1262
1573
  const hot = (zone: string) => hotZones.includes(zone);
@@ -1301,7 +1612,7 @@ const ${pascalName}Visualization: React.FC<Props> = ({ onAnimationComplete }) =>
1301
1612
  /* ── Mount / destroy VizCraft scene ─────────────────── */
1302
1613
  useLayoutEffect(() => {
1303
1614
  if (!containerRef.current) return;
1304
- const saved = pzRef.current?.getState() ?? null;
1615
+ const saved = viewportRef.current;
1305
1616
  builderRef.current?.destroy();
1306
1617
  builderRef.current = scene;
1307
1618
  pzRef.current =
@@ -1311,6 +1622,10 @@ const ${pascalName}Visualization: React.FC<Props> = ({ onAnimationComplete }) =>
1311
1622
  initialZoom: saved?.zoom ?? 1,
1312
1623
  initialPan: saved?.pan ?? { x: 0, y: 0 },
1313
1624
  }) ?? null;
1625
+ const unsub = pzRef.current?.onChange((s) => {
1626
+ viewportRef.current = s;
1627
+ });
1628
+ return () => { unsub?.(); };
1314
1629
  }, [scene]);
1315
1630
 
1316
1631
  useEffect(() => {
@@ -1579,6 +1894,78 @@ scssContent = `.${pluginName}-root {
1579
1894
  }
1580
1895
  }
1581
1896
  `;
1897
+ } else if (isComparison) {
1898
+ scssContent = `.${pluginName}-root {
1899
+ --${pluginName}-bg: #020617;
1900
+ --${pluginName}-panel: rgba(7, 17, 34, 0.88);
1901
+ --${pluginName}-border: rgba(148, 163, 184, 0.18);
1902
+ --${pluginName}-text: #e2e8f0;
1903
+ --${pluginName}-muted: #94a3b8;
1904
+
1905
+ display: flex;
1906
+ flex-direction: column;
1907
+ width: 100%;
1908
+ height: 100%;
1909
+ overflow: hidden;
1910
+ color: var(--${pluginName}-text);
1911
+ background:
1912
+ radial-gradient(circle at top left, rgba(59, 130, 246, 0.14), transparent 28%),
1913
+ radial-gradient(circle at bottom right, rgba(20, 184, 166, 0.12), transparent 30%),
1914
+ linear-gradient(180deg, #020617 0%, #071325 100%);
1915
+ }
1916
+
1917
+ /* ── Stage ──────────────────────────────────────────── */
1918
+ .${pluginName}-stage {
1919
+ background: var(--${pluginName}-panel);
1920
+ border: 1px solid var(--${pluginName}-border);
1921
+ box-shadow: 0 20px 42px -28px rgba(0, 0, 0, 0.7);
1922
+ border-radius: 24px;
1923
+ padding: 1rem;
1924
+ display: flex;
1925
+ flex-direction: column;
1926
+ min-height: 0;
1927
+ }
1928
+
1929
+ /* ── Phase colours ──────────────────────────────────── */
1930
+ .${pluginName}-phase--overview .vc-stat-badge__value { color: #fbbf24; }
1931
+ .${pluginName}-phase--traffic .vc-stat-badge__value { color: #60a5fa; }
1932
+ .${pluginName}-phase--comparison .vc-stat-badge__value { color: #14b8a6; }
1933
+ .${pluginName}-phase--summary .vc-stat-badge__value { color: #86efac; }
1934
+
1935
+ /* ── Controls ────────────────────────────────────────── */
1936
+ .${pluginName}-controls {
1937
+ display: flex;
1938
+ align-items: center;
1939
+ gap: 0.5rem;
1940
+ flex-wrap: wrap;
1941
+ }
1942
+
1943
+ .${pluginName}-controls__btn {
1944
+ background: rgba(30, 41, 59, 0.6);
1945
+ color: #e2e8f0;
1946
+ border: 1px solid rgba(148, 163, 184, 0.25);
1947
+ border-radius: 8px;
1948
+ padding: 0.3rem 0.75rem;
1949
+ font-size: 0.78rem;
1950
+ font-weight: 500;
1951
+ cursor: pointer;
1952
+ transition: background 0.15s, border-color 0.15s;
1953
+ }
1954
+ .${pluginName}-controls__btn:hover {
1955
+ background: rgba(51, 65, 85, 0.7);
1956
+ }
1957
+ .${pluginName}-controls__btn--active {
1958
+ border-color: currentColor;
1959
+ background: rgba(51, 65, 85, 0.5);
1960
+ font-weight: 700;
1961
+ }
1962
+
1963
+ .${pluginName}-controls__sep {
1964
+ width: 1px;
1965
+ height: 1.2rem;
1966
+ background: rgba(148, 163, 184, 0.2);
1967
+ }
1968
+ `;
1582
1969
  } else {
1583
1970
  scssContent = `.${pluginName}-root {
1584
1971
  --${pluginName}-bg: #020617;
@@ -1700,6 +2087,45 @@ const ${pascalName}Plugin: DemoPlugin<
1700
2087
  selector: (state: LocalRootState) => state.${camelName},
1701
2088
  };
1702
2089
 
2090
+ export { buildSteps };
2091
+ export type { StepKey, TaggedStep };
2092
+ export default ${pascalName}Plugin;
2093
+ `;
2094
+ } else if (isComparison) {
2095
+ indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
2096
+ import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
2097
+ import ${pascalName}Visualization from "./main";
2098
+ import ${pascalName}Controls from "./controls";
2099
+ import ${camelName}Reducer, {
2100
+ type ${pascalName}State,
2101
+ initialState,
2102
+ reset,
2103
+ } from "./${camelName}Slice";
2104
+ import { buildSteps, type StepKey, type TaggedStep } from "./flow-engine";
2105
+
2106
+ type LocalRootState = { ${camelName}: ${pascalName}State };
2107
+
2108
+ const ${pascalName}Plugin: DemoPlugin<
2109
+ ${pascalName}State,
2110
+ Action,
2111
+ LocalRootState,
2112
+ Dispatch<Action>
2113
+ > = {
2114
+ id: "${pluginName}",
2115
+ name: "${pascalName}",
2116
+ description: "Describe what this comparison lab teaches.",
2117
+ initialState,
2118
+ reducer: ${camelName}Reducer,
2119
+ Component: ${pascalName}Visualization,
2120
+ Controls: ${pascalName}Controls,
2121
+ restartConfig: { text: "Replay", color: "#3b82f6" },
2122
+ getSteps: (state: ${pascalName}State): DemoStep[] => buildSteps(state),
2123
+ init: (dispatch) => {
2124
+ dispatch(reset());
2125
+ },
2126
+ selector: (state: LocalRootState) => state.${camelName},
2127
+ };
2128
+
1703
2129
  export { buildSteps };
1704
2130
  export type { StepKey, TaggedStep };
1705
2131
  export default ${pascalName}Plugin;
@@ -2404,6 +2830,168 @@ export const connections: {
2404
2830
  fs.writeFileSync(path.join(targetDir, "data.ts"), dataContent);
2405
2831
  } // end timeline-only files
2406
2832
 
2833
+ /* ================================================================
2834
+ 7d. (Comparison only) Flow Engine — flow-engine.ts
2835
+ ================================================================ */
2836
+ if (isComparison) {
2837
+ const flowEngineContent = `import type { ${pascalName}State } from "./${camelName}Slice";
2838
+ import {
2839
+ buildSteps as genericBuildSteps,
2840
+ executeFlow as genericExecuteFlow,
2841
+ type FlowBeat as GenericFlowBeat,
2842
+ type StepDef as GenericStepDef,
2843
+ type TaggedStep as GenericTaggedStep,
2844
+ type FlowExecutorDeps as GenericFlowExecutorDeps,
2845
+ } from "../../lib/lab-engine";
2846
+
2847
+ /* ══════════════════════════════════════════════════════════
2848
+ ${pascalName} Lab — Declarative Flow Engine
2849
+
2850
+ Uses the shared lab-engine for build/execute logic.
2851
+ This file defines the plugin-specific steps, tokens,
2852
+ and type aliases.
2853
+ ══════════════════════════════════════════════════════════ */
2854
+
2855
+ /* ── Specialised type aliases ──────────────────────────── */
2856
+
2857
+ export type FlowBeat = GenericFlowBeat<${pascalName}State>;
2858
+ export type StepDef = GenericStepDef<${pascalName}State, StepKey>;
2859
+ export type TaggedStep = GenericTaggedStep<StepKey>;
2860
+ export type FlowExecutorDeps = GenericFlowExecutorDeps<${pascalName}State>;
2861
+
2862
+ /* ── Token expansion ─────────────────────────────────── */
2863
+
2864
+ export function expandToken(token: string, _state: ${pascalName}State): string[] {
2865
+ // TODO: expand $-prefixed tokens to runtime node IDs
2866
+ // e.g. if (token === "$clients") return state.clients.map(c => c.id);
2867
+ return [token];
2868
+ }
2869
+
2870
+ /* ── Step keys ───────────────────────────────────────── */
2871
+
2872
+ export type StepKey = "overview" | "send-traffic" | "observe-metrics" | "summary";
2873
+ // TODO: add more step keys as needed
2874
+
2875
+ /* ── Step Configuration ──────────────────────────────── */
2876
+
2877
+ export const STEPS: StepDef[] = [
2878
+ {
2879
+ key: "overview",
2880
+ label: "Architecture Overview",
2881
+ nextButton: "Send Traffic",
2882
+ action: "resetRun",
2883
+ explain: (s) =>
2884
+ \`\${s.variant === "variant-a" ? "Variant A" : "Variant B"} selected. Step through to compare.\`,
2885
+ },
2886
+ {
2887
+ key: "send-traffic",
2888
+ label: "Send Traffic",
2889
+ processingText: "Sending...",
2890
+ nextButtonColor: "#2563eb",
2891
+ phase: "traffic",
2892
+ flow: [
2893
+ {
2894
+ from: "node-a",
2895
+ to: "node-b",
2896
+ duration: 700,
2897
+ explain: "Requests flow from A to B.",
2898
+ },
2899
+ ],
2900
+ recalcMetrics: true,
2901
+ explain: (s) =>
2902
+ \`Throughput: \${s.throughput} rps — Latency: \${s.latencyMs}ms.\`,
2903
+ },
2904
+ {
2905
+ key: "observe-metrics",
2906
+ label: "Observe Metrics",
2907
+ nextButtonColor: "#2563eb",
2908
+ recalcMetrics: true,
2909
+ delay: 500,
2910
+ phase: "comparison",
2911
+ finalHotZones: ["node-b"],
2912
+ explain: (s) =>
2913
+ \`\${s.variant === "variant-a" ? "Variant A" : "Variant B"} — \${s.throughput} rps at \${s.latencyMs}ms latency.\`,
2914
+ },
2915
+ {
2916
+ key: "summary",
2917
+ label: "Summary",
2918
+ phase: "summary",
2919
+ explain: (s) =>
2920
+ \`Comparison complete. Try switching variants and replaying.\`,
2921
+ },
2922
+ ];
2923
+
2924
+ /* ── Build active steps ──────────────────────────────── */
2925
+
2926
+ export function buildSteps(state: ${pascalName}State): TaggedStep[] {
2927
+ return genericBuildSteps(STEPS, state);
2928
+ }
2929
+
2930
+ /* ── Execute flow ────────────────────────────────────── */
2931
+
2932
+ export async function executeFlow(
2933
+ beats: FlowBeat[],
2934
+ deps: FlowExecutorDeps,
2935
+ ): Promise<void> {
2936
+ return genericExecuteFlow(beats, deps, expandToken);
2937
+ }
2938
+ `;
2939
+
2940
+ fs.writeFileSync(path.join(targetDir, "flow-engine.ts"), flowEngineContent);
2941
+
2942
+ /* ================================================================
2943
+ 7e. (Comparison only) Controls — controls.tsx
2944
+ ================================================================ */
2945
+ const controlsContent = `import React from "react";
2946
+ import { useDispatch, useSelector } from "react-redux";
2947
+ import { type RootState } from "../../store/store";
2948
+ import { resetSimulation } from "../../store/slices/simulationSlice";
2949
+ import {
2950
+ setVariant,
2951
+ VARIANT_PROFILES,
2952
+ type ${pascalName}State,
2953
+ type VariantKey,
2954
+ } from "./${camelName}Slice";
2955
+
2956
+ const variantKeys = Object.keys(VARIANT_PROFILES) as VariantKey[];
2957
+
2958
+ const ${pascalName}Controls: React.FC = () => {
2959
+ const dispatch = useDispatch();
2960
+ const { variant } = useSelector(
2961
+ (state: RootState) => state.${camelName},
2962
+ ) as ${pascalName}State;
2963
+
2964
+ const handleSwitch = (key: VariantKey) => {
2965
+ if (key === variant) return;
2966
+ dispatch(setVariant(key));
2967
+ dispatch(resetSimulation());
2968
+ };
2969
+
2970
+ return (
2971
+ <div className="${pluginName}-controls">
2972
+ {variantKeys.map((key) => {
2973
+ const profile = VARIANT_PROFILES[key];
2974
+ return (
2975
+ <button
2976
+ key={key}
2977
+ className={\`${pluginName}-controls__btn\${key === variant ? " ${pluginName}-controls__btn--active" : ""}\`}
2978
+ style={key === variant ? { color: profile.color, borderColor: profile.color } : {}}
2979
+ onClick={() => handleSwitch(key)}
2980
+ >
2981
+ {profile.label}
2982
+ </button>
2983
+ );
2984
+ })}
2985
+ </div>
2986
+ );
2987
+ };
2988
+
2989
+ export default ${pascalName}Controls;
2990
+ `;
2991
+
2992
+ fs.writeFileSync(path.join(targetDir, "controls.tsx"), controlsContent);
2993
+ } // end comparison-only files
2994
+
2407
2995
  /* ================================================================
2408
2996
  9. Update registry.ts — add import + wire into category
2409
2997
  ================================================================ */
@@ -2514,7 +3102,7 @@ if (fs.existsSync(registryPath)) {
2514
3102
  );
2515
3103
  }
2516
3104
 
2517
- const modeLabel = isSandbox ? 'sandbox ' : isTimeline ? 'timeline ' : '';
3105
+ const modeLabel = isSandbox ? 'sandbox ' : isTimeline ? 'timeline ' : isComparison ? 'comparison-lab ' : '';
2518
3106
  console.log("");
2519
3107
  console.log(
2520
3108
  '✔ Created ' + modeLabel + 'plugin "' + pluginName + '" in src/plugins/' + pluginName,
@@ -2535,6 +3123,10 @@ if (isTimeline) {
2535
3123
  console.log(" • flow-engine.ts — Declarative step config (timeline)");
2536
3124
  console.log(" • data.ts — Timeline items, categories, connections");
2537
3125
  }
3126
+ if (isComparison) {
3127
+ console.log(" • flow-engine.ts — Lab-engine step & flow config");
3128
+ console.log(" • controls.tsx — Variant selector panel");
3129
+ }
2538
3130
  console.log("");
2539
3131
  console.log(" Next steps:");
2540
3132
  if (isSandbox) {
@@ -2549,6 +3141,13 @@ if (isSandbox) {
2549
3141
  console.log(" 3. Steps auto-generate from items — customise labels in flow-engine.ts");
2550
3142
  console.log(" 4. Add concept pills & definitions in concepts.tsx");
2551
3143
  console.log(" 5. Customise node tooltips and sidebar detail in main.tsx");
3144
+ } else if (isComparison) {
3145
+ console.log(" 1. Define VariantKey + VARIANT_PROFILES in " + camelName + "Slice.ts");
3146
+ console.log(" 2. Implement computeMetrics() per variant");
3147
+ console.log(" 3. Define steps & flow beats in STEPS (flow-engine.ts)");
3148
+ console.log(" 4. Build dynamic scene in main.tsx (nodes adapt to selected variant)");
3149
+ console.log(" 5. Add concept pills & definitions in concepts.tsx");
3150
+ console.log(" 6. Uses shared lib/lab-engine — animation hook is ~30 lines");
2552
3151
  } else {
2553
3152
  console.log(" 1. Define your VizCraft nodes/edges in main.tsx");
2554
3153
  console.log(" 2. Add step animations in use" + pascalName + "Animation.ts");
@@ -0,0 +1,93 @@
1
+ /* ═══════════════════════════════════════════════════════════
2
+ * Lab Engine — Generic flow engine
3
+ *
4
+ * buildSteps() — filter + reorder active steps
5
+ * executeFlow() — iterate beats, expand tokens, animate
6
+ * ═══════════════════════════════════════════════════════════ */
7
+
8
+ import type {
9
+ FlowBeat,
10
+ FlowExecutorDeps,
11
+ LabState,
12
+ StepDef,
13
+ TaggedStep,
14
+ } from "./types";
15
+
16
+ /* ── Build active steps from config ───────────────────── */
17
+
18
+ export function buildSteps<S extends LabState, K extends string>(
19
+ allSteps: StepDef<S, K>[],
20
+ state: S,
21
+ opts?: {
22
+ /** Optional post-filter reorder (e.g. adapter.reorderSteps) */
23
+ reorder?: (steps: StepDef<S, K>[], state: S) => StepDef<S, K>[];
24
+ /** Optional per-step label transform */
25
+ relabel?: (step: StepDef<S, K>, state: S) => string;
26
+ },
27
+ ): TaggedStep<K>[] {
28
+ let active = allSteps.filter((s) => !s.when || s.when(state));
29
+
30
+ if (opts?.reorder) {
31
+ active = opts.reorder(active, state);
32
+ }
33
+
34
+ return active.map((step, i) => {
35
+ const nextStep = active[i + 1];
36
+
37
+ let nextButtonText: string | undefined;
38
+ if (typeof step.nextButton === "function") {
39
+ nextButtonText = step.nextButton(state);
40
+ } else if (typeof step.nextButton === "string") {
41
+ nextButtonText = step.nextButton;
42
+ } else if (nextStep) {
43
+ nextButtonText = nextStep.label;
44
+ }
45
+
46
+ const label = opts?.relabel ? opts.relabel(step, state) : step.label;
47
+
48
+ return {
49
+ key: step.key,
50
+ label,
51
+ autoAdvance: false,
52
+ nextButtonText,
53
+ nextButtonColor: step.nextButtonColor,
54
+ processingText: step.processingText,
55
+ };
56
+ });
57
+ }
58
+
59
+ /* ── Flow executor ────────────────────────────────────── */
60
+
61
+ export async function executeFlow<S extends LabState>(
62
+ beats: FlowBeat<S>[],
63
+ deps: FlowExecutorDeps<S>,
64
+ expandToken: (token: string, state: S) => string[],
65
+ ): Promise<void> {
66
+ for (const beat of beats) {
67
+ if (deps.cancelled()) return;
68
+
69
+ const state = deps.getState();
70
+ if (beat.when && !beat.when(state)) continue;
71
+
72
+ const froms = expandToken(beat.from, state);
73
+ const tos = expandToken(beat.to, state);
74
+
75
+ const pairs: { from: string; to: string }[] = [];
76
+ for (const f of froms) {
77
+ for (const t of tos) {
78
+ if (f !== t) pairs.push({ from: f, to: t });
79
+ }
80
+ }
81
+ if (pairs.length === 0) continue;
82
+
83
+ const hotZones = [...new Set([...froms, ...tos])];
84
+ const update: Partial<S> = { hotZones } as Partial<S>;
85
+ if (beat.explain)
86
+ (update as Record<string, unknown>).explanation = beat.explain;
87
+ deps.patch(update);
88
+
89
+ const color =
90
+ typeof beat.color === "function" ? beat.color(state) : beat.color;
91
+ await deps.animateParallel(pairs, beat.duration ?? 650, color);
92
+ }
93
+ }
@@ -0,0 +1,25 @@
1
+ /* ═══════════════════════════════════════════════════════════
2
+ * Lab Engine — Public API
3
+ *
4
+ * Usage:
5
+ * import {
6
+ * type FlowBeat, type StepDef, type TaggedStep,
7
+ * type LabState, type ActionMapping, type Signal,
8
+ * buildSteps, executeFlow, useLabAnimation,
9
+ * } from "../../lib/lab-engine";
10
+ * ═══════════════════════════════════════════════════════════ */
11
+
12
+ /* ── Types ────────────────────────────────────────────── */
13
+ export type {
14
+ FlowBeat,
15
+ StepDef,
16
+ TaggedStep,
17
+ FlowExecutorDeps,
18
+ LabState,
19
+ ActionMapping,
20
+ } from "./types";
21
+
22
+ /* ── Runtime ──────────────────────────────────────────── */
23
+ export { buildSteps, executeFlow } from "./flow-engine";
24
+ export { useLabAnimation } from "./useLabAnimation";
25
+ export type { Signal, UseLabAnimationConfig } from "./useLabAnimation";
@@ -0,0 +1,80 @@
1
+ /* ═══════════════════════════════════════════════════════════
2
+ * Lab Engine — Shared types for comparison-lab plugins
3
+ *
4
+ * Every "lab" plugin (Failover Lab, DB Tradeoff Lab, …)
5
+ * re-uses these generic building blocks so the animation
6
+ * lifecycle, step engine, and flow executor are consistent.
7
+ * ═══════════════════════════════════════════════════════════ */
8
+
9
+ /* ── Minimal state contract every lab plugin must satisfy ── */
10
+
11
+ export interface LabState {
12
+ phase: string;
13
+ explanation: string;
14
+ hotZones: string[];
15
+ }
16
+
17
+ /* ── Flow Beat ────────────────────────────────────────── */
18
+
19
+ export interface FlowBeat<S> {
20
+ from: string;
21
+ to: string;
22
+ when?: (s: S) => boolean;
23
+ color?: string | ((s: S) => string);
24
+ duration?: number;
25
+ explain?: string;
26
+ }
27
+
28
+ /* ── Step Definition ──────────────────────────────────── */
29
+
30
+ export interface StepDef<S, K extends string = string> {
31
+ key: K;
32
+ label: string;
33
+ when?: (s: S) => boolean;
34
+ nextButton?: string | ((s: S) => string);
35
+ nextButtonColor?: string;
36
+ processingText?: string;
37
+ phase?: string | ((s: S) => string);
38
+ flow?: FlowBeat<S>[] | ((s: S) => FlowBeat<S>[]);
39
+ delay?: number;
40
+ recalcMetrics?: boolean;
41
+ finalHotZones?: string[] | ((s: S) => string[]);
42
+ explain?: string | ((s: S) => string);
43
+ action?: string;
44
+ }
45
+
46
+ /* ── Tagged Step (visible-step tuple for the Shell) ───── */
47
+
48
+ export interface TaggedStep<K extends string = string> {
49
+ key: K;
50
+ label: string;
51
+ autoAdvance?: boolean;
52
+ nextButtonText?: string;
53
+ nextButtonColor?: string;
54
+ processingText?: string;
55
+ }
56
+
57
+ /* ── Flow executor dependency bag ─────────────────────── */
58
+
59
+ export interface FlowExecutorDeps<S> {
60
+ animateParallel: (
61
+ pairs: { from: string; to: string }[],
62
+ duration: number,
63
+ color?: string,
64
+ ) => Promise<void>;
65
+ patch: (p: Partial<S>) => void;
66
+ getState: () => S;
67
+ cancelled: () => boolean;
68
+ }
69
+
70
+ /* ── Action mapping for the generic animation hook ────── */
71
+
72
+ export interface ActionMapping {
73
+ /** Action creator — the hook calls dispatch(create()) */
74
+ create: () => { type: string };
75
+ /**
76
+ * If true, the step executor finishes immediately after
77
+ * dispatching (e.g. "reset" / "softReset" actions).
78
+ */
79
+ terminal?: boolean;
80
+ }
@@ -0,0 +1,253 @@
1
+ /* ═══════════════════════════════════════════════════════════
2
+ * Lab Engine — Generic animation hook
3
+ *
4
+ * useLabAnimation() provides the common step-executor
5
+ * lifecycle shared by every comparison-lab plugin:
6
+ *
7
+ * cleanup → action → phase → hotZones → flow → recalc →
8
+ * delay → finalHotZones → explain → finish
9
+ * ═══════════════════════════════════════════════════════════ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+ import { useDispatch, useSelector } from "react-redux";
13
+ import type { UnknownAction } from "@reduxjs/toolkit";
14
+ import type { SignalOverlayParams } from "vizcraft";
15
+ import type { RootState } from "../../store/store";
16
+ import { executeFlow } from "./flow-engine";
17
+ import type {
18
+ ActionMapping,
19
+ FlowBeat,
20
+ LabState,
21
+ StepDef,
22
+ TaggedStep,
23
+ } from "./types";
24
+
25
+ /* ── Signal type (shared across all labs) ─────────────── */
26
+
27
+ export type Signal = { id: string; colorClass?: string } & SignalOverlayParams;
28
+
29
+ /* ── Hook configuration ───────────────────────────────── */
30
+
31
+ export interface UseLabAnimationConfig<S extends LabState, K extends string> {
32
+ /** Redux selector that returns the plugin's state slice */
33
+ selector: (root: RootState) => S;
34
+
35
+ /** The full step-definition array (un-filtered) */
36
+ allSteps: StepDef<S, K>[];
37
+
38
+ /** Build the visible, ordered steps for the current state */
39
+ buildSteps: (state: S) => TaggedStep<K>[];
40
+
41
+ /** Expand `$tokens` in FlowBeat from/to fields */
42
+ expandToken: (token: string, state: S) => string[];
43
+
44
+ /** Map of action key → { create(), terminal? } */
45
+ actions: (
46
+ dispatch: ReturnType<typeof useDispatch>,
47
+ ) => Record<string, ActionMapping>;
48
+
49
+ /** Dispatch to recalculate metrics */
50
+ recalcMetrics: () => UnknownAction;
51
+
52
+ /** Dispatch to patch partial state */
53
+ patchState: (p: Partial<S>) => UnknownAction;
54
+ }
55
+
56
+ /* ── The hook ─────────────────────────────────────────── */
57
+
58
+ export function useLabAnimation<S extends LabState, K extends string>(
59
+ config: UseLabAnimationConfig<S, K>,
60
+ onAnimationComplete?: () => void,
61
+ ) {
62
+ const dispatch = useDispatch();
63
+ const { currentStep } = useSelector((s: RootState) => s.simulation);
64
+ const runtime = useSelector(config.selector) as S;
65
+
66
+ const [signals, setSignals] = useState<Signal[]>([]);
67
+ const rafRef = useRef<number>(0);
68
+ const timeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
69
+ const onCompleteRef = useRef(onAnimationComplete);
70
+ const runtimeRef = useRef(runtime);
71
+
72
+ onCompleteRef.current = onAnimationComplete;
73
+ runtimeRef.current = runtime;
74
+
75
+ /* ── Utilities ──────────────────────────────────────── */
76
+
77
+ const cleanup = useCallback(() => {
78
+ cancelAnimationFrame(rafRef.current);
79
+ timeoutsRef.current.forEach((id) => clearTimeout(id));
80
+ timeoutsRef.current = [];
81
+ setSignals([]);
82
+ }, []);
83
+
84
+ const sleep = useCallback(
85
+ (ms: number) =>
86
+ new Promise<void>((resolve) => {
87
+ const id = setTimeout(resolve, ms);
88
+ timeoutsRef.current.push(id);
89
+ }),
90
+ [],
91
+ );
92
+
93
+ const animateParallel = useCallback(
94
+ (pairs: { from: string; to: string }[], duration: number, color?: string) =>
95
+ new Promise<void>((resolve) => {
96
+ const start = performance.now();
97
+ const colorClass =
98
+ color === "#22c55e"
99
+ ? "viz-signal viz-signal-green"
100
+ : color === "#f59e0b"
101
+ ? "viz-signal viz-signal-amber"
102
+ : undefined;
103
+ const sigs: Signal[] = pairs.map((p, i) => ({
104
+ id: `sig-${Date.now()}-${i}`,
105
+ from: p.from,
106
+ to: p.to,
107
+ progress: 0,
108
+ magnitude: 0.85,
109
+ ...(color ? { color, glowColor: color } : {}),
110
+ ...(colorClass ? { colorClass } : {}),
111
+ }));
112
+
113
+ const tick = (now: number) => {
114
+ const p = Math.min((now - start) / duration, 1);
115
+ setSignals(sigs.map((s) => ({ ...s, progress: p })));
116
+ if (p < 1) {
117
+ rafRef.current = requestAnimationFrame(tick);
118
+ } else {
119
+ resolve();
120
+ }
121
+ };
122
+ rafRef.current = requestAnimationFrame(tick);
123
+ }),
124
+ [],
125
+ );
126
+
127
+ /* ── Resolve current step ──────────────────────────── */
128
+
129
+ const steps = config.buildSteps(runtime);
130
+ const currentKey: K | undefined = steps[currentStep]?.key;
131
+
132
+ /* ── Build action map ──────────────────────────────── */
133
+
134
+ const actionsRef = useRef(config.actions(dispatch));
135
+ actionsRef.current = config.actions(dispatch);
136
+
137
+ /* ── Generic step executor ─────────────────────────── */
138
+
139
+ useEffect(() => {
140
+ let cancelled = false;
141
+ cleanup();
142
+
143
+ const finish = () => {
144
+ if (!cancelled) setTimeout(() => onCompleteRef.current?.(), 0);
145
+ };
146
+ const rt = () => runtimeRef.current;
147
+ const doPatch = (p: Partial<S>) => dispatch(config.patchState(p));
148
+
149
+ const stepDef = config.allSteps.find((s) => s.key === currentKey);
150
+ if (!stepDef) {
151
+ finish();
152
+ return cleanup;
153
+ }
154
+
155
+ /* Resolve helpers that may be literal or function */
156
+ const resolveFhz = (): string[] | undefined => {
157
+ const f = stepDef.finalHotZones;
158
+ if (f === undefined) return undefined;
159
+ return typeof f === "function" ? f(rt()) : f;
160
+ };
161
+
162
+ const run = async () => {
163
+ /* 1. Dispatch actions */
164
+ if (stepDef.action) {
165
+ const mapping = actionsRef.current[stepDef.action];
166
+ if (mapping) {
167
+ dispatch(mapping.create() as UnknownAction);
168
+ if (mapping.terminal) {
169
+ finish();
170
+ return;
171
+ }
172
+ }
173
+ }
174
+
175
+ /* 2. Resolve flow */
176
+ const resolvedFlow: FlowBeat<S>[] | undefined =
177
+ typeof stepDef.flow === "function" ? stepDef.flow(rt()) : stepDef.flow;
178
+
179
+ /* 3. Recalc metrics early (non-flow steps) */
180
+ if (stepDef.recalcMetrics && !resolvedFlow) {
181
+ dispatch(config.recalcMetrics());
182
+ }
183
+
184
+ /* 4. Set phase */
185
+ if (stepDef.phase) {
186
+ const phase =
187
+ typeof stepDef.phase === "function"
188
+ ? stepDef.phase(rt())
189
+ : stepDef.phase;
190
+ doPatch({ phase } as Partial<S>);
191
+ }
192
+
193
+ /* 5. Set initial hot zones for non-flow steps */
194
+ const fhzNoFlow = resolveFhz();
195
+ if (fhzNoFlow !== undefined && !resolvedFlow) {
196
+ doPatch({ hotZones: fhzNoFlow } as Partial<S>);
197
+ }
198
+
199
+ /* 6. Execute flow beats */
200
+ if (resolvedFlow && resolvedFlow.length > 0) {
201
+ await executeFlow(
202
+ resolvedFlow,
203
+ {
204
+ animateParallel,
205
+ patch: doPatch,
206
+ getState: rt,
207
+ cancelled: () => cancelled,
208
+ },
209
+ config.expandToken,
210
+ );
211
+ if (cancelled) return;
212
+ }
213
+
214
+ /* 7. Recalc metrics after flow */
215
+ if (stepDef.recalcMetrics && resolvedFlow) {
216
+ dispatch(config.recalcMetrics());
217
+ }
218
+
219
+ /* 8. Delay */
220
+ if (stepDef.delay) {
221
+ await sleep(stepDef.delay);
222
+ if (cancelled) return;
223
+ }
224
+
225
+ /* 9. Final hot zones */
226
+ const fhz = resolveFhz();
227
+ if (fhz !== undefined) {
228
+ doPatch({ hotZones: fhz } as Partial<S>);
229
+ } else if (!resolvedFlow) {
230
+ doPatch({ hotZones: [] as string[] } as Partial<S>);
231
+ }
232
+
233
+ /* 10. Final explanation */
234
+ if (stepDef.explain) {
235
+ const explanation =
236
+ typeof stepDef.explain === "function"
237
+ ? stepDef.explain(rt())
238
+ : stepDef.explain;
239
+ doPatch({ explanation } as Partial<S>);
240
+ }
241
+
242
+ finish();
243
+ };
244
+
245
+ run();
246
+ return () => {
247
+ cancelled = true;
248
+ cleanup();
249
+ };
250
+ }, [currentStep, currentKey, cleanup, dispatch, sleep, animateParallel]);
251
+
252
+ return { runtime, signals };
253
+ }
@@ -15,10 +15,7 @@ import {
15
15
  CanvasStage,
16
16
  } from "../../components/plugin-kit";
17
17
  import { concepts, type ConceptKey } from "./concepts";
18
- import {
19
- useHelloWorldAnimation,
20
- type Signal,
21
- } from "./useHelloWorldAnimation";
18
+ import { useHelloWorldAnimation, type Signal } from "./useHelloWorldAnimation";
22
19
  import "./main.scss";
23
20
 
24
21
  interface Props {
@@ -35,6 +32,10 @@ const HelloWorldVisualization: React.FC<Props> = ({ onAnimationComplete }) => {
35
32
  const containerRef = useRef<HTMLDivElement>(null!);
36
33
  const builderRef = useRef<ReturnType<typeof viz> | null>(null);
37
34
  const pzRef = useRef<PanZoomController | null>(null);
35
+ const viewportRef = useRef<{
36
+ zoom: number;
37
+ pan: { x: number; y: number };
38
+ } | null>(null);
38
39
 
39
40
  const { phase, message } = runtime;
40
41
  const isGreeting = phase === "greeting" || phase === "done";
@@ -76,7 +77,7 @@ const HelloWorldVisualization: React.FC<Props> = ({ onAnimationComplete }) => {
76
77
  /* ── Mount / destroy ────────────────────────────────── */
77
78
  useLayoutEffect(() => {
78
79
  if (!containerRef.current) return;
79
- const saved = pzRef.current?.getState() ?? null;
80
+ const saved = viewportRef.current;
80
81
  builderRef.current?.destroy();
81
82
  builderRef.current = scene;
82
83
  pzRef.current =
@@ -86,6 +87,12 @@ const HelloWorldVisualization: React.FC<Props> = ({ onAnimationComplete }) => {
86
87
  initialZoom: saved?.zoom ?? 1,
87
88
  initialPan: saved?.pan ?? { x: 0, y: 0 },
88
89
  }) ?? null;
90
+ const unsub = pzRef.current?.onChange((s) => {
91
+ viewportRef.current = s;
92
+ });
93
+ return () => {
94
+ unsub?.();
95
+ };
89
96
  }, [scene]);
90
97
 
91
98
  useEffect(() => {
@@ -113,7 +120,10 @@ const HelloWorldVisualization: React.FC<Props> = ({ onAnimationComplete }) => {
113
120
  toolbar={<ConceptPills pills={pills} onOpen={openConcept} />}
114
121
  canvas={
115
122
  <div className="hello-world-stage">
116
- <StageHeader title="Hello World" subtitle="A minimal reference plugin">
123
+ <StageHeader
124
+ title="Hello World"
125
+ subtitle="A minimal reference plugin"
126
+ >
117
127
  <StatBadge
118
128
  label="Phase"
119
129
  value={phase}