create-vizcraft-playground 0.1.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/index.js +171 -0
- package/package.json +44 -0
- package/template/.github/skills/vizcraft-playground/SKILL.md +573 -0
- package/template/README.md +59 -0
- package/template/eslint.config.js +23 -0
- package/template/index.html +13 -0
- package/template/package.json +37 -0
- package/template/public/vite.svg +1 -0
- package/template/scripts/generate-plugin.js +594 -0
- package/template/scripts/init-playground.js +119 -0
- package/template/src/App.scss +137 -0
- package/template/src/App.tsx +72 -0
- package/template/src/assets/react.svg +1 -0
- package/template/src/components/InfoModal/InfoModal.scss +211 -0
- package/template/src/components/InfoModal/InfoModal.tsx +85 -0
- package/template/src/components/Landing/Landing.scss +85 -0
- package/template/src/components/Landing/Landing.tsx +55 -0
- package/template/src/components/Shell.tsx +144 -0
- package/template/src/components/StepIndicator/StepIndicator.scss +151 -0
- package/template/src/components/StepIndicator/StepIndicator.tsx +73 -0
- package/template/src/components/VizInfoBeacon/VizInfoBeacon.scss +41 -0
- package/template/src/components/VizInfoBeacon/VizInfoBeacon.tsx +157 -0
- package/template/src/components/plugin-kit/CanvasStage.tsx +30 -0
- package/template/src/components/plugin-kit/ConceptPills.tsx +55 -0
- package/template/src/components/plugin-kit/PluginLayout.tsx +41 -0
- package/template/src/components/plugin-kit/SidePanel.tsx +69 -0
- package/template/src/components/plugin-kit/StageHeader.tsx +35 -0
- package/template/src/components/plugin-kit/StatBadge.tsx +35 -0
- package/template/src/components/plugin-kit/index.ts +42 -0
- package/template/src/components/plugin-kit/plugin-kit.scss +241 -0
- package/template/src/components/plugin-kit/useConceptModal.tsx +51 -0
- package/template/src/index.scss +81 -0
- package/template/src/main.tsx +14 -0
- package/template/src/playground.config.ts +27 -0
- package/template/src/plugins/hello-world/concepts.tsx +70 -0
- package/template/src/plugins/hello-world/helloWorldSlice.ts +29 -0
- package/template/src/plugins/hello-world/index.ts +48 -0
- package/template/src/plugins/hello-world/main.scss +32 -0
- package/template/src/plugins/hello-world/main.tsx +144 -0
- package/template/src/plugins/hello-world/useHelloWorldAnimation.ts +99 -0
- package/template/src/registry.ts +73 -0
- package/template/src/store/slices/simulationSlice.ts +47 -0
- package/template/src/store/store.ts +13 -0
- package/template/src/types/ModelPlugin.ts +55 -0
- package/template/src/utils/random.ts +11 -0
- package/template/tsconfig.app.json +35 -0
- package/template/tsconfig.json +7 -0
- package/template/tsconfig.node.json +26 -0
- package/template/vite.config.ts +7 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vizcraft-playground
|
|
3
|
+
description: "Build, extend, and debug VizCraft interactive visualization playgrounds. USE WHEN: the workspace depends on 'vizcraft' in package.json, has src/registry.ts with PluginCategory[], has src/components/plugin-kit/, or has scripts/generate-plugin.js. USE FOR: creating new plugins, building VizCraft scenes (nodes, edges, signals, overlays), writing animation hooks, defining concept pills and InfoModal content, configuring Redux slices for plugin state, styling plugins with dark-theme SCSS, managing signal persistence and multi-hop chains, wiring plugins into the category registry. DO NOT USE FOR: non-VizCraft React projects, general Redux questions unrelated to plugins."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# VizCraft Playground Skill
|
|
7
|
+
|
|
8
|
+
Use this skill whenever working inside a VizCraft playground — any project scaffolded by `create-vizcraft-playground` or matching these markers:
|
|
9
|
+
|
|
10
|
+
- `package.json` depends on `vizcraft`
|
|
11
|
+
- `src/registry.ts` exports `PluginCategory[]`
|
|
12
|
+
- `src/components/plugin-kit/` exists
|
|
13
|
+
- `src/types/ModelPlugin.ts` defines `DemoPlugin`
|
|
14
|
+
- `scripts/generate-plugin.js` exists
|
|
15
|
+
|
|
16
|
+
## Playground architecture
|
|
17
|
+
|
|
18
|
+
A VizCraft playground is a **plugin-based interactive visualization platform**.
|
|
19
|
+
|
|
20
|
+
| Layer | Files | Purpose |
|
|
21
|
+
|-------|-------|---------|
|
|
22
|
+
| Config | `src/playground.config.ts` | Branding: title, subtitle, accent |
|
|
23
|
+
| Registry | `src/registry.ts` | Single source of truth for categories + plugins |
|
|
24
|
+
| Store | `src/store/store.ts` | Redux store from `pluginReducerMap` |
|
|
25
|
+
| Simulation | `src/store/slices/simulationSlice.ts` | Shared `currentStep`, `passCount`, `isPlaying` |
|
|
26
|
+
| Shell | `src/components/Shell.tsx` | Step lifecycle, restart, nav |
|
|
27
|
+
| Plugin Kit | `src/components/plugin-kit/` | Reusable UI building blocks |
|
|
28
|
+
| Plugins | `src/plugins/{name}/` | Self-contained demos |
|
|
29
|
+
|
|
30
|
+
## Plugin anatomy
|
|
31
|
+
|
|
32
|
+
Every plugin lives in `src/plugins/{kebab-name}/` with exactly six files.
|
|
33
|
+
|
|
34
|
+
| File | Naming | Purpose |
|
|
35
|
+
|------|--------|---------|
|
|
36
|
+
| `index.ts` | — | DemoPlugin export, steps, restart config |
|
|
37
|
+
| `{camelName}Slice.ts` | `eventStreamingSlice.ts` | Redux slice: state type, reducers, actions |
|
|
38
|
+
| `use{PascalName}Animation.ts` | `useEventStreamingAnimation.ts` | Step-driven animation orchestration |
|
|
39
|
+
| `main.tsx` | — | React component rendering VizCraft scene |
|
|
40
|
+
| `concepts.tsx` | — | ConceptKey type + ConceptDefinition record |
|
|
41
|
+
| `main.scss` | — | Plugin-specific dark-theme styles |
|
|
42
|
+
|
|
43
|
+
### Scaffolding
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm run generate my-plugin --category "Category Name"
|
|
47
|
+
# or shorthand:
|
|
48
|
+
npm run generate my-plugin -c "Category Name"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If the category exists, the plugin is appended into its `plugins` array.
|
|
52
|
+
If the category does not exist, a new entry is created in the `categories` array.
|
|
53
|
+
|
|
54
|
+
## DemoPlugin interface
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
interface DemoPlugin<State, Actions, TRootState, TDispatch> {
|
|
58
|
+
id: string; // kebab-case
|
|
59
|
+
name: string; // Display name
|
|
60
|
+
description: string; // One-liner
|
|
61
|
+
initialState: State;
|
|
62
|
+
reducer: Reducer<State, Actions>;
|
|
63
|
+
Component: React.FC<{ onAnimationComplete?: () => void }>;
|
|
64
|
+
Controls?: React.FC;
|
|
65
|
+
restartConfig?: { text?: string; color?: string };
|
|
66
|
+
getSteps: (state: State) => (string | DemoStep)[];
|
|
67
|
+
init: (dispatch: TDispatch) => void;
|
|
68
|
+
selector: (state: TRootState) => State;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface DemoStep {
|
|
72
|
+
label: string;
|
|
73
|
+
autoAdvance?: boolean;
|
|
74
|
+
nextButtonText?: string;
|
|
75
|
+
processingText?: string;
|
|
76
|
+
nextButtonColor?: string;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `selector` uses a local root type: `type LocalRootState = { [camelName]: State }`.
|
|
81
|
+
|
|
82
|
+
## Plugin-kit components
|
|
83
|
+
|
|
84
|
+
Import from `../../components/plugin-kit`. All classes use the `vc-` prefix.
|
|
85
|
+
|
|
86
|
+
### PluginLayout
|
|
87
|
+
|
|
88
|
+
Two-column layout: toolbar → body (canvas + optional sidebar).
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
<PluginLayout
|
|
92
|
+
toolbar={<ConceptPills pills={pills} onOpen={openConcept} />}
|
|
93
|
+
canvas={<div className="my-stage"><StageHeader .../><CanvasStage canvasRef={ref} /></div>}
|
|
94
|
+
sidebar={<SidePanel>...</SidePanel>}
|
|
95
|
+
/>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Props: `toolbar?: ReactNode`, `canvas: ReactNode`, `sidebar?: ReactNode`, `className?`.
|
|
99
|
+
When `sidebar` is omitted, the canvas takes full width.
|
|
100
|
+
|
|
101
|
+
### CanvasStage
|
|
102
|
+
|
|
103
|
+
Wrapper for the VizCraft mount point with a dot-grid background.
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
<CanvasStage canvasRef={containerRef}>
|
|
107
|
+
<VizInfoBeacon ... /> {/* Optional overlays */}
|
|
108
|
+
</CanvasStage>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Props: `canvasRef?: Ref<HTMLDivElement>`, `children?: ReactNode`, `className?`.
|
|
112
|
+
|
|
113
|
+
### ConceptPills
|
|
114
|
+
|
|
115
|
+
Horizontal row of clickable info pills.
|
|
116
|
+
|
|
117
|
+
```tsx
|
|
118
|
+
const pills: PillDef[] = [
|
|
119
|
+
{ key: "kafka", label: "Kafka", color: "#7dd3fc", borderColor: "#0ea5e9" },
|
|
120
|
+
];
|
|
121
|
+
<ConceptPills pills={pills} onOpen={openConcept} />
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Props: `pills: PillDef[]`, `onOpen: (key: string) => void`, `className?`.
|
|
125
|
+
|
|
126
|
+
**PillDef:** `{ key, label, variant?, color?, borderColor? }`.
|
|
127
|
+
Use inline `color`/`borderColor` for pill colours (not variant classes).
|
|
128
|
+
|
|
129
|
+
### useConceptModal
|
|
130
|
+
|
|
131
|
+
Hook that manages the InfoModal lifecycle.
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
const { openConcept, closeConcept, ConceptModal } = useConceptModal<ConceptKey>(concepts);
|
|
135
|
+
// Render <ConceptModal /> anywhere in JSX
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Generic over `K extends string`. Returns `{ activeConcept, openConcept, closeConcept, ConceptModal }`.
|
|
139
|
+
|
|
140
|
+
### StageHeader
|
|
141
|
+
|
|
142
|
+
Title + subtitle + right-aligned stats area.
|
|
143
|
+
|
|
144
|
+
```tsx
|
|
145
|
+
<StageHeader title="ECS Autoscaling" subtitle="Watch containers scale">
|
|
146
|
+
<StatBadge label="Phase" value="alarm" color="#fda4af" />
|
|
147
|
+
</StageHeader>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Props: `title: string`, `subtitle?: string`, `children?: ReactNode` (stat badges), `className?`.
|
|
151
|
+
|
|
152
|
+
### StatBadge
|
|
153
|
+
|
|
154
|
+
Small stat chip: uppercase label + bold coloured value.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
<StatBadge label="Tasks" value="3/5" color="#86efac" />
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Props: `label: string`, `value: ReactNode`, `color?: string`, `className?`.
|
|
161
|
+
|
|
162
|
+
### SidePanel and SideCard
|
|
163
|
+
|
|
164
|
+
Scrollable sidebar with labelled cards.
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
<SidePanel>
|
|
168
|
+
<SideCard label="What's happening" variant="explanation">
|
|
169
|
+
<p>{explanation}</p>
|
|
170
|
+
</SideCard>
|
|
171
|
+
</SidePanel>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
SideCard props: `label?`, `heading?`, `sub?`, `variant?`, `children`, `className?`.
|
|
175
|
+
|
|
176
|
+
### VizInfoBeacon
|
|
177
|
+
|
|
178
|
+
Floating info indicator over a scene region. Opens concepts on click.
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
<VizInfoBeacon
|
|
182
|
+
viewWidth={900} viewHeight={600}
|
|
183
|
+
hoverRegion={{ x: 100, y: 200, width: 140, height: 60 }}
|
|
184
|
+
indicatorPosition={{ x: 170, y: 195 }}
|
|
185
|
+
ariaLabel="Learn about Producer"
|
|
186
|
+
onActivate={() => openConcept("producer")}
|
|
187
|
+
/>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### InfoModal
|
|
191
|
+
|
|
192
|
+
Full-screen modal for concept definitions. Typically managed by `useConceptModal`, not used directly.
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
interface InfoModalSection {
|
|
196
|
+
title: string;
|
|
197
|
+
content: ReactNode;
|
|
198
|
+
accent?: string;
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## VizCraft builder API
|
|
203
|
+
|
|
204
|
+
### Scene setup
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { viz, type PanZoomController, type SignalOverlayParams } from "vizcraft";
|
|
208
|
+
|
|
209
|
+
const b = viz().view(width, height);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Nodes
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
b.node("my-node")
|
|
216
|
+
.at(x, y)
|
|
217
|
+
.rect(w, h, cornerRadius) // or .circle(radius)
|
|
218
|
+
.fill(color)
|
|
219
|
+
.stroke(color, width)
|
|
220
|
+
.label(text, { fill, fontSize, fontWeight, dy })
|
|
221
|
+
.tooltip({ title, sections: [{ label, value }] })
|
|
222
|
+
.badge(label, { position })
|
|
223
|
+
.onClick(handler);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Edges
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
b.edge("from-id", "to-id", "edge-id")
|
|
230
|
+
.stroke(color, width)
|
|
231
|
+
.arrow(true)
|
|
232
|
+
.label(text, { fill, fontSize })
|
|
233
|
+
.dashed()
|
|
234
|
+
.animate("flow", { duration: "2s" });
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Overlays
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
b.overlay((o) => {
|
|
241
|
+
o.add("rect", { x, y, w, h, rx, ry, fill, stroke, strokeWidth, opacity }, { key, className });
|
|
242
|
+
o.add("text", { x, y, text, fill, fontSize, fontWeight }, { key });
|
|
243
|
+
o.add("circle", { x, y, r, fill, stroke }, { key });
|
|
244
|
+
o.add("signal", signalParams, { key });
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Mounting
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
const pz = scene.mount(containerEl, {
|
|
252
|
+
autoplay: true,
|
|
253
|
+
panZoom: true,
|
|
254
|
+
initialZoom: saved?.zoom ?? 1,
|
|
255
|
+
initialPan: saved?.pan ?? { x: 0, y: 0 },
|
|
256
|
+
});
|
|
257
|
+
// pz.getState() → { zoom, pan: { x, y } }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Always preserve pan/zoom across rebuilds:
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
const pzRef = useRef<PanZoomController | null>(null);
|
|
264
|
+
|
|
265
|
+
useLayoutEffect(() => {
|
|
266
|
+
const saved = pzRef.current?.getState() ?? null;
|
|
267
|
+
builderRef.current?.destroy();
|
|
268
|
+
builderRef.current = scene;
|
|
269
|
+
pzRef.current = scene.mount(containerRef.current, {
|
|
270
|
+
autoplay: true, panZoom: true,
|
|
271
|
+
initialZoom: saved?.zoom ?? 1,
|
|
272
|
+
initialPan: saved?.pan ?? { x: 0, y: 0 },
|
|
273
|
+
}) ?? null;
|
|
274
|
+
}, [scene]);
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
return () => { builderRef.current?.destroy(); };
|
|
278
|
+
}, []);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Signals: the core animation primitive
|
|
282
|
+
|
|
283
|
+
A signal is an animated ball traveling along an edge between nodes.
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
type Signal = { id: string } & SignalOverlayParams;
|
|
287
|
+
|
|
288
|
+
// SignalOverlayParams:
|
|
289
|
+
{
|
|
290
|
+
from: string; // start node
|
|
291
|
+
to: string; // end node
|
|
292
|
+
progress: number; // 0 = at from, 1 = at to
|
|
293
|
+
magnitude?: number; // ball size multiplier
|
|
294
|
+
color?: string;
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Persistent vs transient signals
|
|
299
|
+
|
|
300
|
+
**This is critical.** When signals represent persistent state (events sitting in a partition, messages stored in a queue, data at rest), they must remain visible after animation completes. When signals represent transient events (a request in flight), they disappear after reaching their destination.
|
|
301
|
+
|
|
302
|
+
**Transient** — disappear after arrival:
|
|
303
|
+
```typescript
|
|
304
|
+
animateChain(hops, 500, () => setSignals([]));
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
**Persistent** — stay visible at destination:
|
|
308
|
+
```typescript
|
|
309
|
+
// After animation, keep as resting circles
|
|
310
|
+
persistedRef.current.push({
|
|
311
|
+
id: `rest-${Date.now()}`,
|
|
312
|
+
nodeId: "partition-0",
|
|
313
|
+
offsetX: 8, offsetY: -4,
|
|
314
|
+
});
|
|
315
|
+
// Render resting signals as circle overlays
|
|
316
|
+
b.overlay((o) => {
|
|
317
|
+
persistedRef.current.forEach(s => {
|
|
318
|
+
const pos = nodePositions[s.nodeId];
|
|
319
|
+
o.add("circle", {
|
|
320
|
+
x: pos.x + s.offsetX, y: pos.y + s.offsetY,
|
|
321
|
+
r: 5, fill: "#fbbf24",
|
|
322
|
+
}, { key: s.id });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Use `useRef` to carry persistent signal state across step changes. Maintain a position map for nodes where signals rest.
|
|
328
|
+
|
|
329
|
+
### Multi-hop signal chains
|
|
330
|
+
|
|
331
|
+
Signals natively travel one edge. For multi-hop chains, use `requestAnimationFrame`:
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
const animateSignalChain = useCallback((
|
|
335
|
+
hops: { from: string; to: string }[],
|
|
336
|
+
durationPerHop: number,
|
|
337
|
+
onDone: () => void,
|
|
338
|
+
options?: { keepFinal?: boolean; extra?: Signal[] },
|
|
339
|
+
) => {
|
|
340
|
+
const extra = options?.extra ?? [];
|
|
341
|
+
const totalDuration = hops.length * durationPerHop;
|
|
342
|
+
const sigId = `chain-${Date.now()}`;
|
|
343
|
+
const startTime = performance.now();
|
|
344
|
+
|
|
345
|
+
const step = (now: number) => {
|
|
346
|
+
const rawP = Math.min((now - startTime) / totalDuration, 1);
|
|
347
|
+
const progress = rawP * hops.length;
|
|
348
|
+
setSignals([...extra, { id: sigId, chain: hops, progress, magnitude: 1 }]);
|
|
349
|
+
if (rawP < 1) {
|
|
350
|
+
rafRef.current = requestAnimationFrame(step);
|
|
351
|
+
} else {
|
|
352
|
+
if (!options?.keepFinal) setSignals([...extra]);
|
|
353
|
+
onDone();
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
rafRef.current = requestAnimationFrame(step);
|
|
357
|
+
}, []);
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Parallel signals (fan-out / fan-in)
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
const animateSignalsParallel = useCallback((
|
|
364
|
+
pairs: { from: string; to: string }[],
|
|
365
|
+
duration: number,
|
|
366
|
+
onDone: () => void,
|
|
367
|
+
options?: { extra?: Signal[] },
|
|
368
|
+
) => {
|
|
369
|
+
const start = performance.now();
|
|
370
|
+
const sigs = pairs.map((p, i) => ({ id: `par-${i}`, from: p.from, to: p.to, progress: 0 }));
|
|
371
|
+
const step = (now: number) => {
|
|
372
|
+
const p = Math.min((now - start) / duration, 1);
|
|
373
|
+
setSignals([...(options?.extra ?? []), ...sigs.map(s => ({ ...s, progress: p }))]);
|
|
374
|
+
if (p < 1) rafRef.current = requestAnimationFrame(step);
|
|
375
|
+
else onDone();
|
|
376
|
+
};
|
|
377
|
+
rafRef.current = requestAnimationFrame(step);
|
|
378
|
+
}, []);
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Animation hook pattern
|
|
382
|
+
|
|
383
|
+
Every plugin has a `use{PascalName}Animation` hook. Follow this exact structure:
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
export const useMyPluginAnimation = (onAnimationComplete?: () => void) => {
|
|
387
|
+
const dispatch = useDispatch();
|
|
388
|
+
const { currentStep } = useSelector((s: RootState) => s.simulation);
|
|
389
|
+
const runtime = useSelector((s: RootState) => s.myPlugin);
|
|
390
|
+
const [signals, setSignals] = useState<Signal[]>([]);
|
|
391
|
+
const [animPhase, setAnimPhase] = useState<string>("idle");
|
|
392
|
+
|
|
393
|
+
// Refs
|
|
394
|
+
const rafRef = useRef<number>(undefined!);
|
|
395
|
+
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
396
|
+
const onCompleteRef = useRef(onAnimationComplete);
|
|
397
|
+
onCompleteRef.current = onAnimationComplete;
|
|
398
|
+
|
|
399
|
+
const cleanup = useCallback(() => {
|
|
400
|
+
cancelAnimationFrame(rafRef.current);
|
|
401
|
+
timeoutsRef.current.forEach(clearTimeout);
|
|
402
|
+
timeoutsRef.current = [];
|
|
403
|
+
setSignals([]);
|
|
404
|
+
}, []);
|
|
405
|
+
|
|
406
|
+
const sleep = useCallback((ms: number) =>
|
|
407
|
+
new Promise<void>(resolve => {
|
|
408
|
+
const id = setTimeout(resolve, ms);
|
|
409
|
+
timeoutsRef.current.push(id);
|
|
410
|
+
}), []);
|
|
411
|
+
|
|
412
|
+
const finish = useCallback(() => onCompleteRef.current?.(), []);
|
|
413
|
+
|
|
414
|
+
// Step orchestration
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
cleanup();
|
|
417
|
+
const run = async () => {
|
|
418
|
+
switch (currentStep) {
|
|
419
|
+
case 0:
|
|
420
|
+
dispatch(reset());
|
|
421
|
+
finish();
|
|
422
|
+
break;
|
|
423
|
+
case 1:
|
|
424
|
+
dispatch(patchState({ phase: "running", explanation: "..." }));
|
|
425
|
+
// animate...
|
|
426
|
+
await sleep(1200);
|
|
427
|
+
finish();
|
|
428
|
+
break;
|
|
429
|
+
// more steps...
|
|
430
|
+
default:
|
|
431
|
+
finish();
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
run();
|
|
435
|
+
return cleanup;
|
|
436
|
+
}, [currentStep]);
|
|
437
|
+
|
|
438
|
+
return { runtime, currentStep, signals, animPhase };
|
|
439
|
+
};
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Key rules:
|
|
443
|
+
- Always call `cleanup()` at the start of each step.
|
|
444
|
+
- Always call `finish()` when a step's animation is done — this enables the Next button.
|
|
445
|
+
- Use `onCompleteRef` to avoid stale closure issues.
|
|
446
|
+
- The `useEffect` dependency should be `[currentStep]`.
|
|
447
|
+
- For steps with `autoAdvance: true`, the Shell auto-advances when `finish()` fires.
|
|
448
|
+
- Return `signals` so the component can pass them to the overlay.
|
|
449
|
+
|
|
450
|
+
## Redux slice pattern
|
|
451
|
+
|
|
452
|
+
Use the `patchState` pattern for flexible updates:
|
|
453
|
+
|
|
454
|
+
```typescript
|
|
455
|
+
interface MyPluginState {
|
|
456
|
+
phase: "idle" | "running" | "done";
|
|
457
|
+
explanation: string;
|
|
458
|
+
hotZones: string[];
|
|
459
|
+
// domain-specific fields
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const slice = createSlice({
|
|
463
|
+
name: "myPlugin",
|
|
464
|
+
initialState,
|
|
465
|
+
reducers: {
|
|
466
|
+
patchState(state, action: PayloadAction<Partial<MyPluginState>>) {
|
|
467
|
+
Object.assign(state, action.payload);
|
|
468
|
+
},
|
|
469
|
+
reset() { return initialState; },
|
|
470
|
+
// domain-specific reducers as needed
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Always export `initialState` and `reset` — the plugin registration and Shell need them.
|
|
476
|
+
|
|
477
|
+
## Concepts file pattern
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
export type ConceptKey = "concept-a" | "concept-b";
|
|
481
|
+
|
|
482
|
+
export const concepts: Record<ConceptKey, ConceptDefinition> = {
|
|
483
|
+
"concept-a": {
|
|
484
|
+
title: "Concept A",
|
|
485
|
+
subtitle: "Brief description",
|
|
486
|
+
accentColor: "#60a5fa",
|
|
487
|
+
sections: [
|
|
488
|
+
{ title: "Section heading", accent: "#60a5fa", content: <p>...</p> },
|
|
489
|
+
],
|
|
490
|
+
aside: <div>Optional sidebar content</div>, // optional
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## SCSS conventions
|
|
496
|
+
|
|
497
|
+
```scss
|
|
498
|
+
.{plugin-name}-root {
|
|
499
|
+
// CSS custom properties for the plugin
|
|
500
|
+
--{plugin-name}-bg: #020617;
|
|
501
|
+
--{plugin-name}-panel: rgba(7, 17, 34, 0.88);
|
|
502
|
+
--{plugin-name}-border: rgba(148, 163, 184, 0.18);
|
|
503
|
+
--{plugin-name}-text: #e2e8f0;
|
|
504
|
+
|
|
505
|
+
display: flex;
|
|
506
|
+
flex-direction: column;
|
|
507
|
+
width: 100%;
|
|
508
|
+
height: 100%;
|
|
509
|
+
overflow: hidden;
|
|
510
|
+
color: var(--{plugin-name}-text);
|
|
511
|
+
background:
|
|
512
|
+
radial-gradient(circle at top left, rgba(59, 130, 246, 0.14), transparent 28%),
|
|
513
|
+
radial-gradient(circle at bottom right, rgba(20, 184, 166, 0.12), transparent 30%),
|
|
514
|
+
linear-gradient(180deg, #020617 0%, #071325 100%);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
.{plugin-name}-stage {
|
|
518
|
+
background: var(--{plugin-name}-panel);
|
|
519
|
+
border: 1px solid var(--{plugin-name}-border);
|
|
520
|
+
border-radius: 24px;
|
|
521
|
+
padding: 1rem;
|
|
522
|
+
display: flex;
|
|
523
|
+
flex-direction: column;
|
|
524
|
+
min-height: 0;
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Dark theme defaults: bg `#0f172a`, text `#e2e8f0`, muted `#94a3b8`, borders `rgba(148,163,184,0.1)`.
|
|
529
|
+
Shared plugin-kit classes use `vc-` prefix. Per-plugin classes use the plugin name prefix.
|
|
530
|
+
|
|
531
|
+
## Hot zone pattern
|
|
532
|
+
|
|
533
|
+
Use `hotZones: string[]` in state to highlight active nodes:
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
const hot = (zone: string) => runtime.hotZones.includes(zone);
|
|
537
|
+
|
|
538
|
+
b.node("broker")
|
|
539
|
+
.fill(hot("broker") ? "#1e40af" : "#0f172a")
|
|
540
|
+
.stroke(hot("broker") ? "#60a5fa" : "#334155", 2);
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
This makes the active part of the architecture visually dominant in each step.
|
|
544
|
+
|
|
545
|
+
## Registry wiring
|
|
546
|
+
|
|
547
|
+
Plugins are registered in `src/registry.ts`. The `generate-plugin` script handles this automatically with `--category`.
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
import MyPlugin from "./plugins/my-plugin";
|
|
551
|
+
|
|
552
|
+
export const categories: PluginCategory[] = [
|
|
553
|
+
{
|
|
554
|
+
id: "my-category", // URL slug
|
|
555
|
+
name: "My Category", // Display name
|
|
556
|
+
description: "...",
|
|
557
|
+
accent: "#3b82f6", // Card colour
|
|
558
|
+
plugins: [MyPlugin],
|
|
559
|
+
},
|
|
560
|
+
];
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
The store, routes, and landing page derive from this single source of truth.
|
|
564
|
+
|
|
565
|
+
## Common mistakes to avoid
|
|
566
|
+
|
|
567
|
+
- Forgetting to call `finish()` — step will never advance.
|
|
568
|
+
- Not cleaning up RAF/timeouts — animations bleed across steps.
|
|
569
|
+
- Using stale closures for `onAnimationComplete` — use `.current` ref.
|
|
570
|
+
- Signals disappearing when they should persist — use `useRef` for resting signals.
|
|
571
|
+
- Hardcoding node positions twice (in builder and overlay) — maintain a single position map.
|
|
572
|
+
- Not preserving pan/zoom on scene rebuild — save/restore via `pzRef`.
|
|
573
|
+
- Using `variant` for pill colours instead of inline `color`/`borderColor`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# VizCraft Playground
|
|
2
|
+
|
|
3
|
+
Explore interactive visualizations.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Open [http://localhost:5173](http://localhost:5173) in your browser.
|
|
13
|
+
|
|
14
|
+
## Personalise
|
|
15
|
+
|
|
16
|
+
Run the init wizard to set your playground's title, subtitle, and accent colour:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm run init
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or edit `src/playground.config.ts` directly.
|
|
23
|
+
|
|
24
|
+
## Adding a Plugin
|
|
25
|
+
|
|
26
|
+
Generate a new plugin with the scaffolding tool:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm run generate <plugin-name> --category "Category Name"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For example:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm run generate supply-demand --category "Microeconomics"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
This creates `src/plugins/<plugin-name>/` with six files and wires it into the registry automatically. If the category doesn't exist yet, it will be created.
|
|
39
|
+
|
|
40
|
+
### Plugin structure
|
|
41
|
+
|
|
42
|
+
| File | Purpose |
|
|
43
|
+
|------|---------|
|
|
44
|
+
| `index.ts` | Plugin registration (id, name, steps, reducer) |
|
|
45
|
+
| `*Slice.ts` | Redux Toolkit slice for local state |
|
|
46
|
+
| `use*Animation.ts` | Step orchestration & signal animation |
|
|
47
|
+
| `main.tsx` | React component using plugin-kit |
|
|
48
|
+
| `main.scss` | Plugin-specific styles |
|
|
49
|
+
| `concepts.tsx` | Clickable info-pill definitions |
|
|
50
|
+
|
|
51
|
+
See the **hello-world** plugin for a minimal working example.
|
|
52
|
+
|
|
53
|
+
## Tech Stack
|
|
54
|
+
|
|
55
|
+
- [React](https://react.dev/) + [TypeScript](https://www.typescriptlang.org/)
|
|
56
|
+
- [Vite](https://vitejs.dev/)
|
|
57
|
+
- [Redux Toolkit](https://redux-toolkit.js.org/)
|
|
58
|
+
- [vizcraft](https://www.npmjs.com/package/vizcraft) (SVG visualization engine)
|
|
59
|
+
- SCSS
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import globals from 'globals'
|
|
3
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
4
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
5
|
+
import tseslint from 'typescript-eslint'
|
|
6
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
7
|
+
|
|
8
|
+
export default defineConfig([
|
|
9
|
+
globalIgnores(['dist']),
|
|
10
|
+
{
|
|
11
|
+
files: ['**/*.{ts,tsx}'],
|
|
12
|
+
extends: [
|
|
13
|
+
js.configs.recommended,
|
|
14
|
+
tseslint.configs.recommended,
|
|
15
|
+
reactHooks.configs.flat.recommended,
|
|
16
|
+
reactRefresh.configs.vite,
|
|
17
|
+
],
|
|
18
|
+
languageOptions: {
|
|
19
|
+
ecmaVersion: 2020,
|
|
20
|
+
globals: globals.browser,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>VizCraft Playground</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vizcraft-playground",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"lint": "eslint .",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"generate": "node scripts/generate-plugin.js",
|
|
12
|
+
"init": "node scripts/init-playground.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@reduxjs/toolkit": "^2.11.1",
|
|
16
|
+
"react": "^19.2.0",
|
|
17
|
+
"react-dom": "^19.2.0",
|
|
18
|
+
"react-redux": "^9.2.0",
|
|
19
|
+
"react-router-dom": "^7.10.1",
|
|
20
|
+
"vizcraft": "^1.15.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@eslint/js": "^9.39.1",
|
|
24
|
+
"@types/node": "^24.10.1",
|
|
25
|
+
"@types/react": "^19.2.5",
|
|
26
|
+
"@types/react-dom": "^19.2.3",
|
|
27
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
28
|
+
"eslint": "^9.39.1",
|
|
29
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
30
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
31
|
+
"globals": "^16.5.0",
|
|
32
|
+
"sass-embedded": "^1.88.0",
|
|
33
|
+
"typescript": "~5.8.3",
|
|
34
|
+
"typescript-eslint": "^8.29.0",
|
|
35
|
+
"vite": "^7.2.7"
|
|
36
|
+
}
|
|
37
|
+
}
|