/
nek5132
/
Genius
Обзор
Документация
Войти
/
nek5132
/
Genius
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/core/LayoutEngine.js
1 435 строк
49 KB
Peter Kosov
fix(rotation): stabilize safe branch turning and hub routing
15 апр 2026, 06:33
15 апр 2026, 06:33
1afa792
Код
Авторство
О чём код?
import { LAYOUT_PARAMS, STYLE_PARAMS } from './GraphState.js'; export class LayoutEngine { compute(state, mode, layout) { const layoutVersion = state.config?.layoutVersion || 'ortho-tree-v1'; const params = LAYOUT_PARAMS[layoutVersion] || LAYOUT_PARAMS['ortho-tree-v1']; const modelProfile = state.config?.modelProfile || 'genealogy'; if (modelProfile === 'phylogeny') { return this.computePhylogeny(state, mode, layout, params); } if (modelProfile === 'generic-tree') { return this.computeGenericTree(state, mode, layout, params); } if (layout.startsWith('radial')) { return this.computeRadial(state, mode, layout, params); } return this.computeVertical(state, mode, layout, params); } computePhylogeny(state, mode, layout, params) { const nodes = []; const edges = []; const pivots = []; const hitAreas = []; const hasCompact = state.config?.compact; const compactMultiplier = hasCompact ? 0.6 : 1.0; const nodeSpacing = 60 * compactMultiplier; const levelGap = 80 * compactMultiplier; const entityMap = new Map(state.entities.map(e => [e.id, e])); const nodePositions = new Map(); let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; const rootNodes = state.entities.filter(e => e.attrs?.isRoot); const childRelations = state.relations.filter(r => r.kind === 'phylo-edge'); const buildTree = (nodeId, depth, x, y) => { const entity = entityMap.get(nodeId); if (!entity) return; const isRadial = layout.startsWith('radial'); let posX, posY, posZ; if (isRadial) { const angle = (x / (childRelations.length + 1)) * Math.PI * 2; const radius = depth * params.radiusStep * 50; posX = Math.cos(angle) * radius; posY = Math.sin(angle) * radius; posZ = 0; } else { posX = x; posY = -depth * levelGap; posZ = mode === '3d' ? 0 : 0; } nodePositions.set(nodeId, { x: posX, y: posY, z: posZ }); minX = Math.min(minX, posX); maxX = Math.max(maxX, posX); minY = Math.min(minY, posY); maxY = Math.max(maxY, posY); minZ = Math.min(minZ, posZ); maxZ = Math.max(maxZ, posZ); const branchLength = entity.attrs?.branchLength || 0.5; const nodeSize = 15 + branchLength * 10; nodes.push({ id: nodeId, role: 'phylo-node', groupId: null, position: { x: posX, y: posY, z: posZ }, rotationY: 0, visible: true, selectable: true, nodeSize, isLeaf: entity.attrs?.isLeaf || false }); const childEdges = childRelations.filter(r => r.fromId === nodeId); const childCount = childEdges.length; const totalWidth = childCount * nodeSpacing; let startX = x - totalWidth / 2 + nodeSpacing / 2; childEdges.forEach((edge, idx) => { const childPos = { x: startX + idx * nodeSpacing, y: depth + 1 }; const childId = edge.toId; buildTree(childId, depth + 1, childPos.x, childPos.y); const childPosFinal = nodePositions.get(childId); if (childPosFinal) { const edgeLength = edge.attrs?.branchLength || 0.3; edges.push({ id: edge.id, role: 'phylo-edge', fromId: nodeId, toId: childId, points: [ { x: posX, y: posY, z: posZ }, { x: posX, y: posY + levelGap * 0.5, z: posZ }, { x: childPosFinal.x, y: posY + levelGap * 0.5, z: childPosFinal.z }, { x: childPosFinal.x, y: childPosFinal.y, z: childPosFinal.z } ], edgeLength }); } }); }; if (rootNodes.length > 0) { rootNodes.forEach(root => buildTree(root.id, 0, 0, 0)); } else if (state.entities.length > 0) { buildTree(state.entities[0].id, 0, 0, 0); } return { mode, layout, modelProfile: 'phylogeny', nodes, edges, labels: [], pivots, hitAreas, bounds: { min: { x: minX - 50, y: minY - 50, z: minZ - 50 }, max: { x: maxX + 50, y: maxY + 50, z: maxZ + 50 } } }; } computeGenericTree(state, mode, layout, params) { const nodes = []; const edges = []; const pivots = []; const hitAreas = []; const hasCompact = state.config?.compact; const compactMultiplier = hasCompact ? 0.6 : 1.0; const nodeSpacing = 60 * compactMultiplier; const levelGap = 70 * compactMultiplier; const entityMap = new Map(state.entities.map(e => [e.id, e])); const nodePositions = new Map(); let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, minZ = Infinity, maxZ = -Infinity; const childRelations = state.relations.filter(r => r.kind === 'tree-edge'); const rootEntities = state.entities.filter(e => e.attrs?.category === 'root'); const buildTree = (nodeId, depth, x, y, isRadial) => { const entity = entityMap.get(nodeId); if (!entity) return; let posX, posY, posZ; if (isRadial) { const angle = (x / (childRelations.length + 1)) * Math.PI * 2; const radius = depth * params.radiusStep * 50; posX = Math.cos(angle) * radius; posY = Math.sin(angle) * radius; posZ = mode === '3d' ? 0 : 0; } else { posX = x; posY = -depth * levelGap; posZ = mode === '3d' ? 0 : 0; } nodePositions.set(nodeId, { x: posX, y: posY, z: posZ }); minX = Math.min(minX, posX); maxX = Math.max(maxX, posX); minY = Math.min(minY, posY); maxY = Math.max(maxY, posY); minZ = Math.min(minZ, posZ); maxZ = Math.max(maxZ, posZ); const category = entity.attrs?.category || 'internal'; const nodeSize = category === 'leaf' ? 12 : 18; nodes.push({ id: nodeId, role: 'tree-node', groupId: null, position: { x: posX, y: posY, z: posZ }, rotationY: 0, visible: true, selectable: true, nodeSize, category }); const childEdges = childRelations.filter(r => r.fromId === nodeId); const childCount = childEdges.length; const totalWidth = childCount * nodeSpacing; let startX = x - totalWidth / 2 + nodeSpacing / 2; childEdges.forEach((edge, idx) => { const childPos = { x: startX + idx * nodeSpacing, y: depth + 1 }; const childId = edge.toId; buildTree(childId, depth + 1, childPos.x, childPos.y, isRadial); const childPosFinal = nodePositions.get(childId); if (childPosFinal) { const midY = (posY + childPosFinal.y) / 2; edges.push({ id: edge.id, role: 'tree-edge', fromId: nodeId, toId: childId, points: [ { x: posX, y: posY, z: posZ }, { x: posX, y: midY, z: posZ }, { x: childPosFinal.x, y: midY, z: childPosFinal.z }, { x: childPosFinal.x, y: childPosFinal.y, z: childPosFinal.z } ] }); } }); }; const isRadial = layout.startsWith('radial'); if (rootEntities.length > 0) { rootEntities.forEach(root => buildTree(root.id, 0, 0, 0, isRadial)); } else if (state.entities.length > 0) { buildTree(state.entities[0].id, 0, 0, 0, isRadial); } return { mode, layout, modelProfile: 'generic-tree', nodes, edges, labels: [], pivots, hitAreas, bounds: { min: { x: minX - 50, y: minY - 50, z: minZ - 50 }, max: { x: maxX + 50, y: maxY + 50, z: maxZ + 50 } } }; } computeVertical(state, mode, layout, params) { const nodes = []; const edges = []; const pivots = []; const labels = []; const hasCompact = state.groups.some(g => g.attrs?.compact); const compactMultiplier = hasCompact ? 0.5 : 1.0; const genMultiplier = hasCompact ? 0.7 : 1.0; const coupleGap = params.coupleGap * 50 * compactMultiplier; const genGap = params.genGap * 50 * genMultiplier; // Dynamic vertical gap for complex trees to reduce overlaps // Heuristic: if total number of children across all families is large, increase spacing const totalChildren = state.groups.reduce((acc, g) => acc + ((g.attrs?.childrenIds?.length) || 0), 0); const isComplexTree = totalChildren > 8; const dynamicGenGap = genGap * (isComplexTree ? 1.4 : 1.0); const familyGap = params.familyGap * 50 * compactMultiplier; const rotateAngle = params.rotateAngle; const zSpread = params.zSpread * 50; const minNodeSeparation = (params.minNodeSeparation || 0.3) * 50; const entityMap = new Map(state.entities.map(e => [e.id, e])); const familyMap = new Map(state.groups.map(g => [g.id, g])); // Build relation maps const childToFamily = new Map(); const familyToParent = new Map(); const familyToChildren = new Map(); state.relations.forEach(rel => { const attrs = rel.attrs || {}; if (attrs.childPersonId && attrs.childFamilyId) { childToFamily.set(attrs.childPersonId, attrs.childFamilyId); } if (attrs.childFamilyId && attrs.parentFamilyId) { familyToParent.set(attrs.childFamilyId, { parentFamilyId: attrs.parentFamilyId, childPersonId: attrs.childPersonId }); } if (attrs.parentFamilyId && attrs.childFamilyId) { if (!familyToChildren.has(attrs.parentFamilyId)) { familyToChildren.set(attrs.parentFamilyId, []); } familyToChildren.get(attrs.parentFamilyId).push({ childPersonId: attrs.childPersonId, childFamilyId: attrs.childFamilyId }); } }); // Phase 1: Assign Y levels using generation-aware BFS // For genealogy: ancestors go BELOW root (negative Y), descendants go ABOVE root (positive Y) const rootFamilies = state.groups.filter(g => { return !familyToParent.has(g.id) && (g.attrs?.husbandId || g.attrs?.wifeId || (g.attrs?.childrenIds || []).length > 0); }); const familyY = new Map(); const familyGen = new Map(); // generation index: negative for ancestors, 0 for root, positive for descendants const visitedGen = new Set(); // Start from root families at generation 0 const queue = []; rootFamilies.forEach(f => { if (!familyY.has(f.id)) { familyY.set(f.id, 0); familyGen.set(f.id, 0); visitedGen.add(f.id); queue.push({ id: f.id, gen: 0 }); } }); // BFS with generation tracking - process ancestors first (lower Y), then descendants (higher Y) while (queue.length > 0) { const { id: famId, gen } = queue.shift(); const currentY = familyY.get(famId); // Ancestors: go DOWN (negative Y) - process first to ensure they're lower const parentInfo = familyToParent.get(famId); if (parentInfo && !visitedGen.has(parentInfo.parentFamilyId)) { const parentGen = gen - 1; familyY.set(parentInfo.parentFamilyId, parentGen * dynamicGenGap); familyGen.set(parentInfo.parentFamilyId, parentGen); visitedGen.add(parentInfo.parentFamilyId); queue.push({ id: parentInfo.parentFamilyId, gen: parentGen }); } // Descendants: go UP (positive Y) const children = familyToChildren.get(famId) || []; children.forEach(child => { if (!visitedGen.has(child.childFamilyId)) { const childGen = gen + 1; familyY.set(child.childFamilyId, childGen * dynamicGenGap); familyGen.set(child.childFamilyId, childGen); visitedGen.add(child.childFamilyId); queue.push({ id: child.childFamilyId, gen: childGen }); } }); } // Any remaining families not reached by BFS get Y=0, gen=0 familyMap.forEach((fam, id) => { if (!familyY.has(id)) { const parentInfo = familyToParent.get(id); if (parentInfo) { const parentY = familyY.get(parentInfo.parentFamilyId) || 0; const parentGen = familyGen.get(parentInfo.parentFamilyId) || 0; const childGen = parentGen + 1; familyY.set(id, childGen * dynamicGenGap); familyGen.set(id, childGen); } else { familyY.set(id, 0); familyGen.set(id, 0); } } }); // Phase 2: Group families by Y level and compute X positions const familiesByY = new Map(); familyY.forEach((y, famId) => { if (!familiesByY.has(y)) familiesByY.set(y, []); familiesByY.get(y).push(famId); }); const familyPositions = new Map(); familiesByY.forEach((famIds, y) => { const minNodeWidth = coupleGap * 1.8; let totalWidth = 0; const widths = famIds.map(famId => { const fam = familyMap.get(famId); const w = this.estimateFamilyWidth(fam, familyMap, entityMap, coupleGap); totalWidth += w; return { famId, width: w }; }); const totalGaps = Math.max(0, famIds.length - 1) * familyGap; let currentX = -(totalWidth + totalGaps) / 2; const levelZStart = -((famIds.length - 1) * zSpread) / 2; widths.forEach(({ famId, width }, idx) => { familyPositions.set(famId, { x: currentX + width / 2, y, z: levelZStart + idx * zSpread }); currentX += width + familyGap; }); }); const personMemberships = new Map(); state.groups.forEach(fam => { const attrs = fam.attrs || {}; if (attrs.husbandId) { if (!personMemberships.has(attrs.husbandId)) personMemberships.set(attrs.husbandId, []); personMemberships.get(attrs.husbandId).push({ familyId: fam.id, role: 'husband' }); } if (attrs.wifeId) { if (!personMemberships.has(attrs.wifeId)) personMemberships.set(attrs.wifeId, []); personMemberships.get(attrs.wifeId).push({ familyId: fam.id, role: 'wife' }); } (attrs.childrenIds || []).forEach(childId => { if (!personMemberships.has(childId)) personMemberships.set(childId, []); personMemberships.get(childId).push({ familyId: fam.id, role: 'child' }); }); }); const personAnchorFamily = new Map(); const personAnchorPosition = new Map(); const getFamilyCenter = (familyId) => familyPositions.get(familyId) || { x: 0, y: 0, z: 0 }; personMemberships.forEach((memberships, personId) => { const spouseFamilies = memberships.filter(m => m.role === 'husband' || m.role === 'wife'); const childFamilies = memberships.filter(m => m.role === 'child'); const candidates = spouseFamilies.length > 0 ? spouseFamilies : childFamilies; if (candidates.length === 0) return; let chosen = candidates[0]; let chosenY = familyY.get(chosen.familyId) ?? 0; candidates.slice(1).forEach(candidate => { const candidateY = familyY.get(candidate.familyId) ?? 0; if (candidateY > chosenY) { chosen = candidate; chosenY = candidateY; } }); personAnchorFamily.set(personId, chosen.familyId); const center = getFamilyCenter(chosen.familyId); const fam = familyMap.get(chosen.familyId); const attrs = fam?.attrs || {}; const isHusband = attrs.husbandId === personId; const isWife = attrs.wifeId === personId; const x = isHusband ? center.x - coupleGap / 2 : isWife ? center.x + coupleGap / 2 : center.x; personAnchorPosition.set(personId, { x, y: center.y, z: center.z || 0 }); }); // Phase 3: Place all families and create edges const placedNodes = new Map(); let allMinX = Infinity, allMaxX = -Infinity, allMinY = Infinity, allMaxY = -Infinity, allMinZ = Infinity, allMaxZ = -Infinity; const updateBounds = (x, y, z) => { allMinX = Math.min(allMinX, x); allMaxX = Math.max(allMaxX, x); allMinY = Math.min(allMinY, y); allMaxY = Math.max(allMaxY, y); allMinZ = Math.min(allMinZ, z); allMaxZ = Math.max(allMaxZ, z); }; const placeFamily = (familyId) => { const pos = familyPositions.get(familyId); if (!pos) return; const family = familyMap.get(familyId); if (!family) return; const attrs = family.attrs || {}; const husbandId = attrs.husbandId; const wifeId = attrs.wifeId; const childrenIds = attrs.childrenIds || []; const hubId = `hub-${family.id}`; const hubX = pos.x; const hubY = pos.y; const hubZ = pos.z || 0; updateBounds(hubX, hubY, hubZ); placedNodes.set(hubId, { x: hubX, y: hubY, z: hubZ }); nodes.push({ id: hubId, role: 'family_hub', groupId: family.id, position: { x: hubX, y: hubY, z: hubZ }, rotationY: 0, visible: true, selectable: true }); if (husbandId) { const husbandPos = personAnchorPosition.get(husbandId) || { x: hubX - coupleGap / 2, y: hubY, z: hubZ }; if (!placedNodes.has(husbandId) && personAnchorFamily.get(husbandId) === family.id) { placedNodes.set(husbandId, husbandPos); personAnchorPosition.set(husbandId, husbandPos); nodes.push({ id: husbandId, role: 'person', groupId: family.id, position: husbandPos, rotationY: 0, visible: true, selectable: true }); updateBounds(husbandPos.x, husbandPos.y, husbandPos.z); } edges.push({ id: `edge-${family.id}-${husbandId}-hub`, role: 'spouse', fromId: husbandId, toId: hubId, route: { points: [ { x: husbandPos.x, y: husbandPos.y, z: husbandPos.z }, { x: hubX, y: hubY, z: hubZ } ]}, visible: true }); } if (wifeId) { const wifePos = personAnchorPosition.get(wifeId) || { x: hubX + coupleGap / 2, y: hubY, z: hubZ }; if (!placedNodes.has(wifeId) && personAnchorFamily.get(wifeId) === family.id) { placedNodes.set(wifeId, wifePos); personAnchorPosition.set(wifeId, wifePos); nodes.push({ id: wifeId, role: 'person', groupId: family.id, position: wifePos, rotationY: 0, visible: true, selectable: true }); updateBounds(wifePos.x, wifePos.y, wifePos.z); } edges.push({ id: `edge-${family.id}-${wifeId}-hub`, role: 'spouse', fromId: wifeId, toId: hubId, route: { points: [ { x: wifePos.x, y: wifePos.y, z: wifePos.z }, { x: hubX, y: hubY, z: hubZ } ]}, visible: true }); } // Create parent-child edges using child family hubs as the inter-family anchor. childrenIds.forEach((childId, idx) => { const childFamilyId = childToFamily.get(childId); const childHubId = childFamilyId ? `hub-${childFamilyId}` : null; // Children ALWAYS go above parent hub (positive Y), regardless of their own family position const childPos = { x: hubX + (idx - (childrenIds.length - 1) / 2) * coupleGap, y: hubY + dynamicGenGap + 0.1, // Ensure strict Y > hubY z: hubZ }; if (!placedNodes.has(childId) && personAnchorFamily.get(childId) === family.id) { placedNodes.set(childId, childPos); personAnchorPosition.set(childId, childPos); nodes.push({ id: childId, role: 'person', groupId: family.id, position: childPos, rotationY: mode === '2d' ? 0 : (attrs.turned ? rotateAngle : 0), visible: true, selectable: true }); updateBounds(childPos.x, childPos.y, childPos.z); } // Collision-free edge routing const laneOffset = (idx - (childrenIds.length - 1) / 2) * (dynamicGenGap * 0.18); const childFamilyCenter = childFamilyId ? familyPositions.get(childFamilyId) : null; const routedChildPos = childFamilyCenter ? { x: childFamilyCenter.x, y: childFamilyCenter.y, z: childFamilyCenter.z || 0 } : childPos; // Ensure child is strictly above parent for correct edge routing if (routedChildPos.y <= hubY) { routedChildPos.y = hubY + 0.1; } edges.push({ id: `edge-${family.id}-${childFamilyId || childId}-hub`, role: 'parent-child', fromId: hubId, toId: childHubId || childId, route: { points: this.routeEdge(hubX, hubY, hubZ, routedChildPos.x, routedChildPos.y, routedChildPos.z, familyY, dynamicGenGap, laneOffset) }, visible: true }); }); // Children without families const childrenWithoutFamily = childrenIds.filter(cid => !childToFamily.has(cid)); if (childrenWithoutFamily.length > 0) { const childBaseY = hubY + dynamicGenGap + 0.1; // Ensure strictly above const minNodeWidth = coupleGap * 1.8; const totalWidth = childrenWithoutFamily.length * minNodeWidth; const startX = hubX - totalWidth / 2 + minNodeWidth / 2; childrenWithoutFamily.forEach((childId, idx) => { const childX = startX + idx * minNodeWidth; const childZ = hubZ; const childPos = { ...personAnchorPosition.get(childId), x: childX, y: childBaseY, z: childZ }; if (!placedNodes.has(childId) && personAnchorFamily.get(childId) === family.id) { placedNodes.set(childId, childPos); personAnchorPosition.set(childId, childPos); nodes.push({ id: childId, role: 'person', groupId: family.id, position: childPos, rotationY: mode === '2d' ? 0 : (attrs.turned ? rotateAngle : 0), visible: true, selectable: true }); updateBounds(childPos.x, childPos.y, childPos.z); } const laneOffset = (idx - (childrenWithoutFamily.length - 1) / 2) * (dynamicGenGap * 0.18); const edgePoints = this.routeEdge(hubX, hubY, hubZ, childPos.x, childPos.y, childPos.z, familyY, dynamicGenGap, laneOffset); edges.push({ id: `edge-${family.id}-${childId}-hub`, role: 'parent-child', fromId: hubId, toId: childId, route: { points: edgePoints }, visible: true }); }); } const rotationY = (mode === '3d' && attrs.turned) ? rotateAngle : 0; pivots.push({ id: family.id, sourceGroupId: family.id, pivot: { x: hubX, y: hubY, z: hubZ }, rotationY: mode === '2d' ? 0 : rotationY }); }; // Place all families familyMap.forEach((fam, id) => placeFamily(id)); // Forest layout: distribute independent components to keep graph compact const forestEnabled = (state.config?.forestLayoutEnabled !== false); // default enabled if (forestEnabled) { try { // Simple forest broad-stroke distribution: if there are multiple components, offset by component index // Build connected components over family graph based on parent/child relations const adj = new Map(); state.groups.forEach(g => adj.set(g.id, [])); state.relations.forEach(rel => { const a = rel.attrs?.parentFamilyId; const b = rel.attrs?.childFamilyId; if (a && b && adj.has(a) && adj.has(b)) { adj.get(a).push(b); adj.get(b).push(a); } }); const compOf = new Map(); let compIndex = 0; state.groups.forEach(g => { const gid = g.id; if (compOf.has(gid)) return; const q = [gid]; compOf.set(gid, compIndex); while (q.length) { const cur = q.shift(); for (const nb of adj.get(cur) || []) { if (!compOf.has(nb)) { compOf.set(nb, compIndex); q.push(nb); } } } compIndex++; }); const totalComps = compIndex; if (totalComps > 1) { const COMPONENT_GAP = 1000; nodes.forEach(n => { const cid = n.groupId; if (!cid) return; const comp = compOf.get(cid); if (typeof comp === 'number' && comp > 0) { n.position.x += comp * COMPONENT_GAP; } }); edges.forEach(e => { const fromNode = nodes.find(n => n.id === e.fromId); const toNode = nodes.find(n => n.id === e.toId); const compFrom = fromNode?.groupId ? compOf.get(fromNode.groupId) : undefined; const compTo = toNode?.groupId ? compOf.get(toNode.groupId) : undefined; const comp = (typeof compFrom === 'number') ? compFrom : compTo; if (typeof comp === 'number' && comp > 0 && e.route?.points) { const offset = comp * COMPONENT_GAP; e.route.points.forEach(p => { p.x += offset; }); } }); pivots.forEach(p => { if (p.sourceGroupId) { const comp = compOf.get(p.sourceGroupId); if (typeof comp === 'number' && comp > 0) { p.pivot.x += comp * COMPONENT_GAP; } } }); labels.forEach(lbl => { const nodeId = lbl.sourceId; const n = nodes.find(nn => nn.id === nodeId); if (n && n.groupId) { const comp = compOf.get(n.groupId); if (typeof comp === 'number' && comp > 0) { lbl.anchor.x += comp * COMPONENT_GAP; } } }); } } catch (e) { // ignore forest layout errors, keep existing behavior } } // Forest connectivity: rely on hub routing and component separation; avoid explicit bridge edges to keep graph clean // Forest layout: distribute independent components to keep graph compact try { const adj = new Map(); state.groups.forEach(g => adj.set(g.id, [])); state.relations.forEach(rel => { const a = rel.attrs?.parentFamilyId; const b = rel.attrs?.childFamilyId; if (a && b && adj.has(a) && adj.has(b)) { adj.get(a).push(b); adj.get(b).push(a); } }); const compOf = new Map(); let compIndex = 0; state.groups.forEach(g => { const gid = g.id; if (compOf.has(gid)) return; const q = [gid]; compOf.set(gid, compIndex); while (q.length) { const cur = q.shift(); for (const nb of adj.get(cur) || []) { if (!compOf.has(nb)) { compOf.set(nb, compIndex); q.push(nb); } } } compIndex++; }); const totalComps = compIndex; if (totalComps > 1) { const COMPONENT_GAP = 1000; // Offset nodes by component index*gap nodes.forEach(n => { const cid = n.groupId; if (!cid) return; const comp = compOf.get(cid); if (typeof comp === 'number' && comp > 0) { n.position.x += comp * COMPONENT_GAP; } }); // Offset edge routes edges.forEach(e => { const fromNode = nodes.find(n => n.id === e.fromId); const toNode = nodes.find(n => n.id === e.toId); const compFrom = fromNode?.groupId ? compOf.get(fromNode.groupId) : undefined; const compTo = toNode?.groupId ? compOf.get(toNode.groupId) : undefined; const comp = (typeof compFrom === 'number') ? compFrom : compTo; if (typeof comp === 'number' && comp > 0 && e.route?.points) { const offset = comp * COMPONENT_GAP; e.route.points.forEach(p => { p.x += offset; }); } }); // Offset pivots pivots.forEach(p => { if (p.sourceGroupId) { const comp = compOf.get(p.sourceGroupId); if (typeof comp === 'number' && comp > 0) { p.pivot.x += comp * 1000; } } }); // Offset labels anchors labels.forEach(lbl => { const nodeId = lbl.sourceId; const n = nodes.find(nn => nn.id === nodeId); if (n && n.groupId) { const comp = compOf.get(n.groupId); if (typeof comp === 'number' && comp > 0) { lbl.anchor.x += comp * 1000; } } }); } } catch (e) { // ignore forest layout errors, keep existing behavior } // Step: Add backbones between forest roots to ensure a sequential connected graph try { // Determine root families (no parent) that participate in forest const rootFamiliesForBackbone = state.groups.filter(g => { const hubPresent = g.attrs?.husbandId || g.attrs?.wifeId; return hubPresent && !familyToParent.has(g.id); }); const hubIds = rootFamiliesForBackbone.map(g => `hub-${g.id}`).filter(id => nodes.find(n => n.id === id)); const hubNodes = hubIds.map(id => nodes.find(n => n.id === id)).filter(n => Boolean(n)); for (let i = 0; i < hubNodes.length - 1; i++) { const a = hubNodes[i]; const b = hubNodes[i + 1]; const id = `edge-forest-backbone-${i}`; // avoid duplicates if (edges.find(e => e.id === id)) continue; const mid = { x: (a.position.x + b.position.x) / 2, y: (a.position.y + b.position.y) / 2, z: (a.position.z + b.position.z) / 2 }; edges.push({ id, role: 'forest-backbone', fromId: a.id, toId: b.id, route: { points: [ { x: a.position.x, y: a.position.y, z: a.position.z }, mid, { x: b.position.x, y: b.position.y, z: b.position.z } ] }, visible: true }); } } catch (e) { // ignore backbone creation issues to avoid breaking layout } // Resolve node collisions using minNodeSeparation if (minNodeSeparation > 0) { const personNodes = nodes.filter(n => n.role === 'person'); let collisionResolved = true; let iterations = 0; const maxIterations = 10; while (collisionResolved && iterations < maxIterations) { collisionResolved = false; iterations++; for (let i = 0; i < personNodes.length; i++) { for (let j = i + 1; j < personNodes.length; j++) { const n1 = personNodes[i]; const n2 = personNodes[j]; const dx = n2.position.x - n1.position.x; const dy = n2.position.y - n1.position.y; const dz = n2.position.z - n1.position.z; const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); if (dist < minNodeSeparation && dist > 0) { const overlap = (minNodeSeparation - dist) / 2; const nx = dx / dist; const ny = dy / dist; const nz = dz / dist; n1.position.x -= nx * overlap; n1.position.y -= ny * overlap; n1.position.z -= nz * overlap; n2.position.x += nx * overlap; n2.position.y += ny * overlap; n2.position.z += nz * overlap; collisionResolved = true; } } } } // Update bounds after collision resolution personNodes.forEach(n => { updateBounds(n.position.x, n.position.y, n.position.z); }); } // Forest-aware layout: detect disconnected components and offset them horizontally const familyIds = Array.from(familyMap.keys()); const adj = new Map(); familyIds.forEach(id => adj.set(id, [])); state.relations.forEach(rel => { const a = rel.attrs?.parentFamilyId; const b = rel.attrs?.childFamilyId; if (a && b && adj.has(a) && adj.has(b)) { adj.get(a).push(b); adj.get(b).push(a); } }); const visitedComp = new Set(); const components = []; familyIds.forEach(id => { if (visitedComp.has(id)) return; const comp = []; const stack = [id]; visitedComp.add(id); while (stack.length) { const cur = stack.pop(); comp.push(cur); const neighbors = adj.get(cur) || []; neighbors.forEach(n => { if (!visitedComp.has(n)) { visitedComp.add(n); stack.push(n); } }); } components.push(comp); }); if (components.length > 1) { const COMPONENT_GAP = 900; components.forEach((comp, idx) => { if (idx === 0) return; const offset = idx * COMPONENT_GAP; const compSet = new Set(comp); nodes.forEach(n => { if (n.groupId && compSet.has(n.groupId)) { n.position.x += offset; } }); edges.forEach(e => { if (e.route?.points) { e.route.points.forEach(p => { p.x += offset; }); } }); pivots.forEach(p => { if (p.sourceGroupId && compSet.has(p.sourceGroupId)) { if (p.pivot?.x !== undefined) p.pivot.x += offset; } }); labels.forEach(lbl => { const personId = lbl.sourceId; const belongs = Array.from(compSet).some(fid => { const fam = familyMap.get(fid); const attrs = fam?.attrs || {}; return attrs.husbandId === personId || attrs.wifeId === personId || (attrs.childrenIds || []).includes(personId); }); if (belongs && lbl.anchor?.x !== undefined) { lbl.anchor.x += offset; } }); }); let recomputeMinX = Infinity, recomputeMaxX = -Infinity, recomputeMinY = Infinity, recomputeMaxY = -Infinity, recomputeMinZ = Infinity, recomputeMaxZ = -Infinity; nodes.forEach(n => { const p = n.position; recomputeMinX = Math.min(recomputeMinX, p.x); recomputeMaxX = Math.max(recomputeMaxX, p.x); recomputeMinY = Math.min(recomputeMinY, p.y); recomputeMaxY = Math.max(recomputeMaxY, p.y); recomputeMinZ = Math.min(recomputeMinZ, p.z); recomputeMaxZ = Math.max(recomputeMaxZ, p.z); }); if (Number.isFinite(recomputeMinX)) { allMinX = recomputeMinX; allMaxX = recomputeMaxX; } if (Number.isFinite(recomputeMinY)) { allMinY = recomputeMinY; allMaxY = recomputeMaxY; } if (Number.isFinite(recomputeMinZ)) { allMinZ = recomputeMinZ; allMaxZ = recomputeMaxZ; } console.log('[Forest] Detected components:', components.length, components); } const allEdgeKeys = new Set(); edges.forEach(edge => { const key = `${edge.fromId}->${edge.toId}:${edge.role}`; if (allEdgeKeys.has(key)) { return; } allEdgeKeys.add(key); }); const nodeById = new Map(nodes.map(node => [node.id, node])); edges.forEach((edge) => { const fromNode = nodeById.get(edge.fromId); const toNode = nodeById.get(edge.toId); if (!edge.route?.points || !fromNode || !toNode) return; edge.route.points[0] = { ...fromNode.position }; edge.route.points[edge.route.points.length - 1] = { ...toNode.position }; }); return { mode, layout, modelProfile: state.config?.modelProfile || 'genealogy', nodes, edges, labels: [], pivots, hitAreas: [], bounds: { min: { x: allMinX - 50, y: allMinY - 50, z: allMinZ - 50 }, max: { x: allMaxX + 50, y: allMaxY + 50, z: allMaxZ + 50 } } }; } routeEdge(hubX, hubY, hubZ, childX, childY, childZ, familyY, gap, laneOffset = 0) { const clearance = gap * 0.25; const occupiedYs = new Set(familyY.values()); const upwardFloor = hubY + clearance; const buildPath = (viaY) => { // Child MUST be above parent: Y > hubY // Edge MUST approach from below: Y < childY if (childY <= hubY) { console.warn('buildPath: childY <= hubY', { hubY, childY }); } const approachY = Math.min(viaY, childY - 0.1); const zDiffers = Math.abs(childZ - hubZ) > 1e-6; if (zDiffers) { // 3D case: need Z transition if (Math.abs(hubX - childX) < 1e-6) { // X-aligned: use 3-segment path with final vertical approach return [ { x: hubX, y: hubY, z: hubZ }, { x: hubX, y: viaY, z: hubZ }, { x: hubX, y: viaY, z: childZ }, { x: childX, y: approachY, z: childZ }, { x: childX, y: childY, z: childZ } ]; } // Standard 4-segment with final vertical segment return [ { x: hubX, y: hubY, z: hubZ }, { x: hubX, y: viaY, z: hubZ }, { x: childX, y: viaY, z: childZ }, { x: childX, y: approachY, z: childZ }, { x: childX, y: childY, z: childZ } ]; } // 2D case (Z aligned) if (Math.abs(hubX - childX) < 1e-6) { // X-aligned: final vertical move return [ { x: hubX, y: hubY, z: hubZ }, { x: hubX, y: viaY, z: hubZ }, { x: childX, y: childY, z: hubZ } ]; } // Final segment MUST be vertical up if (Math.abs(viaY - hubY) < 1e-6) { return [ { x: hubX, y: hubY, z: hubZ }, { x: childX, y: hubY, z: hubZ }, { x: childX, y: childY, z: hubZ } ]; } // Standard 4-segment with vertical final return [ { x: hubX, y: hubY, z: hubZ }, { x: hubX, y: viaY, z: hubZ }, { x: childX, y: viaY, z: hubZ }, { x: childX, y: childY, z: hubZ } ]; }; // Heuristic: if child is above and roughly aligned, route directly up const directY = Math.max(upwardFloor, childY - 1); if (childY > hubY && Math.abs(hubX - childX) < gap * 0.5) { if (!this.checkEdgeIntersection(directY, hubX, childX, occupiedYs, clearance)) { return buildPath(directY); } } // Try mid Y const midY = Math.max(upwardFloor, (hubY + childY) / 2 + laneOffset); if (!this.checkEdgeIntersection(midY, hubX, childX, occupiedYs, clearance)) { return buildPath(midY); } // Try small offsets const offsets = [clearance, -clearance, clearance * 2, -clearance * 2]; for (const off of offsets) { const safeY = Math.max(upwardFloor, hubY + off + laneOffset); if (!this.checkEdgeIntersection(safeY, hubX, childX, occupiedYs, clearance)) { return buildPath(safeY); } } // Last resort let safeY = Math.max(upwardFloor, hubY + clearance * 4 + laneOffset); for (let attempt = 0; attempt < 8; attempt++) { if (!this.checkEdgeIntersection(safeY, hubX, childX, occupiedYs, clearance)) break; safeY += clearance; } return buildPath(safeY); } checkEdgeIntersection(testY, fromX, toX, occupiedYs, clearance) { for (const occY of occupiedYs) { if (Math.abs(testY - occY) < clearance) { // Check if the X range overlaps const minX = Math.min(fromX, toX) - clearance; const maxX = Math.max(fromX, toX) + clearance; // If edge spans across this Y level's families, it's an intersection if (minX < maxX) return true; } } return false; } estimateFamilyWidth(family, familyMap, entityMap, coupleGap) { const attrs = family.attrs || {}; const childrenIds = attrs.childrenIds || []; const spouseCount = (attrs.husbandId ? 1 : 0) + (attrs.wifeId ? 1 : 0); const baseWidth = Math.max(spouseCount * coupleGap / 2 + coupleGap / 2, coupleGap); if (childrenIds.length === 0) return baseWidth; let totalChildWidth = 0; childrenIds.forEach(childId => { const childFamily = [...familyMap.values()].find(g => { const ga = g.attrs || {}; return ga.husbandId === childId || ga.wifeId === childId; }); if (childFamily) { totalChildWidth += this.estimateFamilyWidth(childFamily, familyMap, entityMap, coupleGap); } else { totalChildWidth += coupleGap; } }); return Math.max(baseWidth, totalChildWidth); } calculateSubtreeWidth(family, familyMap, minNodeWidth, rotationFactor = 1.0) { if (!family) return minNodeWidth; const attrs = family.attrs || {}; const childrenIds = attrs.childrenIds || []; const turned = attrs.turned || false; const effectiveFactor = turned ? rotationFactor : 1.0; if (childrenIds.length === 0) { const spouseCount = (attrs.husbandId ? 1 : 0) + (attrs.wifeId ? 1 : 0); const baseWidth = Math.max(spouseCount * minNodeWidth / 2 + minNodeWidth / 2, minNodeWidth); return baseWidth * effectiveFactor; } const childToFamily = new Map(); [...familyMap.values()].forEach(g => { const ga = g.attrs || {}; if (ga.husbandId) childToFamily.set(ga.husbandId, g.id); if (ga.wifeId) childToFamily.set(ga.wifeId, g.id); }); const minSubtreeGap = minNodeWidth * 0.7; let totalWidth = 0; childrenIds.forEach((childId, idx) => { const childFamilyId = childToFamily.get(childId); if (childFamilyId) { const childFamily = familyMap.get(childFamilyId); totalWidth += this.calculateSubtreeWidth(childFamily, familyMap, minNodeWidth, rotationFactor); } else { totalWidth += minNodeWidth; } if (idx < childrenIds.length - 1) { totalWidth += minSubtreeGap; } }); const spouseCount = (attrs.husbandId ? 1 : 0) + (attrs.wifeId ? 1 : 0); const baseWidth = Math.max(spouseCount * minNodeWidth / 2 + minNodeWidth / 2, minNodeWidth); return Math.max(baseWidth, totalWidth) * effectiveFactor; } computeRadial(state, mode, layout, params) { const nodes = []; const nodesMap = new Map(); const edges = []; const pivots = []; const baseRadius = 200; const genHeightStep = 60; const spouseOffset = 50; const childFamilyOffset = 40; const rootFamilies = state.groups .filter(g => !state.relations.some(r => r.attrs.childFamilyId === g.id)) .sort((a, b) => a.id.localeCompare(b.id)); if (rootFamilies.length === 0) { return this.computeVertical(state, mode, layout, params); } const familyMap = new Map(state.groups.map(g => [g.id, g])); const childToFamily = new Map(); state.relations.forEach(rel => { if (rel.attrs && rel.attrs.childPersonId && rel.attrs.childFamilyId) { childToFamily.set(rel.attrs.childPersonId, rel.attrs.childFamilyId); } }); const visitedFamilies = new Set(); const visitedEntities = new Set(); const processFamily = (familyId, depth, startAngle, angleSpan, hubOffset = { x: 0, z: 0 }) => { if (visitedFamilies.has(familyId) || depth > 10) return; visitedFamilies.add(familyId); const family = familyMap.get(familyId); if (!family) return; const attrs = family.attrs || {}; const childrenIds = attrs.childrenIds || []; const radius = depth * baseRadius; const y = -depth * genHeightStep; const centerAngle = startAngle + angleSpan / 2; const hubX = radius * Math.cos(centerAngle) + (hubOffset?.x || 0); const hubZ = radius * Math.sin(centerAngle) + (hubOffset?.z || 0); const hubId = `hub-${family.id}`; const nodeData = { id: hubId, role: 'family_hub', groupId: family.id, position: { x: hubX, y, z: hubZ }, rotationY: 0, visible: true, selectable: true }; nodes.push(nodeData); nodesMap.set(hubId, nodeData); const currentRadius = depth * baseRadius; const radialAngle = currentRadius > 0 ? Math.atan2(hubZ, hubX) : centerAngle; const radialPerpAngle = radialAngle + Math.PI / 2; const perpX = spouseOffset * Math.cos(radialPerpAngle); const perpZ = spouseOffset * Math.sin(radialPerpAngle); if (attrs.husbandId) { const husbandNode = nodes.find(n => n.id === attrs.husbandId); if (husbandNode) { husbandNode.position = { x: hubX - perpX, y, z: hubZ - perpZ }; } else if (!visitedEntities.has(attrs.husbandId)) { visitedEntities.add(attrs.husbandId); nodes.push({ id: attrs.husbandId, role: 'person', groupId: family.id, position: { x: hubX - perpX, y, z: hubZ - perpZ }, rotationY: 0, visible: true, selectable: true }); } } if (attrs.wifeId) { const wifeNode = nodes.find(n => n.id === attrs.wifeId); if (wifeNode) { wifeNode.position = { x: hubX + perpX, y, z: hubZ + perpZ }; } else if (!visitedEntities.has(attrs.wifeId)) { visitedEntities.add(attrs.wifeId); nodes.push({ id: attrs.wifeId, role: 'person', groupId: family.id, position: { x: hubX + perpX, y, z: hubZ + perpZ }, rotationY: 0, visible: true, selectable: true }); } } pivots.push({ id: family.id, sourceGroupId: family.id, pivot: { x: hubX, y, z: hubZ }, rotationY: 0 }); if (attrs.husbandId) { const husbandNode = nodes.find(n => n.id === attrs.husbandId); const husbandPos = husbandNode ? husbandNode.position : { x: hubX - perpX, y, z: hubZ - perpZ }; if (!edges.find(e => e.fromId === hubId && e.toId === attrs.husbandId)) { edges.push({ id: `edge-${family.id}-${hubId}-${attrs.husbandId}`, role: 'spouse', fromId: hubId, toId: attrs.husbandId, route: { points: [ { x: hubX, y, z: hubZ }, { x: husbandPos.x, y: husbandPos.y, z: husbandPos.z } ] }, visible: true }); } } if (attrs.wifeId) { const wifeNode = nodes.find(n => n.id === attrs.wifeId); const wifePos = wifeNode ? wifeNode.position : { x: hubX + perpX, y, z: hubZ + perpZ }; if (!edges.find(e => e.fromId === hubId && e.toId === attrs.wifeId)) { edges.push({ id: `edge-${family.id}-${hubId}-${attrs.wifeId}`, role: 'spouse', fromId: hubId, toId: attrs.wifeId, route: { points: [ { x: hubX, y, z: hubZ }, { x: wifePos.x, y: wifePos.y, z: wifePos.z } ] }, visible: true }); } } if (childrenIds.length > 0) { const anglePerChild = angleSpan / childrenIds.length; childrenIds.forEach((childId, idx) => { const childAngle = startAngle + idx * anglePerChild + anglePerChild / 2; const childRadius = (depth + 1) * baseRadius; const childY = -(depth + 1) * genHeightStep; const childX = childRadius * Math.cos(childAngle); const childZ = childRadius * Math.sin(childAngle); const childFamilyId = childToFamily.get(childId); if (childFamilyId) { const childHubOffset = { x: childFamilyOffset * Math.cos(childAngle), z: childFamilyOffset * Math.sin(childAngle) }; // Build (and position) the child's family before we route the parent edge, // so that if the child is a spouse in that family we connect to the final position. processFamily(childFamilyId, depth + 1, startAngle + idx * anglePerChild, anglePerChild, childHubOffset); } const childPersonNode = nodes.find(n => n.id === childId); const childPersonPos = childPersonNode ? childPersonNode.position : { x: childX, y: childY, z: childZ }; if (!visitedEntities.has(childId)) { visitedEntities.add(childId); nodes.push({ id: childId, role: 'person', groupId: family.id, position: { x: childX, y: childY, z: childZ }, rotationY: 0, visible: true, selectable: true }); } if (!edges.find(e => e.fromId === hubId && e.toId === childId)) { edges.push({ id: `edge-${family.id}-${hubId}-${childId}`, role: 'parent-child', fromId: hubId, toId: childId, route: { points: [ { x: hubX, y, z: hubZ }, { x: childPersonPos.x, y: childPersonPos.y, z: childPersonPos.z } ] }, visible: true }); } }); } }; const rootAngleSpan = (Math.PI * 2) / rootFamilies.length; const rootDistributionRadius = baseRadius * 0.75; rootFamilies.forEach((rootFamily, idx) => { const startAngle = idx * rootAngleSpan; const rootCenterAngle = startAngle + rootAngleSpan / 2; const rootHubOffset = { x: rootDistributionRadius * Math.cos(rootCenterAngle), z: rootDistributionRadius * Math.sin(rootCenterAngle) }; processFamily(rootFamily.id, 0, startAngle, rootAngleSpan, rootHubOffset); }); const allPositions = nodes.map(n => n.position); const bounds = { min: { x: Math.min(...allPositions.map(p => p.x)) - 80, y: Math.min(...allPositions.map(p => p.y)) - 80, z: Math.min(...allPositions.map(p => p.z)) - 80 }, max: { x: Math.max(...allPositions.map(p => p.x)) + 80, y: Math.max(...allPositions.map(p => p.y)) + 80, z: Math.max(...allPositions.map(p => p.z)) + 80 } }; return { mode, layout, modelProfile: state.config?.modelProfile || 'genealogy', nodes, edges, labels: [], pivots, hitAreas: [], bounds }; } }