864 lines
48 KiB
HTML
864 lines
48 KiB
HTML
<!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>
|