refactor: fsd 구조로 변환

This commit is contained in:
2026-02-13 15:20:35 +09:00
parent bb1a6fbdab
commit d60d4ccd9e
45 changed files with 1283 additions and 1222 deletions

View File

@@ -0,0 +1,249 @@
'use client';
import { useEffect, useRef } from 'react';
import { projectFlightStar, createVanishingPoint } 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,
}: {
vanishYOffset?: number;
centerProtectRadius?: number;
}) {
const canvasRef = useRef<HTMLCanvasElement>(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;
const vanishXJitter = createFlightVanishXJitter();
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();
let stars: FlightStar[] = createStars();
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 });
return;
}
drawStar({
star,
fromX: from.x,
fromY: from.y,
toX: to.x,
toY: to.y,
});
});
drawVignette();
};
const render = () => {
drawFrame(true);
animationFrameId = requestAnimationFrame(render);
};
const renderStatic = () => {
context.clearRect(0, 0, width, height);
drawFrame(false);
};
const handleResize = () => {
setCanvasSize();
stars = createStars();
if (prefersReducedMotion) {
renderStatic();
}
};
const handleMotionChange = (event: MediaQueryListEvent) => {
prefersReducedMotion = event.matches;
if (prefersReducedMotion) {
cancelAnimationFrame(animationFrameId);
renderStatic();
return;
}
render();
};
window.addEventListener('resize', handleResize);
motionQuery.addEventListener('change', handleMotionChange);
if (prefersReducedMotion) {
renderStatic();
} else {
render();
}
return () => {
window.removeEventListener('resize', handleResize);
motionQuery.removeEventListener('change', handleMotionChange);
cancelAnimationFrame(animationFrameId);
};
}, [vanishYOffset, centerProtectRadius]);
return <canvas ref={canvasRef} className="fixed inset-0 z-0 bg-black pointer-events-none" />;
}