Fourier Draw
Spinning circles on top of spinning circles until a drawing appears.
// 01·LIVE PREVIEW
Interactive stage// 01.B·PARAMETERS
Shape
Kinematics Style
Harmonics (N)N=64
Speed1x
Epicycles
Coordinate Grid
Ghost Silhouette
Curve Loop
Traced Trail
Animation
Playing
// 02·USAGE
example.tsximport { FourierDraw } from "@/components/ui/fourier-draw";
export default function Example() {
return (
<div className="w-full h-[520px]">
<FourierDraw
preset="world-map"
harmonics={64}
chainStyle="single-center"
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 {
type EpicycleChainStyle,
evaluateFourierPoint,
type FourierMultiShapeData,
type FourierSeriesData,
getCenterEpicycles,
getHorizontalEpicycles,
getPresetMultiShapeData,
getVerticalEpicycles,
type NamedGeometryPath,
type Point2D,
} from "./fourier-engine";
export type FourierPresetType = "world-map" | "pythagoras-tree" | "custom-svg";
export const POWER_OF_TWO_HARMONICS = [
8, 16, 32, 64, 128, 256, 512, 1024,
] as const;
export interface FourierDrawProps {
harmonics?: number;
preset?: FourierPresetType;
chainStyle?: EpicycleChainStyle;
speed?: number;
isPlaying?: boolean;
showEpicycles?: boolean;
showGrid?: boolean;
showGhostTarget?: boolean;
showCurveLoop?: boolean;
showTracedTrail?: boolean;
showCentroid?: boolean;
replayKey?: number;
maxHarmonicsToRender?: number;
selectedShapeId?: string;
customGeometries?: NamedGeometryPath[] | null;
enablePanZoom?: boolean;
className?: string;
onComplete?: () => void;
}
const GHOST_SILHOUETTE_CACHE = new Map<string, Map<string, Point2D[]>>();
const RECONSTRUCTED_CURVES_CACHE = new Map<string, Map<string, Point2D[]>>();
function getGhostSilhouetteCurves(
preset: string,
multiShapeData: FourierMultiShapeData,
): Map<string, Point2D[]> {
const cached = GHOST_SILHOUETTE_CACHE.get(preset);
if (cached) return cached;
const samples = 1000;
const map = new Map<string, Point2D[]>();
for (const s of multiShapeData.shapes) {
const pts: Point2D[] = [];
const n = Math.min(256, s.series.harmonics.length);
for (let i = 0; i <= samples; i++) {
const t = (2 * Math.PI * i) / samples;
pts.push(evaluateFourierPoint(s.series, n, t));
}
map.set(s.id, pts);
}
if (preset !== "custom-svg") {
GHOST_SILHOUETTE_CACHE.set(preset, map);
}
return map;
}
function getReconstructedCurves(
preset: string,
multiShapeData: FourierMultiShapeData,
harmonics: number,
): Map<string, Point2D[]> {
const cacheKey = `${preset}-${harmonics}`;
const cached = RECONSTRUCTED_CURVES_CACHE.get(cacheKey);
if (cached) return cached;
const samples = Math.max(720, Math.min(1800, harmonics * 3));
const map = new Map<string, Point2D[]>();
for (const s of multiShapeData.shapes) {
const pts: Point2D[] = [];
for (let i = 0; i <= samples; i++) {
const t = (2 * Math.PI * i) / samples;
pts.push(evaluateFourierPoint(s.series, harmonics, t));
}
map.set(s.id, pts);
}
if (preset !== "custom-svg") {
RECONSTRUCTED_CURVES_CACHE.set(cacheKey, map);
}
return map;
}
if (typeof window !== "undefined") {
const warmUp = () => {
try {
const wm = getPresetMultiShapeData("world-map");
getGhostSilhouetteCurves("world-map", wm);
getReconstructedCurves("world-map", wm, 64);
const pt = getPresetMultiShapeData("pythagoras-tree");
getGhostSilhouetteCurves("pythagoras-tree", pt);
getReconstructedCurves("pythagoras-tree", pt, 64);
getReconstructedCurves("pythagoras-tree", pt, 128);
} catch {
}
};
if ("requestIdleCallback" in window) {
(
window as unknown as { requestIdleCallback: (cb: () => void) => void }
).requestIdleCallback(warmUp);
} else {
setTimeout(warmUp, 50);
}
}
const DEFAULT_ZOOM = 1.0;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 8.0;
export function FourierDraw({
harmonics = 64,
preset = "world-map",
chainStyle = "single-center",
speed = 1.0,
isPlaying = true,
showEpicycles = true,
showGrid = false,
showGhostTarget = true,
showCurveLoop = true,
showTracedTrail = true,
showCentroid = false,
maxHarmonicsToRender = 20,
selectedShapeId = "all",
customGeometries = null,
enablePanZoom = true,
className = "",
onComplete,
}: FourierDrawProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const clampedHarmonics = Math.max(8, Math.min(1024, Math.round(harmonics)));
const tParamRef = useRef<number>(0);
const isPlayingRef = useRef<boolean>(isPlaying);
isPlayingRef.current = isPlaying;
const isFinishedRef = useRef<boolean>(false);
const speedRef = useRef<number>(speed);
speedRef.current = speed;
const activeShapeIndexRef = useRef<number>(0);
const trailRef = useRef<Point2D[]>([]);
const shapeTrailsRef = useRef<Map<string, Point2D[]>>(new Map());
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 }>({ x: 0, y: 0 });
const panStartRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const multiShapeData = useMemo<FourierMultiShapeData>(() => {
return getPresetMultiShapeData(preset, customGeometries);
}, [preset, customGeometries]);
const reconstructedCurvesFull = useMemo<Map<string, Point2D[]>>(() => {
return getGhostSilhouetteCurves(preset, multiShapeData);
}, [preset, multiShapeData]);
const reconstructedCurves = useMemo<Map<string, Point2D[]>>(() => {
return getReconstructedCurves(preset, multiShapeData, clampedHarmonics);
}, [preset, multiShapeData, clampedHarmonics]);
useEffect(() => {
tParamRef.current = 0;
isFinishedRef.current = false;
trailRef.current = [];
shapeTrailsRef.current.clear();
activeShapeIndexRef.current = 0;
}, [clampedHarmonics, preset, chainStyle, selectedShapeId]);
const resetZoomAndPan = useCallback(() => {
zoomRef.current = DEFAULT_ZOOM;
panRef.current = { x: 0, y: 0 };
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!enablePanZoom) return;
isDraggingRef.current = true;
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
panStartRef.current = { ...panRef.current };
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
},
[enablePanZoom],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!enablePanZoom || !isDraggingRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const dy = e.clientY - dragStartRef.current.y;
panRef.current = {
x: panStartRef.current.x + dx,
y: panStartRef.current.y + dy,
};
},
[enablePanZoom],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!enablePanZoom) return;
isDraggingRef.current = false;
setIsDragging(false);
try {
(e.target as HTMLElement).releasePointerCapture?.(e.pointerId);
} catch {
}
},
[enablePanZoom],
);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !enablePanZoom) return;
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;
}
};
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
canvas.removeEventListener("wheel", handleWheel);
};
}, [enablePanZoom]);
const drawCanvas = useCallback(
(currentT: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const displayWidth = canvas.clientWidth || 800;
const displayHeight = canvas.clientHeight || 560;
if (
canvas.width !== Math.round(displayWidth * dpr) ||
canvas.height !== Math.round(displayHeight * dpr)
) {
canvas.width = Math.round(displayWidth * dpr);
canvas.height = Math.round(displayHeight * dpr);
}
ctx.save();
ctx.scale(dpr, dpr);
const W = displayWidth;
const H = displayHeight;
ctx.fillStyle = "#0a0a0a";
ctx.fillRect(0, 0, W, H);
const curZoom = zoomRef.current;
const curPan = panRef.current;
const isCenterStyle = chainStyle === "single-center";
const baseCx = isCenterStyle ? Math.round(W * 0.5) : Math.round(W * 0.57);
const baseCy = isCenterStyle ? Math.round(H * 0.5) : Math.round(H * 0.57);
const Cx = baseCx + curPan.x;
const Cy = baseCy + curPan.y;
const radialBg = ctx.createRadialGradient(
Cx,
Cy,
10,
Cx,
Cy,
Math.max(W, H) * 0.6 * curZoom,
);
radialBg.addColorStop(0, "rgba(255, 255, 255, 0.04)");
radialBg.addColorStop(0.5, "rgba(255, 255, 255, 0.015)");
radialBg.addColorStop(1, "rgba(3, 7, 18, 0)");
ctx.fillStyle = radialBg;
ctx.fillRect(0, 0, W, H);
const { shapes, globalBounds } = multiShapeData;
const numShapes = Math.max(1, shapes.length);
const isSimultaneousCenter =
isCenterStyle && selectedShapeId === "all" && numShapes > 1;
const maxBound = Math.max(
Math.abs(globalBounds.maxX - globalBounds.a0),
Math.abs(globalBounds.minX - globalBounds.a0),
Math.abs(globalBounds.maxY - globalBounds.c0),
Math.abs(globalBounds.minY - globalBounds.c0),
1,
);
const targetRadius =
(isCenterStyle
? Math.min(W * 0.42, H * 0.42)
: Math.min(W * 0.35, H * 0.36)) * curZoom;
const S = targetRadius / maxBound;
const topBaseY = Cy - targetRadius * 1.1;
const leftBaseX = Cx - targetRadius * 1.1;
if (showGrid) {
ctx.save();
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 1.0;
ctx.setLineDash([3, 5]);
ctx.beginPath();
ctx.moveTo(0, Cy);
ctx.lineTo(W, Cy);
ctx.moveTo(Cx, 0);
ctx.lineTo(Cx, H);
ctx.stroke();
ctx.strokeStyle = "rgba(255, 255, 255, 0.03)";
ctx.setLineDash([2, 6]);
const gridStep = Math.max(40, 80 * S);
ctx.beginPath();
for (let x = Cx + gridStep; x < W; x += gridStep) {
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
}
for (let x = Cx - gridStep; x > 0; x -= gridStep) {
ctx.moveTo(x, 0);
ctx.lineTo(x, H);
}
for (let y = Cy + gridStep; y < H; y += gridStep) {
ctx.moveTo(0, y);
ctx.lineTo(W, y);
}
for (let y = Cy - gridStep; y > 0; y -= gridStep) {
ctx.moveTo(0, y);
ctx.lineTo(W, y);
}
ctx.stroke();
ctx.restore();
}
if (showGhostTarget) {
ctx.save();
ctx.setLineDash([3, 5]);
for (const s of shapes) {
const pts = reconstructedCurvesFull.get(s.id);
if (!pts || pts.length === 0) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const px = Cx + (pts[i].x - globalBounds.a0) * S;
const py = Cy + (pts[i].y - globalBounds.c0) * S;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
if (selectedShapeId !== "all" && s.id !== selectedShapeId) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.05)";
ctx.lineWidth = 0.8;
} else {
ctx.strokeStyle = "rgba(255, 255, 255, 0.14)";
ctx.lineWidth = 1.0;
}
ctx.stroke();
}
ctx.restore();
}
const safeT = Number.isFinite(currentT)
? Math.max(0, Math.min(2 * Math.PI, currentT))
: 0;
const progressFraction = Math.min(
1.0,
Math.max(0.0, safeT / (2 * Math.PI)),
);
const finished = isFinishedRef.current;
if (isSimultaneousCenter) {
for (const s of shapes) {
const anchorX = Cx + (s.series.a0 - globalBounds.a0) * S;
const anchorY = Cy + (s.series.c0 - globalBounds.c0) * S;
const scaledData: FourierSeriesData = {
...s.series,
a0: anchorX,
c0: anchorY,
harmonics: s.series.harmonics.map((h) => ({
...h,
ak: h.ak * S,
bk: h.bk * S,
ck: h.ck * S,
dk: h.dk * S,
rx: h.rx * S,
ry: h.ry * S,
})),
};
const centerChain = getCenterEpicycles(
scaledData,
clampedHarmonics,
safeT,
anchorX,
anchorY,
true,
);
const penPoint = centerChain.finalTip;
if (showCurveLoop) {
const pts = reconstructedCurves.get(s.id);
if (pts && pts.length > 0) {
ctx.save();
if (finished) {
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const px = Cx + (pts[i].x - globalBounds.a0) * S;
const py = Cy + (pts[i].y - globalBounds.c0) * S;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = "rgba(255, 255, 255, 0.90)";
ctx.lineWidth = 1.8;
ctx.shadowColor = "rgba(255, 255, 255, 0.25)";
ctx.shadowBlur = 6;
ctx.stroke();
ctx.fillStyle = "rgba(255, 255, 255, 0.04)";
ctx.fill();
} else {
const maxIdx = Math.min(
pts.length - 1,
Math.floor(progressFraction * (pts.length - 1)),
);
ctx.beginPath();
for (let i = 0; i <= maxIdx; i++) {
const px = Cx + (pts[i].x - globalBounds.a0) * S;
const py = Cy + (pts[i].y - globalBounds.c0) * S;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.lineTo(penPoint.x, penPoint.y);
if (progressFraction >= 0.998) {
ctx.closePath();
}
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = 2.0;
ctx.shadowColor = "rgba(255, 255, 255, 0.50)";
ctx.shadowBlur = 8;
ctx.stroke();
}
ctx.restore();
}
}
if (showTracedTrail && !finished) {
let tList = shapeTrailsRef.current.get(s.id);
if (!tList) {
tList = [];
shapeTrailsRef.current.set(s.id, tList);
}
tList.push(penPoint);
if (tList.length > 120) tList.shift();
if (tList.length > 1) {
ctx.save();
for (let j = 1; j < tList.length; j++) {
const p1 = tList[j - 1];
const p2 = tList[j];
const alpha = j / tList.length;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.7})`;
ctx.lineWidth = 2.0 * alpha + 0.4;
ctx.shadowColor = "rgba(255, 255, 255, 0.4)";
ctx.shadowBlur = 4 * alpha;
ctx.stroke();
}
ctx.restore();
}
}
if (showEpicycles && !finished) {
ctx.save();
const numCircles = Math.min(
centerChain.circles.length,
maxHarmonicsToRender * 2,
);
const maxR = centerChain.circles[0]?.radius || 1;
for (let i = 0; i < numCircles; i++) {
const c = centerChain.circles[i];
if (c.radius < 0.6) continue;
const ratio = i / Math.max(1, numCircles);
const alpha = Math.max(
0.08,
Math.min(0.35, 0.12 + (c.radius / maxR) * 0.25),
);
ctx.beginPath();
ctx.arc(c.centerX, c.centerY, c.radius, 0, 2 * Math.PI);
if (i === 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
ctx.lineWidth = 1.0;
} else if (i === 1) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
ctx.lineWidth = 0.9;
} else {
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.75})`;
ctx.lineWidth = 0.75;
}
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c.centerX, c.centerY);
ctx.lineTo(c.tipX, c.tipY);
if (i === 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = `rgba(255, 255, 255, ${Math.max(
0.25,
0.75 - ratio * 0.4,
)})`;
ctx.lineWidth = Math.max(0.7, 1.3 - ratio * 0.6);
}
ctx.stroke();
ctx.beginPath();
ctx.arc(
c.centerX,
c.centerY,
i === 0 ? 1.4 : Math.max(0.7, 1.2 - ratio * 0.5),
0,
2 * Math.PI,
);
ctx.fillStyle =
i === 0
? "rgba(255, 255, 255, 0.95)"
: "rgba(255, 255, 255, 0.55)";
ctx.fill();
}
if (showCentroid) {
ctx.beginPath();
ctx.arc(anchorX, anchorY, 1.6, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 4;
ctx.fill();
}
ctx.beginPath();
ctx.arc(
centerChain.finalTip.x,
centerChain.finalTip.y,
1.8,
0,
2 * Math.PI,
);
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 6;
ctx.fill();
ctx.restore();
}
if (!finished) {
ctx.save();
ctx.beginPath();
ctx.arc(penPoint.x, penPoint.y, 5, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.20)";
ctx.fill();
ctx.beginPath();
ctx.arc(penPoint.x, penPoint.y, 2.4, 0, 2 * Math.PI);
ctx.fillStyle = "#ffffff";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 8;
ctx.fill();
ctx.restore();
}
}
if (finished) {
shapeTrailsRef.current.clear();
}
} else {
let activeShapeIndex = 0;
let localProgress = progressFraction;
let localT = safeT;
if (selectedShapeId !== "all") {
const foundIdx = shapes.findIndex((s) => s.id === selectedShapeId);
activeShapeIndex = foundIdx >= 0 ? foundIdx : 0;
localProgress = progressFraction;
localT = safeT;
} else if (numShapes > 1) {
if (finished || progressFraction >= 0.999) {
activeShapeIndex = numShapes - 1;
localProgress = 1.0;
localT = 2 * Math.PI;
} else {
const rawIdx = Math.floor(progressFraction * numShapes);
activeShapeIndex = Math.max(
0,
Math.min(numShapes - 1, Number.isFinite(rawIdx) ? rawIdx : 0),
);
localProgress = Math.max(
0,
Math.min(1.0, progressFraction * numShapes - activeShapeIndex),
);
localT = localProgress * 2 * Math.PI;
}
}
activeShapeIndex = Math.max(
0,
Math.min(numShapes - 1, activeShapeIndex),
);
if (activeShapeIndexRef.current !== activeShapeIndex) {
activeShapeIndexRef.current = activeShapeIndex;
trailRef.current = [];
}
const activeShape = shapes[activeShapeIndex] || shapes[0];
if (!activeShape?.series) {
ctx.restore();
return;
}
const anchorX = Cx + (activeShape.series.a0 - globalBounds.a0) * S;
const anchorY = Cy + (activeShape.series.c0 - globalBounds.c0) * S;
const scaledActiveData: FourierSeriesData = {
...activeShape.series,
a0: anchorX,
c0: anchorY,
harmonics: activeShape.series.harmonics.map((h) => ({
...h,
ak: h.ak * S,
bk: h.bk * S,
ck: h.ck * S,
dk: h.dk * S,
rx: h.rx * S,
ry: h.ry * S,
})),
};
const topChain = getHorizontalEpicycles(
scaledActiveData,
clampedHarmonics,
localT,
anchorX,
topBaseY,
);
const leftChain = getVerticalEpicycles(
scaledActiveData,
clampedHarmonics,
localT,
leftBaseX,
anchorY,
);
const centerChain = getCenterEpicycles(
scaledActiveData,
clampedHarmonics,
localT,
anchorX,
anchorY,
true,
);
const penPoint = isCenterStyle
? centerChain.finalTip
: { x: topChain.finalTip.x, y: leftChain.finalTip.y };
if (showCurveLoop) {
ctx.save();
shapes.forEach((s, idx) => {
const pts = reconstructedCurves.get(s.id);
if (!pts || pts.length === 0) return;
const isFullyCompleted =
selectedShapeId === "all"
? idx < activeShapeIndex || finished
: s.id === selectedShapeId && finished;
const isActivelyDrawing =
selectedShapeId === "all"
? idx === activeShapeIndex && !finished
: s.id === selectedShapeId && !finished;
if (isFullyCompleted) {
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
const px = Cx + (pts[i].x - globalBounds.a0) * S;
const py = Cy + (pts[i].y - globalBounds.c0) * S;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.strokeStyle = "rgba(255, 255, 255, 0.90)";
ctx.lineWidth = 1.8;
ctx.shadowColor = "rgba(255, 255, 255, 0.25)";
ctx.shadowBlur = 6;
ctx.stroke();
ctx.fillStyle = "rgba(255, 255, 255, 0.04)";
ctx.fill();
} else if (isActivelyDrawing) {
const maxIdx = Math.min(
pts.length - 1,
Math.floor(localProgress * (pts.length - 1)),
);
ctx.beginPath();
for (let i = 0; i <= maxIdx; i++) {
const px = Cx + (pts[i].x - globalBounds.a0) * S;
const py = Cy + (pts[i].y - globalBounds.c0) * S;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.lineTo(penPoint.x, penPoint.y);
if (localProgress >= 0.998) {
ctx.closePath();
}
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = 2.0;
ctx.shadowColor = "rgba(255, 255, 255, 0.50)";
ctx.shadowBlur = 8;
ctx.stroke();
}
});
ctx.restore();
}
if (showTracedTrail && !finished) {
trailRef.current.push(penPoint);
if (trailRef.current.length > 250) {
trailRef.current.shift();
}
if (trailRef.current.length > 1) {
ctx.save();
for (let i = 1; i < trailRef.current.length; i++) {
const p1 = trailRef.current[i - 1];
const p2 = trailRef.current[i];
const alpha = i / trailRef.current.length;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.7})`;
ctx.lineWidth = 2.0 * alpha + 0.4;
ctx.shadowColor = "rgba(255, 255, 255, 0.4)";
ctx.shadowBlur = 4 * alpha;
ctx.stroke();
}
ctx.restore();
}
} else if (finished) {
trailRef.current = [];
}
if (showEpicycles && !finished) {
if (isCenterStyle) {
ctx.save();
const numCircles = Math.min(
centerChain.circles.length,
maxHarmonicsToRender * 2,
);
const maxR = centerChain.circles[0]?.radius || 1;
for (let i = 0; i < numCircles; i++) {
const c = centerChain.circles[i];
if (c.radius < 0.6) continue;
const ratio = i / Math.max(1, numCircles);
const alpha = Math.max(
0.08,
Math.min(0.35, 0.12 + (c.radius / maxR) * 0.25),
);
ctx.beginPath();
ctx.arc(c.centerX, c.centerY, c.radius, 0, 2 * Math.PI);
if (i === 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
ctx.lineWidth = 1.0;
} else if (i === 1) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.25)";
ctx.lineWidth = 0.9;
} else {
ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.75})`;
ctx.lineWidth = 0.75;
}
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c.centerX, c.centerY);
ctx.lineTo(c.tipX, c.tipY);
if (i === 0) {
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = `rgba(255, 255, 255, ${Math.max(
0.25,
0.75 - ratio * 0.4,
)})`;
ctx.lineWidth = Math.max(0.7, 1.3 - ratio * 0.6);
}
ctx.stroke();
ctx.beginPath();
ctx.arc(
c.centerX,
c.centerY,
i === 0 ? 1.4 : Math.max(0.7, 1.2 - ratio * 0.5),
0,
2 * Math.PI,
);
ctx.fillStyle =
i === 0
? "rgba(255, 255, 255, 0.95)"
: "rgba(255, 255, 255, 0.55)";
ctx.fill();
}
if (showCentroid) {
ctx.beginPath();
ctx.arc(anchorX, anchorY, 1.6, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 4;
ctx.fill();
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = "9px ui-monospace, SFMono-Regular, monospace";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
ctx.fillText(
`${activeShape?.name || "Outline"} Centroid`,
anchorX,
anchorY - 5,
);
}
ctx.beginPath();
ctx.arc(
centerChain.finalTip.x,
centerChain.finalTip.y,
1.8,
0,
2 * Math.PI,
);
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 6;
ctx.fill();
ctx.restore();
} else {
ctx.save();
const numTop = Math.min(
topChain.circles.length,
maxHarmonicsToRender,
);
for (let i = 0; i < numTop; i++) {
const c = topChain.circles[i];
if (c.radius < 0.5) continue;
ctx.beginPath();
ctx.arc(c.centerX, c.centerY, c.radius, 0, 2 * Math.PI);
ctx.strokeStyle =
i === 0
? "rgba(255, 255, 255, 0.35)"
: "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c.centerX, c.centerY);
ctx.lineTo(c.tipX, c.tipY);
ctx.strokeStyle = "rgba(255, 255, 255, 0.75)";
ctx.lineWidth = 1.3;
ctx.stroke();
ctx.beginPath();
ctx.arc(c.centerX, c.centerY, 1.0, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.fill();
}
ctx.beginPath();
ctx.arc(
topChain.finalTip.x,
topChain.finalTip.y,
2.0,
0,
2 * Math.PI,
);
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 6;
ctx.fill();
const numLeft = Math.min(
leftChain.circles.length,
maxHarmonicsToRender,
);
for (let i = 0; i < numLeft; i++) {
const c = leftChain.circles[i];
if (c.radius < 0.5) continue;
ctx.beginPath();
ctx.arc(c.centerX, c.centerY, c.radius, 0, 2 * Math.PI);
ctx.strokeStyle =
i === 0
? "rgba(255, 255, 255, 0.35)"
: "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(c.centerX, c.centerY);
ctx.lineTo(c.tipX, c.tipY);
ctx.strokeStyle = "rgba(255, 255, 255, 0.75)";
ctx.lineWidth = 1.3;
ctx.stroke();
ctx.beginPath();
ctx.arc(c.centerX, c.centerY, 1.0, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.fill();
}
ctx.beginPath();
ctx.arc(
leftChain.finalTip.x,
leftChain.finalTip.y,
2.0,
0,
2 * Math.PI,
);
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 6;
ctx.fill();
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(penPoint.x, topChain.finalTip.y);
ctx.lineTo(penPoint.x, penPoint.y);
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
ctx.lineWidth = 1.0;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(leftChain.finalTip.x, penPoint.y);
ctx.lineTo(penPoint.x, penPoint.y);
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)";
ctx.lineWidth = 1.0;
ctx.stroke();
ctx.restore();
}
}
if (!finished) {
ctx.save();
ctx.beginPath();
ctx.arc(penPoint.x, penPoint.y, 5, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.20)";
ctx.fill();
ctx.beginPath();
ctx.arc(penPoint.x, penPoint.y, 2.4, 0, 2 * Math.PI);
ctx.fillStyle = "#ffffff";
ctx.shadowColor = "#ffffff";
ctx.shadowBlur = 8;
ctx.fill();
ctx.restore();
}
}
ctx.restore();
},
[
multiShapeData,
selectedShapeId,
clampedHarmonics,
maxHarmonicsToRender,
showEpicycles,
showGrid,
showGhostTarget,
showCurveLoop,
showTracedTrail,
showCentroid,
reconstructedCurves,
reconstructedCurvesFull,
chainStyle,
],
);
useEffect(() => {
let active = true;
let animId: number;
let lastTime = performance.now();
const loop = (now: number) => {
if (!active) return;
try {
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
if (isPlayingRef.current) {
const spd = Number.isFinite(speedRef.current)
? speedRef.current
: 1.0;
const currentVal = Number.isFinite(tParamRef.current)
? tParamRef.current
: 0;
const shapes = multiShapeData?.shapes || [];
const M = Math.max(1, shapes.length);
const isSimul =
chainStyle === "single-center" &&
selectedShapeId === "all" &&
M > 1;
const modeSpeedMultiplier = isSimul ? M : 1;
const nextT = currentVal + dt * spd * 0.75 * modeSpeedMultiplier;
if (nextT >= 2 * Math.PI) {
tParamRef.current = 2 * Math.PI;
isFinishedRef.current = true;
onComplete?.();
} else {
tParamRef.current = nextT;
}
}
const currentT = Number.isFinite(tParamRef.current)
? tParamRef.current
: 0;
drawCanvas(currentT);
} catch (err) {
console.error("Canvas draw loop error:", err);
}
animId = requestAnimationFrame(loop);
};
animId = requestAnimationFrame(loop);
return () => {
active = false;
cancelAnimationFrame(animId);
};
}, [drawCanvas, multiShapeData, selectedShapeId, chainStyle, onComplete]);
return (
<div
ref={containerRef}
className={`relative w-full h-full min-h-[360px] 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 FourierDraw;