Files
ucvl-home-vision/web/family-map.js
T

89 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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.resizeFrame=0;this.resizeSize='';
this.resize=new ResizeObserver(()=>this.scheduleResize());this.resize.observe(viewport,{box:'border-box'});
}
reset(){cancelAnimationFrame(this.resizeFrame);this.resizeFrame=0;this.resizeSize='';this.key='';this.data=null;this.graph=null;this.viewport.replaceChildren();this.autoFit=true;this.drag=null;$('#family-zoom-value').textContent='100%';}
viewportSize(){
const v=this.viewport,r=v.getBoundingClientRect();
// clientHeight rounds fractional pixels up; using it as a child height can
// create a scrollbar, remove it on the next fit, and repeat indefinitely.
return {width:Math.floor(Math.min(v.clientWidth,r.width)),height:Math.floor(Math.min(v.clientHeight,r.height))};
}
scheduleResize(){
if(this.resizeFrame)return;
this.resizeFrame=requestAnimationFrame(()=>{
this.resizeFrame=0;const {width,height}=this.viewportSize(),key=width+':'+height;
if(!width||!height||key===this.resizeSize)return;
this.resizeSize=key;if(this.autoFit)this.fit();else this.applyScale();
});
}
render(data) {
this.data=data;
const key=JSON.stringify([data.people,data.links,data.showArchived,data.selfId]);
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?`<div class="family-map-space"><div class="family-map-surface"><svg class="family-map-lines" width="${g.width}" height="${g.height}" aria-hidden="true"><defs><marker id="family-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L8 4 L0 8 Z" fill="#679d93"/></marker></defs>${g.paths.map(e=>`<path class="family-edge ${e.kind==='spouse'?'spouse':e.lineage==='adoptive'||e.lineage==='step'?'nonbiological':'parent'}" data-from="${esc(e.fromId)}" data-to="${esc(e.toId)}" d="${e.d}" ${e.kind==='parent'?'marker-end="url(#family-arrow)"':''}><title>${esc(data.people.find(p=>p.id===e.fromId)?.name)}${e.kind==='parent'?' → 子女:':' ↔ 配偶:'}${esc(data.people.find(p=>p.id===e.toId)?.name)}${e.lineage==='adoptive'?'(收养)':e.lineage==='step'?'(继亲)':''}</title></path>`).join('')}</svg>${g.sections.map(s=>`<div class="family-branch-label">${esc(s.label)}</div>`).join('')}${g.nodes.map(n=>`<button type="button" class="family-node" data-map-person="${esc(n.id)}"></button>`).join('')}</div></div>`: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&&personSearch(p).includes(data.term);
const relation=p.id===data.reference&&!self?'参照人物':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=`<span class="family-node-top"><span class="family-node-avatar" aria-hidden="true">${esc(Array.from(p.name)[0]||'人')}</span><span class="family-node-flags">${self?'<span class="self-badge">本人</span>':''}${p.archived?'<small>归档</small>':''}${p.lifeStatus==='deceased'?'<small>已故</small>':''}</span></span><strong>${esc(p.name)}</strong><span class="family-node-relation">${esc(relation)}</span><small>${esc(p.birthDate?p.birthDate.slice(0,4)+' 年生':'生年待考')}</small>`;
});
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');
const {width,height}=this.viewportSize();if(!g||!space||!width||!height)return;
const w=Math.max(width,Math.ceil(g.width*this.scale+32)),h=Math.max(height,Math.ceil(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,{width,height}=this.viewportSize();if(!g||!width||!height)return;
this.autoFit=true;this.scale=Math.max(.001,Math.min(1,(width-40)/g.width,(height-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});
}
}