"use client"; import { useEffect, useRef } from "react"; import { createVanishingPoint, projectFlightStar, } from "@/features/flight-starfield/lib/projection"; import { createFlightStar, createFlightVanishXJitter, getFlightStarCount, shouldRecycleFlightStar, } from "@/features/flight-starfield/model/starfieldModel"; import { FlightStar } from "@/features/flight-starfield/model/types"; import { clamp } from "@/shared/lib/math/number"; import { getPrefersReducedMotionMediaQuery } from "@/shared/lib/motion/prefersReducedMotion"; export function FlightStarfieldCanvas({ vanishYOffset = -68, centerProtectRadius = 200, isPaused = false, }: { vanishYOffset?: number; centerProtectRadius?: number; isPaused?: boolean; }) { const canvasRef = useRef(null); const starsRef = useRef([]); const vanishXJitterRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const context = canvas.getContext("2d"); if (!context) return; let width = window.innerWidth; let height = window.innerHeight; let animationFrameId = 0; const motionQuery = getPrefersReducedMotionMediaQuery(); let prefersReducedMotion = motionQuery.matches; if (vanishXJitterRef.current === null) { vanishXJitterRef.current = createFlightVanishXJitter(); } const vanishXJitter = vanishXJitterRef.current ?? 0; const setCanvasSize = () => { width = window.innerWidth; height = window.innerHeight; canvas.width = width; canvas.height = height; }; const getVanishingPoint = () => createVanishingPoint({ width, height, xJitter: vanishXJitter, yOffset: vanishYOffset, }); const createStars = () => Array.from({ length: getFlightStarCount(width, height) }, () => createFlightStar({ width, height, isRespawn: false }), ); setCanvasSize(); if (starsRef.current.length === 0) { starsRef.current = createStars(); } let stars = starsRef.current; const applyCenterProtection = (x: number, y: number, alpha: number) => { const centerX = width / 2; const centerY = height / 2; const distance = Math.hypot(x - centerX, y - centerY); if (distance >= centerProtectRadius) return alpha; const ratio = clamp(distance / centerProtectRadius, 0, 1); const attenuation = 0.35 + ratio * 0.65; return alpha * attenuation; }; const drawStar = ({ star, fromX, fromY, toX, toY, }: { star: FlightStar; fromX: number; fromY: number; toX: number; toY: number; }) => { const visibleAlpha = applyCenterProtection(toX, toY, star.alpha); const deltaX = toX - fromX; const deltaY = toY - fromY; const movementLength = Math.hypot(deltaX, deltaY); if (star.tailLength < 1 || movementLength < 0.001) { context.globalAlpha = visibleAlpha; context.fillStyle = "#f8fbff"; context.beginPath(); context.arc(toX, toY, star.radius, 0, Math.PI * 2); context.fill(); context.globalAlpha = 1; return; } const directionX = deltaX / movementLength; const directionY = deltaY / movementLength; const tailX = toX - directionX * star.tailLength; const tailY = toY - directionY * star.tailLength; const gradient = context.createLinearGradient(tailX, tailY, toX, toY); gradient.addColorStop(0, "rgba(248, 251, 255, 0)"); gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`); context.strokeStyle = gradient; context.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6); context.beginPath(); context.moveTo(tailX, tailY); context.lineTo(toX, toY); context.stroke(); context.globalAlpha = Math.min(1, visibleAlpha + 0.08); context.fillStyle = "#f8fbff"; context.beginPath(); context.arc( toX, toY, clamp(star.radius * 0.72, 0.6, 1.45), 0, Math.PI * 2, ); context.fill(); context.globalAlpha = 1; }; const drawCenterVeil = () => { const vp = getVanishingPoint(); const veil = context.createRadialGradient( vp.x, vp.y, centerProtectRadius * 0.18, vp.x, vp.y, centerProtectRadius * 1.35, ); veil.addColorStop(0, "rgba(160, 185, 235, 0.08)"); veil.addColorStop(0.55, "rgba(90, 114, 170, 0.03)"); veil.addColorStop(1, "rgba(0, 0, 0, 0)"); context.fillStyle = veil; context.fillRect(0, 0, width, height); }; const drawVignette = () => { const vignette = context.createRadialGradient( width / 2, height / 2, Math.min(width, height) * 0.25, width / 2, height / 2, Math.max(width, height) * 0.95, ); vignette.addColorStop(0, "rgba(0, 0, 0, 0)"); vignette.addColorStop(1, "rgba(0, 0, 0, 0.82)"); context.fillStyle = vignette; context.fillRect(0, 0, width, height); }; const drawFrame = (moveStars: boolean) => { context.fillStyle = "rgba(2, 5, 10, 0.3)"; context.fillRect(0, 0, width, height); drawCenterVeil(); stars.forEach((star, index) => { const vp = getVanishingPoint(); const from = projectFlightStar(star, vp, star.z); if (moveStars) { star.z -= star.speed; } const to = projectFlightStar(star, vp, star.z); if ( shouldRecycleFlightStar({ x: to.x, y: to.y, z: star.z, width, height, }) ) { stars[index] = createFlightStar({ width, height, isRespawn: true }); starsRef.current = stars; return; } drawStar({ star, fromX: from.x, fromY: from.y, toX: to.x, toY: to.y, }); }); drawVignette(); }; const stopAnimation = () => { if (!animationFrameId) return; cancelAnimationFrame(animationFrameId); animationFrameId = 0; }; const render = () => { if (prefersReducedMotion || isPaused) { animationFrameId = 0; return; } drawFrame(true); animationFrameId = requestAnimationFrame(render); }; const renderStatic = () => { context.clearRect(0, 0, width, height); drawFrame(false); }; const handleResize = () => { setCanvasSize(); stars = createStars(); starsRef.current = stars; if (prefersReducedMotion || isPaused) { renderStatic(); } }; const handleMotionChange = (event: MediaQueryListEvent) => { prefersReducedMotion = event.matches; if (prefersReducedMotion) { stopAnimation(); renderStatic(); return; } if (!isPaused && !animationFrameId) { render(); } }; window.addEventListener("resize", handleResize); motionQuery.addEventListener("change", handleMotionChange); if (prefersReducedMotion || isPaused) { renderStatic(); } else { render(); } return () => { window.removeEventListener("resize", handleResize); motionQuery.removeEventListener("change", handleMotionChange); stopAnimation(); }; }, [vanishYOffset, centerProtectRadius, isPaused]); return ( ); }