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 +1 -1
- package/template/scripts/generate-plugin.js +610 -11
- package/template/src/lib/lab-engine/flow-engine.ts +93 -0
- package/template/src/lib/lab-engine/index.ts +25 -0
- package/template/src/lib/lab-engine/types.ts +80 -0
- package/template/src/lib/lab-engine/useLabAnimation.ts +253 -0
- package/template/src/plugins/hello-world/main.tsx +16 -6
package/package.json
CHANGED
|
@@ -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
|
|
33
|
-
" --sandbox
|
|
34
|
-
"
|
|
35
|
-
" --timeline
|
|
36
|
-
"
|
|
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
|
|
42
|
-
console.error("Error: --sandbox and --
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
123
|
+
<StageHeader
|
|
124
|
+
title="Hello World"
|
|
125
|
+
subtitle="A minimal reference plugin"
|
|
126
|
+
>
|
|
117
127
|
<StatBadge
|
|
118
128
|
label="Phase"
|
|
119
129
|
value={phase}
|