No description available.
} />
{data.minYear != null && (
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
diff --git a/src/components/study/ScripturePane.jsx b/src/components/study/ScripturePane.jsx
index c453382..e3f2176 100644
--- a/src/components/study/ScripturePane.jsx
+++ b/src/components/study/ScripturePane.jsx
@@ -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}
- {v.text}
+ {markUnits(v.text).map((part, i) => (part.note
+ ? {part.text}
+ : {part.text} ))}
{onVerseNote && (
setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
diff --git a/src/utils/biblicalUnits.js b/src/utils/biblicalUnits.js
new file mode 100644
index 0000000..d6af157
--- /dev/null
+++ b/src/utils/biblicalUnits.js
@@ -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 ?? '' }];
+}
diff --git a/src/utils/biblicalUnits.test.js b/src/utils/biblicalUnits.test.js
new file mode 100644
index 0000000..2bd49e9
--- /dev/null
+++ b/src/utils/biblicalUnits.test.js
@@ -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();
+});
diff --git a/src/utils/family.js b/src/utils/family.js
new file mode 100644
index 0000000..ceadf7e
--- /dev/null
+++ b/src/utils/family.js
@@ -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);
diff --git a/src/utils/family.test.js b/src/utils/family.test.js
new file mode 100644
index 0000000..7145664
--- /dev/null
+++ b/src/utils/family.test.js
@@ -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
+});