Units hover notes and a family tree for people (Theographic)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fdb7f16d8a
commit
f05d38ecde
8 files changed
+282
-1
No files matched your search
@@ -0,0 +1,61 @@
|
||||
// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
|
||||
// descendants a few generations out. From Theographic Bible Metadata.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadPerson } from '../hooks/useVerseStudy.js';
|
||||
import { relatives, walk } from '../utils/family.js';
|
||||
|
||||
function Branch({ node, onOpen }) {
|
||||
return (
|
||||
<li>
|
||||
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
|
||||
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
|
||||
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Tree({ id, onOpen }) {
|
||||
const [state, setState] = useState({ loading: true });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })])
|
||||
.then(([up, down]) => { if (live) setState({ up, down }); })
|
||||
.catch(() => { if (live) setState({ error: true }); });
|
||||
return () => { live = false; };
|
||||
}, [id]);
|
||||
if (state.loading) return <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
|
||||
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
|
||||
const block = (title, node, empty) => (
|
||||
<div>
|
||||
<p className="font-medium text-[var(--r-text)]">{title}</p>
|
||||
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
|
||||
</div>
|
||||
);
|
||||
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
|
||||
}
|
||||
|
||||
// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
|
||||
export default function Family({ person, renderPerson }) {
|
||||
const groups = relatives(person);
|
||||
const [shown, setShown] = useState(null); // a relative opened from a name or the tree
|
||||
const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
|
||||
{groups.map((g) => (
|
||||
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
|
||||
{g.people.map((p) => (
|
||||
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
|
||||
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
|
||||
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
|
||||
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
|
||||
</details>
|
||||
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import Family from './FamilyTree.jsx';
|
||||
|
||||
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||
const RECORDS = {
|
||||
fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] },
|
||||
fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] },
|
||||
fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] },
|
||||
fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] },
|
||||
fam_abel: { id: 'fam_abel', name: 'Abel' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
const id = String(url).match(/people\/(.+)\.json/)?.[1];
|
||||
return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) };
|
||||
}));
|
||||
});
|
||||
|
||||
test('the relatives named in the record are listed, and clicking one opens them', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||
const family = screen.getByLabelText('Family');
|
||||
for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument();
|
||||
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||
expect(screen.getByText('details for fam_adam')).toBeInTheDocument();
|
||||
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||
expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the family tree shows ancestors and descendants', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||
await user.click(screen.getByText('Family tree'));
|
||||
expect(await screen.findByText('Ancestors')).toBeInTheDocument();
|
||||
const tree = screen.getByText('Ancestors').closest('div').parentElement;
|
||||
expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0);
|
||||
expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a person with no recorded family shows nothing', () => {
|
||||
const { container } = render(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
import IqCommentary from './iq/IqCommentary.jsx';
|
||||
import Family from './FamilyTree.jsx';
|
||||
import {
|
||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||
} from '../hooks/useVerseStudy.js';
|
||||
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
|
||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||
{!data.description?.length && <p>No description available.</p>}
|
||||
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
|
||||
{data.minYear != null && (
|
||||
<p className="text-[var(--r-sub,#64748b)]">
|
||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
||||
import GrammarPanel from '../GrammarPanel.jsx';
|
||||
import { markUnits } from '../../utils/biblicalUnits.js';
|
||||
|
||||
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||
const [openVerse, setOpenVerse] = useState(null);
|
||||
@@ -22,7 +23,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
||||
className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}>
|
||||
{v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||
</button>
|
||||
<span>{v.text}</span>
|
||||
<span>{markUnits(v.text).map((part, i) => (part.note
|
||||
? <abbr key={i} title={part.note} tabIndex={0} className="cursor-help underline decoration-dotted decoration-[var(--r-accent)] underline-offset-4">{part.text}</abbr>
|
||||
: <span key={i}>{part.text}</span>))}</span>
|
||||
{onVerseNote && (
|
||||
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Ancient weights, measures and money, with a modern guide. Hover a unit in the passage to see it. The sizes are the
|
||||
// usual round figures; the real ones varied by place and period, so every note says "about".
|
||||
const UNITS = [
|
||||
// length
|
||||
{ words: ['cubit', 'cubits'], note: 'A cubit: about 18 inches (45 cm), the length from elbow to fingertip.' },
|
||||
{ words: ['span', 'spans'], note: 'A span: about 9 inches (23 cm), a hand stretched wide. Half a cubit.' },
|
||||
{ words: ['handbreadth', 'handbreadths', 'handsbreadth'], note: 'A handbreadth: about 3 inches (7.5 cm), the width of four fingers.' },
|
||||
{ words: ['fathom', 'fathoms'], note: 'A fathom: about 6 feet (1.8 m), the span of outstretched arms.' },
|
||||
{ words: ['furlong', 'furlongs', 'stadia', 'stadion'], note: 'A stadion (furlong): about 607 feet (185 m), an eighth of a Roman mile.' },
|
||||
{ words: ['mile', 'miles'], note: 'A Roman mile: about 4,860 feet (1.48 km), a little shorter than a modern mile.' },
|
||||
// weight
|
||||
{ words: ['talent', 'talents'], note: 'A talent: about 75 pounds (34 kg) of silver or gold. Worth around 6,000 days of a labourer’s wages.' },
|
||||
{ words: ['mina', 'minas', 'pound', 'pounds'], note: 'A mina: about 1¼ pounds (0.57 kg). A hundred days’ wages. (Older versions call it a "pound".)', only: ['mina', 'minas'] },
|
||||
{ words: ['shekel', 'shekels'], note: 'A shekel: about 0.4 ounce (11 g). The usual weight of silver for payments.' },
|
||||
{ words: ['beka'], note: 'A beka: about 0.2 ounce (5.7 g). Half a shekel.' },
|
||||
{ words: ['gerah', 'gerahs'], note: 'A gerah: about 0.02 ounce (0.6 g). A twentieth of a shekel.' },
|
||||
// money
|
||||
{ words: ['denarius', 'denarii'], note: 'A denarius: a silver Roman coin, about a day’s wage for a labourer.' },
|
||||
{ words: ['drachma', 'drachmas'], note: 'A drachma: a Greek silver coin, about a day’s wage.' },
|
||||
{ words: ['penny', 'pennies', 'pence'], note: 'In older versions, a "penny" is usually a denarius, about a day’s wage.' },
|
||||
{ words: ['farthing', 'farthings'], note: 'A farthing: a small Roman copper coin, a fraction of a day’s wage.' },
|
||||
{ words: ['lepton', 'mite', 'mites'], note: 'A lepton ("mite"): the smallest Greek coin, a fraction of a day’s wage.' },
|
||||
{ words: ['quadrans'], note: 'A quadrans: a small Roman copper coin worth a fraction of a day’s wage.' },
|
||||
// volume
|
||||
{ words: ['ephah', 'ephahs'], note: 'An ephah: about 22 liters (⅗ bushel) of grain. Ten omers.' },
|
||||
{ words: ['omer', 'omers'], note: 'An omer: about 2.2 liters (2 quarts) of grain. A tenth of an ephah.' },
|
||||
{ words: ['homer', 'homers'], note: 'A homer: about 220 liters (6 bushels), a donkey load. Ten ephahs.' },
|
||||
{ words: ['seah', 'seahs'], note: 'A seah: about 7 liters (7½ quarts) of grain. A third of an ephah.' },
|
||||
{ words: ['bath', 'baths'], note: 'A bath: about 22 liters (6 gallons) of liquid. The same size as an ephah.' },
|
||||
{ words: ['hin'], note: 'A hin: about 3.7 liters (4 quarts) of liquid.' },
|
||||
{ words: ['cor', 'cors', 'kor'], note: 'A cor: about 220 liters (6 bushels). The same as a homer.' },
|
||||
];
|
||||
|
||||
// word -> note, for the words that are safe to mark in running text ("pound" is left out: it is a common word and only the mina is meant in a few verses)
|
||||
const INDEX = new Map();
|
||||
for (const u of UNITS) for (const w of u.words) {
|
||||
if (u.only && !u.only.includes(w)) continue; // "pounds" and "penny" are common words with other meanings; "bath" too
|
||||
INDEX.set(w, u.note);
|
||||
}
|
||||
const RE = new RegExp(`\\b(${[...INDEX.keys()].sort((a, b) => b.length - a.length).join('|')})\\b`, 'gi');
|
||||
|
||||
export const unitNote = (word) => INDEX.get(String(word).toLowerCase()) ?? null;
|
||||
|
||||
// Splits a verse into plain pieces and unit words: [{ text, note? }]. Text that has no unit is a single piece.
|
||||
export function markUnits(text) {
|
||||
const parts = [];
|
||||
let at = 0;
|
||||
for (const m of String(text ?? '').matchAll(RE)) {
|
||||
if (m.index > at) parts.push({ text: text.slice(at, m.index) });
|
||||
parts.push({ text: m[0], note: INDEX.get(m[0].toLowerCase()) });
|
||||
at = m.index + m[0].length;
|
||||
}
|
||||
if (at < text.length) parts.push({ text: text.slice(at) });
|
||||
return parts.length ? parts : [{ text: text ?? '' }];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { markUnits, unitNote } from './biblicalUnits.js';
|
||||
|
||||
const noted = (text) => markUnits(text).filter((p) => p.note).map((p) => p.text);
|
||||
|
||||
test('units in a verse are found with their meaning, in any form', () => {
|
||||
expect(noted('The ark was two and a half cubits long and a handbreadth thick.')).toEqual(['cubits', 'handbreadth']);
|
||||
expect(noted('He gave him five talents, and she lost one drachma; it cost a denarius.')).toEqual(['talents', 'drachma', 'denarius']);
|
||||
expect(noted('An ephah of flour, an omer of manna, and a hin of oil.')).toEqual(['ephah', 'omer', 'hin']);
|
||||
expect(noted('about twenty-five or thirty stadia')).toEqual(['stadia']);
|
||||
expect(unitNote('Talent')).toMatch(/75 pounds/);
|
||||
});
|
||||
|
||||
test('the pieces rejoin to the original text, and a verse with no units is one piece', () => {
|
||||
const text = 'five talents of silver (a talent is heavy)';
|
||||
expect(markUnits(text).map((p) => p.text).join('')).toBe(text);
|
||||
expect(markUnits('For God so loved the world')).toEqual([{ text: 'For God so loved the world' }]);
|
||||
expect(markUnits('')).toEqual([{ text: '' }]);
|
||||
});
|
||||
|
||||
test('ordinary words are not mistaken for units', () => {
|
||||
expect(noted('He took a pound of flesh; a span of the river; the mile stone')).toEqual(['span', 'mile']);
|
||||
expect(noted('Bathsheba and Cornelius, the stadium crowd, a corner and a hinge')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a mina is a mina, and the older word pound is left alone', () => {
|
||||
expect(noted('ten minas and a mina')).toEqual(['minas', 'mina']);
|
||||
expect(unitNote('pound')).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Family connections from the Theographic person records (father, mother, siblings, partners, children).
|
||||
const GROUPS = [['father', 'Father'], ['mother', 'Mother'], ['partners', 'Partner'], ['siblings', 'Siblings'], ['children', 'Children']];
|
||||
|
||||
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []).filter((p) => p && p.id && p.name);
|
||||
|
||||
// -> [{ label: 'Children', people: [{ id, name }] }] for the relations this person has
|
||||
export function relatives(person) {
|
||||
return GROUPS.map(([key, label]) => ({ label: key === 'partners' && asList(person?.partners).length > 1 ? 'Partners' : label, people: asList(person?.[key]).map(({ id, name }) => ({ id, name })) }))
|
||||
.filter((g) => g.people.length > 0);
|
||||
}
|
||||
|
||||
// Walks up (parents) or down (children) from one person, `depth` generations, never visiting anyone twice and
|
||||
// stopping after `max` people so a long line like Adam's doesn't fetch the whole Bible.
|
||||
// load(id) -> person record or null. -> { id, name, more: bool, kids: [node…] } (kids are the parents when going up)
|
||||
export async function walk(load, id, { direction = 'down', depth = 2, max = 40 } = {}) {
|
||||
const seen = new Set([id]);
|
||||
let budget = max;
|
||||
const links = (p) => (direction === 'down' ? asList(p?.children) : [...asList(p?.father), ...asList(p?.mother)]);
|
||||
async function visit(personId, name, level) {
|
||||
const person = await load(personId);
|
||||
const next = links(person).filter((l) => !seen.has(l.id));
|
||||
const node = { id: personId, name: person?.name ?? name, more: false, kids: [] };
|
||||
if (level >= depth) { node.more = next.length > 0; return node; }
|
||||
const taking = next.filter((l) => budget > 0 && (budget -= 1) >= 0);
|
||||
taking.forEach((l) => seen.add(l.id));
|
||||
node.more = taking.length < next.length;
|
||||
node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1)));
|
||||
return node;
|
||||
}
|
||||
const root = await load(id);
|
||||
return visit(id, root?.name ?? '', 0);
|
||||
}
|
||||
|
||||
// Counts the people below a node (not counting it)
|
||||
export const countKids = (node) => node.kids.reduce((n, k) => n + 1 + countKids(k), 0);
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { countKids, relatives, walk } from './family.js';
|
||||
|
||||
const p = (id, name, extra = {}) => ({ id, name, ...extra });
|
||||
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||
const people = {
|
||||
adam: p('adam', 'Adam', { partners: [ref('eve', 'Eve')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||
eve: p('eve', 'Eve', { partners: [ref('adam', 'Adam')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||
cain: p('cain', 'Cain', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enoch', 'Enoch')] }),
|
||||
abel: p('abel', 'Abel', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')] }),
|
||||
seth: p('seth', 'Seth', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enos', 'Enos')], siblings: [ref('cain', 'Cain'), ref('abel', 'Abel')] }),
|
||||
enoch: p('enoch', 'Enoch', { father: [ref('cain', 'Cain')], children: [ref('irad', 'Irad')] }),
|
||||
enos: p('enos', 'Enos', { father: [ref('seth', 'Seth')] }),
|
||||
};
|
||||
const load = async (id) => people[id] ?? null;
|
||||
const names = (n) => ({ [n.name]: n.kids.map(names) });
|
||||
|
||||
test('relations are grouped and named, and empty ones are left out', () => {
|
||||
expect(relatives(people.seth).map((g) => [g.label, g.people.map((x) => x.name)])).toEqual([
|
||||
['Father', ['Adam']], ['Mother', ['Eve']], ['Siblings', ['Cain', 'Abel']], ['Children', ['Enos']],
|
||||
]);
|
||||
expect(relatives(people.adam).map((g) => g.label)).toEqual(['Partner', 'Children']);
|
||||
expect(relatives({ id: 'x', name: 'X', partners: [ref('a', 'A'), ref('b', 'B')] })[0].label).toBe('Partners');
|
||||
expect(relatives(null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('descendants are walked a few generations down', async () => {
|
||||
const tree = await walk(load, 'adam', { direction: 'down', depth: 2 });
|
||||
expect(names(tree)).toEqual({ Adam: [{ Cain: [{ Enoch: [] }] }, { Abel: [] }, { Seth: [{ Enos: [] }] }] });
|
||||
expect(countKids(tree)).toBe(5);
|
||||
const shallow = await walk(load, 'adam', { direction: 'down', depth: 1 });
|
||||
expect(shallow.kids.find((k) => k.name === 'Cain').more).toBe(true); // Cain has children not shown
|
||||
});
|
||||
|
||||
test('ancestors go up through both parents, without visiting anyone twice', async () => {
|
||||
const up = await walk(load, 'enoch', { direction: 'up', depth: 3 });
|
||||
expect(names(up)).toEqual({ Enoch: [{ Cain: [{ Adam: [] }, { Eve: [] }] }] });
|
||||
});
|
||||
|
||||
test('a long line stops at the limit and says there is more', async () => {
|
||||
const tree = await walk(load, 'adam', { direction: 'down', depth: 3, max: 2 });
|
||||
expect(countKids(tree)).toBeLessThanOrEqual(2 + 1); // two taken at the top, each may take nothing more
|
||||
expect(tree.more).toBe(true);
|
||||
});
|
||||
|
||||
test('someone missing from the data still appears by name', async () => {
|
||||
const tree = await walk(load, 'adam', { direction: 'down', depth: 3 });
|
||||
const enoch = tree.kids[0].kids[0];
|
||||
expect(enoch.kids[0].name).toBe('Irad'); // Enoch's child Irad has no record but is named by the link
|
||||
});
|
||||
Reference in new issue
Block a user