From 197f7b681b694c1134b964e2bec77b6b97bc80b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 18:33:32 +0000 Subject: [PATCH] =?UTF-8?q?kasuti:=20new=20interactive=20=E2=80=94=20alter?= =?UTF-8?q?nating=20Eulerian=20tour=20on=20a=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trace traditional Kasuti embroidery motifs so the design appears identical on both sides of the fabric. Two side-by-side 20×20 SVG fabrics (front/back); the active side flips after every stitch, and only legal unvisited pattern edges from the current vertex are clickable. Seven Eulerian motifs in the top reel (Square → Temple), per-side thread colour pickers (default black on front, red on back), undo / reset, completion detection + canvas-confetti. Implemented from the idea in admin-data.json; idea removed and replaced with a note on the slug. https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw --- src/components/InteractiveRenderer.tsx | 1 + .../interactives/KasutiEmbroidery.tsx | 815 ++++++++++++++++++ src/data/admin-data.json | 3 +- src/lib/interactive-components.ts | 1 + src/lib/interactives.ts | 19 + 5 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 src/components/interactives/KasutiEmbroidery.tsx diff --git a/src/components/InteractiveRenderer.tsx b/src/components/InteractiveRenderer.tsx index 3e418ba..745df61 100644 --- a/src/components/InteractiveRenderer.tsx +++ b/src/components/InteractiveRenderer.tsx @@ -43,6 +43,7 @@ const componentMap: Record Promise<{ default: ComponentType } "knights-and-knaves": () => import("./interactives/KnightsAndKnavesI"), "three-bank-accounts": () => import("./interactives/ThreeBankAccounts"), "computing-pi": () => import("./interactives/ComputingPi"), + "kasuti": () => import("./interactives/KasutiEmbroidery"), }; const lazyCache = new Map>>(); diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx new file mode 100644 index 0000000..8c609fc --- /dev/null +++ b/src/components/interactives/KasutiEmbroidery.tsx @@ -0,0 +1,815 @@ +import confetti from 'canvas-confetti'; +import { RotateCcw, Undo2 } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +type V = readonly [number, number]; // [row, col] +type EdgeKey = string; +type Side = 'front' | 'back'; + +interface Pattern { + id: string; + title: string; + description: string; + bbox: readonly [number, number]; // [rows, cols] + edges: ReadonlyArray; +} + +interface Stitch { + side: Side; + from: V; + to: V; +} + +// ─── Constants ────────────────────────────────────────────────────────────── + +const GRID_SIZE = 20; // (GRID_SIZE + 1) x (GRID_SIZE + 1) points +const CELL = 22; // px per cell +const PAD = 12; +const SVG_DIM = GRID_SIZE * CELL + PAD * 2; + +const FRONT_COLORS = ['#000000', '#1e3a8a', '#7c2d12', '#052e16', '#3b0764']; +const BACK_COLORS = ['#b91c1c', '#ca8a04', '#0f766e', '#6d28d9', '#be185d']; + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function edgeKey(a: V, b: V): EdgeKey { + const [r1, c1] = a; + const [r2, c2] = b; + if (r1 < r2 || (r1 === r2 && c1 < c2)) return `${r1},${c1}|${r2},${c2}`; + return `${r2},${c2}|${r1},${c1}`; +} + +function vKey(v: V): string { + return `${v[0]},${v[1]}`; +} + +function isNeighbor(a: V, b: V): boolean { + const dr = Math.abs(a[0] - b[0]); + const dc = Math.abs(a[1] - b[1]); + return (dr === 1 && dc === 0) || (dr === 0 && dc === 1); +} + +function vEqual(a: V | null, b: V | null): boolean { + if (!a || !b) return false; + return a[0] === b[0] && a[1] === b[1]; +} + +// ─── Patterns ─────────────────────────────────────────────────────────────── + +const PATTERNS: Pattern[] = [ + { + id: 'square', + title: 'Square', + description: '4 stitches', + bbox: [1, 1], + edges: [ + [[0, 0], [0, 1]], + [[0, 1], [1, 1]], + [[1, 1], [1, 0]], + [[1, 0], [0, 0]], + ], + }, + { + id: 'rectangle', + title: 'Rectangle', + description: '6 stitches', + bbox: [1, 2], + edges: [ + [[0, 0], [0, 1]], + [[0, 1], [0, 2]], + [[0, 2], [1, 2]], + [[1, 2], [1, 1]], + [[1, 1], [1, 0]], + [[1, 0], [0, 0]], + ], + }, + { + id: 'box', + title: 'Window', + description: '8 stitches', + bbox: [2, 2], + edges: [ + [[0, 0], [0, 1]], + [[0, 1], [0, 2]], + [[0, 2], [1, 2]], + [[1, 2], [2, 2]], + [[2, 2], [2, 1]], + [[2, 1], [2, 0]], + [[2, 0], [1, 0]], + [[1, 0], [0, 0]], + ], + }, + { + id: 'l-shape', + title: 'L-Shape', + description: '8 stitches', + bbox: [2, 2], + edges: [ + [[0, 0], [0, 1]], + [[0, 1], [1, 1]], + [[1, 1], [1, 2]], + [[1, 2], [2, 2]], + [[2, 2], [2, 1]], + [[2, 1], [2, 0]], + [[2, 0], [1, 0]], + [[1, 0], [0, 0]], + ], + }, + { + id: 'figure-eight', + title: 'Figure-Eight', + description: '8 stitches, crossing', + bbox: [2, 2], + edges: [ + [[0, 0], [0, 1]], + [[0, 1], [1, 1]], + [[1, 1], [1, 0]], + [[1, 0], [0, 0]], + [[1, 1], [1, 2]], + [[1, 2], [2, 2]], + [[2, 2], [2, 1]], + [[2, 1], [1, 1]], + ], + }, + { + id: 'plus', + title: 'Cross', + description: '12 stitches', + bbox: [3, 3], + edges: [ + [[0, 1], [0, 2]], + [[0, 2], [1, 2]], + [[1, 2], [1, 3]], + [[1, 3], [2, 3]], + [[2, 3], [2, 2]], + [[2, 2], [3, 2]], + [[3, 2], [3, 1]], + [[3, 1], [2, 1]], + [[2, 1], [2, 0]], + [[2, 0], [1, 0]], + [[1, 0], [1, 1]], + [[1, 1], [0, 1]], + ], + }, + { + id: 'temple', + title: 'Temple', + description: '12 stitches', + bbox: [3, 3], + edges: [ + [[0, 1], [0, 2]], + [[0, 2], [1, 2]], + [[1, 2], [1, 3]], + [[1, 3], [2, 3]], + [[2, 3], [3, 3]], + [[3, 3], [3, 2]], + [[3, 2], [3, 1]], + [[3, 1], [3, 0]], + [[3, 0], [2, 0]], + [[2, 0], [1, 0]], + [[1, 0], [1, 1]], + [[1, 1], [0, 1]], + ], + }, +]; + +// ─── Pattern placement (centered in GRID_SIZE x GRID_SIZE) ────────────────── + +interface PlacedPattern { + pattern: Pattern; + edgeKeys: Set; + edges: Array; + vertices: V[]; + neighbors: Map; + offset: readonly [number, number]; +} + +function placePattern(pattern: Pattern): PlacedPattern { + const [br, bc] = pattern.bbox; + const offsetR = Math.floor((GRID_SIZE - br) / 2); + const offsetC = Math.floor((GRID_SIZE - bc) / 2); + const shifted: Array = pattern.edges.map(([a, b]) => [ + [a[0] + offsetR, a[1] + offsetC] as V, + [b[0] + offsetR, b[1] + offsetC] as V, + ]); + const edgeKeys = new Set(); + const vMap = new Map(); + const neighbors = new Map(); + for (const [a, b] of shifted) { + edgeKeys.add(edgeKey(a, b)); + vMap.set(vKey(a), a); + vMap.set(vKey(b), b); + if (!neighbors.has(vKey(a))) neighbors.set(vKey(a), []); + if (!neighbors.has(vKey(b))) neighbors.set(vKey(b), []); + neighbors.get(vKey(a))!.push(b); + neighbors.get(vKey(b))!.push(a); + } + return { + pattern, + edgeKeys, + edges: shifted, + vertices: Array.from(vMap.values()), + neighbors, + offset: [offsetR, offsetC], + }; +} + +// ─── Coordinate helpers ───────────────────────────────────────────────────── + +function toXY([r, c]: V): { x: number; y: number } { + return { x: PAD + c * CELL, y: PAD + r * CELL }; +} + +// ─── Mini pattern preview for the reel ────────────────────────────────────── + +const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern, active }) => { + const [rows, cols] = pattern.bbox; + const pad = 4; + const cell = 14; + const w = cols * cell + pad * 2; + const h = rows * cell + pad * 2; + return ( + + {pattern.edges.map(([a, b], i) => ( + + ))} + + ); +}; + +// ─── Main component ──────────────────────────────────────────────────────── + +const KasutiEmbroidery: React.FC = () => { + const [patternId, setPatternId] = useState(PATTERNS[0].id); + const [currentVertex, setCurrentVertex] = useState(null); + const [startVertex, setStartVertex] = useState(null); + const [activeSide, setActiveSide] = useState('front'); + const [stitches, setStitches] = useState([]); + const [frontColor, setFrontColor] = useState(FRONT_COLORS[0]); + const [backColor, setBackColor] = useState(BACK_COLORS[0]); + const [completed, setCompleted] = useState(false); + const celebratedRef = useRef(false); + + const placed = useMemo(() => { + const p = PATTERNS.find((x) => x.id === patternId) ?? PATTERNS[0]; + return placePattern(p); + }, [patternId]); + + const drawnFront = useMemo(() => { + const s = new Set(); + for (const st of stitches) { + if (st.side === 'front') s.add(edgeKey(st.from, st.to)); + } + return s; + }, [stitches]); + + const drawnBack = useMemo(() => { + const s = new Set(); + for (const st of stitches) { + if (st.side === 'back') s.add(edgeKey(st.from, st.to)); + } + return s; + }, [stitches]); + + const resetTrace = useCallback(() => { + setCurrentVertex(null); + setStartVertex(null); + setActiveSide('front'); + setStitches([]); + setCompleted(false); + celebratedRef.current = false; + }, []); + + const selectPattern = useCallback( + (id: string) => { + setPatternId(id); + resetTrace(); + }, + [resetTrace] + ); + + // Pick starting vertex — any pattern-incident vertex is valid. + const canStartAt = useCallback( + (v: V) => placed.neighbors.has(vKey(v)), + [placed] + ); + + // For a given current vertex and active side, compute reachable neighbors. + const legalNextVertices = useMemo(() => { + if (!currentVertex) return [] as V[]; + const key = vKey(currentVertex); + const neigh = placed.neighbors.get(key) ?? []; + const drawn = activeSide === 'front' ? drawnFront : drawnBack; + return neigh.filter((n) => !drawn.has(edgeKey(currentVertex, n))); + }, [currentVertex, placed, activeSide, drawnFront, drawnBack]); + + const handleVertexClick = useCallback( + (v: V) => { + if (completed) return; + if (!currentVertex) { + if (!canStartAt(v)) return; + setStartVertex(v); + setCurrentVertex(v); + return; + } + if (!isNeighbor(currentVertex, v)) return; + const k = edgeKey(currentVertex, v); + if (!placed.edgeKeys.has(k)) return; + const drawn = activeSide === 'front' ? drawnFront : drawnBack; + if (drawn.has(k)) return; + const next: Stitch = { side: activeSide, from: currentVertex, to: v }; + const newStitches = [...stitches, next]; + setStitches(newStitches); + setCurrentVertex(v); + setActiveSide(activeSide === 'front' ? 'back' : 'front'); + + // Check completion: all edges drawn on both sides AND we've returned to start. + const totalEdges = placed.edges.length; + const newFront = activeSide === 'front' ? drawnFront.size + 1 : drawnFront.size; + const newBack = activeSide === 'back' ? drawnBack.size + 1 : drawnBack.size; + if ( + newFront === totalEdges && + newBack === totalEdges && + startVertex && + vEqual(v, startVertex) + ) { + setCompleted(true); + } + }, + [ + completed, + currentVertex, + canStartAt, + placed, + activeSide, + drawnFront, + drawnBack, + stitches, + startVertex, + ] + ); + + const undo = useCallback(() => { + if (stitches.length === 0) { + setCurrentVertex(null); + setStartVertex(null); + return; + } + const last = stitches[stitches.length - 1]; + setStitches(stitches.slice(0, -1)); + setCurrentVertex(last.from); + setActiveSide(last.side); + setCompleted(false); + celebratedRef.current = false; + }, [stitches]); + + useEffect(() => { + if (completed && !celebratedRef.current) { + celebratedRef.current = true; + confetti({ particleCount: 180, spread: 90, origin: { y: 0.6 } }); + } + }, [completed]); + + const totalEdges = placed.edges.length; + const frontRemaining = totalEdges - drawnFront.size; + const backRemaining = totalEdges - drawnBack.size; + + return ( +
+ + + + Kasuti + +

