Family Tree: превью + full-stack (frontend, backend, SQL, Docker)

This commit is contained in:
german
2026-06-28 04:12:08 +03:00
commit 638778e807
35 changed files with 2114 additions and 0 deletions
+857
View File
@@ -0,0 +1,857 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Родословное дерево семьи — превью</title>
<style>
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-2: #f0f2f5;
--text: #1c2230;
--muted: #687087;
--border: #dfe3ea;
--accent: #4f6df5;
--male: #4f8ff5;
--male-bg: #e8f1ff;
--female: #e06aa3;
--female-bg: #ffe9f3;
--shadow: 0 4px 20px rgba(20,30,60,.08);
--node-shadow: 0 2px 6px rgba(20,30,60,.10);
}
html[data-theme="dark"] {
--bg: #0f1320;
--panel: #171c2b;
--panel-2: #1f2536;
--text: #e6e9f2;
--muted: #97a0b8;
--border: #2a3145;
--accent: #6d86ff;
--male: #5a93ff;
--male-bg: #16263f;
--female: #ff85bd;
--female-bg: #361927;
--shadow: 0 6px 28px rgba(0,0,0,.45);
--node-shadow: 0 2px 8px rgba(0,0,0,.45);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg); color: var(--text);
display: flex; flex-direction: column; overflow: hidden;
transition: background .25s, color .25s;
}
header {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
background: var(--panel); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow); z-index: 20; flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 16px; }
.brand .logo {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(135deg, var(--accent), #9d7bff);
display: grid; place-items: center; color: #fff; font-size: 16px;
}
.spacer { flex: 1; }
.search { position: relative; }
.search input {
width: 220px; max-width: 46vw; padding: 8px 12px 8px 32px;
border: 1px solid var(--border); border-radius: 9px;
background: var(--panel-2); color: var(--text); font-size: 14px; outline: none;
}
.search input:focus { border-color: var(--accent); }
.search svg { position: absolute; left: 9px; top: 9px; opacity: .55; }
.results {
position: absolute; top: 40px; left: 0; right: 0; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow);
max-height: 280px; overflow: auto; display: none; z-index: 30;
}
.results.show { display: block; }
.results .row { padding: 9px 12px; cursor: pointer; display: flex; gap: 10px; align-items: center; font-size: 14px; }
.results .row:hover { background: var(--panel-2); }
.btn {
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 8px 12px; border-radius: 9px; cursor: pointer; font-size: 14px; display: inline-flex; gap: 6px; align-items: center;
transition: .15s;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.menu { position: relative; }
.menu .dropdown {
position: absolute; right: 0; top: 42px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow);
min-width: 190px; display: none; overflow: hidden; z-index: 30;
}
.menu.open .dropdown { display: block; }
.menu .dropdown button {
width: 100%; text-align: left; padding: 10px 14px; background: none; border: none;
color: var(--text); cursor: pointer; font-size: 14px;
}
.menu .dropdown button:hover { background: var(--panel-2); }
.menu .dropdown .sep { height: 1px; background: var(--border); margin: 4px 0; }
.menu .dropdown .label { padding: 6px 14px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.seg { flex:1; padding:7px 6px; border:1px solid var(--border); background:var(--panel-2); color:var(--text); border-radius:8px; cursor:pointer; font-size:12px; }
.seg.active { background:var(--accent); color:#fff; border-color:var(--accent); }
input[type=color]{ width:34px; height:24px; border:1px solid var(--border); border-radius:6px; background:none; padding:0; cursor:pointer; }
#stage { flex: 1; position: relative; overflow: hidden; cursor: grab; }
#stage.grabbing { cursor: grabbing; }
svg.tree { width: 100%; height: 100%; display: block; }
.link { fill: none; stroke: var(--border); stroke-width: 2; transition: stroke-width .12s; }
.link.spouse { stroke-width: 2.5; }
.link.mag, .link.magfix { stroke-width: 4.5 !important; filter: drop-shadow(0 0 4px var(--accent)); }
.node { cursor: pointer; }
.node .card {
fill: var(--panel); stroke: var(--border); stroke-width: 1.5;
filter: drop-shadow(var(--node-shadow));
}
.node.male .card { stroke: var(--male); }
.node.female .card { stroke: var(--female); }
.node .accent { }
.node.male .accent { fill: var(--male-bg); }
.node.female .accent { fill: var(--female-bg); }
.node .nm { font-size: 13px; font-weight: 700; fill: var(--text); }
.node .yr { font-size: 11px; fill: var(--muted); }
.node.highlight .card { stroke: var(--accent); stroke-width: 3; }
.node .avatar-txt { font-size: 15px; font-weight: 700; fill: #fff; }
.collapse {
fill: var(--accent); cursor: pointer;
}
.collapse-txt { fill: #fff; font-size: 12px; font-weight: 700; pointer-events: none; }
.controls {
position: absolute; left: 14px; bottom: 14px; display: flex; flex-direction: column; gap: 6px; z-index: 10;
}
.controls button {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); font-size: 18px; cursor: pointer; box-shadow: var(--shadow);
}
.legend {
position: absolute; right: 14px; bottom: 14px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; font-size: 12px;
box-shadow: var(--shadow); display: flex; gap: 14px; align-items: center;
}
.legend .dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: -1px; }
/* Modal */
.overlay {
position: fixed; inset: 0; background: rgba(10,14,25,.55); backdrop-filter: blur(2px);
display: none; align-items: center; justify-content: center; z-index: 50; padding: 16px;
}
.overlay.show { display: flex; }
.modal {
background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
width: 720px; max-width: 100%; max-height: 90vh; overflow: auto; box-shadow: var(--shadow);
}
.modal .head {
display: flex; gap: 16px; padding: 22px; border-bottom: 1px solid var(--border);
align-items: center; position: relative;
}
.modal .avatar {
width: 92px; height: 92px; border-radius: 18px; flex: none; display: grid; place-items: center;
font-size: 32px; font-weight: 700; color: #fff;
}
.modal h2 { margin: 0 0 4px; font-size: 22px; }
.modal .sub { color: var(--muted); font-size: 14px; }
.modal .close { position: absolute; right: 16px; top: 16px; border: none; background: var(--panel-2); width: 34px; height: 34px; border-radius: 9px; cursor: pointer; color: var(--text); font-size: 18px; }
.modal .body { padding: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
.modal .full { grid-column: 1 / -1; }
.field-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 3px; }
.field-val { font-size: 14px; margin-bottom: 14px; line-height: 1.45; }
.rel-group { margin-bottom: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; margin: 0 6px 6px 0;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 20px; cursor: pointer; font-size: 13px;
}
.chip:hover { border-color: var(--accent); }
.chip .d { width: 8px; height: 8px; border-radius: 50%; }
.gallery { display: flex; gap: 8px; flex-wrap: wrap; }
.gallery .ph {
width: 76px; height: 76px; border-radius: 12px; display: grid; place-items: center;
background: var(--panel-2); border: 1px solid var(--border); font-size: 22px;
}
.modal .actions { padding: 0 22px 20px; display: flex; gap: 8px; }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 22px; }
.form-grid .full { grid-column: 1 / -1; }
.form-grid label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; }
.form-grid input, .form-grid select, .form-grid textarea {
width: 100%; padding: 9px 11px; border: 1px solid var(--border); border-radius: 9px;
background: var(--panel-2); color: var(--text); font-size: 14px; font-family: inherit;
}
.form-grid textarea { resize: vertical; min-height: 64px; }
.toast {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: var(--bg); padding: 10px 18px; border-radius: 10px; font-size: 14px;
opacity: 0; transition: .25s; z-index: 80; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 640px) {
.modal .body, .form-grid { grid-template-columns: 1fr; }
.brand span.t { display: none; }
.search input { width: 150px; }
}
</style>
</head>
<body>
<header>
<div class="brand"><div class="logo">🌳</div><span class="t">Родословное дерево</span></div>
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="searchInput" placeholder="Поиск по имени или фамилии…" autocomplete="off" />
<div class="results" id="results"></div>
</div>
<div class="spacer"></div>
<button class="btn primary" id="addBtn"> Добавить</button>
<div class="menu" id="ioMenu">
<button class="btn" id="ioBtn">Импорт / Экспорт ▾</button>
<div class="dropdown">
<div class="label">Экспорт</div>
<button data-act="exp-json">Экспорт в JSON</button>
<button data-act="exp-csv">Экспорт в CSV</button>
<button data-act="exp-gedcom">Экспорт в GEDCOM</button>
<div class="sep"></div>
<div class="label">Импорт</div>
<button data-act="imp-json">Импорт JSON</button>
<button data-act="imp-csv">Импорт CSV</button>
<button data-act="imp-gedcom">Импорт GEDCOM</button>
<div class="sep"></div>
<button data-act="print">🖨 Печать / PDF</button>
</div>
</div>
<div class="menu" id="vizMenu">
<button class="btn" id="vizBtn" title="Визуализация веток">🌿 Вид ▾</button>
<div class="dropdown" style="min-width:240px;padding:6px 0">
<div class="label">Ориентация дерева</div>
<div style="display:flex;gap:6px;padding:4px 14px 8px">
<button class="seg" data-orient="vertical">Вертикально</button>
<button class="seg" data-orient="horizontal">Горизонтально</button>
</div>
<div class="label">Вид связей (лучей)</div>
<div style="display:flex;gap:6px;padding:4px 14px 8px">
<button class="seg" data-edge="ortho">Примагнич.</button>
<button class="seg" data-edge="curve">Кривые</button>
<button class="seg" data-edge="straight">Прямые</button>
</div>
<div class="label">Цвета лучей</div>
<div style="padding:4px 14px 8px;display:grid;grid-template-columns:1fr auto;gap:8px 6px;align-items:center;font-size:13px">
<span>Родители — дети</span><input type="color" data-color="parent" value="#7c8aa5">
<span>Супруги</span><input type="color" data-color="spouse" value="#c9a14a">
<span>Усыновление</span><input type="color" data-color="adopt" value="#e06aa3">
</div>
<div class="sep"></div>
<button data-act-viz="expand">▽ Развернуть все ветки</button>
<button data-act-viz="collapse">△ Свернуть все ветки</button>
</div>
</div>
<button class="btn" id="themeBtn" title="Тема">🌙</button>
</header>
<div id="stage">
<svg class="tree" id="svg">
<g id="viewport">
<g id="links"></g>
<g id="nodes"></g>
</g>
</svg>
<div class="controls">
<button id="zoomIn"></button>
<button id="zoomOut"></button>
<button id="fit" title="Показать всё"></button>
</div>
<div class="legend">
<span><span class="dot" style="background:var(--male)"></span>Мужчины</span>
<span><span class="dot" style="background:var(--female)"></span>Женщины</span>
<span id="count"></span>
</div>
</div>
<input type="file" id="fileInput" style="display:none" />
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
<script>
/* ====================== Данные (тестовый набор) ====================== */
let PEOPLE = [
{id:"p1", firstName:"Иван", lastName:"Соколов", middleName:"Петрович", gender:"male", birthDate:"1928-03-12", deathDate:"1998-11-04", birthPlace:"Тула, СССР", bio:"Глава рода, ветеран, работал инженером-конструктором на оборонном заводе.", contacts:"", notes:"Основатель семейного дела.", context:"Призван в армию в 1944. Награждён за восстановление завода. Вёл подробный дневник, который хранится в семейном архиве.", resourceUrl:"https://ru.wikipedia.org/wiki/Память_народа", spouseIds:["p2"], gallery:["📜","🏅","🏭"]},
{id:"p2", firstName:"Мария", lastName:"Соколова", middleName:"Андреевна", gender:"female", birthDate:"1931-07-22", deathDate:"2010-02-19", birthPlace:"Калуга, СССР", bio:"Учительница русского языка и литературы, хранительница семейного архива.", contacts:"", notes:"", spouseIds:["p1"], gallery:["📚","🌷"]},
{id:"p3", firstName:"Сергей", lastName:"Соколов", middleName:"Иванович", gender:"male", birthDate:"1955-05-30", deathDate:"", birthPlace:"Тула, СССР", bio:"Старший сын, врач-хирург, кандидат медицинских наук.", contacts:"sergey@example.com", notes:"", fatherId:"p1", motherId:"p2", spouseIds:["p4"], gallery:["🩺","🎓"]},
{id:"p4", firstName:"Ольга", lastName:"Соколова", middleName:"Викторовна", gender:"female", birthDate:"1958-09-14", deathDate:"", birthPlace:"Рязань, СССР", bio:"Архитектор, преподаёт в университете.", contacts:"olga@example.com", notes:"", spouseIds:["p3"], gallery:["📐"]},
{id:"p5", firstName:"Елена", lastName:"Кузнецова", middleName:"Ивановна", gender:"female", birthDate:"1959-12-02", deathDate:"", birthPlace:"Тула, СССР", bio:"Младшая дочь, экономист.", contacts:"", notes:"Сменила фамилию после замужества.", fatherId:"p1", motherId:"p2", spouseIds:["p6"], gallery:["💼"]},
{id:"p6", firstName:"Дмитрий", lastName:"Кузнецов", middleName:"Олегович", gender:"male", birthDate:"1956-04-18", deathDate:"", birthPlace:"Москва, СССР", bio:"Журналист.", contacts:"", notes:"", spouseIds:["p5"], gallery:["📰"]},
{id:"p7", firstName:"Алексей", lastName:"Соколов", middleName:"Сергеевич", gender:"male", birthDate:"1982-08-08", deathDate:"", birthPlace:"Тула, Россия", bio:"Программист, основатель IT-компании.", contacts:"alex@example.com", notes:"", fatherId:"p3", motherId:"p4", spouseIds:["p8"], gallery:["💻","🚀"]},
{id:"p8", firstName:"Анна", lastName:"Соколова", middleName:"Павловна", gender:"female", birthDate:"1985-01-25", deathDate:"", birthPlace:"Санкт-Петербург, Россия", bio:"Дизайнер, иллюстратор.", contacts:"", notes:"", spouseIds:["p7"], gallery:["🎨"]},
{id:"p9", firstName:"Наталья", lastName:"Соколова", middleName:"Сергеевна", gender:"female", birthDate:"1986-11-30", deathDate:"", birthPlace:"Тула, Россия", bio:"Биолог-генетик.", contacts:"", notes:"", fatherId:"p3", motherId:"p4", spouseIds:[], gallery:["🧬"]},
{id:"p10", firstName:"Павел", lastName:"Кузнецов", middleName:"Дмитриевич", gender:"male", birthDate:"1984-06-11", deathDate:"", birthPlace:"Москва, Россия", bio:"Музыкант, композитор.", contacts:"", notes:"", fatherId:"p6", motherId:"p5", spouseIds:["p11"], gallery:["🎼","🎹"]},
{id:"p11", firstName:"Светлана", lastName:"Кузнецова", middleName:"Игоревна", gender:"female", birthDate:"1987-03-03", deathDate:"", birthPlace:"Москва, Россия", bio:"Врач-педиатр.", contacts:"", notes:"", spouseIds:["p10"], gallery:[]},
{id:"p12", firstName:"Максим", lastName:"Соколов", middleName:"Алексеевич", gender:"male", birthDate:"2010-02-14", deathDate:"", birthPlace:"Москва, Россия", bio:"Школьник, увлекается робототехникой.", contacts:"", notes:"", fatherId:"p7", motherId:"p8", spouseIds:[], gallery:["🤖"]},
{id:"p13", firstName:"София", lastName:"Соколова", middleName:"Алексеевна", gender:"female", birthDate:"2013-09-09", deathDate:"", birthPlace:"Москва, Россия", bio:"Школьница, занимается балетом.", contacts:"", notes:"Удочерена в 2015 году.", fatherId:"p7", motherId:"p8", adopted:true, spouseIds:[], gallery:["🩰"]},
{id:"p14", firstName:"Артём", lastName:"Кузнецов", middleName:"Павлович", gender:"male", birthDate:"2012-12-20", deathDate:"", birthPlace:"Москва, Россия", bio:"Школьник.", contacts:"", notes:"", fatherId:"p10", motherId:"p11", spouseIds:[], gallery:[]}
];
/* ====================== Утилиты ====================== */
const $ = s => document.querySelector(s);
const byId = {};
function reindex(){ for(const k in byId) delete byId[k]; PEOPLE.forEach(p=>byId[p.id]=p); }
reindex();
const NODE_W = 168, NODE_H = 96, V_GAP = 78, COUPLE_GAP = 26, H_GAP = 34;
let collapsed = new Set();
/* Настройки визуализации */
const settings = {
orientation: "vertical", // vertical | horizontal
edgeStyle: "ortho", // straight | ortho (примагниченные) | curve
colors: { parent:"#7c8aa5", spouse:"#c9a14a", adopt:"#e06aa3" }
};
function fullName(p){ return [p.lastName, p.firstName, p.middleName].filter(Boolean).join(" "); }
function shortName(p){ return [p.firstName, p.lastName].filter(Boolean).join(" "); }
function years(p){ const b=p.birthDate?p.birthDate.slice(0,4):"?"; const d=p.deathDate?p.deathDate.slice(0,4):""; return d?`${b} ${d}`:`р. ${b}`; }
function initials(p){ return ((p.firstName||" ")[0]+(p.lastName||" ")[0]).toUpperCase(); }
function genderColor(p){ return p.gender==="female" ? "var(--female)" : "var(--male)"; }
function childrenOf(id){ return PEOPLE.filter(c => c.fatherId===id || c.motherId===id); }
function primaryParentChildren(coupleA, coupleB){
return PEOPLE.filter(c => {
const pr = c.fatherId || c.motherId; // «владелец» раскладки ребёнка — основной родитель
return pr===coupleA || (coupleB && pr===coupleB);
});
}
/* ====================== Раскладка дерева (поддержка ориентаций) ====================== */
let positioned = {};
let cursor = 0;
function curConf(){
const horiz = settings.orientation==="horizontal";
return {
horiz,
BW: horiz ? NODE_H : NODE_W, // размер узла вдоль оси «ширины»
CG: horiz ? 22 : COUPLE_GAP, // зазор между супругами
FG: horiz ? 30 : H_GAP, // зазор между семьями
DEP: horiz ? (NODE_W + 120) : (NODE_H + V_GAP) // расстояние между поколениями
};
}
function setPos(id, b, depth, C){
positioned[id] = C.horiz ? {x:depth*C.DEP, y:b, depth, b} : {x:b, y:depth*C.DEP, depth, b};
}
function layout(id, depth, C){
if(positioned[id]) return;
const a = byId[id];
const bId = (a.spouseIds||[]).find(s => byId[s] && !positioned[s]);
let kids = [];
if(!collapsed.has(id) && !(bId && collapsed.has(bId))){
kids = primaryParentChildren(id, bId).map(c=>c.id);
}
if(kids.length===0){
setPos(id, cursor, depth, C); cursor += C.BW;
if(bId){ cursor += C.CG; setPos(bId, cursor, depth, C); cursor += C.BW; }
cursor += C.FG;
} else {
kids.forEach(k => layout(k, depth+1, C));
const centers = kids.map(coupleCenterB);
const mid = (Math.min(...centers)+Math.max(...centers))/2;
const coupleW = bId ? C.BW*2+C.CG : C.BW;
const aB = mid - coupleW/2;
setPos(id, aB, depth, C);
if(bId) setPos(bId, aB+C.BW+C.CG, depth, C);
}
}
function coupleCenterB(id){
const a = positioned[id]; if(!a) return 0;
const C = curConf();
const bId = (byId[id].spouseIds||[]).find(s=>positioned[s]);
if(bId){ const b = positioned[bId]; return (a.b + b.b + C.BW)/2; }
return a.b + C.BW/2;
}
/* ====================== Отрисовка ====================== */
const SVGNS = "http://www.w3.org/2000/svg";
function el(tag, attrs){ const e=document.createElementNS(SVGNS, tag); for(const k in attrs) e.setAttribute(k, attrs[k]); return e; }
function render(){
positioned = {}; cursor = 0;
const C = curConf();
const roots = PEOPLE.filter(p => !p.fatherId && !p.motherId);
roots.forEach(r => layout(r.id, 0, C));
PEOPLE.forEach(p => { if(!positioned[p.id]) layout(p.id, 0, C); });
const linksG = $("#links"), nodesG = $("#nodes");
linksG.innerHTML = ""; nodesG.innerHTML = "";
// супружеские связи
const drawn = new Set();
PEOPLE.forEach(p => {
const a = positioned[p.id]; if(!a) return;
(p.spouseIds||[]).forEach(sid => {
const b = positioned[sid]; if(!b) return;
const key = [p.id,sid].sort().join("-"); if(drawn.has(key)) return; drawn.add(key);
let lk;
if(C.horiz){
const top = a.y<b.y?a:b, bot = a.y<b.y?b:a;
lk = el("line", {x1:top.x+NODE_W/2, y1:top.y+NODE_H, x2:bot.x+NODE_W/2, y2:bot.y});
} else {
const L = a.x<b.x?a:b, R = a.x<b.x?b:a;
lk = el("line", {x1:L.x+NODE_W, y1:L.y+NODE_H/2, x2:R.x, y2:R.y+NODE_H/2});
}
lk.setAttribute("class","link spouse");
lk.setAttribute("stroke", settings.colors.spouse);
lk.setAttribute("data-a", p.id); lk.setAttribute("data-b", sid);
linksG.appendChild(lk);
});
});
// связи родитель-ребёнок
const handledCouple = new Set();
PEOPLE.forEach(p => {
if(collapsed.has(p.id)) return;
const a = positioned[p.id]; if(!a) return;
const bId = (p.spouseIds||[]).find(s=>positioned[s]);
const coupleKey = [p.id, bId||""].sort().join("-");
if(handledCouple.has(coupleKey)) return; handledCouple.add(coupleKey);
const kids = primaryParentChildren(p.id, bId);
if(!kids.length) return;
const cB = coupleCenterB(p.id);
let sx, sy;
if(C.horiz){ sx = a.x+NODE_W; sy = cB; } else { sx = cB; sy = a.y+NODE_H; }
kids.forEach(c => {
const cc = positioned[c.id]; if(!cc) return;
let ex, ey;
if(C.horiz){ ex = cc.x; ey = cc.y+NODE_H/2; } else { ex = cc.x+NODE_W/2; ey = cc.y; }
const path = el("path", {class:"link", d:edgePath(sx,sy,ex,ey,C), fill:"none"});
path.setAttribute("stroke", c.adopted ? settings.colors.adopt : settings.colors.parent);
if(c.adopted) path.setAttribute("stroke-dasharray","6 5");
path.setAttribute("data-a", p.id); path.setAttribute("data-b", c.id);
if(bId) path.setAttribute("data-c", bId);
linksG.appendChild(path);
});
});
drawNodes(C);
$("#count").textContent = PEOPLE.length + " чел.";
}
function edgePath(sx,sy,ex,ey,C){
if(settings.edgeStyle==="straight") return `M ${sx} ${sy} L ${ex} ${ey}`;
if(settings.edgeStyle==="curve"){
if(C.horiz){ const mx=(sx+ex)/2; return `M ${sx} ${sy} C ${mx} ${sy} ${mx} ${ey} ${ex} ${ey}`; }
const my=(sy+ey)/2; return `M ${sx} ${sy} C ${sx} ${my} ${ex} ${my} ${ex} ${ey}`;
}
// ortho «примагниченные»
if(C.horiz){ const mx=(sx+ex)/2; return `M ${sx} ${sy} L ${mx} ${sy} L ${mx} ${ey} L ${ex} ${ey}`; }
const my=(sy+ey)/2; return `M ${sx} ${sy} L ${sx} ${my} L ${ex} ${my} L ${ex} ${ey}`;
}
function drawNodes(C){
const nodesG = $("#nodes");
PEOPLE.forEach(p => {
const pos = positioned[p.id]; if(!pos) return;
const g = el("g", {class:`node ${p.gender}`, transform:`translate(${pos.x},${pos.y})`, "data-id":p.id});
g.appendChild(el("rect", {class:"card", x:0, y:0, width:NODE_W, height:NODE_H, rx:14}));
g.appendChild(el("rect", {class:"accent", x:0, y:0, width:NODE_W, height:6, rx:3}));
// аватар
const av = el("g", {transform:"translate(14,18)"});
av.appendChild(el("circle", {cx:26, cy:26, r:26, fill:genderColor(p)}));
const t = el("text", {class:"avatar-txt", x:26, y:31, "text-anchor":"middle"}); t.textContent = initials(p);
av.appendChild(t);
g.appendChild(av);
// имя + годы
const nm = el("text", {class:"nm", x:72, y:38}); nm.textContent = trim(shortName(p), 14);
const pt = el("text", {class:"nm", x:72, y:55, "font-size":"11", "font-weight":"500", fill:"var(--muted)"}); pt.textContent = trim(p.middleName||"", 16);
const yr = el("text", {class:"yr", x:72, y:74}); yr.textContent = years(p);
g.appendChild(nm); g.appendChild(pt); g.appendChild(yr);
// кнопка сворачивания, если есть дети
if(childrenOf(p.id).length){
const cg = el("g", {class:"collapse-wrap", transform:`translate(${NODE_W-18},${NODE_H-18})`, "data-collapse":p.id});
cg.appendChild(el("circle", {class:"collapse", cx:0, cy:0, r:11}));
const ct = el("text", {class:"collapse-txt", x:0, y:4, "text-anchor":"middle"}); ct.textContent = collapsed.has(p.id) ? "+" : "";
cg.appendChild(ct);
g.appendChild(cg);
}
nodesG.appendChild(g);
});
$("#count").textContent = PEOPLE.length + " чел.";
}
function trim(s, n){ return s.length>n ? s.slice(0,n-1)+"…" : s; }
/* ====================== Pan / Zoom ====================== */
let scale = 1, tx = 60, ty = 40;
const vp = $("#viewport"), stage = $("#stage");
function applyTransform(){ vp.setAttribute("transform", `translate(${tx},${ty}) scale(${scale})`); }
applyTransform();
stage.addEventListener("wheel", e => {
e.preventDefault();
const rect = stage.getBoundingClientRect();
const mx = e.clientX-rect.left, my = e.clientY-rect.top;
const factor = e.deltaY<0 ? 1.12 : 1/1.12;
const ns = Math.min(2.5, Math.max(0.15, scale*factor));
tx = mx - (mx-tx)*(ns/scale); ty = my - (my-ty)*(ns/scale);
scale = ns; applyTransform();
}, {passive:false});
let dragging=false, lastX, lastY, moved=false;
stage.addEventListener("mousedown", e => { dragging=true; moved=false; lastX=e.clientX; lastY=e.clientY; stage.classList.add("grabbing"); });
window.addEventListener("mousemove", e => {
if(!dragging) return;
const dx=e.clientX-lastX, dy=e.clientY-lastY;
if(Math.abs(dx)+Math.abs(dy)>3) moved=true;
tx+=dx; ty+=dy; lastX=e.clientX; lastY=e.clientY; applyTransform();
});
window.addEventListener("mouseup", ()=>{ dragging=false; stage.classList.remove("grabbing"); });
// touch
let pinchDist=0;
stage.addEventListener("touchstart", e=>{
if(e.touches.length===1){ dragging=true; moved=false; lastX=e.touches[0].clientX; lastY=e.touches[0].clientY; }
else if(e.touches.length===2){ pinchDist = dist2(e.touches); }
},{passive:true});
stage.addEventListener("touchmove", e=>{
if(e.touches.length===1 && dragging){
const dx=e.touches[0].clientX-lastX, dy=e.touches[0].clientY-lastY;
if(Math.abs(dx)+Math.abs(dy)>3) moved=true;
tx+=dx; ty+=dy; lastX=e.touches[0].clientX; lastY=e.touches[0].clientY; applyTransform();
} else if(e.touches.length===2){
const d=dist2(e.touches); const factor=d/pinchDist; pinchDist=d;
const ns=Math.min(2.5,Math.max(0.15,scale*factor)); scale=ns; applyTransform();
}
},{passive:true});
stage.addEventListener("touchend", ()=> dragging=false);
function dist2(t){ return Math.hypot(t[0].clientX-t[1].clientX, t[0].clientY-t[1].clientY); }
$("#zoomIn").onclick = ()=>{ scale=Math.min(2.5,scale*1.2); applyTransform(); };
$("#zoomOut").onclick = ()=>{ scale=Math.max(0.15,scale/1.2); applyTransform(); };
$("#fit").onclick = fitView;
function fitView(){
const xs=[], ys=[];
for(const k in positioned){ xs.push(positioned[k].x, positioned[k].x+NODE_W); ys.push(positioned[k].y, positioned[k].y+NODE_H); }
if(!xs.length) return;
const minX=Math.min(...xs), maxX=Math.max(...xs), minY=Math.min(...ys), maxY=Math.max(...ys);
const rect=stage.getBoundingClientRect();
const sx=(rect.width-80)/(maxX-minX), sy=(rect.height-80)/(maxY-minY);
scale=Math.min(1.2, Math.max(0.15, Math.min(sx,sy)));
tx=(rect.width-(maxX-minX)*scale)/2 - minX*scale;
ty=(rect.height-(maxY-minY)*scale)/2 - minY*scale;
applyTransform();
}
/* клики по узлам */
$("#nodes").addEventListener("click", e=>{
if(moved) return;
const col = e.target.closest("[data-collapse]");
if(col){ const id=col.getAttribute("data-collapse"); collapsed.has(id)?collapsed.delete(id):collapsed.add(id); render(); return; }
const node = e.target.closest(".node");
if(node){ openCard(node.getAttribute("data-id")); }
});
function centerOn(id){
const pos=positioned[id]; if(!pos) return;
const rect=stage.getBoundingClientRect();
scale=Math.max(scale,0.8);
tx=rect.width/2 - (pos.x+NODE_W/2)*scale;
ty=rect.height/2 - (pos.y+NODE_H/2)*scale;
applyTransform();
document.querySelectorAll(".node.highlight").forEach(n=>n.classList.remove("highlight"));
const n=document.querySelector(`.node[data-id="${id}"]`); if(n) n.classList.add("highlight");
document.querySelectorAll(".link.magfix").forEach(l=>l.classList.remove("magfix"));
document.querySelectorAll(`.link[data-a="${id}"],.link[data-b="${id}"],.link[data-c="${id}"]`).forEach(l=>l.classList.add("magfix"));
}
/* ====================== Поиск ====================== */
const searchInput=$("#searchInput"), results=$("#results");
searchInput.addEventListener("input", ()=>{
const q=searchInput.value.trim().toLowerCase();
if(!q){ results.classList.remove("show"); return; }
const found=PEOPLE.filter(p => fullName(p).toLowerCase().includes(q)).slice(0,8);
results.innerHTML = found.map(p=>`<div class="row" data-id="${p.id}">
<span style="width:9px;height:9px;border-radius:50%;background:${genderColor(p)}"></span>
<span>${fullName(p)}</span><span style="margin-left:auto;color:var(--muted);font-size:12px">${years(p)}</span></div>`).join("") || `<div class="row">Ничего не найдено</div>`;
results.classList.add("show");
});
results.addEventListener("click", e=>{
const row=e.target.closest("[data-id]"); if(!row) return;
const id=row.getAttribute("data-id");
results.classList.remove("show"); searchInput.value="";
centerOn(id); setTimeout(()=>openCard(id), 250);
});
document.addEventListener("click", e=>{ if(!e.target.closest(".search")) results.classList.remove("show"); });
/* ====================== Карточка человека ====================== */
const overlay=$("#overlay");
function relChips(list){ return list.map(p=>`<span class="chip" data-go="${p.id}"><span class="d" style="background:${genderColor(p)}"></span>${shortName(p)}</span>`).join("") || '<span style="color:var(--muted)">—</span>'; }
function openCard(id){
const p=byId[id]; if(!p) return;
const parents=[p.fatherId,p.motherId].map(x=>byId[x]).filter(Boolean);
const spouses=(p.spouseIds||[]).map(x=>byId[x]).filter(Boolean);
const kids=childrenOf(id);
const sibs=PEOPLE.filter(s=>s.id!==id && ((p.fatherId&&s.fatherId===p.fatherId)||(p.motherId&&s.motherId===p.motherId)));
const gallery=(p.gallery||[]).map(g=>`<div class="ph">${g}</div>`).join("") || '<span style="color:var(--muted)">Нет фотографий</span>';
overlay.innerHTML=`<div class="modal">
<div class="head">
<div class="avatar" style="background:${genderColor(p)}">${initials(p)}</div>
<div>
<h2>${fullName(p)}</h2>
<div class="sub">${p.gender==="female"?"Женщина":"Мужчина"} · ${years(p)} ${p.adopted?' · усыновл./удочерён':''}</div>
<div class="sub">${p.birthPlace||""}</div>
</div>
<button class="close" data-close>✕</button>
</div>
<div class="body">
<div class="full"><div class="field-label">Биография</div><div class="field-val">${p.bio||"—"}</div></div>
<div><div class="field-label">Дата рождения</div><div class="field-val">${p.birthDate||"—"}</div></div>
<div><div class="field-label">Дата смерти</div><div class="field-val">${p.deathDate||"—"}</div></div>
<div><div class="field-label">Место рождения</div><div class="field-val">${p.birthPlace||"—"}</div></div>
<div><div class="field-label">Контакты</div><div class="field-val">${p.contacts||"—"}</div></div>
<div class="full"><div class="field-label">Заметки</div><div class="field-val">${p.notes||"—"}</div></div>
<div class="full"><div class="field-label">Контекст / дополнительная информация</div><div class="field-val">${p.context||"—"}</div></div>
${p.resourceUrl?`<div class="full"><div class="field-label">Подробный ресурс</div><div class="field-val">🔗 <a href="${p.resourceUrl}" target="_blank" rel="noopener" style="color:var(--accent)">${p.resourceUrl}</a></div></div>`:""}
<div class="full"><div class="field-label">Родственники</div>
<div class="rel-group"><b>Родители:</b> ${relChips(parents)}</div>
<div class="rel-group"><b>Супруг(а):</b> ${relChips(spouses)}</div>
<div class="rel-group"><b>Дети:</b> ${relChips(kids)}</div>
<div class="rel-group"><b>Братья/сёстры:</b> ${relChips(sibs)}</div>
</div>
<div class="full"><div class="field-label">Галерея фотографий</div><div class="gallery">${gallery}</div></div>
</div>
<div class="actions">
<button class="btn primary" data-edit="${id}">✎ Редактировать</button>
<button class="btn" data-locate="${id}">🎯 Показать в дереве</button>
<button class="btn" data-del="${id}" style="margin-left:auto;color:#e0556b">🗑 Удалить</button>
</div>
</div>`;
overlay.classList.add("show");
}
overlay.addEventListener("click", e=>{
if(e.target===overlay || e.target.closest("[data-close]")){ overlay.classList.remove("show"); return; }
const go=e.target.closest("[data-go]"); if(go){ openCard(go.getAttribute("data-go")); return; }
const loc=e.target.closest("[data-locate]"); if(loc){ overlay.classList.remove("show"); centerOn(loc.getAttribute("data-locate")); return; }
const ed=e.target.closest("[data-edit]"); if(ed){ openForm(ed.getAttribute("data-edit")); return; }
const del=e.target.closest("[data-del]"); if(del){ deletePerson(del.getAttribute("data-del")); return; }
});
/* ====================== Добавление / Редактирование ====================== */
function openForm(id){
const p = id ? byId[id] : {id:"p"+Date.now(), gender:"male", spouseIds:[], gallery:[]};
const opt = (val,sel)=>`<option value="${val}" ${val===sel?"selected":""}>`;
const personOpts = sel => `<option value="">—</option>`+PEOPLE.filter(x=>x.id!==p.id).map(x=>`<option value="${x.id}" ${sel===x.id?"selected":""}>${fullName(x)}</option>`).join("");
const spouseSel = (p.spouseIds||[])[0]||"";
overlay.innerHTML=`<div class="modal"><div class="head"><h2>${id?"Редактирование":"Новый человек"}</h2><button class="close" data-close>✕</button></div>
<div class="form-grid">
<div><label>Имя</label><input id="f_first" value="${p.firstName||""}"></div>
<div><label>Фамилия</label><input id="f_last" value="${p.lastName||""}"></div>
<div><label>Отчество</label><input id="f_mid" value="${p.middleName||""}"></div>
<div><label>Пол</label><select id="f_gender">${opt("male",p.gender)}Мужской</option>${opt("female",p.gender)}Женский</option></select></div>
<div><label>Дата рождения</label><input id="f_birth" type="date" value="${p.birthDate||""}"></div>
<div><label>Дата смерти</label><input id="f_death" type="date" value="${p.deathDate||""}"></div>
<div><label>Отец</label><select id="f_father">${personOpts(p.fatherId)}</select></div>
<div><label>Мать</label><select id="f_mother">${personOpts(p.motherId)}</select></div>
<div><label>Супруг(а)</label><select id="f_spouse">${personOpts(spouseSel)}</select></div>
<div><label>Место рождения</label><input id="f_place" value="${p.birthPlace||""}"></div>
<div class="full"><label>Контакты</label><input id="f_contacts" value="${p.contacts||""}"></div>
<div class="full"><label>Биография</label><textarea id="f_bio">${p.bio||""}</textarea></div>
<div class="full"><label>Заметки</label><textarea id="f_notes">${p.notes||""}</textarea></div>
<div class="full"><label>Контекст / дополнительная информация</label><textarea id="f_context" placeholder="Любой дополнительный контекст о человеке…">${p.context||""}</textarea></div>
<div class="full"><label>Ссылка на подробный ресурс</label><input id="f_resource" type="url" placeholder="https://…" value="${p.resourceUrl||""}"></div>
</div>
<div class="actions"><button class="btn primary" id="saveBtn">💾 Сохранить</button><button class="btn" data-close>Отмена</button></div>
</div>`;
overlay.classList.add("show");
$("#saveBtn").onclick = ()=>{
const np = {...p,
firstName:$("#f_first").value.trim(), lastName:$("#f_last").value.trim(), middleName:$("#f_mid").value.trim(),
gender:$("#f_gender").value, birthDate:$("#f_birth").value, deathDate:$("#f_death").value,
fatherId:$("#f_father").value||undefined, motherId:$("#f_mother").value||undefined,
birthPlace:$("#f_place").value.trim(), contacts:$("#f_contacts").value.trim(),
bio:$("#f_bio").value.trim(), notes:$("#f_notes").value.trim(),
context:$("#f_context").value.trim(), resourceUrl:$("#f_resource").value.trim(),
gallery:p.gallery||[]
};
const sp=$("#f_spouse").value;
np.spouseIds = sp ? [sp] : [];
const exists = byId[np.id];
if(exists){ Object.assign(exists, np); } else { PEOPLE.push(np); }
// двунаправленная супружеская связь
PEOPLE.forEach(x=>{ if(x.spouseIds) x.spouseIds = x.spouseIds.filter(s=>s!==np.id); });
if(sp){ const s=byId[sp]; if(s){ s.spouseIds=s.spouseIds||[]; if(!s.spouseIds.includes(np.id)) s.spouseIds.push(np.id); } }
reindex(); collapsed.clear(); render();
overlay.classList.remove("show");
toast(exists?"Изменения сохранены":"Человек добавлен");
};
}
$("#addBtn").onclick = ()=>openForm(null);
function deletePerson(id){
if(!confirm("Удалить этого человека из дерева?")) return;
PEOPLE = PEOPLE.filter(p=>p.id!==id);
PEOPLE.forEach(p=>{
if(p.fatherId===id) p.fatherId=undefined;
if(p.motherId===id) p.motherId=undefined;
if(p.spouseIds) p.spouseIds=p.spouseIds.filter(s=>s!==id);
});
reindex(); render(); overlay.classList.remove("show"); toast("Запись удалена");
}
/* ====================== Тема ====================== */
const themeBtn=$("#themeBtn");
function setTheme(t){ document.documentElement.setAttribute("data-theme", t); themeBtn.textContent = t==="dark"?"☀️":"🌙"; }
themeBtn.onclick = ()=>{ const cur=document.documentElement.getAttribute("data-theme")==="dark"?"light":"dark"; setTheme(cur); };
setTheme(window.matchMedia && matchMedia("(prefers-color-scheme: dark)").matches ? "dark":"light");
/* ====================== Импорт / Экспорт ====================== */
const ioMenu=$("#ioMenu");
$("#ioBtn").onclick = e=>{ e.stopPropagation(); ioMenu.classList.toggle("open"); };
document.addEventListener("click", e=>{ if(!e.target.closest("#ioMenu")) ioMenu.classList.remove("open"); });
ioMenu.querySelector(".dropdown").addEventListener("click", e=>{
const b=e.target.closest("[data-act]"); if(!b) return;
ioMenu.classList.remove("open");
const act=b.getAttribute("data-act");
if(act==="exp-json") download("family-tree.json", JSON.stringify(PEOPLE,null,2));
if(act==="exp-csv") download("family-tree.csv", toCSV());
if(act==="exp-gedcom") download("family-tree.ged", toGEDCOM());
if(act==="print") window.print();
if(act.startsWith("imp-")) importFile(act.split("-")[1]);
});
function download(name, content){
const blob=new Blob([content],{type:"text/plain;charset=utf-8"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=name; a.click();
toast("Файл «"+name+"» экспортирован");
}
function toCSV(){
const cols=["id","firstName","lastName","middleName","gender","birthDate","deathDate","birthPlace","contacts","bio","notes","fatherId","motherId","spouseIds"];
const esc=v=>`"${String(v??"").replace(/"/g,'""')}"`;
const rows=PEOPLE.map(p=>cols.map(c=>esc(c==="spouseIds"?(p.spouseIds||[]).join(";"):p[c])).join(","));
return cols.join(",")+"\n"+rows.join("\n");
}
function toGEDCOM(){
let g="0 HEAD\n1 SOUR FamilyTreePreview\n1 GEDC\n2 VERS 5.5.1\n1 CHAR UTF-8\n";
const fams=[]; const famKey={};
PEOPLE.forEach(p=>{
g+=`0 @${p.id.toUpperCase()}@ INDI\n1 NAME ${p.firstName} /${p.lastName}/\n1 SEX ${p.gender==="female"?"F":"M"}\n`;
if(p.birthDate||p.birthPlace){ g+=`1 BIRT\n`; if(p.birthDate)g+=`2 DATE ${p.birthDate}\n`; if(p.birthPlace)g+=`2 PLAC ${p.birthPlace}\n`; }
if(p.deathDate){ g+=`1 DEAT\n2 DATE ${p.deathDate}\n`; }
});
// семьи по парам родителей
let fi=1;
PEOPLE.forEach(c=>{
if(c.fatherId||c.motherId){
const key=(c.fatherId||"")+"|"+(c.motherId||"");
if(!famKey[key]){ famKey[key]={id:"F"+(fi++), husb:c.fatherId, wife:c.motherId, chil:[]}; fams.push(famKey[key]); }
famKey[key].chil.push(c.id);
}
});
fams.forEach(f=>{
g+=`0 @${f.id}@ FAM\n`;
if(f.husb)g+=`1 HUSB @${f.husb.toUpperCase()}@\n`;
if(f.wife)g+=`1 WIFE @${f.wife.toUpperCase()}@\n`;
f.chil.forEach(c=>g+=`1 CHIL @${c.toUpperCase()}@\n`);
});
g+="0 TRLR\n"; return g;
}
let importKind="json";
function importFile(kind){ importKind=kind; $("#fileInput").value=""; $("#fileInput").click(); }
$("#fileInput").addEventListener("change", e=>{
const file=e.target.files[0]; if(!file) return;
const r=new FileReader();
r.onload = ()=>{
try{
if(importKind==="json") PEOPLE=normalize(JSON.parse(r.result));
else if(importKind==="csv") PEOPLE=fromCSV(r.result);
else if(importKind==="gedcom") PEOPLE=fromGEDCOM(r.result);
reindex(); collapsed.clear(); render(); fitView();
toast("Импортировано: "+PEOPLE.length+" чел.");
}catch(err){ alert("Ошибка импорта: "+err.message); }
};
r.readAsText(file);
});
function normalize(arr){ return arr.map(p=>({...p, spouseIds:p.spouseIds||[], gallery:p.gallery||[]})); }
function fromCSV(text){
const lines=text.split(/\r?\n/).filter(l=>l.trim());
const cols=parseCSVLine(lines[0]);
return lines.slice(1).map(l=>{
const vals=parseCSVLine(l); const o={};
cols.forEach((c,i)=>o[c]=vals[i]);
o.spouseIds = o.spouseIds ? o.spouseIds.split(";").filter(Boolean) : [];
o.gallery=[]; return o;
});
}
function parseCSVLine(line){
const out=[]; let cur="",q=false;
for(let i=0;i<line.length;i++){ const ch=line[i];
if(q){ if(ch==='"'){ if(line[i+1]==='"'){cur+='"';i++;} else q=false; } else cur+=ch; }
else { if(ch===','){out.push(cur);cur="";} else if(ch==='"')q=true; else cur+=ch; }
} out.push(cur); return out;
}
function fromGEDCOM(text){
const lines=text.split(/\r?\n/); const indi={}; const fam={}; let cur=null, ctx=null, curFam=null;
lines.forEach(line=>{
const m=line.match(/^(\d+)\s+(@\w+@\s+)?(\w+)(\s+(.*))?$/); if(!m) return;
const level=+m[1], tagOrId=m[2]?m[2].trim():null, kw=m[3], val=m[5]||"";
if(level===0){
if(val==="INDI"){ const id=tagOrId.replace(/@/g,"").toLowerCase(); cur=indi[id]={id, spouseIds:[], gallery:[]}; ctx=null; curFam=null; }
else if(val==="FAM"){ const id=tagOrId.replace(/@/g,""); curFam=fam[id]={chil:[]}; cur=null; }
else { cur=null; curFam=null; }
} else if(cur){
if(kw==="NAME"){ const nm=val.match(/(.*)\/(.*)\//); if(nm){ cur.firstName=nm[1].trim(); cur.lastName=nm[2].trim(); } else cur.firstName=val.trim(); }
else if(kw==="SEX") cur.gender = val.trim()==="F"?"female":"male";
else if(kw==="BIRT") ctx="BIRT"; else if(kw==="DEAT") ctx="DEAT";
else if(kw==="DATE"){ if(ctx==="BIRT")cur.birthDate=val.trim(); else if(ctx==="DEAT")cur.deathDate=val.trim(); }
else if(kw==="PLAC"){ if(ctx==="BIRT")cur.birthPlace=val.trim(); }
} else if(curFam){
if(kw==="HUSB") curFam.husb=val.replace(/@/g,"").toLowerCase();
else if(kw==="WIFE") curFam.wife=val.replace(/@/g,"").toLowerCase();
else if(kw==="CHIL") curFam.chil.push(val.replace(/@/g,"").toLowerCase());
}
});
Object.values(fam).forEach(f=>{
if(f.husb&&f.wife){ if(indi[f.husb])indi[f.husb].spouseIds=[f.wife]; if(indi[f.wife])indi[f.wife].spouseIds=[f.husb]; }
f.chil.forEach(c=>{ if(indi[c]){ if(f.husb)indi[c].fatherId=f.husb; if(f.wife)indi[c].motherId=f.wife; } });
});
return Object.values(indi);
}
/* ====================== Toast ====================== */
let toastTimer;
function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove("show"),2200); }
/* ====================== Настройки визуализации ====================== */
const vizMenu=$("#vizMenu");
$("#vizBtn").onclick=e=>{ e.stopPropagation(); vizMenu.classList.toggle("open"); };
document.addEventListener("click", e=>{ if(!e.target.closest("#vizMenu")) vizMenu.classList.remove("open"); });
function syncViz(){
vizMenu.querySelectorAll("[data-orient]").forEach(b=>b.classList.toggle("active", b.dataset.orient===settings.orientation));
vizMenu.querySelectorAll("[data-edge]").forEach(b=>b.classList.toggle("active", b.dataset.edge===settings.edgeStyle));
}
vizMenu.addEventListener("click", e=>{
const o=e.target.closest("[data-orient]");
if(o){ settings.orientation=o.dataset.orient; syncViz(); render(); setTimeout(fitView,30); return; }
const ed=e.target.closest("[data-edge]");
if(ed){ settings.edgeStyle=ed.dataset.edge; syncViz(); render(); return; }
const av=e.target.closest("[data-act-viz]");
if(av){
if(av.dataset.actViz==="collapse") collapsed=new Set(PEOPLE.filter(p=>childrenOf(p.id).length).map(p=>p.id));
else collapsed.clear();
render();
}
});
vizMenu.querySelectorAll("[data-color]").forEach(inp=>{
inp.addEventListener("input", ()=>{ settings.colors[inp.dataset.color]=inp.value; render(); });
});
syncViz();
/* подсветка «примагниченных» лучей при наведении */
function magEdges(id, on){
document.querySelectorAll(`.link[data-a="${id}"],.link[data-b="${id}"],.link[data-c="${id}"]`).forEach(l=>l.classList.toggle("mag", on));
}
$("#nodes").addEventListener("mouseover", e=>{ const n=e.target.closest(".node"); if(n) magEdges(n.dataset.id, true); });
$("#nodes").addEventListener("mouseout", e=>{ const n=e.target.closest(".node"); if(n) magEdges(n.dataset.id, false); });
/* ====================== Старт ====================== */
render();
setTimeout(fitView, 60);
window.addEventListener("resize", ()=>{ /* viewport авто */ });
</script>
</body>
</html>