aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui
diff options
context:
space:
mode:
author苏向夜 <fu050409@163.com>2026-03-26 09:05:44 +0800
committer苏向夜 <fu050409@163.com>2026-03-26 09:05:44 +0800
commit18aceb4ddf01e964d0b81a4e926e42b72c64e355 (patch)
treeceef83124ae966838f5620bbf37d0fc099d5f931 /packages/ui
parentb762558df79a9bae7ec67f1f98caa9ccebcda861 (diff)
downloadDropOut-18aceb4ddf01e964d0b81a4e926e42b72c64e355.tar.gz
DropOut-18aceb4ddf01e964d0b81a4e926e42b72c64e355.zip
refactor(ui): rewrite particle background
Diffstat (limited to 'packages/ui')
-rw-r--r--packages/ui/src/components/particle-background.tsx68
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts299
-rw-r--r--packages/ui/src/main.tsx28
-rw-r--r--packages/ui/src/pages/routes.ts25
4 files changed, 57 insertions, 363 deletions
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;