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.
Files changed (49) hide show
  1. package/index.js +171 -0
  2. package/package.json +44 -0
  3. package/template/.github/skills/vizcraft-playground/SKILL.md +573 -0
  4. package/template/README.md +59 -0
  5. package/template/eslint.config.js +23 -0
  6. package/template/index.html +13 -0
  7. package/template/package.json +37 -0
  8. package/template/public/vite.svg +1 -0
  9. package/template/scripts/generate-plugin.js +594 -0
  10. package/template/scripts/init-playground.js +119 -0
  11. package/template/src/App.scss +137 -0
  12. package/template/src/App.tsx +72 -0
  13. package/template/src/assets/react.svg +1 -0
  14. package/template/src/components/InfoModal/InfoModal.scss +211 -0
  15. package/template/src/components/InfoModal/InfoModal.tsx +85 -0
  16. package/template/src/components/Landing/Landing.scss +85 -0
  17. package/template/src/components/Landing/Landing.tsx +55 -0
  18. package/template/src/components/Shell.tsx +144 -0
  19. package/template/src/components/StepIndicator/StepIndicator.scss +151 -0
  20. package/template/src/components/StepIndicator/StepIndicator.tsx +73 -0
  21. package/template/src/components/VizInfoBeacon/VizInfoBeacon.scss +41 -0
  22. package/template/src/components/VizInfoBeacon/VizInfoBeacon.tsx +157 -0
  23. package/template/src/components/plugin-kit/CanvasStage.tsx +30 -0
  24. package/template/src/components/plugin-kit/ConceptPills.tsx +55 -0
  25. package/template/src/components/plugin-kit/PluginLayout.tsx +41 -0
  26. package/template/src/components/plugin-kit/SidePanel.tsx +69 -0
  27. package/template/src/components/plugin-kit/StageHeader.tsx +35 -0
  28. package/template/src/components/plugin-kit/StatBadge.tsx +35 -0
  29. package/template/src/components/plugin-kit/index.ts +42 -0
  30. package/template/src/components/plugin-kit/plugin-kit.scss +241 -0
  31. package/template/src/components/plugin-kit/useConceptModal.tsx +51 -0
  32. package/template/src/index.scss +81 -0
  33. package/template/src/main.tsx +14 -0
  34. package/template/src/playground.config.ts +27 -0
  35. package/template/src/plugins/hello-world/concepts.tsx +70 -0
  36. package/template/src/plugins/hello-world/helloWorldSlice.ts +29 -0
  37. package/template/src/plugins/hello-world/index.ts +48 -0
  38. package/template/src/plugins/hello-world/main.scss +32 -0
  39. package/template/src/plugins/hello-world/main.tsx +144 -0
  40. package/template/src/plugins/hello-world/useHelloWorldAnimation.ts +99 -0
  41. package/template/src/registry.ts +73 -0
  42. package/template/src/store/slices/simulationSlice.ts +47 -0
  43. package/template/src/store/store.ts +13 -0
  44. package/template/src/types/ModelPlugin.ts +55 -0
  45. package/template/src/utils/random.ts +11 -0
  46. package/template/tsconfig.app.json +35 -0
  47. package/template/tsconfig.json +7 -0
  48. package/template/tsconfig.node.json +26 -0
  49. 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;