From fb0be3964f415a0c7754c11481aca4cc81c059b1 Mon Sep 17 00:00:00 2001 From: gbanyan Date: Wed, 17 Dec 2025 01:23:14 +0800 Subject: [PATCH] Add collision detection for family overlaps and second line labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../PedigreeCanvas/hooks/useD3Pedigree.ts | 43 +- .../PropertyPanel/PropertyPanel.module.css | 43 ++ .../PropertyPanel/PropertyPanel.tsx | 78 +++ src/core/layout/PedigreeLayout.ts | 651 ++++++++++++++++-- src/core/model/types.ts | 17 + 5 files changed, 769 insertions(+), 63 deletions(-) diff --git a/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts b/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts index 330fedb..6b5a1fc 100644 --- a/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts +++ b/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts @@ -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,9 +472,50 @@ 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, layoutNodes: Map diff --git a/src/components/PropertyPanel/PropertyPanel.module.css b/src/components/PropertyPanel/PropertyPanel.module.css index a12d172..0809e9a 100644 --- a/src/components/PropertyPanel/PropertyPanel.module.css +++ b/src/components/PropertyPanel/PropertyPanel.module.css @@ -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; +} diff --git a/src/components/PropertyPanel/PropertyPanel.tsx b/src/components/PropertyPanel/PropertyPanel.tsx index c3fdaeb..2e65bce 100644 --- a/src/components/PropertyPanel/PropertyPanel.tsx +++ b/src/components/PropertyPanel/PropertyPanel.tsx @@ -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 (
@@ -68,6 +80,72 @@ export function PropertyPanel() { />
+
+
Label Line 2
+ handleLabel2Change(e.target.value)} + placeholder="Second line text..." + /> +
+ +
+
Birth/Death/Age
+
+ + handleMetadataChange('birthYear', e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="Year" + /> +
+
+ + handleMetadataChange('deathYear', e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="Year" + /> +
+
+ + handleMetadataChange('age', e.target.value ? parseInt(e.target.value) : undefined)} + placeholder="Age" + /> +
+
+
Sex
diff --git a/src/core/layout/PedigreeLayout.ts b/src/core/layout/PedigreeLayout.ts index 3fe8a85..53ec0f6 100644 --- a/src/core/layout/PedigreeLayout.ts +++ b/src/core/layout/PedigreeLayout.ts @@ -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, + pedigree: Pedigree, + familyUnits: FamilyUnit[], + sharedPersons: Map + ): Map { + const sorted = new Map(); + + 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(); + 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 { const generations = new Map(); const personGenerations = new Map(); 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(); - // BFS from founders - const queue: Array<{ person: Person; generation: number }> = []; + const calculateMinGeneration = (personId: string, visited: Set): 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()!; + let minGen = 0; - // Add to generation map - if (!generations.has(generation)) { - generations.set(generation, []); + // Check parents + if (person.fatherId) { + const fatherMin = calculateMinGeneration(person.fatherId, visited); + minGen = Math.max(minGen, fatherMin + 1); } - if (!generations.get(generation)!.find(p => p.id === person.id)) { - generations.get(generation)!.push(person); + if (person.motherId) { + const motherMin = calculateMinGeneration(person.motherId, visited); + minGen = Math.max(minGen, motherMin + 1); } - // 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 }); - } - } + minGeneration.set(personId, minGen); + return minGen; + }; - // 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 + // Calculate minimum generation for all persons for (const person of persons) { - if (!personGenerations.has(person.id)) { - // Try to infer from children or parents - let gen = 0; + calculateMinGeneration(person.id, new Set()); + } - if (person.fatherId) { - const fatherGen = personGenerations.get(person.fatherId); - if (fatherGen !== undefined) { - gen = fatherGen + 1; + // 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, + positions: Map, + 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(); + + 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, @@ -302,25 +495,117 @@ 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 - for (const sibling of siblings) { - const node = positions.get(sibling.id); - if (node) { - node.x += offset; - sibling.x = node.x; + // 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) { + node.x += offset; + 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, + 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, + 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(); + + // 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(); + 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, 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, 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 { + const personToUnits = new Map(); + + 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(); + 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 + ): Person[] { + if (persons.length <= 1) { + return [...persons]; + } + + const result: Person[] = []; + const processed = new Set(); + + // 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) */ diff --git a/src/core/model/types.ts b/src/core/model/types.ts index 1431369..53fb3f3 100644 --- a/src/core/model/types.ts +++ b/src/core/model/types.ts @@ -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 // ============================================