case0 / web /src /screens /board-threads.tsx
HusseinEid's picture
UI overhaul: mobile corkboard, Connect the Dots, nav and overflow fixes (#3)
2d15f23
Raw
History Blame Contribute Delete
4.7 kB
// 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>
)
}