Galaxy of Shape
Shape of galaxy? Galaxy of shape?
// 01·LIVE PREVIEW
Interactive stage// 01.B·PARAMETERS
Shape
Light Mode
Generation4,096 (12)
Spiral Winding (κ)2
Rotation (ω)0.18 rad/s
Polar Grid
Spiral Guides
Split Trails
Playing
Auto Evolve
// 02·USAGE
example.tsximport { GalaxyOfShape } from "@/components/ui/galaxy-of-shape";
export default function Example() {
return (
<div className="w-full h-[520px]">
<GalaxyOfShape
shape="square"
generation={12}
lightMode="monochrome"
spiralKappa={2}
isPlaying
/>
</div>
);
}
// 03·API REFERENCE
14 propsProp
Type
// 04·COMPONENT SOURCE
2 files"use client";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import {
buildGalaxyGenerationNodes,
DEFAULT_GALAXY_CONFIG,
evaluateLogSpiralAngle,
evaluateShapeSize,
type GalaxyLightMode,
type GalaxyShapeNode,
type GalaxyShapeType,
THREE_ARM_BASE_ANGLES,
} from "./galaxy-shape-engine";
const DEFAULT_ZOOM = 1.0;
const MIN_ZOOM = 1.0;
const MAX_ZOOM = 12.0;
export interface GalaxyOfShapeProps {
shape?: GalaxyShapeType;
generation?: number;
autoEvolve?: boolean;
sizeDecay?: number;
baseSize?: number;
spiralKappa?: number;
splitDistance?: number;
splitAngleDeg?: number;
angularVelocity?: number;
lightMode?: GalaxyLightMode;
showPolarGrid?: boolean;
showSpiralGuides?: boolean;
showSplitTrails?: boolean;
isPlaying?: boolean;
className?: string;
}
const STAR_UNIT_VERTICES: Array<{ x: number; y: number }> = (() => {
const pts: Array<{ x: number; y: number }> = [];
const spikes = 5;
const outerR = 1.0;
const innerR = 0.44;
for (let i = 0; i < spikes * 2; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (i * Math.PI) / spikes - Math.PI / 2;
pts.push({ x: r * Math.cos(angle), y: r * Math.sin(angle) });
}
return pts;
})();
export function GalaxyOfShape({
shape = "square",
generation = DEFAULT_GALAXY_CONFIG.generation,
autoEvolve = false,
sizeDecay = DEFAULT_GALAXY_CONFIG.sizeDecay,
baseSize = DEFAULT_GALAXY_CONFIG.baseSize,
spiralKappa = DEFAULT_GALAXY_CONFIG.spiralKappa,
splitDistance = DEFAULT_GALAXY_CONFIG.splitDistance,
splitAngleDeg = DEFAULT_GALAXY_CONFIG.splitAngleDeg,
angularVelocity = DEFAULT_GALAXY_CONFIG.angularVelocity,
lightMode = "galaxy-light",
showPolarGrid = false,
showSpiralGuides = false,
showSplitTrails = false,
isPlaying = true,
className = "",
}: GalaxyOfShapeProps) {
const clampedGen = Math.max(0, Math.min(13, Math.round(generation)));
const [isDragging, setIsDragging] = useState<boolean>(false);
const zoomRef = useRef<number>(DEFAULT_ZOOM);
const panRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const isDraggingRef = useRef<boolean>(false);
const dragStartRef = useRef<{
x: number;
y: number;
panX: number;
panY: number;
}>({
x: 0,
y: 0,
panX: 0,
panY: 0,
});
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const simTimeRef = useRef<number>(0);
const splitAnimRef = useRef<number>(1.0);
const evolveTimerRef = useRef<number>(0);
const currentGenRef = useRef<number>(clampedGen);
const shapeRef = useRef<GalaxyShapeType>(shape);
shapeRef.current = shape;
const isPlayingRef = useRef<boolean>(isPlaying);
isPlayingRef.current = isPlaying;
const autoEvolveRef = useRef<boolean>(autoEvolve);
autoEvolveRef.current = autoEvolve;
const sizeDecayRef = useRef<number>(sizeDecay);
sizeDecayRef.current = sizeDecay;
const baseSizeRef = useRef<number>(baseSize);
baseSizeRef.current = baseSize;
const spiralKappaRef = useRef<number>(spiralKappa);
spiralKappaRef.current = spiralKappa;
const splitDistanceRef = useRef<number>(splitDistance);
splitDistanceRef.current = splitDistance;
const splitAngleDegRef = useRef<number>(splitAngleDeg);
splitAngleDegRef.current = splitAngleDeg;
const angularVelocityRef = useRef<number>(angularVelocity);
angularVelocityRef.current = angularVelocity;
const lightModeRef = useRef<GalaxyLightMode>(lightMode);
lightModeRef.current = lightMode;
const showPolarGridRef = useRef<boolean>(showPolarGrid);
showPolarGridRef.current = showPolarGrid;
const showSpiralGuidesRef = useRef<boolean>(showSpiralGuides);
showSpiralGuidesRef.current = showSpiralGuides;
const showSplitTrailsRef = useRef<boolean>(showSplitTrails);
showSplitTrailsRef.current = showSplitTrails;
const galaxyNodes = useMemo<GalaxyShapeNode[]>(() => {
return buildGalaxyGenerationNodes(
currentGenRef.current,
spiralKappa,
splitDistance,
splitAngleDeg,
);
}, [spiralKappa, splitDistance, splitAngleDeg]);
const galaxyNodesRef = useRef<GalaxyShapeNode[]>(galaxyNodes);
galaxyNodesRef.current = galaxyNodes;
useEffect(() => {
if (currentGenRef.current !== clampedGen) {
currentGenRef.current = clampedGen;
splitAnimRef.current = 0.0;
galaxyNodesRef.current = buildGalaxyGenerationNodes(
clampedGen,
spiralKappaRef.current,
splitDistanceRef.current,
splitAngleDegRef.current,
);
}
}, [clampedGen]);
const resetZoomAndPan = useCallback(() => {
zoomRef.current = DEFAULT_ZOOM;
panRef.current = { x: 0, y: 0 };
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
isDraggingRef.current = true;
setIsDragging(true);
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
panX: panRef.current.x,
panY: panRef.current.y,
};
e.currentTarget.setPointerCapture(e.pointerId);
},
[],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDraggingRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
panRef.current = {
x: dragStartRef.current.panX + dx,
y: dragStartRef.current.panY + dy,
};
},
[],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
setIsDragging(false);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
},
[],
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let animId: number;
let lastTime = performance.now();
const handleResize = () => {
if (!canvas) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, rect.width || 800);
const height = Math.max(320, rect.height || 640);
if (
canvas.width !== Math.round(width * dpr) ||
canvas.height !== Math.round(height * dpr)
) {
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const centerX = rect.width * 0.5;
const centerY = rect.height * 0.5;
const prevZoom = zoomRef.current;
const zoomFactor = e.deltaY < 0 ? 1.12 : 0.89;
const nextZoom = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, Number((prevZoom * zoomFactor).toFixed(2))),
);
if (nextZoom === prevZoom) return;
if (nextZoom <= DEFAULT_ZOOM) {
zoomRef.current = DEFAULT_ZOOM;
panRef.current = { x: 0, y: 0 };
} else {
const scaleRatio = nextZoom / prevZoom;
const relX = mouseX - (centerX + panRef.current.x);
const relY = mouseY - (centerY + panRef.current.y);
panRef.current = {
x: mouseX - centerX - relX * scaleRatio,
y: mouseY - centerY - relY * scaleRatio,
};
zoomRef.current = nextZoom;
}
};
handleResize();
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(canvas);
window.addEventListener("resize", handleResize);
canvas.addEventListener("wheel", handleWheel, { passive: false });
const drawShapePrimitive = (
context: CanvasRenderingContext2D,
shapeType: GalaxyShapeType,
px: number,
py: number,
size: number,
rotation: number,
fillColor: string,
withBorder: boolean,
alpha: number,
) => {
context.globalAlpha = alpha;
context.fillStyle = fillColor;
if (shapeType === "square") {
const half = size * 0.5;
if (size <= 3.6 && !withBorder) {
context.fillRect(px - half, py - half, size, size);
} else {
context.save();
context.translate(px, py);
context.rotate(rotation);
context.fillRect(-half, -half, size, size);
if (withBorder) {
context.strokeStyle = "rgba(255, 255, 255, 0.72)";
context.lineWidth = Math.max(0.75, Math.min(1.35, size * 0.1));
context.strokeRect(-half, -half, size, size);
}
context.restore();
}
} else if (shapeType === "circle") {
const radius = size * 0.5;
context.beginPath();
context.arc(px, py, radius, 0, Math.PI * 2);
context.fill();
if (withBorder) {
context.strokeStyle = "rgba(255, 255, 255, 0.72)";
context.lineWidth = Math.max(0.75, Math.min(1.35, size * 0.1));
context.stroke();
}
} else {
const outerR = size * 0.65;
if (outerR <= 1.8 && !withBorder) {
context.fillRect(
px - outerR * 0.7,
py - outerR * 0.7,
outerR * 1.4,
outerR * 1.4,
);
} else {
context.save();
context.translate(px, py);
context.rotate(rotation);
context.beginPath();
for (let v = 0; v < STAR_UNIT_VERTICES.length; v++) {
const vx = STAR_UNIT_VERTICES[v].x * outerR;
const vy = STAR_UNIT_VERTICES[v].y * outerR;
if (v === 0) context.moveTo(vx, vy);
else context.lineTo(vx, vy);
}
context.closePath();
context.fill();
if (withBorder) {
context.strokeStyle = "rgba(255, 255, 255, 0.72)";
context.lineWidth = Math.max(0.7, Math.min(1.25, size * 0.08));
context.stroke();
}
context.restore();
}
}
};
const renderFrame = (now: number) => {
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = canvas.width / dpr;
const height = canvas.height / dpr;
if (isPlayingRef.current) {
simTimeRef.current += dt;
if (splitAnimRef.current < 1.0) {
splitAnimRef.current = Math.min(
1.0,
splitAnimRef.current + dt * 1.85,
);
}
if (autoEvolveRef.current) {
evolveTimerRef.current += dt;
const holdDuration = currentGenRef.current === 13 ? 3.6 : 1.65;
if (evolveTimerRef.current >= holdDuration) {
evolveTimerRef.current = 0;
splitAnimRef.current = 0.0;
const nextG =
currentGenRef.current >= 13 ? 0 : currentGenRef.current + 1;
currentGenRef.current = nextG;
galaxyNodesRef.current = buildGalaxyGenerationNodes(
nextG,
spiralKappaRef.current,
splitDistanceRef.current,
splitAngleDegRef.current,
);
}
}
}
const curZoom = zoomRef.current;
const curPan = panRef.current;
const cx = width * 0.5 + curPan.x;
const cy = height * 0.5 + curPan.y;
const maxRadiusPx = Math.min(width, height) * 0.44 * curZoom;
const curGen = currentGenRef.current;
const curShape = shapeRef.current;
const curQ = sizeDecayRef.current;
const curBaseSize = baseSizeRef.current;
const curKappa = spiralKappaRef.current;
const curOmega = angularVelocityRef.current;
const curTime = simTimeRef.current;
const globalRot = curOmega * curTime;
const activeLight = lightModeRef.current;
const rawTau = splitAnimRef.current;
const tau = 1 - (1 - rawTau) ** 3;
ctx.save();
ctx.scale(dpr, dpr);
const bgGrad = ctx.createRadialGradient(
cx,
cy,
maxRadiusPx * 0.02,
cx,
cy,
Math.max(width, height) * 0.72 * curZoom,
);
if (activeLight === "galaxy-light") {
bgGrad.addColorStop(0.0, "#31276e");
bgGrad.addColorStop(0.24, "#1b184a");
bgGrad.addColorStop(0.58, "#0d1130");
bgGrad.addColorStop(1.0, "#050614");
} else if (activeLight === "monochrome") {
bgGrad.addColorStop(0.0, "#18181b");
bgGrad.addColorStop(0.5, "#09090b");
bgGrad.addColorStop(1.0, "#020204");
} else {
bgGrad.addColorStop(0.0, "#241e56");
bgGrad.addColorStop(0.38, "#16133d");
bgGrad.addColorStop(0.75, "#0b0926");
bgGrad.addColorStop(1.0, "#050414");
}
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
if (showPolarGridRef.current) {
const gridOpacity = activeLight === "galaxy-light" ? 0.18 : 0.5;
ctx.save();
ctx.strokeStyle = `rgba(148, 163, 225, ${0.13 * gridOpacity})`;
ctx.lineWidth = 0.8;
const ringRadiiNorm = [0.085, 0.15, 0.23, 0.33, 0.45, 0.58, 0.74];
for (let i = 0; i < ringRadiiNorm.length; i++) {
const rPx = ringRadiiNorm[i] * maxRadiusPx;
ctx.beginPath();
ctx.arc(cx, cy, rPx, 0, Math.PI * 2);
ctx.stroke();
}
const innerR = ringRadiiNorm[0] * maxRadiusPx;
const outerR = maxRadiusPx * 1.15;
ctx.strokeStyle = `rgba(148, 163, 225, ${0.12 * gridOpacity})`;
for (let deg = 0; deg < 360; deg += 30) {
const rad = (deg * Math.PI) / 180;
ctx.beginPath();
ctx.moveTo(cx + innerR * Math.cos(rad), cy + innerR * Math.sin(rad));
ctx.lineTo(cx + outerR * Math.cos(rad), cy + outerR * Math.sin(rad));
ctx.stroke();
}
ctx.restore();
}
if (showSpiralGuidesRef.current && curGen >= 2) {
const guideAlpha = activeLight === "galaxy-light" ? 0.16 : 0.36;
const r0 = 0.044;
const discEdgeR = 0.78;
ctx.save();
ctx.strokeStyle = `rgba(196, 181, 253, ${guideAlpha})`;
ctx.lineWidth = 1.2;
for (let arm = 0; arm < 3; arm++) {
const armTheta0 = THREE_ARM_BASE_ANGLES[arm] + globalRot;
ctx.beginPath();
const steps = 150;
for (let s = 0; s <= steps; s++) {
const rNorm = r0 + (s / steps) * (discEdgeR - r0);
const theta = evaluateLogSpiralAngle(
rNorm,
r0,
armTheta0,
curKappa,
);
const sx = cx + rNorm * maxRadiusPx * Math.cos(theta);
const sy = cy + rNorm * maxRadiusPx * Math.sin(theta);
if (s === 0) ctx.moveTo(sx, sy);
else ctx.lineTo(sx, sy);
}
ctx.stroke();
}
ctx.restore();
}
if (activeLight === "galaxy-light") {
ctx.save();
ctx.globalCompositeOperation = "lighter";
const r0 = 0.044;
for (let arm = 0; arm < 3; arm++) {
const armTheta0 = THREE_ARM_BASE_ANGLES[arm] + globalRot;
const puffSteps = 28;
for (let s = 1; s <= puffSteps; s++) {
const frac = s / puffSteps;
const rNorm = 0.065 + frac * 0.695;
const theta = evaluateLogSpiralAngle(
rNorm,
r0,
armTheta0,
curKappa,
);
const px = cx + rNorm * maxRadiusPx * Math.cos(theta);
const py = cy + rNorm * maxRadiusPx * Math.sin(theta);
const puffRadius = maxRadiusPx * (0.072 + frac * 0.105);
const edgeFade = (1.0 - frac) ** 1.3;
const puffAlpha = 0.046 * edgeFade;
const armGlow = ctx.createRadialGradient(
px,
py,
0,
px,
py,
puffRadius,
);
armGlow.addColorStop(0.0, `rgba(180, 195, 255, ${puffAlpha})`);
armGlow.addColorStop(
0.5,
`rgba(129, 140, 248, ${puffAlpha * 0.45})`,
);
armGlow.addColorStop(1.0, "rgba(30, 27, 75, 0.0)");
ctx.fillStyle = armGlow;
ctx.beginPath();
ctx.arc(px, py, puffRadius, 0, Math.PI * 2);
ctx.fill();
}
}
const coreGlow = ctx.createRadialGradient(
cx,
cy,
0,
cx,
cy,
maxRadiusPx * 0.46,
);
coreGlow.addColorStop(0.0, "rgba(255, 255, 255, 0.96)");
coreGlow.addColorStop(0.055, "rgba(255, 255, 255, 0.85)");
coreGlow.addColorStop(0.11, "rgba(224, 231, 255, 0.52)");
coreGlow.addColorStop(0.24, "rgba(165, 180, 252, 0.22)");
coreGlow.addColorStop(0.52, "rgba(109, 40, 217, 0.07)");
coreGlow.addColorStop(1.0, "rgba(15, 23, 42, 0.0)");
ctx.fillStyle = coreGlow;
ctx.beginPath();
ctx.arc(cx, cy, maxRadiusPx * 0.46, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
const nodes = galaxyNodesRef.current;
const cosG = Math.cos(globalRot);
const sinG = Math.sin(globalRot);
if (showSplitTrailsRef.current && curGen >= 1 && curGen <= 8) {
ctx.save();
const trailAlpha = Math.max(0.08, 0.28 - curGen * 0.022);
ctx.strokeStyle = `rgba(196, 181, 253, ${trailAlpha})`;
ctx.lineWidth = 0.85;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const px0 = n.parentX * cosG - n.parentY * sinG;
const py0 = n.parentX * sinG + n.parentY * cosG;
const ix = n.parentX + (n.x - n.parentX) * tau;
const iy = n.parentY + (n.y - n.parentY) * tau;
const px1 = ix * cosG - iy * sinG;
const py1 = iy * cosG + ix * sinG;
const sx0 = cx + px0 * maxRadiusPx;
const sy0 = cy + py0 * maxRadiusPx;
const sx1 = cx + px1 * maxRadiusPx;
const sy1 = cy + py1 * maxRadiusPx;
const midX = (sx0 + sx1) * 0.5 - (sy1 - sy0) * 0.18 * n.lastSign;
const midY = (sy0 + sy1) * 0.5 + (sx1 - sx0) * 0.18 * n.lastSign;
ctx.beginPath();
ctx.moveTo(sx0, sy0);
ctx.quadraticCurveTo(midX, midY, sx1, sy1);
ctx.stroke();
}
ctx.restore();
}
const effectiveGen = Math.max(0, curGen - 1 + tau);
const shapeSizePx =
evaluateShapeSize(curBaseSize, curQ, effectiveGen) * curZoom;
const withCrispBorder = activeLight === "palette" && curGen <= 6;
ctx.save();
if (activeLight === "galaxy-light") {
ctx.globalCompositeOperation = "lighter";
}
if (curGen <= 5 && activeLight === "palette") {
ctx.shadowBlur = 8;
ctx.shadowColor = "rgba(255, 255, 255, 0.35)";
}
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const localX = n.parentX + (n.x - n.parentX) * tau;
const localY = n.parentY + (n.y - n.parentY) * tau;
const rx = localX * cosG - localY * sinG;
const ry = localX * sinG + localY * cosG;
const screenX = cx + rx * maxRadiusPx;
const screenY = cy + ry * maxRadiusPx;
let color = n.paletteColor;
let alpha = 0.95;
let drawSize = shapeSizePx;
if (activeLight === "galaxy-light") {
color = n.galaxyColor;
alpha = Math.min(1.0, 0.32 + n.luminosity * 0.68);
drawSize = shapeSizePx * (0.72 + n.luminosity * 0.78);
if (n.luminosity > 0.72 && curGen >= 10) {
const haloSize = drawSize * 2.2;
ctx.globalAlpha = alpha * 0.24;
ctx.fillStyle = "#a5b4fc";
ctx.fillRect(
screenX - haloSize * 0.5,
screenY - haloSize * 0.5,
haloSize,
haloSize,
);
}
} else if (activeLight === "monochrome") {
color = "#f8fafc";
alpha = 0.45 + n.luminosity * 0.55;
drawSize = shapeSizePx * (0.75 + n.luminosity * 0.65);
}
drawShapePrimitive(
ctx,
curShape,
screenX,
screenY,
drawSize,
n.localRotation + globalRot,
color,
withCrispBorder,
alpha,
);
}
ctx.restore();
ctx.restore();
animId = requestAnimationFrame(renderFrame);
};
animId = requestAnimationFrame(renderFrame);
return () => {
cancelAnimationFrame(animId);
resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
canvas.removeEventListener("wheel", handleWheel);
};
}, []);
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}
onDoubleClick={resetZoomAndPan}
className={`w-full h-full block touch-none ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
title="Scroll to zoom, drag to pan, double-click to reset view"
/>
</div>
);
}
export default GalaxyOfShape;