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,85 @@
|
|
|
1
|
+
@use "sass:color";
|
|
2
|
+
|
|
3
|
+
.landing {
|
|
4
|
+
width: 100%;
|
|
5
|
+
min-height: 100vh;
|
|
6
|
+
background: #0f172a;
|
|
7
|
+
color: #e2e8f0;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
align-items: center;
|
|
11
|
+
padding: 4rem 2rem 2rem;
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
|
|
14
|
+
&__header {
|
|
15
|
+
text-align: center;
|
|
16
|
+
margin-bottom: 3rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&__title {
|
|
20
|
+
font-size: 2rem;
|
|
21
|
+
font-weight: 700;
|
|
22
|
+
color: #f1f5f9;
|
|
23
|
+
margin: 0 0 0.5rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&__subtitle {
|
|
27
|
+
font-size: 1rem;
|
|
28
|
+
color: #94a3b8;
|
|
29
|
+
margin: 0;
|
|
30
|
+
max-width: 480px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
&__grid {
|
|
34
|
+
display: grid;
|
|
35
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
36
|
+
gap: 1.5rem;
|
|
37
|
+
width: 100%;
|
|
38
|
+
max-width: 960px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
&__card {
|
|
42
|
+
background: #1e293b;
|
|
43
|
+
border: 1px solid #334155;
|
|
44
|
+
border-radius: 12px;
|
|
45
|
+
padding: 1.75rem;
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
transition:
|
|
48
|
+
transform 0.15s ease,
|
|
49
|
+
border-color 0.2s ease,
|
|
50
|
+
box-shadow 0.2s ease;
|
|
51
|
+
|
|
52
|
+
&:hover {
|
|
53
|
+
transform: translateY(-3px);
|
|
54
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&__card-name {
|
|
59
|
+
font-size: 1.25rem;
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
margin: 0 0 0.5rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
&__card-desc {
|
|
65
|
+
font-size: 0.875rem;
|
|
66
|
+
color: #94a3b8;
|
|
67
|
+
margin: 0 0 1rem;
|
|
68
|
+
line-height: 1.5;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
&__card-count {
|
|
72
|
+
font-size: 0.75rem;
|
|
73
|
+
color: #64748b;
|
|
74
|
+
display: flex;
|
|
75
|
+
align-items: center;
|
|
76
|
+
gap: 0.35rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
&__dot {
|
|
80
|
+
width: 8px;
|
|
81
|
+
height: 8px;
|
|
82
|
+
border-radius: 50%;
|
|
83
|
+
display: inline-block;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { categories } from "../../registry";
|
|
4
|
+
import playgroundConfig from "../../playground.config";
|
|
5
|
+
import "./Landing.scss";
|
|
6
|
+
|
|
7
|
+
const Landing: React.FC = () => {
|
|
8
|
+
const navigate = useNavigate();
|
|
9
|
+
|
|
10
|
+
const handlePickCategory = (categoryId: string, firstPluginId: string) => {
|
|
11
|
+
navigate(`/${categoryId}/${firstPluginId}`);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="landing">
|
|
16
|
+
<div className="landing__header">
|
|
17
|
+
<h1 className="landing__title">{playgroundConfig.title}</h1>
|
|
18
|
+
<p className="landing__subtitle">{playgroundConfig.subtitle}</p>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div className="landing__grid">
|
|
22
|
+
{categories.map((cat) => {
|
|
23
|
+
const firstPlugin = cat.plugins[0];
|
|
24
|
+
if (!firstPlugin) return null;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
key={cat.id}
|
|
29
|
+
className="landing__card"
|
|
30
|
+
style={{ borderColor: cat.accent }}
|
|
31
|
+
onClick={() => handlePickCategory(cat.id, firstPlugin.id)}
|
|
32
|
+
onMouseEnter={(e) => {
|
|
33
|
+
(e.currentTarget as HTMLElement).style.borderColor = cat.accent;
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
<h2 className="landing__card-name" style={{ color: cat.accent }}>
|
|
37
|
+
{cat.name}
|
|
38
|
+
</h2>
|
|
39
|
+
<p className="landing__card-desc">{cat.description}</p>
|
|
40
|
+
<span className="landing__card-count">
|
|
41
|
+
<span
|
|
42
|
+
className="landing__dot"
|
|
43
|
+
style={{ background: cat.accent }}
|
|
44
|
+
/>
|
|
45
|
+
{cat.plugins.length} demo{cat.plugins.length !== 1 ? "s" : ""}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
})}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export default Landing;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useDispatch, useSelector } from "react-redux";
|
|
3
|
+
import { useNavigate } from "react-router-dom";
|
|
4
|
+
import { type RootState } from "../store/store";
|
|
5
|
+
import {
|
|
6
|
+
nextStep,
|
|
7
|
+
incrementPass,
|
|
8
|
+
resetSimulation,
|
|
9
|
+
} from "../store/slices/simulationSlice";
|
|
10
|
+
import type { DemoPlugin, DemoStep } from "../types/ModelPlugin";
|
|
11
|
+
import type { PluginCategory } from "../registry";
|
|
12
|
+
import StepIndicator from "./StepIndicator/StepIndicator";
|
|
13
|
+
|
|
14
|
+
interface ShellProps {
|
|
15
|
+
plugin: DemoPlugin;
|
|
16
|
+
category: PluginCategory;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Helper to normalize steps to objects
|
|
20
|
+
const normalizeSteps = (steps: (string | DemoStep)[]): DemoStep[] => {
|
|
21
|
+
return steps.map((s) =>
|
|
22
|
+
typeof s === "string" ? { label: s, autoAdvance: false } : s,
|
|
23
|
+
);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const Shell: React.FC<ShellProps> = ({ plugin, category }) => {
|
|
27
|
+
const dispatch = useDispatch();
|
|
28
|
+
const navigate = useNavigate();
|
|
29
|
+
const simulationState = useSelector((state: RootState) => state.simulation);
|
|
30
|
+
|
|
31
|
+
// Use the plugin's selector to get its specific state
|
|
32
|
+
const modelState = useSelector(plugin.selector);
|
|
33
|
+
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
// Reset shared simulation state (step 0, pass 0)
|
|
36
|
+
dispatch(resetSimulation());
|
|
37
|
+
// Initialize the plugin on mount
|
|
38
|
+
plugin.init(dispatch);
|
|
39
|
+
}, [dispatch, plugin]);
|
|
40
|
+
|
|
41
|
+
const rawSteps = plugin.getSteps(modelState);
|
|
42
|
+
const steps = normalizeSteps(rawSteps);
|
|
43
|
+
const { currentStep, passCount } = simulationState;
|
|
44
|
+
|
|
45
|
+
// Track if the current step is actively processing (animating)
|
|
46
|
+
// Default to true when entering a step, release when animation completes
|
|
47
|
+
const [isProcessing, setIsProcessing] = React.useState(true);
|
|
48
|
+
|
|
49
|
+
// Reset processing state when step changes
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
setIsProcessing(true);
|
|
52
|
+
}, [currentStep]);
|
|
53
|
+
|
|
54
|
+
const handleAnimationComplete = () => {
|
|
55
|
+
const currentConfig = steps[currentStep];
|
|
56
|
+
|
|
57
|
+
// If autoAdvance is true, proceed automatically.
|
|
58
|
+
// Otherwise, unlock the "Next" button (isProcessing = false).
|
|
59
|
+
if (currentConfig?.autoAdvance) {
|
|
60
|
+
dispatch(nextStep(steps.length));
|
|
61
|
+
} else {
|
|
62
|
+
setIsProcessing(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="app">
|
|
68
|
+
<header className="app-header">
|
|
69
|
+
<div className="app-header__nav">
|
|
70
|
+
<button
|
|
71
|
+
className="app-header__back"
|
|
72
|
+
onClick={() => navigate("/")}
|
|
73
|
+
title="Back to topics"
|
|
74
|
+
>
|
|
75
|
+
←
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
<div className="app-header__text">
|
|
79
|
+
<div className="app-header__breadcrumb">
|
|
80
|
+
<span
|
|
81
|
+
className="app-header__category"
|
|
82
|
+
style={{ color: category.accent }}
|
|
83
|
+
>
|
|
84
|
+
{category.name}
|
|
85
|
+
</span>
|
|
86
|
+
{category.plugins.length > 1 && (
|
|
87
|
+
<>
|
|
88
|
+
<span className="app-header__sep">/</span>
|
|
89
|
+
<select
|
|
90
|
+
className="app-header__select"
|
|
91
|
+
value={plugin.id}
|
|
92
|
+
onChange={(e) =>
|
|
93
|
+
navigate(`/${category.id}/${e.target.value}`)
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
{category.plugins.map((p) => (
|
|
97
|
+
<option key={p.id} value={p.id}>
|
|
98
|
+
{p.name}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</select>
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
{category.plugins.length <= 1 && (
|
|
105
|
+
<>
|
|
106
|
+
<span className="app-header__sep">/</span>
|
|
107
|
+
<span className="app-header__plugin-name">{plugin.name}</span>
|
|
108
|
+
</>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
<p>{plugin.description}</p>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</header>
|
|
115
|
+
|
|
116
|
+
<div className="main-content">
|
|
117
|
+
<StepIndicator
|
|
118
|
+
steps={steps.map((s) => s.label)}
|
|
119
|
+
currentStep={currentStep}
|
|
120
|
+
onNextStep={() => dispatch(nextStep(steps.length))}
|
|
121
|
+
onReset={() => {
|
|
122
|
+
dispatch(resetSimulation());
|
|
123
|
+
dispatch(incrementPass());
|
|
124
|
+
}}
|
|
125
|
+
passCount={passCount}
|
|
126
|
+
// Button is disabled if processing (waiting for anim)
|
|
127
|
+
isProcessing={isProcessing}
|
|
128
|
+
nextButtonConfig={{
|
|
129
|
+
text: steps[currentStep]?.nextButtonText,
|
|
130
|
+
processingText: steps[currentStep]?.processingText,
|
|
131
|
+
color: steps[currentStep]?.nextButtonColor,
|
|
132
|
+
}}
|
|
133
|
+
restartButtonConfig={plugin.restartConfig}
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<div className="visualization-container">
|
|
137
|
+
<plugin.Component onAnimationComplete={handleAnimationComplete} />
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default Shell;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
.step-indicator {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 0;
|
|
5
|
+
padding: 2rem;
|
|
6
|
+
background: #1f2937; // Gray-800
|
|
7
|
+
border-right: 1px solid #374151; // Gray-700
|
|
8
|
+
width: 300px;
|
|
9
|
+
min-width: 300px;
|
|
10
|
+
overflow-y: auto;
|
|
11
|
+
|
|
12
|
+
h2 {
|
|
13
|
+
color: #f3f4f6;
|
|
14
|
+
margin-top: 0;
|
|
15
|
+
margin-bottom: 2rem;
|
|
16
|
+
font-size: 1.5rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.step-list {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
position: relative;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.step-item {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: 1rem;
|
|
28
|
+
position: relative;
|
|
29
|
+
padding-bottom: 2rem;
|
|
30
|
+
|
|
31
|
+
&:last-child {
|
|
32
|
+
padding-bottom: 0;
|
|
33
|
+
|
|
34
|
+
.step-line {
|
|
35
|
+
display: none;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&.completed {
|
|
40
|
+
.step-circle {
|
|
41
|
+
background: #10b981; // Emerald-500
|
|
42
|
+
border-color: #10b981;
|
|
43
|
+
color: white;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.step-line {
|
|
47
|
+
background: #10b981;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.step-label {
|
|
51
|
+
color: #d1d5db; // Gray-300
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
&.active {
|
|
56
|
+
.step-circle {
|
|
57
|
+
background: #4f46e5; // Indigo-600
|
|
58
|
+
border-color: #4f46e5;
|
|
59
|
+
color: white;
|
|
60
|
+
box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.3);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.step-label {
|
|
64
|
+
color: #fff;
|
|
65
|
+
font-weight: 600;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&.pending {
|
|
70
|
+
.step-circle {
|
|
71
|
+
background: transparent;
|
|
72
|
+
border-color: #4b5563; // Gray-600
|
|
73
|
+
color: #9ca3af; // Gray-400
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.step-label {
|
|
77
|
+
color: #6b7280; // Gray-500
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.step-marker {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
align-items: center;
|
|
86
|
+
width: 24px;
|
|
87
|
+
flex-shrink: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.step-circle {
|
|
91
|
+
width: 24px;
|
|
92
|
+
height: 24px;
|
|
93
|
+
border-radius: 50%;
|
|
94
|
+
border: 2px solid;
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
background: #1f2937;
|
|
100
|
+
z-index: 2;
|
|
101
|
+
transition: all 0.3s ease;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.step-line {
|
|
105
|
+
width: 2px;
|
|
106
|
+
flex: 1;
|
|
107
|
+
background: #374151; // Gray-700
|
|
108
|
+
margin-top: 4px;
|
|
109
|
+
margin-bottom: 4px;
|
|
110
|
+
transition: background 0.3s ease;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.step-content {
|
|
114
|
+
padding-top: 2px;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.step-label {
|
|
118
|
+
font-size: 1rem;
|
|
119
|
+
transition: color 0.3s ease;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.controls {
|
|
123
|
+
margin-top: auto;
|
|
124
|
+
padding-top: 2rem;
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
|
|
128
|
+
button {
|
|
129
|
+
background: #10b981; // Emerald-500
|
|
130
|
+
color: white;
|
|
131
|
+
border: none;
|
|
132
|
+
padding: 0.75rem 2rem;
|
|
133
|
+
border-radius: 8px;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
font-size: 1rem;
|
|
136
|
+
cursor: pointer;
|
|
137
|
+
transition: background 0.2s;
|
|
138
|
+
width: 100%;
|
|
139
|
+
|
|
140
|
+
&:hover {
|
|
141
|
+
background: #059669; // Emerald-600
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&:disabled {
|
|
145
|
+
background: #374151;
|
|
146
|
+
cursor: not-allowed;
|
|
147
|
+
opacity: 0.5;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import './StepIndicator.scss';
|
|
3
|
+
|
|
4
|
+
interface StepIndicatorProps {
|
|
5
|
+
steps: string[];
|
|
6
|
+
currentStep: number;
|
|
7
|
+
onNextStep: () => void;
|
|
8
|
+
onReset: () => void;
|
|
9
|
+
passCount?: number;
|
|
10
|
+
isProcessing?: boolean;
|
|
11
|
+
nextButtonConfig?: {
|
|
12
|
+
text?: string;
|
|
13
|
+
processingText?: string;
|
|
14
|
+
color?: string;
|
|
15
|
+
};
|
|
16
|
+
restartButtonConfig?: {
|
|
17
|
+
text?: string;
|
|
18
|
+
color?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const StepIndicator: React.FC<StepIndicatorProps> = ({ steps, currentStep, onNextStep, onReset, passCount = 1, isProcessing = false, nextButtonConfig, restartButtonConfig }) => {
|
|
23
|
+
const isFinished = currentStep >= steps.length;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="step-indicator">
|
|
27
|
+
<h2>Process Flow <span style={{ fontSize: '0.8em', opacity: 0.6, marginLeft: '10px' }}>Pass {passCount}</span></h2>
|
|
28
|
+
|
|
29
|
+
<div className="step-list">
|
|
30
|
+
{steps.map((step, index) => {
|
|
31
|
+
let status = 'pending';
|
|
32
|
+
if (index < currentStep) status = 'completed';
|
|
33
|
+
if (index === currentStep) status = 'active';
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div key={index} className={`step-item ${status}`}>
|
|
37
|
+
<div className="step-marker">
|
|
38
|
+
<div className="step-circle">
|
|
39
|
+
{status === 'completed' ? '✓' : index + 1}
|
|
40
|
+
</div>
|
|
41
|
+
{index < steps.length - 1 && <div className="step-line" />}
|
|
42
|
+
</div>
|
|
43
|
+
<div className="step-content">
|
|
44
|
+
<div className="step-label">{step}</div>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div className="controls">
|
|
52
|
+
{isFinished ? (
|
|
53
|
+
<button onClick={onReset} style={{ background: restartButtonConfig?.color || '#4f46e5' }}>
|
|
54
|
+
{restartButtonConfig?.text || 'Start Next Pass'}
|
|
55
|
+
</button>
|
|
56
|
+
) : (
|
|
57
|
+
<button
|
|
58
|
+
onClick={onNextStep}
|
|
59
|
+
disabled={isProcessing}
|
|
60
|
+
style={{
|
|
61
|
+
...(isProcessing ? { opacity: 0.5, cursor: 'not-allowed' } : {}),
|
|
62
|
+
...(nextButtonConfig?.color ? { background: nextButtonConfig.color } : {})
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{isProcessing ? (nextButtonConfig?.processingText || 'Processing...') : (nextButtonConfig?.text || 'Next Step')}
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default StepIndicator;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
.viz-info-beacon {
|
|
2
|
+
position: absolute;
|
|
3
|
+
inset: 0;
|
|
4
|
+
pointer-events: none;
|
|
5
|
+
z-index: 4;
|
|
6
|
+
overflow: visible;
|
|
7
|
+
|
|
8
|
+
&__hover-region {
|
|
9
|
+
pointer-events: auto;
|
|
10
|
+
cursor: default;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
&__indicator {
|
|
14
|
+
pointer-events: none;
|
|
15
|
+
cursor: pointer;
|
|
16
|
+
opacity: 0;
|
|
17
|
+
transition:
|
|
18
|
+
opacity 0.22s ease,
|
|
19
|
+
transform 0.22s ease;
|
|
20
|
+
transform-origin: center;
|
|
21
|
+
transform: scale(0.85);
|
|
22
|
+
outline: none;
|
|
23
|
+
|
|
24
|
+
&--visible {
|
|
25
|
+
opacity: 1;
|
|
26
|
+
pointer-events: auto;
|
|
27
|
+
transform: scale(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&:hover circle:nth-child(2),
|
|
31
|
+
&:focus-visible circle:nth-child(2) {
|
|
32
|
+
filter: brightness(1.3);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&:focus-visible {
|
|
36
|
+
outline: 2px solid currentColor;
|
|
37
|
+
outline-offset: 4px;
|
|
38
|
+
border-radius: 50%;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import "./VizInfoBeacon.scss";
|
|
3
|
+
|
|
4
|
+
export interface VizSceneRect {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VizScenePoint {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VizInfoBeaconProps {
|
|
17
|
+
viewWidth: number;
|
|
18
|
+
viewHeight: number;
|
|
19
|
+
hoverRegion: VizSceneRect;
|
|
20
|
+
indicatorPosition: VizScenePoint;
|
|
21
|
+
ariaLabel: string;
|
|
22
|
+
onActivate: () => void;
|
|
23
|
+
accentColor?: string;
|
|
24
|
+
label?: string;
|
|
25
|
+
hideDelayMs?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const VizInfoBeacon: React.FC<VizInfoBeaconProps> = ({
|
|
30
|
+
viewWidth,
|
|
31
|
+
viewHeight,
|
|
32
|
+
hoverRegion,
|
|
33
|
+
indicatorPosition,
|
|
34
|
+
ariaLabel,
|
|
35
|
+
onActivate,
|
|
36
|
+
accentColor = "#60a5fa",
|
|
37
|
+
label = "ⓘ",
|
|
38
|
+
hideDelayMs = 3000,
|
|
39
|
+
className,
|
|
40
|
+
}) => {
|
|
41
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
42
|
+
const hideTimerRef = useRef<number | null>(null);
|
|
43
|
+
const isHoveringButtonRef = useRef(false);
|
|
44
|
+
|
|
45
|
+
const clearHideTimer = useCallback(() => {
|
|
46
|
+
if (hideTimerRef.current !== null) {
|
|
47
|
+
window.clearTimeout(hideTimerRef.current);
|
|
48
|
+
hideTimerRef.current = null;
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const show = useCallback(() => {
|
|
53
|
+
clearHideTimer();
|
|
54
|
+
setIsVisible(true);
|
|
55
|
+
}, [clearHideTimer]);
|
|
56
|
+
|
|
57
|
+
const scheduleHide = useCallback(() => {
|
|
58
|
+
clearHideTimer();
|
|
59
|
+
hideTimerRef.current = window.setTimeout(() => {
|
|
60
|
+
if (!isHoveringButtonRef.current) {
|
|
61
|
+
setIsVisible(false);
|
|
62
|
+
}
|
|
63
|
+
hideTimerRef.current = null;
|
|
64
|
+
}, hideDelayMs);
|
|
65
|
+
}, [clearHideTimer, hideDelayMs]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
return () => clearHideTimer();
|
|
69
|
+
}, [clearHideTimer]);
|
|
70
|
+
|
|
71
|
+
const onButtonEnter = useCallback(() => {
|
|
72
|
+
isHoveringButtonRef.current = true;
|
|
73
|
+
clearHideTimer();
|
|
74
|
+
setIsVisible(true);
|
|
75
|
+
}, [clearHideTimer]);
|
|
76
|
+
|
|
77
|
+
const onButtonLeave = useCallback(() => {
|
|
78
|
+
isHoveringButtonRef.current = false;
|
|
79
|
+
scheduleHide();
|
|
80
|
+
}, [scheduleHide]);
|
|
81
|
+
|
|
82
|
+
const ix = indicatorPosition.x;
|
|
83
|
+
const iy = indicatorPosition.y;
|
|
84
|
+
const R = 13;
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<svg
|
|
88
|
+
className={`viz-info-beacon ${className ?? ""}`.trim()}
|
|
89
|
+
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
|
|
90
|
+
preserveAspectRatio="xMidYMid meet"
|
|
91
|
+
>
|
|
92
|
+
{/* Invisible hover region over the node */}
|
|
93
|
+
<rect
|
|
94
|
+
className="viz-info-beacon__hover-region"
|
|
95
|
+
x={hoverRegion.x}
|
|
96
|
+
y={hoverRegion.y}
|
|
97
|
+
width={hoverRegion.width}
|
|
98
|
+
height={hoverRegion.height}
|
|
99
|
+
fill="transparent"
|
|
100
|
+
onPointerEnter={show}
|
|
101
|
+
onPointerLeave={scheduleHide}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
{/* (i) indicator circle — appears on hover, sticks when hovered itself */}
|
|
105
|
+
<g
|
|
106
|
+
className={`viz-info-beacon__indicator ${isVisible ? "viz-info-beacon__indicator--visible" : ""}`}
|
|
107
|
+
onPointerEnter={onButtonEnter}
|
|
108
|
+
onPointerLeave={onButtonLeave}
|
|
109
|
+
onClick={(e) => {
|
|
110
|
+
e.stopPropagation();
|
|
111
|
+
onActivate();
|
|
112
|
+
}}
|
|
113
|
+
role="button"
|
|
114
|
+
tabIndex={0}
|
|
115
|
+
aria-label={ariaLabel}
|
|
116
|
+
onKeyDown={(e) => {
|
|
117
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
onActivate();
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
onFocus={show}
|
|
123
|
+
onBlur={onButtonLeave}
|
|
124
|
+
>
|
|
125
|
+
{/* Larger invisible hit area */}
|
|
126
|
+
<circle cx={ix} cy={iy} r={R + 6} fill="transparent" />
|
|
127
|
+
{/* Background circle */}
|
|
128
|
+
<circle
|
|
129
|
+
cx={ix}
|
|
130
|
+
cy={iy}
|
|
131
|
+
r={R}
|
|
132
|
+
fill="#0f172a"
|
|
133
|
+
stroke={accentColor}
|
|
134
|
+
strokeWidth={1.6}
|
|
135
|
+
opacity={0.95}
|
|
136
|
+
/>
|
|
137
|
+
{/* Label text */}
|
|
138
|
+
<text
|
|
139
|
+
x={ix}
|
|
140
|
+
y={iy}
|
|
141
|
+
textAnchor="middle"
|
|
142
|
+
dominantBaseline="central"
|
|
143
|
+
fill={accentColor}
|
|
144
|
+
fontSize={14}
|
|
145
|
+
fontWeight={700}
|
|
146
|
+
fontFamily="Georgia, 'Times New Roman', serif"
|
|
147
|
+
fontStyle="italic"
|
|
148
|
+
style={{ pointerEvents: "none" }}
|
|
149
|
+
>
|
|
150
|
+
{label}
|
|
151
|
+
</text>
|
|
152
|
+
</g>
|
|
153
|
+
</svg>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export default VizInfoBeacon;
|