/
nek5132
/
Genius
Обзор
Документация
Войти
/
nek5132
/
Genius
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/components/TreeGraph.js
858 строк
26 KB
Peter Kosov
fix(rotation): stabilize safe branch turning and hub routing
15 апр 2026, 06:33
15 апр 2026, 06:33
1afa792
Код
Авторство
О чём код?
import * as THREE from 'three'; import { LayoutEngine } from '../core/LayoutEngine.js'; import { normalizeGenealogyState, parseGenealogyDSL, serializeGenealogyDSL } from '../core/GraphState.js'; import { computeBranchRotationSafety, sanitizeBranchRotationState } from '../core/branchRotation.js'; import { ThreeAdapter } from '../render/ThreeAdapter.js'; export class TreeGraph extends HTMLElement { static get observedAttributes() { return ['model-profile', 'mode', 'layout', 'theme']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (!this._adapter) return; switch (name) { case 'model-profile': this._profile = newValue; if (this._state) this.setState(this._state); break; case 'mode': this._mode = newValue; if (this._state) this.setState(this._state); break; case 'layout': this._layout = newValue; if (this._state) this.setState(this._state); break; case 'theme': this.setTheme(newValue); break; } } constructor() { super(); this._state = null; this._layoutEngine = new LayoutEngine(); this._adapter = null; this._mode = '3d'; this._layout = 'vertical-3d'; this._profile = 'genealogy'; this._selectedEntityId = null; this._undoStack = []; this._redoStack = []; this._maxUndoDepth = 50; this._onBeforeMutate = null; this._onAfterMutate = null; } setMutationHooks(beforeFn, afterFn) { this._onBeforeMutate = typeof beforeFn === 'function' ? beforeFn : null; this._onAfterMutate = typeof afterFn === 'function' ? afterFn : null; } connectedCallback() { this.style.display = 'block'; this.style.width = '100%'; this.style.height = '100%'; this.style.position = 'relative'; this._adapter = new ThreeAdapter(this); this._adapter.init(); this._adapter.onClick((event) => this._handleClick(event)); } disconnectedCallback() { if (this._adapter) { this._adapter.dispose(); } } _handleClick(event) { if (event.type === 'hub') { this.toggleBranch(event.id); } this._selectedEntityId = event.id; const entity = this._state?.entities?.find(e => e.id === event.id) || this._state?.groups?.find(g => g.id === event.id); let ancestorMaleIds = [], ancestorFemaleIds = [], descendantIds = []; let ancestorHubIds = [], descendantHubIds = [], ancestorEdgeIds = [], descendantEdgeIds = []; if (entity && entity.kind === 'person' && this._state) { const result = this._computeGenealogy(event.id); ancestorMaleIds = result.ancestorMaleIds; ancestorFemaleIds = result.ancestorFemaleIds; descendantIds = result.descendantIds; ancestorHubIds = result.ancestorHubIds; descendantHubIds = result.descendantHubIds; ancestorEdgeIds = result.ancestorEdgeIds; descendantEdgeIds = result.descendantEdgeIds; } const detail = { id: event.id, type: event.type, role: event.role, entity: entity || null, ancestorMaleIds, ancestorFemaleIds, descendantIds, ancestorHubIds, descendantHubIds, ancestorEdgeIds, descendantEdgeIds, }; this.dispatchEvent(new CustomEvent('entity-selected', { detail })); } _computeGenealogy(personId) { const ancestorMaleIds = []; const ancestorFemaleIds = []; const descendantIds = []; const ancestorHubIds = []; const descendantHubIds = []; const ancestorEdgeIds = []; const descendantEdgeIds = []; const state = this._state; if (!state) return { ancestorMaleIds, ancestorFemaleIds, descendantIds, ancestorHubIds, descendantHubIds, ancestorEdgeIds, descendantEdgeIds }; const familyMap = new Map(state.groups.map(g => [g.id, g])); const personToFamily = new Map(); state.groups.forEach(g => { if (g.attrs.husbandId) personToFamily.set(g.attrs.husbandId, g.id); if (g.attrs.wifeId) personToFamily.set(g.attrs.wifeId, g.id); }); const childToParentFamily = new Map(); state.relations.forEach(rel => { if (rel.attrs?.childPersonId && rel.attrs?.parentFamilyId) { childToParentFamily.set(rel.attrs.childPersonId, rel.attrs.parentFamilyId); } }); const visitedAncestors = new Set(); const collectAncestors = (pid) => { if (visitedAncestors.has(pid)) return; visitedAncestors.add(pid); const parentFamilyId = childToParentFamily.get(pid); if (!parentFamilyId) return; const parentFamily = familyMap.get(parentFamilyId); if (!parentFamily) return; ancestorHubIds.push(parentFamilyId); ancestorEdgeIds.push(`edge-${parentFamilyId}-hub-${pid}`); if (parentFamily.attrs.husbandId && parentFamily.attrs.husbandId !== pid) { ancestorMaleIds.push(parentFamily.attrs.husbandId); ancestorEdgeIds.push(`edge-${parentFamily.id}-hub-${parentFamily.attrs.husbandId}`); collectAncestors(parentFamily.attrs.husbandId); } if (parentFamily.attrs.wifeId && parentFamily.attrs.wifeId !== pid) { ancestorFemaleIds.push(parentFamily.attrs.wifeId); ancestorEdgeIds.push(`edge-${parentFamily.id}-hub-${parentFamily.attrs.wifeId}`); collectAncestors(parentFamily.attrs.wifeId); } }; collectAncestors(personId); const visitedDescendants = new Set(); const collectDescendants = (familyId) => { if (visitedDescendants.has(familyId)) return; visitedDescendants.add(familyId); const family = familyMap.get(familyId); if (!family) return; (family.attrs.childrenIds || []).forEach(childId => { descendantIds.push(childId); descendantEdgeIds.push(`edge-${familyId}-hub-${childId}`); const childOwnFamilyId = personToFamily.get(childId); if (childOwnFamilyId) { descendantHubIds.push(childOwnFamilyId); collectDescendants(childOwnFamilyId); } }); }; const personFamilyId = personToFamily.get(personId); if (personFamilyId) { descendantHubIds.push(personFamilyId); collectDescendants(personFamilyId); } return { ancestorMaleIds, ancestorFemaleIds, descendantIds, ancestorHubIds, descendantHubIds, ancestorEdgeIds, descendantEdgeIds }; } _saveToUndo() { const stateCopy = JSON.parse(JSON.stringify(this._state)); this._undoStack.push(stateCopy); if (this._undoStack.length > this._maxUndoDepth) { this._undoStack.shift(); } this._redoStack = []; } setState(state, preserveCamera = false) { const needsNormalization = state?.config?.modelProfile === 'genealogy'; const normalizedState = needsNormalization ? normalizeGenealogyState(state) : state; if (this._onBeforeMutate) { const result = this._onBeforeMutate(normalizedState); if (result === false) return; } this._state = sanitizeBranchRotationState(normalizedState); if (normalizedState?.config?.mode === '2d' && normalizedState?.groups) { normalizedState.groups.forEach(group => { if (group?.attrs) group.attrs.turned = false; }); if (this._adapter && this._adapter.resetAllBranches) { this._adapter.resetAllBranches(); } } const layoutScene = this._layoutEngine.compute(normalizedState, this._mode, this._layout); if (this._adapter) { this._adapter.render(layoutScene, preserveCamera); const theme = normalizedState.config?.theme || 'light'; if (this._adapter.applyTheme) { this._adapter.applyTheme(theme); } } this.dispatchEvent(new CustomEvent('state-changed', { detail: { state: normalizedState } })); if (this._onAfterMutate) { this._onAfterMutate(normalizedState); } } getState() { return this._state; } setMode(mode) { this._mode = mode; if (mode === '2d' && this._state?.groups?.length) { this._state.groups.forEach(group => { if (group?.attrs) group.attrs.turned = false; }); } if (this._state) { this.setState(this._state); } if (this._adapter && this._adapter.setCameraMode) { this._adapter.setCameraMode(mode); } } setLayout(layout) { this._layout = layout; if (this._state) { this.setState(this._state); } } setModelProfile(profile) { this._profile = profile; if (this._state) { this.setState(this._state); } } getMode() { return this._mode; } getLayout() { return this._layout; } getModelProfile() { return this._profile; } setTheme(theme) { if (!this._state) return; const validThemes = ['light', 'dark', 'colorblind']; if (!validThemes.includes(theme)) { console.warn('Invalid theme:', theme, '- valid themes:', validThemes); return; } this._state.config.theme = theme; if (this._adapter && this._adapter.applyTheme) { this._adapter.applyTheme(theme); } } setPhotosEnabled(enabled, photoUrls = {}) { if (this._adapter && this._adapter.setPhotosEnabled) { this._adapter.setPhotosEnabled(enabled, photoUrls); if (this._state) { this.setState(this._state, true); } } } toggleBranch(familyId) { if (!this._state?.groups?.length) return false; const family = this._state.groups.find((group) => group.id === familyId); if (!family?.attrs) return false; const childFamilyIds = (this._state.relations || []) .filter((relation) => relation.attrs?.parentFamilyId === familyId) .map((relation) => relation.attrs?.childFamilyId) .filter(Boolean); if (childFamilyIds.length > 0) { const safety = computeBranchRotationSafety(this._state); const blockedChildFamilyIds = childFamilyIds.filter((childFamilyId) => !safety.isRotatableBranch(familyId, childFamilyId)); if (blockedChildFamilyIds.length > 0) { this.dispatchEvent(new CustomEvent('branch-rotation-blocked', { detail: { familyId, reason: `Rotation blocked: branch would detach ${blockedChildFamilyIds.join(', ')}`, blockedChildFamilyIds } })); return false; } } this._saveToUndo(); family.attrs.turned = !family.attrs.turned; this.setState(this._state, true); return true; } resetBranch(familyId) { if (!this._state?.groups?.length) return false; const family = this._state.groups.find((group) => group.id === familyId); if (!family?.attrs || !family.attrs.turned) return false; this._saveToUndo(); family.attrs.turned = false; this.setState(this._state, true); return true; } resetAllBranches() { if (!this._state?.groups?.length) return false; let changed = false; const familiesToReset = []; this._state.groups.forEach((group) => { if (group?.attrs?.turned) { familiesToReset.push(group); changed = true; } }); if (!changed) return false; this._saveToUndo(); familiesToReset.forEach((group) => { group.attrs.turned = false; }); this.setState(this._state, true); return true; } getRotationProfiles() { return new Map(); } getRotationDebugState() { if (!this._state?.groups) return null; return { turnedFamilyIds: this._state.groups .filter((group) => group?.attrs?.turned) .map((group) => group.id) }; } getSceneDebugSnapshot() { return this._adapter?.getSceneDebugSnapshot?.() || null; } selectEntity(id) { this._selectedEntityId = id; const entity = this._state?.entities?.find(e => e.id === id) || this._state?.groups?.find(g => g.id === id); this.dispatchEvent(new CustomEvent('entity-selected', { detail: { id, type: entity?.kind || 'unknown', entity } })); } clearSelection() { this._selectedEntityId = null; this.dispatchEvent(new CustomEvent('selection-cleared', { detail: {} })); } toggleDebugOverlay(enabled) { if (enabled === undefined) { this._debugOverlayEnabled = !this._debugOverlayEnabled; } else { this._debugOverlayEnabled = enabled; } if (this._adapter && this._adapter.toggleDebugOverlay) { this._adapter.toggleDebugOverlay(this._debugOverlayEnabled); } return this._debugOverlayEnabled; } fit() { if (this._adapter && this._adapter.fit) { this._adapter.fit(); } } resetView() { if (this._adapter && this._adapter.resetView) { this._adapter.resetView(); } } serialize() { if (this._state) { return JSON.stringify(this._state, null, 2); } return ''; } exportLayout() { if (!this._state || !this._layoutEngine) return null; return this._layoutEngine.compute(this._state, this._mode, this._layout); } exportLayoutJSON() { const layout = this.exportLayout(); if (!layout) return ''; return JSON.stringify(layout, null, 2); } serializeGenealogyDSL() { if (!this._state) return ''; return serializeGenealogyDSL(this._state); } restoreGenealogyDSL(serialized) { try { const state = parseGenealogyDSL(serialized); this.setState(state); return true; } catch (err) { console.error('Restore DSL failed:', err); return false; } } restore(serialized) { try { const state = JSON.parse(serialized); this.setState(state); return true; } catch (err) { console.error('Restore failed:', err); return false; } } undo() { if (this._undoStack.length === 0) return null; const currentState = JSON.parse(JSON.stringify(this._state)); this._redoStack.push(currentState); const previousState = this._undoStack.pop(); this.setState(previousState); return previousState; } redo() { if (this._redoStack.length === 0) return null; const currentState = JSON.parse(JSON.stringify(this._state)); this._undoStack.push(currentState); const nextState = this._redoStack.pop(); this.setState(nextState); return nextState; } canUndo() { return this._undoStack.length > 0; } canRedo() { return this._redoStack.length > 0; } editEntity(id, changes) { this._saveToUndo(); const entity = this._state.entities.find(e => e.id === id); if (entity) { Object.assign(entity.attrs, changes); this.setState(this._state); return true; } return false; } addLeafChild(familyId, personData) { this._saveToUndo(); const family = this._state.groups.find(g => g.id === familyId); if (!family) return null; const childId = `p${Date.now()}`; const newPerson = { id: childId, kind: 'person', attrs: { gender: personData.gender || 'male', firstName: personData.firstName || 'New', lastName: personData.lastName || '', birthYear: personData.birthYear || null } }; const childFamilyId = `f${Date.now()}`; const newChildFamily = { id: childFamilyId, kind: 'family', memberIds: [childId], attrs: { husbandId: personData.gender === 'male' || !personData.gender ? childId : null, wifeId: personData.gender === 'female' ? childId : null, childrenIds: [], turned: false, compact: false } }; this._state.entities.push(newPerson); this._state.groups.push(newChildFamily); family.attrs.childrenIds.push(childId); const relationId = `r${Date.now()}`; this._state.relations.push({ id: relationId, kind: 'parent-child-family-link', fromId: familyId, toId: childFamilyId, attrs: { parentFamilyId: familyId, childPersonId: childId, childFamilyId: childFamilyId, roleInChildFamily: personData.gender === 'female' ? 'wife' : 'husband' } }); this.setState(this._state); return { person: newPerson, family: newChildFamily }; } addSpouse(personId, spouseData) { this._saveToUndo(); const person = this._state.entities.find(e => e.id === personId); if (!person) return null; const spouseId = `p${Date.now()}`; const spouseGender = spouseData.gender || (person.attrs.gender === 'male' ? 'female' : 'male'); const personFamily = this._state.groups.find(g => g.attrs.husbandId === personId || g.attrs.wifeId === personId); if (!personFamily) return null; const spousePerson = { id: spouseId, kind: 'person', attrs: { gender: spouseGender, firstName: spouseData.firstName || 'Spouse', lastName: spouseData.lastName || '', birthYear: spouseData.birthYear || null } }; this._state.entities.push(spousePerson); if (spouseGender === 'male') { personFamily.attrs.husbandId = spouseId; } else { personFamily.attrs.wifeId = spouseId; } personFamily.memberIds.push(spouseId); this.setState(this._state); return { person: spousePerson }; } addParent(personId, parentData) { this._saveToUndo(); const person = this._state.entities.find(e => e.id === personId); if (!person) return null; const parentGender = parentData.gender || 'male'; const parentId = `p${Date.now() + 1}`; const otherParentId = `p${Date.now() + 2}`; const otherGender = parentGender === 'male' ? 'female' : 'male'; const newParent = { id: parentId, kind: 'person', attrs: { gender: parentGender, firstName: parentData.firstName || 'Parent', lastName: parentData.lastName || '', birthYear: parentData.birthYear || null } }; const otherParent = { id: otherParentId, kind: 'person', attrs: { gender: otherGender, firstName: parentData.otherFirstName || 'Other Parent', lastName: parentData.otherLastName || '', birthYear: parentData.otherBirthYear || null } }; const newFamilyId = `f${Date.now() + 3}`; const husbandId = parentGender === 'male' ? parentId : otherParentId; const wifeId = parentGender === 'female' ? parentId : otherParentId; const newFamily = { id: newFamilyId, kind: 'family', memberIds: [parentId, otherParentId, personId], attrs: { husbandId, wifeId, childrenIds: [personId], turned: false, compact: false } }; const existingParentLink = this._state.relations.find(r => r.attrs?.childPersonId === personId); const personFamilyId = this._state.groups.find(g => g.attrs.husbandId === personId || g.attrs.wifeId === personId)?.id; if (existingParentLink) { const oldFamilyId = existingParentLink.attrs.parentFamilyId; const oldFamily = this._state.groups.find(g => g.id === oldFamilyId); if (oldFamily) { oldFamily.attrs.childrenIds = oldFamily.attrs.childrenIds.filter(c => c !== personId); oldFamily.memberIds = oldFamily.memberIds.filter(m => m !== personId); } existingParentLink.attrs.parentFamilyId = newFamilyId; existingParentLink.attrs.fromId = newFamilyId; if (personFamilyId) { existingParentLink.attrs.childFamilyId = personFamilyId; } } else if (personFamilyId) { const relationId = `r${Date.now() + 4}`; this._state.relations.push({ id: relationId, kind: 'parent-child-family-link', fromId: newFamilyId, toId: personFamilyId, attrs: { parentFamilyId: newFamilyId, childPersonId: personId, childFamilyId: personFamilyId, roleInChildFamily: person.attrs.gender === 'male' ? 'husband' : 'wife' } }); } this._state.entities.push(newParent, otherParent); this._state.groups.push(newFamily); this.setState(this._state); return { parents: [newParent, otherParent], family: newFamily }; } addSibling(personId, siblingData) { this._saveToUndo(); const person = this._state.entities.find(e => e.id === personId); if (!person) return null; const personFamily = this._state.groups.find(g => g.attrs.husbandId === personId || g.attrs.wifeId === personId); if (!personFamily) return null; const parentLink = this._state.relations.find(r => r.attrs?.childPersonId === personId); if (!parentLink) return null; const parentFamilyId = parentLink.attrs.parentFamilyId; const parentFamily = this._state.groups.find(g => g.id === parentFamilyId); if (!parentFamily) return null; const siblingId = `p${Date.now()}`; const siblingGender = siblingData.gender || 'male'; const newSibling = { id: siblingId, kind: 'person', attrs: { gender: siblingGender, firstName: siblingData.firstName || 'Sibling', lastName: siblingData.lastName || '', birthYear: siblingData.birthYear || null } }; const siblingFamilyId = `f${Date.now() + 1}`; const siblingHusband = siblingGender === 'male' ? siblingId : null; const siblingWife = siblingGender === 'female' ? siblingId : null; const siblingFamily = { id: siblingFamilyId, kind: 'family', memberIds: [siblingId], attrs: { husbandId: siblingHusband, wifeId: siblingWife, childrenIds: [], turned: false, compact: false } }; const relationId = `r${Date.now() + 2}`; this._state.relations.push({ id: relationId, kind: 'parent-child-family-link', fromId: parentFamilyId, toId: siblingFamilyId, attrs: { parentFamilyId: parentFamilyId, childPersonId: siblingId, childFamilyId: siblingFamilyId, roleInChildFamily: siblingGender === 'male' ? 'husband' : 'wife' } }); parentFamily.attrs.childrenIds.push(siblingId); this._state.entities.push(newSibling); this._state.groups.push(siblingFamily); this.setState(this._state); return { person: newSibling, family: siblingFamily }; } hasChildren(personId) { const personFamily = this._state.groups.find(g => g.attrs.husbandId === personId || g.attrs.wifeId === personId); if (!personFamily) return false; return (personFamily.attrs.childrenIds || []).length > 0; } deletePerson(personId, confirmLevel = 0) { const person = this._state.entities.find(e => e.id === personId); if (!person) return false; const hasKids = this.hasChildren(personId); if (hasKids && confirmLevel < 2) return confirmLevel + 1; this._saveToUndo(); const personFamily = this._state.groups.find(g => g.attrs.husbandId === personId || g.attrs.wifeId === personId); if (!personFamily) { this._state.entities = this._state.entities.filter(e => e.id !== personId); this.setState(this._state); return true; } const isHusband = personFamily.attrs.husbandId === personId; const isWife = personFamily.attrs.wifeId === personId; if (isHusband) personFamily.attrs.husbandId = null; if (isWife) personFamily.attrs.wifeId = null; personFamily.memberIds = personFamily.memberIds.filter(m => m !== personId); const childrenIds = personFamily.attrs.childrenIds || []; childrenIds.forEach(childId => { const childLink = this._state.relations.find(r => r.attrs?.childPersonId === childId); if (childLink) { const childFamily = this._state.groups.find(g => g.id === childLink.attrs.childFamilyId); if (childFamily) { if (isHusband) childFamily.attrs.husbandId = null; if (isWife) childFamily.attrs.wifeId = null; childFamily.memberIds = childFamily.memberIds.filter(m => m !== childId); } } }); this._state.entities = this._state.entities.filter(e => e.id !== personId); const emptyFamilies = this._state.groups.filter(g => !g.attrs.husbandId && !g.attrs.wifeId && (g.attrs.childrenIds || []).length === 0); emptyFamilies.forEach(f => { this._state.relations = this._state.relations.filter(r => r.attrs?.parentFamilyId !== f.id && r.attrs?.childFamilyId !== f.id); }); this._state.groups = this._state.groups.filter(g => !emptyFamilies.includes(g)); this.setState(this._state); return true; } dispose() { this.disconnectedCallback(); } exportPNG(filename = 'graph.png') { if (!this._adapter || !this._adapter.renderer) return false; try { this._adapter.renderer.render(this._adapter.scene, this._adapter.camera); const dataURL = this._adapter.renderer.domElement.toDataURL('image/png'); const link = document.createElement('a'); link.download = filename; link.href = dataURL; link.click(); return true; } catch (err) { console.error('PNG export failed:', err); return false; } } exportSVG(filename = 'graph.svg') { const state = this._state; if (!state) return false; const layoutScene = this._layoutEngine.compute(state, this._mode, this._layout); const nodes = layoutScene.nodes; const edges = layoutScene.edges; let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; nodes.forEach(n => { minX = Math.min(minX, n.position.x); maxX = Math.max(maxX, n.position.x); minY = Math.min(minY, n.position.y); maxY = Math.max(maxY, n.position.y); }); const padding = 50; const width = (maxX - minX) + padding * 2; const height = (maxY - minY) + padding * 2; let svg = `<?xml version="1.0" encoding="UTF-8"?>\n`; svg += `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">\n`; svg += `<rect width="100%" height="100%" fill="white"/>\n`; const offsetX = -minX + padding; const offsetY = -minY + padding; edges.forEach(edge => { const pts = edge.points || (edge.route && edge.route.points); if (!pts) return; const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x + offsetX} ${p.y + offsetY}`).join(' '); svg += `<path d="${d}" stroke="#6C757D" stroke-width="2" fill="none"/>\n`; }); nodes.forEach(node => { const cx = node.position.x + offsetX; const cy = node.position.y + offsetY; const r = 15; svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="#4A90D9" stroke="#333" stroke-width="1"/>\n`; }); svg += `</svg>`; const blob = new Blob([svg], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.download = filename; link.href = url; link.click(); URL.revokeObjectURL(url); return true; } }