Spaces:
Running
Running
| // 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<string>): 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<string>() | |
| 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<ThreadPair[]>(() => 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 ( | |
| <svg class="thread-layer" viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true"> | |
| {pairs.map(([a, b]) => { | |
| const pa = anchor(a) | |
| const pb = anchor(b) | |
| if (!pa || !pb) return null | |
| const d = threadD(pa.x, pa.y, pb.x, pb.y) | |
| return ( | |
| // Keyed by pair so the draw-in animation runs once on creation, not on drags. | |
| <g key={pairKey(a, b)}> | |
| <path class="thread thread--under" d={d} pathLength={1} /> | |
| <path class="thread thread--draw" d={d} pathLength={1} /> | |
| {connect && ( | |
| <path | |
| class="thread-hit" | |
| d={d} | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| onRemove(a, b) | |
| }} | |
| /> | |
| )} | |
| <circle class="thread-knot" cx={pa.x} cy={pa.y} r={2.5} /> | |
| <circle class="thread-knot" cx={pb.x} cy={pb.y} r={2.5} /> | |
| </g> | |
| ) | |
| })} | |
| {ghost && <path class="thread thread--ghost" d={threadD(ghost.from.x, ghost.from.y, ghost.to.x, ghost.to.y)} />} | |
| </svg> | |
| ) | |
| } | |