'use strict'; class FamilyMap { constructor(viewport,onSelect) { this.viewport=viewport;this.onSelect=onSelect;this.scale=1;this.autoFit=true;this.key='';this.data=null; viewport.addEventListener('click',e=>{const b=e.target.closest('[data-map-person]');if(b)this.onSelect(b.dataset.mapPerson);}); viewport.addEventListener('pointerdown',e=>{ if(e.pointerType!=='mouse'||e.button!==0||e.target.closest('button'))return; this.drag={x:e.clientX,y:e.clientY,left:viewport.scrollLeft,top:viewport.scrollTop};viewport.setPointerCapture(e.pointerId);viewport.classList.add('panning');e.preventDefault(); }); viewport.addEventListener('pointermove',e=>{if(!this.drag)return;viewport.scrollLeft=this.drag.left+this.drag.x-e.clientX;viewport.scrollTop=this.drag.top+this.drag.y-e.clientY;}); const release=()=>{this.drag=null;viewport.classList.remove('panning');}; viewport.addEventListener('pointerup',release);viewport.addEventListener('pointercancel',release);viewport.addEventListener('lostpointercapture',release); this.resize=new ResizeObserver(()=>{if(this.autoFit)this.fit();else this.applyScale();});this.resize.observe(viewport); } reset(){this.key='';this.data=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';} render(data) { this.data=data; const key=JSON.stringify([data.people,data.links,data.showArchived]); if(key!==this.key) { this.key=key;this.graph=ZhaoFamilyGraph.layout(data.people,data.links,data); const g=this.graph; this.viewport.innerHTML=g.nodes.length?`
${g.sections.map(s=>`
${esc(s.label)}
`).join('')}${g.nodes.map(n=>``).join('')}
`:empty('家谱从一个人开始','先录入本人,再连接父母、配偶与子女。'); const surface=this.viewport.querySelector('.family-map-surface'); if(surface){surface.style.width=g.width+'px';surface.style.height=g.height+'px'; surface.querySelectorAll('.family-branch-label').forEach((b,i)=>{b.style.left=g.sections[i].x+'px';b.style.top=g.sections[i].y+'px';}); surface.querySelectorAll('[data-map-person]').forEach((b,i)=>{const n=g.nodes[i];Object.assign(b.style,{left:n.x+'px',top:n.y+'px',width:n.width+'px',height:n.height+'px'});});} this.autoFit=true;requestAnimationFrame(()=>this.fit()); } const byId=new Map(data.people.map(p=>[p.id,p])); this.viewport.querySelectorAll('[data-map-person]').forEach(b=>{ const p=byId.get(b.dataset.mapPerson),self=p.id===data.selfId,selected=p.id===data.focusId; const matched=data.term&&[p.name,p.alias,p.birthplace].join(' ').toLowerCase().includes(data.term); const relation=ZhaoKinship.describe(data.people,data.links,data.reference,p.id); b.classList.toggle('self',self);b.classList.toggle('selected',selected);b.classList.toggle('search-hit',!!matched);b.classList.toggle('archived',!!p.archived); b.setAttribute('aria-pressed',String(selected));b.setAttribute('aria-label',p.name+(self?',本人':'')+','+relation+',查看人物档案'); b.title=p.name+' · '+relation+(p.archived?' · 已归档':''); b.innerHTML=`${self?'本人':''}${p.archived?'归档':''}${p.lifeStatus==='deceased'?'已故':''}${esc(p.name)}${esc(relation)}${esc(p.birthDate?p.birthDate.slice(0,4)+' 年生':'生年待考')}`; }); this.viewport.querySelectorAll('.family-edge').forEach(e=>e.classList.toggle('focused',e.dataset.from===data.focusId||e.dataset.to===data.focusId)); const messages=[...this.graph.warnings]; if(!data.selfId)messages.unshift('当前账号尚未关联家谱人物,可在“账号与关系”中设置。'); else if(!this.graph.nodes.some(n=>n.id===data.selfId))messages.unshift('本人已归档,勾选“显示归档人物”后可以定位。'); if(data.people.filter(p=>!p.archived).length===1)messages.push('目前仅录入一位人物,连接亲属后会自动展开家谱。'); $('#family-map-note').textContent=messages.join(' '); $('#family-locate-self').disabled=!this.graph.nodes.some(n=>n.id===data.selfId); } applyScale() { const v=this.viewport,g=this.graph,space=v.querySelector('.family-map-space'),surface=v.querySelector('.family-map-surface'); if(!g||!space||!v.clientWidth)return; const w=Math.max(v.clientWidth,g.width*this.scale+32),h=Math.max(v.clientHeight,g.height*this.scale+32); space.style.width=w+'px';space.style.height=h+'px'; this.left=(w-g.width*this.scale)/2;this.top=(h-g.height*this.scale)/2; surface.style.transform=`translate(${this.left}px,${this.top}px) scale(${this.scale})`; $('#family-zoom-value').textContent=Math.round(this.scale*100)+'%'; } fit() { const v=this.viewport,g=this.graph;if(!g||!v.clientWidth||!v.clientHeight)return; this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(v.clientWidth-40)/g.width,(v.clientHeight-40)/g.height)); this.applyScale();v.scrollTo(0,0); } zoom(factor) { if(!this.graph)return; const v=this.viewport,x=(v.scrollLeft+v.clientWidth/2-(this.left||0))/this.scale,y=(v.scrollTop+v.clientHeight/2-(this.top||0))/this.scale; this.autoFit=false;this.scale=Math.max(.001,Math.min(2,this.scale*factor));this.applyScale(); v.scrollTo(x*this.scale+this.left-v.clientWidth/2,y*this.scale+this.top-v.clientHeight/2); } locate(id) { const n=this.graph?.nodes.find(n=>n.id===id);if(!n)return; this.autoFit=false;this.scale=Math.max(this.scale,.9);this.applyScale(); this.viewport.scrollTo((n.x+n.width/2)*this.scale+this.left-this.viewport.clientWidth/2,(n.y+n.height/2)*this.scale+this.top-this.viewport.clientHeight/2); const b=[...this.viewport.querySelectorAll('[data-map-person]')].find(b=>b.dataset.mapPerson===id);b?.focus({preventScroll:true}); } }