+ A traditional hand-embroidery from Karnataka, India (7th century). +

+
+ +

+ Kasuti follows three rules: the design looks{' '} + identical on both sides of the cloth, + you end where you start, and{' '} + no stitch is made twice. Trace a + motif alternately on the front (left) and back (right). Each stitch flips the fabric. +

+

+ Equivalently: given the pattern graph, double every edge with two colours and find a + closed Eulerian tour that alternates colours at every step. +

+
+
+ + {/* Pattern reel */} + + +
+ Choose a motif + + easy → more intricate + +
+
+ +
+ {PATTERNS.map((p) => { + const active = p.id === patternId; + return ( + + ); + })} +
+
+
+ + {/* Status bar */} +
+
+ + Front{activeSide === 'front' ? ' · active' : ''} + + + Back{activeSide === 'back' ? ' · active' : ''} + + + {stitches.length} stitch{stitches.length === 1 ? '' : 'es'} · front{' '} + {drawnFront.size}/{totalEdges} · back {drawnBack.size}/{totalEdges} + +
+
+ + +
+
+ + {/* Two fabric panels */} +
+ + +
+ + {completed && ( +
+ You ended where you started — both sides match exactly. A complete kasuti tour in{' '} + {stitches.length} stitches. +
+ )} + + {!completed && currentVertex && legalNextVertices.length === 0 && ( +
+ No legal stitches from here on the {activeSide}. Undo a step or reset to try a + different route. +
+ )} +
+ ); +}; + +// ─── Fabric panel (one side) ──────────────────────────────────────────────── + +interface FabricPanelProps { + side: Side; + title: string; + color: string; + setColor: (c: string) => void; + palette: string[]; + placed: PlacedPattern; + currentVertex: V | null; + startVertex: V | null; + drawn: Set; + otherDrawn: Set; + stitches: Stitch[]; + frontColor: string; + backColor: string; + activeSide: Side; + onVertexClick: (v: V) => void; + legalNext: V[]; + canStartAt: (v: V) => boolean; + completed: boolean; + remaining: number; +} + +const FabricPanel: React.FC = ({ + side, + title, + color, + setColor, + palette, + placed, + currentVertex, + startVertex, + drawn, + stitches, + activeSide, + onVertexClick, + legalNext, + canStartAt, + completed, + remaining, +}) => { + const isActive = activeSide === side && !completed; + const dots: V[] = useMemo(() => { + const out: V[] = []; + for (let r = 0; r <= GRID_SIZE; r++) { + for (let c = 0; c <= GRID_SIZE; c++) { + out.push([r, c]); + } + } + return out; + }, []); + + const legalSet = useMemo(() => { + const s = new Set(); + for (const v of legalNext) s.add(vKey(v)); + return s; + }, [legalNext]); + + const mySideStitches = stitches.filter((s) => s.side === side); + + return ( + + +
+
+ {title} + {isActive && ( + active + )} + {!isActive && !completed && ( + + waiting + + )} +
+
+ +
+ {palette.map((c) => ( +
+
+
+
+ {mySideStitches.length} / {placed.edges.length} stitches · {remaining} remaining +
+
+ +
+ + {/* Grid dots */} + {dots.map(([r, c]) => { + const { x, y } = toXY([r, c]); + return ( + + ); + })} + + {/* Pattern guide lines (undrawn). Drawn ones rendered below in thread color. */} + {placed.edges.map(([a, b], i) => { + const k = edgeKey(a, b); + if (drawn.has(k)) return null; + const p = toXY(a); + const q = toXY(b); + return ( + + ); + })} + + {/* Drawn stitches on this side in thread color */} + {mySideStitches.map((st, i) => { + const p = toXY(st.from); + const q = toXY(st.to); + return ( + + ); + })} + + {/* Legal-next affordances */} + {isActive && + currentVertex && + legalNext.map((v, i) => { + const { x, y } = toXY(v); + return ( + + ); + })} + + {/* Start-picker affordances (only when no current vertex yet and this side is active) */} + {isActive && + !currentVertex && + placed.vertices.map((v, i) => { + const { x, y } = toXY(v); + return ( + + ); + })} + + {/* Start vertex marker (persists across sides) */} + {startVertex && ( + + )} + + {/* Current vertex marker */} + {currentVertex && ( + + )} + + {/* Click overlays: only active side accepts clicks */} + {isActive && + dots.map(([r, c]) => { + const v: V = [r, c]; + const clickable = currentVertex + ? legalSet.has(vKey(v)) + : canStartAt(v); + if (!clickable) return null; + const { x, y } = toXY(v); + return ( + onVertexClick(v)} + className="cursor-pointer" + /> + ); + })} + +
+
+
+ ); +}; + +export default KasutiEmbroidery; diff --git a/src/data/admin-data.json b/src/data/admin-data.json index d5fcb03..4672feb 100644 --- a/src/data/admin-data.json +++ b/src/data/admin-data.json @@ -2,7 +2,8 @@ "statusOverrides": {}, "notes": { "three-bank-accounts": "Implemented from idea. Three tabs: Play (sandbox), Explore (guided discovery with binary view), Solution (step-through algorithm). Uses KaTeX for math, canvas-confetti for celebrations.", - "computing-pi": "Implemented from idea. Header row: slider (N runs) | running avg × 4 | Start/Reset. Grid of N tiles, each running an independent first-passage simulation; tiles use a right-aligned mask-fade for the marquee effect on long sequences. Click any stopped tile after completion → Dialog showing the full toss sequence. Safety cap of 1000 tosses per run (first-passage time has infinite expectation)." + "computing-pi": "Implemented from idea. Header row: slider (N runs) | running avg × 4 | Start/Reset. Grid of N tiles, each running an independent first-passage simulation; tiles use a right-aligned mask-fade for the marquee effect on long sequences. Click any stopped tile after completion → Dialog showing the full toss sequence. Safety cap of 1000 tosses per run (first-passage time has infinite expectation).", + "kasuti": "Implemented from idea. Two side-by-side 20×20 grid SVGs (front/back). Motif reel along the top with seven patterns of increasing complexity (Square → Rectangle → Window → L-Shape → Figure-Eight → Cross → Temple); each pattern graph is Eulerian so an alternating closed tour exists. The active side flips on every stitch; only legal unvisited pattern edges from the current vertex are clickable. Per-side thread colour pickers (default black on front, red on back). Undo / Reset, completion detection (all edges drawn on both sides AND back at start), canvas-confetti celebration." }, "ideas": [], "todos": { diff --git a/src/lib/interactive-components.ts b/src/lib/interactive-components.ts index a5313d8..b6e924a 100644 --- a/src/lib/interactive-components.ts +++ b/src/lib/interactive-components.ts @@ -45,4 +45,5 @@ export const componentMap: Record = { "knights-and-knaves": () => import("@/components/interactives/KnightsAndKnavesI"), "three-bank-accounts": () => import("@/components/interactives/ThreeBankAccounts"), "computing-pi": () => import("@/components/interactives/ComputingPi"), + "kasuti": () => import("@/components/interactives/KasutiEmbroidery"), }; diff --git a/src/lib/interactives.ts b/src/lib/interactives.ts index 127d557..f1f2ebe 100644 --- a/src/lib/interactives.ts +++ b/src/lib/interactives.ts @@ -285,6 +285,25 @@ export const allInteractives: Interactive[] = [ }, // === Discrete Math - Graphs === + { + slug: "kasuti", + title: "Kasuti", + description: + "Trace traditional Kasuti embroidery motifs so the design appears identical on both sides — an alternating Eulerian tour puzzle on a grid.", + tags: [ + "graph-theory", + "eulerian-tour", + "embroidery", + "kasuti", + "karnataka", + "tracing", + ], + themes: ["Discrete Math"], + subcategory: "graphs", + dateAdded: "2026-04-21", + hasGreenScreen: false, + status: "published" as const, + }, { slug: "neighbor-sum-avoidance", title: "Neighbor Sum Avoidance",