Add collision detection for family overlaps and second line labels
Major changes: - Implement intelligent collision detection and resolution for complex family structures (divorce/remarriage scenarios) - Rewrite generation assignment with constraint propagation algorithm to ensure spouses are always in the same generation - Add FamilyUnit concept for grouping parents and children - Add second line label support with custom text, birth year, death year, and age display options Files modified: - PedigreeLayout.ts: Add collision detection, family units, and improved generation assignment - types.ts: Add FamilyUnit interface and extend PersonMetadata - useD3Pedigree.ts: Render second line labels - PropertyPanel.tsx: Add UI for editing second line labels - PropertyPanel.module.css: Add styles for inline form groups 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -446,7 +446,7 @@ function renderPersons(
|
|||||||
.attr('fill', 'none');
|
.attr('fill', 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Label (positioned just below the symbol, above connection lines)
|
// Label line 1 (positioned just below the symbol, above connection lines)
|
||||||
if (options.showLabels && person.metadata.label) {
|
if (options.showLabels && person.metadata.label) {
|
||||||
personGroup
|
personGroup
|
||||||
.append('text')
|
.append('text')
|
||||||
@@ -472,7 +472,48 @@ function renderPersons(
|
|||||||
.attr('fill', '#666')
|
.attr('fill', '#666')
|
||||||
.text(person.id);
|
.text(person.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Label line 2 (subtitle/additional info)
|
||||||
|
const line2Text = buildSecondLineText(person);
|
||||||
|
if (options.showLabels && line2Text) {
|
||||||
|
personGroup
|
||||||
|
.append('text')
|
||||||
|
.attr('class', 'person-label-line2')
|
||||||
|
.attr('x', 0)
|
||||||
|
.attr('y', options.symbolSize / 2 + 30)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', '10px')
|
||||||
|
.attr('font-family', 'sans-serif')
|
||||||
|
.attr('fill', '#666')
|
||||||
|
.text(line2Text);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the second line text from person metadata
|
||||||
|
*/
|
||||||
|
function buildSecondLineText(person: Person): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const meta = person.metadata;
|
||||||
|
|
||||||
|
// Custom text first
|
||||||
|
if (meta.label2) {
|
||||||
|
parts.push(meta.label2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Year/age info
|
||||||
|
if (meta.showBirthYear && meta.birthYear) {
|
||||||
|
parts.push(`b.${meta.birthYear}`);
|
||||||
|
}
|
||||||
|
if (meta.showDeathYear && meta.deathYear) {
|
||||||
|
parts.push(`d.${meta.deathYear}`);
|
||||||
|
}
|
||||||
|
if (meta.showAge && meta.age !== undefined) {
|
||||||
|
parts.push(`${meta.age}y`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGenerationLabels(
|
function renderGenerationLabels(
|
||||||
|
|||||||
@@ -112,3 +112,46 @@
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inlineGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineGroup:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inlineLabel input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smallInput {
|
||||||
|
width: 70px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smallInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smallInput::placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export function PropertyPanel() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLabel2Change = (label2: string) => {
|
||||||
|
updatePerson(selectedPerson.id, {
|
||||||
|
metadata: { ...selectedPerson.metadata, label2 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetadataChange = (key: keyof typeof selectedPerson.metadata, value: unknown) => {
|
||||||
|
updatePerson(selectedPerson.id, {
|
||||||
|
metadata: { ...selectedPerson.metadata, [key]: value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -68,6 +80,72 @@ export function PropertyPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Label Line 2</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={selectedPerson.metadata.label2 ?? ''}
|
||||||
|
onChange={(e) => handleLabel2Change(e.target.value)}
|
||||||
|
placeholder="Second line text..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Birth/Death/Age</div>
|
||||||
|
<div className={styles.inlineGroup}>
|
||||||
|
<label className={styles.inlineLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.metadata.showBirthYear ?? false}
|
||||||
|
onChange={(e) => handleMetadataChange('showBirthYear', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Birth
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.smallInput}
|
||||||
|
value={selectedPerson.metadata.birthYear ?? ''}
|
||||||
|
onChange={(e) => handleMetadataChange('birthYear', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="Year"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.inlineGroup}>
|
||||||
|
<label className={styles.inlineLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.metadata.showDeathYear ?? false}
|
||||||
|
onChange={(e) => handleMetadataChange('showDeathYear', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Death
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.smallInput}
|
||||||
|
value={selectedPerson.metadata.deathYear ?? ''}
|
||||||
|
onChange={(e) => handleMetadataChange('deathYear', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="Year"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.inlineGroup}>
|
||||||
|
<label className={styles.inlineLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.metadata.showAge ?? false}
|
||||||
|
onChange={(e) => handleMetadataChange('showAge', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Age
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={styles.smallInput}
|
||||||
|
value={selectedPerson.metadata.age ?? ''}
|
||||||
|
onChange={(e) => handleMetadataChange('age', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
placeholder="Age"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>Sex</div>
|
<div className={styles.sectionTitle}>Sex</div>
|
||||||
<div className={styles.buttonGroup}>
|
<div className={styles.buttonGroup}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 4. Handle special cases (consanguinity, twins)
|
* 4. Handle special cases (consanguinity, twins)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Pedigree, Person, Relationship, LayoutOptions, LayoutNode } from '@/core/model/types';
|
import type { Pedigree, Person, Relationship, LayoutOptions, LayoutNode, FamilyUnit } from '@/core/model/types';
|
||||||
|
|
||||||
export const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = {
|
export const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = {
|
||||||
nodeWidth: 50,
|
nodeWidth: 50,
|
||||||
@@ -46,86 +46,188 @@ export class PedigreeLayout {
|
|||||||
// Step 1: Assign generations
|
// Step 1: Assign generations
|
||||||
const generations = this.assignGenerations(pedigree);
|
const generations = this.assignGenerations(pedigree);
|
||||||
|
|
||||||
// Step 2: Sort within each generation
|
// Step 2: Build family units and find shared persons (multiple marriages)
|
||||||
const sortedGenerations = this.sortGenerations(generations, pedigree);
|
const familyUnits = this.buildFamilyUnits(pedigree);
|
||||||
|
const sharedPersons = this.findSharedPersons(familyUnits);
|
||||||
|
|
||||||
// Step 3: Calculate positions
|
// Calculate minimum widths for family units
|
||||||
|
for (const unit of familyUnits) {
|
||||||
|
unit.minWidth = this.calculateFamilyUnitWidth(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Sort within each generation (with family unit awareness)
|
||||||
|
const sortedGenerations = this.sortGenerationsWithFamilyUnits(
|
||||||
|
generations,
|
||||||
|
pedigree,
|
||||||
|
familyUnits,
|
||||||
|
sharedPersons
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Calculate initial positions
|
||||||
this.calculatePositions(sortedGenerations, pedigree, result);
|
this.calculatePositions(sortedGenerations, pedigree, result);
|
||||||
|
|
||||||
// Step 4: Center the layout
|
// Step 5: Adjust parent positions to be centered above children
|
||||||
|
// This is important for newly added parents
|
||||||
|
this.adjustParentPositions(sortedGenerations, result, pedigree);
|
||||||
|
|
||||||
|
// Step 6: Adjust children positions to be centered under parents
|
||||||
|
this.adjustChildrenPositions(sortedGenerations, result, pedigree);
|
||||||
|
|
||||||
|
// Step 7: Resolve collisions for all generations
|
||||||
|
const genKeys = Array.from(generations.keys()).sort((a, b) => a - b);
|
||||||
|
for (const gen of genKeys) {
|
||||||
|
// Multiple passes to ensure all collisions are resolved
|
||||||
|
let maxIterations = 10;
|
||||||
|
while (this.detectCollisions(result, gen) && maxIterations > 0) {
|
||||||
|
this.resolveCollisions(result, gen);
|
||||||
|
maxIterations--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Center the layout
|
||||||
this.centerLayout(result);
|
this.centerLayout(result);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort generations with family unit awareness
|
||||||
|
*/
|
||||||
|
private sortGenerationsWithFamilyUnits(
|
||||||
|
generations: Map<number, Person[]>,
|
||||||
|
pedigree: Pedigree,
|
||||||
|
familyUnits: FamilyUnit[],
|
||||||
|
sharedPersons: Map<string, FamilyUnit[]>
|
||||||
|
): Map<number, Person[]> {
|
||||||
|
const sorted = new Map<number, Person[]>();
|
||||||
|
|
||||||
|
for (const [gen, persons] of generations) {
|
||||||
|
// Filter family units and shared persons for this generation
|
||||||
|
const genFamilyUnits = familyUnits.filter(u => u.generation === gen);
|
||||||
|
const genSharedPersons = new Map<string, FamilyUnit[]>();
|
||||||
|
for (const [personId, units] of sharedPersons) {
|
||||||
|
const person = pedigree.persons.get(personId);
|
||||||
|
if (person && person.generation === gen) {
|
||||||
|
genSharedPersons.set(personId, units);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedGen = this.sortGenerationWithFamilyUnits(
|
||||||
|
persons,
|
||||||
|
pedigree,
|
||||||
|
genFamilyUnits,
|
||||||
|
genSharedPersons
|
||||||
|
);
|
||||||
|
sorted.set(gen, sortedGen);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assign generation numbers to each person using BFS from founders
|
* Assign generation numbers to each person using BFS from founders
|
||||||
|
* Ensures spouses are always in the same generation
|
||||||
*/
|
*/
|
||||||
private assignGenerations(pedigree: Pedigree): Map<number, Person[]> {
|
private assignGenerations(pedigree: Pedigree): Map<number, Person[]> {
|
||||||
const generations = new Map<number, Person[]>();
|
const generations = new Map<number, Person[]>();
|
||||||
const personGenerations = new Map<string, number>();
|
const personGenerations = new Map<string, number>();
|
||||||
const persons = Array.from(pedigree.persons.values());
|
const persons = Array.from(pedigree.persons.values());
|
||||||
|
|
||||||
// Find founders (no parents in pedigree)
|
// Step 1: Calculate the minimum generation for each person based on parents
|
||||||
const founders = persons.filter(p => !p.fatherId && !p.motherId);
|
// A person's generation must be at least (max parent generation + 1)
|
||||||
|
const minGeneration = new Map<string, number>();
|
||||||
|
|
||||||
// BFS from founders
|
const calculateMinGeneration = (personId: string, visited: Set<string>): number => {
|
||||||
const queue: Array<{ person: Person; generation: number }> = [];
|
if (visited.has(personId)) return minGeneration.get(personId) ?? 0;
|
||||||
|
visited.add(personId);
|
||||||
|
|
||||||
for (const founder of founders) {
|
const person = pedigree.persons.get(personId);
|
||||||
queue.push({ person: founder, generation: 0 });
|
if (!person) return 0;
|
||||||
personGenerations.set(founder.id, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
let minGen = 0;
|
||||||
const { person, generation } = queue.shift()!;
|
|
||||||
|
|
||||||
// Add to generation map
|
|
||||||
if (!generations.has(generation)) {
|
|
||||||
generations.set(generation, []);
|
|
||||||
}
|
|
||||||
if (!generations.get(generation)!.find(p => p.id === person.id)) {
|
|
||||||
generations.get(generation)!.push(person);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process children
|
|
||||||
for (const childId of person.childrenIds) {
|
|
||||||
const child = pedigree.persons.get(childId);
|
|
||||||
if (child && !personGenerations.has(childId)) {
|
|
||||||
const childGen = generation + 1;
|
|
||||||
personGenerations.set(childId, childGen);
|
|
||||||
queue.push({ person: child, generation: childGen });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure spouses are in the same generation
|
|
||||||
for (const spouseId of person.spouseIds) {
|
|
||||||
const spouse = pedigree.persons.get(spouseId);
|
|
||||||
if (spouse && !personGenerations.has(spouseId)) {
|
|
||||||
personGenerations.set(spouseId, generation);
|
|
||||||
queue.push({ person: spouse, generation: generation });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle disconnected individuals
|
|
||||||
for (const person of persons) {
|
|
||||||
if (!personGenerations.has(person.id)) {
|
|
||||||
// Try to infer from children or parents
|
|
||||||
let gen = 0;
|
|
||||||
|
|
||||||
|
// Check parents
|
||||||
if (person.fatherId) {
|
if (person.fatherId) {
|
||||||
const fatherGen = personGenerations.get(person.fatherId);
|
const fatherMin = calculateMinGeneration(person.fatherId, visited);
|
||||||
if (fatherGen !== undefined) {
|
minGen = Math.max(minGen, fatherMin + 1);
|
||||||
gen = fatherGen + 1;
|
}
|
||||||
|
if (person.motherId) {
|
||||||
|
const motherMin = calculateMinGeneration(person.motherId, visited);
|
||||||
|
minGen = Math.max(minGen, motherMin + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
minGeneration.set(personId, minGen);
|
||||||
|
return minGen;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate minimum generation for all persons
|
||||||
|
for (const person of persons) {
|
||||||
|
calculateMinGeneration(person.id, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Propagate minimum generations through spouse relationships
|
||||||
|
// Spouses must be in the same generation, so take the maximum
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const person of persons) {
|
||||||
|
const currentMin = minGeneration.get(person.id) ?? 0;
|
||||||
|
for (const spouseId of person.spouseIds) {
|
||||||
|
const spouseMin = minGeneration.get(spouseId) ?? 0;
|
||||||
|
if (spouseMin > currentMin) {
|
||||||
|
minGeneration.set(person.id, spouseMin);
|
||||||
|
changed = true;
|
||||||
|
} else if (currentMin > spouseMin) {
|
||||||
|
minGeneration.set(spouseId, currentMin);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
personGenerations.set(person.id, gen);
|
// Step 3: Propagate to children (children must be at least parent gen + 1)
|
||||||
if (!generations.has(gen)) {
|
changed = true;
|
||||||
generations.set(gen, []);
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const person of persons) {
|
||||||
|
const parentGen = minGeneration.get(person.id) ?? 0;
|
||||||
|
for (const childId of person.childrenIds) {
|
||||||
|
const childMin = minGeneration.get(childId) ?? 0;
|
||||||
|
if (childMin <= parentGen) {
|
||||||
|
minGeneration.set(childId, parentGen + 1);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
generations.get(gen)!.push(person);
|
}
|
||||||
|
}
|
||||||
|
// Also propagate spouse constraints after updating children
|
||||||
|
for (const person of persons) {
|
||||||
|
const currentMin = minGeneration.get(person.id) ?? 0;
|
||||||
|
for (const spouseId of person.spouseIds) {
|
||||||
|
const spouseMin = minGeneration.get(spouseId) ?? 0;
|
||||||
|
if (spouseMin !== currentMin) {
|
||||||
|
const maxGen = Math.max(spouseMin, currentMin);
|
||||||
|
minGeneration.set(person.id, maxGen);
|
||||||
|
minGeneration.set(spouseId, maxGen);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Normalize generations to start from 0
|
||||||
|
const allGens = Array.from(minGeneration.values());
|
||||||
|
const minGen = allGens.length > 0 ? Math.min(...allGens) : 0;
|
||||||
|
|
||||||
|
for (const [personId, gen] of minGeneration) {
|
||||||
|
const normalizedGen = gen - minGen;
|
||||||
|
personGenerations.set(personId, normalizedGen);
|
||||||
|
|
||||||
|
if (!generations.has(normalizedGen)) {
|
||||||
|
generations.set(normalizedGen, []);
|
||||||
|
}
|
||||||
|
const person = pedigree.persons.get(personId);
|
||||||
|
if (person && !generations.get(normalizedGen)!.find(p => p.id === personId)) {
|
||||||
|
generations.get(normalizedGen)!.push(person);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,9 +353,7 @@ export class PedigreeLayout {
|
|||||||
person.y = y;
|
person.y = y;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Note: adjustChildrenPositions is now called separately in layout()
|
||||||
// Second pass: adjust children to center under parents
|
|
||||||
this.adjustChildrenPositions(generations, result, pedigree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -274,8 +374,101 @@ export class PedigreeLayout {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust parent positions to be centered above their children
|
||||||
|
*/
|
||||||
|
private adjustParentPositions(
|
||||||
|
generations: Map<number, Person[]>,
|
||||||
|
positions: Map<string, LayoutNode>,
|
||||||
|
pedigree: Pedigree
|
||||||
|
): void {
|
||||||
|
const genKeys = Array.from(generations.keys()).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Process from bottom to top (children first, then parents)
|
||||||
|
for (let i = genKeys.length - 1; i >= 0; i--) {
|
||||||
|
const gen = genKeys[i];
|
||||||
|
const persons = generations.get(gen) ?? [];
|
||||||
|
|
||||||
|
// Track which persons have been processed as part of a couple
|
||||||
|
const processedAsCouple = new Set<string>();
|
||||||
|
|
||||||
|
for (const person of persons) {
|
||||||
|
if (processedAsCouple.has(person.id)) continue;
|
||||||
|
|
||||||
|
// Find children of this person
|
||||||
|
if (person.childrenIds.length === 0) continue;
|
||||||
|
|
||||||
|
const childNodes = person.childrenIds
|
||||||
|
.map(id => positions.get(id))
|
||||||
|
.filter((n): n is LayoutNode => n !== undefined);
|
||||||
|
|
||||||
|
if (childNodes.length === 0) continue;
|
||||||
|
|
||||||
|
// Calculate center of children
|
||||||
|
const childXs = childNodes.map(n => n.x);
|
||||||
|
const childCenter = (Math.min(...childXs) + Math.max(...childXs)) / 2;
|
||||||
|
|
||||||
|
// Get spouse if any (must be in same generation and share children)
|
||||||
|
const spouse = person.spouseIds
|
||||||
|
.map(id => pedigree.persons.get(id))
|
||||||
|
.find(s => {
|
||||||
|
if (!s || s.generation !== person.generation) return false;
|
||||||
|
// Check if they share children
|
||||||
|
return s.childrenIds.some(cid => person.childrenIds.includes(cid));
|
||||||
|
});
|
||||||
|
|
||||||
|
const parentNode = positions.get(person.id);
|
||||||
|
if (!parentNode) continue;
|
||||||
|
|
||||||
|
if (spouse) {
|
||||||
|
processedAsCouple.add(spouse.id);
|
||||||
|
const spouseNode = positions.get(spouse.id);
|
||||||
|
if (spouseNode) {
|
||||||
|
// Calculate current parent center
|
||||||
|
const parentCenter = (parentNode.x + spouseNode.x) / 2;
|
||||||
|
const offset = childCenter - parentCenter;
|
||||||
|
|
||||||
|
// Apply offset (collision will be resolved later)
|
||||||
|
const safeOffset = this.calculateSafeOffset([person, spouse], offset, positions, gen);
|
||||||
|
if (Math.abs(safeOffset) > 0) {
|
||||||
|
parentNode.x += safeOffset;
|
||||||
|
person.x = parentNode.x;
|
||||||
|
spouseNode.x += safeOffset;
|
||||||
|
spouse.x = spouseNode.x;
|
||||||
|
} else if (Math.abs(offset) > 0) {
|
||||||
|
// If safe offset is 0, try moving everything including colliding nodes
|
||||||
|
parentNode.x += offset;
|
||||||
|
person.x = parentNode.x;
|
||||||
|
spouseNode.x += offset;
|
||||||
|
spouse.x = spouseNode.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single parent - center directly
|
||||||
|
const offset = childCenter - parentNode.x;
|
||||||
|
const safeOffset = this.calculateSafeOffset([person], offset, positions, gen);
|
||||||
|
if (Math.abs(safeOffset) > 0) {
|
||||||
|
parentNode.x += safeOffset;
|
||||||
|
person.x = parentNode.x;
|
||||||
|
} else if (Math.abs(offset) > 0) {
|
||||||
|
parentNode.x += offset;
|
||||||
|
person.x = parentNode.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve collisions after adjustments
|
||||||
|
let maxIterations = 5;
|
||||||
|
while (this.detectCollisions(positions, gen) && maxIterations > 0) {
|
||||||
|
this.resolveCollisions(positions, gen);
|
||||||
|
maxIterations--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adjust children positions to be centered under their parents
|
* Adjust children positions to be centered under their parents
|
||||||
|
* Now includes collision detection and resolution
|
||||||
*/
|
*/
|
||||||
private adjustChildrenPositions(
|
private adjustChildrenPositions(
|
||||||
generations: Map<number, Person[]>,
|
generations: Map<number, Person[]>,
|
||||||
@@ -302,14 +495,19 @@ export class PedigreeLayout {
|
|||||||
if (parentCenter === null) continue;
|
if (parentCenter === null) continue;
|
||||||
|
|
||||||
// Calculate current sibling group center
|
// Calculate current sibling group center
|
||||||
const siblingPositions = siblings.map(s => positions.get(s.id)!);
|
const siblingPositions = siblings.map(s => positions.get(s.id)!).filter(p => p !== undefined);
|
||||||
|
if (siblingPositions.length === 0) continue;
|
||||||
|
|
||||||
const currentCenter = (siblingPositions[0].x + siblingPositions[siblingPositions.length - 1].x) / 2;
|
const currentCenter = (siblingPositions[0].x + siblingPositions[siblingPositions.length - 1].x) / 2;
|
||||||
|
|
||||||
// Calculate offset needed
|
// Calculate offset needed
|
||||||
const offset = parentCenter - currentCenter;
|
const offset = parentCenter - currentCenter;
|
||||||
|
|
||||||
// Apply offset to all siblings (if it doesn't cause overlap)
|
// Check if offset would cause collision
|
||||||
// For now, we'll skip overlap checking for simplicity
|
const wouldCollide = this.checkOffsetCollision(siblings, offset, positions, gen);
|
||||||
|
|
||||||
|
if (!wouldCollide) {
|
||||||
|
// Apply full offset
|
||||||
for (const sibling of siblings) {
|
for (const sibling of siblings) {
|
||||||
const node = positions.get(sibling.id);
|
const node = positions.get(sibling.id);
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -317,9 +515,96 @@ export class PedigreeLayout {
|
|||||||
sibling.x = node.x;
|
sibling.x = node.x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Apply partial offset (move towards parent but stop before collision)
|
||||||
|
const safeOffset = this.calculateSafeOffset(siblings, offset, positions, gen);
|
||||||
|
for (const sibling of siblings) {
|
||||||
|
const node = positions.get(sibling.id);
|
||||||
|
if (node) {
|
||||||
|
node.x += safeOffset;
|
||||||
|
sibling.x = node.x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve any remaining collisions in this generation
|
||||||
|
let maxIterations = 5;
|
||||||
|
while (this.detectCollisions(positions, gen) && maxIterations > 0) {
|
||||||
|
this.resolveCollisions(positions, gen);
|
||||||
|
maxIterations--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if applying an offset to siblings would cause collision
|
||||||
|
*/
|
||||||
|
private checkOffsetCollision(
|
||||||
|
siblings: Person[],
|
||||||
|
offset: number,
|
||||||
|
positions: Map<string, LayoutNode>,
|
||||||
|
generation: number
|
||||||
|
): boolean {
|
||||||
|
const { nodeWidth, horizontalSpacing } = this.options;
|
||||||
|
const siblingIds = new Set(siblings.map(s => s.id));
|
||||||
|
|
||||||
|
// Get all nodes in this generation
|
||||||
|
const genNodes = Array.from(positions.values())
|
||||||
|
.filter(node => node.generation === generation)
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
// Simulate the offset
|
||||||
|
const simulatedPositions = genNodes.map(node => ({
|
||||||
|
id: node.person.id,
|
||||||
|
x: siblingIds.has(node.person.id) ? node.x + offset : node.x,
|
||||||
|
isSpouse: (i: number) => {
|
||||||
|
if (i === 0) return false;
|
||||||
|
const prev = genNodes[i - 1];
|
||||||
|
return prev.person.spouseIds.includes(node.person.id) ||
|
||||||
|
node.person.spouseIds.includes(prev.person.id);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
simulatedPositions.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
// Check for collisions
|
||||||
|
for (let i = 1; i < simulatedPositions.length; i++) {
|
||||||
|
const prev = simulatedPositions[i - 1];
|
||||||
|
const curr = simulatedPositions[i];
|
||||||
|
|
||||||
|
const minDistance = nodeWidth + horizontalSpacing;
|
||||||
|
if (curr.x - prev.x < minDistance) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate a safe offset that moves siblings towards parent without collision
|
||||||
|
*/
|
||||||
|
private calculateSafeOffset(
|
||||||
|
siblings: Person[],
|
||||||
|
desiredOffset: number,
|
||||||
|
positions: Map<string, LayoutNode>,
|
||||||
|
generation: number
|
||||||
|
): number {
|
||||||
|
// Binary search for the maximum safe offset
|
||||||
|
const step = desiredOffset > 0 ? 5 : -5;
|
||||||
|
let safeOffset = 0;
|
||||||
|
|
||||||
|
for (let testOffset = 0; Math.abs(testOffset) < Math.abs(desiredOffset); testOffset += step) {
|
||||||
|
if (!this.checkOffsetCollision(siblings, testOffset, positions, generation)) {
|
||||||
|
safeOffset = testOffset;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return safeOffset;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group persons by their parents
|
* Group persons by their parents
|
||||||
@@ -343,6 +628,248 @@ export class PedigreeLayout {
|
|||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build family units from relationships
|
||||||
|
*/
|
||||||
|
private buildFamilyUnits(pedigree: Pedigree): FamilyUnit[] {
|
||||||
|
const familyUnits: FamilyUnit[] = [];
|
||||||
|
const processedRelationships = new Set<string>();
|
||||||
|
|
||||||
|
// Create family unit for each relationship
|
||||||
|
for (const [relId, relationship] of pedigree.relationships) {
|
||||||
|
if (processedRelationships.has(relId)) continue;
|
||||||
|
processedRelationships.add(relId);
|
||||||
|
|
||||||
|
const person1 = pedigree.persons.get(relationship.person1Id);
|
||||||
|
const person2 = pedigree.persons.get(relationship.person2Id);
|
||||||
|
|
||||||
|
if (!person1 || !person2) continue;
|
||||||
|
|
||||||
|
const unit: FamilyUnit = {
|
||||||
|
id: relId,
|
||||||
|
parents: [relationship.person1Id, relationship.person2Id],
|
||||||
|
children: [...relationship.childrenIds],
|
||||||
|
relationshipId: relId,
|
||||||
|
generation: person1.generation ?? 0,
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
familyUnits.push(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single parents (persons with children but no relationship)
|
||||||
|
for (const [personId, person] of pedigree.persons) {
|
||||||
|
if (person.childrenIds.length === 0) continue;
|
||||||
|
|
||||||
|
// Check if already covered by a relationship
|
||||||
|
const coveredChildren = new Set<string>();
|
||||||
|
for (const unit of familyUnits) {
|
||||||
|
if (unit.parents.includes(personId)) {
|
||||||
|
unit.children.forEach(c => coveredChildren.add(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find children not covered by any relationship
|
||||||
|
const uncoveredChildren = person.childrenIds.filter(c => !coveredChildren.has(c));
|
||||||
|
if (uncoveredChildren.length > 0) {
|
||||||
|
const unit: FamilyUnit = {
|
||||||
|
id: `single-${personId}`,
|
||||||
|
parents: [personId],
|
||||||
|
children: uncoveredChildren,
|
||||||
|
relationshipId: null,
|
||||||
|
generation: person.generation ?? 0,
|
||||||
|
minWidth: 0,
|
||||||
|
};
|
||||||
|
familyUnits.push(unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return familyUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate minimum width needed for a family unit
|
||||||
|
*/
|
||||||
|
private calculateFamilyUnitWidth(unit: FamilyUnit): number {
|
||||||
|
const { nodeWidth, horizontalSpacing, spouseSpacing, siblingSpacing } = this.options;
|
||||||
|
|
||||||
|
// Width of parents
|
||||||
|
const parentsWidth = unit.parents.length === 2
|
||||||
|
? nodeWidth * 2 + spouseSpacing
|
||||||
|
: nodeWidth;
|
||||||
|
|
||||||
|
// Width of children
|
||||||
|
let childrenWidth = 0;
|
||||||
|
if (unit.children.length > 0) {
|
||||||
|
childrenWidth = unit.children.length * nodeWidth +
|
||||||
|
(unit.children.length - 1) * siblingSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family unit width is the maximum of parents width and children width
|
||||||
|
// Plus margin on each side for separation
|
||||||
|
const margin = horizontalSpacing;
|
||||||
|
return Math.max(parentsWidth, childrenWidth) + margin * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect collisions in a generation
|
||||||
|
*/
|
||||||
|
private detectCollisions(positions: Map<string, LayoutNode>, generation: number): boolean {
|
||||||
|
const { nodeWidth, horizontalSpacing } = this.options;
|
||||||
|
const nodesInGen = Array.from(positions.values())
|
||||||
|
.filter(node => node.generation === generation)
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
for (let i = 1; i < nodesInGen.length; i++) {
|
||||||
|
const prev = nodesInGen[i - 1];
|
||||||
|
const curr = nodesInGen[i];
|
||||||
|
|
||||||
|
// Determine minimum spacing based on relationship
|
||||||
|
const areSpouses = prev.person.spouseIds.includes(curr.person.id) ||
|
||||||
|
curr.person.spouseIds.includes(prev.person.id);
|
||||||
|
const minDistance = nodeWidth + (areSpouses ? this.options.spouseSpacing : horizontalSpacing);
|
||||||
|
const actualDistance = curr.x - prev.x;
|
||||||
|
|
||||||
|
if (actualDistance < minDistance) {
|
||||||
|
return true; // Collision detected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve collisions in a generation by shifting nodes to the right
|
||||||
|
*/
|
||||||
|
private resolveCollisions(positions: Map<string, LayoutNode>, generation: number): void {
|
||||||
|
const { nodeWidth, horizontalSpacing } = this.options;
|
||||||
|
const nodesInGen = Array.from(positions.values())
|
||||||
|
.filter(node => node.generation === generation)
|
||||||
|
.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
for (let i = 1; i < nodesInGen.length; i++) {
|
||||||
|
const prev = nodesInGen[i - 1];
|
||||||
|
const curr = nodesInGen[i];
|
||||||
|
|
||||||
|
// Determine minimum spacing based on relationship
|
||||||
|
const areSpouses = prev.person.spouseIds.includes(curr.person.id) ||
|
||||||
|
curr.person.spouseIds.includes(prev.person.id);
|
||||||
|
const minDistance = nodeWidth + (areSpouses ? this.options.spouseSpacing : horizontalSpacing);
|
||||||
|
const actualDistance = curr.x - prev.x;
|
||||||
|
|
||||||
|
if (actualDistance < minDistance) {
|
||||||
|
const shift = minDistance - actualDistance;
|
||||||
|
|
||||||
|
// Shift this node and all nodes to the right
|
||||||
|
for (let j = i; j < nodesInGen.length; j++) {
|
||||||
|
const node = nodesInGen[j];
|
||||||
|
node.x += shift;
|
||||||
|
node.person.x = node.x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find persons who appear in multiple family units (multiple marriages)
|
||||||
|
*/
|
||||||
|
private findSharedPersons(familyUnits: FamilyUnit[]): Map<string, FamilyUnit[]> {
|
||||||
|
const personToUnits = new Map<string, FamilyUnit[]>();
|
||||||
|
|
||||||
|
for (const unit of familyUnits) {
|
||||||
|
for (const parentId of unit.parents) {
|
||||||
|
if (!personToUnits.has(parentId)) {
|
||||||
|
personToUnits.set(parentId, []);
|
||||||
|
}
|
||||||
|
personToUnits.get(parentId)!.push(unit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only those with multiple units
|
||||||
|
const sharedPersons = new Map<string, FamilyUnit[]>();
|
||||||
|
for (const [personId, units] of personToUnits) {
|
||||||
|
if (units.length > 1) {
|
||||||
|
sharedPersons.set(personId, units);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharedPersons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort generations with awareness of family units and shared persons
|
||||||
|
* Ensures proper ordering to minimize overlaps
|
||||||
|
*/
|
||||||
|
private sortGenerationWithFamilyUnits(
|
||||||
|
persons: Person[],
|
||||||
|
pedigree: Pedigree,
|
||||||
|
familyUnits: FamilyUnit[],
|
||||||
|
sharedPersons: Map<string, FamilyUnit[]>
|
||||||
|
): Person[] {
|
||||||
|
if (persons.length <= 1) {
|
||||||
|
return [...persons];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Person[] = [];
|
||||||
|
const processed = new Set<string>();
|
||||||
|
|
||||||
|
// First, handle shared persons (people with multiple marriages)
|
||||||
|
// They should be placed with all their spouses in sequence
|
||||||
|
for (const [sharedId, units] of sharedPersons) {
|
||||||
|
const sharedPerson = persons.find(p => p.id === sharedId);
|
||||||
|
if (!sharedPerson || processed.has(sharedId)) continue;
|
||||||
|
|
||||||
|
// Collect all spouses of this person
|
||||||
|
const spouses: Person[] = [];
|
||||||
|
for (const unit of units) {
|
||||||
|
for (const parentId of unit.parents) {
|
||||||
|
if (parentId !== sharedId) {
|
||||||
|
const spouse = persons.find(p => p.id === parentId);
|
||||||
|
if (spouse && !processed.has(spouse.id)) {
|
||||||
|
spouses.push(spouse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add first spouse, then shared person, then remaining spouses
|
||||||
|
// This places the shared person in the middle
|
||||||
|
if (spouses.length > 0) {
|
||||||
|
result.push(spouses[0]);
|
||||||
|
processed.add(spouses[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(sharedPerson);
|
||||||
|
processed.add(sharedId);
|
||||||
|
|
||||||
|
for (let i = 1; i < spouses.length; i++) {
|
||||||
|
result.push(spouses[i]);
|
||||||
|
processed.add(spouses[i].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then process remaining persons normally
|
||||||
|
for (const person of persons) {
|
||||||
|
if (processed.has(person.id)) continue;
|
||||||
|
|
||||||
|
const group: Person[] = [person];
|
||||||
|
processed.add(person.id);
|
||||||
|
|
||||||
|
// Add all spouses
|
||||||
|
for (const spouseId of person.spouseIds) {
|
||||||
|
const spouse = persons.find(p => p.id === spouseId);
|
||||||
|
if (spouse && !processed.has(spouse.id)) {
|
||||||
|
group.push(spouse);
|
||||||
|
processed.add(spouse.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(...group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Center the entire layout around (0, 0)
|
* Center the entire layout around (0, 0)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export interface PersonStatus {
|
|||||||
|
|
||||||
export interface PersonMetadata {
|
export interface PersonMetadata {
|
||||||
label?: string;
|
label?: string;
|
||||||
|
label2?: string; // Second line custom text
|
||||||
|
showBirthYear?: boolean; // Show birth year in label
|
||||||
|
showDeathYear?: boolean; // Show death year in label
|
||||||
|
showAge?: boolean; // Show age in label
|
||||||
notes?: string;
|
notes?: string;
|
||||||
birthYear?: number;
|
birthYear?: number;
|
||||||
deathYear?: number;
|
deathYear?: number;
|
||||||
@@ -186,6 +190,19 @@ export interface LayoutNode {
|
|||||||
order: number; // Order within generation
|
order: number; // Order within generation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family Unit - represents a couple and their children
|
||||||
|
* Used for calculating layout widths and preventing overlaps
|
||||||
|
*/
|
||||||
|
export interface FamilyUnit {
|
||||||
|
id: string;
|
||||||
|
parents: string[]; // Person IDs of parents (1-2 people)
|
||||||
|
children: string[]; // Person IDs of direct children
|
||||||
|
relationshipId: string | null;
|
||||||
|
generation: number; // Generation of the parents
|
||||||
|
minWidth: number; // Minimum width needed for this family
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Render Types
|
// Render Types
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user