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
Speed
1x
Epicycles
Coordinate Grid
Ghost Silhouette
Curve Loop
Traced Trail
Animation
Playing
// 02·USAGE
example.tsx
example.tsx
import { 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 props

Prop

Type

// 04·COMPONENT SOURCE
2 files
FourierDraw.tsx
"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;
IHSOR Discrete Fourier Transform 124-Harmonics Epicycle Synthesis Wordmark
@ 2024-2026 ihsor.dev