"use client"; import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { Rows3 } from "lucide-react"; import { slidePointText, type SlideView } from "../../../lib/report/slides"; import type { ReportPageView } from "../../../lib/report/views"; import { Slide, verdictStyleOf } from "./Slide"; import { SlidesHostProvider, useSlidesHost, type SlidesHost } from "./SlidesHost"; // THE OVERVIEW — the article and its slides side by side on one plane, tilted // isometrically: the article's parts as a column of page blocks, each slide // beside its part as a 16:9 card, a line between the two. One pair is the // reader's place (lit, lifted); pointing at a pair lights it. Choosing a block // opens the article there; choosing a card opens the slides there. // // THE TILT IS NEVER THE ONLY WAY TO READ IT. The same list of pairs lies flat // (two columns; a narrow screen stacks each pair) when motion is reduced, the // box is narrow, the reader came in by keyboard, or they asked for it (the // "Flat" toggle, remembered in this browser). Flat or tilted, the DOM is the // same ordered list, and the keys are the same: // // ↑ ↓ the pair before / after (Home, End: the first, the last) // ← → the block / the card of this pair // Enter Space open what is focused // Esc back to the article at this pair (`onExit`) // // The place is the caller's (`focusAnchor`, the URL's hash); walking pairs // reports it through `onFocusAnchor`. const CARD_W = 300; const THUMB_SCALE = CARD_W / 1280; const NARROW_PX = 900; const TILT_KEY = "archilyzer:overview-tilt"; // How the reader last moved: a keyboard user gets the flat plane. let lastInput: "keyboard" | "pointer" = "pointer"; if (typeof window !== "undefined") { window.addEventListener("keydown", () => (lastInput = "keyboard"), true); window.addEventListener("pointerdown", () => (lastInput = "pointer"), true); } function storedTilt(): boolean | null { try { const v = window.localStorage.getItem(TILT_KEY); return v === "flat" ? false : v === "iso" ? true : null; } catch { return null; } } function storeTilt(tilt: boolean) { try { window.localStorage.setItem(TILT_KEY, tilt ? "iso" : "flat"); } catch { // no storage here: the choice lasts this visit } } function kindName(s: SlideView): string { switch (s.kind) { case "title": return "The head"; case "summary": return "In brief"; case "entry": return `Timeline · ${s.date}`; case "found": return "What the check found"; case "section": return `Section ${s.index}`; case "claim": return "Claim"; case "sources": return "Sources"; } } // A part of the article as a page block: what the reader would find there, // in the article's own type. function BlockFace({ s, view }: { s: SlideView; view: ReportPageView }) { let line: string | undefined; let mark: string | undefined; switch (s.kind) { case "title": line = [s.series, s.dates.join(" · ")].filter(Boolean).join(" · "); break; case "summary": line = s.text ? slidePointText(s.text) : s.points?.map(slidePointText).join(" · "); break; case "entry": line = s.text ? slidePointText(s.text) : undefined; break; case "found": line = s.groups.map((g) => `${g.claims.length} ${verdictStyleOf(view, g.verdict).label}`).join(" · "); break; case "section": line = s.text ? slidePointText(s.text) : s.points ? s.points.map(slidePointText).join(" · ") : `${s.claims.length} claims`; break; case "claim": if (s.verdict) mark = verdictStyleOf(view, s.verdict).color; line = s.gist ?? s.text; break; case "sources": line = `${s.references} references`; break; } const verdict = s.kind === "claim" && s.verdict ? verdictStyleOf(view, s.verdict).label : undefined; return ( {kindName(s)} {verdict ? · {verdict} : null} {s.title} {line && {line}} ); } // A thumbnail renders its slide's words as text: nothing in it is a link. const thumbHost = (base: SlidesHost): SlidesHost => ({ ...base, md: (text) => {slidePointText(text)}, cite: (_id, children) => children, onRead: undefined, badge: undefined, }); export const ReportOverview = forwardRef< HTMLElement, { slides: readonly SlideView[]; view: ReportPageView; // The pair in force: an anchor id (the URL's hash). focusAnchor: string | null; onFocusAnchor: (anchorId: string) => void; onOpenArticle: (slide: SlideView) => void; onOpenSlide: (slide: SlideView) => void; onExit?: (slide: SlideView | undefined) => void; keyScope?: "window" | "stage"; } >(function ReportOverview({ slides, view, focusAnchor, onFocusAnchor, onOpenArticle, onOpenSlide, onExit, keyScope = "window" }, ref) { const host = useSlidesHost(); const thumbs = useMemo(() => thumbHost(host), [host]); const active = Math.max( 0, slides.findIndex((s) => s.anchorId === focusAnchor), ); const [side, setSide] = useState<"block" | "card">("card"); const scrollerRef = useRef(null); const planeRef = useRef(null); const stageRef = useRef(null); // Tilted or flat: the reader's choice, else flat for reduced motion, a // narrow box or a keyboard arrival, else tilted. const [tilt, setTilt] = useState(null); const [narrow, setNarrow] = useState(false); useLayoutEffect(() => { const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; setTilt(storedTilt() ?? !(reduce || lastInput === "keyboard")); const el = scrollerRef.current; if (!el) return; const measure = () => setNarrow(el.clientWidth < NARROW_PX); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); const iso = tilt === true && !narrow; // The tilted plane's box: rotateX(55deg) rotateZ(-45deg) of a W × H plane // spans 0.7071(W + H) across and 0.4056(W + H) down, its top corner raised // 0.4056 W — the sizer holds exactly that, so the plane scrolls whole. const [plane, setPlane] = useState<{ w: number; h: number } | null>(null); useLayoutEffect(() => { const el = planeRef.current; if (!el) return; const measure = () => setPlane((p) => (p && p.w === el.offsetWidth && p.h === el.offsetHeight ? p : { w: el.offsetWidth, h: el.offsetHeight })); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [iso]); // The place in view: the pair in force, centred. useEffect(() => { const pair = planeRef.current?.querySelector(`[data-pair-index="${active}"]`); const scroller = scrollerRef.current; if (!pair || !scroller) return; // The scroller alone moves (never the article behind): by the pair's // drawn box, which the tilt has moved. const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; const p = pair.getBoundingClientRect(); const s = scroller.getBoundingClientRect(); scroller.scrollTo({ left: scroller.scrollLeft + (p.left + p.width / 2) - (s.left + s.width / 2), top: scroller.scrollTop + (p.top + p.height / 2) - (s.top + s.height / 2), behavior: reduce ? "auto" : "smooth", }); }, [active, iso, plane]); const focusPair = useCallback( (i: number, which: "block" | "card") => { const at = Math.min(Math.max(i, 0), slides.length - 1); setSide(which); onFocusAnchor(slides[at].anchorId); requestAnimationFrame(() => { planeRef.current?.querySelector(`[data-pair-index="${at}"] [data-ov-${which}]`)?.focus({ preventScroll: true }); }); }, [slides, onFocusAnchor], ); const onKey = useCallback( (e: KeyboardEvent | React.KeyboardEvent) => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return; const t = e.target as HTMLElement | null; if (t?.closest?.("input, textarea, select, [contenteditable=true]")) return; // Inside the plane the focused side is the one moved from; elsewhere // (the toggle, the stage itself) the last one. const from = (t?.closest?.("[data-ov-block]") ? "block" : t?.closest?.("[data-ov-card]") ? "card" : side) as "block" | "card"; switch (e.key) { case "ArrowDown": e.preventDefault(); focusPair(active + 1, from); break; case "ArrowUp": e.preventDefault(); focusPair(active - 1, from); break; case "Home": e.preventDefault(); focusPair(0, from); break; case "End": e.preventDefault(); focusPair(slides.length - 1, from); break; case "ArrowLeft": e.preventDefault(); focusPair(active, "block"); break; case "ArrowRight": e.preventDefault(); focusPair(active, "card"); break; case "Escape": if (onExit) { e.preventDefault(); onExit(slides[active]); } break; } }, [active, focusPair, onExit, side, slides], ); useEffect(() => { if (keyScope !== "window") return; const h = (e: KeyboardEvent) => onKey(e); window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [keyScope, onKey]); const sizer: CSSProperties | undefined = iso && plane ? { width: 0.7071 * (plane.w + plane.h) + 160, height: 0.4056 * (plane.w + plane.h) + 200 } : undefined; const planeStyle: CSSProperties | undefined = iso && plane ? { transform: `translate(80px, ${0.4056 * plane.w + 100}px) rotateX(55deg) rotateZ(-45deg)` } : undefined; return (
{ stageRef.current = el; if (typeof ref === "function") ref(el); else if (ref) ref.current = el; }} className="rs-root rs-ov" aria-roledescription="overview" aria-label={`Overview: ${view.title}, each part of the article beside its slide`} tabIndex={-1} data-report-overview={iso ? "iso" : "flat"} onKeyDown={keyScope === "stage" ? onKey : undefined} >
{!narrow && ( )}
    {slides.map((s, i) => { const here = i === active; return (
  1. ); })}
); });