aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/lib/effects
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-02-25 02:06:07 +0800
committerGitHub <noreply@github.com>2026-02-25 02:06:07 +0800
commit78ac61904d78d558d092eff08c9f261cbdb187e8 (patch)
tree96f68d1f1554ee3a0532793afaaa52b0c73dcbec /packages/ui/src/lib/effects
parent8ff3af6cb908fd824b512379dd21ed4f595ab6bb (diff)
parent329734b23957b84cde2af459fa61c7385fb5b5f1 (diff)
downloadDropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.tar.gz
DropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.zip
feat(ui): partial react rewrite (#77)
## Summary by Sourcery Export backend data structures to TypeScript for the new React-based UI and update CI to build additional targets. New Features: - Generate TypeScript definitions for core backend structs and enums used by the UI. - Now use our own Azure app(_DropOut_) to finish the authorize process. Enhancements: - Annotate existing Rust models with ts-rs metadata to control exported TypeScript shapes, including tagged enums and opaque JSON fields. Build: - Add ts-rs as a dependency for generating TypeScript bindings from Rust types. CI: - Extend the Semifold CI workflow to run on the dev branch and build additional Linux musl and Windows GNU targets using cross where needed.
Diffstat (limited to 'packages/ui/src/lib/effects')
-rw-r--r--packages/ui/src/lib/effects/ConstellationEffect.ts162
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts303
2 files changed, 131 insertions, 334 deletions
diff --git a/packages/ui/src/lib/effects/ConstellationEffect.ts b/packages/ui/src/lib/effects/ConstellationEffect.ts
deleted file mode 100644
index d2db529..0000000
--- a/packages/ui/src/lib/effects/ConstellationEffect.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-export class ConstellationEffect {
- private canvas: HTMLCanvasElement;
- private ctx: CanvasRenderingContext2D;
- private width: number = 0;
- private height: number = 0;
- private particles: Particle[] = [];
- private animationId: number = 0;
- private mouseX: number = -1000;
- private mouseY: number = -1000;
-
- // Configuration
- private readonly particleCount = 100;
- private readonly connectionDistance = 150;
- private readonly particleSpeed = 0.5;
-
- constructor(canvas: HTMLCanvasElement) {
- this.canvas = canvas;
- this.ctx = canvas.getContext("2d", { alpha: true })!;
-
- // Bind methods
- this.animate = this.animate.bind(this);
- this.handleMouseMove = this.handleMouseMove.bind(this);
-
- // Initial setup
- this.resize(window.innerWidth, window.innerHeight);
- this.initParticles();
-
- // Mouse interaction
- window.addEventListener("mousemove", this.handleMouseMove);
-
- // Start animation
- this.animate();
- }
-
- resize(width: number, height: number) {
- const dpr = window.devicePixelRatio || 1;
- this.width = width;
- this.height = height;
-
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
- this.canvas.style.width = `${width}px`;
- this.canvas.style.height = `${height}px`;
-
- this.ctx.scale(dpr, dpr);
-
- // Re-initialize if screen size changes significantly to maintain density
- if (this.particles.length === 0) {
- this.initParticles();
- }
- }
-
- private initParticles() {
- this.particles = [];
- // Adjust density based on screen area
- const area = this.width * this.height;
- const density = Math.floor(area / 15000); // 1 particle per 15000px²
- const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200
-
- for (let i = 0; i < count; i++) {
- this.particles.push(new Particle(this.width, this.height, this.particleSpeed));
- }
- }
-
- private handleMouseMove(e: MouseEvent) {
- const rect = this.canvas.getBoundingClientRect();
- this.mouseX = e.clientX - rect.left;
- this.mouseY = e.clientY - rect.top;
- }
-
- animate() {
- this.ctx.clearRect(0, 0, this.width, this.height);
-
- // Update and draw particles
- this.particles.forEach((p) => {
- p.update(this.width, this.height);
- p.draw(this.ctx);
- });
-
- // Draw lines
- this.drawConnections();
-
- this.animationId = requestAnimationFrame(this.animate);
- }
-
- private drawConnections() {
- this.ctx.lineWidth = 1;
-
- for (let i = 0; i < this.particles.length; i++) {
- const p1 = this.particles[i];
-
- // Connect to mouse if close
- const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY);
- if (distMouse < this.connectionDistance + 50) {
- const alpha = 1 - distMouse / (this.connectionDistance + 50);
- this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse
- this.ctx.beginPath();
- this.ctx.moveTo(p1.x, p1.y);
- this.ctx.lineTo(this.mouseX, this.mouseY);
- this.ctx.stroke();
-
- // Gently attract to mouse
- if (distMouse > 10) {
- p1.x += (this.mouseX - p1.x) * 0.005;
- p1.y += (this.mouseY - p1.y) * 0.005;
- }
- }
-
- // Connect to other particles
- for (let j = i + 1; j < this.particles.length; j++) {
- const p2 = this.particles[j];
- const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
-
- if (dist < this.connectionDistance) {
- const alpha = 1 - dist / this.connectionDistance;
- this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`;
- this.ctx.beginPath();
- this.ctx.moveTo(p1.x, p1.y);
- this.ctx.lineTo(p2.x, p2.y);
- this.ctx.stroke();
- }
- }
- }
- }
-
- destroy() {
- cancelAnimationFrame(this.animationId);
- window.removeEventListener("mousemove", this.handleMouseMove);
- }
-}
-
-class Particle {
- x: number;
- y: number;
- vx: number;
- vy: number;
- size: number;
-
- constructor(w: number, h: number, speed: number) {
- this.x = Math.random() * w;
- this.y = Math.random() * h;
- this.vx = (Math.random() - 0.5) * speed;
- this.vy = (Math.random() - 0.5) * speed;
- this.size = Math.random() * 2 + 1;
- }
-
- update(w: number, h: number) {
- this.x += this.vx;
- this.y += this.vy;
-
- // Bounce off walls
- if (this.x < 0 || this.x > w) this.vx *= -1;
- if (this.y < 0 || this.y > h) this.vy *= -1;
- }
-
- draw(ctx: CanvasRenderingContext2D) {
- ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
- ctx.beginPath();
- ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- }
-}
diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts
index 357da9d..497a340 100644
--- a/packages/ui/src/lib/effects/SaturnEffect.ts
+++ b/packages/ui/src/lib/effects/SaturnEffect.ts
@@ -1,46 +1,61 @@
-// Optimized Saturn Effect for low-end hardware
-// Uses TypedArrays for memory efficiency and reduced particle density
+/**
+ * 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: number = 0;
- private height: number = 0;
-
- // Data-oriented design for performance
- // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z
- private xyz: Float32Array | null = null;
- // types: Uint8Array where 0 = planet, 1 = ring
- private types: Uint8Array | null = null;
- private count: number = 0;
-
- private animationId: number = 0;
- private angle: number = 0;
- private scaleFactor: number = 1;
-
- // Mouse interaction properties
- private isDragging: boolean = false;
- private lastMouseX: number = 0;
- private lastMouseTime: number = 0;
- private mouseVelocities: number[] = []; // Store recent velocities for averaging
-
- // Rotation speed control
- private readonly baseSpeed: number = 0.005; // Original rotation speed
- private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse)
- private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise
- private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay)
- private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed
- private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed
- private isStopped: boolean = false; // Whether the user has stopped the rotation
+ 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;
- this.ctx = canvas.getContext("2d", {
- alpha: true,
- desynchronized: false, // default is usually fine, 'desynchronized' can help latency but might flicker
- })!;
+ const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false });
+ if (!ctx) {
+ throw new Error("Failed to get 2D context for SaturnEffect");
+ }
+ this.ctx = ctx;
- // Initial resize will set up everything
+ // Initialize size & particles
this.resize(window.innerWidth, window.innerHeight);
this.initParticles();
@@ -48,9 +63,7 @@ export class SaturnEffect {
this.animate();
}
- // Public methods for external mouse event handling
- // These can be called from any element that wants to control the Saturn rotation
-
+ // External interaction handlers (accept clientX)
handleMouseDown(clientX: number) {
this.isDragging = true;
this.lastMouseX = clientX;
@@ -60,26 +73,18 @@ export class SaturnEffect {
handleMouseMove(clientX: number) {
if (!this.isDragging) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastMouseTime;
-
- if (deltaTime > 0) {
- const deltaX = clientX - this.lastMouseX;
- const velocity = deltaX / deltaTime; // pixels per millisecond
-
- // Store recent velocities (keep last 5 for smoothing)
+ 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();
- }
-
- // Apply direct rotation while dragging
- this.angle += deltaX * 0.002;
+ 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 = currentTime;
+ this.lastMouseTime = now;
}
handleMouseUp() {
@@ -90,174 +95,130 @@ export class SaturnEffect {
}
handleTouchStart(clientX: number) {
- this.isDragging = true;
- this.lastMouseX = clientX;
- this.lastMouseTime = performance.now();
- this.mouseVelocities = [];
+ this.handleMouseDown(clientX);
}
handleTouchMove(clientX: number) {
- if (!this.isDragging) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastMouseTime;
-
- if (deltaTime > 0) {
- const deltaX = clientX - this.lastMouseX;
- const velocity = deltaX / deltaTime;
-
- this.mouseVelocities.push(velocity);
- if (this.mouseVelocities.length > 5) {
- this.mouseVelocities.shift();
- }
-
- this.angle += deltaX * 0.002;
- }
-
- this.lastMouseX = clientX;
- this.lastMouseTime = currentTime;
+ this.handleMouseMove(clientX);
}
handleTouchEnd() {
- if (this.isDragging && this.mouseVelocities.length > 0) {
- this.applyFlingVelocity();
- }
- this.isDragging = false;
- }
-
- private applyFlingVelocity() {
- // Calculate average velocity from recent samples
- const avgVelocity =
- this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length;
-
- // Threshold for considering it a "fling" (pixels per millisecond)
- const flingThreshold = 0.3;
- // Threshold for considering the rotation as "stopped" by user
- const stopThreshold = 0.1;
-
- if (Math.abs(avgVelocity) > flingThreshold) {
- // User flung it - start rotating again
- this.isStopped = false;
-
- // Determine new direction based on fling direction
- const newDirection = avgVelocity > 0 ? 1 : -1;
-
- // If direction changed, update it permanently
- if (newDirection !== this.rotationDirection) {
- this.rotationDirection = newDirection;
- }
-
- // Calculate speed boost based on fling strength
- // Map velocity to speed multiplier (stronger fling = faster rotation)
- const speedMultiplier = Math.min(
- this.maxSpeedMultiplier,
- this.minSpeedMultiplier + Math.abs(avgVelocity) * 10,
- );
-
- this.currentSpeed = this.baseSpeed * speedMultiplier;
- } else if (Math.abs(avgVelocity) < stopThreshold) {
- // User gently released - keep it stopped
- this.isStopped = true;
- this.currentSpeed = 0;
- }
- // If velocity is between stopThreshold and flingThreshold,
- // keep current state (don't change isStopped)
+ 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;
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
+ // 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);
- // Dynamic scaling based on screen size
const minDim = Math.min(width, height);
- this.scaleFactor = minDim * 0.45;
+ this.scaleFactor = Math.max(1, minDim * 0.45);
}
- initParticles() {
- // Significantly reduced particle count for CPU optimization
- // Planet: 1800 -> 1000
- // Rings: 5000 -> 2500
- // Total approx 3500 vs 6800 previously (approx 50% reduction)
+ // 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;
- // Use TypedArrays for better memory locality
this.xyz = new Float32Array(this.count * 3);
this.types = new Uint8Array(this.count);
let idx = 0;
- // 1. Planet
- for (let i = 0; i < planetCount; i++) {
+ // 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;
- // x, y, z
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; // 0 for planet
- idx++;
+ this.types[idx] = 0;
}
- // 2. Rings
+ // Ring points
const ringInner = 1.4;
const ringOuter = 2.3;
-
- for (let i = 0; i < ringCount; i++) {
+ 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,
+ Math.random() * (ringOuter * ringOuter - ringInner * ringInner) +
+ ringInner * ringInner,
);
- // x, y, z
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; // 1 for ring
- idx++;
+ this.types[idx] = 1;
}
}
- animate() {
+ // 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);
- // Normal blending
+ // Standard composition
this.ctx.globalCompositeOperation = "source-over";
- // Update rotation speed - decay towards base speed while maintaining direction
+ // Update rotation speed (decay)
if (!this.isDragging && !this.isStopped) {
if (this.currentSpeed > this.baseSpeed) {
- // Gradually decay speed back to base speed
this.currentSpeed =
- this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
-
- // Snap to base speed when close enough
+ this.baseSpeed +
+ (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
if (this.currentSpeed - this.baseSpeed < 0.00001) {
this.currentSpeed = this.baseSpeed;
}
}
-
- // Apply rotation with current speed and direction
this.angle += this.currentSpeed * this.rotationDirection;
}
+ // Center positions
const cx = this.width * 0.6;
const cy = this.height * 0.5;
- // Pre-calculate rotation matrices
+ // Pre-calc rotations
const rotationY = this.angle;
const rotationX = 0.4;
const rotationZ = 0.15;
@@ -272,29 +233,27 @@ export class SaturnEffect {
const fov = 1500;
const scaleFactor = this.scaleFactor;
- if (!this.xyz || !this.types) return;
+ 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];
- // Apply Scale
+ // Scale to screen
const px = x * scaleFactor;
const py = y * scaleFactor;
const pz = z * scaleFactor;
- // 1. Rotate Y
+ // Rotate Y then X then Z
const x1 = px * cosY - pz * sinY;
const z1 = pz * cosY + px * sinY;
- // y1 = py
-
- // 2. Rotate X
const y2 = py * cosX - z1 * sinX;
const z2 = z1 * cosX + py * sinX;
- // x2 = x1
-
- // 3. Rotate Z
const x3 = x1 * cosZ - y2 * sinZ;
const y3 = y2 * cosZ + x1 * sinZ;
const z3 = z2;
@@ -305,28 +264,23 @@ export class SaturnEffect {
const x2d = cx + x3 * scale;
const y2d = cy + y3 * scale;
- // Size calculation - slightly larger dots to compensate for lower count
- // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5
const type = this.types[i];
const sizeBase = type === 0 ? 2.4 : 1.5;
const size = sizeBase * scale;
- // Opacity
let alpha = scale * scale * scale;
if (alpha > 1) alpha = 1;
- if (alpha < 0.15) continue; // Skip very faint particles for performance
+ if (alpha < 0.15) continue;
- // Optimization: Planet color vs Ring color
if (type === 0) {
- // Planet: Warm White
+ // Planet: warm-ish
this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
} else {
- // Ring: Cool White
+ // Ring: cool-ish
this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`;
}
- // Render as squares (fillRect) instead of circles (arc)
- // This is significantly faster for software rendering and reduces GPU usage.
+ // Render as small rectangles (faster than arc)
this.ctx.fillRect(x2d, y2d, size, size);
}
}
@@ -334,7 +288,12 @@ export class SaturnEffect {
this.animationId = requestAnimationFrame(this.animate);
}
+ // Stop animations and release resources
destroy() {
- cancelAnimationFrame(this.animationId);
+ if (this.animationId) {
+ cancelAnimationFrame(this.animationId);
+ this.animationId = 0;
+ }
+ // Intentionally do not null out arrays to allow reuse if desired.
}
}