import { useInView } from "motion/react"; import { useEffect, useId, useMemo, useRef, useState, type PointerEvent } from "react"; const H = 1000; // SVG units: both photos share this height, widths follow their aspect ratios const GAP = 24; export const INDIVIDUAL_W = 0.7; type Size = [number, number]; /** Two photos and the inlier lines between them, drawn from the evidence data (not a baked image). */ const LENS_R = 130; const LENS_ZOOM = 2.6; export function MatchView({ refSrc, curSrc, refSize, curSize, matches, cells, onlyMarks = false, label }: { refSrc: string; curSrc: string; refSize: Size; curSize: Size; matches: number[][]; cells?: number[][]; onlyMarks?: boolean; label: string; }) { const ref = useRef(null); const inView = useInView(ref, { once: true, margin: "0px 0px -20% 0px" }); const uid = useId().replace(/:/g, ""); const [lens, setLens] = useState<{ x: number; y: number } | null>(null); const finePointer = typeof window !== "undefined" && window.matchMedia("(pointer: fine)").matches; const w1 = (H * refSize[0]) / refSize[1]; const w2 = (H * curSize[0]) / curSize[1]; const lines = useMemo(() => { const rows = matches.filter((m) => !onlyMarks || m[4] >= INDIVIDUAL_W); // design features first (underneath), individual marks last (on top) return [...rows].sort((a, b) => a[4] - b[4]); }, [matches, onlyMarks]); const onMove = (e: PointerEvent) => { const svg = ref.current; const m = svg?.getScreenCTM(); if (!svg || !m || !finePointer) return; const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()); setLens({ x: p.x, y: p.y }); }; return ( setLens(null)}> {cells?.map(([x, y, w, h], i) => ( ))} {lines.map(([x0, y0, x1, y1, w], i) => ( = INDIVIDUAL_W ? "matchview__mark" : "matchview__design"} x1={x0 * w1} y1={y0 * H} x2={w1 + GAP + x1 * w2} y2={y1 * H} style={{ ["--i" as string]: i }} /> ))} {lines.filter((m) => m[4] >= INDIVIDUAL_W).map(([x0, y0, x1, y1], i) => ( ))} {lens && ( )} ); } /** Before / after: the current photo warped into the reference frame, revealed by a draggable divider. */ export function CompareSlider({ beforeSrc, afterSrc, beforeLabel, afterLabel, sliderLabel }: { beforeSrc: string; afterSrc: string; beforeLabel: string; afterLabel: string; sliderLabel: string; }) { const [pos, setPos] = useState(50); const box = useRef(null); const dragging = useRef(false); const fromPointer = (e: PointerEvent) => { const r = box.current?.getBoundingClientRect(); if (!r) return; setPos(Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100))); }; useEffect(() => { const up = () => (dragging.current = false); window.addEventListener("pointerup", up); return () => window.removeEventListener("pointerup", up); }, []); return (
{ dragging.current = true; fromPointer(e); }} onPointerMove={(e) => dragging.current && fromPointer(e)}> {beforeLabel} {afterLabel} {beforeLabel} {afterLabel} setPos(Number(e.target.value))} />
); }