// Red-yarn threads for the investigation board's "Connect the Dots" mode: pair state // persisted per case in localStorage, plus the SVG layer that draws the yarn. (Named // ThreadPair, not Thread — Evidence.thread already means phone messages in types.ts.) import { useState } from 'preact/hooks' export type ThreadPair = [string, string] const keyFor = (caseId: string) => `cz-threads-${caseId}` export const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`) const canon = (a: string, b: string): ThreadPair => (a < b ? [a, b] : [b, a]) function loadPairs(caseId: string, valid: Set): ThreadPair[] { try { const raw = localStorage.getItem(keyFor(caseId)) if (!raw) return [] const data = JSON.parse(raw) as { v?: number; pairs?: unknown } if (data?.v !== 1 || !Array.isArray(data.pairs)) return [] const seen = new Set() const out: ThreadPair[] = [] for (const p of data.pairs) { if (!Array.isArray(p) || typeof p[0] !== 'string' || typeof p[1] !== 'string') continue const [a, b] = canon(p[0], p[1]) if (a === b || !valid.has(a) || !valid.has(b) || seen.has(pairKey(a, b))) continue seen.add(pairKey(a, b)) out.push([a, b]) } return out } catch { return [] } } function savePairs(caseId: string, pairs: ThreadPair[]): void { try { localStorage.setItem(keyFor(caseId), JSON.stringify({ v: 1, pairs })) } catch { /* ignore */ } } export interface Threads { pairs: ThreadPair[] /** Add the pair — or remove it if it already exists. */ toggle: (a: string, b: string) => 'added' | 'removed' remove: (a: string, b: string) => void clear: () => void } /** Thread pairs for one case, restored from and written through to localStorage so the * yarn survives screen switches (the board unmounts on nav) and full reloads. */ export function useThreads(caseId: string, validIds: string[]): Threads { const [pairs, setPairs] = useState(() => loadPairs(caseId, new Set(validIds))) const commit = (next: ThreadPair[]) => { savePairs(caseId, next) setPairs(next) } return { pairs, toggle: (a, b) => { const k = pairKey(a, b) const without = pairs.filter(([x, y]) => pairKey(x, y) !== k) const removed = without.length < pairs.length commit(removed ? without : [...pairs, canon(a, b)]) return removed ? 'removed' : 'added' }, remove: (a, b) => { const k = pairKey(a, b) commit(pairs.filter(([x, y]) => pairKey(x, y) !== k)) }, clear: () => commit([]), } } /** Yarn path between two pushpins: a quadratic curve with a little gravity sag. */ export function threadD(x1: number, y1: number, x2: number, y2: number): string { const dist = Math.hypot(x2 - x1, y2 - y1) const sag = Math.min(48, 12 + dist * 0.14) return `M ${x1} ${y1} Q ${(x1 + x2) / 2} ${(y1 + y2) / 2 + sag} ${x2} ${y2}` } interface ThreadLayerProps { pairs: ThreadPair[] /** Pushpin position for a card id, in board coordinates (null until laid out). */ anchor: (id: string) => { x: number; y: number } | null w: number h: number /** Connect mode: threads grow an invisible fat hit-stroke so a tap can cut them. */ connect: boolean /** Live dashed thread from the armed pin to the pointer (desktop juice). */ ghost?: { from: { x: number; y: number }; to: { x: number; y: number } } | null onRemove: (a: string, b: string) => void } export function ThreadLayer({ pairs, anchor, w, h, connect, ghost, onRemove }: ThreadLayerProps) { return ( ) }