"use client"; import { useEffect, useRef, type ReactNode } from "react"; type KolamFieldProps = { children?: ReactNode; className?: string; /** Floor colour. */ ground?: string; /** Rice flour colour. */ powder?: string; /** Size of one kolam cell in pixels. */ spacing?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; }; type Pt = [number, number]; /* Seconds to finish one whole kolam at speed 1. */ const DRAW_TIME = 26; /* A sikku kolam by the mirror-curve method. The line runs on the diagonal between cell edges and bounces off the border and off a few mirrors. Mirrors are placed in one quarter and copied to the other three, so the pattern is symmetric. Returns each closed line, longest first. */ function weave(m: number, n: number) { const id = (x: number, y: number) => x * 10000 + y; const mirrors = new Map(); for (let x = 1; x <= m; x++) { for (let y = 1; y <= n; y++) { if ((x + y) % 2 === 0 || Math.random() > 0.58) continue; const kind = Math.random() < 0.5 ? 1 : 2; for (const px of [x, 2 * m - x]) for (const py of [y, 2 * n - y]) mirrors.set(id(px, py), kind); } } const seen = new Set(); const loops: Pt[][] = []; const inside = (x: number, y: number) => x >= 0 && x <= 2 * m && y >= 0 && y <= 2 * n; for (let x = 0; x <= 2 * m; x++) { for (let y = 0; y <= 2 * n; y++) { if ((x + y) % 2 === 0) continue; for (const [dx, dy] of [[1, 1], [1, -1], [-1, 1], [-1, -1]]) { if (!inside(x + dx, y + dy) || seen.has(id(2 * x + dx, 2 * y + dy))) continue; const loop: Pt[] = []; let cx = x; let cy = y; let ax = dx; let ay = dy; do { seen.add(id(2 * cx + ax, 2 * cy + ay)); cx += ax; cy += ay; loop.push([cx, cy]); if (cx === 0 || cx === 2 * m) ax = -ax; if (cy === 0 || cy === 2 * n) ay = -ay; const kind = mirrors.get(id(cx, cy)); if (kind === 1) ay = -ay; if (kind === 2) ax = -ax; } while (cx !== x || cy !== y || ax !== dx || ay !== dy); loops.push(loop); } } } return loops.sort((a, b) => b.length - a.length); } /* KolamField: a floor of pulli dots, and a line of rice flour that loops round them, never lifting within a loop. When the kolam is finished it holds, fades, and a new one starts. */ export function KolamField({ children, className = "", ground = "#7a2e1d", powder = "#f6efe0", spacing = 62, speed = 1, }: KolamFieldProps) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const floor = document.createElement("canvas"); const ink = document.createElement("canvas"); let w = 0; let h = 0; let raf = 0; let last = 0; let m = 2; let n = 2; let ox = 0; let oy = 0; let path: { x: number; y: number; jump: boolean }[] = []; let drawn = 1; let pos = 0; let hold = 0; let fade = 1; const paintFloor = () => { const g = floor.getContext("2d"); if (!g) return; g.setTransform(1, 0, 0, 1, 0, 0); g.fillStyle = ground; g.fillRect(0, 0, w, h); // Grit, so the floor is not a flat colour. for (let i = 0; i < (w * h) / 60; i++) { g.fillStyle = Math.random() < 0.5 ? "rgba(0,0,0,0.1)" : "rgba(255,220,180,0.06)"; g.fillRect(Math.random() * w, Math.random() * h, 1.5, 1.5); } g.fillStyle = powder; for (let x = 1; x < 2 * m; x++) { for (let y = 1; y < 2 * n; y++) { if ((x + y) % 2) continue; g.beginPath(); g.arc(ox + (x * spacing) / 2, oy + (y * spacing) / 2, Math.max(1.4, spacing * 0.045), 0, Math.PI * 2); g.fill(); } } }; const newKolam = () => { const half = spacing / 2; path = []; for (const loop of weave(m, n)) { const at = (i: number) => loop[(i + loop.length) % loop.length]; // Round each corner: from the middle of one leg to the middle of the next. loop.forEach((c, i) => { const a = at(i - 1); const b = at(i + 1); for (let s = 0; s < 6; s++) { const t = s / 6; const q0 = (1 - t) * (1 - t); const q1 = 2 * t * (1 - t); const q2 = t * t; path.push({ x: ox + half * (q0 * (a[0] + c[0]) * 0.5 + q1 * c[0] + q2 * (c[0] + b[0]) * 0.5), y: oy + half * (q0 * (a[1] + c[1]) * 0.5 + q1 * c[1] + q2 * (c[1] + b[1]) * 0.5), jump: i === 0 && s === 0, }); } }); const first = path[path.length - loop.length * 6]; path.push({ x: first.x, y: first.y, jump: false }); } drawn = 1; pos = 1; hold = 0; fade = 1; ink.getContext("2d")?.clearRect(0, 0, w, h); }; /* Each step of line is a stroke, a few loose grains beside it and a thin spot in it. */ const pour = (to: number) => { const g = ink.getContext("2d"); if (!g) return; const width = Math.max(1.6, spacing * 0.05); g.strokeStyle = powder; g.fillStyle = powder; g.lineCap = "round"; g.lineWidth = width; for (; drawn < to; drawn++) { const a = path[drawn - 1]; const b = path[drawn]; if (b.jump) continue; g.beginPath(); g.moveTo(a.x, a.y); g.lineTo(b.x, b.y); g.stroke(); for (let i = 0; i < 3; i++) { g.globalAlpha = 0.4 + Math.random() * 0.5; g.fillRect(b.x + (Math.random() - 0.5) * width * 2.4, b.y + (Math.random() - 0.5) * width * 2.4, 1, 1); } g.globalAlpha = 1; g.globalCompositeOperation = "destination-out"; g.fillRect(b.x + (Math.random() - 0.5) * width, b.y + (Math.random() - 0.5) * width, 1.4, 1.4); g.globalCompositeOperation = "source-over"; } }; const draw = () => { ctx.globalAlpha = 1; ctx.drawImage(floor, 0, 0, w, h); ctx.globalAlpha = fade; ctx.drawImage(ink, 0, 0, w, h); ctx.globalAlpha = 1; const tip = path[Math.min(drawn, path.length - 1)]; if (!still && tip && drawn < path.length) { const glow = ctx.createRadialGradient(tip.x, tip.y, 0, tip.x, tip.y, spacing * 0.3); glow.addColorStop(0, powder); glow.addColorStop(1, `${powder}00`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(tip.x, tip.y, spacing * 0.3, 0, Math.PI * 2); ctx.fill(); } }; const frame = (now: number) => { const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0; last = now; if (pos < path.length) { pos = Math.min(path.length, pos + (path.length / DRAW_TIME) * dt); pour(Math.floor(pos)); } else if (hold < 4) { hold += dt; } else { fade -= dt / 1.5; if (fade <= 0) { newKolam(); pour(1); } } draw(); raf = requestAnimationFrame(frame); }; const start = () => { if (still || raf) return; last = 0; raf = requestAnimationFrame(frame); }; const stop = () => { cancelAnimationFrame(raf); raf = 0; }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2); w = canvas.clientWidth; h = canvas.clientHeight; if (!w || !h) return; for (const c of [canvas, floor, ink]) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); c.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); } m = Math.max(2, Math.floor((w - 20) / spacing)); n = Math.max(2, Math.floor((h - 20) / spacing)); ox = (w - m * spacing) / 2; oy = (h - n * spacing) / 2; paintFloor(); newKolam(); // Part done on arrival, so the first look is already a kolam, not bare dots. pos = still ? path.length : path.length * 0.4; pour(Math.floor(pos)); draw(); }; // Redraw on size change; only animate while on screen. const ro = new ResizeObserver(resize); const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop())); ro.observe(canvas); io.observe(canvas); return () => { stop(); ro.disconnect(); io.disconnect(); }; }, [ground, powder, spacing, speed]); return (
{children}
); }