Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- 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>
90 lines
6.7 KiB
React
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>
|
|
);
|
|
}
|