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
4.9 KiB
JavaScript
90 lines
4.9 KiB
JavaScript
// 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));
|
|
}
|