mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
* Introduced BevyScene
React component in landing-component
for rendering a 3D cockpit visualization.
* Included WebAssembly asset `yachtpit.js` for cockpit functionality. * Added Bevy MIT license file. * Implemented a service worker to cache assets locally instead of fetching them remotely. * Added collapsible functionality to **Tweakbox** and included the `@chakra-ui/icons` dependency. * Applied the `hidden` prop to the Tweakbox Heading for better accessibility. * Refactored **Particles** component for improved performance, clarity, and maintainability. * Introduced helper functions for particle creation and count management. * Added responsive resizing with particle repositioning. * Optimized animation updates, including velocity adjustments for speed changes. * Ensured canvas size and particle state are cleanly managed on component unmount.
This commit is contained in:

committed by
Geoff Seemueller

parent
858282929c
commit
0ff8b5c03e
@@ -0,0 +1,45 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface BevySceneProps {
|
||||
speed?: number; // transition seconds
|
||||
intensity?: number;
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
export const BevyScene: React.FC<BevySceneProps> = ({ speed = 1, intensity = 1, glow = false }) => {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = '/yachtpit.js';
|
||||
script.type = 'module';
|
||||
document.body.appendChild(script);
|
||||
script.onload = loaded => {
|
||||
console.log('loaded', loaded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
pos="absolute"
|
||||
inset={0}
|
||||
zIndex={0}
|
||||
pointerEvents="none"
|
||||
opacity={Math.min(Math.max(intensity, 0), 1)}
|
||||
filter={glow ? 'blur(1px)' : 'none'}
|
||||
transition={`opacity ${speed}s ease-in-out`}
|
||||
>
|
||||
<script type="module"></script>
|
||||
<canvas id="yachtpit-canvas" width="1280" height="720"></canvas>
|
||||
{/*<iframe*/}
|
||||
{/* src="/yachtpit.html"*/}
|
||||
{/* style={{*/}
|
||||
{/* width: '100%',*/}
|
||||
{/* height: '100%',*/}
|
||||
{/* border: 'none',*/}
|
||||
{/* backgroundColor: 'transparent',*/}
|
||||
{/* }}*/}
|
||||
{/* title="Bevy Scene"*/}
|
||||
{/*/>*/}
|
||||
</Box>
|
||||
);
|
||||
};
|
@@ -0,0 +1,102 @@
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { BevyScene } from './BevyScene.tsx';
|
||||
import { MatrixRain } from './MatrixRain.tsx';
|
||||
import Particles from './Particles.tsx';
|
||||
import Tweakbox from './Tweakbox.tsx';
|
||||
|
||||
export const LandingComponent: React.FC = () => {
|
||||
const [speed, setSpeed] = useState(0.2);
|
||||
const [intensity, setIntensity] = useState(0.5);
|
||||
const [particles, setParticles] = useState(false);
|
||||
const [glow, setGlow] = useState(false);
|
||||
const [matrixRain, setMatrixRain] = useState(false);
|
||||
const [bevyScene, setBevyScene] = useState(true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="section"
|
||||
bg="background.primary"
|
||||
w="100%"
|
||||
h="100vh"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
<Box
|
||||
position="fixed"
|
||||
bottom="24px"
|
||||
right="24px"
|
||||
maxWidth="300px"
|
||||
minWidth="200px"
|
||||
zIndex={1000}
|
||||
>
|
||||
<Tweakbox
|
||||
sliders={{
|
||||
speed: {
|
||||
value: speed,
|
||||
onChange: setSpeed,
|
||||
label: 'Animation Speed',
|
||||
min: 0.01,
|
||||
max: 0.99,
|
||||
step: 0.01,
|
||||
ariaLabel: 'animation-speed',
|
||||
},
|
||||
intensity: {
|
||||
value: intensity,
|
||||
onChange: setIntensity,
|
||||
label: 'Effect Intensity',
|
||||
min: 0.01,
|
||||
max: 0.99,
|
||||
step: 0.01,
|
||||
ariaLabel: 'effect-intensity',
|
||||
},
|
||||
}}
|
||||
switches={{
|
||||
particles: {
|
||||
value: particles,
|
||||
onChange(enabled) {
|
||||
if (enabled) {
|
||||
setMatrixRain(!enabled);
|
||||
setBevyScene(!enabled);
|
||||
}
|
||||
setParticles(enabled);
|
||||
},
|
||||
label: 'Particles',
|
||||
},
|
||||
matrixRain: {
|
||||
value: matrixRain,
|
||||
onChange(enabled) {
|
||||
if (enabled) {
|
||||
setParticles(!enabled);
|
||||
setBevyScene(!enabled);
|
||||
}
|
||||
setMatrixRain(enabled);
|
||||
},
|
||||
label: 'Matrix Rain',
|
||||
},
|
||||
bevyScene: {
|
||||
value: bevyScene,
|
||||
onChange(enabled) {
|
||||
if (enabled) {
|
||||
setParticles(!enabled);
|
||||
setMatrixRain(!enabled);
|
||||
}
|
||||
setBevyScene(enabled);
|
||||
},
|
||||
label: 'Bevy Scene',
|
||||
},
|
||||
glow: {
|
||||
value: glow,
|
||||
onChange: setGlow,
|
||||
label: 'Glow Effect',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!particles && !matrixRain && <BevyScene speed={speed} intensity={intensity} glow={glow} />}
|
||||
{!particles && matrixRain && <MatrixRain speed={speed} intensity={intensity} glow={glow} />}
|
||||
{particles && <Particles particles glow speed={speed} intensity={intensity} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
117
packages/client/src/components/landing-component/MatrixRain.tsx
Normal file
117
packages/client/src/components/landing-component/MatrixRain.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useBreakpointValue, useTheme } from '@chakra-ui/react';
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
const MATRIX_CHARS =
|
||||
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
interface MatrixRainProps {
|
||||
speed?: number;
|
||||
glow?: boolean;
|
||||
intensity?: number;
|
||||
}
|
||||
|
||||
export const MatrixRain: React.FC<MatrixRainProps> = ({
|
||||
speed = 1,
|
||||
glow = false,
|
||||
intensity = 1,
|
||||
}) => {
|
||||
const fontSize = useBreakpointValue({ base: 14, md: 18, lg: 22 }) ?? 14;
|
||||
const theme = useTheme();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const dropsRef = useRef<number[]>([]);
|
||||
const columnsRef = useRef<number>(0);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
background: theme.colors.background.primary,
|
||||
textAccent: theme.colors.text.accent,
|
||||
}),
|
||||
[theme.colors.background.primary, theme.colors.text.accent],
|
||||
);
|
||||
|
||||
const colorsRef = useRef(colors);
|
||||
colorsRef.current = colors;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
const newColumns = Math.floor(canvas.width / fontSize);
|
||||
if (newColumns !== columnsRef.current) {
|
||||
columnsRef.current = newColumns;
|
||||
const newDrops: number[] = [];
|
||||
|
||||
for (let i = 0; i < newColumns; i++) {
|
||||
if (i < dropsRef.current.length) {
|
||||
newDrops[i] = dropsRef.current[i];
|
||||
} else {
|
||||
newDrops[i] = Math.random() * (canvas.height / fontSize);
|
||||
}
|
||||
}
|
||||
dropsRef.current = newDrops;
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
if (dropsRef.current.length === 0) {
|
||||
const columns = Math.floor(canvas.width / fontSize);
|
||||
columnsRef.current = columns;
|
||||
|
||||
for (let i = 0; i < columns; i++) {
|
||||
dropsRef.current[i] = Math.random() * (canvas.height / fontSize);
|
||||
}
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
if (!ctx || !canvas) return;
|
||||
|
||||
const currentColors = colorsRef.current;
|
||||
|
||||
ctx.fillStyle = currentColors.background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
for (let i = 0; i < dropsRef.current.length; i++) {
|
||||
const text = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)];
|
||||
const x = i * fontSize;
|
||||
const y = dropsRef.current[i] * fontSize;
|
||||
|
||||
ctx.fillStyle = currentColors.textAccent;
|
||||
if (glow) {
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = currentColors.textAccent;
|
||||
}
|
||||
ctx.fillText(text, x, y);
|
||||
|
||||
if (y > canvas.height) {
|
||||
dropsRef.current[i] = -Math.random() * 5;
|
||||
} else {
|
||||
dropsRef.current[i] += (0.1 + Math.random() * 0.5) * speed * intensity;
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
};
|
||||
}, [fontSize, speed, glow, intensity]);
|
||||
|
||||
return <canvas ref={canvasRef} style={{ display: 'block' }} />;
|
||||
};
|
161
packages/client/src/components/landing-component/Particles.tsx
Normal file
161
packages/client/src/components/landing-component/Particles.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Box, useTheme } from '@chakra-ui/react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface ParticlesProps {
|
||||
speed: number;
|
||||
intensity: number;
|
||||
particles: boolean;
|
||||
glow: boolean;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const Particles: React.FC<ParticlesProps> = ({ speed, intensity, particles, glow }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||
const theme = useTheme();
|
||||
|
||||
// Helper function to create a single particle with proper canvas dimensions
|
||||
const createParticle = (canvas: HTMLCanvasElement): Particle => ({
|
||||
x: Math.random() * canvas.parentElement!.getBoundingClientRect().width,
|
||||
y: Math.random() * canvas.parentElement!.getBoundingClientRect().height,
|
||||
vx: (Math.random() - 0.5) * speed,
|
||||
vy: (Math.random() - 0.5) * speed,
|
||||
size: Math.random() * 3 + 1,
|
||||
});
|
||||
|
||||
// Main animation effect
|
||||
useEffect(() => {
|
||||
if (!particles) {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
particlesRef.current = []; // Clear particles when disabled
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
// Reposition existing particles that are outside new bounds
|
||||
particlesRef.current.forEach(particle => {
|
||||
if (particle.x > canvas.width) particle.x = Math.random() * canvas.width;
|
||||
if (particle.y > canvas.height) particle.y = Math.random() * canvas.height;
|
||||
});
|
||||
};
|
||||
|
||||
const ensureParticleCount = () => {
|
||||
const targetCount = Math.floor(intensity * 100);
|
||||
const currentCount = particlesRef.current.length;
|
||||
|
||||
if (currentCount < targetCount) {
|
||||
// Add new particles
|
||||
const newParticles = Array.from({ length: targetCount - currentCount }, () =>
|
||||
createParticle(canvas),
|
||||
);
|
||||
particlesRef.current = [...particlesRef.current, ...newParticles];
|
||||
} else if (currentCount > targetCount) {
|
||||
// Remove excess particles
|
||||
particlesRef.current = particlesRef.current.slice(0, targetCount);
|
||||
}
|
||||
};
|
||||
|
||||
const updateParticles = () => {
|
||||
particlesRef.current.forEach(particle => {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
});
|
||||
};
|
||||
|
||||
const drawParticles = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = theme.colors.text.accent;
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
|
||||
if (glow) {
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = 'white';
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
particlesRef.current.forEach(particle => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
updateParticles();
|
||||
drawParticles();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
resizeCanvas();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
resizeCanvas(); // Set canvas size first
|
||||
ensureParticleCount(); // Then create particles with proper dimensions
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = undefined;
|
||||
}
|
||||
};
|
||||
}, [particles, intensity, speed, glow, theme.colors.text.accent]);
|
||||
|
||||
// Separate effect for speed changes - update existing particle velocities
|
||||
useEffect(() => {
|
||||
if (!particles) return;
|
||||
|
||||
particlesRef.current.forEach(particle => {
|
||||
const currentSpeed = Math.sqrt(particle.vx * particle.vx + particle.vy * particle.vy);
|
||||
if (currentSpeed > 0) {
|
||||
const normalizedVx = particle.vx / currentSpeed;
|
||||
const normalizedVy = particle.vy / currentSpeed;
|
||||
particle.vx = normalizedVx * speed;
|
||||
particle.vy = normalizedVy * speed;
|
||||
} else {
|
||||
particle.vx = (Math.random() - 0.5) * speed;
|
||||
particle.vy = (Math.random() - 0.5) * speed;
|
||||
}
|
||||
});
|
||||
}, [speed, particles]);
|
||||
|
||||
return (
|
||||
<Box zIndex={0} pointerEvents={'none'}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: particles ? 'block' : 'none', pointerEvents: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Particles;
|
111
packages/client/src/components/landing-component/Tweakbox.tsx
Normal file
111
packages/client/src/components/landing-component/Tweakbox.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
GridItem,
|
||||
Heading,
|
||||
Slider,
|
||||
SliderTrack,
|
||||
SliderFilledTrack,
|
||||
SliderThumb,
|
||||
Text,
|
||||
Switch,
|
||||
Collapse,
|
||||
IconButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface SliderControl {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
interface SwitchControl {
|
||||
value: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
label: string;
|
||||
exclusive?: boolean;
|
||||
}
|
||||
|
||||
interface TweakboxProps {
|
||||
sliders: {
|
||||
speed: SliderControl;
|
||||
intensity: SliderControl;
|
||||
};
|
||||
switches: {
|
||||
particles: SwitchControl;
|
||||
glow: SwitchControl;
|
||||
} & Record<string, SwitchControl>;
|
||||
}
|
||||
|
||||
const Tweakbox = observer(({ sliders, switches }: TweakboxProps) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="flex-start">
|
||||
<IconButton
|
||||
aria-label="Toggle controls"
|
||||
borderRadius="lg"
|
||||
bg="whiteAlpha.300"
|
||||
backdropFilter="blur(10px)"
|
||||
boxShadow="xl"
|
||||
icon={isCollapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
size="sm"
|
||||
marginRight={2}
|
||||
/>
|
||||
<Collapse in={!isCollapsed} style={{ width: '100%' }}>
|
||||
<Box p={4} borderRadius="lg" bg="whiteAlpha.100" backdropFilter="blur(10px)" boxShadow="xl">
|
||||
<Grid templateColumns="1fr" gap={4}>
|
||||
<GridItem>
|
||||
<Heading hidden={true} size="sm" mb={4} color="text.accent">
|
||||
Controls
|
||||
</Heading>
|
||||
</GridItem>
|
||||
{Object.keys(switches).map(key => {
|
||||
return (
|
||||
<GridItem key={key}>
|
||||
<Text mb={2} color="text.accent">
|
||||
{switches[key].label}
|
||||
</Text>
|
||||
<Switch
|
||||
isChecked={switches[key].value}
|
||||
onChange={e => switches[key].onChange(e.target.checked)}
|
||||
/>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
{Object.entries(sliders).map(([key, slider]) => (
|
||||
<GridItem key={key}>
|
||||
<Text mb={2} color="text.accent">
|
||||
{slider.label}
|
||||
</Text>
|
||||
<Slider
|
||||
aria-label={slider.ariaLabel}
|
||||
value={slider.value}
|
||||
min={slider.min}
|
||||
step={slider.step}
|
||||
max={slider.max}
|
||||
onChange={slider.onChange}
|
||||
>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default Tweakbox;
|
Reference in New Issue
Block a user