Files
Heritage/preview/index.html
T

864 lines
48 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Родословное дерево семьи — превью</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600&family=Spectral:wght@300;400;500;600&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #F3ECDD;
--panel: #FBF7EC;
--panel-2: #EFE6D2;
--text: #231B12;
--muted: #6B5E4C;
--border: #DCCFB4;
--accent: #7E3B2D;
--male: #3F6F6F;
--male-bg: #E5EAE7;
--female: #9C5A6F;
--female-bg: #F0E2E5;
--shadow: 0 10px 34px -18px rgba(35,27,18,.40);
--node-shadow: 0 2px 10px -3px rgba(35,27,18,.28);
--serif: 'Playfair Display', Georgia, serif;
--body: 'Spectral', Georgia, serif;
--sans: 'Manrope', system-ui, sans-serif;
}
html[data-theme="dark"] {
--bg: #1C160E;
--panel: #2B2216;
--panel-2: #33291A;
--text: #EDE4D2;
--muted: #A99E88;
--border: #473A26;
--accent: #C9A05E;
--male: #6FA3A0;
--male-bg: #21302E;
--female: #C98AA0;
--female-bg: #321F27;
--shadow: 0 14px 40px -18px rgba(0,0,0,.6);
--node-shadow: 0 3px 12px -3px rgba(0,0,0,.55);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: var(--sans);
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-family: var(--serif); font-weight: 500; font-size: 19px; letter-spacing: .01em; }
.brand .logo {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--gold, #A9854C));
display: grid; place-items: center; color: #F3ECDD; font-size: 15px;
}
.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: 24px; font-family: var(--serif); font-weight: 500; }
.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>
<a class="brand" href="../landing/index.html" style="text-decoration:none;color:inherit" title="На главную"><div class="logo">H</div><span class="t">Heritage · Древо</span></a>
<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="#8A7B63">
<span>Супруги</span><input type="color" data-color="spouse" value="#A9854C">
<span>Усыновление</span><input type="color" data-color="adopt" value="#9C5A6F">
</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:"#8A7B63", spouse:"#A9854C", adopt:"#9C5A6F" }
};
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>