import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { ArrowLeftIcon, // Added ArrowLeftIcon DownloadSimple, Eraser, Play, Pause, } from '@phosphor-icons/react'; import Seo from '../../components/Seo'; import { useToast } from '../../hooks/useToast'; import BreadcrumbTitle from '../../components/BreadcrumbTitle'; const SpirographPage = () => { const { addToast } = useToast(); const canvasRef = useRef(null); // Parameters const [outerRadius, setOuterRadius] = useState(150); // R const [innerRadius, setInnerRadius] = useState(52); // r const [penOffset, setPenOffset] = useState(50); // d const [resolution, setResolution] = useState(0.1); // t step const [speed, setSpeed] = useState(5); const [color, setColor] = useState('#ec4899'); // Default pink-ish const [isRainbow, setIsRainbow] = useState(false); // State const [isDrawing, setIsDrawing] = useState(false); const [angle, setAngle] = useState(0); // Refs for animation loop const requestRef = useRef(); // Draw helper const draw = useCallback( (ctx, currentAngle) => { const width = ctx.canvas.width; const height = ctx.canvas.height; const centerX = width / 2; const centerY = height / 2; const R = outerRadius; const r = innerRadius; const d = penOffset; // Current point const x = (R - r) * Math.cos(currentAngle) + d * Math.cos(((R - r) / r) * currentAngle); const y = (R - r) * Math.sin(currentAngle) - d * Math.sin(((R - r) / r) * currentAngle); // Previous point (to draw line) // We approximate prev point by subtracting resolution. // Ideally we store the last point, but for high res this is okayish, // or we can use moveTo/lineTo in a path. // Better approach for continuous drawing: ctx.beginPath(); // Calculate a slightly previous point to connect to const prevAngle = currentAngle - resolution; const prevX = (R - r) * Math.cos(prevAngle) + d * Math.cos(((R - r) / r) * prevAngle); const prevY = (R - r) * Math.sin(prevAngle) - d * Math.sin(((R - r) / r) * prevAngle); ctx.moveTo(centerX + prevX, centerY + prevY); ctx.lineTo(centerX + x, centerY + y); ctx.strokeStyle = isRainbow ? `hsl(${(currentAngle * 10) % 360}, 70%, 50%)` : color; ctx.lineWidth = 1; ctx.stroke(); }, [outerRadius, innerRadius, penOffset, resolution, isRainbow, color], ); const animate = useCallback(() => { if (!isDrawing) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); // Draw multiple steps per frame based on speed let currentAngle = angle; for (let i = 0; i < speed; i++) { currentAngle += resolution; draw(ctx, currentAngle); } setAngle(currentAngle); // Stop if we've done a lot of cycles? // For now, let it run infinitely or until user stops. // Real spirographs close loops, but with floats it might not perfectly align. // A huge number of rotations usually fills the circle. if (currentAngle > 600 * Math.PI) { setIsDrawing(false); addToast({ title: 'Finished', message: 'Autostop after 300 cycles', duration: 2000, }); } else { requestRef.current = requestAnimationFrame(animate); } }, [isDrawing, angle, speed, resolution, draw, addToast]); useEffect(() => { if (isDrawing) { requestRef.current = requestAnimationFrame(animate); } else { cancelAnimationFrame(requestRef.current); } return () => cancelAnimationFrame(requestRef.current); }, [isDrawing, animate]); // Init canvas useEffect(() => { const canvas = canvasRef.current; if (canvas) { canvas.width = 800; canvas.height = 800; // Optional: Clear on mount or keep? keep blank } }, []); const handleClear = () => { const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); setAngle(0); setIsDrawing(false); }; const handleDownload = () => { const canvas = canvasRef.current; const link = document.createElement('a'); link.download = 'spirograph.png'; link.href = canvas.toDataURL(); link.click(); addToast({ title: 'Saved', message: 'Image downloaded successfully.', duration: 3000, }); }; return (
Lower is smoother but slower.