mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-18 01:31:47 +00:00
* fix(upload): bypass history.pushState size limit with in-memory GEDCOM store
Firefox caps history.pushState state at 640KB; Safari at ~512KB. The upload
handler was passing the raw GEDCOM string through navigate(..., {state}),
which calls history.pushState. A 10MB GEDCOM caused an immediate SecurityError
in Firefox and Safari, preventing the chart from ever loading.
Add a bounded in-memory store (gedcom_store.ts) capped to 1 entry — uploading
a new file evicts the previous one, preventing unbounded memory growth during
a session. navigate() now carries only a file fingerprint hash in the URL;
loadGedcom() retrieves the GEDCOM from the store by hash.
Update embedded.ts to store its injected GEDCOM under the key 'embedded'
before calling loadGedcom(), keeping the data path consistent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(fingerprint): replace 500ms full-file md5 with fast metadata + 4KB sample hash
The upload handler called md5() over the full GEDCOM string before navigating
— ~240ms for a 10MB file, blocking the main thread before the user saw
any feedback.
Replace with a hash over file.name|file.size|file.lastModified plus the first
and last 4KB of content. Runtime drops to ~1ms. The 4KB sample captures the
GEDCOM header (software name, export date, submitter info) which differs
between any two real exports, providing strong collision resistance for a local
session cache without a cryptographic guarantee.
Extract fileFingerprint to src/util/file_fingerprint.ts so it can be unit
tested independently of the React component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(parse): async GEDCOM conversion with event-loop yields and onProgress callback
convertGedcom was synchronous, blocking the main thread for the full parse
duration (~1.2s parseGedcom + ~700ms gedcomEntriesToJson in Safari). The
browser spinner could not update and GC could not run between steps.
Make convertGedcom async and insert await setTimeout(0) yields between the
four major steps (parse, convert, normalize, index). This breaks the work
into chunks, allows incremental GC, and keeps the spinner responsive.
Add onProgress?(status: string) => void to convertGedcom, prepareData,
loadGedcom, loadFromUrl, loadAndPrepareFile, and the DataSource interface.
All data source implementations (UploadedDataSource, GedcomUrlDataSource,
GoogleDriveDataSource) forward the callback down the load pipeline.
WikiTree and Embedded accept the parameter but do not use it.
Also skip the sessionStorage JSON.stringify for GEDCOM files over 512KB.
The serialized TopolaData (chartData 7.85MB + raw parse-gedcom tree 25MB
= 33MB) always exceeded the 5MB sessionStorage limit, wasting 10-20s in
Safari on a write that was guaranteed to fail.
Simplify loadGedcom to (hash, onProgress?) now that the GEDCOM string is
always retrieved from the in-memory store rather than passed as a parameter.
Remove console.log timing instrumentation and window.__topolaDebug globals
that were left in from debugging.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ui): skip redundant chart re-renders; persist progress overlay through D3 layout
Two related issues caused the chart to freeze or appear unresponsive:
1. The Chart useEffect has no dependency array, so it ran after every React
render. Each call re-created a JsonDataProvider from all 23K individuals,
re-ran the ancestor/descendant layout, and called getComputedTextLength for
every visible text element (~500ms per call in Chrome, longer in Safari).
For unrelated state changes this froze the browser 3-5 extra times per load.
Fix: return early in renderChart() when neither initialRender nor
resetPosition is set — the SVG is already correct.
2. The "Loading..." progress pill was mounted only in AppState.LOADING. When
data finished parsing, React transitioned to SHOWING_CHART and unmounted the
pill before the D3 layout (the slow part) had run. Users saw a blank screen
with no feedback for several seconds.
Fix: replace the state-gated pill with a persistent fixed overlay driven by
a loadingStatus string, visible through both LOADING and the initial chart
render. Add onFirstRender() callback to Chart; App clears loadingStatus when
the first D3 layout completes.
Wire the onProgress callback from the load pipeline into setLoadingStatus so
users see step-by-step progress ("Step 1/4: parsing GEDCOM..." through
"Rendering chart (23,909 people)...").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* perf(search): pre-build Lunr index during browser idle time
Building the full-text search index over 23K individuals takes ~600ms
synchronously. This was deferred to the first keystroke, freezing the input
field for up to 3 seconds in Safari with no user feedback.
Schedule the build via requestIdleCallback (5s timeout) after data loads,
with a 200ms setTimeout fallback for browsers without requestIdleCallback.
The index is typically warm before the user types. getOrBuildIndex() still
builds synchronously on the first keystroke if idle scheduling has not yet
fired (e.g. user types immediately after load).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: add coverage for gedcom_store, loadGedcom store path, and fileFingerprint
gedcom_store.spec.ts
- Returns undefined for unknown hash
- Stores and retrieves GEDCOM by hash
- Evicts the previous entry when a new file is stored (bounded-to-1 behavior)
- Returns undefined for a hash that was evicted
load_data_store.spec.ts
- loadGedcom throws ERROR_LOADING_UPLOADED_FILE when hash is absent from store
- loadGedcom successfully parses GEDCOM retrieved from the in-memory store
- onProgress callback receives all four step messages in the correct order
upload_menu.spec.ts (via src/util/file_fingerprint.ts)
- Returns a valid 32-character hex hash
- Produces identical hashes for identical inputs
- Differs for different filename, file size, content beyond 4KB boundary,
and image file list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(ui): clear loading pill for all chart types including Donatso
The progress pill persisted indefinitely on the Donatso chart because
DonatsoChart did not call onFirstRender — only the D3-based Chart did.
Add onFirstRender? to DonatsoChartProps and call it from the existing
useEffect on the first render, consistent with how Chart handles it.
Pass onFirstRender={() => setLoadingStatus('')} from renderChart in app.tsx.
Update visual regression snapshots for macOS (darwin) to reflect the new
progress pill and its correct dismissal across all chart types. The 12
non-Donatso failures were pre-existing snapshot staleness; chart-donatso
was the one newly introduced by this PR.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix thread onProgress through EmbeddedDataSource.onMessage to loadGedcom
The _onProgress parameter accepted by loadData was never forwarded to the
onMessage event handler, silently dropping all step-progress callbacks for
embedded GEDCOM loads. The progress pill stayed at 'Loading…' for the full
parse duration instead of showing the four-step breakdown.
Thread onProgress through the event-listener closure by adding it as a
parameter to onMessage and passing it to loadGedcom.
* Make gedcom_store tests independent of execution order
Jest caches module instances, so the module-level let variables persisted
across tests. Running with --randomize or in parallel could cause test 1
('returns undefined for unknown hash') to see a hash left by a previous test.
Export clearStoredGedcom() for testing and call it in beforeEach so each test
starts from a clean slate regardless of execution order.
* Document intentional pre-animation timing of onFirstRender in Chart
onFirstRender fires when the chart SVG is in the DOM, before the D3
fade-in animation completes (~400ms). A reviewer noted this as a potential
issue, but moving it into animationPromise.then() would keep the progress
pill visible for 400ms after the chart is already visible and interactive,
creating a confusing overlap. The current timing is correct.
Add a comment explaining the design intent.
* Pass onProgress to WikiTree and Embedded data sources at call site
Both call sites in the loadData callback omitted the onProgress parameter
that all other sources (UPLOADED, GEDCOM_URL, GOOGLE_DRIVE) already forward.
WikiTree's implementation ignores it today (_onProgress), but wiring it here
ensures parity and makes future progress reporting possible without touching
app.tsx again. Embedded now receives it and threads it to loadGedcom.
* Use const-scoped handles in search useEffect to eliminate type casts
The shared 'let handle' variable was typed as a union of setTimeout and
requestIdleCallback return types, requiring unsafe casts (handle as number,
handle as ReturnType<typeof setTimeout>) in the cleanup closures. Each
cleanup closure captured the shared variable by reference rather than value.
Use a const declaration in each branch so TypeScript infers the correct
type automatically and no casts are needed. The early-return form also
makes the two branches structurally symmetric.
* Remove window event listener in EmbeddedDataSource after GEDCOM received
The listener registered in loadData was never removed, causing it to
accumulate on every loadData call. In a multi-file workflow the second
load would have two listeners firing on the same message, the first
with a stale resolve/reject pair.
Wrap resolve and reject so the listener removes itself on first
settlement, making the registration effectively once-per-load.
* Minor code fixes. Moved test to a different file
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Przemek Więch <pwiech@gmail.com>
608 lines
17 KiB
TypeScript
608 lines
17 KiB
TypeScript
import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom';
|
|
import {
|
|
DateOrRange,
|
|
gedcomEntriesToJson,
|
|
getDate,
|
|
JsonFam,
|
|
JsonGedcomData,
|
|
JsonImage,
|
|
JsonIndi,
|
|
} from 'topola';
|
|
import {compareDates} from './date_util';
|
|
import {TopolaError} from './error';
|
|
|
|
export interface GedcomData {
|
|
/** The HEAD entry. */
|
|
head?: GedcomEntry;
|
|
/** INDI entries mapped by id. */
|
|
indis: {[key: string]: GedcomEntry};
|
|
/** FAM entries mapped by id. */
|
|
fams: {[key: string]: GedcomEntry};
|
|
/** Other entries mapped by id, e.g. NOTE, SOUR. */
|
|
other: {[key: string]: GedcomEntry};
|
|
}
|
|
|
|
export interface TopolaData {
|
|
chartData: JsonGedcomData;
|
|
gedcom: GedcomData;
|
|
images?: Map<string, string>;
|
|
}
|
|
|
|
export interface Source {
|
|
title?: string;
|
|
author?: string;
|
|
page?: string;
|
|
date?: DateOrRange;
|
|
publicationInfo?: string;
|
|
}
|
|
|
|
/**
|
|
* Returns the identifier extracted from a pointer string.
|
|
* E.g. '@I123@' -> 'I123'
|
|
*/
|
|
export function pointerToId(pointer: string): string {
|
|
return pointer.substring(1, pointer.length - 1);
|
|
}
|
|
|
|
/** Returns a map from individual ID to individual data object. */
|
|
export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
|
|
const map = new Map<string, JsonIndi>();
|
|
data.indis.forEach((indi) => {
|
|
map.set(indi.id, indi);
|
|
});
|
|
return map;
|
|
}
|
|
|
|
/** Returns a map from family ID to family data object. */
|
|
export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
|
|
const map = new Map<string, JsonFam>();
|
|
data.fams.forEach((fam) => {
|
|
map.set(fam.id, fam);
|
|
});
|
|
return map;
|
|
}
|
|
|
|
function prepareGedcom(entries: GedcomEntry[]): GedcomData {
|
|
const head = entries.find((entry) => entry.tag === 'HEAD');
|
|
const indis: {[key: string]: GedcomEntry} = {};
|
|
const fams: {[key: string]: GedcomEntry} = {};
|
|
const other: {[key: string]: GedcomEntry} = {};
|
|
entries.forEach((entry) => {
|
|
if (entry.tag === 'INDI') {
|
|
indis[pointerToId(entry.pointer)] = entry;
|
|
} else if (entry.tag === 'FAM') {
|
|
fams[pointerToId(entry.pointer)] = entry;
|
|
} else if (entry.pointer) {
|
|
other[pointerToId(entry.pointer)] = entry;
|
|
}
|
|
});
|
|
return {head, indis, fams, other};
|
|
}
|
|
|
|
function strcmp(a: string, b: string) {
|
|
if (a < b) {
|
|
return -1;
|
|
}
|
|
if (a > b) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/** Birth date comparator for individuals. */
|
|
function birthDatesComparator(gedcom: JsonGedcomData) {
|
|
const indiMap = idToIndiMap(gedcom);
|
|
|
|
return (indiId1: string, indiId2: string) => {
|
|
const indi1: JsonIndi | undefined = indiMap.get(indiId1);
|
|
const indi2: JsonIndi | undefined = indiMap.get(indiId2);
|
|
return (
|
|
compareDates(indi1 && indi1.birth, indi2 && indi2.birth) ||
|
|
strcmp(indiId1, indiId2)
|
|
);
|
|
};
|
|
}
|
|
|
|
/** Marriage date comparator for families. */
|
|
function marriageDatesComparator(gedcom: JsonGedcomData) {
|
|
const famMap = idToFamMap(gedcom);
|
|
|
|
return (famId1: string, famId2: string) => {
|
|
const fam1: JsonFam | undefined = famMap.get(famId1);
|
|
const fam2: JsonFam | undefined = famMap.get(famId2);
|
|
return (
|
|
compareDates(fam1 && fam1.marriage, fam2 && fam2.marriage) ||
|
|
strcmp(famId1, famId2)
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sorts children by birth date in the given family.
|
|
* Does not modify the input objects.
|
|
*/
|
|
function sortFamilyChildren(
|
|
fam: JsonFam,
|
|
comparator: (id1: string, id2: string) => number,
|
|
): JsonFam {
|
|
if (!fam.children) {
|
|
return fam;
|
|
}
|
|
const newChildren = fam.children.sort(comparator);
|
|
return Object.assign({}, fam, {children: newChildren});
|
|
}
|
|
|
|
/**
|
|
* Sorts children by birth date.
|
|
* Does not modify the input object.
|
|
*/
|
|
function sortChildren(gedcom: JsonGedcomData): JsonGedcomData {
|
|
const comparator = birthDatesComparator(gedcom);
|
|
const newFams = gedcom.fams.map((fam) => sortFamilyChildren(fam, comparator));
|
|
return Object.assign({}, gedcom, {fams: newFams});
|
|
}
|
|
|
|
/**
|
|
* Sorts spouses by marriage date.
|
|
* Does not modify the input objects.
|
|
*/
|
|
function sortIndiSpouses(
|
|
indi: JsonIndi,
|
|
comparator: (id1: string, id2: string) => number,
|
|
): JsonFam {
|
|
if (!indi.fams) {
|
|
return indi;
|
|
}
|
|
const newFams = indi.fams.sort(comparator);
|
|
return Object.assign({}, indi, {fams: newFams});
|
|
}
|
|
|
|
function sortSpouses(gedcom: JsonGedcomData): JsonGedcomData {
|
|
const comparator = marriageDatesComparator(gedcom);
|
|
const newIndis = gedcom.indis.map((indi) =>
|
|
sortIndiSpouses(indi, comparator),
|
|
);
|
|
return Object.assign({}, gedcom, {indis: newIndis});
|
|
}
|
|
|
|
/**
|
|
* If the entry is a reference to a top-level entry, the referenced entry is
|
|
* returned. Otherwise, returns the given entry unmodified.
|
|
*/
|
|
export function dereference(
|
|
entry: GedcomEntry,
|
|
gedcom: GedcomData,
|
|
getterFunction: (gedcom: GedcomData) => {[key: string]: GedcomEntry},
|
|
) {
|
|
if (entry.data) {
|
|
const dereferenced = getterFunction(gedcom)[pointerToId(entry.data)];
|
|
if (dereferenced) {
|
|
return dereferenced;
|
|
}
|
|
}
|
|
return entry;
|
|
}
|
|
|
|
/**
|
|
* Returns the data for the given GEDCOM entry as an array of lines. Supports
|
|
* continuations with CONT and CONC.
|
|
*/
|
|
export function getData(entry: GedcomEntry) {
|
|
const result = [entry.data];
|
|
entry.tree.forEach((subentry) => {
|
|
if (subentry.tag === 'CONC' && subentry.data) {
|
|
const last = result.length - 1;
|
|
result[last] += subentry.data;
|
|
} else if (subentry.tag === 'CONT' && subentry.data) {
|
|
result.push(subentry.data);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/** Sorts children and spouses. */
|
|
export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
|
|
return sortSpouses(sortChildren(gedcom));
|
|
}
|
|
|
|
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
|
|
/** Returns true if the given file name has a known image extension. */
|
|
export function isImageFile(fileName: string): boolean {
|
|
const cleanName = fileName.split(/[?#]/)[0];
|
|
const lowerName = cleanName.toLowerCase();
|
|
return IMAGE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
|
}
|
|
|
|
/**
|
|
* Removes images that are not HTTP links or do not have known image extensions.
|
|
* Does not modify the input object.
|
|
*/
|
|
export function isBrowserLoadable(url: string): boolean {
|
|
return /^(https?:|blob:|data:|\/\/)/i.test(url);
|
|
}
|
|
|
|
export function resolveFileUrl(
|
|
url: string,
|
|
images?: Map<string, string>,
|
|
): string {
|
|
if (isBrowserLoadable(url)) {
|
|
return url;
|
|
}
|
|
const normalizedUrl = url.replace(/\\/g, '/');
|
|
if (images instanceof Map) {
|
|
const lowercasePath = normalizedUrl.toLowerCase();
|
|
const mappedUrl = images.get(lowercasePath);
|
|
if (mappedUrl) {
|
|
return mappedUrl;
|
|
}
|
|
}
|
|
return normalizedUrl;
|
|
}
|
|
|
|
function filterImage(indi: JsonIndi, images: Map<string, string>): JsonIndi {
|
|
if (!indi.images || indi.images.length === 0) {
|
|
return indi;
|
|
}
|
|
const newImages: JsonImage[] = [];
|
|
indi.images.forEach((image) => {
|
|
const resolvedUrl = resolveFileUrl(image.url, images);
|
|
const normalizedUrl = image.url.replace(/\\/g, '/');
|
|
if (
|
|
resolvedUrl !== normalizedUrl ||
|
|
(isBrowserLoadable(resolvedUrl) && isImageFile(resolvedUrl))
|
|
) {
|
|
newImages.push({url: resolvedUrl, title: image.title});
|
|
}
|
|
});
|
|
return Object.assign({}, indi, {images: newImages});
|
|
}
|
|
|
|
/**
|
|
* Removes images that are not HTTP links.
|
|
* Does not modify the input object.
|
|
*/
|
|
function filterImages(
|
|
gedcom: JsonGedcomData,
|
|
images: Map<string, string>,
|
|
): JsonGedcomData {
|
|
const newIndis = gedcom.indis.map((indi) => filterImage(indi, images));
|
|
return Object.assign({}, gedcom, {indis: newIndis});
|
|
}
|
|
|
|
/**
|
|
* Converts GEDCOM file into JSON data performing additional transformations:
|
|
* - sort children by birth date
|
|
* - remove images that are not HTTP links and aren't mapped in `images`.
|
|
*
|
|
* @param images Map from file name to image URL. This is used to pass in
|
|
* uploaded images.
|
|
*/
|
|
/**
|
|
* Yields to the browser event loop, allowing incremental GC and UI updates.
|
|
* Calls onProgress if provided; does not touch the DOM directly.
|
|
*/
|
|
function yieldToEventLoop(
|
|
onProgress?: (status: string) => void,
|
|
status?: string,
|
|
): Promise<void> {
|
|
if (status) onProgress?.(status);
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
export async function convertGedcom(
|
|
gedcom: string,
|
|
images: Map<string, string>,
|
|
onProgress?: (status: string) => void,
|
|
): Promise<TopolaData> {
|
|
await yieldToEventLoop(onProgress, 'Step 1/4: parsing GEDCOM…');
|
|
|
|
const entries = parseGedcom(gedcom);
|
|
|
|
await yieldToEventLoop(onProgress, 'Step 2/4: building family graph…');
|
|
|
|
const json = gedcomEntriesToJson(entries);
|
|
|
|
if (
|
|
!json ||
|
|
!json.indis ||
|
|
!json.indis.length ||
|
|
!json.fams ||
|
|
!json.fams.length
|
|
) {
|
|
throw new TopolaError('GEDCOM_READ_FAILED', 'Failed to read GEDCOM file');
|
|
}
|
|
|
|
await yieldToEventLoop(onProgress, 'Step 3/4: sorting & normalizing…');
|
|
|
|
const chartData = filterImages(normalizeGedcom(json), images);
|
|
|
|
await yieldToEventLoop(onProgress, 'Step 4/4: indexing records…');
|
|
|
|
const gedcomData = prepareGedcom(entries);
|
|
|
|
return {
|
|
chartData,
|
|
gedcom: gedcomData,
|
|
images,
|
|
};
|
|
}
|
|
|
|
/** Returns the name of the software used to generate the GEDCOM file, if available. */
|
|
export function getSoftware(head?: GedcomEntry): string | null {
|
|
const sour =
|
|
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
|
|
const name =
|
|
sour && sour.tree && sour.tree.find((entry) => entry.tag === 'NAME');
|
|
return (name && name.data) || null;
|
|
}
|
|
|
|
/** Returns the name of an individual, preferring birth name over married name. */
|
|
export function getName(person: GedcomEntry): string | undefined {
|
|
const names = person.tree.filter((subEntry) => subEntry.tag === 'NAME');
|
|
const notMarriedName = names.find(
|
|
(subEntry) =>
|
|
subEntry.tree.filter(
|
|
(nameEntry) => nameEntry.tag === 'TYPE' && nameEntry.data === 'married',
|
|
).length === 0,
|
|
);
|
|
const name = notMarriedName || names[0];
|
|
return name?.data.replace(/\//g, '');
|
|
}
|
|
|
|
/** Returns the file name for a media entry, combining title and extension. */
|
|
export function getFileName(fileEntry: GedcomEntry): string | undefined {
|
|
const fileTitle = fileEntry?.tree.find((entry) => entry.tag === 'TITL')?.data;
|
|
|
|
const fileExtension = fileEntry?.tree.find(
|
|
(entry) => entry.tag === 'FORM',
|
|
)?.data;
|
|
|
|
if (fileTitle && fileExtension) {
|
|
return fileTitle + '.' + fileExtension;
|
|
}
|
|
|
|
if (fileEntry && fileEntry.data) {
|
|
const path = fileEntry.data.replace(/\\/g, '/');
|
|
const cleanPath = path.split(/[?#]/)[0];
|
|
return cleanPath.split('/').pop();
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function findFileEntry(
|
|
objectEntry: GedcomEntry,
|
|
predicate: (entry: GedcomEntry) => boolean,
|
|
): GedcomEntry | undefined {
|
|
return objectEntry.tree.find(
|
|
(entry) => entry.tag === 'FILE' && entry.data && predicate(entry),
|
|
);
|
|
}
|
|
|
|
/** Returns the first non-image file entry for a media object. */
|
|
export function getNonImageFileEntry(
|
|
objectEntry: GedcomEntry,
|
|
): GedcomEntry | undefined {
|
|
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
|
|
}
|
|
|
|
/** Returns the first image file entry for a media object. */
|
|
export function getImageFileEntry(
|
|
objectEntry: GedcomEntry,
|
|
): GedcomEntry | undefined {
|
|
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
|
|
}
|
|
|
|
/** Resolves the DATE sub-entry for the given GEDCOM entry. */
|
|
export function resolveDate(entry: GedcomEntry) {
|
|
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
|
|
}
|
|
|
|
/** Resolves the TYPE sub-entry data for the given GEDCOM entry. */
|
|
export function resolveType(entry: GedcomEntry) {
|
|
return entry.tree.find((subEntry) => subEntry.tag === 'TYPE')?.data;
|
|
}
|
|
|
|
/** Converts a GEDCOM source reference entry to a structured Source object. */
|
|
export function mapToSource(
|
|
sourceEntryReference: GedcomEntry,
|
|
gedcom: GedcomData,
|
|
) {
|
|
const sourceEntry = dereference(
|
|
sourceEntryReference,
|
|
gedcom,
|
|
(gedcom) => gedcom.other,
|
|
);
|
|
|
|
const title = sourceEntry.tree.find((subEntry) => 'TITL' === subEntry.tag);
|
|
|
|
const abbr = sourceEntry.tree.find((subEntry) => 'ABBR' === subEntry.tag);
|
|
|
|
const author = sourceEntry.tree.find((subEntry) => 'AUTH' === subEntry.tag);
|
|
|
|
const publicationInfo = sourceEntry.tree.find(
|
|
(subEntry) => 'PUBL' === subEntry.tag,
|
|
);
|
|
|
|
const page = sourceEntryReference.tree.find(
|
|
(subEntry) => 'PAGE' === subEntry.tag,
|
|
);
|
|
|
|
const sourceData = sourceEntryReference.tree.find(
|
|
(subEntry) => 'DATA' === subEntry.tag,
|
|
);
|
|
|
|
const date = sourceData ? resolveDate(sourceData) : undefined;
|
|
|
|
return {
|
|
title: title?.data || abbr?.data,
|
|
author: author?.data,
|
|
page: page?.data,
|
|
date: date ? getDate(date.data) : undefined,
|
|
publicationInfo: publicationInfo?.data,
|
|
};
|
|
}
|
|
|
|
/** Finds the shortest relationship path between two individuals in the family tree. */
|
|
export function findRelationshipPath(
|
|
indiId1: string,
|
|
indiId2: string,
|
|
indiMap: Map<string, JsonIndi>,
|
|
famMap: Map<string, JsonFam>,
|
|
): string[] {
|
|
const getNeighbors = (id: string): string[] => {
|
|
const indi = indiMap.get(id);
|
|
if (!indi) {
|
|
return [];
|
|
}
|
|
|
|
const neighbors: string[] = [];
|
|
if (indi.famc) {
|
|
const fam = famMap.get(indi.famc);
|
|
if (fam) {
|
|
if (fam.wife) {
|
|
neighbors.push(fam.wife);
|
|
}
|
|
if (fam.husb) {
|
|
neighbors.push(fam.husb);
|
|
}
|
|
if (fam.children) {
|
|
fam.children.forEach((child) => {
|
|
if (child !== id) {
|
|
neighbors.push(child);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (indi.fams) {
|
|
indi.fams.forEach((famId) => {
|
|
const fam = famMap.get(famId);
|
|
if (fam) {
|
|
if (fam.wife && fam.wife !== id) {
|
|
neighbors.push(fam.wife);
|
|
}
|
|
if (fam.husb && fam.husb !== id) {
|
|
neighbors.push(fam.husb);
|
|
}
|
|
if (fam.children) {
|
|
fam.children.forEach((child) => neighbors.push(child));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return neighbors.filter((nId) => !nId.startsWith('private_'));
|
|
};
|
|
|
|
const queue: string[] = [indiId1];
|
|
const visited = new Map<string, string | null>();
|
|
visited.set(indiId1, null);
|
|
|
|
while (queue.length > 0) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const current = queue.shift()!;
|
|
if (current === indiId2) {
|
|
const path: string[] = [];
|
|
let curr: string | null = current;
|
|
while (curr !== null) {
|
|
path.push(curr);
|
|
curr = visited.get(curr) || null;
|
|
}
|
|
return path.reverse();
|
|
}
|
|
|
|
const neighbors = getNeighbors(current);
|
|
for (const neighbor of neighbors) {
|
|
if (!visited.has(neighbor)) {
|
|
visited.set(neighbor, current);
|
|
queue.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/** Returns the ancestors of an individual up to a specified number of generations. */
|
|
export function getAncestors(
|
|
indiId: string,
|
|
generations: number,
|
|
indiMap: Map<string, JsonIndi>,
|
|
famMap: Map<string, JsonFam>,
|
|
): string[] {
|
|
const result: string[] = [];
|
|
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
|
|
const visited = new Set<string>();
|
|
visited.add(indiId);
|
|
|
|
while (queue.length > 0) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const {id, gen} = queue.shift()!;
|
|
if (id !== indiId && !id.startsWith('private_')) {
|
|
result.push(id);
|
|
}
|
|
|
|
if (gen < generations) {
|
|
const indi = indiMap.get(id);
|
|
if (indi && indi.famc) {
|
|
const fam = famMap.get(indi.famc);
|
|
if (fam) {
|
|
if (fam.wife && !visited.has(fam.wife)) {
|
|
visited.add(fam.wife);
|
|
queue.push({id: fam.wife, gen: gen + 1});
|
|
}
|
|
if (fam.husb && !visited.has(fam.husb)) {
|
|
visited.add(fam.husb);
|
|
queue.push({id: fam.husb, gen: gen + 1});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/** Returns the descendants of an individual up to a specified number of generations. */
|
|
export function getDescendants(
|
|
indiId: string,
|
|
generations: number,
|
|
indiMap: Map<string, JsonIndi>,
|
|
famMap: Map<string, JsonFam>,
|
|
): string[] {
|
|
const result: string[] = [];
|
|
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
|
|
const visited = new Set<string>();
|
|
visited.add(indiId);
|
|
|
|
while (queue.length > 0) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const {id, gen} = queue.shift()!;
|
|
if (id !== indiId && !id.startsWith('private_')) {
|
|
result.push(id);
|
|
}
|
|
|
|
if (gen < generations) {
|
|
const indi = indiMap.get(id);
|
|
if (indi && indi.fams) {
|
|
indi.fams.forEach((famId) => {
|
|
const fam = famMap.get(famId);
|
|
if (fam && fam.children) {
|
|
fam.children.forEach((child) => {
|
|
if (!visited.has(child)) {
|
|
visited.add(child);
|
|
queue.push({id: child, gen: gen + 1});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|