From d2fa216b8fee6dc711d5913b419d8fa337052515 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 08:37:19 +0000 Subject: [PATCH] kasuti: reel polish, greener cues, custom colours, motif file I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI cleanups: - MiniPreview fits every motif into a 60×60 box (Snowflake no longer overshoots its card). - Reel replaces the native overflow scrollbar with a scroll container plus left/right chevron buttons and edge fades that appear only when there is more content in that direction. - Legal-next vertex markers are green instead of red; the current vertex renders green on the active side and muted grey on the inactive side (making the side-flip obvious without a jump). Thread colours: - Each fabric panel now has an extra swatch opening the native colour picker, so the user can dial in any thread colour beyond the five presets. The swatch shows a conic rainbow and highlights when the current colour isn't one of the presets. Editor: - Validation simplified to connectivity only (the alternating Eulerian tour on the doubled graph exists for every connected base graph, so odd-degree vertices don't actually block solvability — the extra warning was misleading). - Download button exports the current motif as a small JSON file ({kind: "kasuti-pattern", version: 1, edges: [...]}); Upload reads one back in, validates structure (orthogonal unit segments only, fits in the editor grid), and loads it centered for editing. https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw --- .../interactives/KasutiEmbroidery.tsx | 408 +++++++++++++----- 1 file changed, 310 insertions(+), 98 deletions(-) diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx index d265df7..6aa0a99 100644 --- a/src/components/interactives/KasutiEmbroidery.tsx +++ b/src/components/interactives/KasutiEmbroidery.tsx @@ -1,5 +1,5 @@ import confetti from 'canvas-confetti'; -import { Plus, RotateCcw, Undo2 } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Download, Plus, RotateCcw, Undo2, Upload } from 'lucide-react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Badge } from '@/components/ui/badge'; @@ -446,22 +446,27 @@ function toXY([r, c]: V): { x: number; y: number } { // ─── Mini pattern preview for the reel ────────────────────────────────────── +const MINI_SIZE = 60; +const MINI_PAD = 4; + 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; + // Scale to fit the fixed MINI_SIZE square so big motifs (Snowflake, Rose) don't overshoot. + const span = Math.max(rows, cols, 1); + const inner = MINI_SIZE - MINI_PAD * 2; + const cell = inner / span; + const offsetR = (inner - rows * cell) / 2; + const offsetC = (inner - cols * cell) / 2; return ( - + {pattern.edges.map(([a, b], i) => ( = ({ pattern, ); }; +// ─── Motif reel (custom horizontal scroller) ──────────────────────────────── + +interface MotifReelProps { + patterns: Pattern[]; + activeId: string; + onSelect: (id: string) => void; + onOpenEditor: () => void; +} + +const MotifReel: React.FC = ({ patterns, activeId, onSelect, onOpenEditor }) => { + const trackRef = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + + const updateEdgeState = useCallback(() => { + const el = trackRef.current; + if (!el) return; + setAtStart(el.scrollLeft <= 1); + setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 1); + }, []); + + useEffect(() => { + updateEdgeState(); + const el = trackRef.current; + if (!el) return; + const handler = () => updateEdgeState(); + el.addEventListener('scroll', handler, { passive: true }); + const ro = new ResizeObserver(handler); + ro.observe(el); + return () => { + el.removeEventListener('scroll', handler); + ro.disconnect(); + }; + }, [updateEdgeState, patterns.length]); + + const scrollBy = useCallback((dir: 1 | -1) => { + const el = trackRef.current; + if (!el) return; + const step = Math.max(el.clientWidth * 0.8, 180); + el.scrollBy({ left: dir * step, behavior: 'smooth' }); + }, []); + + return ( +
+
+ {patterns.map((p) => { + const active = p.id === activeId; + return ( + + ); + })} + + +
+ + {/* Edge fades + arrow buttons */} + {!atStart && ( +
+ )} + {!atEnd && ( +
+ )} + + +
+ ); +}; + // ─── Main component ──────────────────────────────────────────────────────── const KasutiEmbroidery: React.FC = () => { @@ -652,49 +776,12 @@ const KasutiEmbroidery: React.FC = () => {
-
- {visiblePatterns.map((p) => { - const active = p.id === patternId; - return ( - - ); - })} - - -
+ setEditorOpen(true)} + />
@@ -896,7 +983,7 @@ const FabricPanel: React.FC = ({
-
+
{palette.map((c) => (
@@ -979,7 +1083,7 @@ const FabricPanel: React.FC = ({ ); })} - {/* Legal-next affordances */} + {/* Legal-next affordances (green = go) */} {isActive && currentVertex && legalNext.map((v, i) => { @@ -990,7 +1094,7 @@ const FabricPanel: React.FC = ({ cx={x} cy={y} r={6} - className="fill-primary/30 stroke-primary" + className="fill-green-500/30 stroke-green-600 dark:fill-green-400/25 dark:stroke-green-400" strokeWidth={1.5} /> ); @@ -1007,7 +1111,7 @@ const FabricPanel: React.FC = ({ cx={x} cy={y} r={5} - className="fill-primary/20 stroke-primary/70" + className="fill-green-500/20 stroke-green-600/70 dark:fill-green-400/15 dark:stroke-green-400/70" strokeWidth={1} /> ); @@ -1025,13 +1129,17 @@ const FabricPanel: React.FC = ({ /> )} - {/* Current vertex marker */} + {/* Current vertex marker — full colour on the active side, grey on the other */} {currentVertex && ( )} @@ -1108,17 +1216,6 @@ function isConnected(edges: Array): boolean { return seen.size === adj.size; } -function oddDegreeVertices(edges: Array): number { - const deg = new Map(); - for (const [a, b] of edges) { - deg.set(vKey(a), (deg.get(vKey(a)) ?? 0) + 1); - deg.set(vKey(b), (deg.get(vKey(b)) ?? 0) + 1); - } - let odd = 0; - for (const d of deg.values()) if (d % 2 !== 0) odd++; - return odd; -} - function normalizePattern(edges: Array): Pattern | null { if (edges.length === 0) return null; let minR = Infinity, @@ -1153,27 +1250,74 @@ interface PatternEditorProps { onSave: (p: Pattern) => void; } +interface KasutiPatternFile { + kind: 'kasuti-pattern'; + version: 1; + title: string; + edges: Array<[[number, number], [number, number]]>; +} + +function centeredEdgeSetFromPattern(p: Pattern): Set { + const [br, bc] = p.bbox; + const offR = Math.max(0, Math.floor((EDITOR_SIZE - br) / 2)); + const offC = Math.max(0, Math.floor((EDITOR_SIZE - bc) / 2)); + const out = new Set(); + for (const [a, b] of p.edges) { + const sa: V = [a[0] + offR, a[1] + offC]; + const sb: V = [b[0] + offR, b[1] + offC]; + out.add(edgeKey(sa, sb)); + } + return out; +} + +function parsePatternFile(raw: string): Pattern | null { + try { + const data = JSON.parse(raw) as KasutiPatternFile; + if (!data || data.kind !== 'kasuti-pattern' || !Array.isArray(data.edges)) return null; + const edges: Array = []; + for (const e of data.edges) { + if (!Array.isArray(e) || e.length !== 2) return null; + const [a, b] = e; + if (!Array.isArray(a) || !Array.isArray(b)) return null; + if (a.length !== 2 || b.length !== 2) return null; + const [r1, c1] = a; + const [r2, c2] = b; + if ( + typeof r1 !== 'number' || + typeof c1 !== 'number' || + typeof r2 !== 'number' || + typeof c2 !== 'number' + ) + return null; + // Only orthogonal unit-length segments are valid. + const dr = Math.abs(r1 - r2); + const dc = Math.abs(c1 - c2); + if (!((dr === 1 && dc === 0) || (dr === 0 && dc === 1))) return null; + edges.push([[r1, c1] as V, [r2, c2] as V]); + } + const normalized = normalizePattern(edges); + if (!normalized) return null; + return { ...normalized, title: data.title || 'Custom' }; + } catch { + return null; + } +} + const PatternEditor: React.FC = ({ open, onOpenChange, initial, onSave }) => { const [edges, setEdges] = useState>(new Set()); + const [loadError, setLoadError] = useState(null); + const fileInputRef = useRef(null); // When the modal opens, seed from the initial custom pattern (if any), centred in the // editor grid. Resetting here avoids stale state between open/close cycles. useEffect(() => { if (!open) return; + setLoadError(null); if (!initial) { setEdges(new Set()); return; } - const [br, bc] = initial.bbox; - const offR = Math.max(0, Math.floor((EDITOR_SIZE - br) / 2)); - const offC = Math.max(0, Math.floor((EDITOR_SIZE - bc) / 2)); - const seed = new Set(); - for (const [a, b] of initial.edges) { - const sa: V = [a[0] + offR, a[1] + offC]; - const sb: V = [b[0] + offR, b[1] + offC]; - seed.add(edgeKey(sa, sb)); - } - setEdges(seed); + setEdges(centeredEdgeSetFromPattern(initial)); }, [open, initial]); const toggleEdge = useCallback((a: V, b: V) => { @@ -1188,7 +1332,6 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi const edgeList = useMemo(() => edgeListFromKeys(edges), [edges]); const connected = useMemo(() => isConnected(edgeList), [edgeList]); - const oddCount = useMemo(() => oddDegreeVertices(edgeList), [edgeList]); const count = edges.size; const allEdges: Array<[V, V]> = useMemo(() => { @@ -1218,15 +1361,67 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi const handleClear = useCallback(() => setEdges(new Set()), []); + const handleDownload = useCallback(() => { + const pattern = normalizePattern(edgeList); + if (!pattern) return; + const payload: KasutiPatternFile = { + kind: 'kasuti-pattern', + version: 1, + title: 'Custom', + edges: pattern.edges.map(([a, b]) => [ + [a[0], a[1]], + [b[0], b[1]], + ]), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `kasuti-motif-${pattern.edges.length}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, [edgeList]); + + const handleUpload = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFilePicked = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === 'string' ? reader.result : ''; + const parsed = parsePatternFile(result); + if (!parsed) { + setLoadError('That file does not look like a saved Kasuti motif.'); + return; + } + const [br, bc] = parsed.bbox; + if (br > EDITOR_SIZE || bc > EDITOR_SIZE) { + setLoadError( + `That motif is too big for the editor grid (${br + 1}×${bc + 1} > ${EDITOR_SIZE + 1}×${EDITOR_SIZE + 1}).` + ); + return; + } + setLoadError(null); + setEdges(centeredEdgeSetFromPattern(parsed)); + }; + reader.onerror = () => setLoadError('Could not read that file.'); + reader.readAsText(file); + }, []); + return ( Design your own motif - Click between two dots to add or remove a stitch line. The motif must be{' '} - connected for the puzzle to start. For a complete alternating tour, every - vertex should have an even number of incident lines. + Click between two dots to add or remove a stitch line. As long as your motif is{' '} + connected, an alternating kasuti tour exists on it. @@ -1303,22 +1498,39 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi Not connected — every drawn segment must reach every other segment.

)} - {count > 0 && connected && oddCount === 0 && ( -

- Connected and every vertex has even degree — a complete alternating tour exists. -

- )} - {count > 0 && connected && oddCount > 0 && ( -

- Connected, but {oddCount} vertex{oddCount === 1 ? '' : 'es'} {oddCount === 1 ? 'has' : 'have'} odd degree — you may not be able to close the tour. -

+ {count > 0 && connected && ( +

Connected — ready to stitch.

)} + {loadError &&

{loadError}

} - - + + + +
+ + + +