family.papp
All 367 lines. No libraries, nothing hidden. Read it before you run it.
<!doctype html>
<meta charset="utf-8">
<meta name="papp-write" content="./family.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path fill='%23111114' d='M20 46.2A12 12 0 0 1 31.2 35L30.6 26.5A20.5 20.5 0 0 0 11.5 45.6zM32.8 35A12 12 0 0 1 44 46.2L52.5 45.6A20.5 20.5 0 0 0 33.4 26.5zM9 46A23 23 0 0 1 15 31.5L9.9 26.7A30 30 0 0 0 2 45.7zM16.5 30A23 23 0 0 1 31 24L30.7 17A30 30 0 0 0 11.7 24.9zM33 24A23 23 0 0 1 47.5 30L52.3 24.9A30 30 0 0 0 33.3 17zM49 31.5A23 23 0 0 1 55 46L62 45.7A30 30 0 0 0 54.1 26.7z'/><path fill='%23ff4f00' d='M23 47a9 9 0 0 1 18 0z'/></svg>">
<!--
Family tree: who is who, and which dates can't be right.
Put this page next to your tree, exported as GEDCOM (.ged) from Ancestry, MyHeritage, FamilySearch or Gramps.
It reads every .ged file here, and keeps people from different files apart.
The fan shows four generations of ancestors around one person; click anyone to put them in the middle.
The left column lists the dates that can't be right: died before born, a parent under 12, a mother over 55,
a child born after a parent died, a life past 110, and likely duplicates: the same name, born within two years.
A vague date ("about 1890", "before 1900") only counts for what it makes certain, so it never raises a false alarm.
family.json remembers who was in the middle.
-->
<title>Family tree</title>
<style>
:root { color-scheme: light dark; --paper: light-dark(#fff, #0f0f11); --well: light-dark(#f4f4f5, #1c1c20); --ink: light-dark(#0f0f11, #f2f2f3); --soft: light-dark(#6b6b72, #9b9ba4); --rule: light-dark(#e4e4e7, #2b2b31); --accent: #ff4f00; }
body { display: grid; grid-template: auto minmax(0, 1fr) / 17rem minmax(0, 1fr) 18rem; height: 100vh; margin: 0; background: var(--paper); color: var(--ink); font: 13px/1.4 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
header { grid-column: 1 / -1; display: flex; gap: 16px; align-items: baseline; padding: 16px 20px; border-bottom: 1px solid var(--rule); }
h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
header p { flex: 1; margin: 0; color: var(--soft); }
#find { position: relative; }
input { width: 15rem; padding: 6px 12px; border: 1px solid var(--rule); border-radius: 99px; background: var(--well); color: var(--ink); font: inherit; outline: none; }
input:focus { border-color: var(--accent); }
#found { position: absolute; z-index: 1; top: 100%; right: 0; width: 18rem; margin: 6px 0 0; padding: 0; border-radius: 10px; background: var(--paper); box-shadow: 0 10px 30px -12px rgb(0 0 0 / 0.5); list-style: none; }
#found:empty { display: none; }
#found li { padding: 7px 12px; cursor: pointer; }
#found li:hover, #found li:first-child { background: var(--well); }
h2 { margin: 0 0 10px; color: var(--soft); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
#problems { overflow: auto; padding: 18px 16px; border-right: 1px solid var(--rule); background: var(--well); }
#list { margin: 0; padding: 0; list-style: none; }
#list li { margin-bottom: 6px; padding: 9px 12px; border: 1px solid var(--rule); border-left: 3px solid var(--rule); border-radius: 8px; background: var(--paper); cursor: pointer; }
#list li:hover, #list li.here { border-left-color: var(--accent); }
#list small, #panel h3 { display: block; color: var(--soft); font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
#list strong { display: block; }
#list span, #undated { color: var(--soft); font-size: 12px; }
#chart { display: grid; grid-template-rows: minmax(0, 1fr) auto; padding: 20px; }
#fan { width: 100%; height: 100%; font-size: 11px; }
#fan path { fill: var(--well); stroke: var(--paper); stroke-width: 2; }
#fan .none path { fill: none; stroke: var(--rule); stroke-width: 1; }
#fan [data-id] { cursor: pointer; }
#fan [data-id]:hover path { fill: var(--rule); }
#fan text { fill: var(--ink); text-anchor: middle; }
#fan .years { fill: var(--soft); }
#fan .g3, #fan .g4 { font-size: 10px; }
#fan .dot { fill: var(--accent); }
#fan .centre circle { fill: var(--accent); }
#fan .centre text, #fan .centre .years { fill: #0f0f11; font-size: 13px; font-weight: 600; }
#fan .centre .years { font-size: 11px; font-weight: 400; }
#chart p { margin: 0; color: var(--soft); text-align: center; }
#panel { overflow: auto; padding: 18px 20px; border-left: 1px solid var(--rule); }
#panel h2 { margin: 0; color: var(--ink); font-size: 18px; letter-spacing: -0.01em; text-transform: none; }
#panel h3 { margin: 18px 0 6px; }
#panel p { margin: 2px 0 0; color: var(--soft); }
dl { display: grid; grid-template-columns: 4.5rem 1fr; gap: 6px 10px; margin: 16px 0 0; }
dt { color: var(--soft); }
dd { margin: 0; }
.link { display: flex; justify-content: space-between; width: 100%; padding: 5px 0; border: 0; border-bottom: 1px solid var(--rule); background: none; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
.link:hover { color: var(--accent); }
.link span, #found span { color: var(--soft); }
.problem { padding-left: 10px; border-left: 3px solid var(--accent); }
#empty { display: none; grid-column: 1 / -1; place-content: center; max-width: 30rem; margin: 0 auto; text-align: center; }
#empty code { font: 12px ui-monospace, monospace; }
#empty h2 { color: var(--ink); font-size: 18px; letter-spacing: -0.01em; text-transform: none; }
.empty > :not(header, #empty), .empty #find { display: none; }
.empty #empty { display: grid; }
</style>
<header><h1>Family tree</h1><p id="summary"></p><div id="find"><input id="search" type="search" placeholder="Find someone" autocomplete="off"><ul id="found"></ul></div></header>
<section id="problems"><h2 id="count"></h2><ul id="list"></ul><p id="undated"></p></section>
<section id="chart"><svg id="fan" viewBox="-325 -325 650 490"></svg><p>Click anyone to put them in the middle</p></section>
<aside id="panel"></aside>
<section id="empty"><h2>No family tree here yet</h2><p>Export your tree as a GEDCOM file (<code>.ged</code>) from Ancestry, MyHeritage, FamilySearch or Gramps, put it in <code id="folder"></code>, and open the app again.</p></section>
<script type="module">
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
const words = { ABT: 'about', CAL: 'about', EST: 'about', INT: '', BEF: 'before', AFT: 'after', BET: 'between', AND: 'and', FROM: 'from', TO: 'to' };
const labels = { BIRT: 'Born', CHR: 'Christened', BAPM: 'Baptised', DEAT: 'Died', BURI: 'Buried' };
const kinds = ['Died before born', 'Born after father died', 'Born after mother died', 'Parent under 12', 'Mother over 55', 'Lived past 110', 'Likely duplicate'];
const rings = [0, 64, 134, 200, 262, 320];
const people = new Map();
const families = new Map();
const files = (await papp.readdir('.')).filter(name => name.toLowerCase().endsWith('.ged'));
for (const file of files) {
// Most exports are UTF-8; a few are UTF-16. The decoder drops the byte-order mark either way.
const bytes = await papp.readFile(file, null);
const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8';
read(file, parse(new TextDecoder(encoding).decode(bytes)));
}
// Families hold the links; each person gets the family they were born into and the ones they started.
for (const family of families.values()) {
family.husband = people.get(family.husband);
family.wife = people.get(family.wife);
family.children = family.children.map(id => people.get(id)).filter(Boolean);
for (const spouse of [family.husband, family.wife]) spouse?.unions.push(family);
for (const child of family.children) child.family ??= family;
}
const problems = check();
const state = (await papp.exists('family.json')) ? JSON.parse(await papp.readFile('family.json')) : {};
const start = people.get(state.centre) ?? people.values().next().value;
if (start) {
summary.textContent = `${people.size.toLocaleString()} people · ${families.size.toLocaleString()} families · ${files.join(', ')}`;
count.textContent = problems.length ? `${problems.length.toLocaleString()} problem${problems.length === 1 ? '' : 's'}` : 'No problems found';
const undatedCount = [...people.values()].filter(person => person.born?.date?.year === undefined).length;
undated.textContent = undatedCount ? `${undatedCount.toLocaleString()} ${undatedCount === 1 ? 'person has' : 'people have'} no birth date` : '';
list.innerHTML = problems.map(({ person, kind, detail }) => `<li data-id="${esc(person.id)}"><small>${kind}</small><strong>${esc(person.name)}</strong><span>${esc(detail)}</span></li>`).join('');
show(start);
} else {
document.body.className = 'empty';
folder.textContent = papp.dir;
}
// One handler for every name on screen: the fan, the panel, the problems and the search results.
addEventListener('click', ({ target }) => {
const id = target.closest('[data-id]')?.dataset.id;
if (id) show(people.get(id));
});
search.oninput = () => {
const terms = plain(search.value).split(' ').filter(Boolean);
const hits = terms.length ? [...people.values()].filter(person => terms.every(term => person.plain.includes(term))).slice(0, 8) : [];
found.innerHTML = hits.map(person => `<li data-id="${esc(person.id)}">${esc(person.name)} <span>${years(person)}</span></li>`).join('');
};
search.onkeydown = ({ key }) => {
if (key === 'Enter' && found.firstChild) show(people.get(found.firstChild.dataset.id));
if (key === 'Escape') search.value = found.innerHTML = '';
};
async function show(person) {
search.value = found.innerHTML = '';
drawFan(person);
drawPanel(person);
for (const row of list.children) row.classList.toggle('here', row.dataset.id === person.id);
document.title = `Family tree · ${person.name}`;
await papp.writeFile('family.json', JSON.stringify({ centre: person.id }, null, 2) + '\n');
}
// GEDCOM is one "level [@id@] TAG [value]" per line; CONC and CONT carry on the line above.
function parse(text) {
const records = [];
const open = [];
for (const line of text.split(/\r\n?|\n/)) {
const match = line.match(/^\s*(\d+)\s+(?:(@[^@\s]+@)\s+)?(\S+) ?(.*)$/);
if (!match) continue;
const [, depth, id, tag, value] = match;
const level = Number(depth);
if (tag === 'CONC' || tag === 'CONT') {
if (open[level - 1]) open[level - 1].value += (tag === 'CONT' ? '\n' : '') + value;
continue;
}
const node = { id, tag, value, children: [] };
if (level === 0) records.push(node);
else if (open[level - 1]) open[level - 1].children.push(node);
else continue;
open[level] = node;
open.length = level + 1;
}
return records;
}
// Ids are only unique within a file, so each one is kept as "file:@I12@".
function read(file, records) {
const key = id => `${file}:${id}`;
const all = (node, tag) => node.children.filter(child => child.tag === tag).map(child => key(child.value.trim()));
for (const record of records) {
if (record.tag === 'INDI') {
const name = first(record, 'NAME');
const [given, surname] = (name?.value ?? '').split('/').map(part => part.trim());
const person = {
id: key(record.id),
file,
given: given || (name && first(name, 'GIVN')?.value) || '',
surname: surname || (name && first(name, 'SURN')?.value) || '',
sex: first(record, 'SEX')?.value.trim(),
born: event(record, 'BIRT', 'CHR', 'BAPM'),
died: event(record, 'DEAT', 'BURI'),
unions: [],
};
person.name = [person.given, person.surname].filter(Boolean).join(' ') || 'Unnamed';
person.plain = plain(person.name);
people.set(person.id, person);
}
if (record.tag === 'FAM') {
const [husband] = all(record, 'HUSB');
const [wife] = all(record, 'WIFE');
families.set(key(record.id), { husband, wife, children: all(record, 'CHIL'), married: event(record, 'MARR') });
}
}
}
function first(node, tag) {
return node.children.find(child => child.tag === tag);
}
// The first of the tags the record has, as { tag, date, place }.
function event(record, ...tags) {
for (const tag of tags) {
const found = first(record, tag);
if (!found) continue;
const date = first(found, 'DATE')?.value.trim();
const place = first(found, 'PLAC')?.value.trim();
if (date || place) return { tag, date: date ? parseDate(date) : null, place };
}
}
// A date becomes a range of years, min to max: "MAR 1890" is all of March, "ABT 1890" 1888 to 1892,
// "BEF 1890" anything up to 1890. A date it can't read keeps its text and an endless range.
function parseDate(raw) {
const upper = raw.toUpperCase().replace(/@#D\w+@|\(.*?\)|\./g, '').replace(/\b(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)[A-Z]+/g, '$1').replace(/\s+/g, ' ').trim();
const text = upper.split(' ').map(word => (word in words ? words[word] : months.includes(word) ? word[0] + word.slice(1).toLowerCase() : word)).filter(Boolean).join(' ') || raw;
const [, mode = '', from, , to] = upper.match(/^(?:(ABT|CAL|EST|INT|BEF|AFT|BET|FROM|TO) )?(.+?)(?: (AND|TO) (.+))?$/) ?? [];
const a = from && point(from);
const b = to && point(to);
if (!a || (to && !b)) return { text, min: -Infinity, max: Infinity };
let { min, max } = a;
if (b) max = b.max;
else if (mode === 'BEF' || mode === 'TO') [min, max] = [-Infinity, a.min];
else if (mode === 'AFT' || mode === 'FROM') [min, max] = [a.max, Infinity];
else if (mode && mode !== 'INT') [min, max] = [min - 2, max + 2];
const year = Math.floor(b ? (a.min + b.max) / 2 : a.min);
const prefix = b || ['ABT', 'CAL', 'EST'].includes(mode) ? 'c. ' : { BEF: 'bef. ', TO: 'bef. ', AFT: 'aft. ', FROM: 'aft. ' }[mode] ?? '';
return { text, min, max, year, short: prefix + year, exact: a.exact && !mode };
}
// "12 MAR 1890", "MAR 1890", "1890", or a double-dated "1750/51".
function point(text) {
const [, day, month, year] = text.match(/^(?:(\d{1,2}) )?(?:([A-Z]{3}) )?(\d{1,4})(?:\/\d+)?$/) ?? [];
const m = months.indexOf(month);
if (!year || (month && m < 0) || (day && !month)) return null;
if (!month) return { min: +year, max: +year + 1 };
const start = +year + (day ? (m * 30.44 + (day - 1)) / 365.25 : m / 12);
return { min: start, max: start + (day ? 1 / 365.25 : 1 / 12), exact: Boolean(day) };
}
function check() {
const problems = [];
const flag = (person, kind, detail, other) => problems.push({ person, kind, detail, other });
for (const person of people.values()) {
const born = person.born?.date;
const died = person.died?.date;
if (born && died && died.max < born.min) flag(person, 'Died before born', `Born ${born.text}, died ${died.text}`);
if (born && died && died.min - born.max > 110) flag(person, 'Lived past 110', `Born ${born.text}, died ${died.text}`);
if (!born) continue;
const { husband: father, wife: mother } = person.family ?? {};
for (const parent of [father, mother]) {
const theirs = parent?.born?.date;
if (theirs && born.max < theirs.min + 12) flag(person, 'Parent under 12', `Born ${born.text}; ${parent.name} born ${theirs.text}`, parent);
}
const motherBorn = mother?.born?.date;
if (motherBorn && born.min > motherBorn.max + 55) flag(person, 'Mother over 55', `Born ${born.text}; mother born ${motherBorn.text}`, mother);
const fatherDied = father?.died?.date;
if (fatherDied && born.min > fatherDied.max + 1) flag(person, 'Born after father died', `Born ${born.text}; father died ${fatherDied.text}`, father);
const motherDied = mother?.died?.date;
if (motherDied && born.min > motherDied.max) flag(person, 'Born after mother died', `Born ${born.text}; mother died ${motherDied.text}`, mother);
}
// Same name, born within two years. Not a child named after a sibling who had already died.
const named = {};
for (const person of people.values()) if (person.name !== 'Unnamed' && person.born?.date?.year !== undefined) (named[person.plain] ??= []).push(person);
for (const group of Object.values(named)) {
group.forEach((a, index) => {
for (const b of group.slice(index + 1)) {
const [older, younger] = a.born.date.min <= b.born.date.min ? [a, b] : [b, a];
if (Math.abs(a.born.date.year - b.born.date.year) > 2 || older.died?.date?.max < younger.born.date.min) continue;
flag(a, 'Likely duplicate', `Also ${b.name}, born ${b.born.date.text}${b.file === a.file ? '' : ` in ${b.file}`}`, b);
}
});
}
return problems.sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind) || a.person.name.localeCompare(b.person.name));
}
// Four generations above the centre, in ahnentafel order: the parents of slot k sit in 2k and 2k + 1.
function drawFan(centre) {
const slots = [, centre];
for (let k = 1; k < 16; k++) [slots[2 * k], slots[2 * k + 1]] = [slots[k]?.family?.husband, slots[k]?.family?.wife];
const flagged = new Set(problems.map(problem => problem.person));
let svg = '';
for (let k = 2; k < 32; k++) {
const generation = Math.floor(Math.log2(k));
const span = 240 / 2 ** generation;
const from = -120 + (k - 2 ** generation) * span;
const [inner, outer] = [rings[generation], rings[generation + 1]];
const person = slots[k];
const d = `M${at(inner, from)}A${inner} ${inner} 0 0 1 ${at(inner, from + span)}L${at(outer, from + span)}A${outer} ${outer} 0 0 0 ${at(outer, from)}Z`;
svg += person
? `<g class="g${generation}" data-id="${esc(person.id)}"><path d="${d}"/>${label(person, generation, (inner + outer) / 2, from + span / 2)}${flagged.has(person) ? `<circle class="dot" r="3.5" transform="rotate(${from + span / 2}) translate(0 ${8 - outer})"/>` : ''}</g>`
: `<g class="none"><path d="${d}"/></g>`;
}
const names = centre.given || centre.surname ? [centre.given, centre.surname] : [centre.name];
fan.innerHTML = `${svg}<g class="centre"><circle r="${rings[1]}"/><text>${tspans(names.map(name => cut(name, 14)), years(centre))}</text></g>`;
}
// The inner rings read along the arc; the outer ones, too narrow for a full name, read outward.
function label(person, generation, radius, angle) {
const radial = generation > 2;
const flip = radial ? angle < 0 : Math.abs(angle) > 90;
const turn = radial ? angle + (flip ? 90 : -90) : angle + (flip ? 180 : 0);
const move = radial ? `${flip ? -radius : radius} 0` : `0 ${flip ? radius : -radius}`;
const size = [0, 30, 24, 11, 9][generation];
const names = radial ? [person.given.split(' ')[0], person.surname] : [person.name];
return `<text transform="rotate(${turn}) translate(${move})">${tspans(names.map(name => cut(name, size)), years(person))}</text>`;
}
// Lines centred on the text's origin, the years in grey below the name.
function tspans(names, dates) {
const lines = [...names.filter(Boolean).map(name => [name, '']), ...(dates ? [[dates, ' class="years"']] : [])];
return lines.map(([line, grey], index) => `<tspan x="0" dy="${index ? 1.2 : 0.35 - (lines.length - 1) * 0.6}em"${grey}>${esc(line)}</tspan>`).join('');
}
function drawPanel(person) {
const { husband: father, wife: mother } = person.family ?? {};
const [born, died] = [person.born?.date, person.died?.date];
const exact = born?.exact && died?.exact;
const age = born?.year !== undefined && died?.year !== undefined ? Math.floor(exact ? died.min - born.min : died.year - born.year) : -1;
const spouses = person.unions.map(union => [union.husband === person ? union.wife : union.husband, union]).filter(([spouse]) => spouse);
const children = person.unions.flatMap(union => union.children);
const mine = problems.filter(problem => problem.person === person || problem.other === person);
panel.innerHTML = `
<h2>${esc(person.name)}</h2>
<p>${{ M: 'Male', F: 'Female' }[person.sex] ?? 'Sex unknown'} · ${esc(person.file)}</p>
<dl>${fact(person.born)}${fact(person.died)}${age >= 0 ? `<dt>Age</dt><dd>${exact ? '' : 'about '}${age}</dd>` : ''}</dl>
<h3>Parents</h3>${links([father, mother].filter(Boolean))}
<h3>Spouses</h3>${spouses.length ? spouses.map(([spouse, union]) => link(spouse, union.married?.date ? `m. ${union.married.date.short ?? union.married.date.text}` : '')).join('') : '<p>None recorded</p>'}
<h3>Children</h3>${links(children)}
${mine.length ? `<h3>Problems</h3>${mine.map(problem => `<p class="problem"><strong>${problem.kind}</strong>${problem.person === person ? '' : ` · ${esc(problem.person.name)}`}<br>${esc(problem.detail)}</p>`).join('')}` : ''}`;
}
function fact(event) {
return event ? `<dt>${labels[event.tag]}</dt><dd>${[event.date?.text, event.place].filter(Boolean).map(esc).join('<br>')}</dd>` : '';
}
function links(people) {
return people.length ? people.map(person => link(person)).join('') : '<p>None recorded</p>';
}
function link(person, note = years(person)) {
return `<button class="link" data-id="${esc(person.id)}">${esc(person.name)}<span>${note}</span></button>`;
}
function years(person) {
const [born, died] = [person.born?.date?.short ?? '', person.died?.date?.short ?? ''];
return born || died ? `${born}–${died}` : '';
}
function at(radius, angle) {
const radians = (angle * Math.PI) / 180;
return `${(radius * Math.sin(radians)).toFixed(1)} ${(-radius * Math.cos(radians)).toFixed(1)}`;
}
function cut(text, length) {
return text.length > length ? text.slice(0, length - 1) + '…' : text;
}
// Accents and case don't count, so "Müller" finds "Muller"; letters from any script stay.
function plain(text) {
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L} ]/gu, '').replace(/\s+/g, ' ').trim();
}
function esc(text) {
return String(text).replace(/[&<>"]/g, char => `&#${char.charCodeAt(0)};`);
}
</script>