Add collision detection for family overlaps and second line labels
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

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:
gbanyan
2025-12-17 01:23:14 +08:00
parent 597ca0eaa7
commit fb0be3964f
5 changed files with 769 additions and 63 deletions

View File

@@ -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,8 +472,49 @@ 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(
group: d3.Selection<SVGGElement, unknown, null, undefined>, group: d3.Selection<SVGGElement, unknown, null, undefined>,

View File

@@ -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;
}

View File

@@ -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}>

View File

@@ -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)
*/ */

View File

@@ -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
// ============================================ // ============================================