Solar System Motion
It's how our solar system is moving.
// 01·LIVE PREVIEW
Interactive stage// 01.B·PARAMETERS
Theme
Target
Speed1x
Pitch36°
Angle48°
Streak Turns2.4 rev
Streaks
Orbits
Playing
// 02·USAGE
example.tsximport { SolarSystemMotion } from "@/components/ui/solar-system-motion";
export default function Example() {
return (
<div className="w-full h-[500px]">
<SolarSystemMotion
colorMode="monochrome"
speed={1.0}
pitchDeg={36}
angleDeg={48}
showStreaks
/>
</div>
);
}
// 03·API REFERENCE
9 propsProp
Type
// 04·COMPONENT SOURCE
2 files"use client";
import {
Camera,
Color,
Geometry,
Mesh,
Polyline,
Program,
Renderer,
Sphere,
Transform,
Vec3,
} from "ogl";
import type React from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/cn";
import {
CELESTIAL_PLANETS,
DEFAULT_VORTEX_CONFIG,
MOON_CONFIG,
} from "./motion-engine";
const STREAK_VERTEX_SHADER = `
precision highp float;
attribute vec3 position;
attribute vec3 next;
attribute vec3 prev;
attribute vec2 uv;
attribute float side;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform vec2 uResolution;
uniform float uDPR;
uniform float uThickness;
uniform float uMiter;
varying vec2 vUv;
vec4 getPosition() {
mat4 mvp = projectionMatrix * modelViewMatrix;
vec4 current = mvp * vec4(position, 1.0);
vec4 nextPos = mvp * vec4(next, 1.0);
vec4 prevPos = mvp * vec4(prev, 1.0);
vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
vec2 currentScreen = current.xy / current.w * aspect;
vec2 nextScreen = nextPos.xy / nextPos.w * aspect;
vec2 prevScreen = prevPos.xy / prevPos.w * aspect;
vec2 dir1 = normalize(currentScreen - prevScreen);
vec2 dir2 = normalize(nextScreen - currentScreen);
vec2 dir = normalize(dir1 + dir2);
vec2 normal = vec2(-dir.y, dir.x);
normal /= mix(1.0, max(0.3, dot(normal, vec2(-dir1.y, dir1.x))), uMiter);
normal /= aspect;
float pixelWidthRatio = 1.0 / (uResolution.y / uDPR);
float pixelWidth = current.w * pixelWidthRatio;
normal *= pixelWidth * uThickness;
current.xy -= normal * side;
return current;
}
void main() {
vUv = uv;
gl_Position = getPosition();
}
`;
const STREAK_FRAGMENT_SHADER = `
precision highp float;
uniform vec3 uColor;
uniform float uAlpha;
varying vec2 vUv;
void main() {
float dist = abs(vUv.x - 0.5) * 2.0;
float edge = smoothstep(1.0, 0.0, dist);
float core = pow(1.0 - dist, 2.0);
float tailFade = pow(max(0.0, 1.0 - vUv.y), 1.35);
vec3 col = mix(uColor, vec3(1.0, 1.0, 1.0), core * 0.45);
float alpha = edge * tailFade * uAlpha;
gl_FragColor = vec4(col, alpha);
}
`;
const STAR_VERTEX_SHADER = `
precision highp float;
attribute vec3 position;
attribute float aSize;
attribute vec2 aTwinkle;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float uTime;
varying float vAlpha;
void main() {
float twinkle = 0.65 + 0.35 * sin(uTime * aTwinkle.x + aTwinkle.y);
vAlpha = twinkle;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = aSize * (220.0 / -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}
`;
const STAR_FRAGMENT_SHADER = `
precision highp float;
varying float vAlpha;
void main() {
float dist = length(gl_PointCoord - 0.5);
if (dist > 0.5) discard;
float alpha = smoothstep(0.5, 0.08, dist);
gl_FragColor = vec4(1.0, 1.0, 1.0, alpha * vAlpha);
}
`;
const SPHERE_VERTEX_SHADER = `
precision highp float;
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
varying vec3 vNormal;
varying vec3 vPosition;
varying vec2 vUv;
void main() {
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vPosition = mvPosition.xyz;
vUv = uv;
gl_Position = projectionMatrix * mvPosition;
}
`;
const PLANET_FRAGMENT_SHADER = `
precision highp float;
uniform vec3 uColor;
uniform vec3 uGlowColor;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vec3 normal = normalize(vNormal);
vec3 lightDir = normalize(vec3(0.5, 0.8, 1.0));
float diff = max(dot(normal, lightDir), 0.0);
vec3 viewDir = normalize(-vPosition);
float fresnel = 1.0 - max(dot(viewDir, normal), 0.0);
vec3 ambient = uColor * 0.45;
vec3 diffuse = uColor * (0.55 * diff);
vec3 halo = uGlowColor * (0.45 * pow(fresnel, 2.0));
gl_FragColor = vec4(ambient + diffuse + halo, 1.0);
}
`;
const SUN_FRAGMENT_SHADER = `
precision highp float;
uniform vec3 uCoreColor;
uniform vec3 uRimColor;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
vec3 normal = normalize(vNormal);
vec3 viewDir = normalize(-vPosition);
float fresnel = 1.0 - max(dot(viewDir, normal), 0.0);
vec3 col = mix(uCoreColor, uRimColor, pow(fresnel, 1.5));
gl_FragColor = vec4(col, 1.0);
}
`;
const RING_VERTEX_SHADER = `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const SATURN_RING_FRAGMENT_SHADER = `
precision highp float;
uniform vec3 uRingColor;
varying vec2 vUv;
void main() {
float dist = length(vUv - 0.5) * 2.0;
if (dist < 0.48 || dist > 0.98) discard;
float band = sin(dist * 60.0) * 0.12 + 0.88;
float edge = smoothstep(0.48, 0.54, dist) * smoothstep(0.98, 0.90, dist);
vec3 ringColor = uRingColor * band;
gl_FragColor = vec4(ringColor, edge * 0.75);
}
`;
const SIMPLE_LINE_VERTEX_SHADER = `
precision highp float;
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const SIMPLE_LINE_FRAGMENT_SHADER = `
precision highp float;
uniform vec3 uColor;
uniform float uAlpha;
void main() {
gl_FragColor = vec4(uColor, uAlpha);
}
`;
function hexToOglColor(hex: string): Color {
return new Color(hex);
}
export const MONOCHROME_PLANET_PALETTE: Record<
string,
{ color: string; trailColor: string; glowColor: string }
> = {
mercury: { color: "#94a3b8", trailColor: "#ffffff", glowColor: "#cbd5e1" },
venus: { color: "#f8fafc", trailColor: "#ffffff", glowColor: "#ffffff" },
earth: { color: "#e2e8f0", trailColor: "#ffffff", glowColor: "#f1f5f9" },
mars: { color: "#cbd5e1", trailColor: "#ffffff", glowColor: "#e2e8f0" },
jupiter: { color: "#f1f5f9", trailColor: "#ffffff", glowColor: "#ffffff" },
saturn: { color: "#e2e8f0", trailColor: "#ffffff", glowColor: "#f8fafc" },
uranus: { color: "#cbd5e1", trailColor: "#ffffff", glowColor: "#e2e8f0" },
neptune: { color: "#94a3b8", trailColor: "#ffffff", glowColor: "#cbd5e1" },
};
export interface SolarSystemMotionProps {
speed?: number;
pitchDeg?: number;
angleDeg?: number;
colorMode?: "monochrome" | "color";
showStreaks?: boolean;
showOrbits?: boolean;
streakTurns?: number;
isPlaying?: boolean;
visiblePlanets?: string[];
selectedPlanet?: string;
className?: string;
}
export function SolarSystemMotion({
speed = 1.0,
pitchDeg = DEFAULT_VORTEX_CONFIG.pitchDeg,
angleDeg = DEFAULT_VORTEX_CONFIG.angleDeg,
colorMode = "monochrome",
showStreaks = true,
showOrbits = false,
streakTurns = 2.4,
isPlaying = true,
visiblePlanets,
selectedPlanet = "earth",
className = "",
}: SolarSystemMotionProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const isPlayingRef = useRef(isPlaying);
useEffect(() => {
isPlayingRef.current = isPlaying;
}, [isPlaying]);
const speedRef = useRef(speed);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
const colorModeRef = useRef(colorMode);
useEffect(() => {
colorModeRef.current = colorMode;
}, [colorMode]);
const cameraPitchRef = useRef(pitchDeg);
useEffect(() => {
cameraPitchRef.current = pitchDeg;
}, [pitchDeg]);
const trajectoryAngleRef = useRef(angleDeg);
useEffect(() => {
trajectoryAngleRef.current = angleDeg;
}, [angleDeg]);
const showStreaksRef = useRef(showStreaks);
useEffect(() => {
showStreaksRef.current = showStreaks;
}, [showStreaks]);
const showOrbitsRef = useRef(showOrbits);
useEffect(() => {
showOrbitsRef.current = showOrbits;
}, [showOrbits]);
const streakTurnsRef = useRef(streakTurns);
useEffect(() => {
streakTurnsRef.current = streakTurns;
}, [streakTurns]);
const visiblePlanetIdsRef = useRef<Set<string>>(
new Set(visiblePlanets || CELESTIAL_PLANETS.map((p) => p.id)),
);
useEffect(() => {
if (visiblePlanets) {
visiblePlanetIdsRef.current = new Set(visiblePlanets);
}
}, [visiblePlanets]);
const selectedPlanetIdRef = useRef(selectedPlanet);
useEffect(() => {
selectedPlanetIdRef.current = selectedPlanet;
}, [selectedPlanet]);
const cameraYawRef = useRef(0);
const zoomRef = useRef(1.0);
const isDraggingRef = useRef(false);
const lastMousePosRef = useRef({ x: 0, y: 0 });
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
isDraggingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDraggingRef.current) return;
const dx = e.clientX - lastMousePosRef.current.x;
const dy = e.clientY - lastMousePosRef.current.y;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
cameraYawRef.current = (cameraYawRef.current + dx * 0.35) % 360;
cameraPitchRef.current = Math.max(
0,
Math.min(85, cameraPitchRef.current - dy * 0.3),
);
};
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
isDraggingRef.current = false;
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch {}
};
const handleWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
const zoomDelta = e.deltaY * -0.0012;
zoomRef.current = Math.max(0.4, Math.min(2.5, zoomRef.current + zoomDelta));
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const renderer = new Renderer({
canvas,
dpr: Math.min(window.devicePixelRatio || 1, 2),
alpha: true,
antialias: true,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
const gl = renderer.gl;
gl.clearColor(0.0392, 0.0392, 0.0392, 1.0);
const scene = new Transform();
const camera = new Camera(gl, { fov: 42, near: 0.1, far: 2000 });
const starCount = 380;
const starPositions = new Float32Array(starCount * 3);
const starSizes = new Float32Array(starCount);
const starTwinkle = new Float32Array(starCount * 2);
for (let i = 0; i < starCount; i++) {
const u = Math.random();
const v = Math.random();
const theta = u * 2.0 * Math.PI;
const phi = Math.acos(2.0 * v - 1.0);
const r = 60 + Math.random() * 80;
starPositions[i * 3 + 0] = r * Math.sin(phi) * Math.cos(theta);
starPositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
starPositions[i * 3 + 2] = r * Math.cos(phi);
starSizes[i] = Math.random() > 0.85 ? 2.2 : 1.2;
starTwinkle[i * 2 + 0] = 0.8 + Math.random() * 2.5;
starTwinkle[i * 2 + 1] = Math.random() * Math.PI * 2;
}
const starGeom = new Geometry(gl, {
position: { size: 3, data: starPositions },
aSize: { size: 1, data: starSizes },
aTwinkle: { size: 2, data: starTwinkle },
});
const starProg = new Program(gl, {
vertex: STAR_VERTEX_SHADER,
fragment: STAR_FRAGMENT_SHADER,
uniforms: {
uTime: { value: 0 },
},
transparent: true,
depthWrite: false,
});
starProg.setBlendFunc(gl.SRC_ALPHA, gl.ONE);
const starMesh = new Mesh(gl, {
mode: gl.POINTS,
geometry: starGeom,
program: starProg,
});
starMesh.setParent(scene);
const SAMPLES = 140;
const allPolylines: Polyline[] = [];
const sunStreakPtsCore: Vec3[] = [];
const sunStreakPtsAura: Vec3[] = [];
const sunStreakPtsCenter: Vec3[] = [];
for (let i = 0; i < SAMPLES; i++) {
sunStreakPtsCore.push(new Vec3(i * 0.05, 0, 0));
sunStreakPtsAura.push(new Vec3(i * 0.05, 0, 0));
sunStreakPtsCenter.push(new Vec3(i * 0.05, 0, 0));
}
const sunStreakAura = new Polyline(gl, {
points: sunStreakPtsAura,
vertex: STREAK_VERTEX_SHADER,
fragment: STREAK_FRAGMENT_SHADER,
uniforms: {
uThickness: { value: 16.0 },
uColor: {
value: new Color(
colorModeRef.current === "monochrome" ? "#ffffff" : "#f59e0b",
),
},
uAlpha: { value: colorModeRef.current === "monochrome" ? 0.25 : 0.4 },
},
});
sunStreakAura.program.transparent = true;
sunStreakAura.program.depthWrite = false;
sunStreakAura.program.setBlendFunc(gl.SRC_ALPHA, gl.ONE);
sunStreakAura.mesh.setParent(scene);
const sunStreakCore = new Polyline(gl, {
points: sunStreakPtsCore,
vertex: STREAK_VERTEX_SHADER,
fragment: STREAK_FRAGMENT_SHADER,
uniforms: {
uThickness: { value: 6.0 },
uColor: {
value: new Color(
colorModeRef.current === "monochrome" ? "#f8fafc" : "#fbbf24",
),
},
uAlpha: { value: colorModeRef.current === "monochrome" ? 0.75 : 0.9 },
},
});
sunStreakCore.program.transparent = true;
sunStreakCore.program.depthWrite = false;
sunStreakCore.program.setBlendFunc(gl.SRC_ALPHA, gl.ONE);
sunStreakCore.mesh.setParent(scene);
const sunStreakCenter = new Polyline(gl, {
points: sunStreakPtsCenter,
vertex: STREAK_VERTEX_SHADER,
fragment: STREAK_FRAGMENT_SHADER,
uniforms: {
uThickness: { value: 2.2 },
uColor: { value: new Color("#ffffff") },
uAlpha: { value: 1.0 },
},
});
sunStreakCenter.program.transparent = true;
sunStreakCenter.program.depthWrite = false;
sunStreakCenter.program.setBlendFunc(gl.SRC_ALPHA, gl.ONE);
sunStreakCenter.mesh.setParent(scene);
allPolylines.push(sunStreakAura, sunStreakCore, sunStreakCenter);
const planetPolylines = CELESTIAL_PLANETS.map((planet) => {
const pts: Vec3[] = [];
for (let i = 0; i < SAMPLES; i++) {
pts.push(new Vec3(i * 0.05, 0, 0));
}
const initTrailColor =
colorModeRef.current === "monochrome"
? MONOCHROME_PLANET_PALETTE[planet.id]?.trailColor || "#ffffff"
: planet.trailColor;
const pLine = new Polyline(gl, {
points: pts,
vertex: STREAK_VERTEX_SHADER,
fragment: STREAK_FRAGMENT_SHADER,
uniforms: {
uThickness: {
value:
planet.id === "jupiter" || planet.id === "saturn" ? 4.2 : 3.0,
},
uColor: { value: hexToOglColor(initTrailColor) },
uAlpha: { value: 0.9 },
},
});
pLine.program.transparent = true;
pLine.program.depthWrite = false;
pLine.program.setBlendFunc(gl.SRC_ALPHA, gl.ONE);
pLine.mesh.setParent(scene);
allPolylines.push(pLine);
return pLine;
});
const moonPts: Vec3[] = [];
for (let i = 0; i < SAMPLES; i++) {
moonPts.push(new Vec3(i * 0.05, 0, 0));
}
const moonPolyline = new Polyline(gl, {
points: moonPts,
vertex: STREAK_VERTEX_SHADER,
fragment: STREAK_FRAGMENT_SHADER,
uniforms: {
uThickness: { value: 2.0 },
uColor: {
value: hexToOglColor(
colorModeRef.current === "monochrome"
? "#cbd5e1"
: MOON_CONFIG.trailColor,
),
},
uAlpha: { value: 0.8 },
},
});
moonPolyline.program.transparent = true;
moonPolyline.program.depthWrite = false;
moonPolyline.program.setBlendFunc(gl.SRC_ALPHA, gl.ONE);
moonPolyline.mesh.setParent(scene);
allPolylines.push(moonPolyline);
const unitSphereGeom = new Sphere(gl, {
radius: 1,
widthSegments: 20,
heightSegments: 14,
});
const sunProg = new Program(gl, {
vertex: SPHERE_VERTEX_SHADER,
fragment: SUN_FRAGMENT_SHADER,
uniforms: {
uCoreColor: {
value: new Color(
colorModeRef.current === "monochrome"
? [1.0, 1.0, 1.0]
: [1.0, 0.98, 0.92],
),
},
uRimColor: {
value: new Color(
colorModeRef.current === "monochrome"
? [0.82, 0.85, 0.9]
: [0.98, 0.72, 0.12],
),
},
},
});
const sunMesh = new Mesh(gl, {
geometry: unitSphereGeom,
program: sunProg,
});
sunMesh.scale.set(1.4, 1.4, 1.4);
sunMesh.setParent(scene);
const planetMeshes = CELESTIAL_PLANETS.map((planet) => {
const initColor =
colorModeRef.current === "monochrome"
? MONOCHROME_PLANET_PALETTE[planet.id]?.color || "#e2e8f0"
: planet.color;
const initGlow =
colorModeRef.current === "monochrome"
? MONOCHROME_PLANET_PALETTE[planet.id]?.glowColor || "#ffffff"
: planet.trailColor;
const pProg = new Program(gl, {
vertex: SPHERE_VERTEX_SHADER,
fragment: PLANET_FRAGMENT_SHADER,
uniforms: {
uColor: { value: hexToOglColor(initColor) },
uGlowColor: { value: hexToOglColor(initGlow) },
},
});
const pMesh = new Mesh(gl, { geometry: unitSphereGeom, program: pProg });
const scale = Math.max(0.24, planet.sizePx * 0.065);
pMesh.scale.set(scale, scale, scale);
pMesh.setParent(scene);
return pMesh;
});
const saturnRingGeom = new Geometry(gl, {
position: {
size: 3,
data: new Float32Array([
-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, 1, 1, 0, -1, 1, 0,
]),
},
normal: {
size: 3,
data: new Float32Array([
0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1,
]),
},
uv: {
size: 2,
data: new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]),
},
});
const saturnRingProg = new Program(gl, {
vertex: RING_VERTEX_SHADER,
fragment: SATURN_RING_FRAGMENT_SHADER,
uniforms: {
uRingColor: {
value: new Color(
colorModeRef.current === "monochrome"
? [0.92, 0.94, 0.96]
: [0.96, 0.88, 0.62],
),
},
},
transparent: true,
depthWrite: false,
cullFace: false,
});
const saturnRingMesh = new Mesh(gl, {
geometry: saturnRingGeom,
program: saturnRingProg,
});
saturnRingMesh.scale.set(2.4, 2.4, 1.0);
saturnRingMesh.rotation.x = Math.PI * 0.35;
const saturnIdx = CELESTIAL_PLANETS.findIndex((p) => p.id === "saturn");
if (saturnIdx >= 0) {
saturnRingMesh.setParent(planetMeshes[saturnIdx]);
}
const moonProg = new Program(gl, {
vertex: SPHERE_VERTEX_SHADER,
fragment: PLANET_FRAGMENT_SHADER,
uniforms: {
uColor: {
value: hexToOglColor(
colorModeRef.current === "monochrome"
? "#f8fafc"
: MOON_CONFIG.color,
),
},
uGlowColor: { value: hexToOglColor("#ffffff") },
},
});
const moonMesh = new Mesh(gl, {
geometry: unitSphereGeom,
program: moonProg,
});
moonMesh.scale.set(0.14, 0.14, 0.14);
moonMesh.setParent(scene);
const ORBIT_SEGS = 64;
const orbitCirclePositions = new Float32Array(ORBIT_SEGS * 3);
for (let i = 0; i < ORBIT_SEGS; i++) {
const th = (i / ORBIT_SEGS) * Math.PI * 2;
orbitCirclePositions[i * 3 + 0] = Math.cos(th);
orbitCirclePositions[i * 3 + 1] = Math.sin(th);
orbitCirclePositions[i * 3 + 2] = 0;
}
const orbitGeom = new Geometry(gl, {
position: { size: 3, data: orbitCirclePositions },
});
const orbitProg = new Program(gl, {
vertex: SIMPLE_LINE_VERTEX_SHADER,
fragment: SIMPLE_LINE_FRAGMENT_SHADER,
uniforms: {
uColor: { value: new Color("#94a3b8") },
uAlpha: { value: 0.16 },
},
transparent: true,
depthWrite: false,
});
const orbitGroup = new Transform();
orbitGroup.setParent(scene);
const orbitMeshes = CELESTIAL_PLANETS.map((planet) => {
const oMesh = new Mesh(gl, {
mode: gl.LINE_LOOP,
geometry: orbitGeom,
program: orbitProg,
});
const r3D = planet.radiusAU * 0.045;
oMesh.scale.set(r3D, r3D, 1.0);
oMesh.setParent(orbitGroup);
return oMesh;
});
const targetRingProg = new Program(gl, {
vertex: SIMPLE_LINE_VERTEX_SHADER,
fragment: SIMPLE_LINE_FRAGMENT_SHADER,
uniforms: {
uColor: {
value: new Color(
colorModeRef.current === "monochrome" ? "#ffffff" : "#38bdf8",
),
},
uAlpha: { value: 0.9 },
},
transparent: true,
depthWrite: false,
});
const targetRingMesh = new Mesh(gl, {
mode: gl.LINE_LOOP,
geometry: orbitGeom,
program: targetRingProg,
});
targetRingMesh.setParent(scene);
const handleResize = () => {
if (!canvas) return;
const width = Math.max(
300,
canvas.parentElement?.clientWidth || canvas.clientWidth || 800,
);
const height = Math.max(
240,
canvas.parentElement?.clientHeight ||
canvas.clientHeight ||
Math.round((width * 9) / 16),
);
renderer.setSize(width, height);
camera.perspective({ aspect: width / height });
for (const p of allPolylines) {
p.resize();
}
};
handleResize();
const resizeObserver = new ResizeObserver(handleResize);
if (canvas.parentElement) {
resizeObserver.observe(canvas.parentElement);
}
window.addEventListener("resize", handleResize);
let animId: number;
let lastTime = performance.now();
let activeColorMode = colorModeRef.current;
let simTime = 2.0;
const loop = (timestamp: number) => {
const dt = Math.min((timestamp - lastTime) / 1000, 0.1);
lastTime = timestamp;
if (activeColorMode !== colorModeRef.current) {
activeColorMode = colorModeRef.current;
const isMono = activeColorMode === "monochrome";
if (isMono) {
sunProg.uniforms.uCoreColor.value.set(1.0, 1.0, 1.0);
sunProg.uniforms.uRimColor.value.set(0.82, 0.85, 0.9);
sunStreakAura.program.uniforms.uColor.value.set("#ffffff");
sunStreakAura.program.uniforms.uAlpha.value = 0.25;
sunStreakCore.program.uniforms.uColor.value.set("#f8fafc");
sunStreakCore.program.uniforms.uAlpha.value = 0.75;
sunStreakCenter.program.uniforms.uColor.value.set("#ffffff");
sunStreakCenter.program.uniforms.uAlpha.value = 1.0;
saturnRingProg.uniforms.uRingColor.value.set(0.92, 0.94, 0.96);
targetRingProg.uniforms.uColor.value.set("#ffffff");
CELESTIAL_PLANETS.forEach((planet, idx) => {
const mono = MONOCHROME_PLANET_PALETTE[planet.id] || {
color: "#e2e8f0",
trailColor: "#ffffff",
glowColor: "#ffffff",
};
planetMeshes[idx].program.uniforms.uColor.value.set(mono.color);
planetMeshes[idx].program.uniforms.uGlowColor.value.set(
mono.glowColor,
);
planetPolylines[idx].program.uniforms.uColor.value.set(
mono.trailColor,
);
});
moonMesh.program.uniforms.uColor.value.set("#f8fafc");
moonMesh.program.uniforms.uGlowColor.value.set("#ffffff");
moonPolyline.program.uniforms.uColor.value.set("#cbd5e1");
} else {
sunProg.uniforms.uCoreColor.value.set(1.0, 0.98, 0.92);
sunProg.uniforms.uRimColor.value.set(0.98, 0.72, 0.12);
sunStreakAura.program.uniforms.uColor.value.set("#f59e0b");
sunStreakAura.program.uniforms.uAlpha.value = 0.4;
sunStreakCore.program.uniforms.uColor.value.set("#fbbf24");
sunStreakCore.program.uniforms.uAlpha.value = 0.9;
sunStreakCenter.program.uniforms.uColor.value.set("#ffffff");
sunStreakCenter.program.uniforms.uAlpha.value = 1.0;
saturnRingProg.uniforms.uRingColor.value.set(0.96, 0.88, 0.62);
targetRingProg.uniforms.uColor.value.set("#38bdf8");
CELESTIAL_PLANETS.forEach((planet, idx) => {
planetMeshes[idx].program.uniforms.uColor.value.set(planet.color);
planetMeshes[idx].program.uniforms.uGlowColor.value.set(
planet.trailColor,
);
planetPolylines[idx].program.uniforms.uColor.value.set(
planet.trailColor,
);
});
moonMesh.program.uniforms.uColor.value.set(MOON_CONFIG.color);
moonMesh.program.uniforms.uGlowColor.value.set("#ffffff");
moonPolyline.program.uniforms.uColor.value.set(
MOON_CONFIG.trailColor,
);
}
}
if (isPlayingRef.current) {
simTime += dt * speedRef.current * 1.5;
}
const curTime = simTime;
starProg.uniforms.uTime.value = curTime;
const angleRad = (trajectoryAngleRef.current * Math.PI) / 180;
const dirX = Math.cos(angleRad);
const dirY = Math.sin(angleRad);
const uX = -Math.sin(angleRad);
const uY = Math.cos(angleRad);
const L = 28.0;
const speedAU = 4.2;
const sunX = L * 0.28 * dirX;
const sunY = L * 0.28 * dirY;
sunMesh.position.set(sunX, sunY, 0);
const maxHistoryTime = streakTurnsRef.current * 2.2;
if (showStreaksRef.current) {
sunStreakAura.mesh.visible = true;
sunStreakCore.mesh.visible = true;
sunStreakCenter.mesh.visible = true;
for (let i = 0; i < SAMPLES; i++) {
const tau = (i / (SAMPLES - 1)) * maxHistoryTime;
const beamDist = tau * speedAU;
const hx = sunX - dirX * beamDist;
const hy = sunY - dirY * beamDist;
sunStreakAura.points[i].set(hx, hy, 0);
sunStreakCore.points[i].set(hx, hy, 0);
sunStreakCenter.points[i].set(hx, hy, 0);
}
sunStreakAura.updateGeometry();
sunStreakCore.updateGeometry();
sunStreakCenter.updateGeometry();
} else {
sunStreakAura.mesh.visible = false;
sunStreakCore.mesh.visible = false;
sunStreakCenter.mesh.visible = false;
}
orbitGroup.visible = showOrbitsRef.current;
if (showOrbitsRef.current) {
orbitGroup.position.set(sunX, sunY, 0);
orbitGroup.rotation.z = angleRad;
}
const earthPos = new Vec3(0, 0, 0);
targetRingMesh.visible = false;
CELESTIAL_PLANETS.forEach((planet, idx) => {
const pMesh = planetMeshes[idx];
const pLine = planetPolylines[idx];
const oMesh = orbitMeshes[idx];
const r3D = planet.radiusAU * 0.045;
const omega = (2 * Math.PI) / planet.orbitalPeriodYr;
const isPlanetVisible = visiblePlanetIdsRef.current.has(planet.id);
const thetaNow = omega * curTime + planet.initialPhase;
const px = sunX + uX * r3D * Math.cos(thetaNow);
const py = sunY + uY * r3D * Math.cos(thetaNow);
const pz = r3D * Math.sin(thetaNow);
pMesh.position.set(px, py, pz);
pMesh.visible = isPlanetVisible;
if (oMesh) {
oMesh.visible = isPlanetVisible;
}
if (planet.id === "earth") {
earthPos.set(px, py, pz);
}
if (isPlanetVisible && planet.id === selectedPlanetIdRef.current) {
const pulse = 1.0 + 0.15 * Math.sin(curTime * 5.0);
const ringR = Math.max(0.5, pMesh.scale.x * 1.8 * pulse);
targetRingMesh.visible = true;
targetRingMesh.position.set(px, py, pz);
targetRingMesh.scale.set(ringR, ringR, 1.0);
targetRingMesh.rotation.z = curTime * 1.5;
}
if (showStreaksRef.current && isPlanetVisible) {
pLine.mesh.visible = true;
for (let i = 0; i < SAMPLES; i++) {
const tau = (i / (SAMPLES - 1)) * maxHistoryTime;
const histT = curTime - tau;
const beamDist = tau * speedAU;
const histCenterX = sunX - dirX * beamDist;
const histCenterY = sunY - dirY * beamDist;
const histTheta = omega * histT + planet.initialPhase;
const hx = histCenterX + uX * r3D * Math.cos(histTheta);
const hy = histCenterY + uY * r3D * Math.cos(histTheta);
const hz = r3D * Math.sin(histTheta);
pLine.points[i].set(hx, hy, hz);
}
pLine.updateGeometry();
} else {
pLine.mesh.visible = false;
}
});
const isEarthVisible = visiblePlanetIdsRef.current.has("earth");
const moonOmega = (2 * Math.PI) / MOON_CONFIG.orbitalPeriodYr;
const moonTheta = moonOmega * curTime + 0.4;
const moonR3D = MOON_CONFIG.radiusAU * 0.045;
const mx = earthPos.x + uX * moonR3D * Math.cos(moonTheta);
const my = earthPos.y + uY * moonR3D * Math.cos(moonTheta);
const mz = earthPos.z + moonR3D * Math.sin(moonTheta);
moonMesh.position.set(mx, my, mz);
moonMesh.visible = isEarthVisible;
if (showStreaksRef.current && isEarthVisible) {
moonPolyline.mesh.visible = true;
const moonMaxT = 1.2;
for (let i = 0; i < SAMPLES; i++) {
const tau = (i / (SAMPLES - 1)) * moonMaxT;
const histT = curTime - tau;
const beamDist = tau * speedAU;
const histCenterX = sunX - dirX * beamDist;
const histCenterY = sunY - dirY * beamDist;
const earthOmega = (2 * Math.PI) / 1.85;
const eTheta = earthOmega * histT + 4.9;
const eR3D = 76 * 0.045;
const ex = histCenterX + uX * eR3D * Math.cos(eTheta);
const ey = histCenterY + uY * eR3D * Math.cos(eTheta);
const ez = eR3D * Math.sin(eTheta);
const mTheta = moonOmega * histT + 0.4;
const mhx = ex + uX * moonR3D * Math.cos(mTheta);
const mhy = ey + uY * moonR3D * Math.cos(mTheta);
const mhz = ez + moonR3D * Math.sin(mTheta);
moonPolyline.points[i].set(mhx, mhy, mhz);
}
moonPolyline.updateGeometry();
} else {
moonPolyline.mesh.visible = false;
}
const targetX = sunX - 5.5 * dirX;
const targetY = sunY - 5.5 * dirY + 2.6;
const targetZ = 0;
const pitchRad = (cameraPitchRef.current * Math.PI) / 180;
const yawRad = (cameraYawRef.current * Math.PI) / 180;
const dist = 38.0 / zoomRef.current;
camera.position.x =
targetX + dist * Math.sin(yawRad) * Math.cos(pitchRad);
camera.position.y = targetY - dist * Math.sin(pitchRad);
camera.position.z =
targetZ + dist * Math.cos(yawRad) * Math.cos(pitchRad);
camera.lookAt(new Vec3(targetX, targetY, targetZ));
renderer.render({ scene, camera });
animId = requestAnimationFrame(loop);
};
animId = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", handleResize);
resizeObserver.disconnect();
};
}, []);
return (
<div
className={cn(
"relative w-full h-full min-h-[380px] overflow-hidden select-none bg-neutral-950",
className,
)}
>
<canvas
ref={canvasRef}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onWheel={handleWheel}
className="w-full h-full block cursor-grab active:cursor-grabbing"
style={{ touchAction: "none" }}
/>
</div>
);
}
export default SolarSystemMotion;