98 lines
6.5 KiB
JavaScript
98 lines
6.5 KiB
JavaScript
|
|
'use strict';
|
||
|
|
(function(root) {
|
||
|
|
const W=184,H=104,GAP=34,ROW=166,PAD=36;
|
||
|
|
// People appear once. Spouse groups share a row when this preserves the parent DAG.
|
||
|
|
function layout(inputPeople,inputLinks,{selfId='',showArchived=false}={}) {
|
||
|
|
const people=inputPeople.filter(p=>showArchived||!p.archived),byId=new Map(people.map(p=>[p.id,p]));
|
||
|
|
const links=inputLinks.filter(e=>e.active&&byId.has(e.fromId)&&byId.has(e.toId)&&e.fromId!==e.toId);
|
||
|
|
const adjacency=new Map(people.map(p=>[p.id,[]]));
|
||
|
|
links.forEach(e=>{adjacency.get(e.fromId).push(e.toId);adjacency.get(e.toId).push(e.fromId);});
|
||
|
|
const seen=new Set(),components=[];
|
||
|
|
for(const p of people) {
|
||
|
|
if(seen.has(p.id))continue;
|
||
|
|
const ids=[p.id];seen.add(p.id);
|
||
|
|
for(let i=0;i<ids.length;i++)for(const next of adjacency.get(ids[i]))if(!seen.has(next)){seen.add(next);ids.push(next);}
|
||
|
|
components.push(ids);
|
||
|
|
}
|
||
|
|
const order=(a,b)=>{
|
||
|
|
const x=byId.get(a),y=byId.get(b);
|
||
|
|
return (x.birthDate||'9999').localeCompare(y.birthDate||'9999')||x.name.localeCompare(y.name,'zh-CN')||a.localeCompare(b);
|
||
|
|
};
|
||
|
|
components.sort((a,b)=>Number(b.includes(selfId))-Number(a.includes(selfId))||b.length-a.length||order(a[0],b[0]));
|
||
|
|
const nodes=[],sections=[],warnings=[];let offset=PAD,maxWidth=400;
|
||
|
|
const connected=components.filter(c=>c.length>1),isolated=components.filter(c=>c.length===1).map(c=>c[0]);
|
||
|
|
for(const [componentIndex,ids] of connected.entries()) {
|
||
|
|
const idSet=new Set(ids),edges=links.filter(e=>idSet.has(e.fromId));
|
||
|
|
function ranked(alignSpouses) {
|
||
|
|
const roots=new Map(ids.map(id=>[id,id]));
|
||
|
|
function find(id){let r=id;while(roots.get(r)!==r)r=roots.get(r);while(roots.get(id)!==id){const next=roots.get(id);roots.set(id,r);id=next;}return r;}
|
||
|
|
if(alignSpouses)for(const e of edges.filter(e=>e.kind==='spouse'))roots.set(find(e.toId),find(e.fromId));
|
||
|
|
const units=new Map();for(const id of ids){const key=find(id);if(!units.has(key))units.set(key,{id:key,ids:[],rank:0,out:new Set(),incoming:new Set()});units.get(key).ids.push(id);}
|
||
|
|
for(const e of edges.filter(e=>e.kind==='parent')) {
|
||
|
|
const a=units.get(find(e.fromId)),b=units.get(find(e.toId));if(a===b)return null;
|
||
|
|
a.out.add(b.id);b.incoming.add(a.id);
|
||
|
|
}
|
||
|
|
const degree=new Map([...units.values()].map(u=>[u.id,u.incoming.size]));
|
||
|
|
const queue=[...units.values()].filter(u=>!degree.get(u.id));
|
||
|
|
for(let i=0;i<queue.length;i++)for(const id of queue[i].out){const u=units.get(id);u.rank=Math.max(u.rank,queue[i].rank+1);degree.set(id,degree.get(id)-1);if(!degree.get(id))queue.push(u);}
|
||
|
|
return queue.length===units.size?units:null;
|
||
|
|
}
|
||
|
|
let units=ranked(true);
|
||
|
|
if(!units){units=ranked(false);warnings.push('部分配偶跨层排列,请以亲子连线确认关系。');}
|
||
|
|
if(!units) {
|
||
|
|
// The API rejects ancestry cycles; imported/malformed input must still terminate.
|
||
|
|
units=new Map(ids.map(id=>[id,{id,ids:[id],rank:0,out:new Set(),incoming:new Set()}]));
|
||
|
|
warnings.push('检测到循环关系,请核对人物关系。');
|
||
|
|
}
|
||
|
|
const rows=[];
|
||
|
|
for(const u of units.values()){u.ids.sort(order);u.width=u.ids.length*W+(u.ids.length-1)*GAP;(rows[u.rank]??=[]).push(u);}
|
||
|
|
rows.forEach(row=>row.sort((a,b)=>order(a.ids[0],b.ids[0])));
|
||
|
|
// Barycentric passes keep sibling branches together without changing generations.
|
||
|
|
const positions=new Map();
|
||
|
|
function measure(){for(const row of rows){let x=0;for(const u of row){positions.set(u.id,x+u.width/2);x+=u.width+GAP*2;}}}
|
||
|
|
for(let pass=0;pass<4;pass++) {
|
||
|
|
measure();const indices=rows.map((_,i)=>i);if(pass%2)indices.reverse();
|
||
|
|
for(const i of indices)rows[i].sort((a,b)=>{
|
||
|
|
const center=u=>{const ns=[...(pass%2?u.out:u.incoming)];return ns.length?ns.reduce((n,id)=>n+positions.get(id),0)/ns.length:positions.get(u.id);};
|
||
|
|
return center(a)-center(b)||order(a.ids[0],b.ids[0]);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const widths=rows.map(row=>row.reduce((n,u)=>n+u.width,0)+Math.max(0,row.length-1)*GAP*2);
|
||
|
|
const width=Math.max(...widths)+PAD*2+70,height=rows.length*ROW+20;
|
||
|
|
sections.push({x:PAD,y:offset,width:width-PAD*2,height,label:`家族分支 ${componentIndex+1} · ${ids.length} 位人物`});
|
||
|
|
rows.forEach((row,rank)=>{
|
||
|
|
let x=PAD+(width-PAD*2-70-widths[rank])/2;
|
||
|
|
for(const u of row){for(const id of u.ids){nodes.push({id,x,y:offset+42+rank*ROW,width:W,height:H,rank,component:componentIndex});x+=W+GAP;}x+=GAP;}
|
||
|
|
});
|
||
|
|
for(const node of nodes.filter(n=>n.component===componentIndex))node.corridor=width-PAD-46;
|
||
|
|
offset+=height+48;maxWidth=Math.max(maxWidth,width);
|
||
|
|
}
|
||
|
|
if(isolated.length) {
|
||
|
|
isolated.sort((a,b)=>Number(b===selfId)-Number(a===selfId)||order(a,b));
|
||
|
|
const beside=connected.length===1&&isolated.length<=4;
|
||
|
|
const columns=beside?1:Math.min(5,isolated.length),width=columns*(W+GAP)-GAP+PAD*2;
|
||
|
|
const x=beside?maxWidth:PAD,y=beside?PAD:offset,height=Math.ceil(isolated.length/columns)*150+42;
|
||
|
|
sections.push({x,y,width:width-PAD*2,height,label:people.length===1?'从自己开始,连接每一代亲人':'待连接的人物'});
|
||
|
|
isolated.forEach((id,i)=>nodes.push({id,x:x+i%columns*(W+GAP),y:y+42+Math.floor(i/columns)*150,width:W,height:H,rank:0,component:-1}));
|
||
|
|
offset=Math.max(offset,y+height);maxWidth=beside?maxWidth+width:Math.max(maxWidth,width);
|
||
|
|
}
|
||
|
|
const located=new Map(nodes.map(n=>[n.id,n]));
|
||
|
|
const paths=links.map((e,i)=>{
|
||
|
|
const a=located.get(e.fromId),b=located.get(e.toId);let d;
|
||
|
|
if(e.kind==='parent') {
|
||
|
|
const sx=a.x+W/2,sy=a.y+H,tx=b.x+W/2,ty=b.y;
|
||
|
|
if(b.rank===a.rank+1){const mid=(sy+ty)/2+(i%3-1)*9;d=`M${sx},${sy} V${mid} H${tx} V${ty}`;}
|
||
|
|
else {const x=(a.corridor||maxWidth-PAD)+(i%5)*8;d=`M${sx},${sy} V${sy+18} H${x} V${ty-18} H${tx} V${ty}`;}
|
||
|
|
}else{
|
||
|
|
const [left,right]=a.x<=b.x?[a,b]:[b,a];
|
||
|
|
if(a.y===b.y&&right.x-left.x<=W+GAP+1)d=`M${left.x+W},${left.y+H/2} H${right.x}`;
|
||
|
|
else if(a.y===b.y){const top=a.y-16-(i%3)*8;d=`M${a.x+W/2},${a.y} V${top} H${b.x+W/2} V${b.y}`;}
|
||
|
|
else {const x=(a.corridor||maxWidth-PAD)+(i%5)*8;d=`M${a.x+W},${a.y+H/2} H${x} V${b.y+H/2} H${b.x+W}`;}
|
||
|
|
}
|
||
|
|
return {...e,d};
|
||
|
|
});
|
||
|
|
return {nodes,paths,sections,width:maxWidth,height:Math.max(220,offset+PAD),warnings:[...new Set(warnings)],components:components.length};
|
||
|
|
}
|
||
|
|
const api={layout};if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.ZhaoFamilyGraph=api;
|
||
|
|
})(typeof window!=='undefined'?window:globalThis);
|