mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-27 06:01:49 +00:00
Add WebMCP integration
Add getting individuals and focusing the view on a selected person. This feature was added following the method described in the article "Elephants, Goldfish and the New Golden Age of Software Engineering" by Dave Rensin https://drensin.medium.com/elephants-goldfish-and-the-new-golden-age-of-software-engineering-c33641a48874 #vibecoded
This commit is contained in:
@@ -139,3 +139,51 @@ describe('getName()', () => {
|
||||
expect(getName(person)).toBe('Only Name');
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
findRelationshipPath,
|
||||
getAncestors,
|
||||
getDescendants,
|
||||
idToFamMap,
|
||||
idToIndiMap,
|
||||
} from './gedcom_util';
|
||||
|
||||
describe('Relationship algorithms', () => {
|
||||
const sampleData = {
|
||||
indis: [
|
||||
{id: 'I1', fams: ['F1']},
|
||||
{id: 'I2', fams: ['F1'], famc: 'F2'},
|
||||
{id: 'I3', famc: 'F1'},
|
||||
{id: 'I4', famc: 'F2'},
|
||||
],
|
||||
fams: [
|
||||
{id: 'F1', husb: 'I1', wife: 'I2', children: ['I3']},
|
||||
{id: 'F2', children: ['I2', 'I4']},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const indiMap = idToIndiMap(sampleData);
|
||||
const famMap = idToFamMap(sampleData);
|
||||
|
||||
it('findRelationshipPath finds direct paths', () => {
|
||||
const path = findRelationshipPath('I1', 'I3', indiMap, famMap);
|
||||
expect(path).toEqual(['I1', 'I3']);
|
||||
});
|
||||
|
||||
it('findRelationshipPath finds sibling paths', () => {
|
||||
const path = findRelationshipPath('I2', 'I4', indiMap, famMap);
|
||||
expect(path).toEqual(['I2', 'I4']);
|
||||
});
|
||||
|
||||
it('getAncestors respects generations bounds', () => {
|
||||
const ancestors = getAncestors('I3', 1, indiMap, famMap);
|
||||
expect(ancestors).toContain('I1');
|
||||
expect(ancestors).toContain('I2');
|
||||
expect(ancestors).not.toContain('I4');
|
||||
});
|
||||
|
||||
it('getDescendants respects generations bounds', () => {
|
||||
const descendants = getDescendants('I2', 1, indiMap, famMap);
|
||||
expect(descendants).toContain('I3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ 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) => {
|
||||
@@ -51,6 +52,7 @@ export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
|
||||
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) => {
|
||||
@@ -276,6 +278,7 @@ export function convertGedcom(
|
||||
};
|
||||
}
|
||||
|
||||
/** 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');
|
||||
@@ -284,6 +287,7 @@ export function getSoftware(head: GedcomEntry): string | null {
|
||||
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(
|
||||
@@ -296,6 +300,7 @@ export function getName(person: GedcomEntry): string | undefined {
|
||||
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;
|
||||
|
||||
@@ -316,26 +321,31 @@ function findFileEntry(
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -374,3 +384,162 @@ export function mapToSource(
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user