Files
study-bible-project/src/components/reader/CrossRefMap.jsx
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00

90 lines
6.7 KiB
React

// The cross-reference map for a chapter (from IQ Bible's reference data): the chapter in the middle, the chapters
// it is linked to around it, in Bible order. Heavier lines mean more verse-to-verse links. Tap a chapter to see how
// they connect, open it, or move the map's centre there. A list below says the same in words.
import { useEffect, useMemo, useState } from 'react';
import Sheet from './Sheet.jsx';
import { useIq } from '../../hooks/useIq.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
import { chapterId, layoutMap, linkedChapters, parseGraph } from '../../utils/xrefMap.js';
const SIZE = 700;
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
export default function CrossRefMap({ open, onClose, book, chapter, onOpenChapter }) {
const [center, setCenter] = useState({ book, chapter });
const [depth, setDepth] = useState(1);
const [picked, setPicked] = useState(null);
useEffect(() => { if (open) { setCenter({ book, chapter }); setPicked(null); } }, [open, book, chapter]);
const { status, data, error } = useIq(open ? 'cross-references/graph' : null, { book: center.book, chapter: center.chapter, depth });
const centerId = chapterId(center.book, center.chapter);
const graph = useMemo(() => parseGraph(data), [data]);
const map = useMemo(() => layoutMap(graph, centerId, SIZE), [graph, centerId]);
const linked = useMemo(() => linkedChapters(graph, centerId), [graph, centerId]);
const byId = new Map(map.nodes.map((n) => [n.id, n]));
const sel = picked ? byId.get(picked) : null;
const selLinks = sel ? graph.edges.filter((e) => (e.source === centerId && e.target === sel.id) || (e.target === centerId && e.source === sel.id)) : [];
const label = (n) => `${bookName(n.book)} ${n.chapter}`;
const hot = new Set([...map.nodes].filter((n) => n.id !== centerId).sort((a, b) => b.weight - a.weight).slice(0, 14).map((n) => n.id)); // labelled on the picture
return (
<Sheet open={open} onClose={onClose} title={`Cross-reference map · ${bookName(center.book)} ${center.chapter}`} label="Cross-reference map">
<div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-[var(--r-sub)]">
<span role="group" aria-label="How far to follow the links" className="flex overflow-hidden rounded-full border border-[var(--r-line)]">
{[[1, 'Directly linked'], [2, 'And the ones they link to']].map(([d, name]) => (
<button key={d} type="button" aria-pressed={depth === d} onClick={() => { setDepth(d); setPicked(null); }}
className={`px-3 py-1 ${depth === d ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'hover:bg-black/5'}`}>{name}</button>
))}
</span>
{(center.book !== book || center.chapter !== chapter) && (
<button type="button" onClick={() => { setCenter({ book, chapter }); setPicked(null); }} className="text-[var(--r-accent)] hover:underline">Back to {bookName(book)} {chapter}</button>
)}
<span>Lines are heavier where there are more verse links.</span>
</div>
{status === 'loading' && <p className="text-sm text-[var(--r-sub)]">Drawing the map…</p>}
{status === 'error' && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{status === 'empty' && <p className="text-sm text-[var(--r-sub)]">No cross-references are recorded for this chapter.</p>}
{status === 'ready' && map.nodes.length > 1 && (
<>
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={`Map of ${linked.length} chapters linked to ${bookName(center.book)} ${center.chapter}`} className="mx-auto block w-full max-w-[34rem]">
{map.edges.map((e) => <path key={`${e.source}-${e.target}`} d={e.path} fill="none" stroke="currentColor" strokeWidth={e.width} opacity={picked && (e.source === picked || e.target === picked) ? 0.9 : e.opacity} className="text-[var(--r-text)]" />)}
{map.nodes.map((n) => (
<g key={n.id} onClick={() => setPicked(n.id === picked ? null : n.id)} className="cursor-pointer" role="button" tabIndex={0} aria-label={`${label(n)}, ${n.weight} links`}
onKeyDown={(ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); setPicked(n.id === picked ? null : n.id); } }}>
<circle cx={n.x} cy={n.y} r={n.r + (n.id === picked ? 3 : 0)} fill={n.color} stroke={n.id === picked || n.id === centerId ? 'currentColor' : 'none'} strokeWidth="2" className="text-[var(--r-text)]" />
{(n.id === centerId || n.id === picked || hot.has(n.id)) && (
<text x={n.x} y={n.y + (n.y < SIZE / 2 ? -n.r - 5 : n.r + 14)} textAnchor="middle" fontSize={n.id === centerId ? 18 : 13} fontWeight={n.id === centerId ? 700 : 500} fill="currentColor" className="text-[var(--r-text)]">{label(n)}</text>
)}
</g>
))}
</svg>
{graph.truncated && <p className="mt-1 text-center text-[11px] text-[var(--r-sub)]">A large map: only the first part is shown.</p>}
{sel && (
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3 text-sm" role="status">
<p className="font-medium">{label(sel)}</p>
<p className="text-xs text-[var(--r-sub)]">{selLinks.length ? `${selLinks.reduce((n, e) => n + e.weight, 0)} verse links with ${bookName(center.book)} ${center.chapter}` : `${sel.ring > 1 ? 'Linked through another chapter' : 'Linked'}`}</p>
<div className="mt-2 flex gap-3 text-xs">
<button type="button" onClick={() => { onOpenChapter(sel.book, sel.chapter); }} className="font-medium text-[var(--r-accent)] hover:underline">Read {label(sel)}</button>
{sel.id !== centerId && <button type="button" onClick={() => { setCenter({ book: sel.book, chapter: sel.chapter }); setPicked(null); }} className="hover:underline">Put it in the middle</button>}
</div>
</div>
)}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Most linked to {bookName(center.book)} {center.chapter}</h3>
<ol className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
{linked.slice(0, 20).map((c) => (
<li key={c.id} className="flex items-baseline justify-between gap-2">
<button type="button" onClick={() => setPicked(c.id)} className="truncate text-left hover:underline">{bookName(c.book)} {c.chapter}</button>
<span className="shrink-0 text-xs text-[var(--r-sub)]">{c.weight}</span>
</li>
))}
</ol>
</>
)}
<p className="mt-4 text-[10px] text-[var(--r-sub)]">Cross-references via IQ Bible.</p>
</Sheet>
);
}