// The cross-reference map: a chapter in the middle, the chapters it is linked to around it in rings, in Bible order, // joined by lines that are heavier where there are more verse-to-verse links. Pure layout code (no drawing) so it can be tested. import { bookOptions } from './bibleUtils.jsx'; import { groupOfBook } from './bookCovers.js'; const ORDER = new Map(bookOptions.map((b, i) => [b.abbrev, i])); const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev; export const chapterId = (book, chapter) => `${book}.${chapter}`; // The API's { nodes: [{ id, book, name_en, chapter }], edges: [{ source, target, weight }] } -> a clean copy export function parseGraph(data) { const nodes = (Array.isArray(data?.nodes) ? data.nodes : []).filter((n) => n && typeof n.id === 'string' && n.book && Number.isFinite(Number(n.chapter))) .map((n) => ({ id: n.id, book: n.book, chapter: Number(n.chapter), name: n.name_en || bookName(n.book) })); const have = new Set(nodes.map((n) => n.id)); const edges = (Array.isArray(data?.edges) ? data.edges : []).filter((e) => have.has(e?.source) && have.has(e?.target) && e.source !== e.target) .map((e) => ({ source: e.source, target: e.target, weight: Math.max(1, Number(e.weight) || 1) })); return { nodes, edges, truncated: !!data?.truncated }; } const canonical = (a, b) => (ORDER.get(a.book) ?? 99) - (ORDER.get(b.book) ?? 99) || a.chapter - b.chapter; // How many chapter-to-chapter hops each chapter is from the centre (the centre is 0) export function ringsFrom(nodes, edges, centerId) { const next = new Map(nodes.map((n) => [n.id, []])); for (const e of edges) { next.get(e.source)?.push(e.target); next.get(e.target)?.push(e.source); } const ring = new Map([[centerId, 0]]); const queue = [centerId]; while (queue.length) { const id = queue.shift(); for (const other of next.get(id) ?? []) if (!ring.has(other)) { ring.set(other, ring.get(id) + 1); queue.push(other); } } return ring; } // -> { nodes: [{ ...node, x, y, ring, weight, color, r }], edges: [{ ...edge, path, width, opacity }], size } // Positions are in a square of `size` units with the centre in the middle. export function layoutMap(graph, centerId, size = 700) { const ring = ringsFrom(graph.nodes, graph.edges, centerId); const maxRing = Math.max(1, ...ring.values()); const radii = maxRing === 1 ? [0, 0.42] : maxRing === 2 ? [0, 0.24, 0.44] : [0, 0.18, 0.31, 0.44]; const weightOf = new Map(graph.nodes.map((n) => [n.id, 0])); for (const e of graph.edges) { weightOf.set(e.source, weightOf.get(e.source) + e.weight); weightOf.set(e.target, weightOf.get(e.target) + e.weight); } const maxWeight = Math.max(1, ...[...weightOf.entries()].filter(([id]) => id !== centerId).map(([, w]) => w)); const byRing = new Map(); for (const n of graph.nodes) { const k = Math.min(ring.get(n.id) ?? maxRing, maxRing); if (!byRing.has(k)) byRing.set(k, []); byRing.get(k).push(n); } const placed = new Map(); for (const [k, list] of byRing) { list.sort(canonical); list.forEach((n, i) => { const angle = k === 0 ? 0 : -Math.PI / 2 + (2 * Math.PI * i) / list.length; const radius = (radii[k] ?? 0.44) * size; const w = weightOf.get(n.id); placed.set(n.id, { ...n, ring: k, weight: w, color: groupOfBook(n.book).colors[0], x: size / 2 + Math.cos(angle) * radius, y: size / 2 + Math.sin(angle) * radius, r: n.id === centerId ? 13 : 3 + 6 * Math.sqrt(w / maxWeight), }); }); } const maxEdge = Math.max(1, ...graph.edges.map((e) => e.weight)); const edges = graph.edges.map((e) => { const a = placed.get(e.source); const b = placed.get(e.target); const toCenter = e.source === centerId || e.target === centerId; // lines to the middle are straight; the others bow toward the middle so they don't cross the rim const c = toCenter ? null : { x: size / 2 + (a.x + b.x - size) * 0.18, y: size / 2 + (a.y + b.y - size) * 0.18 }; return { ...e, path: c ? `M${a.x.toFixed(1)},${a.y.toFixed(1)} Q${c.x.toFixed(1)},${c.y.toFixed(1)} ${b.x.toFixed(1)},${b.y.toFixed(1)}` : `M${a.x.toFixed(1)},${a.y.toFixed(1)} L${b.x.toFixed(1)},${b.y.toFixed(1)}`, width: 0.6 + 2.4 * (e.weight / maxEdge), opacity: toCenter ? 0.25 + 0.5 * (e.weight / maxEdge) : 0.08 + 0.3 * (e.weight / maxEdge), }; }); return { nodes: [...placed.values()], edges, size }; } // The chapters linked to the centre, strongest first: [{ id, name, chapter, weight }] export function linkedChapters(graph, centerId) { const out = new Map(); for (const e of graph.edges) { const other = e.source === centerId ? e.target : e.target === centerId ? e.source : null; if (other) out.set(other, (out.get(other) ?? 0) + e.weight); } const byId = new Map(graph.nodes.map((n) => [n.id, n])); return [...out].map(([id, weight]) => ({ ...byId.get(id), weight })).sort((a, b) => b.weight - a.weight || canonical(a, b)); }