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');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
personGroup
|
||||
.append('text')
|
||||
@@ -472,8 +472,49 @@ function renderPersons(
|
||||
.attr('fill', '#666')
|
||||
.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(
|
||||
group: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
|
||||
@@ -112,3 +112,46 @@
|
||||
color: #999;
|
||||
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 (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>
|
||||
@@ -68,6 +80,72 @@ export function PropertyPanel() {
|
||||
/>
|
||||
</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.sectionTitle}>Sex</div>
|
||||
<div className={styles.buttonGroup}>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 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 = {
|
||||
nodeWidth: 50,
|
||||
@@ -46,86 +46,188 @@ export class PedigreeLayout {
|
||||
// Step 1: Assign generations
|
||||
const generations = this.assignGenerations(pedigree);
|
||||
|
||||
// Step 2: Sort within each generation
|
||||
const sortedGenerations = this.sortGenerations(generations, pedigree);
|
||||
// Step 2: Build family units and find shared persons (multiple marriages)
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
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
|
||||
* Ensures spouses are always in the same generation
|
||||
*/
|
||||
private assignGenerations(pedigree: Pedigree): Map<number, Person[]> {
|
||||
const generations = new Map<number, Person[]>();
|
||||
const personGenerations = new Map<string, number>();
|
||||
const persons = Array.from(pedigree.persons.values());
|
||||
|
||||
// Find founders (no parents in pedigree)
|
||||
const founders = persons.filter(p => !p.fatherId && !p.motherId);
|
||||
// Step 1: Calculate the minimum generation for each person based on parents
|
||||
// A person's generation must be at least (max parent generation + 1)
|
||||
const minGeneration = new Map<string, number>();
|
||||
|
||||
// BFS from founders
|
||||
const queue: Array<{ person: Person; generation: number }> = [];
|
||||
const calculateMinGeneration = (personId: string, visited: Set<string>): number => {
|
||||
if (visited.has(personId)) return minGeneration.get(personId) ?? 0;
|
||||
visited.add(personId);
|
||||
|
||||
for (const founder of founders) {
|
||||
queue.push({ person: founder, generation: 0 });
|
||||
personGenerations.set(founder.id, 0);
|
||||
}
|
||||
const person = pedigree.persons.get(personId);
|
||||
if (!person) return 0;
|
||||
|
||||
while (queue.length > 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;
|
||||
let minGen = 0;
|
||||
|
||||
// Check parents
|
||||
if (person.fatherId) {
|
||||
const fatherGen = personGenerations.get(person.fatherId);
|
||||
if (fatherGen !== undefined) {
|
||||
gen = fatherGen + 1;
|
||||
const fatherMin = calculateMinGeneration(person.fatherId, visited);
|
||||
minGen = Math.max(minGen, fatherMin + 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);
|
||||
if (!generations.has(gen)) {
|
||||
generations.set(gen, []);
|
||||
// Step 3: Propagate to children (children must be at least parent gen + 1)
|
||||
changed = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: adjust children to center under parents
|
||||
this.adjustChildrenPositions(generations, result, pedigree);
|
||||
// Note: adjustChildrenPositions is now called separately in layout()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,8 +374,101 @@ export class PedigreeLayout {
|
||||
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
|
||||
* Now includes collision detection and resolution
|
||||
*/
|
||||
private adjustChildrenPositions(
|
||||
generations: Map<number, Person[]>,
|
||||
@@ -302,14 +495,19 @@ export class PedigreeLayout {
|
||||
if (parentCenter === null) continue;
|
||||
|
||||
// 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;
|
||||
|
||||
// Calculate offset needed
|
||||
const offset = parentCenter - currentCenter;
|
||||
|
||||
// Apply offset to all siblings (if it doesn't cause overlap)
|
||||
// For now, we'll skip overlap checking for simplicity
|
||||
// Check if offset would cause collision
|
||||
const wouldCollide = this.checkOffsetCollision(siblings, offset, positions, gen);
|
||||
|
||||
if (!wouldCollide) {
|
||||
// Apply full offset
|
||||
for (const sibling of siblings) {
|
||||
const node = positions.get(sibling.id);
|
||||
if (node) {
|
||||
@@ -317,9 +515,96 @@ export class PedigreeLayout {
|
||||
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
|
||||
@@ -343,6 +628,248 @@ export class PedigreeLayout {
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -62,6 +62,10 @@ export interface PersonStatus {
|
||||
|
||||
export interface PersonMetadata {
|
||||
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;
|
||||
birthYear?: number;
|
||||
deathYear?: number;
|
||||
@@ -186,6 +190,19 @@ export interface LayoutNode {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user