diff options
| author | 2026-03-26 09:05:44 +0800 | |
|---|---|---|
| committer | 2026-03-26 09:05:44 +0800 | |
| commit | 18aceb4ddf01e964d0b81a4e926e42b72c64e355 (patch) | |
| tree | ceef83124ae966838f5620bbf37d0fc099d5f931 | |
| parent | b762558df79a9bae7ec67f1f98caa9ccebcda861 (diff) | |
| download | DropOut-18aceb4ddf01e964d0b81a4e926e42b72c64e355.tar.gz DropOut-18aceb4ddf01e964d0b81a4e926e42b72c64e355.zip | |
refactor(ui): rewrite particle background
| -rw-r--r-- | .changes/particle-background.md | 5 | ||||
| -rw-r--r-- | packages/ui/src/components/particle-background.tsx | 68 | ||||
| -rw-r--r-- | packages/ui/src/lib/effects/SaturnEffect.ts | 299 | ||||
| -rw-r--r-- | packages/ui/src/main.tsx | 28 | ||||
| -rw-r--r-- | packages/ui/src/pages/routes.ts | 25 |
5 files changed, 62 insertions, 363 deletions
diff --git a/.changes/particle-background.md b/.changes/particle-background.md new file mode 100644 index 0000000..ca8bdf5 --- /dev/null +++ b/.changes/particle-background.md @@ -0,0 +1,5 @@ +--- +"@dropout/ui": "patch:refactor" +--- + +Rewrite `ParticleBackground` to morden component design instead of global `window` api call. diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx index 2e0b15a..2bf6793 100644 --- a/packages/ui/src/components/particle-background.tsx +++ b/packages/ui/src/components/particle-background.tsx @@ -1,63 +1,55 @@ -import { useEffect, useRef } from "react"; -import { SaturnEffect } from "../lib/effects/SaturnEffect"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { SaturnEffect } from "@/lib/effects/saturn"; -export function ParticleBackground() { +const SaturnEffectContext = createContext<SaturnEffect | null>(null); + +export function useSaturnEffect() { + return useContext(SaturnEffectContext); +} + +export function ParticleBackground({ + children, +}: { + children?: React.ReactNode; +}) { const canvasRef = useRef<HTMLCanvasElement | null>(null); - const effectRef = useRef<SaturnEffect | null>(null); + const [effect, setEffect] = useState<SaturnEffect | null>(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - // Instantiate SaturnEffect and attach to canvas - let effect: SaturnEffect | null = null; + let saturnEffect: SaturnEffect | null = null; try { - effect = new SaturnEffect(canvas); - effectRef.current = effect; + saturnEffect = new SaturnEffect(canvas); + setEffect(saturnEffect); } catch (err) { - // If effect fails, silently degrade (keep background blank) - // eslint-disable-next-line no-console console.warn("SaturnEffect initialization failed:", err); } const resizeHandler = () => { - if (effectRef.current) { - try { - effectRef.current.resize(window.innerWidth, window.innerHeight); - } catch { - // ignore - } - } + saturnEffect?.resize(window.innerWidth, window.innerHeight); }; window.addEventListener("resize", resizeHandler); - // Expose getter for HomeView interactions (getSaturnEffect) - // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up - ( - window as unknown as { getSaturnEffect?: () => SaturnEffect | null } - ).getSaturnEffect = () => effectRef.current; - return () => { window.removeEventListener("resize", resizeHandler); - if (effectRef.current) { - try { - effectRef.current.destroy(); - } catch { - // ignore - } - } - effectRef.current = null; - ( - window as unknown as { getSaturnEffect?: () => SaturnEffect | null } - ).getSaturnEffect = undefined; + saturnEffect?.destroy(); + + setEffect(null); }; }, []); return ( - <canvas - ref={canvasRef} - className="absolute inset-0 z-0 pointer-events-none" - /> + <SaturnEffectContext.Provider value={effect}> + <canvas + ref={canvasRef} + className="absolute inset-0 -z-10 pointer-events-none" + /> + {children} + </SaturnEffectContext.Provider> ); } + +export default ParticleBackground; diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts deleted file mode 100644 index 497a340..0000000 --- a/packages/ui/src/lib/effects/SaturnEffect.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Ported SaturnEffect for the React UI (ui-new). - * Adapted from the original Svelte implementation but written as a standalone - * TypeScript class that manages a 2D canvas particle effect resembling a - * rotating "Saturn" with rings. Designed to be instantiated and controlled - * from a React component (e.g. ParticleBackground). - * - * Usage: - * const effect = new SaturnEffect(canvasElement); - * effect.handleMouseDown(clientX); - * effect.handleMouseMove(clientX); - * effect.handleMouseUp(); - * // on resize: - * effect.resize(width, height); - * // on unmount: - * effect.destroy(); - */ - -export class SaturnEffect { - private canvas: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private width = 0; - private height = 0; - - // Particle storage - private xyz: Float32Array | null = null; // interleaved x,y,z - private types: Uint8Array | null = null; // 0 = planet, 1 = ring - private count = 0; - - // Animation - private animationId = 0; - private angle = 0; - private scaleFactor = 1; - - // Interaction - private isDragging = false; - private lastMouseX = 0; - private lastMouseTime = 0; - private mouseVelocities: number[] = []; - - // Speed control - private readonly baseSpeed = 0.005; - private currentSpeed = 0.005; - private rotationDirection = 1; - private readonly speedDecayRate = 0.992; - private readonly minSpeedMultiplier = 1; - private readonly maxSpeedMultiplier = 50; - private isStopped = false; - - constructor(canvas: HTMLCanvasElement) { - this.canvas = canvas; - const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false }); - if (!ctx) { - throw new Error("Failed to get 2D context for SaturnEffect"); - } - this.ctx = ctx; - - // Initialize size & particles - this.resize(window.innerWidth, window.innerHeight); - this.initParticles(); - - this.animate = this.animate.bind(this); - this.animate(); - } - - // External interaction handlers (accept clientX) - handleMouseDown(clientX: number) { - this.isDragging = true; - this.lastMouseX = clientX; - this.lastMouseTime = performance.now(); - this.mouseVelocities = []; - } - - handleMouseMove(clientX: number) { - if (!this.isDragging) return; - const now = performance.now(); - const dt = now - this.lastMouseTime; - if (dt > 0) { - const dx = clientX - this.lastMouseX; - const velocity = dx / dt; - this.mouseVelocities.push(velocity); - if (this.mouseVelocities.length > 5) this.mouseVelocities.shift(); - // Rotate directly while dragging for immediate feedback - this.angle += dx * 0.002; - } - this.lastMouseX = clientX; - this.lastMouseTime = now; - } - - handleMouseUp() { - if (this.isDragging && this.mouseVelocities.length > 0) { - this.applyFlingVelocity(); - } - this.isDragging = false; - } - - handleTouchStart(clientX: number) { - this.handleMouseDown(clientX); - } - - handleTouchMove(clientX: number) { - this.handleMouseMove(clientX); - } - - handleTouchEnd() { - this.handleMouseUp(); - } - - // Resize canvas & scale (call on window resize) - resize(width: number, height: number) { - const dpr = window.devicePixelRatio || 1; - this.width = width; - this.height = height; - - // Update canvas pixel size and CSS size - this.canvas.width = Math.max(1, Math.floor(width * dpr)); - this.canvas.height = Math.max(1, Math.floor(height * dpr)); - this.canvas.style.width = `${width}px`; - this.canvas.style.height = `${height}px`; - - // Reset transform and scale for devicePixelRatio - this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset - this.ctx.scale(dpr, dpr); - - const minDim = Math.min(width, height); - this.scaleFactor = Math.max(1, minDim * 0.45); - } - - // Initialize particle arrays with reduced counts to keep performance reasonable - private initParticles() { - // Tuned particle counts for reasonable performance across platforms - const planetCount = 1000; - const ringCount = 2500; - this.count = planetCount + ringCount; - - this.xyz = new Float32Array(this.count * 3); - this.types = new Uint8Array(this.count); - - let idx = 0; - - // Planet points - for (let i = 0; i < planetCount; i++, idx++) { - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(Math.random() * 2 - 1); - const r = 1.0; - - this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta); - this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); - this.xyz[idx * 3 + 2] = r * Math.cos(phi); - - this.types[idx] = 0; - } - - // Ring points - const ringInner = 1.4; - const ringOuter = 2.3; - for (let i = 0; i < ringCount; i++, idx++) { - const angle = Math.random() * Math.PI * 2; - const dist = Math.sqrt( - Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + - ringInner * ringInner, - ); - - this.xyz[idx * 3] = dist * Math.cos(angle); - this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05; - this.xyz[idx * 3 + 2] = dist * Math.sin(angle); - - this.types[idx] = 1; - } - } - - // Map fling/velocity samples to a rotation speed and direction - private applyFlingVelocity() { - if (this.mouseVelocities.length === 0) return; - const avg = - this.mouseVelocities.reduce((a, b) => a + b, 0) / - this.mouseVelocities.length; - const flingThreshold = 0.3; - const stopThreshold = 0.1; - - if (Math.abs(avg) > flingThreshold) { - this.isStopped = false; - const newDir = avg > 0 ? 1 : -1; - if (newDir !== this.rotationDirection) this.rotationDirection = newDir; - const multiplier = Math.min( - this.maxSpeedMultiplier, - this.minSpeedMultiplier + Math.abs(avg) * 10, - ); - this.currentSpeed = this.baseSpeed * multiplier; - } else if (Math.abs(avg) < stopThreshold) { - this.isStopped = true; - this.currentSpeed = 0; - } - } - - // Main render loop - private animate() { - // Clear with full alpha to allow layering over background - this.ctx.clearRect(0, 0, this.width, this.height); - - // Standard composition - this.ctx.globalCompositeOperation = "source-over"; - - // Update rotation speed (decay) - if (!this.isDragging && !this.isStopped) { - if (this.currentSpeed > this.baseSpeed) { - this.currentSpeed = - this.baseSpeed + - (this.currentSpeed - this.baseSpeed) * this.speedDecayRate; - if (this.currentSpeed - this.baseSpeed < 0.00001) { - this.currentSpeed = this.baseSpeed; - } - } - this.angle += this.currentSpeed * this.rotationDirection; - } - - // Center positions - const cx = this.width * 0.6; - const cy = this.height * 0.5; - - // Pre-calc rotations - const rotationY = this.angle; - const rotationX = 0.4; - const rotationZ = 0.15; - - const sinY = Math.sin(rotationY); - const cosY = Math.cos(rotationY); - const sinX = Math.sin(rotationX); - const cosX = Math.cos(rotationX); - const sinZ = Math.sin(rotationZ); - const cosZ = Math.cos(rotationZ); - - const fov = 1500; - const scaleFactor = this.scaleFactor; - - if (!this.xyz || !this.types) { - this.animationId = requestAnimationFrame(this.animate); - return; - } - - // Loop particles - for (let i = 0; i < this.count; i++) { - const x = this.xyz[i * 3]; - const y = this.xyz[i * 3 + 1]; - const z = this.xyz[i * 3 + 2]; - - // Scale to screen - const px = x * scaleFactor; - const py = y * scaleFactor; - const pz = z * scaleFactor; - - // Rotate Y then X then Z - const x1 = px * cosY - pz * sinY; - const z1 = pz * cosY + px * sinY; - const y2 = py * cosX - z1 * sinX; - const z2 = z1 * cosX + py * sinX; - const x3 = x1 * cosZ - y2 * sinZ; - const y3 = y2 * cosZ + x1 * sinZ; - const z3 = z2; - - const scale = fov / (fov + z3); - - if (z3 > -fov) { - const x2d = cx + x3 * scale; - const y2d = cy + y3 * scale; - - const type = this.types[i]; - const sizeBase = type === 0 ? 2.4 : 1.5; - const size = sizeBase * scale; - - let alpha = scale * scale * scale; - if (alpha > 1) alpha = 1; - if (alpha < 0.15) continue; - - if (type === 0) { - // Planet: warm-ish - this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`; - } else { - // Ring: cool-ish - this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`; - } - - // Render as small rectangles (faster than arc) - this.ctx.fillRect(x2d, y2d, size, size); - } - } - - this.animationId = requestAnimationFrame(this.animate); - } - - // Stop animations and release resources - destroy() { - if (this.animationId) { - cancelAnimationFrame(this.animationId); - this.animationId = 0; - } - // Intentionally do not null out arrays to allow reuse if desired. - } -} diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index c5cbfc8..912fea8 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,33 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import { createHashRouter, RouterProvider } from "react-router"; +import { RouterProvider } from "react-router"; import { Toaster } from "./components/ui/sonner"; -import { HomeView } from "./pages/home-view"; -import { IndexPage } from "./pages/index"; -import { InstancesView } from "./pages/instances-view"; -import { SettingsPage } from "./pages/settings"; - -const router = createHashRouter([ - { - path: "/", - element: <IndexPage />, - children: [ - { - index: true, - element: <HomeView />, - }, - { - path: "instances", - element: <InstancesView />, - }, - { - path: "settings", - element: <SettingsPage />, - }, - ], - }, -]); +import router from "./pages/routes"; const root = createRoot(document.getElementById("root") as HTMLElement); root.render( diff --git a/packages/ui/src/pages/routes.ts b/packages/ui/src/pages/routes.ts new file mode 100644 index 0000000..8d105d4 --- /dev/null +++ b/packages/ui/src/pages/routes.ts @@ -0,0 +1,25 @@ +import { createHashRouter } from "react-router"; +import { IndexPage } from "."; +import { HomeView } from "./home-view"; +import instanceRoute from "./instances/routes"; +import { SettingsPage } from "./settings"; + +const router = createHashRouter([ + { + path: "/", + Component: IndexPage, + children: [ + { + index: true, + Component: HomeView, + }, + { + path: "settings", + Component: SettingsPage, + }, + instanceRoute, + ], + }, +]); + +export default router; |