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,27 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════
|
|
2
|
+
* Playground Configuration
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for branding & metadata.
|
|
5
|
+
* Run `npm run init` to regenerate this file interactively,
|
|
6
|
+
* or edit the values below directly.
|
|
7
|
+
* ═══════════════════════════════════════════════════════ */
|
|
8
|
+
|
|
9
|
+
export interface PlaygroundConfig {
|
|
10
|
+
/** Project slug (kebab-case), used in package.json name */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Display title shown on the landing page */
|
|
13
|
+
title: string;
|
|
14
|
+
/** One-liner shown below the title on the landing page */
|
|
15
|
+
subtitle: string;
|
|
16
|
+
/** Primary accent colour (hex) */
|
|
17
|
+
accent: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const playgroundConfig: PlaygroundConfig = {
|
|
21
|
+
name: "vizcraft-playground",
|
|
22
|
+
title: "VizCraft Playground",
|
|
23
|
+
subtitle: "Explore interactive visualizations.",
|
|
24
|
+
accent: "#3b82f6",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default playgroundConfig;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { InfoModalSection } from "../../components/InfoModal/InfoModal";
|
|
3
|
+
|
|
4
|
+
export type ConceptKey = "how-plugins-work";
|
|
5
|
+
|
|
6
|
+
interface ConceptDefinition {
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
accentColor: string;
|
|
10
|
+
sections: InfoModalSection[];
|
|
11
|
+
aside?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const concepts: Record<ConceptKey, ConceptDefinition> = {
|
|
15
|
+
"how-plugins-work": {
|
|
16
|
+
title: "How Plugins Work",
|
|
17
|
+
subtitle: "The building blocks of every playground demo",
|
|
18
|
+
accentColor: "#60a5fa",
|
|
19
|
+
sections: [
|
|
20
|
+
{
|
|
21
|
+
title: "Plugin anatomy",
|
|
22
|
+
accent: "#60a5fa",
|
|
23
|
+
content: (
|
|
24
|
+
<>
|
|
25
|
+
<p>
|
|
26
|
+
Every plugin lives in <code>src/plugins/your-name/</code> and
|
|
27
|
+
contains six files:
|
|
28
|
+
</p>
|
|
29
|
+
<ul style={{ paddingLeft: "1.2rem", marginTop: "0.5rem" }}>
|
|
30
|
+
<li>
|
|
31
|
+
<strong>index.ts</strong> — registers the plugin with the
|
|
32
|
+
playground (id, name, steps, reducer).
|
|
33
|
+
</li>
|
|
34
|
+
<li>
|
|
35
|
+
<strong>yourNameSlice.ts</strong> — Redux Toolkit slice for
|
|
36
|
+
local state.
|
|
37
|
+
</li>
|
|
38
|
+
<li>
|
|
39
|
+
<strong>useYourNameAnimation.ts</strong> — a hook that
|
|
40
|
+
orchestrates step-by-step animations.
|
|
41
|
+
</li>
|
|
42
|
+
<li>
|
|
43
|
+
<strong>main.tsx</strong> — the React component that renders
|
|
44
|
+
nodes, edges, and UI using the plugin-kit.
|
|
45
|
+
</li>
|
|
46
|
+
<li>
|
|
47
|
+
<strong>main.scss</strong> — plugin-specific styles.
|
|
48
|
+
</li>
|
|
49
|
+
<li>
|
|
50
|
+
<strong>concepts.tsx</strong> — definitions for the clickable
|
|
51
|
+
info pills.
|
|
52
|
+
</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
title: "Scaffolding a new plugin",
|
|
59
|
+
accent: "#34d399",
|
|
60
|
+
content: (
|
|
61
|
+
<p>
|
|
62
|
+
Run <code>npm run generate my-plugin --category "My Category"</code>{" "}
|
|
63
|
+
to create all six files and wire the plugin into the registry
|
|
64
|
+
automatically.
|
|
65
|
+
</p>
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
2
|
+
|
|
3
|
+
export type HelloWorldPhase = "idle" | "greeting" | "done";
|
|
4
|
+
|
|
5
|
+
export interface HelloWorldState {
|
|
6
|
+
phase: HelloWorldPhase;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const initialState: HelloWorldState = {
|
|
11
|
+
phase: "idle",
|
|
12
|
+
message: "Press Next to see the plugin system in action.",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const helloWorldSlice = createSlice({
|
|
16
|
+
name: "helloWorld",
|
|
17
|
+
initialState,
|
|
18
|
+
reducers: {
|
|
19
|
+
patchState(state, action: PayloadAction<Partial<HelloWorldState>>) {
|
|
20
|
+
Object.assign(state, action.payload);
|
|
21
|
+
},
|
|
22
|
+
reset() {
|
|
23
|
+
return initialState;
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const { patchState, reset } = helloWorldSlice.actions;
|
|
29
|
+
export default helloWorldSlice.reducer;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Action, Dispatch } from "@reduxjs/toolkit";
|
|
2
|
+
import type { DemoPlugin, DemoStep } from "../../types/ModelPlugin";
|
|
3
|
+
import HelloWorldVisualization from "./main";
|
|
4
|
+
import helloWorldReducer, {
|
|
5
|
+
type HelloWorldState,
|
|
6
|
+
initialState,
|
|
7
|
+
reset,
|
|
8
|
+
} from "./helloWorldSlice";
|
|
9
|
+
|
|
10
|
+
type LocalRootState = { helloWorld: HelloWorldState };
|
|
11
|
+
|
|
12
|
+
const HelloWorldPlugin: DemoPlugin<
|
|
13
|
+
HelloWorldState,
|
|
14
|
+
Action,
|
|
15
|
+
LocalRootState,
|
|
16
|
+
Dispatch<Action>
|
|
17
|
+
> = {
|
|
18
|
+
id: "hello-world",
|
|
19
|
+
name: "Hello World",
|
|
20
|
+
description:
|
|
21
|
+
"A minimal reference plugin — two nodes, one signal, three steps. Study this to learn how the plugin system works.",
|
|
22
|
+
initialState,
|
|
23
|
+
reducer: helloWorldReducer,
|
|
24
|
+
Component: HelloWorldVisualization,
|
|
25
|
+
restartConfig: { text: "Replay", color: "#1e40af" },
|
|
26
|
+
getSteps: (_: HelloWorldState): DemoStep[] => [
|
|
27
|
+
{
|
|
28
|
+
label: "Idle",
|
|
29
|
+
autoAdvance: false,
|
|
30
|
+
nextButtonText: "Send Signal",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
label: "Signal in flight",
|
|
34
|
+
autoAdvance: true,
|
|
35
|
+
processingText: "Sending…",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
label: "Delivered",
|
|
39
|
+
autoAdvance: true,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
init: (dispatch) => {
|
|
43
|
+
dispatch(reset());
|
|
44
|
+
},
|
|
45
|
+
selector: (state: LocalRootState) => state.helloWorld,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default HelloWorldPlugin;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.hello-world-root {
|
|
2
|
+
--hw-bg: #020617;
|
|
3
|
+
--hw-panel: rgba(7, 17, 34, 0.88);
|
|
4
|
+
--hw-border: rgba(148, 163, 184, 0.18);
|
|
5
|
+
--hw-text: #e2e8f0;
|
|
6
|
+
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
width: 100%;
|
|
10
|
+
height: 100%;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
color: var(--hw-text);
|
|
13
|
+
background:
|
|
14
|
+
radial-gradient(circle at top left, rgba(59, 130, 246, 0.14), transparent 28%),
|
|
15
|
+
radial-gradient(circle at bottom right, rgba(52, 211, 153, 0.12), transparent 30%),
|
|
16
|
+
linear-gradient(180deg, #020617 0%, #071325 100%);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.hello-world-stage {
|
|
20
|
+
background: var(--hw-panel);
|
|
21
|
+
border: 1px solid var(--hw-border);
|
|
22
|
+
box-shadow: 0 20px 42px -28px rgba(0, 0, 0, 0.7);
|
|
23
|
+
border-radius: 24px;
|
|
24
|
+
padding: 1rem;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
min-height: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.hello-world-phase--idle .vc-stat-badge__value { color: #94a3b8; }
|
|
31
|
+
.hello-world-phase--greeting .vc-stat-badge__value { color: #60a5fa; }
|
|
32
|
+
.hello-world-phase--done .vc-stat-badge__value { color: #34d399; }
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React, { useLayoutEffect, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
viz,
|
|
4
|
+
type PanZoomController,
|
|
5
|
+
type SignalOverlayParams,
|
|
6
|
+
} from "vizcraft";
|
|
7
|
+
import {
|
|
8
|
+
useConceptModal,
|
|
9
|
+
ConceptPills,
|
|
10
|
+
PluginLayout,
|
|
11
|
+
StageHeader,
|
|
12
|
+
StatBadge,
|
|
13
|
+
SidePanel,
|
|
14
|
+
SideCard,
|
|
15
|
+
CanvasStage,
|
|
16
|
+
} from "../../components/plugin-kit";
|
|
17
|
+
import { concepts, type ConceptKey } from "./concepts";
|
|
18
|
+
import {
|
|
19
|
+
useHelloWorldAnimation,
|
|
20
|
+
type Signal,
|
|
21
|
+
} from "./useHelloWorldAnimation";
|
|
22
|
+
import "./main.scss";
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
onAnimationComplete?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const W = 900;
|
|
29
|
+
const H = 500;
|
|
30
|
+
|
|
31
|
+
const HelloWorldVisualization: React.FC<Props> = ({ onAnimationComplete }) => {
|
|
32
|
+
const { runtime, currentStep, signals } =
|
|
33
|
+
useHelloWorldAnimation(onAnimationComplete);
|
|
34
|
+
const { openConcept, ConceptModal } = useConceptModal<ConceptKey>(concepts);
|
|
35
|
+
const containerRef = useRef<HTMLDivElement>(null!);
|
|
36
|
+
const builderRef = useRef<ReturnType<typeof viz> | null>(null);
|
|
37
|
+
const pzRef = useRef<PanZoomController | null>(null);
|
|
38
|
+
|
|
39
|
+
const { phase, message } = runtime;
|
|
40
|
+
const isGreeting = phase === "greeting" || phase === "done";
|
|
41
|
+
|
|
42
|
+
/* ── Build VizCraft scene ─────────────────────────────── */
|
|
43
|
+
const scene = (() => {
|
|
44
|
+
const b = viz().view(W, H);
|
|
45
|
+
|
|
46
|
+
b.node("sender")
|
|
47
|
+
.at(200, 250)
|
|
48
|
+
.rect(140, 60, 12)
|
|
49
|
+
.fill(isGreeting ? "#1e40af" : "#0f172a")
|
|
50
|
+
.stroke(isGreeting ? "#60a5fa" : "#334155", 2)
|
|
51
|
+
.label("Sender", { fill: "#fff", fontSize: 14, fontWeight: "bold" });
|
|
52
|
+
|
|
53
|
+
b.node("receiver")
|
|
54
|
+
.at(650, 250)
|
|
55
|
+
.rect(140, 60, 12)
|
|
56
|
+
.fill(phase === "done" ? "#065f46" : "#0f172a")
|
|
57
|
+
.stroke(phase === "done" ? "#34d399" : "#334155", 2)
|
|
58
|
+
.label("Receiver", { fill: "#fff", fontSize: 14, fontWeight: "bold" });
|
|
59
|
+
|
|
60
|
+
b.edge("sender", "receiver", "link")
|
|
61
|
+
.stroke("#475569", 2)
|
|
62
|
+
.animate("flow", { duration: "3s" });
|
|
63
|
+
|
|
64
|
+
if (signals.length > 0) {
|
|
65
|
+
b.overlay((o) => {
|
|
66
|
+
signals.forEach((sig) => {
|
|
67
|
+
const { id, ...params } = sig;
|
|
68
|
+
o.add("signal", params as SignalOverlayParams, { key: id });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return b;
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
/* ── Mount / destroy ────────────────────────────────── */
|
|
77
|
+
useLayoutEffect(() => {
|
|
78
|
+
if (!containerRef.current) return;
|
|
79
|
+
const saved = pzRef.current?.getState() ?? null;
|
|
80
|
+
builderRef.current?.destroy();
|
|
81
|
+
builderRef.current = scene;
|
|
82
|
+
pzRef.current =
|
|
83
|
+
scene.mount(containerRef.current, {
|
|
84
|
+
autoplay: true,
|
|
85
|
+
panZoom: true,
|
|
86
|
+
initialZoom: saved?.zoom ?? 1,
|
|
87
|
+
initialPan: saved?.pan ?? { x: 0, y: 0 },
|
|
88
|
+
}) ?? null;
|
|
89
|
+
}, [scene]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
return () => {
|
|
93
|
+
builderRef.current?.destroy();
|
|
94
|
+
builderRef.current = null;
|
|
95
|
+
pzRef.current = null;
|
|
96
|
+
};
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
/* ── Pills ──────────────────────────────────────────── */
|
|
100
|
+
const pills = [
|
|
101
|
+
{
|
|
102
|
+
key: "how-plugins-work",
|
|
103
|
+
label: "How Plugins Work",
|
|
104
|
+
color: "#93c5fd",
|
|
105
|
+
borderColor: "#3b82f6",
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
/* ── Render ─────────────────────────────────────────── */
|
|
110
|
+
return (
|
|
111
|
+
<div className="hello-world-root">
|
|
112
|
+
<PluginLayout
|
|
113
|
+
toolbar={<ConceptPills pills={pills} onOpen={openConcept} />}
|
|
114
|
+
canvas={
|
|
115
|
+
<div className="hello-world-stage">
|
|
116
|
+
<StageHeader title="Hello World" subtitle="A minimal reference plugin">
|
|
117
|
+
<StatBadge
|
|
118
|
+
label="Phase"
|
|
119
|
+
value={phase}
|
|
120
|
+
className={`hello-world-phase hello-world-phase--${phase}`}
|
|
121
|
+
/>
|
|
122
|
+
</StageHeader>
|
|
123
|
+
<CanvasStage canvasRef={containerRef} />
|
|
124
|
+
</div>
|
|
125
|
+
}
|
|
126
|
+
sidebar={
|
|
127
|
+
<SidePanel>
|
|
128
|
+
<SideCard label="What's happening" variant="explanation">
|
|
129
|
+
<p>{message}</p>
|
|
130
|
+
</SideCard>
|
|
131
|
+
<SideCard label="Current step" variant="info">
|
|
132
|
+
<p style={{ fontSize: "2rem", fontWeight: 700, margin: 0 }}>
|
|
133
|
+
{currentStep}
|
|
134
|
+
</p>
|
|
135
|
+
</SideCard>
|
|
136
|
+
</SidePanel>
|
|
137
|
+
}
|
|
138
|
+
/>
|
|
139
|
+
<ConceptModal />
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default HelloWorldVisualization;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useDispatch, useSelector } from "react-redux";
|
|
3
|
+
import type { SignalOverlayParams } from "vizcraft";
|
|
4
|
+
import { type RootState } from "../../store/store";
|
|
5
|
+
import { patchState, reset } from "./helloWorldSlice";
|
|
6
|
+
|
|
7
|
+
export type Signal = { id: string } & SignalOverlayParams;
|
|
8
|
+
|
|
9
|
+
export const useHelloWorldAnimation = (onAnimationComplete?: () => void) => {
|
|
10
|
+
const dispatch = useDispatch();
|
|
11
|
+
const { currentStep } = useSelector((state: RootState) => state.simulation);
|
|
12
|
+
const runtime = useSelector((state: RootState) => state.helloWorld);
|
|
13
|
+
const [signals, setSignals] = useState<Signal[]>([]);
|
|
14
|
+
const timeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
|
15
|
+
const onCompleteRef = useRef(onAnimationComplete);
|
|
16
|
+
|
|
17
|
+
onCompleteRef.current = onAnimationComplete;
|
|
18
|
+
|
|
19
|
+
const cleanup = useCallback(() => {
|
|
20
|
+
timeoutsRef.current.forEach((id) => clearTimeout(id));
|
|
21
|
+
timeoutsRef.current = [];
|
|
22
|
+
setSignals([]);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const sleep = useCallback(
|
|
26
|
+
(ms: number) =>
|
|
27
|
+
new Promise<void>((resolve) => {
|
|
28
|
+
const id = setTimeout(resolve, ms);
|
|
29
|
+
timeoutsRef.current.push(id);
|
|
30
|
+
}),
|
|
31
|
+
[],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const finish = useCallback(() => onCompleteRef.current?.(), []);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
cleanup();
|
|
38
|
+
|
|
39
|
+
const run = async () => {
|
|
40
|
+
switch (currentStep) {
|
|
41
|
+
case 0:
|
|
42
|
+
dispatch(reset());
|
|
43
|
+
finish();
|
|
44
|
+
break;
|
|
45
|
+
|
|
46
|
+
case 1:
|
|
47
|
+
dispatch(
|
|
48
|
+
patchState({
|
|
49
|
+
phase: "greeting",
|
|
50
|
+
message: "A signal is traveling between the two nodes.",
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
setSignals([
|
|
54
|
+
{
|
|
55
|
+
id: "hello-sig",
|
|
56
|
+
from: "sender",
|
|
57
|
+
to: "receiver",
|
|
58
|
+
progress: 0,
|
|
59
|
+
magnitude: 1,
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
await sleep(1400);
|
|
63
|
+
setSignals([
|
|
64
|
+
{
|
|
65
|
+
id: "hello-sig",
|
|
66
|
+
from: "sender",
|
|
67
|
+
to: "receiver",
|
|
68
|
+
progress: 1,
|
|
69
|
+
magnitude: 1,
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
await sleep(400);
|
|
73
|
+
finish();
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 2:
|
|
77
|
+
dispatch(
|
|
78
|
+
patchState({
|
|
79
|
+
phase: "done",
|
|
80
|
+
message:
|
|
81
|
+
"Signal delivered! This is how every plugin works — define nodes, edges, signals, and step logic.",
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
setSignals([]);
|
|
85
|
+
await sleep(200);
|
|
86
|
+
finish();
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
finish();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
run();
|
|
95
|
+
return cleanup;
|
|
96
|
+
}, [currentStep]);
|
|
97
|
+
|
|
98
|
+
return { runtime, currentStep, signals };
|
|
99
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { DemoPlugin } from "./types/ModelPlugin";
|
|
2
|
+
import HelloWorldPlugin from "./plugins/hello-world";
|
|
3
|
+
|
|
4
|
+
/* ──────────────────────────────────────────────────────────
|
|
5
|
+
* Plugin Category Registry
|
|
6
|
+
*
|
|
7
|
+
* To add a new category:
|
|
8
|
+
* 1. Push a new PluginCategory into `categories` below.
|
|
9
|
+
*
|
|
10
|
+
* To add a plugin to an existing category:
|
|
11
|
+
* 1. Import the plugin.
|
|
12
|
+
* 2. Add it to the `plugins` array of the target category.
|
|
13
|
+
*
|
|
14
|
+
* The store, routes, and landing page all derive from this
|
|
15
|
+
* single source of truth — no other files need to change.
|
|
16
|
+
* ────────────────────────────────────────────────────────── */
|
|
17
|
+
|
|
18
|
+
export interface PluginCategory {
|
|
19
|
+
/** URL slug, e.g. "system-design" */
|
|
20
|
+
id: string;
|
|
21
|
+
/** Display name shown on the landing page */
|
|
22
|
+
name: string;
|
|
23
|
+
/** One-liner for the landing card */
|
|
24
|
+
description: string;
|
|
25
|
+
/** Accent colour for the card / heading */
|
|
26
|
+
accent: string;
|
|
27
|
+
/** Plugins that belong to this category */
|
|
28
|
+
plugins: DemoPlugin[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const categories: PluginCategory[] = [
|
|
32
|
+
{
|
|
33
|
+
id: "examples",
|
|
34
|
+
name: "Examples",
|
|
35
|
+
description:
|
|
36
|
+
"Reference plugins that demonstrate how the playground engine works.",
|
|
37
|
+
accent: "#6366f1",
|
|
38
|
+
plugins: [HelloWorldPlugin],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/* ── Helpers ─────────────────────────────────────────────── */
|
|
43
|
+
|
|
44
|
+
/** Flat list of every registered plugin. */
|
|
45
|
+
export const allPlugins: DemoPlugin[] = categories.flatMap((c) => c.plugins);
|
|
46
|
+
|
|
47
|
+
/** kebab-case → camelCase ("event-streaming" → "eventStreaming") */
|
|
48
|
+
export const toCamelCase = (s: string) =>
|
|
49
|
+
s.replace(/-(\w)/g, (_, c: string) => c.toUpperCase());
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a `{ [camelKey]: reducer }` map for configureStore.
|
|
53
|
+
*/
|
|
54
|
+
export const pluginReducerMap = Object.fromEntries(
|
|
55
|
+
allPlugins.map((p) => [toCamelCase(p.id), p.reducer]),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/** Look up which category a plugin belongs to. */
|
|
59
|
+
export function categoryForPlugin(
|
|
60
|
+
pluginId: string,
|
|
61
|
+
): PluginCategory | undefined {
|
|
62
|
+
return categories.find((c) => c.plugins.some((p) => p.id === pluginId));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Look up a plugin by id. */
|
|
66
|
+
export function findPlugin(pluginId: string): DemoPlugin | undefined {
|
|
67
|
+
return allPlugins.find((p) => p.id === pluginId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Look up a category by id. */
|
|
71
|
+
export function findCategory(categoryId: string): PluginCategory | undefined {
|
|
72
|
+
return categories.find((c) => c.id === categoryId);
|
|
73
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
2
|
+
|
|
3
|
+
interface SimulationState {
|
|
4
|
+
currentStep: number;
|
|
5
|
+
passCount: number;
|
|
6
|
+
isPlaying: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const initialState: SimulationState = {
|
|
10
|
+
currentStep: 0,
|
|
11
|
+
passCount: 1,
|
|
12
|
+
isPlaying: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const simulationSlice = createSlice({
|
|
16
|
+
name: "simulation",
|
|
17
|
+
initialState,
|
|
18
|
+
reducers: {
|
|
19
|
+
nextStep(state, action: PayloadAction<number>) {
|
|
20
|
+
// action.payload is maxSteps
|
|
21
|
+
state.currentStep = Math.min(state.currentStep + 1, action.payload);
|
|
22
|
+
},
|
|
23
|
+
setStep(state, action: PayloadAction<number>) {
|
|
24
|
+
state.currentStep = action.payload;
|
|
25
|
+
},
|
|
26
|
+
incrementPass(state) {
|
|
27
|
+
state.passCount += 1;
|
|
28
|
+
},
|
|
29
|
+
resetSimulation(state) {
|
|
30
|
+
state.currentStep = 0;
|
|
31
|
+
state.passCount = 1;
|
|
32
|
+
state.isPlaying = false;
|
|
33
|
+
},
|
|
34
|
+
setIsPlaying(state, action: PayloadAction<boolean>) {
|
|
35
|
+
state.isPlaying = action.payload;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const {
|
|
41
|
+
nextStep,
|
|
42
|
+
setStep,
|
|
43
|
+
incrementPass,
|
|
44
|
+
resetSimulation,
|
|
45
|
+
setIsPlaying,
|
|
46
|
+
} = simulationSlice.actions;
|
|
47
|
+
export default simulationSlice.reducer;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { configureStore } from "@reduxjs/toolkit";
|
|
2
|
+
import simulationReducer from "./slices/simulationSlice";
|
|
3
|
+
import { pluginReducerMap } from "../registry";
|
|
4
|
+
|
|
5
|
+
export const store = configureStore({
|
|
6
|
+
reducer: {
|
|
7
|
+
...pluginReducerMap,
|
|
8
|
+
simulation: simulationReducer,
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export type RootState = ReturnType<typeof store.getState>;
|
|
13
|
+
export type AppDispatch = typeof store.dispatch;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Reducer, Action } from "@reduxjs/toolkit";
|
|
3
|
+
|
|
4
|
+
export interface DemoPluginComponentProps {
|
|
5
|
+
onAnimationComplete?: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DemoPlugin<
|
|
9
|
+
State = any,
|
|
10
|
+
Actions extends Action = any,
|
|
11
|
+
TRootState = any,
|
|
12
|
+
TDispatch = any,
|
|
13
|
+
> {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
|
|
18
|
+
// State Management
|
|
19
|
+
initialState: State;
|
|
20
|
+
reducer: Reducer<State, Actions>;
|
|
21
|
+
|
|
22
|
+
// Rendering
|
|
23
|
+
Component: React.FC<DemoPluginComponentProps>;
|
|
24
|
+
Controls?: React.FC; // Optional settings panel
|
|
25
|
+
|
|
26
|
+
// Customization
|
|
27
|
+
restartConfig?: {
|
|
28
|
+
text?: string;
|
|
29
|
+
color?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
getSteps: (state: State) => (string | DemoStep)[];
|
|
33
|
+
|
|
34
|
+
// Lifecycle & Data Access
|
|
35
|
+
init: (dispatch: TDispatch) => void;
|
|
36
|
+
selector: (state: TRootState) => State;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DemoStep {
|
|
40
|
+
label: string;
|
|
41
|
+
/**
|
|
42
|
+
* If true, the shell will automatically proceed to the next step
|
|
43
|
+
* after this step's animation/action is complete, without waiting for user input.
|
|
44
|
+
* Default: false (Requires "Next Step" button press)
|
|
45
|
+
*/
|
|
46
|
+
autoAdvance?: boolean;
|
|
47
|
+
|
|
48
|
+
// Customization for the "Next Step" button
|
|
49
|
+
/** Text to display on the button when waiting for user input. Default: "Next Step" */
|
|
50
|
+
nextButtonText?: string;
|
|
51
|
+
/** Text to display on the button while processing/animating. Default: "Processing..." */
|
|
52
|
+
processingText?: string;
|
|
53
|
+
/** Background color of the button. Can be a CSS color string. Default: Theme blue */
|
|
54
|
+
nextButtonColor?: string;
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export /**
|
|
2
|
+
* Generates a deterministic pseudo-random number between 0 and 1 based on a seed.
|
|
3
|
+
* This is widely used in shader code (GLSL) for generating noise.
|
|
4
|
+
* The magic number 43758.5453123 is a canonical constant optimized to produce
|
|
5
|
+
* a "random-looking" distribution of bits in the floating point mantissa when
|
|
6
|
+
* multiplied by the sine of the seed.
|
|
7
|
+
*/
|
|
8
|
+
function deterministicRandom(seed: number): number {
|
|
9
|
+
const raw = Math.sin(seed) * 43758.5453123;
|
|
10
|
+
return raw - Math.floor(raw);
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"useDefineForClassFields": true,
|
|
6
|
+
"lib": [
|
|
7
|
+
"ES2022",
|
|
8
|
+
"DOM",
|
|
9
|
+
"DOM.Iterable"
|
|
10
|
+
],
|
|
11
|
+
"module": "ESNext",
|
|
12
|
+
"types": [
|
|
13
|
+
"vite/client"
|
|
14
|
+
],
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
/* Bundler mode */
|
|
17
|
+
"moduleResolution": "bundler",
|
|
18
|
+
"allowImportingTsExtensions": true,
|
|
19
|
+
"verbatimModuleSyntax": true,
|
|
20
|
+
"moduleDetection": "force",
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"jsx": "react-jsx",
|
|
23
|
+
/* Linting */
|
|
24
|
+
"strict": true,
|
|
25
|
+
"noUnusedLocals": true,
|
|
26
|
+
"noUnusedParameters": true,
|
|
27
|
+
"erasableSyntaxOnly": true,
|
|
28
|
+
"noFallthroughCasesInSwitch": true,
|
|
29
|
+
"noUncheckedSideEffectImports": true,
|
|
30
|
+
"esModuleInterop": true
|
|
31
|
+
},
|
|
32
|
+
"include": [
|
|
33
|
+
"src"
|
|
34
|
+
]
|
|
35
|
+
}
|