create-vizcraft-playground 0.1.0 → 0.1.1
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/index.js +4 -0
- package/package.json +1 -1
- package/template/scripts/generate-plugin.js +759 -11
- package/template/src/App.scss +19 -8
- package/template/src/components/Shell.tsx +5 -0
- package/template/src/index.scss +1 -1
package/index.js
CHANGED
|
@@ -160,6 +160,10 @@ export default playgroundConfig;
|
|
|
160
160
|
console.log("");
|
|
161
161
|
console.log(' npm run generate my-concept --category "My Category"');
|
|
162
162
|
console.log("");
|
|
163
|
+
console.log(" Or scaffold a sandbox plugin (dynamic components, flow engine):");
|
|
164
|
+
console.log("");
|
|
165
|
+
console.log(' npm run generate my-concept --sandbox --category "My Category"');
|
|
166
|
+
console.log("");
|
|
163
167
|
|
|
164
168
|
rl.close();
|
|
165
169
|
}
|
package/package.json
CHANGED
|
@@ -6,14 +6,17 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
6
6
|
const __dirname = path.dirname(__filename);
|
|
7
7
|
|
|
8
8
|
// ── Arg parsing ────────────────────────────────────────────
|
|
9
|
-
// Usage: npm run generate <plugin-name> [--category "Category Name"]
|
|
9
|
+
// Usage: npm run generate <plugin-name> [--category "Category Name"] [--sandbox]
|
|
10
10
|
const args = process.argv.slice(2);
|
|
11
11
|
let pluginName = null;
|
|
12
12
|
let categoryName = null;
|
|
13
|
+
let isSandbox = false;
|
|
13
14
|
|
|
14
15
|
for (let i = 0; i < args.length; i++) {
|
|
15
16
|
if (args[i] === "--category" || args[i] === "-c") {
|
|
16
17
|
categoryName = args[++i];
|
|
18
|
+
} else if (args[i] === "--sandbox" || args[i] === "-s") {
|
|
19
|
+
isSandbox = true;
|
|
17
20
|
} else if (!args[i].startsWith("-")) {
|
|
18
21
|
pluginName = args[i];
|
|
19
22
|
}
|
|
@@ -21,9 +24,11 @@ for (let i = 0; i < args.length; i++) {
|
|
|
21
24
|
|
|
22
25
|
if (!pluginName) {
|
|
23
26
|
console.error(
|
|
24
|
-
"Usage: npm run generate <plugin-name> [--category \"Category Name\"]\n" +
|
|
27
|
+
"Usage: npm run generate <plugin-name> [--category \"Category Name\"] [--sandbox]\n" +
|
|
25
28
|
" Name must be kebab-case, e.g. npm run generate api-gateway\n" +
|
|
26
|
-
" --category / -c Existing or new category to place the plugin in"
|
|
29
|
+
" --category / -c Existing or new category to place the plugin in\n" +
|
|
30
|
+
" --sandbox / -s Generate a sandbox plugin with declarative flow engine,\n" +
|
|
31
|
+
" Controls panel, and dynamic component toggling",
|
|
27
32
|
);
|
|
28
33
|
process.exit(1);
|
|
29
34
|
}
|
|
@@ -49,7 +54,175 @@ fs.mkdirSync(targetDir, { recursive: true });
|
|
|
49
54
|
/* ================================================================
|
|
50
55
|
1. Redux Slice — ${camelName}Slice.ts
|
|
51
56
|
================================================================ */
|
|
52
|
-
|
|
57
|
+
let sliceContent;
|
|
58
|
+
if (isSandbox) {
|
|
59
|
+
sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
60
|
+
|
|
61
|
+
/* ── Addable infrastructure components ───────────────── */
|
|
62
|
+
export interface InfraComponents {
|
|
63
|
+
// TODO: add your togglable components here
|
|
64
|
+
// database: boolean;
|
|
65
|
+
// loadBalancer: boolean;
|
|
66
|
+
// cache: boolean;
|
|
67
|
+
// extraServers: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ComponentName = keyof InfraComponents;
|
|
71
|
+
|
|
72
|
+
/** Which components require which prerequisites. */
|
|
73
|
+
const PREREQUISITES: Partial<Record<ComponentName, ComponentName[]>> = {
|
|
74
|
+
// cache: ["database"],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Which components cascade-remove when toggled off. */
|
|
78
|
+
const CASCADE_REMOVE: Partial<Record<ComponentName, ComponentName[]>> = {
|
|
79
|
+
// database: ["cache"],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/* ── Client model ────────────────────────────────────── */
|
|
83
|
+
export interface ClientNode {
|
|
84
|
+
id: string;
|
|
85
|
+
type: "desktop" | "mobile";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ── State shape ─────────────────────────────────────── */
|
|
89
|
+
export interface ${pascalName}State {
|
|
90
|
+
components: InfraComponents;
|
|
91
|
+
clients: ClientNode[];
|
|
92
|
+
|
|
93
|
+
/* derived metrics (recomputed by computeMetrics) */
|
|
94
|
+
requestsPerSecond: number;
|
|
95
|
+
maxCapacity: number;
|
|
96
|
+
throughput: number;
|
|
97
|
+
droppedRequests: number;
|
|
98
|
+
|
|
99
|
+
/* ui */
|
|
100
|
+
hotZones: string[];
|
|
101
|
+
explanation: string;
|
|
102
|
+
phase: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const defaultClients: ClientNode[] = [
|
|
106
|
+
{ id: "client-1", type: "desktop" },
|
|
107
|
+
{ id: "client-2", type: "mobile" },
|
|
108
|
+
{ id: "client-3", type: "desktop" },
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/* ── Capacity model ──────────────────────────────────── */
|
|
112
|
+
function getMaxCapacity(c: InfraComponents): number {
|
|
113
|
+
let cap = 60; // base solo capacity
|
|
114
|
+
// TODO: adjust capacity based on components
|
|
115
|
+
return cap;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function computeMetrics(state: ${pascalName}State) {
|
|
119
|
+
const rps = state.clients.length * 10;
|
|
120
|
+
state.requestsPerSecond = rps;
|
|
121
|
+
state.maxCapacity = getMaxCapacity(state.components);
|
|
122
|
+
state.throughput = Math.min(rps, state.maxCapacity);
|
|
123
|
+
state.droppedRequests = Math.max(0, rps - state.maxCapacity);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function describeArch(c: InfraComponents): string {
|
|
127
|
+
const parts: string[] = ["Server"];
|
|
128
|
+
// TODO: push active component labels
|
|
129
|
+
return parts.join(" + ");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const initialState: ${pascalName}State = {
|
|
133
|
+
components: {
|
|
134
|
+
// TODO: initialise your components (all off by default)
|
|
135
|
+
} as InfraComponents,
|
|
136
|
+
clients: defaultClients,
|
|
137
|
+
|
|
138
|
+
requestsPerSecond: 30,
|
|
139
|
+
maxCapacity: 60,
|
|
140
|
+
throughput: 30,
|
|
141
|
+
droppedRequests: 0,
|
|
142
|
+
|
|
143
|
+
hotZones: [],
|
|
144
|
+
explanation: "Welcome — build the architecture using the controls above.",
|
|
145
|
+
phase: "overview",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
computeMetrics(initialState);
|
|
149
|
+
|
|
150
|
+
/* ── Slice ───────────────────────────────────────────── */
|
|
151
|
+
const ${camelName}Slice = createSlice({
|
|
152
|
+
name: "${camelName}",
|
|
153
|
+
initialState,
|
|
154
|
+
reducers: {
|
|
155
|
+
reset: () => {
|
|
156
|
+
const s = { ...initialState, clients: [...initialState.clients] };
|
|
157
|
+
computeMetrics(s);
|
|
158
|
+
return s;
|
|
159
|
+
},
|
|
160
|
+
patchState(state, action: PayloadAction<Partial<${pascalName}State>>) {
|
|
161
|
+
Object.assign(state, action.payload);
|
|
162
|
+
},
|
|
163
|
+
recalcMetrics(state) {
|
|
164
|
+
computeMetrics(state);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/* ── Component toggles ─────────────────────────── */
|
|
168
|
+
addComponent(state, action: PayloadAction<ComponentName>) {
|
|
169
|
+
const name = action.payload;
|
|
170
|
+
const prereqs = PREREQUISITES[name];
|
|
171
|
+
if (prereqs?.some((p) => !state.components[p])) return;
|
|
172
|
+
|
|
173
|
+
// TODO: handle numeric components (e.g. extraServers)
|
|
174
|
+
if (state.components[name]) return;
|
|
175
|
+
(state.components[name] as boolean) = true;
|
|
176
|
+
|
|
177
|
+
computeMetrics(state);
|
|
178
|
+
state.explanation = \`Added! Architecture: \${describeArch(state.components)}. Capacity: ~\${state.maxCapacity} rps.\`;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
removeComponent(state, action: PayloadAction<ComponentName>) {
|
|
182
|
+
const name = action.payload;
|
|
183
|
+
if (!state.components[name]) return;
|
|
184
|
+
(state.components[name] as boolean) = false;
|
|
185
|
+
|
|
186
|
+
const cascades = CASCADE_REMOVE[name];
|
|
187
|
+
if (cascades) {
|
|
188
|
+
for (const dep of cascades) {
|
|
189
|
+
(state.components[dep] as boolean) = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
computeMetrics(state);
|
|
194
|
+
state.explanation = \`Removed. Architecture: \${describeArch(state.components)}. Capacity: ~\${state.maxCapacity} rps.\`;
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/* ── Client controls ───────────────────────────── */
|
|
198
|
+
addClient(state) {
|
|
199
|
+
if (state.clients.length >= 12) return;
|
|
200
|
+
const id = \`client-\${Date.now()}\`;
|
|
201
|
+
const type = state.clients.length % 2 === 0 ? "desktop" : "mobile";
|
|
202
|
+
state.clients.push({ id, type });
|
|
203
|
+
computeMetrics(state);
|
|
204
|
+
},
|
|
205
|
+
removeClient(state) {
|
|
206
|
+
if (state.clients.length <= 1) return;
|
|
207
|
+
state.clients.pop();
|
|
208
|
+
computeMetrics(state);
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
export const {
|
|
214
|
+
reset,
|
|
215
|
+
patchState,
|
|
216
|
+
recalcMetrics,
|
|
217
|
+
addComponent,
|
|
218
|
+
removeComponent,
|
|
219
|
+
addClient,
|
|
220
|
+
removeClient,
|
|
221
|
+
} = ${camelName}Slice.actions;
|
|
222
|
+
export default ${camelName}Slice.reducer;
|
|
223
|
+
`;
|
|
224
|
+
} else {
|
|
225
|
+
sliceContent = `import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
53
226
|
|
|
54
227
|
export type ${pascalName}Phase = "overview" | "processing" | "summary";
|
|
55
228
|
|
|
@@ -81,13 +254,196 @@ const ${camelName}Slice = createSlice({
|
|
|
81
254
|
export const { patchState, reset } = ${camelName}Slice.actions;
|
|
82
255
|
export default ${camelName}Slice.reducer;
|
|
83
256
|
`;
|
|
257
|
+
} // end if/else sandbox slice
|
|
84
258
|
|
|
85
259
|
fs.writeFileSync(path.join(targetDir, `${camelName}Slice.ts`), sliceContent);
|
|
86
260
|
|
|
87
261
|
/* ================================================================
|
|
88
262
|
2. Animation Hook — use${pascalName}Animation.ts
|
|
89
263
|
================================================================ */
|
|
90
|
-
|
|
264
|
+
let hookContent;
|
|
265
|
+
if (isSandbox) {
|
|
266
|
+
hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
|
|
267
|
+
import { useDispatch, useSelector } from "react-redux";
|
|
268
|
+
import type { SignalOverlayParams } from "vizcraft";
|
|
269
|
+
import { type RootState } from "../../store/store";
|
|
270
|
+
import {
|
|
271
|
+
patchState,
|
|
272
|
+
reset,
|
|
273
|
+
recalcMetrics,
|
|
274
|
+
type ${pascalName}State,
|
|
275
|
+
} from "./${camelName}Slice";
|
|
276
|
+
import { STEPS, buildSteps, executeFlow, type StepKey } from "./flow-engine";
|
|
277
|
+
|
|
278
|
+
export type Signal = { id: string; color?: string } & SignalOverlayParams;
|
|
279
|
+
|
|
280
|
+
/* ──────────────────────────────────────────────────────────
|
|
281
|
+
Declarative animation hook.
|
|
282
|
+
|
|
283
|
+
Reads step config from STEPS, resolves the current step
|
|
284
|
+
key, then uses executeFlow to run the declared beats.
|
|
285
|
+
No per-step imperative code needed.
|
|
286
|
+
────────────────────────────────────────────────────────── */
|
|
287
|
+
|
|
288
|
+
export const use${pascalName}Animation = (onAnimationComplete?: () => void) => {
|
|
289
|
+
const dispatch = useDispatch();
|
|
290
|
+
const { currentStep } = useSelector((state: RootState) => state.simulation);
|
|
291
|
+
const runtime = useSelector(
|
|
292
|
+
(state: RootState) => state.${camelName},
|
|
293
|
+
) as ${pascalName}State;
|
|
294
|
+
const [signals, setSignals] = useState<Signal[]>([]);
|
|
295
|
+
const rafRef = useRef<number>(0);
|
|
296
|
+
const timeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
|
297
|
+
const onCompleteRef = useRef(onAnimationComplete);
|
|
298
|
+
const runtimeRef = useRef(runtime);
|
|
299
|
+
|
|
300
|
+
onCompleteRef.current = onAnimationComplete;
|
|
301
|
+
runtimeRef.current = runtime;
|
|
302
|
+
|
|
303
|
+
const cleanup = useCallback(() => {
|
|
304
|
+
cancelAnimationFrame(rafRef.current);
|
|
305
|
+
timeoutsRef.current.forEach((id) => clearTimeout(id));
|
|
306
|
+
timeoutsRef.current = [];
|
|
307
|
+
setSignals([]);
|
|
308
|
+
}, []);
|
|
309
|
+
|
|
310
|
+
const sleep = useCallback(
|
|
311
|
+
(ms: number) =>
|
|
312
|
+
new Promise<void>((resolve) => {
|
|
313
|
+
const id = setTimeout(resolve, ms);
|
|
314
|
+
timeoutsRef.current.push(id);
|
|
315
|
+
}),
|
|
316
|
+
[],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const animateParallel = useCallback(
|
|
320
|
+
(pairs: { from: string; to: string }[], duration: number) => {
|
|
321
|
+
return new Promise<void>((resolve) => {
|
|
322
|
+
const start = performance.now();
|
|
323
|
+
const sigs = pairs.map((p, i) => ({
|
|
324
|
+
id: \`par-\${i}-\${Date.now()}\`,
|
|
325
|
+
from: p.from,
|
|
326
|
+
to: p.to,
|
|
327
|
+
progress: 0,
|
|
328
|
+
magnitude: 0.8,
|
|
329
|
+
}));
|
|
330
|
+
|
|
331
|
+
const tick = (now: number) => {
|
|
332
|
+
const p = Math.min((now - start) / duration, 1);
|
|
333
|
+
setSignals(sigs.map((s) => ({ ...s, progress: p })));
|
|
334
|
+
if (p < 1) {
|
|
335
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
336
|
+
} else {
|
|
337
|
+
resolve();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
[],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
/* ── Resolve current step ─────────────────────────── */
|
|
347
|
+
const steps = buildSteps(runtime);
|
|
348
|
+
const currentKey: StepKey | undefined = steps[currentStep]?.key;
|
|
349
|
+
|
|
350
|
+
/* ── Generic step executor ────────────────────────── */
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
let cancelled = false;
|
|
353
|
+
cleanup();
|
|
354
|
+
|
|
355
|
+
const finish = () => {
|
|
356
|
+
if (!cancelled) setTimeout(() => onCompleteRef.current?.(), 0);
|
|
357
|
+
};
|
|
358
|
+
const rt = () => runtimeRef.current;
|
|
359
|
+
const doPatch = (p: Partial<${pascalName}State>) => dispatch(patchState(p));
|
|
360
|
+
|
|
361
|
+
const stepDef = STEPS.find((s) => s.key === currentKey);
|
|
362
|
+
if (!stepDef) {
|
|
363
|
+
finish();
|
|
364
|
+
return cleanup;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const run = async () => {
|
|
368
|
+
// 1. Special actions
|
|
369
|
+
if (stepDef.action === "reset") {
|
|
370
|
+
dispatch(reset());
|
|
371
|
+
finish();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 2. Recalc metrics early (for non-flow steps)
|
|
376
|
+
if (stepDef.recalcMetrics && !stepDef.flow) {
|
|
377
|
+
dispatch(recalcMetrics());
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 3. Set phase
|
|
381
|
+
if (stepDef.phase) {
|
|
382
|
+
const phase =
|
|
383
|
+
typeof stepDef.phase === "function"
|
|
384
|
+
? stepDef.phase(rt())
|
|
385
|
+
: stepDef.phase;
|
|
386
|
+
doPatch({ phase });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 4. Set initial hot zones for non-flow steps
|
|
390
|
+
if (stepDef.finalHotZones !== undefined && !stepDef.flow) {
|
|
391
|
+
doPatch({ hotZones: stepDef.finalHotZones });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 5. Execute flow beats
|
|
395
|
+
if (stepDef.flow) {
|
|
396
|
+
await executeFlow(stepDef.flow, {
|
|
397
|
+
animateParallel,
|
|
398
|
+
patch: doPatch,
|
|
399
|
+
getState: rt,
|
|
400
|
+
cancelled: () => cancelled,
|
|
401
|
+
});
|
|
402
|
+
if (cancelled) return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 6. Recalc after flow
|
|
406
|
+
if (stepDef.recalcMetrics && stepDef.flow) {
|
|
407
|
+
dispatch(recalcMetrics());
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 7. Delay
|
|
411
|
+
if (stepDef.delay) {
|
|
412
|
+
await sleep(stepDef.delay);
|
|
413
|
+
if (cancelled) return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// 8. Final hot zones
|
|
417
|
+
if (stepDef.finalHotZones !== undefined) {
|
|
418
|
+
doPatch({ hotZones: stepDef.finalHotZones });
|
|
419
|
+
} else if (!stepDef.flow) {
|
|
420
|
+
doPatch({ hotZones: [] });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 9. Final explanation
|
|
424
|
+
if (stepDef.explain) {
|
|
425
|
+
const explanation =
|
|
426
|
+
typeof stepDef.explain === "function"
|
|
427
|
+
? stepDef.explain(rt())
|
|
428
|
+
: stepDef.explain;
|
|
429
|
+
doPatch({ explanation });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
finish();
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
run();
|
|
436
|
+
return () => {
|
|
437
|
+
cancelled = true;
|
|
438
|
+
cleanup();
|
|
439
|
+
};
|
|
440
|
+
}, [currentStep, currentKey, cleanup, dispatch, sleep, animateParallel]);
|
|
441
|
+
|
|
442
|
+
return { runtime, signals };
|
|
443
|
+
};
|
|
444
|
+
`;
|
|
445
|
+
} else {
|
|
446
|
+
hookContent = `import { useCallback, useEffect, useRef, useState } from "react";
|
|
91
447
|
import { useDispatch, useSelector } from "react-redux";
|
|
92
448
|
import type { SignalOverlayParams } from "vizcraft";
|
|
93
449
|
import { type RootState } from "../../store/store";
|
|
@@ -178,6 +534,7 @@ export const use${pascalName}Animation = (onAnimationComplete?: () => void) => {
|
|
|
178
534
|
};
|
|
179
535
|
};
|
|
180
536
|
`;
|
|
537
|
+
} // end if/else sandbox hook
|
|
181
538
|
|
|
182
539
|
fs.writeFileSync(
|
|
183
540
|
path.join(targetDir, `use${pascalName}Animation.ts`),
|
|
@@ -414,7 +771,52 @@ fs.writeFileSync(path.join(targetDir, "main.scss"), scssContent);
|
|
|
414
771
|
/* ================================================================
|
|
415
772
|
6. Plugin Registration — index.ts
|
|
416
773
|
================================================================ */
|
|
417
|
-
|
|
774
|
+
let indexContent;
|
|
775
|
+
if (isSandbox) {
|
|
776
|
+
indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
|
|
777
|
+
import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
|
|
778
|
+
import ${pascalName}Visualization from "./main";
|
|
779
|
+
import ${pascalName}Controls from "./controls";
|
|
780
|
+
import ${camelName}Reducer, {
|
|
781
|
+
type ${pascalName}State,
|
|
782
|
+
initialState,
|
|
783
|
+
reset,
|
|
784
|
+
} from "./${camelName}Slice";
|
|
785
|
+
import {
|
|
786
|
+
buildSteps,
|
|
787
|
+
type StepKey,
|
|
788
|
+
type TaggedStep,
|
|
789
|
+
} from "./flow-engine";
|
|
790
|
+
|
|
791
|
+
type LocalRootState = { ${camelName}: ${pascalName}State };
|
|
792
|
+
|
|
793
|
+
const ${pascalName}Plugin: DemoPlugin<
|
|
794
|
+
${pascalName}State,
|
|
795
|
+
Action,
|
|
796
|
+
LocalRootState,
|
|
797
|
+
Dispatch<Action>
|
|
798
|
+
> = {
|
|
799
|
+
id: "${pluginName}",
|
|
800
|
+
name: "${pascalName}",
|
|
801
|
+
description: "Describe what this demo teaches in one sentence.",
|
|
802
|
+
initialState,
|
|
803
|
+
reducer: ${camelName}Reducer,
|
|
804
|
+
Component: ${pascalName}Visualization,
|
|
805
|
+
Controls: ${pascalName}Controls,
|
|
806
|
+
restartConfig: { text: "Replay", color: "#3b82f6" },
|
|
807
|
+
getSteps: (state: ${pascalName}State): DemoStep[] => buildSteps(state),
|
|
808
|
+
init: (dispatch) => {
|
|
809
|
+
dispatch(reset());
|
|
810
|
+
},
|
|
811
|
+
selector: (state: LocalRootState) => state.${camelName},
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
export { buildSteps };
|
|
815
|
+
export type { StepKey, TaggedStep };
|
|
816
|
+
export default ${pascalName}Plugin;
|
|
817
|
+
`;
|
|
818
|
+
} else {
|
|
819
|
+
indexContent = `import type { Action, Dispatch } from "@reduxjs/toolkit";
|
|
418
820
|
import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
|
|
419
821
|
import ${pascalName}Visualization from "./main";
|
|
420
822
|
import ${camelName}Reducer, {
|
|
@@ -462,11 +864,345 @@ const ${pascalName}Plugin: DemoPlugin<
|
|
|
462
864
|
|
|
463
865
|
export default ${pascalName}Plugin;
|
|
464
866
|
`;
|
|
867
|
+
} // end if/else sandbox index
|
|
465
868
|
|
|
466
869
|
fs.writeFileSync(path.join(targetDir, "index.ts"), indexContent);
|
|
467
870
|
|
|
468
871
|
/* ================================================================
|
|
469
|
-
7.
|
|
872
|
+
7. (Sandbox only) Flow Engine — flow-engine.ts
|
|
873
|
+
================================================================ */
|
|
874
|
+
if (isSandbox) {
|
|
875
|
+
const flowEngineContent = `import type { InfraComponents, ${pascalName}State } from "./${camelName}Slice";
|
|
876
|
+
|
|
877
|
+
/* ══════════════════════════════════════════════════════════
|
|
878
|
+
Declarative Flow Engine
|
|
879
|
+
|
|
880
|
+
Define steps and their animation flows as DATA.
|
|
881
|
+
The engine handles token expansion, signal routing,
|
|
882
|
+
hot-zone derivation, and sequential execution.
|
|
883
|
+
══════════════════════════════════════════════════════════ */
|
|
884
|
+
|
|
885
|
+
/* ── Token expansion ─────────────────────────────────────
|
|
886
|
+
Use $-prefixed tokens as shorthand for dynamic node sets.
|
|
887
|
+
The engine expands them to actual node IDs at runtime.
|
|
888
|
+
──────────────────────────────────────────────────────── */
|
|
889
|
+
|
|
890
|
+
export function expandToken(
|
|
891
|
+
token: string,
|
|
892
|
+
state: ${pascalName}State,
|
|
893
|
+
): string[] {
|
|
894
|
+
if (token === "$clients") return state.clients.map((c) => c.id);
|
|
895
|
+
// TODO: add more token expansions, e.g.:
|
|
896
|
+
// if (token === "$servers") {
|
|
897
|
+
// const count = 1 + state.components.extraServers;
|
|
898
|
+
// return Array.from({ length: count }, (_, i) => \`server-\${i}\`);
|
|
899
|
+
// }
|
|
900
|
+
return [token];
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/* ── Flow Beat ───────────────────────────────────────────
|
|
904
|
+
One animation segment: signals travel from → to.
|
|
905
|
+
Tokens ($clients, $servers) expand to parallel signals.
|
|
906
|
+
──────────────────────────────────────────────────────── */
|
|
907
|
+
|
|
908
|
+
export interface FlowBeat {
|
|
909
|
+
from: string;
|
|
910
|
+
to: string;
|
|
911
|
+
when?: (c: InfraComponents) => boolean;
|
|
912
|
+
duration?: number;
|
|
913
|
+
explain?: string;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/* ── Step Definition ─────────────────────────────────────
|
|
917
|
+
Declarative config for one step in the visualization.
|
|
918
|
+
──────────────────────────────────────────────────────── */
|
|
919
|
+
|
|
920
|
+
export type StepKey = "overview" | "send-traffic" | "observe-metrics" | "summary";
|
|
921
|
+
// TODO: add more step keys as you add components
|
|
922
|
+
|
|
923
|
+
export interface StepDef {
|
|
924
|
+
key: StepKey;
|
|
925
|
+
label: string;
|
|
926
|
+
when?: (c: InfraComponents) => boolean;
|
|
927
|
+
nextButton?: string | ((c: InfraComponents) => string);
|
|
928
|
+
nextButtonColor?: string;
|
|
929
|
+
processingText?: string;
|
|
930
|
+
phase?: string | ((s: ${pascalName}State) => string);
|
|
931
|
+
flow?: FlowBeat[];
|
|
932
|
+
delay?: number;
|
|
933
|
+
recalcMetrics?: boolean;
|
|
934
|
+
finalHotZones?: string[];
|
|
935
|
+
explain?: string | ((s: ${pascalName}State) => string);
|
|
936
|
+
action?: "reset";
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/* ── Step Configuration ──────────────────────────────────
|
|
940
|
+
Single source of truth. Each step gets its own unique
|
|
941
|
+
flow — never repeat the same signal path in two steps.
|
|
942
|
+
──────────────────────────────────────────────────────── */
|
|
943
|
+
|
|
944
|
+
export const STEPS: StepDef[] = [
|
|
945
|
+
{
|
|
946
|
+
key: "overview",
|
|
947
|
+
label: "Architecture Overview",
|
|
948
|
+
nextButton: "Send Traffic",
|
|
949
|
+
action: "reset",
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
key: "send-traffic",
|
|
953
|
+
label: "Send Traffic",
|
|
954
|
+
processingText: "Sending...",
|
|
955
|
+
nextButtonColor: "#2563eb",
|
|
956
|
+
phase: "traffic",
|
|
957
|
+
flow: [
|
|
958
|
+
{
|
|
959
|
+
from: "$clients",
|
|
960
|
+
to: "cloud",
|
|
961
|
+
duration: 700,
|
|
962
|
+
explain: "Clients send requests through the internet.",
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
from: "cloud",
|
|
966
|
+
to: "server-0",
|
|
967
|
+
duration: 600,
|
|
968
|
+
explain: "Requests arrive at the server.",
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
recalcMetrics: true,
|
|
972
|
+
explain: (s) =>
|
|
973
|
+
\`Traffic flowing. \${s.requestsPerSecond} rps demand, \${s.maxCapacity} capacity.\`,
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
key: "observe-metrics",
|
|
977
|
+
label: "Observe Metrics",
|
|
978
|
+
nextButtonColor: "#2563eb",
|
|
979
|
+
recalcMetrics: true,
|
|
980
|
+
delay: 500,
|
|
981
|
+
phase: (s) => (s.droppedRequests > 0 ? "overloaded" : "stable"),
|
|
982
|
+
finalHotZones: ["server-0"],
|
|
983
|
+
explain: (s) =>
|
|
984
|
+
s.droppedRequests > 0
|
|
985
|
+
? \`Overloaded! \${s.droppedRequests} requests dropped.\`
|
|
986
|
+
: \`Stable at \${s.throughput} rps. Try adding components.\`,
|
|
987
|
+
},
|
|
988
|
+
// TODO: add component-specific steps here (each with unique flow)
|
|
989
|
+
{
|
|
990
|
+
key: "summary",
|
|
991
|
+
label: "Summary",
|
|
992
|
+
phase: "summary",
|
|
993
|
+
explain: (s) =>
|
|
994
|
+
\`Max capacity: ~\${s.maxCapacity} rps. Try adding or removing components and replaying.\`,
|
|
995
|
+
},
|
|
996
|
+
];
|
|
997
|
+
|
|
998
|
+
/* ── Build active steps from config ──────────────────── */
|
|
999
|
+
|
|
1000
|
+
export interface TaggedStep {
|
|
1001
|
+
key: StepKey;
|
|
1002
|
+
label: string;
|
|
1003
|
+
autoAdvance?: boolean;
|
|
1004
|
+
nextButtonText?: string;
|
|
1005
|
+
nextButtonColor?: string;
|
|
1006
|
+
processingText?: string;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export function buildSteps(state: ${pascalName}State): TaggedStep[] {
|
|
1010
|
+
const { components: c } = state;
|
|
1011
|
+
const active = STEPS.filter((s) => !s.when || s.when(c));
|
|
1012
|
+
|
|
1013
|
+
return active.map((step, i) => {
|
|
1014
|
+
const nextStep = active[i + 1];
|
|
1015
|
+
let nextButtonText: string | undefined;
|
|
1016
|
+
if (typeof step.nextButton === "function") {
|
|
1017
|
+
nextButtonText = step.nextButton(c);
|
|
1018
|
+
} else if (typeof step.nextButton === "string") {
|
|
1019
|
+
nextButtonText = step.nextButton;
|
|
1020
|
+
} else if (nextStep) {
|
|
1021
|
+
nextButtonText = nextStep.label;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
key: step.key,
|
|
1026
|
+
label: step.label,
|
|
1027
|
+
autoAdvance: false,
|
|
1028
|
+
nextButtonText,
|
|
1029
|
+
nextButtonColor: step.nextButtonColor,
|
|
1030
|
+
processingText: step.processingText,
|
|
1031
|
+
};
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/* ── Flow Executor ───────────────────────────────────── */
|
|
1036
|
+
|
|
1037
|
+
export interface FlowExecutorDeps {
|
|
1038
|
+
animateParallel: (
|
|
1039
|
+
pairs: { from: string; to: string }[],
|
|
1040
|
+
duration: number,
|
|
1041
|
+
) => Promise<void>;
|
|
1042
|
+
patch: (p: Partial<${pascalName}State>) => void;
|
|
1043
|
+
getState: () => ${pascalName}State;
|
|
1044
|
+
cancelled: () => boolean;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export async function executeFlow(
|
|
1048
|
+
beats: FlowBeat[],
|
|
1049
|
+
deps: FlowExecutorDeps,
|
|
1050
|
+
): Promise<void> {
|
|
1051
|
+
const components = deps.getState().components;
|
|
1052
|
+
const activeBeats = beats.filter((b) => !b.when || b.when(components));
|
|
1053
|
+
|
|
1054
|
+
for (const beat of activeBeats) {
|
|
1055
|
+
if (deps.cancelled()) return;
|
|
1056
|
+
|
|
1057
|
+
const state = deps.getState();
|
|
1058
|
+
const froms = expandToken(beat.from, state);
|
|
1059
|
+
const tos = expandToken(beat.to, state);
|
|
1060
|
+
|
|
1061
|
+
// Cartesian product → parallel signal pairs
|
|
1062
|
+
const pairs: { from: string; to: string }[] = [];
|
|
1063
|
+
for (const f of froms) {
|
|
1064
|
+
for (const t of tos) {
|
|
1065
|
+
pairs.push({ from: f, to: t });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const hotZones = [...new Set([...froms, ...tos])];
|
|
1070
|
+
const update: Partial<${pascalName}State> = { hotZones };
|
|
1071
|
+
if (beat.explain) update.explanation = beat.explain;
|
|
1072
|
+
deps.patch(update);
|
|
1073
|
+
|
|
1074
|
+
await deps.animateParallel(pairs, beat.duration ?? 600);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
`;
|
|
1078
|
+
|
|
1079
|
+
fs.writeFileSync(path.join(targetDir, "flow-engine.ts"), flowEngineContent);
|
|
1080
|
+
|
|
1081
|
+
/* ================================================================
|
|
1082
|
+
8. (Sandbox only) Controls — controls.tsx
|
|
1083
|
+
================================================================ */
|
|
1084
|
+
const controlsContent = `import React from "react";
|
|
1085
|
+
import { useDispatch, useSelector } from "react-redux";
|
|
1086
|
+
import { type RootState } from "../../store/store";
|
|
1087
|
+
import { resetSimulation } from "../../store/slices/simulationSlice";
|
|
1088
|
+
import {
|
|
1089
|
+
addClient,
|
|
1090
|
+
removeClient,
|
|
1091
|
+
addComponent,
|
|
1092
|
+
removeComponent,
|
|
1093
|
+
type ${pascalName}State,
|
|
1094
|
+
type ComponentName,
|
|
1095
|
+
} from "./${camelName}Slice";
|
|
1096
|
+
|
|
1097
|
+
/* ── Component toggle descriptor ─────────────────────── */
|
|
1098
|
+
interface Toggle {
|
|
1099
|
+
name: ComponentName;
|
|
1100
|
+
label: string;
|
|
1101
|
+
addLabel: string;
|
|
1102
|
+
removeLabel: string;
|
|
1103
|
+
color: string;
|
|
1104
|
+
requires?: ComponentName[];
|
|
1105
|
+
multi?: boolean;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const TOGGLES: Toggle[] = [
|
|
1109
|
+
// TODO: define your component toggles here, e.g.:
|
|
1110
|
+
// {
|
|
1111
|
+
// name: "database",
|
|
1112
|
+
// label: "Database",
|
|
1113
|
+
// addLabel: "+ Database",
|
|
1114
|
+
// removeLabel: "− Database",
|
|
1115
|
+
// color: "#22c55e",
|
|
1116
|
+
// },
|
|
1117
|
+
];
|
|
1118
|
+
|
|
1119
|
+
const ${pascalName}Controls: React.FC = () => {
|
|
1120
|
+
const dispatch = useDispatch();
|
|
1121
|
+
const { components, clients } = useSelector(
|
|
1122
|
+
(state: RootState) => state.${camelName},
|
|
1123
|
+
) as ${pascalName}State;
|
|
1124
|
+
|
|
1125
|
+
const handleAdd = (name: ComponentName) => {
|
|
1126
|
+
dispatch(addComponent(name));
|
|
1127
|
+
dispatch(resetSimulation());
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
const handleRemove = (name: ComponentName) => {
|
|
1131
|
+
dispatch(removeComponent(name));
|
|
1132
|
+
dispatch(resetSimulation());
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
return (
|
|
1136
|
+
<div className="${pluginName}-controls">
|
|
1137
|
+
{/* Client count */}
|
|
1138
|
+
<div className="${pluginName}-controls__group">
|
|
1139
|
+
<button
|
|
1140
|
+
className="${pluginName}-controls__btn"
|
|
1141
|
+
onClick={() => dispatch(removeClient())}
|
|
1142
|
+
disabled={clients.length <= 1}
|
|
1143
|
+
>
|
|
1144
|
+
−
|
|
1145
|
+
</button>
|
|
1146
|
+
<span className="${pluginName}-controls__label">
|
|
1147
|
+
{clients.length} client{clients.length !== 1 ? "s" : ""}
|
|
1148
|
+
</span>
|
|
1149
|
+
<button
|
|
1150
|
+
className="${pluginName}-controls__btn"
|
|
1151
|
+
onClick={() => dispatch(addClient())}
|
|
1152
|
+
disabled={clients.length >= 12}
|
|
1153
|
+
>
|
|
1154
|
+
+
|
|
1155
|
+
</button>
|
|
1156
|
+
</div>
|
|
1157
|
+
|
|
1158
|
+
<span className="${pluginName}-controls__sep" />
|
|
1159
|
+
|
|
1160
|
+
{/* Infrastructure toggles */}
|
|
1161
|
+
{TOGGLES.map((t) => {
|
|
1162
|
+
const isActive = t.multi
|
|
1163
|
+
? (components[t.name] as number) > 0
|
|
1164
|
+
: !!components[t.name];
|
|
1165
|
+
const prereqMet =
|
|
1166
|
+
!t.requires || t.requires.every((r) => !!components[r]);
|
|
1167
|
+
const canAdd = t.multi
|
|
1168
|
+
? prereqMet
|
|
1169
|
+
: !isActive && prereqMet;
|
|
1170
|
+
|
|
1171
|
+
return (
|
|
1172
|
+
<div key={t.name} className="${pluginName}-controls__group">
|
|
1173
|
+
{isActive && (
|
|
1174
|
+
<button
|
|
1175
|
+
className="${pluginName}-controls__btn ${pluginName}-controls__btn--remove"
|
|
1176
|
+
style={{ borderColor: t.color }}
|
|
1177
|
+
onClick={() => handleRemove(t.name)}
|
|
1178
|
+
>
|
|
1179
|
+
{t.removeLabel}
|
|
1180
|
+
</button>
|
|
1181
|
+
)}
|
|
1182
|
+
{canAdd && (
|
|
1183
|
+
<button
|
|
1184
|
+
className="${pluginName}-controls__btn ${pluginName}-controls__btn--add"
|
|
1185
|
+
style={{ borderColor: t.color }}
|
|
1186
|
+
onClick={() => handleAdd(t.name)}
|
|
1187
|
+
>
|
|
1188
|
+
{t.addLabel}
|
|
1189
|
+
</button>
|
|
1190
|
+
)}
|
|
1191
|
+
</div>
|
|
1192
|
+
);
|
|
1193
|
+
})}
|
|
1194
|
+
</div>
|
|
1195
|
+
);
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
export default ${pascalName}Controls;
|
|
1199
|
+
`;
|
|
1200
|
+
|
|
1201
|
+
fs.writeFileSync(path.join(targetDir, "controls.tsx"), controlsContent);
|
|
1202
|
+
} // end sandbox-only files
|
|
1203
|
+
|
|
1204
|
+
/* ================================================================
|
|
1205
|
+
9. Update registry.ts — add import + wire into category
|
|
470
1206
|
================================================================ */
|
|
471
1207
|
const registryPath = path.join(__dirname, "../src/registry.ts");
|
|
472
1208
|
if (fs.existsSync(registryPath)) {
|
|
@@ -577,7 +1313,7 @@ if (fs.existsSync(registryPath)) {
|
|
|
577
1313
|
|
|
578
1314
|
console.log("");
|
|
579
1315
|
console.log(
|
|
580
|
-
'✔ Created plugin "' + pluginName + '" in src/plugins/' + pluginName,
|
|
1316
|
+
'✔ Created ' + (isSandbox ? 'sandbox ' : '') + 'plugin "' + pluginName + '" in src/plugins/' + pluginName,
|
|
581
1317
|
);
|
|
582
1318
|
console.log("");
|
|
583
1319
|
console.log(" Files generated:");
|
|
@@ -587,8 +1323,20 @@ console.log(" • concepts.tsx — InfoModal concept definitions");
|
|
|
587
1323
|
console.log(" • main.tsx — Component (uses plugin-kit)");
|
|
588
1324
|
console.log(" • main.scss — Styles");
|
|
589
1325
|
console.log(" • index.ts — Plugin registration");
|
|
1326
|
+
if (isSandbox) {
|
|
1327
|
+
console.log(" • flow-engine.ts — Declarative step & flow config");
|
|
1328
|
+
console.log(" • controls.tsx — Controls panel (component toggles)");
|
|
1329
|
+
}
|
|
590
1330
|
console.log("");
|
|
591
1331
|
console.log(" Next steps:");
|
|
592
|
-
|
|
593
|
-
console.log("
|
|
594
|
-
console.log("
|
|
1332
|
+
if (isSandbox) {
|
|
1333
|
+
console.log(" 1. Define InfraComponents in " + camelName + "Slice.ts");
|
|
1334
|
+
console.log(" 2. Add togglable components to TOGGLES in controls.tsx");
|
|
1335
|
+
console.log(" 3. Define steps declaratively in STEPS array (flow-engine.ts)");
|
|
1336
|
+
console.log(" 4. Build dynamic scene in main.tsx (nodes/edges adapt to state)");
|
|
1337
|
+
console.log(" 5. Add concept pills & definitions in concepts.tsx");
|
|
1338
|
+
} else {
|
|
1339
|
+
console.log(" 1. Define your VizCraft nodes/edges in main.tsx");
|
|
1340
|
+
console.log(" 2. Add step animations in use" + pascalName + "Animation.ts");
|
|
1341
|
+
console.log(" 3. Add concept pills & definitions in concepts.tsx");
|
|
1342
|
+
}
|
package/template/src/App.scss
CHANGED
|
@@ -12,11 +12,7 @@
|
|
|
12
12
|
/* dark slate */
|
|
13
13
|
color: #e5e7eb;
|
|
14
14
|
/* light gray */
|
|
15
|
-
font-family:
|
|
16
|
-
system-ui,
|
|
17
|
-
-apple-system,
|
|
18
|
-
BlinkMacSystemFont,
|
|
19
|
-
"Segoe UI",
|
|
15
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
20
16
|
sans-serif;
|
|
21
17
|
background-color: #111827; // Gray-900
|
|
22
18
|
}
|
|
@@ -58,9 +54,7 @@
|
|
|
58
54
|
align-items: center;
|
|
59
55
|
justify-content: center;
|
|
60
56
|
flex-shrink: 0;
|
|
61
|
-
transition:
|
|
62
|
-
background 0.15s,
|
|
63
|
-
border-color 0.15s;
|
|
57
|
+
transition: background 0.15s, border-color 0.15s;
|
|
64
58
|
padding: 0;
|
|
65
59
|
|
|
66
60
|
&:hover {
|
|
@@ -130,8 +124,25 @@
|
|
|
130
124
|
position: relative;
|
|
131
125
|
overflow: hidden;
|
|
132
126
|
display: flex;
|
|
127
|
+
flex-direction: column;
|
|
133
128
|
justify-content: center;
|
|
134
129
|
align-items: center;
|
|
135
130
|
padding: 2rem;
|
|
136
131
|
background-color: #111827; // Gray-900
|
|
137
132
|
}
|
|
133
|
+
|
|
134
|
+
/* ── Plugin Controls slot ─────────────────────────── */
|
|
135
|
+
.plugin-controls {
|
|
136
|
+
width: 100%;
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
justify-content: center;
|
|
141
|
+
padding: 0.5rem 1rem;
|
|
142
|
+
background: rgba(15, 23, 42, 0.6);
|
|
143
|
+
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
|
144
|
+
border-radius: 12px 12px 0 0;
|
|
145
|
+
backdrop-filter: blur(8px);
|
|
146
|
+
gap: 0.75rem;
|
|
147
|
+
flex-wrap: wrap;
|
|
148
|
+
}
|
|
@@ -134,6 +134,11 @@ const Shell: React.FC<ShellProps> = ({ plugin, category }) => {
|
|
|
134
134
|
/>
|
|
135
135
|
|
|
136
136
|
<div className="visualization-container">
|
|
137
|
+
{plugin.Controls && (
|
|
138
|
+
<div className="plugin-controls">
|
|
139
|
+
<plugin.Controls />
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
137
142
|
<plugin.Component onAnimationComplete={handleAnimationComplete} />
|
|
138
143
|
</div>
|
|
139
144
|
</div>
|
package/template/src/index.scss
CHANGED