/
nek5132
/
Genius
Обзор
Документация
Войти
/
nek5132
/
Genius
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/render/ThreeAdapter.js
1 666 строк
56 KB
Peter Kosov
docs(rotation): align specs with safe branch rotation
15 апр 2026, 06:37
15 апр 2026, 06:37
dd39172
Код
Авторство
О чём код?
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { VISUAL_TOKENS, getVisualThemeTokens, VISUAL_THEME_NAMES } from '../core/visualTokens.js'; import { computeBranchRotationSafety } from '../core/branchRotation.js'; const hexToCss = (value) => `#${value.toString(16).padStart(6, '0')}`; export class ThreeAdapter { constructor(hostElement) { this.host = hostElement; this.scene = null; this.camera = null; this.renderer = null; this.controls = null; this.objects = new Map(); this.familyGroups = new Map(); this.mode = '3d'; this.layout = 'vertical-3d'; this._debugOverlayEnabled = false; this._debugObjects = []; this._raycaster = new THREE.Raycaster(); this._mouse = new THREE.Vector2(); this._currentScene = null; this._bounds = null; this._onClickCallback = null; this._onLongPressCallback = null; this._isMobile = 'ontouchstart' in window; this._rootGroup = null; this._animations = []; this._animationDuration = 300; this._preserveCameraState = false; this._savedCameraState = null; this._cameraMoving = false; this._cameraMoveTimeout = null; this._longPressTimer = null; this._gradientTextures = new Map(); this._labelElements = new Map(); this._labelStates = new Map(); this._labelsImmediate = false; } getLabelTokens() { return VISUAL_TOKENS.layout; } bindControlsEvents() { if (!this.controls) return; this.controls.addEventListener('start', () => { this._labelsImmediate = true; }); this.controls.addEventListener('end', () => { this._labelsImmediate = false; }); } shouldSkipLabelCollision() { return this.hasActivePivotAnimation(); } ensureLabelLayer() { if (this._labelLayer) return this._labelLayer; const layer = document.createElement('div'); layer.className = 'person-label-layer'; layer.style.position = 'absolute'; layer.style.inset = '0'; layer.style.pointerEvents = 'none'; layer.style.overflow = 'hidden'; layer.style.zIndex = '40'; layer.style.willChange = 'transform'; this.host.appendChild(layer); this._labelLayer = layer; return layer; } measureLabel(label) { const nameEl = label.querySelector('.person-label-name'); const yearsEl = label.querySelector('.person-label-years'); const width = Math.max(nameEl?.offsetWidth || 0, yearsEl?.offsetWidth || 0) + 18; const height = (nameEl?.offsetHeight || 0) + (yearsEl && yearsEl.textContent ? yearsEl.offsetHeight + 1 : 0) + 10; return { width, height }; } getLabelBounds(x, y, width, height, scale) { return { left: x - (width * scale) / 2, right: x + (width * scale) / 2, top: y - height * scale, bottom: y, width: width * scale, height: height * scale }; } labelsOverlap2D(a, b, gap) { return !(a.right + gap <= b.left || b.right + gap <= a.left || a.bottom + gap <= b.top || b.bottom + gap <= a.top); } pairScaleFromBounds(a, b, gap, minScale) { const xScale = (2 * (Math.abs(a.x - b.x) - gap)) / Math.max(a.width + b.width, 1); const yScale = (2 * (Math.abs(a.y - b.y) - gap)) / Math.max(a.height + b.height, 1); const scale = Math.min(xScale, yScale); return Math.max(minScale, Math.min(1, scale)); } buildCollisionGroups(labels, gap) { const parent = labels.map((_, i) => i); const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; }; const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb) parent[rb] = ra; }; for (let i = 0; i < labels.length; i += 1) { for (let j = i + 1; j < labels.length; j += 1) { if (this.labelsOverlap2D(labels[i].bounds, labels[j].bounds, gap)) { union(i, j); } } } const groups = new Map(); labels.forEach((label, index) => { const root = find(index); if (!groups.has(root)) groups.set(root, []); groups.get(root).push(label); }); return [...groups.values()]; } resolveCollisionScales(entries, gap, minScale) { const groups = this.buildCollisionGroups(entries.map((entry) => ({ ...entry, bounds: this.getLabelBounds(entry.desiredX, entry.desiredY, entry.width, entry.height, entry.scale) })), gap); groups.forEach((group) => { if (group.length <= 1) return; const scaleCandidates = []; for (let i = 0; i < group.length; i += 1) { for (let j = i + 1; j < group.length; j += 1) { scaleCandidates.push(this.pairScaleFromBounds( { x: group[i].desiredX, y: group[i].desiredY, width: group[i].width, height: group[i].height }, { x: group[j].desiredX, y: group[j].desiredY, width: group[j].width, height: group[j].height }, gap, minScale )); } } const groupScale = Math.max(minScale, Math.min(...scaleCandidates, 1)); group.forEach((entry) => { entry.scale = Math.min(entry.scale, groupScale); }); }); } syncLabelDom(entries) { entries.forEach((entry) => { const state = this.getLabelState(entry.nodeId); state.x = entry.desiredX; state.y = entry.desiredY; entry.label.style.opacity = `${entry.opacity}`; entry.label.style.fontSize = `${entry.fontSize}px`; entry.label.style.padding = `${entry.padY}px ${entry.padX}px`; entry.label.style.left = `${state.x}px`; entry.label.style.top = `${state.y}px`; entry.label.style.transform = `translate3d(-50%, -100%, 0) scale(${entry.scale})`; }); } createLabelElement(node) { const label = document.createElement('div'); label.className = 'person-label'; label.dataset.personId = node.id; label.style.position = 'absolute'; label.style.left = '0'; label.style.top = '0'; label.style.transform = 'translate3d(-50%, -100%, 0)'; label.style.transformOrigin = 'center bottom'; label.style.pointerEvents = 'none'; label.style.whiteSpace = 'normal'; label.style.minWidth = 'max-content'; label.style.textAlign = 'center'; label.style.borderRadius = '12px'; label.style.padding = '5px 9px'; label.style.fontSize = '12px'; label.style.fontWeight = '600'; label.style.letterSpacing = '0.1px'; label.style.lineHeight = '1.1'; label.style.backdropFilter = 'blur(4px)'; label.style.webkitBackdropFilter = 'blur(4px)'; label.style.background = 'rgba(255,255,255,0.72)'; label.style.border = '1px solid rgba(80,80,80,0.12)'; label.style.boxShadow = '0 1px 4px rgba(0,0,0,0.08)'; label.style.color = '#2a2a2a'; label.style.opacity = '0'; label.style.maxWidth = '220px'; label.innerHTML = '<div class="person-label-name"></div><div class="person-label-years"></div>'; this.renderLabelElement(label, node.id); return label; } getPersonLabelData(personId) { const entity = this.host._state?.entities?.find(e => e.id === personId); if (!entity) return { name: personId, years: '' }; const first = entity.attrs?.firstName || ''; const last = entity.attrs?.lastName || ''; const name = `${first} ${last}`.trim() || personId; const birthYear = entity.attrs?.birthYear ?? null; const deathYear = entity.attrs?.deathYear ?? entity.attrs?.death ?? null; let years = ''; if (birthYear !== null && birthYear !== undefined && birthYear !== '') { years = String(birthYear); if (deathYear !== null && deathYear !== undefined && deathYear !== '') { years += `-${deathYear}`; } } return { name, years }; } renderLabelElement(label, personId) { const data = this.getPersonLabelData(personId); const nameEl = label.querySelector('.person-label-name'); const yearsEl = label.querySelector('.person-label-years'); if (nameEl) nameEl.textContent = data.name; if (yearsEl) { yearsEl.textContent = data.years; yearsEl.style.fontSize = '10px'; yearsEl.style.fontWeight = '500'; yearsEl.style.opacity = data.years ? '0.85' : '0'; yearsEl.style.marginTop = data.years ? '1px' : '0'; } } getLabelState(personId) { if (!this._labelStates.has(personId)) { this._labelStates.set(personId, { x: 0, y: 0, visible: false }); } return this._labelStates.get(personId); } getPersonBoundingBox(node) { const mesh = this.objects.get(node.id); if (!mesh) return null; const box = new THREE.Box3().setFromObject(mesh); if (!box || !isFinite(box.min.x) || !isFinite(box.max.x)) return null; return box; } projectToScreen(vec3) { const rect = this.renderer?.domElement?.getBoundingClientRect?.(); if (!rect || rect.width <= 0 || rect.height <= 0) return null; const projected = vec3.clone().project(this.camera); const x = (projected.x * 0.5 + 0.5) * rect.width; const y = (-projected.y * 0.5 + 0.5) * rect.height; return { x, y, visible: projected.z >= -1 && projected.z <= 1 }; } updatePersonLabels() { const scene = this._currentScene; if (!scene || !this.camera || !this.renderer) return; const layer = this.ensureLabelLayer(); const tokens = this.getLabelTokens(); const activeIds = new Set(); const labelEntries = []; scene.nodes.forEach((node) => { if (node.role !== 'person') return; activeIds.add(node.id); let label = this._labelElements.get(node.id); if (!label) { label = this.createLabelElement(node); this._labelElements.set(node.id, label); layer.appendChild(label); } this.renderLabelElement(label, node.id); const box = this.getPersonBoundingBox(node); if (!box) { label.style.opacity = '0'; return; } const topCenter = new THREE.Vector3( (box.min.x + box.max.x) / 2, box.max.y, (box.min.z + box.max.z) / 2 ); const screen = this.projectToScreen(topCenter); if (!screen || !screen.visible) { label.style.opacity = '0'; return; } const depth = this.camera.position.distanceTo(topCenter); const referenceDepth = 600; const scaleRaw = referenceDepth / Math.max(depth, 1); const baseScale = Math.max(tokens.labelMinScale, Math.min(tokens.labelMaxScale, scaleRaw)); const fadeRatio = Math.max(0, Math.min(1, 1 - depth / tokens.labelFadeDistance)); const opacity = Math.max(tokens.labelMinOpacity, Math.min(1, tokens.labelBaseOpacity * fadeRatio)); const fontSize = Math.max(7, Math.min(13, 10.5 * baseScale)); const padX = Math.max(4, Math.min(9, 7.5 * baseScale)); const padY = Math.max(3, Math.min(6, 4 * baseScale)); const desiredX = screen.x; const desiredY = Math.max(screen.y - tokens.labelScreenGap, 0); const measured = this.measureLabel(label); const width = measured.width; const height = measured.height; let scale = baseScale; const state = this.getLabelState(node.id); labelEntries.push({ nodeId: node.id, label, state, desiredX, desiredY, width, height, scale: Math.max(tokens.labelCollisionMinScale, Math.min(tokens.labelMaxScale, isFinite(scale) ? scale : baseScale)), opacity, fontSize, padX, padY }); }); if (!this.shouldSkipLabelCollision()) { this.resolveCollisionScales(labelEntries, tokens.labelCollisionGap, tokens.labelCollisionMinScale); } this.syncLabelDom(labelEntries); this._labelElements.forEach((label, id) => { if (!activeIds.has(id)) { label.remove(); this._labelElements.delete(id); this._labelStates.delete(id); } }); } createGradientTexture(baseColor, isMale = true) { const key = `${baseColor}-${isMale}`; if (this._gradientTextures.has(key)) { return this._gradientTextures.get(key); } const canvas = document.createElement('canvas'); canvas.width = 128; canvas.height = 128; const ctx = canvas.getContext('2d'); const base = new THREE.Color(baseColor); const lighter = base.clone().offsetHSL(0, -0.1, 0.15); const darker = base.clone().offsetHSL(0, 0.1, -0.1); if (isMale) { const gradient = ctx.createLinearGradient(0, 0, 128, 128); gradient.addColorStop(0, `#${lighter.getHexString()}`); gradient.addColorStop(0.5, `#${base.getHexString()}`); gradient.addColorStop(1, `#${darker.getHexString()}`); ctx.fillStyle = gradient; } else { const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 90); gradient.addColorStop(0, `#${lighter.getHexString()}`); gradient.addColorStop(0.6, `#${base.getHexString()}`); gradient.addColorStop(1, `#${darker.getHexString()}`); ctx.fillStyle = gradient; } ctx.fillRect(0, 0, 128, 128); const texture = new THREE.CanvasTexture(canvas); texture.minFilter = THREE.LinearFilter; this._gradientTextures.set(key, texture); return texture; } saveCameraState() { if (!this.camera || !this.controls) return; this._savedCameraState = { position: this.camera.position.clone(), target: this.controls.target.clone(), type: this.camera.type }; } restoreCameraState() { if (!this._savedCameraState || !this.camera || !this.controls) return; if (this.camera.type === this._savedCameraState.type) { this.camera.position.copy(this._savedCameraState.position); this.controls.target.copy(this._savedCameraState.target); this.controls.update(); } this._savedCameraState = null; } easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } animatePivotTo(familyId, targetRotation, duration) { const group = this.familyGroups.get(familyId); if (!group) return; const pivot = group.pivot; const startRotation = pivot.rotation.y; const animation = { familyId, pivot, startRotation, targetRotation, duration: duration || this._animationDuration, startTime: performance.now(), complete: false }; this._animations.push(animation); } animateNodePosition(objectId, targetPos, duration) { const obj = this.objects.get(objectId); if (!obj) return; const startPos = obj.position.clone(); const animation = { type: 'position', objectId, obj, startPos, targetPos: new THREE.Vector3(targetPos.x, targetPos.y, targetPos.z), duration: duration || this._animationDuration, startTime: performance.now(), complete: false }; this._animations.push(animation); } processAnimations() { const now = performance.now(); const completed = []; this._animations.forEach((anim, index) => { const elapsed = now - anim.startTime; const progress = Math.min(elapsed / anim.duration, 1); const easedProgress = this.easeInOutCubic(progress); if (anim.type === 'position') { const obj = anim.obj; if (obj) { obj.position.x = anim.startPos.x + (anim.targetPos.x - anim.startPos.x) * easedProgress; obj.position.y = anim.startPos.y + (anim.targetPos.y - anim.startPos.y) * easedProgress; obj.position.z = anim.startPos.z + (anim.targetPos.z - anim.startPos.z) * easedProgress; } } else { const newRotation = anim.startRotation + (anim.targetRotation - anim.startRotation) * easedProgress; const pivotObj = anim.pivot || anim.rotationRoot; if (pivotObj) pivotObj.rotation.y = newRotation; } if (progress >= 1) { anim.complete = true; completed.push(index); } }); for (let i = completed.length - 1; i >= 0; i--) { this._animations.splice(completed[i], 1); } } hasActivePivotAnimation() { return this._animations.some((anim) => anim && anim.pivot); } init() { const container = this.host; const width = container.clientWidth || window.innerWidth; const height = container.clientHeight || window.innerHeight; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(VISUAL_TOKENS.color.bg.light); // Grid backdrop extends to horizon (placed at bottom, large size) const grid = new THREE.GridHelper(4000, 80, VISUAL_TOKENS.color.grid.primary, VISUAL_TOKENS.color.grid.secondary); grid.material.opacity = 0.4; grid.material.transparent = true; grid.position.y = -1000; this.scene.add(grid); if (this.mode === '2d') { const aspect = width / height; const frustumSize = 800; this.camera = new THREE.OrthographicCamera( frustumSize * aspect / -2, frustumSize * aspect / 2, frustumSize / 2, frustumSize / -2, 0.1, 10000 ); this.camera.position.set(0, 0, 600); this.camera.lookAt(0, 0, 0); } else { this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 10000); this.camera.position.set(0, 0, 600); this.camera.lookAt(0, 0, 0); } this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setSize(width, height); this.renderer.setPixelRatio(window.devicePixelRatio); container.appendChild(this.renderer.domElement); const ambientLight = new THREE.AmbientLight(VISUAL_TOKENS.color.bg.light, 0.9); this.scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(VISUAL_TOKENS.color.bg.light, 1.2); directionalLight.position.set(100, 100, 100); this.scene.add(directionalLight); const backLight = new THREE.DirectionalLight(VISUAL_TOKENS.color.bg.light, 0.5); backLight.position.set(-100, -100, -100); this.scene.add(backLight); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.1; this.controls.screenSpacePanning = true; this.controls.minDistance = 50; this.controls.maxDistance = 2000; this.controls.target.set(0, 0, 0); this.setupInteraction(); this.animate(); window.addEventListener('resize', () => this.onResize()); } setupInteraction() { const canvas = this.renderer.domElement; canvas.addEventListener('click', (event) => this.onCanvasClick(event)); canvas.addEventListener('pointerdown', (event) => this.onPointerDown(event)); canvas.addEventListener('pointerup', (event) => this.onPointerUp(event)); canvas.addEventListener('pointercancel', (event) => this.onPointerCancel(event)); if (this._isMobile) { canvas.addEventListener('touchend', (event) => { if (event.touches.length === 0) { this.onCanvasClick(event.changedTouches[0]); } }); } } onPointerDown(event) { if (event.button !== 0) return; if (this._longPressTimer) { clearTimeout(this._longPressTimer); this._longPressTimer = null; } const startX = event.clientX; const startY = event.clientY; this._longPressTimer = setTimeout(() => { if (Math.abs((event.clientX || startX) - startX) > 6 || Math.abs((event.clientY || startY) - startY) > 6) return; const rect = this.renderer.domElement.getBoundingClientRect(); const mx = ((event.clientX - rect.left) / rect.width) * 2 - 1; const my = -((event.clientY - rect.top) / rect.height) * 2 + 1; this._raycaster.setFromCamera(new THREE.Vector2(mx, my), this.camera); const selectableObjects = []; this.objects.forEach((obj) => { if (obj.isMesh && obj.userData.selectable) { selectableObjects.push(obj); } }); const intersects = this._raycaster.intersectObjects(selectableObjects); if (intersects.length > 0) { const hit = intersects[0].object; const ud = hit.userData || {}; if (ud.role === 'person' && this._onLongPressCallback) { event.preventDefault(); this._onLongPressCallback({ personId: ud.id, screenX: event.clientX, screenY: event.clientY, groupId: ud.groupId }); } } }, 600); } onPointerUp(event) { if (this._longPressTimer) { clearTimeout(this._longPressTimer); this._longPressTimer = null; } } onPointerCancel(event) { if (this._longPressTimer) { clearTimeout(this._longPressTimer); this._longPressTimer = null; } } onCanvasClick(event) { const rect = this.renderer.domElement.getBoundingClientRect(); const clientX = event.clientX || (event.changedTouches && event.changedTouches[0]?.clientX); const clientY = event.clientY || (event.changedTouches && event.changedTouches[0]?.clientY); if (clientX === undefined || clientY === undefined) return; this._mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1; this._mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1; this._raycaster.setFromCamera(this._mouse, this.camera); const selectableObjects = []; this.objects.forEach((obj) => { if (obj.isMesh && obj.userData.selectable) { selectableObjects.push(obj); } if (obj.isGroup && obj.userData.selectable) { obj.traverse(child => { if (child.isMesh) selectableObjects.push(child); }); } }); const intersects = this._raycaster.intersectObjects(selectableObjects); if (intersects.length > 0) { const hit = intersects[0].object; let userData = hit.userData; if ((!userData.role || !userData.id) && hit.parent && hit.parent.userData) { userData = hit.parent.userData; } if (!userData.role || !userData.id) return; if (userData.role === 'family_hub') { if (this._onClickCallback) { this._onClickCallback({ type: 'hub', id: userData.groupId, role: userData.role }); } } else { if (this._onClickCallback) { this._onClickCallback({ type: 'person', id: userData.id, role: userData.role, groupId: userData.groupId }); } } } } onClick(callback) { this._onClickCallback = callback; } onLongPress(callback) { this._onLongPressCallback = callback; } render(layoutScene, preserveCamera = false) { this.mode = layoutScene.mode; this.layout = layoutScene.layout; this._currentScene = layoutScene; this._bounds = layoutScene.bounds; console.info('[ThreeAdapter.render]', { mode: layoutScene.mode, layout: layoutScene.layout, nodes: layoutScene.nodes.length, edges: layoutScene.edges.length, preserveCamera, }); const oldPivotRotations = new Map(); if (preserveCamera) { this.saveCameraState(); this.familyGroups.forEach((group, familyId) => { if (group.pivot) { oldPivotRotations.set(familyId, group.pivot.rotation.y); } }); } if (this.controls && !preserveCamera) { this.controls.dispose(); this.controls = null; } if (!this.camera || !preserveCamera) { if (this.mode === '2d') { const aspect = this.renderer.domElement.clientWidth / this.renderer.domElement.clientHeight; const frustumSize = 800; this.camera = new THREE.OrthographicCamera( frustumSize * aspect / -2, frustumSize * aspect / 2, frustumSize / 2, frustumSize / -2, 0.1, 10000 ); this.camera.position.set(0, 0, 600); this.camera.lookAt(0, 0, 0); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableRotate = false; this.controls.enableDamping = true; this.controls.mouseButtons = { LEFT: THREE.MOUSE.PAN, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; this.bindControlsEvents(); } else { this.camera = new THREE.PerspectiveCamera(45, this.renderer.domElement.clientWidth / this.renderer.domElement.clientHeight, 0.1, 10000); this.camera.position.set(0, 0, 600); this.camera.lookAt(0, 0, 0); this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.1; this.controls.screenSpacePanning = true; this.controls.minDistance = 50; this.controls.maxDistance = 2000; this.bindControlsEvents(); } } this.controls.target.set(0, 0, 0); this.clearScene(); this.clearDebugObjects(); this._animations = []; this.buildSceneGraph(layoutScene, preserveCamera, oldPivotRotations); if (!preserveCamera && layoutScene.nodes.length > 0) { this.fit(); } if (this._debugOverlayEnabled) { this.renderDebugOverlays(layoutScene); } this.updatePersonLabels(); if (preserveCamera) { this.restoreCameraState(); } } buildSceneGraph(layoutScene, animatePivots = false, oldPivotRotations = new Map()) { this._rootGroup = new THREE.Group(); this.scene.add(this._rootGroup); const state = this.host._state; if (!state) return; // Non-genealogy profiles don't use the family scene-graph hierarchy. // Render them as a flat node+edge scene. if (layoutScene.modelProfile && layoutScene.modelProfile !== 'genealogy') { this._buildFlatScene(layoutScene); return; } const isRadial = layoutScene.layout.startsWith('radial'); if (isRadial) { this._buildRadialScene(layoutScene); return; } const familyMap = new Map(state.groups.map(g => [g.id, g])); const entityMap = new Map(state.entities.map(e => [e.id, e])); const childToFamily = new Map(); const rotationSafety = computeBranchRotationSafety(state); state.relations.forEach(rel => { childToFamily.set(rel.attrs.childPersonId, rel.attrs.childFamilyId); }); const visitedFamilies = new Set(); const buildFamily = (familyId, hubWorldPos, parentDescendantsGroup) => { if (visitedFamilies.has(familyId)) return; visitedFamilies.add(familyId); const family = familyMap.get(familyId); if (!family) return; const attrs = family.attrs || {}; const hubId = `hub-${family.id}`; const hubNode = layoutScene.nodes.find(n => n.id === hubId); const hubPos = hubNode ? new THREE.Vector3(hubNode.position.x, hubNode.position.y, hubNode.position.z) : new THREE.Vector3(); const familyRoot = new THREE.Group(); familyRoot.name = `familyRoot-${family.id}`; familyRoot.position.copy(hubWorldPos); const staticGroup = new THREE.Group(); staticGroup.name = `staticGroup-${family.id}`; const pivot = new THREE.Group(); pivot.name = `pivot-${family.id}`; pivot.userData = { entityType: 'familyPivot', familyId: family.id, turned: attrs.turned || false }; const descendantsGroup = new THREE.Group(); descendantsGroup.name = `descendantsGroup-${family.id}`; if (hubNode) { const hubMesh = this.createHubMesh(hubNode, layoutScene.mode); hubMesh.position.set(0, 0, 0); staticGroup.add(hubMesh); this.objects.set(hubId, hubMesh); } if (attrs.husbandId) { const husbandNode = layoutScene.nodes.find(n => n.id === attrs.husbandId); if (husbandNode && husbandNode.groupId === family.id) { const mesh = this.createPersonMesh(husbandNode, layoutScene.mode); mesh.position.set( husbandNode.position.x - hubPos.x, husbandNode.position.y - hubPos.y, husbandNode.position.z - hubPos.z ); staticGroup.add(mesh); this.objects.set(attrs.husbandId, mesh); } } if (attrs.wifeId) { const wifeNode = layoutScene.nodes.find(n => n.id === attrs.wifeId); if (wifeNode && wifeNode.groupId === family.id) { const mesh = this.createPersonMesh(wifeNode, layoutScene.mode); mesh.position.set( wifeNode.position.x - hubPos.x, wifeNode.position.y - hubPos.y, wifeNode.position.z - hubPos.z ); staticGroup.add(mesh); this.objects.set(attrs.wifeId, mesh); } } const spouseEdges = layoutScene.edges.filter(e => e.role === 'spouse' && (e.fromId === attrs.husbandId || e.fromId === attrs.wifeId)); spouseEdges.forEach(edge => { if (!edge.route || !edge.route.points) return; const relPoints = edge.route.points.map(p => new THREE.Vector3( p.x - hubPos.x, p.y - hubPos.y, p.z - hubPos.z )); const hasNaN = relPoints.some(p => isNaN(p.x) || isNaN(p.y) || isNaN(p.z)); if (hasNaN) return; const lineGeo = new THREE.BufferGeometry().setFromPoints(relPoints); const lineMat = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.edge.light }); const line = new THREE.Line(lineGeo, lineMat); staticGroup.add(line); this.objects.set(edge.id, line); }); const childrenIds = attrs.childrenIds || []; childrenIds.forEach(childId => { const childNode = layoutScene.nodes.find(n => n.id === childId); const childFamilyId = childToFamily.get(childId); const childHubId = childFamilyId ? `hub-${childFamilyId}` : null; const childBranchRotates = childFamilyId ? rotationSafety.isRotatableBranch(family.id, childFamilyId) : true; if (childNode && childNode.groupId === family.id) { const mesh = this.createPersonMesh(childNode, layoutScene.mode); mesh.position.set( childNode.position.x - hubPos.x, childNode.position.y - hubPos.y, childNode.position.z - hubPos.z ); (childBranchRotates ? descendantsGroup : staticGroup).add(mesh); this.objects.set(childId, mesh); } const childEdge = layoutScene.edges.find( e => e.role === 'parent-child' && e.fromId === `hub-${family.id}` && e.toId === childId ); if (childEdge && childEdge.route && childEdge.route.points) { const relPoints = childEdge.route.points.map(p => new THREE.Vector3( p.x - hubPos.x, p.y - hubPos.y, p.z - hubPos.z )); const hasNaN = relPoints.some(p => isNaN(p.x) || isNaN(p.y) || isNaN(p.z)); if (!hasNaN) { const lineGeo = new THREE.BufferGeometry().setFromPoints(relPoints); const lineMat = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.edge.light }); const line = new THREE.Line(lineGeo, lineMat); (childBranchRotates ? descendantsGroup : staticGroup).add(line); this.objects.set(childEdge.id, line); } } if (childFamilyId) { const descendantEdge = layoutScene.edges.find( e => e.role === 'parent-child' && e.fromId === `hub-${family.id}` && e.toId === childHubId ); if (descendantEdge && descendantEdge.route && descendantEdge.route.points) { const relPoints = descendantEdge.route.points.map(p => new THREE.Vector3( p.x - hubPos.x, p.y - hubPos.y, p.z - hubPos.z )); const hasNaN = relPoints.some(p => isNaN(p.x) || isNaN(p.y) || isNaN(p.z)); if (!hasNaN) { const lineGeo = new THREE.BufferGeometry().setFromPoints(relPoints); const lineMat = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.edge.light }); const line = new THREE.Line(lineGeo, lineMat); (childBranchRotates ? descendantsGroup : staticGroup).add(line); this.objects.set(descendantEdge.id, line); } } } if (childFamilyId) { const childHubNode = layoutScene.nodes.find(n => n.id === childHubId); if (childHubNode) { const childHubRelative = new THREE.Vector3( childHubNode.position.x - hubPos.x, childHubNode.position.y - hubPos.y, childHubNode.position.z - hubPos.z ); buildFamily(childFamilyId, childHubRelative, childBranchRotates ? descendantsGroup : staticGroup); } } }); const rotationY = (layoutScene.mode === '3d' && attrs.turned) ? Math.PI / 2 : 0; const oldRotation = oldPivotRotations.get(family.id); if (animatePivots && oldRotation !== undefined) { pivot.rotation.y = oldRotation; } else { pivot.rotation.y = rotationY; } pivot.add(descendantsGroup); familyRoot.add(staticGroup); familyRoot.add(pivot); this.familyGroups.set(family.id, { familyRoot, staticGroup, pivot, descendantsGroup }); if (animatePivots && oldRotation !== undefined) { this.animatePivotTo(family.id, rotationY, 300); } if (parentDescendantsGroup) { parentDescendantsGroup.add(familyRoot); } else { this._rootGroup.add(familyRoot); } }; const rootFamilies = state.groups.filter(g => { return !state.relations.some(r => r.attrs.childFamilyId === g.id); }); rootFamilies.forEach(rootFamily => { const rootHubNode = layoutScene.nodes.find(n => n.id === `hub-${rootFamily.id}`); const rootHubWorld = rootHubNode ? new THREE.Vector3(rootHubNode.position.x, rootHubNode.position.y, rootHubNode.position.z) : new THREE.Vector3(0, 0, 0); buildFamily(rootFamily.id, rootHubWorld, null); }); } _buildFlatScene(layoutScene) { const mode = layoutScene.mode; layoutScene.nodes.forEach(node => { if (node.role === 'family_hub') { const hubMesh = this.createHubMesh(node, mode); hubMesh.position.set(node.position.x, node.position.y, node.position.z); this._rootGroup.add(hubMesh); this.objects.set(node.id, hubMesh); return; } if (node.role === 'person') { const mesh = this.createPersonMesh(node, mode); mesh.position.set(node.position.x, node.position.y, node.position.z); this._rootGroup.add(mesh); this.objects.set(node.id, mesh); return; } // phylogeny / generic-tree nodes if (node.role === 'phylo-node' || node.role === 'tree-node') { const mesh = this.createGenericNodeMesh(node, mode); mesh.position.set(node.position.x, node.position.y, node.position.z); this._rootGroup.add(mesh); this.objects.set(node.id, mesh); } }); layoutScene.edges.forEach(edge => { const pts = edge.route?.points || edge.points; if (!pts) return; const points = pts.map(p => new THREE.Vector3(p.x, p.y, p.z)); const hasNaN = points.some(p => isNaN(p.x) || isNaN(p.y) || isNaN(p.z)); if (hasNaN) return; const geo = new THREE.BufferGeometry().setFromPoints(points); const mat = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.edge.light }); const line = new THREE.Line(geo, mat); line.userData.originalPoints = pts.map(p => ({ x: p.x, y: p.y, z: p.z })); this._rootGroup.add(line); this.objects.set(edge.id, line); }); } _buildRadialScene(layoutScene) { const mode = layoutScene.mode; layoutScene.nodes.forEach(node => { if (node.role === 'family_hub') { const hubMesh = this.createHubMesh(node, mode); hubMesh.position.set(node.position.x, node.position.y, node.position.z); this._rootGroup.add(hubMesh); this.objects.set(node.id, hubMesh); } else if (node.role === 'person') { const mesh = this.createPersonMesh(node, mode); mesh.position.set(node.position.x, node.position.y, node.position.z); this._rootGroup.add(mesh); this.objects.set(node.id, mesh); } }); layoutScene.edges.forEach(edge => { if (!edge.route || !edge.route.points) return; const points = edge.route.points.map(p => new THREE.Vector3(p.x, p.y, p.z)); const hasNaN = points.some(p => isNaN(p.x) || isNaN(p.y) || isNaN(p.z)); if (hasNaN) return; const geo = new THREE.BufferGeometry().setFromPoints(points); const mat = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.edge.light }); const line = new THREE.Line(geo, mat); line.userData.originalPoints = edge.route.points.map(p => ({ x: p.x, y: p.y, z: p.z })); line.userData.edgeRole = edge.role; this._rootGroup.add(line); this.objects.set(edge.id, line); }); console.info('[ThreeAdapter._buildRadialScene]', { nodes: layoutScene.nodes.length, edges: layoutScene.edges.length, }); } createHubMesh(node, mode) { const geometry = new THREE.SphereGeometry(12, 16, 16); // Visual spec: hub is the visual center, use gradient for emphasis const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 64; const ctx = canvas.getContext('2d'); const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 40); gradient.addColorStop(0, hexToCss(VISUAL_TOKENS.color.hubGradient.inner)); gradient.addColorStop(0.5, hexToCss(VISUAL_TOKENS.color.hubGradient.middle)); gradient.addColorStop(1, hexToCss(VISUAL_TOKENS.color.hubGradient.outer)); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 64, 64); const hubTexture = new THREE.CanvasTexture(canvas); const material = new THREE.MeshPhongMaterial({ map: hubTexture, emissive: VISUAL_TOKENS.color.hubGradient.emissive, specular: VISUAL_TOKENS.color.specular.hub, shininess: 40 }); material.transparent = true; material.opacity = 0.85; const mesh = new THREE.Mesh(geometry, material); mesh.userData = { id: node.id, role: node.role, groupId: node.groupId, selectable: node.selectable }; return mesh; } createPersonMesh(node, mode) { const state = this.host._state; const group = new THREE.Group(); let geometry, material; const nodeSize = 16; const photosEnabled = this._photosEnabled || false; const photoTexture = photosEnabled ? this._photoTextures?.get(node.id) : null; // Visual spec: male = cube with soft edges, female = sphere const isFemale = state?.entities?.find(e => e.id === node.id)?.attrs?.gender === 'female'; const isMale = !isFemale; if (photoTexture) { const photoWidth = nodeSize * 3; const photoHeight = nodeSize * 2.2; // Oval clipping via circular texture UV mapping const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256; const ctx = canvas.getContext('2d'); ctx.beginPath(); ctx.ellipse(128, 128, 120, 150, 0, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); const img = new Image(); img.crossOrigin = 'anonymous'; img.src = photoTexture.image?.source?.data || ''; ctx.drawImage(img, 0, 0, 256, 256); const ovalTexture = new THREE.CanvasTexture(canvas); ovalTexture.minFilter = THREE.LinearFilter; const photoGeo = new THREE.PlaneGeometry(photoWidth, photoHeight); const photoMat = new THREE.MeshBasicMaterial({ map: ovalTexture, side: THREE.DoubleSide, transparent: true }); const photoMesh = new THREE.Mesh(photoGeo, photoMat); photoMesh.position.set(0, -nodeSize * 2.5, 0); photoMesh.name = 'photo'; group.add(photoMesh); // Soft oval border const borderCanvas = document.createElement('canvas'); borderCanvas.width = 256; borderCanvas.height = 256; const borderCtx = borderCanvas.getContext('2d'); borderCtx.beginPath(); borderCtx.ellipse(128, 128, 120, 150, 0, 0, Math.PI * 2); borderCtx.lineWidth = 4; borderCtx.strokeStyle = '#333333'; borderCtx.stroke(); const borderTexture = new THREE.CanvasTexture(borderCanvas); const borderGeo = new THREE.PlaneGeometry(photoWidth + 4, photoHeight + 4); const borderMat = new THREE.MeshBasicMaterial({ map: borderTexture, transparent: true, side: THREE.DoubleSide }); const borderMesh = new THREE.Mesh(borderGeo, borderMat); borderMesh.position.set(0, -nodeSize * 2.5, -0.1); group.add(borderMesh); } // Visual spec colors from docs/visual-spec.md const colors = { male: VISUAL_TOKENS.color.person.male.light, female: VISUAL_TOKENS.color.person.female.light, emissive: VISUAL_TOKENS.color.emissive.default, specular: VISUAL_TOKENS.color.specular.default }; // Visual spec: use gradient texture for richer material const useGradient = true; if (isFemale) { // Visual spec: female = sphere, мягкое различие geometry = new THREE.SphereGeometry(nodeSize, 24, 24); if (useGradient) { const gradientTex = this.createGradientTexture(colors.female, false); material = new THREE.MeshPhongMaterial({ map: gradientTex, emissive: colors.emissive, specular: colors.specular, shininess: 25 }); } else { material = new THREE.MeshPhongMaterial({ color: colors.female, emissive: colors.emissive, specular: colors.specular, shininess: 20 }); } const sphere = new THREE.Mesh(geometry, material); group.add(sphere); } else { // Visual spec: male = cube с мягкими углами geometry = new THREE.BoxGeometry(nodeSize * 2, nodeSize * 2, nodeSize * 2); if (useGradient) { const gradientTex = this.createGradientTexture(colors.male, true); material = new THREE.MeshPhongMaterial({ map: gradientTex, emissive: colors.emissive, specular: colors.specular, shininess: 25 }); } else { material = new THREE.MeshPhongMaterial({ color: colors.male, emissive: colors.emissive, specular: colors.specular, shininess: 20 }); } const cube = new THREE.Mesh(geometry, material); const edgesGeo = new THREE.EdgesGeometry(geometry); const edgesMat = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.specular.edge }); const edges = new THREE.LineSegments(edgesGeo, edgesMat); group.add(cube); group.add(edges); } group.userData = { id: node.id, role: node.role, groupId: node.groupId, selectable: node.selectable }; return group; } setPhotosEnabled(enabled, photoUrls = {}) { this._photosEnabled = enabled; this._photoTextures = new Map(); if (!enabled) return; const loader = new THREE.TextureLoader(); Object.entries(photoUrls).forEach(([personId, url]) => { loader.load(url, (texture) => { texture.minFilter = THREE.LinearFilter; this._photoTextures.set(personId, texture); }); }); } createGenericNodeMesh(node, mode) { const group = new THREE.Group(); const size = typeof node.nodeSize === 'number' ? node.nodeSize : 18; let color = VISUAL_TOKENS.color.generic.default; if (node.role === 'phylo-node') { color = node.isLeaf ? VISUAL_TOKENS.color.generic.leaf : VISUAL_TOKENS.color.generic.default; } else if (node.role === 'tree-node') { const category = node.category || 'internal'; color = category === 'root' ? VISUAL_TOKENS.color.generic.root : category === 'leaf' ? VISUAL_TOKENS.color.generic.accent : VISUAL_TOKENS.color.generic.default; } const geometry = new THREE.SphereGeometry(size, 16, 16); const material = new THREE.MeshPhongMaterial({ color, emissive: VISUAL_TOKENS.color.emissive.soft, specular: VISUAL_TOKENS.color.specular.edge, shininess: 30 }); const sphere = new THREE.Mesh(geometry, material); group.add(sphere); group.userData = { id: node.id, role: node.role, groupId: node.groupId, selectable: node.selectable }; return group; } createEdgeMesh(edge) { if (!edge.route || !edge.route.points) return null; const points = edge.route.points.map(p => new THREE.Vector3(p.x, p.y, p.z)); const hasNaN = points.some(p => isNaN(p.x) || isNaN(p.y) || isNaN(p.z)); if (hasNaN) return null; const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineBasicMaterial({ color: VISUAL_TOKENS.color.edge.light }); const line = new THREE.Line(geometry, material); return line; } getWorldPosition(node) { const mesh = this.objects.get(node.id); if (!mesh) return new THREE.Vector3(node.position.x, node.position.y, node.position.z); const worldPos = new THREE.Vector3(); mesh.getWorldPosition(worldPos); return worldPos; } getSceneDebugSnapshot() { const snapshot = { rotationState: null, objects: [] }; this.objects.forEach((obj, id) => { if (!obj) return; const world = new THREE.Vector3(); obj.getWorldPosition(world); snapshot.objects.push({ id, parent: obj.parent?.name || null, world: { x: world.x, y: world.y, z: world.z }, type: obj.userData?.role || obj.userData?.entityType || obj.type }); }); return snapshot; } clearScene() { this.objects.forEach(obj => { if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach(m => m.dispose()); } else { obj.material.dispose(); } } }); this.objects.clear(); this.familyGroups.clear(); if (this._rootGroup) { this.scene.remove(this._rootGroup); this._rootGroup = null; } } clearDebugObjects() { this._debugObjects.forEach(obj => { if (obj.element) { obj.element.remove(); } else { this.scene.remove(obj); if (obj.geometry) obj.geometry.dispose(); if (obj.material) { if (Array.isArray(obj.material)) { obj.material.forEach(m => m.dispose()); } else { obj.material.dispose(); } } } }); this._debugObjects = []; } renderDebugOverlays(layoutScene) { if (!this._bounds) return; const boundsColor = VISUAL_TOKENS.color.debug.bounds; const pivotColor = VISUAL_TOKENS.color.debug.pivot; const boxGeo = new THREE.BoxGeometry( this._bounds.max.x - this._bounds.min.x, this._bounds.max.y - this._bounds.min.y, this._bounds.max.z - this._bounds.min.z ); const boxMat = new THREE.LineBasicMaterial({ color: boundsColor }); const boxEdges = new THREE.EdgesGeometry(boxGeo); const boundsBox = new THREE.LineSegments(boxEdges, boxMat); boundsBox.position.set( (this._bounds.max.x + this._bounds.min.x) / 2, (this._bounds.max.y + this._bounds.min.y) / 2, (this._bounds.max.z + this._bounds.min.z) / 2 ); this.scene.add(boundsBox); this._debugObjects.push(boundsBox); layoutScene.pivots.forEach(pivot => { const pivotGeo = new THREE.SphereGeometry(5, 8, 8); const pivotMat = new THREE.MeshBasicMaterial({ color: pivotColor }); const pivotMesh = new THREE.Mesh(pivotGeo, pivotMat); pivotMesh.position.set(pivot.pivot.x, pivot.pivot.y, pivot.pivot.z); this.scene.add(pivotMesh); this._debugObjects.push(pivotMesh); if (pivot.rotationY !== 0) { const arrowDir = new THREE.Vector3(1, 0, 0).applyAxisAngle(new THREE.Vector3(0, 1, 0), pivot.rotationY); const arrowHelper = new THREE.ArrowHelper(arrowDir, pivotMesh.position, 20, pivotColor); this.scene.add(arrowHelper); this._debugObjects.push(arrowHelper); } }); } toggleDebugOverlay(enabled) { this._debugOverlayEnabled = enabled; if (this._currentScene) { this.clearDebugObjects(); if (enabled) { this.renderDebugOverlays(this._currentScene); } } return enabled; } fit() { if (!this._bounds || !this.camera) { console.warn('[ThreeAdapter.fit] No bounds or camera', { hasBounds: !!this._bounds, hasCamera: !!this.camera }); return; } const center = new THREE.Vector3( (this._bounds.max.x + this._bounds.min.x) / 2, (this._bounds.max.y + this._bounds.min.y) / 2, (this._bounds.max.z + this._bounds.min.z) / 2 ); const size = new THREE.Vector3( this._bounds.max.x - this._bounds.min.x, this._bounds.max.y - this._bounds.min.y, this._bounds.max.z - this._bounds.min.z ); const maxDim = Math.max(size.x, size.y, size.z) * 1.2; const isRadial = this.layout.startsWith('radial'); console.info('[ThreeAdapter.fit]', { layout: this.layout, isRadial, bounds: this._bounds, center: { x: center.x, y: center.y, z: center.z }, size: { x: size.x, y: size.y, z: size.z }, maxDim, }); if (this.mode === '2d') { this.camera.zoom = this.camera.top / (maxDim / 2); if (isRadial) { this.camera.position.set(center.x, center.y + 1000, center.z); this.camera.lookAt(center); } else { this.camera.position.set(center.x, center.y, 1000); } } else { const distance = maxDim * 2; if (isRadial) { this.camera.position.set(center.x, center.y + distance, center.z); } else { this.camera.position.set(center.x, center.y, center.z + distance); } this.camera.lookAt(center); } this.controls.target.copy(center); this.controls.update(); console.info('[ThreeAdapter.fit] Camera after fit', { position: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }, target: { x: this.controls.target.x, y: this.controls.target.y, z: this.controls.target.z }, }); } resetView() { const isRadial = this.layout.startsWith('radial'); if (this.mode === '2d') { if (isRadial) { this.camera.position.set(0, 1000, 0); } else { this.camera.position.set(0, 0, 1000); } this.camera.zoom = 1; this.camera.updateProjectionMatrix(); this.controls.target.set(0, 0, 0); } else { if (isRadial) { this.camera.position.set(0, 600, 0); this.camera.lookAt(0, 0, 0); } else { this.camera.position.set(0, 0, 600); this.camera.lookAt(0, 0, 0); } this.controls.target.set(0, 0, 0); } this.controls.update(); } onResize() { const width = this.host.clientWidth || this.renderer.domElement.clientWidth; const height = this.host.clientHeight || this.renderer.domElement.clientHeight; this.renderer.setSize(width, height); if (this.camera.isPerspectiveCamera) { this.camera.aspect = width / height; } else { const frustumSize = 800; const aspect = width / height; this.camera.left = frustumSize * aspect / -2; this.camera.right = frustumSize * aspect / 2; this.camera.top = frustumSize / 2; this.camera.bottom = frustumSize / -2; } this.camera.updateProjectionMatrix(); if (this._currentScene && this._currentScene.nodes.length > 0) { this.fit(); } } animate() { requestAnimationFrame(() => this.animate()); this.processAnimations(); if (this.controls) { this.controls.update(); } this.updatePersonLabels(); this.renderer.render(this.scene, this.camera); } clearSelectionHighlight() { const defaultEmissive = VISUAL_TOKENS.color.emissive.soft; const defaultSpecular = VISUAL_TOKENS.color.specular.edge; const light = VISUAL_TOKENS.color.person.male.light; const female = VISUAL_TOKENS.color.person.female.light; this.objects.forEach((obj, id) => { if (obj.material) { if (obj.material.emissive) { obj.material.emissive.setHex(defaultEmissive); } if (obj.material.color && !obj.isLine) { const ud = obj.userData || {}; if (ud.role === 'person') { const entity = this.host._state?.entities?.find(e => e.id === id); obj.material.color.setHex(entity?.attrs?.gender === 'female' ? female : light); } } } if (obj.isGroup) { obj.traverse(child => { if (child.material) { if (child.material.emissive) { child.material.emissive.setHex(defaultEmissive); } if (child.material.color) { child.material.color.setHex(light); } } }); } }); } applySelectionHighlight(selectedId, ancestorMaleIds, ancestorFemaleIds, descendantIds, ancestorHubIds = [], descendantHubIds = [], ancestorEdgeIds = [], descendantEdgeIds = []) { this.clearSelectionHighlight(); const highlightMap = new Map(); const sel = VISUAL_TOKENS.color.selection; // Selected: warm amber, clearly visible but not harsh highlightMap.set(selectedId, { emissive: sel.selectedEmissive, color: sel.selected }); // Ancestor: subtle cool gray, gentle ancestorMaleIds.forEach(id => highlightMap.set(id, { emissive: sel.ancestorEmissive, color: sel.ancestor })); ancestorFemaleIds.forEach(id => highlightMap.set(id, { emissive: sel.ancestorEmissive, color: sel.ancestor })); // Descendant: subtle warm sage, gentle descendantIds.forEach(id => highlightMap.set(id, { emissive: sel.descendantEmissive, color: sel.descendant })); const edgeColor = VISUAL_TOKENS.color.edge.light; ancestorEdgeIds.forEach(id => highlightMap.set(id, { emissive: edgeColor, color: edgeColor })); descendantEdgeIds.forEach(id => highlightMap.set(id, { emissive: edgeColor, color: edgeColor })); highlightMap.forEach((style, id) => { const obj = this.objects.get(id); if (!obj) return; if (obj.isLine) { if (obj.material && obj.material.color) { obj.material.color.setHex(style.color); obj.material.emissive.setHex(style.emissive); } return; } if (obj.material && obj.material.emissive) { obj.material.emissive.setHex(style.emissive); obj.material.color.setHex(style.color); } if (obj.isGroup) { obj.traverse(child => { if (child.material) { if (child.material.emissive) { child.material.emissive.setHex(style.emissive); } if (child.material.color) { child.material.color.setHex(style.color); } } }); } }); } applyTheme(theme) { // Visual spec colors from docs/visual-spec.md + extended themes const t = getVisualThemeTokens(theme); this.scene.background = new THREE.Color(t.bg); this.objects.forEach((obj, id) => { if (!obj.material) return; const ud = obj.userData || {}; if (ud.role === 'person') { const entity = this.host._state?.entities?.find(e => e.id === id); const color = entity?.attrs?.gender === 'female' ? t.female : t.male; if (obj.material.map) { // Gradient textures need refresh for theme const isMale = entity?.attrs?.gender !== 'female'; const newTex = this.createGradientTexture(color, isMale); obj.material.map = newTex; } obj.material.color.setHex(color); } else if (ud.role === 'family_hub') { obj.material.color.setHex(t.hub); } if (obj.isGroup) { obj.traverse(child => { if (child.material && child.material.color) { const ud2 = obj.userData || {}; if (ud2.role === 'person') { const entity = this.host._state?.entities?.find(e => e.id === id); const color = entity?.attrs?.gender === 'female' ? t.female : t.male; if (child.material.map) { const isMale = entity?.attrs?.gender !== 'female'; const newTex = this.createGradientTexture(color, isMale); child.material.map = newTex; } child.material.color.setHex(color); } } }); } }); } dispose() { this.clearScene(); this.clearDebugObjects(); if (this.controls) { this.controls.dispose(); this.controls = null; } if (this.renderer) { this.renderer.dispose(); this.renderer.domElement.remove(); } } }