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

This commit is contained in:
german
2026-06-28 04:12:08 +03:00
commit 638778e807
35 changed files with 2114 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
build/
.env
*.log
.DS_Store
coverage/
Binary file not shown.
+171
View File
@@ -0,0 +1,171 @@
# Родословное дерево семьи (Family Tree)
Современное веб-приложение для построения, визуализации и ведения семейного родословного дерева.
---
## Возможности
- Адаптивный интерфейс для ПК, планшетов и смартфонов
- Современный минималистичный дизайн
- Светлая и тёмная темы
- Высокая производительность на больших деревьях (1000+ человек)
---
## Функционал
### 1. Управление персонами
Для каждого человека хранятся:
- Имя, Фамилия, Отчество
- Пол
- Дата рождения / Дата смерти
- Фотография
- Биография
- Место рождения
- Контакты
- Заметки
### 2. Родственные связи
- Родители и дети
- Супруги, несколько браков
- Братья и сёстры
- Приёмные родители и усыновлённые дети
### 3. Визуализация дерева
- Автоматическое построение родословной
- Масштабирование колесом мыши
- Перемещение перетаскиванием (pan & zoom)
- Сворачивание / раскрытие ветвей
- Фотографии участников на узлах
- Цветовое выделение мужчин и женщин
- Отрисовка линий родства
### 4. Поиск
- По имени и фамилии
- Быстрый переход к найденному человеку
### 5. Карточка человека
Модальное окно с фото, полными данными, биографией, списком родственников и галереей фотографий.
### 6. Импорт и экспорт
- JSON
- CSV
- GEDCOM
### 7. Администрирование
- Добавление, редактирование, удаление людей
- Управление связями через визуальный интерфейс
---
## Технологии
**Frontend:** React, TypeScript, Tailwind CSS, React Flow / D3.js (визуализация дерева)
**Backend:** Node.js, Express, PostgreSQL
**Дополнительно:** авторизация, роли (администратор / редактор / просмотр), автосохранение, история изменений, резервное копирование, печать дерева в PDF, генерация страницы рода с гербом и историей семьи.
---
## Структура проекта
```
family-tree/
├── frontend/ # React + TypeScript + Tailwind
│ ├── src/
│ │ ├── components/ # UI-компоненты
│ │ ├── features/tree/ # Визуализация дерева (React Flow)
│ │ ├── api/ # Клиент REST API
│ │ ├── store/ # Состояние приложения
│ │ └── types/ # Общие типы
│ └── package.json
├── backend/ # Node.js + Express
│ ├── src/
│ │ ├── routes/ # REST-эндпоинты
│ │ ├── controllers/
│ │ ├── models/
│ │ ├── middleware/ # auth, roles
│ │ └── db/ # подключение к PostgreSQL
│ └── package.json
├── db/
│ ├── schema.sql # SQL-схема базы данных
│ └── seed.sql # тестовые данные
├── preview/
│ └── index.html # автономное HTML-превью
├── docker-compose.yml
└── README.md
```
---
## Запуск через Docker
```bash
git clone https://git1.servaki.online/german/Heritage.git
cd Heritage
docker compose up --build
```
После запуска:
- Frontend: http://localhost:3000
- Backend API: http://localhost:4000 (проверка: `GET /api/health`)
- PostgreSQL: localhost:5432
Схема БД и тестовые данные применяются автоматически при первом старте контейнера `db`.
### Демо-пользователи
| Роль | Email | Пароль |
|---------------|----------------------|------------|
| Администратор | admin@example.com | `password` |
| Редактор | editor@example.com | `password` |
| Наблюдатель | viewer@example.com | `password` |
---
## Запуск без Docker (разработка)
```bash
# 1. База
createdb family_tree
psql -d family_tree -f db/schema.sql
psql -d family_tree -f db/seed.sql
# 2. Backend
cd backend && cp .env.example .env && npm install && npm run dev
# 3. Frontend
cd frontend && npm install && npm run dev
```
---
## REST API (основное)
| Метод | Путь | Доступ | Назначение |
|-------|------|--------|------------|
| POST | `/api/auth/login` | все | Вход, возвращает JWT |
| GET | `/api/persons/tree` | авторизован | Всё дерево |
| GET | `/api/persons/search?q=` | авторизован | Поиск по имени/фамилии |
| GET | `/api/persons/:id` | авторизован | Карточка + родственники + медиа |
| POST | `/api/persons` | admin, editor | Создать |
| PUT | `/api/persons/:id` | admin, editor | Изменить |
| DELETE | `/api/persons/:id` | admin | Удалить |
| POST | `/api/relations/parent-child` | admin, editor | Связь родитель→ребёнок |
| POST | `/api/relations/union` | admin, editor | Брак/партнёрство |
| GET | `/api/relations/history` | авторизован | История изменений |
| GET | `/api/io/export/{json,csv,gedcom}` | авторизован | Экспорт |
| POST | `/api/io/import/json` | admin, editor | Импорт |
---
## Превью (важно)
Автономный файл `preview/index.html` открывается в браузере **без сборки и сервера**.
Реализовано: дерево с pan/zoom, светлая/тёмная тема, поиск, карточки персон, цвета по полу,
импорт/экспорт JSON/CSV/GEDCOM, печать в PDF, добавление/редактирование/удаление,
переключение ориентации дерева, стили и цвета связей («лучей»), «примагниченные» линии
при наведении, поля контекста и ссылки на подробный ресурс в карточке.
+5
View File
@@ -0,0 +1,5 @@
# Скопируйте в .env и при необходимости измените
PORT=4000
DATABASE_URL=postgres://postgres:postgres@db:5432/family_tree
JWT_SECRET=change_me_to_a_long_random_string
CORS_ORIGIN=http://localhost:3000
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 4000
CMD ["node", "src/index.js"]
+20
View File
@@ -0,0 +1,20 @@
{
"name": "family-tree-backend",
"version": "1.0.0",
"description": "REST API для приложения «Родословное дерево семьи»",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"migrate": "node src/db/migrate.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
// Применяет schema.sql и (опционально) seed.sql из папки /db
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { pool } from './pool.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbDir = path.resolve(__dirname, '../../../db');
async function run(file) {
const sql = fs.readFileSync(path.join(dbDir, file), 'utf8');
await pool.query(sql);
console.log(`✓ применён ${file}`);
}
(async () => {
try {
await run('schema.sql');
if (process.argv.includes('--seed')) await run('seed.sql');
console.log('Миграция завершена');
} catch (e) {
console.error('Ошибка миграции:', e.message);
process.exit(1);
} finally {
await pool.end();
}
})();
+9
View File
@@ -0,0 +1,9 @@
import pg from 'pg';
import dotenv from 'dotenv';
dotenv.config();
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
export const query = (text, params) => pool.query(text, params);
+27
View File
@@ -0,0 +1,27 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import personRoutes from './routes/persons.js';
import relationRoutes from './routes/relations.js';
import ioRoutes from './routes/io.js';
dotenv.config();
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }));
app.use(express.json({ limit: '10mb' }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
app.use('/api/auth', authRoutes);
app.use('/api/persons', personRoutes);
app.use('/api/relations', relationRoutes);
app.use('/api/io', ioRoutes);
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: err.message });
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`API запущен на http://localhost:${PORT}`));
+34
View File
@@ -0,0 +1,34 @@
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'dev_secret';
export function sign(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, name: user.display_name },
SECRET,
{ expiresIn: '7d' }
);
}
// Проверка токена
export function authRequired(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Требуется авторизация' });
try {
req.user = jwt.verify(token, SECRET);
next();
} catch {
res.status(401).json({ error: 'Недействительный токен' });
}
}
// Ограничение по ролям: requireRole('admin', 'editor')
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Недостаточно прав' });
}
next();
};
}
+39
View File
@@ -0,0 +1,39 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { query } from '../db/pool.js';
import { sign, authRequired } from '../middleware/auth.js';
const router = Router();
// Регистрация (по умолчанию роль viewer)
router.post('/register', async (req, res) => {
const { email, password, displayName } = req.body;
if (!email || !password) return res.status(400).json({ error: 'email и password обязательны' });
try {
const hash = await bcrypt.hash(password, 10);
const { rows } = await query(
`INSERT INTO users (email, password_hash, display_name)
VALUES ($1, $2, $3) RETURNING id, email, display_name, role`,
[email, hash, displayName || email]
);
res.status(201).json({ token: sign(rows[0]), user: rows[0] });
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Email уже занят' });
res.status(500).json({ error: e.message });
}
});
// Вход
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const { rows } = await query('SELECT * FROM users WHERE email=$1', [email]);
const user = rows[0];
if (!user || !(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json({ error: 'Неверный email или пароль' });
res.json({ token: sign(user), user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role } });
});
// Текущий пользователь
router.get('/me', authRequired, (req, res) => res.json(req.user));
export default router;
+79
View File
@@ -0,0 +1,79 @@
import { Router } from 'express';
import { query } from '../db/pool.js';
import { authRequired, requireRole } from '../middleware/auth.js';
const router = Router();
// ---------- Экспорт ----------
async function loadAll() {
const persons = (await query('SELECT * FROM persons')).rows;
const pc = (await query('SELECT * FROM parent_child')).rows;
const unions = (await query('SELECT * FROM unions')).rows;
return { persons, parent_child: pc, unions };
}
router.get('/export/json', authRequired, async (_req, res) => {
res.json(await loadAll());
});
router.get('/export/csv', authRequired, async (_req, res) => {
const { persons } = await loadAll();
const cols = ['id','first_name','last_name','middle_name','gender','birth_date','death_date','birth_place','bio','contacts','notes','context','resource_url'];
const esc = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
const csv = [cols.join(',')]
.concat(persons.map(p => cols.map(c => esc(p[c])).join(',')))
.join('\n');
res.type('text/csv').attachment('family-tree.csv').send(csv);
});
router.get('/export/gedcom', authRequired, async (_req, res) => {
const { persons, parent_child, unions } = await loadAll();
let g = '0 HEAD\n1 SOUR FamilyTree\n1 GEDC\n2 VERS 5.5.1\n1 CHAR UTF-8\n';
const tag = id => '@I' + id.replace(/-/g, '').slice(0, 12).toUpperCase() + '@';
persons.forEach(p => {
g += `0 ${tag(p.id)} INDI\n1 NAME ${p.first_name} /${p.last_name || ''}/\n1 SEX ${p.gender === 'female' ? 'F' : 'M'}\n`;
if (p.birth_date) g += `1 BIRT\n2 DATE ${p.birth_date}\n`;
if (p.death_date) g += `1 DEAT\n2 DATE ${p.death_date}\n`;
});
// семьи на основе общих родителей
const fams = {};
parent_child.forEach(r => { (fams[r.child_id] ||= []).push(r.parent_id); });
let fi = 0;
Object.entries(fams).forEach(([child, parents]) => {
fi++;
g += `0 @F${fi}@ FAM\n`;
parents.forEach((pid, i) => g += `1 ${i === 0 ? 'HUSB' : 'WIFE'} ${tag(pid)}\n`);
g += `1 CHIL ${tag(child)}\n`;
});
g += '0 TRLR\n';
res.type('text/plain').attachment('family-tree.ged').send(g);
});
// ---------- Импорт ----------
router.post('/import/json', authRequired, requireRole('admin', 'editor'), async (req, res) => {
const { persons = [], parent_child = [], unions = [] } = req.body;
const client = await (await import('../db/pool.js')).pool.connect();
try {
await client.query('BEGIN');
for (const p of persons) {
await client.query(
`INSERT INTO persons (id, first_name, last_name, middle_name, gender, birth_date, death_date, birth_place, bio, contacts, notes, context, resource_url)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
ON CONFLICT (id) DO NOTHING`,
[p.id, p.first_name, p.last_name, p.middle_name, p.gender, p.birth_date, p.death_date, p.birth_place, p.bio, p.contacts, p.notes, p.context, p.resource_url]);
}
for (const r of parent_child)
await client.query(`INSERT INTO parent_child (parent_id, child_id, kind) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`, [r.parent_id, r.child_id, r.kind || 'biological']);
for (const u of unions)
await client.query(`INSERT INTO unions (partner_a, partner_b, status) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`, [u.partner_a, u.partner_b, u.status || 'married']);
await client.query('COMMIT');
res.json({ imported: persons.length });
} catch (e) {
await client.query('ROLLBACK');
res.status(400).json({ error: e.message });
} finally {
client.release();
}
});
export default router;
+78
View File
@@ -0,0 +1,78 @@
import { Router } from 'express';
import { query } from '../db/pool.js';
import { authRequired, requireRole } from '../middleware/auth.js';
const router = Router();
const FIELDS = ['first_name','last_name','middle_name','gender','birth_date','death_date',
'birth_place','photo_url','bio','contacts','notes','context','resource_url'];
async function logChange(userId, entityId, action, diff) {
await query(`INSERT INTO change_log (user_id, entity, entity_id, action, diff)
VALUES ($1,'person',$2,$3,$4)`, [userId, entityId, action, diff]);
}
// Всё дерево (персоны + связи) одним запросом — для построения визуализации
router.get('/tree', authRequired, async (_req, res) => {
const persons = (await query('SELECT * FROM person_relations')).rows;
res.json(persons);
});
// Поиск по имени/фамилии
router.get('/search', authRequired, async (req, res) => {
const q = `%${(req.query.q || '').trim()}%`;
const { rows } = await query(
`SELECT id, first_name, last_name, middle_name, gender, birth_date, death_date
FROM persons
WHERE first_name ILIKE $1 OR last_name ILIKE $1 OR middle_name ILIKE $1
ORDER BY last_name, first_name LIMIT 25`, [q]);
res.json(rows);
});
// Карточка одного человека (с родственниками и галереей)
router.get('/:id', authRequired, async (req, res) => {
const { id } = req.params;
const p = (await query('SELECT * FROM persons WHERE id=$1', [id])).rows[0];
if (!p) return res.status(404).json({ error: 'Не найдено' });
p.parents = (await query(`SELECT pc.kind, ps.* FROM parent_child pc JOIN persons ps ON ps.id=pc.parent_id WHERE pc.child_id=$1`, [id])).rows;
p.children = (await query(`SELECT pc.kind, ps.* FROM parent_child pc JOIN persons ps ON ps.id=pc.child_id WHERE pc.parent_id=$1`, [id])).rows;
p.spouses = (await query(`SELECT u.status, ps.* FROM unions u
JOIN persons ps ON ps.id = CASE WHEN u.partner_a=$1 THEN u.partner_b ELSE u.partner_a END
WHERE u.partner_a=$1 OR u.partner_b=$1`, [id])).rows;
p.media = (await query('SELECT * FROM media WHERE person_id=$1 ORDER BY sort_order', [id])).rows;
res.json(p);
});
// Создание
router.post('/', authRequired, requireRole('admin', 'editor'), async (req, res) => {
const vals = FIELDS.map(f => req.body[f] ?? null);
const cols = FIELDS.join(',');
const ph = FIELDS.map((_, i) => `$${i + 1}`).join(',');
const { rows } = await query(
`INSERT INTO persons (${cols}, created_by) VALUES (${ph}, $${FIELDS.length + 1}) RETURNING *`,
[...vals, req.user.id]);
await logChange(req.user.id, rows[0].id, 'create', rows[0]);
res.status(201).json(rows[0]);
});
// Обновление
router.put('/:id', authRequired, requireRole('admin', 'editor'), async (req, res) => {
const before = (await query('SELECT * FROM persons WHERE id=$1', [req.params.id])).rows[0];
if (!before) return res.status(404).json({ error: 'Не найдено' });
const set = FIELDS.map((f, i) => `${f}=$${i + 1}`).join(',');
const vals = FIELDS.map(f => req.body[f] ?? before[f]);
const { rows } = await query(
`UPDATE persons SET ${set} WHERE id=$${FIELDS.length + 1} RETURNING *`,
[...vals, req.params.id]);
await logChange(req.user.id, req.params.id, 'update', { before, after: rows[0] });
res.json(rows[0]);
});
// Удаление
router.delete('/:id', authRequired, requireRole('admin'), async (req, res) => {
await query('DELETE FROM persons WHERE id=$1', [req.params.id]);
await logChange(req.user.id, req.params.id, 'delete', null);
res.status(204).end();
});
export default router;
+47
View File
@@ -0,0 +1,47 @@
import { Router } from 'express';
import { query } from '../db/pool.js';
import { authRequired, requireRole } from '../middleware/auth.js';
const router = Router();
const editor = requireRole('admin', 'editor');
// Добавить связь родитель → ребёнок (kind: biological | adoptive | step | guardian)
router.post('/parent-child', authRequired, editor, async (req, res) => {
const { parentId, childId, kind = 'biological' } = req.body;
const { rows } = await query(
`INSERT INTO parent_child (parent_id, child_id, kind) VALUES ($1,$2,$3)
ON CONFLICT (parent_id, child_id) DO UPDATE SET kind=EXCLUDED.kind RETURNING *`,
[parentId, childId, kind]);
res.status(201).json(rows[0]);
});
router.delete('/parent-child/:id', authRequired, editor, async (req, res) => {
await query('DELETE FROM parent_child WHERE id=$1', [req.params.id]);
res.status(204).end();
});
// Браки / партнёрства (поддержка нескольких браков)
router.post('/union', authRequired, editor, async (req, res) => {
const { partnerA, partnerB, status = 'married', startDate, endDate } = req.body;
const { rows } = await query(
`INSERT INTO unions (partner_a, partner_b, status, start_date, end_date)
VALUES ($1,$2,$3,$4,$5)
ON CONFLICT (partner_a, partner_b) DO UPDATE SET status=EXCLUDED.status RETURNING *`,
[partnerA, partnerB, status, startDate || null, endDate || null]);
res.status(201).json(rows[0]);
});
router.delete('/union/:id', authRequired, editor, async (req, res) => {
await query('DELETE FROM unions WHERE id=$1', [req.params.id]);
res.status(204).end();
});
// История изменений
router.get('/history', authRequired, async (req, res) => {
const { rows } = await query(
`SELECT c.*, u.display_name FROM change_log c LEFT JOIN users u ON u.id=c.user_id
ORDER BY c.created_at DESC LIMIT 200`);
res.json(rows);
});
export default router;
+118
View File
@@ -0,0 +1,118 @@
-- ============================================================
-- Родословное дерево семьи — SQL-схема (PostgreSQL 15+)
-- ============================================================
-- Кодировка UTF-8. Запуск: psql -U postgres -d family_tree -f schema.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- быстрый поиск по имени/фамилии
-- ---------- Пользователи и роли ----------
CREATE TYPE user_role AS ENUM ('admin', 'editor', 'viewer');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'viewer',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------- Персоны ----------
CREATE TYPE gender_t AS ENUM ('male', 'female', 'other');
CREATE TABLE persons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
first_name TEXT NOT NULL,
last_name TEXT,
middle_name TEXT,
gender gender_t NOT NULL DEFAULT 'other',
birth_date DATE,
death_date DATE,
birth_place TEXT,
photo_url TEXT,
bio TEXT,
contacts TEXT,
notes TEXT,
context TEXT, -- произвольный дополнительный контекст
resource_url TEXT, -- ссылка на подробный внешний ресурс
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_persons_last_name ON persons USING gin (last_name gin_trgm_ops);
CREATE INDEX idx_persons_first_name ON persons USING gin (first_name gin_trgm_ops);
-- ---------- Связь родитель → ребёнок (вкл. приёмные) ----------
CREATE TYPE parent_kind AS ENUM ('biological', 'adoptive', 'step', 'guardian');
CREATE TABLE parent_child (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
parent_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
child_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
kind parent_kind NOT NULL DEFAULT 'biological',
UNIQUE (parent_id, child_id),
CHECK (parent_id <> child_id)
);
CREATE INDEX idx_pc_parent ON parent_child(parent_id);
CREATE INDEX idx_pc_child ON parent_child(child_id);
-- ---------- Браки / партнёрства (несколько браков поддерживаются) ----------
CREATE TYPE union_status AS ENUM ('married', 'divorced', 'partner', 'widowed');
CREATE TABLE unions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
partner_a UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
partner_b UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
status union_status NOT NULL DEFAULT 'married',
start_date DATE,
end_date DATE,
UNIQUE (partner_a, partner_b),
CHECK (partner_a <> partner_b)
);
-- ---------- Галерея фотографий ----------
CREATE TABLE media (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
person_id UUID NOT NULL REFERENCES persons(id) ON DELETE CASCADE,
url TEXT NOT NULL,
caption TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_media_person ON media(person_id);
-- ---------- История изменений (аудит) ----------
CREATE TABLE change_log (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
entity TEXT NOT NULL, -- 'person' | 'union' | 'parent_child' | ...
entity_id UUID,
action TEXT NOT NULL, -- 'create' | 'update' | 'delete'
diff JSONB, -- было/стало
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_log_entity ON change_log(entity, entity_id);
-- ---------- Авто-обновление updated_at ----------
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_persons_touch BEFORE UPDATE ON persons
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
-- ---------- Удобное представление дерева ----------
CREATE VIEW person_relations AS
SELECT p.id,
p.first_name, p.last_name, p.middle_name, p.gender,
p.birth_date, p.death_date,
(SELECT array_agg(pc.parent_id) FROM parent_child pc WHERE pc.child_id = p.id) AS parent_ids,
(SELECT array_agg(pc.child_id) FROM parent_child pc WHERE pc.parent_id = p.id) AS child_ids,
(SELECT array_agg(CASE WHEN u.partner_a = p.id THEN u.partner_b ELSE u.partner_a END)
FROM unions u WHERE u.partner_a = p.id OR u.partner_b = p.id) AS spouse_ids
FROM persons p;
+58
View File
@@ -0,0 +1,58 @@
-- ============================================================
-- Тестовые данные для родословного дерева
-- ============================================================
-- Пароль для всех демо-пользователей: "password" (bcrypt-хэш ниже)
INSERT INTO users (id, email, password_hash, display_name, role) VALUES
('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'Администратор', 'admin'),
('22222222-2222-2222-2222-222222222222', 'editor@example.com', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'Редактор', 'editor'),
('33333333-3333-3333-3333-333333333333', 'viewer@example.com', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', 'Наблюдатель', 'viewer');
-- Персоны
INSERT INTO persons (id, first_name, last_name, middle_name, gender, birth_date, death_date, birth_place, bio, context, resource_url) VALUES
('a0000000-0000-0000-0000-000000000001', 'Иван', 'Соколов', 'Петрович', 'male', '1928-03-12', '1998-11-04', 'Тула, СССР', 'Глава рода, ветеран, инженер-конструктор.', 'Призван в армию в 1944. Вёл подробный дневник.', 'https://pamyat-naroda.ru'),
('a0000000-0000-0000-0000-000000000002', 'Мария', 'Соколова', 'Андреевна', 'female', '1931-07-22', '2010-02-19', 'Калуга, СССР', 'Учительница, хранительница семейного архива.', NULL, NULL),
('a0000000-0000-0000-0000-000000000003', 'Сергей', 'Соколов', 'Иванович', 'male', '1955-05-30', NULL, 'Тула, СССР', 'Врач-хирург, к.м.н.', NULL, NULL),
('a0000000-0000-0000-0000-000000000004', 'Ольга', 'Соколова', 'Викторовна','female', '1958-09-14', NULL, 'Рязань, СССР', 'Архитектор.', NULL, NULL),
('a0000000-0000-0000-0000-000000000005', 'Елена', 'Кузнецова', 'Ивановна', 'female', '1959-12-02', NULL, 'Тула, СССР', 'Экономист.', NULL, NULL),
('a0000000-0000-0000-0000-000000000006', 'Дмитрий', 'Кузнецов', 'Олегович', 'male', '1956-04-18', NULL, 'Москва, СССР', 'Журналист.', NULL, NULL),
('a0000000-0000-0000-0000-000000000007', 'Алексей', 'Соколов', 'Сергеевич', 'male', '1982-08-08', NULL, 'Тула, Россия', 'Программист, основатель IT-компании.', NULL, 'https://example.com/alexey'),
('a0000000-0000-0000-0000-000000000008', 'Анна', 'Соколова', 'Павловна', 'female', '1985-01-25', NULL, 'Санкт-Петербург, РФ', 'Дизайнер.', NULL, NULL),
('a0000000-0000-0000-0000-000000000009', 'Наталья', 'Соколова', 'Сергеевна', 'female', '1986-11-30', NULL, 'Тула, Россия', 'Биолог-генетик.', NULL, NULL),
('a0000000-0000-0000-0000-000000000010', 'Павел', 'Кузнецов', 'Дмитриевич','male', '1984-06-11', NULL, 'Москва, Россия', 'Музыкант.', NULL, NULL),
('a0000000-0000-0000-0000-000000000011', 'Светлана','Кузнецова', 'Игоревна', 'female', '1987-03-03', NULL, 'Москва, Россия', 'Врач-педиатр.', NULL, NULL),
('a0000000-0000-0000-0000-000000000012', 'Максим', 'Соколов', 'Алексеевич','male', '2010-02-14', NULL, 'Москва, Россия', 'Школьник, робототехника.', NULL, NULL),
('a0000000-0000-0000-0000-000000000013', 'София', 'Соколова', 'Алексеевна','female', '2013-09-09', NULL, 'Москва, Россия', 'Школьница, балет.', 'Удочерена в 2015 году.', NULL),
('a0000000-0000-0000-0000-000000000014', 'Артём', 'Кузнецов', 'Павлович', 'male', '2012-12-20', NULL, 'Москва, Россия', 'Школьник.', NULL, NULL);
-- Браки
INSERT INTO unions (partner_a, partner_b, status, start_date) VALUES
('a0000000-0000-0000-0000-000000000001','a0000000-0000-0000-0000-000000000002','married','1953-06-01'),
('a0000000-0000-0000-0000-000000000003','a0000000-0000-0000-0000-000000000004','married','1980-08-15'),
('a0000000-0000-0000-0000-000000000005','a0000000-0000-0000-0000-000000000006','married','1981-05-20'),
('a0000000-0000-0000-0000-000000000007','a0000000-0000-0000-0000-000000000008','married','2008-09-01'),
('a0000000-0000-0000-0000-000000000010','a0000000-0000-0000-0000-000000000011','married','2010-07-10');
-- Родители → дети
INSERT INTO parent_child (parent_id, child_id, kind) VALUES
-- дети Ивана и Марии
('a0000000-0000-0000-0000-000000000001','a0000000-0000-0000-0000-000000000003','biological'),
('a0000000-0000-0000-0000-000000000002','a0000000-0000-0000-0000-000000000003','biological'),
('a0000000-0000-0000-0000-000000000001','a0000000-0000-0000-0000-000000000005','biological'),
('a0000000-0000-0000-0000-000000000002','a0000000-0000-0000-0000-000000000005','biological'),
-- дети Сергея и Ольги
('a0000000-0000-0000-0000-000000000003','a0000000-0000-0000-0000-000000000007','biological'),
('a0000000-0000-0000-0000-000000000004','a0000000-0000-0000-0000-000000000007','biological'),
('a0000000-0000-0000-0000-000000000003','a0000000-0000-0000-0000-000000000009','biological'),
('a0000000-0000-0000-0000-000000000004','a0000000-0000-0000-0000-000000000009','biological'),
-- ребёнок Елены и Дмитрия
('a0000000-0000-0000-0000-000000000005','a0000000-0000-0000-0000-000000000010','biological'),
('a0000000-0000-0000-0000-000000000006','a0000000-0000-0000-0000-000000000010','biological'),
-- дети Алексея и Анны (София — удочерена)
('a0000000-0000-0000-0000-000000000007','a0000000-0000-0000-0000-000000000012','biological'),
('a0000000-0000-0000-0000-000000000008','a0000000-0000-0000-0000-000000000012','biological'),
('a0000000-0000-0000-0000-000000000007','a0000000-0000-0000-0000-000000000013','adoptive'),
('a0000000-0000-0000-0000-000000000008','a0000000-0000-0000-0000-000000000013','adoptive'),
-- ребёнок Павла и Светланы
('a0000000-0000-0000-0000-000000000010','a0000000-0000-0000-0000-000000000014','biological'),
('a0000000-0000-0000-0000-000000000011','a0000000-0000-0000-0000-000000000014','biological');
+41
View File
@@ -0,0 +1,41 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: family_tree
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- db_data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
- ./db/seed.sql:/docker-entrypoint-initdb.d/02_seed.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
backend:
build: ./backend
environment:
PORT: 4000
DATABASE_URL: postgres://postgres:postgres@db:5432/family_tree
JWT_SECRET: please_change_this_secret
CORS_ORIGIN: http://localhost:3000
depends_on:
db:
condition: service_healthy
ports:
- "4000:4000"
frontend:
build: ./frontend
depends_on:
- backend
ports:
- "3000:80"
volumes:
db_data:
+14
View File
@@ -0,0 +1,14 @@
# --- сборка ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- раздача через nginx ---
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Родословное дерево семьи</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA-роутинг
location / {
try_files $uri $uri/ /index.html;
}
# проксирование API на backend-контейнер
location /api/ {
proxy_pass http://backend:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "family-tree-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"reactflow": "^11.11.4",
"zustand": "^4.5.4"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { api, setToken } from './api/client';
import type { Person, VizSettings } from './types';
import FamilyTree from './components/FamilyTree';
import PersonModal from './components/PersonModal';
const DEFAULT_VIZ: VizSettings = {
orientation: 'vertical',
edgeStyle: 'ortho',
colors: { parent: '#7c8aa5', spouse: '#c9a14a', adopt: '#e06aa3' },
};
export default function App() {
const [people, setPeople] = useState<Person[]>([]);
const [viz, setViz] = useState<VizSettings>(DEFAULT_VIZ);
const [selected, setSelected] = useState<string | null>(null);
const [dark, setDark] = useState(matchMedia('(prefers-color-scheme: dark)').matches);
const [q, setQ] = useState('');
useEffect(() => { document.documentElement.classList.toggle('dark', dark); }, [dark]);
// демо-логин; в реальном приложении — форма входа
useEffect(() => {
(async () => {
try {
const { token } = await api.login('viewer@example.com', 'password');
setToken(token);
setPeople(await api.tree());
} catch { /* офлайн-демо */ }
})();
}, []);
const found = q.trim()
? people.filter(p => `${p.first_name} ${p.last_name} ${p.middle_name}`.toLowerCase().includes(q.toLowerCase())).slice(0, 8)
: [];
return (
<div className="h-screen flex flex-col bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-100">
<header className="flex items-center gap-3 px-4 py-2 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 flex-wrap">
<div className="flex items-center gap-2 font-bold">🌳 Родословное дерево</div>
<div className="relative">
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Поиск по имени или фамилии…"
className="w-56 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-sm outline-none" />
{found.length > 0 && (
<div className="absolute top-10 left-0 right-0 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow z-20">
{found.map(p => (
<div key={p.id} onClick={() => { setSelected(p.id); setQ(''); }}
className="px-3 py-2 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-700">
{p.last_name} {p.first_name} {p.middle_name}
</div>
))}
</div>
)}
</div>
<div className="flex-1" />
<select value={viz.orientation} onChange={e => setViz(v => ({ ...v, orientation: e.target.value as any }))}
className="px-2 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-sm">
<option value="vertical">Вертикально</option>
<option value="horizontal">Горизонтально</option>
</select>
<select value={viz.edgeStyle} onChange={e => setViz(v => ({ ...v, edgeStyle: e.target.value as any }))}
className="px-2 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 text-sm">
<option value="ortho">Лучи: примагниченные</option>
<option value="curve">Лучи: кривые</option>
<option value="straight">Лучи: прямые</option>
</select>
<button onClick={() => setDark(d => !d)} className="px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700">
{dark ? '☀️' : '🌙'}
</button>
</header>
<main className="flex-1">
<FamilyTree people={people} settings={viz} onSelect={setSelected} />
</main>
{selected && <PersonModal id={selected} onClose={() => setSelected(null)} onNavigate={setSelected} />}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Person } from '../types';
const BASE = import.meta.env.VITE_API_URL || '/api';
let token: string | null = null;
export function setToken(t: string | null) { token = t; }
async function req(path: string, opts: RequestInit = {}) {
const res = await fetch(`${BASE}${path}`, {
...opts,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(opts.headers || {}),
},
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
return res.status === 204 ? null : res.json();
}
export const api = {
login: (email: string, password: string) =>
req('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }),
tree: (): Promise<Person[]> => req('/persons/tree'),
person: (id: string) => req(`/persons/${id}`),
search: (q: string) => req(`/persons/search?q=${encodeURIComponent(q)}`),
create: (p: Partial<Person>) => req('/persons', { method: 'POST', body: JSON.stringify(p) }),
update: (id: string, p: Partial<Person>) => req(`/persons/${id}`, { method: 'PUT', body: JSON.stringify(p) }),
remove: (id: string) => req(`/persons/${id}`, { method: 'DELETE' }),
addParent: (parentId: string, childId: string, kind = 'biological') =>
req('/relations/parent-child', { method: 'POST', body: JSON.stringify({ parentId, childId, kind }) }),
addUnion: (partnerA: string, partnerB: string) =>
req('/relations/union', { method: 'POST', body: JSON.stringify({ partnerA, partnerB }) }),
history: () => req('/relations/history'),
exportJson: () => req('/io/export/json'),
importJson: (data: unknown) => req('/io/import/json', { method: 'POST', body: JSON.stringify(data) }),
};
+40
View File
@@ -0,0 +1,40 @@
import { useMemo, useState } from 'react';
import ReactFlow, { Background, Controls, MiniMap, type Node } from 'reactflow';
import 'reactflow/dist/style.css';
import type { Person, VizSettings } from '../types';
import { buildGraph } from '../lib/layout';
import PersonNode from './PersonNode';
const nodeTypes = { person: PersonNode };
export default function FamilyTree({
people, settings, onSelect,
}: {
people: Person[];
settings: VizSettings;
onSelect: (id: string) => void;
}) {
const [collapsed] = useState<Set<string>>(new Set());
const { nodes, edges } = useMemo(
() => buildGraph(people, settings, collapsed),
[people, settings, collapsed]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={(_, n: Node) => onSelect(n.id)}
fitView
minZoom={0.1}
maxZoom={2.5}
proOptions={{ hideAttribution: true }}
>
<Background gap={24} />
<Controls />
<MiniMap pannable zoomable nodeColor={(n) =>
(n.data?.person?.gender === 'female') ? '#e06aa3' : '#4f8ff5'} />
</ReactFlow>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { Person } from '../types';
export default function PersonModal({ id, onClose, onNavigate }: {
id: string; onClose: () => void; onNavigate: (id: string) => void;
}) {
const [p, setP] = useState<any>(null);
useEffect(() => { api.person(id).then(setP).catch(() => {}); }, [id]);
if (!p) return null;
const Chip = ({ rel }: { rel: any }) => (
<button onClick={() => onNavigate(rel.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 mr-1.5 mb-1.5 rounded-full border border-slate-200 dark:border-slate-600 text-sm hover:border-indigo-400">
<span className={`w-2 h-2 rounded-full ${rel.gender === 'female' ? 'bg-pink-400' : 'bg-blue-400'}`} />
{rel.first_name} {rel.last_name}
</button>
);
return (
<div className="fixed inset-0 bg-black/55 grid place-items-center z-50 p-4" onClick={onClose}>
<div className="bg-white dark:bg-slate-800 rounded-2xl w-[720px] max-w-full max-h-[90vh] overflow-auto"
onClick={e => e.stopPropagation()}>
<div className="flex gap-4 p-6 border-b border-slate-100 dark:border-slate-700 items-center">
<div className={`w-20 h-20 rounded-2xl grid place-items-center text-2xl font-bold text-white ${p.gender === 'female' ? 'bg-pink-400' : 'bg-blue-400'}`}>
{(p.first_name?.[0] || '') + (p.last_name?.[0] || '')}
</div>
<div>
<h2 className="text-xl font-bold">{p.last_name} {p.first_name} {p.middle_name}</h2>
<div className="text-sm text-slate-500">{p.birth_date} {p.death_date && ` ${p.death_date}`} · {p.birth_place}</div>
</div>
<button onClick={onClose} className="ml-auto w-9 h-9 rounded-lg bg-slate-100 dark:bg-slate-700"></button>
</div>
<div className="p-6 grid sm:grid-cols-2 gap-4 text-sm">
<Field label="Биография" full v={p.bio} />
<Field label="Контакты" v={p.contacts} />
<Field label="Заметки" v={p.notes} />
<Field label="Контекст / доп. информация" full v={p.context} />
{p.resource_url && (
<div className="sm:col-span-2">
<div className="text-xs uppercase text-slate-400 mb-1">Подробный ресурс</div>
<a href={p.resource_url} target="_blank" className="text-indigo-500">🔗 {p.resource_url}</a>
</div>
)}
<div className="sm:col-span-2">
<div className="text-xs uppercase text-slate-400 mb-2">Родственники</div>
<div className="mb-2"><b>Родители:</b> {p.parents?.map((r: any) => <Chip key={r.id} rel={r} />) || '—'}</div>
<div className="mb-2"><b>Супруг(а):</b> {p.spouses?.map((r: any) => <Chip key={r.id} rel={r} />) || '—'}</div>
<div className="mb-2"><b>Дети:</b> {p.children?.map((r: any) => <Chip key={r.id} rel={r} />) || '—'}</div>
</div>
</div>
</div>
</div>
);
}
function Field({ label, v, full }: { label: string; v?: string; full?: boolean }) {
return (
<div className={full ? 'sm:col-span-2' : ''}>
<div className="text-xs uppercase text-slate-400 mb-1">{label}</div>
<div>{v || '—'}</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Handle, Position } from 'reactflow';
import type { Person } from '../types';
const initials = (p: Person) => ((p.first_name?.[0] || '') + (p.last_name?.[0] || '')).toUpperCase();
const years = (p: Person) => {
const b = p.birth_date?.slice(0, 4) || '?';
const d = p.death_date?.slice(0, 4);
return d ? `${b} ${d}` : `р. ${b}`;
};
export default function PersonNode({ data }: { data: { person: Person } }) {
const p = data.person;
const accent = p.gender === 'female' ? 'border-pink-400' : 'border-blue-400';
const avatar = p.gender === 'female' ? 'bg-pink-400' : 'bg-blue-400';
return (
<div className={`w-[180px] h-[96px] rounded-2xl border-2 ${accent} bg-white dark:bg-slate-800 shadow-md flex items-center gap-3 px-3`}>
<Handle type="target" position={Position.Top} className="opacity-0" />
<div className={`w-12 h-12 rounded-full ${avatar} text-white grid place-items-center font-bold flex-none`}>
{p.photo_url ? <img src={p.photo_url} className="w-full h-full rounded-full object-cover" /> : initials(p)}
</div>
<div className="min-w-0">
<div className="font-bold text-sm text-slate-800 dark:text-slate-100 truncate">{p.first_name} {p.last_name}</div>
<div className="text-xs text-slate-500 truncate">{p.middle_name}</div>
<div className="text-xs text-slate-400">{years(p)}</div>
</div>
<Handle type="source" position={Position.Bottom} className="opacity-0" />
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root { height: 100%; margin: 0; }
.react-flow__attribution { display: none; }
+83
View File
@@ -0,0 +1,83 @@
import type { Person, VizSettings } from '../types';
import type { Edge, Node } from 'reactflow';
const NODE_W = 180, NODE_H = 96, V_GAP = 80, COUPLE_GAP = 26, FAM_GAP = 34;
// Раскладка генеалогического дерева с поддержкой ориентации и пар.
export function buildGraph(people: Person[], s: VizSettings, collapsed: Set<string>) {
const byId = new Map(people.map(p => [p.id, p]));
const pos: Record<string, { b: number; depth: number }> = {};
const horiz = s.orientation === 'horizontal';
const BW = horiz ? NODE_H : NODE_W;
const DEP = horiz ? NODE_W + 120 : NODE_H + V_GAP;
let cursor = 0;
const childrenOf = (id: string, spouse?: string) =>
people.filter(c => {
const pr = c.parent_ids?.[0];
return pr === id || (spouse && pr === spouse);
});
const coupleCenter = (id: string) => {
const a = pos[id]; if (!a) return 0;
const sp = (byId.get(id)?.spouse_ids || []).find(x => pos[x]);
if (sp) return (a.b + pos[sp].b + BW) / 2;
return a.b + BW / 2;
};
function layout(id: string, depth: number) {
if (pos[id]) return;
const p = byId.get(id)!;
const spouse = (p.spouse_ids || []).find(x => byId.has(x) && !pos[x]);
const kids = (collapsed.has(id) || (spouse && collapsed.has(spouse)))
? [] : childrenOf(id, spouse).map(c => c.id);
if (!kids.length) {
pos[id] = { b: cursor, depth }; cursor += BW;
if (spouse) { cursor += COUPLE_GAP; pos[spouse] = { b: cursor, depth }; cursor += BW; }
cursor += FAM_GAP;
} else {
kids.forEach(k => layout(k, depth + 1));
const centers = kids.map(coupleCenter);
const mid = (Math.min(...centers) + Math.max(...centers)) / 2;
const w = spouse ? BW * 2 + COUPLE_GAP : BW;
const aB = mid - w / 2;
pos[id] = { b: aB, depth };
if (spouse) pos[spouse] = { b: aB + BW + COUPLE_GAP, depth };
}
}
people.filter(p => !p.parent_ids?.length).forEach(r => layout(r.id, 0));
people.forEach(p => { if (!pos[p.id]) layout(p.id, 0); });
const nodes: Node[] = people.filter(p => pos[p.id]).map(p => {
const { b, depth } = pos[p.id];
return {
id: p.id,
type: 'person',
position: horiz ? { x: depth * DEP, y: b } : { x: b, y: depth * DEP },
data: { person: p },
};
});
const type = s.edgeStyle === 'curve' ? 'smoothstep' : s.edgeStyle === 'ortho' ? 'step' : 'straight';
const edges: Edge[] = [];
const seen = new Set<string>();
people.forEach(p => {
(p.spouse_ids || []).forEach(sp => {
const key = [p.id, sp].sort().join('-');
if (seen.has(key) || !pos[sp]) return; seen.add(key);
edges.push({ id: 'm' + key, source: p.id, target: sp, type, style: { stroke: s.colors.spouse, strokeWidth: 2.5, strokeDasharray: '4 3' } });
});
(p.child_ids || []).forEach(cid => {
if (!pos[cid] || collapsed.has(p.id)) return;
const child = byId.get(cid);
const adopt = false; // вид связи приходит с бэкенда (parent_child.kind)
edges.push({
id: `e${p.id}-${cid}`, source: p.id, target: cid, type,
style: { stroke: adopt ? s.colors.adopt : s.colors.parent, strokeWidth: 2, ...(adopt ? { strokeDasharray: '6 5' } : {}) },
});
});
});
return { nodes, edges };
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+30
View File
@@ -0,0 +1,30 @@
export type Gender = 'male' | 'female' | 'other';
export interface Person {
id: string;
first_name: string;
last_name?: string;
middle_name?: string;
gender: Gender;
birth_date?: string;
death_date?: string;
birth_place?: string;
photo_url?: string;
bio?: string;
contacts?: string;
notes?: string;
context?: string; // дополнительный контекст
resource_url?: string; // ссылка на подробный ресурс
parent_ids?: string[];
child_ids?: string[];
spouse_ids?: string[];
}
export type Orientation = 'vertical' | 'horizontal';
export type EdgeStyle = 'ortho' | 'curve' | 'straight';
export interface VizSettings {
orientation: Orientation;
edgeStyle: EdgeStyle;
colors: { parent: string; spouse: string; adopt: string };
}
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
};
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: { '/api': { target: 'http://localhost:4000', changeOrigin: true } },
},
});
+857
View File
@@ -0,0 +1,857 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Родословное дерево семьи — превью</title>
<style>
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-2: #f0f2f5;
--text: #1c2230;
--muted: #687087;
--border: #dfe3ea;
--accent: #4f6df5;
--male: #4f8ff5;
--male-bg: #e8f1ff;
--female: #e06aa3;
--female-bg: #ffe9f3;
--shadow: 0 4px 20px rgba(20,30,60,.08);
--node-shadow: 0 2px 6px rgba(20,30,60,.10);
}
html[data-theme="dark"] {
--bg: #0f1320;
--panel: #171c2b;
--panel-2: #1f2536;
--text: #e6e9f2;
--muted: #97a0b8;
--border: #2a3145;
--accent: #6d86ff;
--male: #5a93ff;
--male-bg: #16263f;
--female: #ff85bd;
--female-bg: #361927;
--shadow: 0 6px 28px rgba(0,0,0,.45);
--node-shadow: 0 2px 8px rgba(0,0,0,.45);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg); color: var(--text);
display: flex; flex-direction: column; overflow: hidden;
transition: background .25s, color .25s;
}
header {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
background: var(--panel); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow); z-index: 20; flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 16px; }
.brand .logo {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(135deg, var(--accent), #9d7bff);
display: grid; place-items: center; color: #fff; font-size: 16px;
}
.spacer { flex: 1; }
.search { position: relative; }
.search input {
width: 220px; max-width: 46vw; padding: 8px 12px 8px 32px;
border: 1px solid var(--border); border-radius: 9px;
background: var(--panel-2); color: var(--text); font-size: 14px; outline: none;
}
.search input:focus { border-color: var(--accent); }
.search svg { position: absolute; left: 9px; top: 9px; opacity: .55; }
.results {
position: absolute; top: 40px; left: 0; right: 0; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow);
max-height: 280px; overflow: auto; display: none; z-index: 30;
}
.results.show { display: block; }
.results .row { padding: 9px 12px; cursor: pointer; display: flex; gap: 10px; align-items: center; font-size: 14px; }
.results .row:hover { background: var(--panel-2); }
.btn {
border: 1px solid var(--border); background: var(--panel-2); color: var(--text);
padding: 8px 12px; border-radius: 9px; cursor: pointer; font-size: 14px; display: inline-flex; gap: 6px; align-items: center;
transition: .15s;
}
.btn:hover { border-color: var(--accent); }
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.menu { position: relative; }
.menu .dropdown {
position: absolute; right: 0; top: 42px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow);
min-width: 190px; display: none; overflow: hidden; z-index: 30;
}
.menu.open .dropdown { display: block; }
.menu .dropdown button {
width: 100%; text-align: left; padding: 10px 14px; background: none; border: none;
color: var(--text); cursor: pointer; font-size: 14px;
}
.menu .dropdown button:hover { background: var(--panel-2); }
.menu .dropdown .sep { height: 1px; background: var(--border); margin: 4px 0; }
.menu .dropdown .label { padding: 6px 14px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.seg { flex:1; padding:7px 6px; border:1px solid var(--border); background:var(--panel-2); color:var(--text); border-radius:8px; cursor:pointer; font-size:12px; }
.seg.active { background:var(--accent); color:#fff; border-color:var(--accent); }
input[type=color]{ width:34px; height:24px; border:1px solid var(--border); border-radius:6px; background:none; padding:0; cursor:pointer; }
#stage { flex: 1; position: relative; overflow: hidden; cursor: grab; }
#stage.grabbing { cursor: grabbing; }
svg.tree { width: 100%; height: 100%; display: block; }
.link { fill: none; stroke: var(--border); stroke-width: 2; transition: stroke-width .12s; }
.link.spouse { stroke-width: 2.5; }
.link.mag, .link.magfix { stroke-width: 4.5 !important; filter: drop-shadow(0 0 4px var(--accent)); }
.node { cursor: pointer; }
.node .card {
fill: var(--panel); stroke: var(--border); stroke-width: 1.5;
filter: drop-shadow(var(--node-shadow));
}
.node.male .card { stroke: var(--male); }
.node.female .card { stroke: var(--female); }
.node .accent { }
.node.male .accent { fill: var(--male-bg); }
.node.female .accent { fill: var(--female-bg); }
.node .nm { font-size: 13px; font-weight: 700; fill: var(--text); }
.node .yr { font-size: 11px; fill: var(--muted); }
.node.highlight .card { stroke: var(--accent); stroke-width: 3; }
.node .avatar-txt { font-size: 15px; font-weight: 700; fill: #fff; }
.collapse {
fill: var(--accent); cursor: pointer;
}
.collapse-txt { fill: #fff; font-size: 12px; font-weight: 700; pointer-events: none; }
.controls {
position: absolute; left: 14px; bottom: 14px; display: flex; flex-direction: column; gap: 6px; z-index: 10;
}
.controls button {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid var(--border);
background: var(--panel); color: var(--text); font-size: 18px; cursor: pointer; box-shadow: var(--shadow);
}
.legend {
position: absolute; right: 14px; bottom: 14px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; font-size: 12px;
box-shadow: var(--shadow); display: flex; gap: 14px; align-items: center;
}
.legend .dot { width: 11px; height: 11px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: -1px; }
/* Modal */
.overlay {
position: fixed; inset: 0; background: rgba(10,14,25,.55); backdrop-filter: blur(2px);
display: none; align-items: center; justify-content: center; z-index: 50; padding: 16px;
}
.overlay.show { display: flex; }
.modal {
background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
width: 720px; max-width: 100%; max-height: 90vh; overflow: auto; box-shadow: var(--shadow);
}
.modal .head {
display: flex; gap: 16px; padding: 22px; border-bottom: 1px solid var(--border);
align-items: center; position: relative;
}
.modal .avatar {
width: 92px; height: 92px; border-radius: 18px; flex: none; display: grid; place-items: center;
font-size: 32px; font-weight: 700; color: #fff;
}
.modal h2 { margin: 0 0 4px; font-size: 22px; }
.modal .sub { color: var(--muted); font-size: 14px; }
.modal .close { position: absolute; right: 16px; top: 16px; border: none; background: var(--panel-2); width: 34px; height: 34px; border-radius: 9px; cursor: pointer; color: var(--text); font-size: 18px; }
.modal .body { padding: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
.modal .full { grid-column: 1 / -1; }
.field-label { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 3px; }
.field-val { font-size: 14px; margin-bottom: 14px; line-height: 1.45; }
.rel-group { margin-bottom: 12px; }
.chip {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; margin: 0 6px 6px 0;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 20px; cursor: pointer; font-size: 13px;
}
.chip:hover { border-color: var(--accent); }
.chip .d { width: 8px; height: 8px; border-radius: 50%; }
.gallery { display: flex; gap: 8px; flex-wrap: wrap; }
.gallery .ph {
width: 76px; height: 76px; border-radius: 12px; display: grid; place-items: center;
background: var(--panel-2); border: 1px solid var(--border); font-size: 22px;
}
.modal .actions { padding: 0 22px 20px; display: flex; gap: 8px; }
/* Form */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 22px; }
.form-grid .full { grid-column: 1 / -1; }
.form-grid label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; }
.form-grid input, .form-grid select, .form-grid textarea {
width: 100%; padding: 9px 11px; border: 1px solid var(--border); border-radius: 9px;
background: var(--panel-2); color: var(--text); font-size: 14px; font-family: inherit;
}
.form-grid textarea { resize: vertical; min-height: 64px; }
.toast {
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: var(--bg); padding: 10px 18px; border-radius: 10px; font-size: 14px;
opacity: 0; transition: .25s; z-index: 80; pointer-events: none;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 640px) {
.modal .body, .form-grid { grid-template-columns: 1fr; }
.brand span.t { display: none; }
.search input { width: 150px; }
}
</style>
</head>
<body>
<header>
<div class="brand"><div class="logo">🌳</div><span class="t">Родословное дерево</span></div>
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
<input id="searchInput" placeholder="Поиск по имени или фамилии…" autocomplete="off" />
<div class="results" id="results"></div>
</div>
<div class="spacer"></div>
<button class="btn primary" id="addBtn"> Добавить</button>
<div class="menu" id="ioMenu">
<button class="btn" id="ioBtn">Импорт / Экспорт ▾</button>
<div class="dropdown">
<div class="label">Экспорт</div>
<button data-act="exp-json">Экспорт в JSON</button>
<button data-act="exp-csv">Экспорт в CSV</button>
<button data-act="exp-gedcom">Экспорт в GEDCOM</button>
<div class="sep"></div>
<div class="label">Импорт</div>
<button data-act="imp-json">Импорт JSON</button>
<button data-act="imp-csv">Импорт CSV</button>
<button data-act="imp-gedcom">Импорт GEDCOM</button>
<div class="sep"></div>
<button data-act="print">🖨 Печать / PDF</button>
</div>
</div>
<div class="menu" id="vizMenu">
<button class="btn" id="vizBtn" title="Визуализация веток">🌿 Вид ▾</button>
<div class="dropdown" style="min-width:240px;padding:6px 0">
<div class="label">Ориентация дерева</div>
<div style="display:flex;gap:6px;padding:4px 14px 8px">
<button class="seg" data-orient="vertical">Вертикально</button>
<button class="seg" data-orient="horizontal">Горизонтально</button>
</div>
<div class="label">Вид связей (лучей)</div>
<div style="display:flex;gap:6px;padding:4px 14px 8px">
<button class="seg" data-edge="ortho">Примагнич.</button>
<button class="seg" data-edge="curve">Кривые</button>
<button class="seg" data-edge="straight">Прямые</button>
</div>
<div class="label">Цвета лучей</div>
<div style="padding:4px 14px 8px;display:grid;grid-template-columns:1fr auto;gap:8px 6px;align-items:center;font-size:13px">
<span>Родители — дети</span><input type="color" data-color="parent" value="#7c8aa5">
<span>Супруги</span><input type="color" data-color="spouse" value="#c9a14a">
<span>Усыновление</span><input type="color" data-color="adopt" value="#e06aa3">
</div>
<div class="sep"></div>
<button data-act-viz="expand">▽ Развернуть все ветки</button>
<button data-act-viz="collapse">△ Свернуть все ветки</button>
</div>
</div>
<button class="btn" id="themeBtn" title="Тема">🌙</button>
</header>
<div id="stage">
<svg class="tree" id="svg">
<g id="viewport">
<g id="links"></g>
<g id="nodes"></g>
</g>
</svg>
<div class="controls">
<button id="zoomIn"></button>
<button id="zoomOut"></button>
<button id="fit" title="Показать всё"></button>
</div>
<div class="legend">
<span><span class="dot" style="background:var(--male)"></span>Мужчины</span>
<span><span class="dot" style="background:var(--female)"></span>Женщины</span>
<span id="count"></span>
</div>
</div>
<input type="file" id="fileInput" style="display:none" />
<div class="overlay" id="overlay"></div>
<div class="toast" id="toast"></div>
<script>
/* ====================== Данные (тестовый набор) ====================== */
let PEOPLE = [
{id:"p1", firstName:"Иван", lastName:"Соколов", middleName:"Петрович", gender:"male", birthDate:"1928-03-12", deathDate:"1998-11-04", birthPlace:"Тула, СССР", bio:"Глава рода, ветеран, работал инженером-конструктором на оборонном заводе.", contacts:"", notes:"Основатель семейного дела.", context:"Призван в армию в 1944. Награждён за восстановление завода. Вёл подробный дневник, который хранится в семейном архиве.", resourceUrl:"https://ru.wikipedia.org/wiki/Память_народа", spouseIds:["p2"], gallery:["📜","🏅","🏭"]},
{id:"p2", firstName:"Мария", lastName:"Соколова", middleName:"Андреевна", gender:"female", birthDate:"1931-07-22", deathDate:"2010-02-19", birthPlace:"Калуга, СССР", bio:"Учительница русского языка и литературы, хранительница семейного архива.", contacts:"", notes:"", spouseIds:["p1"], gallery:["📚","🌷"]},
{id:"p3", firstName:"Сергей", lastName:"Соколов", middleName:"Иванович", gender:"male", birthDate:"1955-05-30", deathDate:"", birthPlace:"Тула, СССР", bio:"Старший сын, врач-хирург, кандидат медицинских наук.", contacts:"sergey@example.com", notes:"", fatherId:"p1", motherId:"p2", spouseIds:["p4"], gallery:["🩺","🎓"]},
{id:"p4", firstName:"Ольга", lastName:"Соколова", middleName:"Викторовна", gender:"female", birthDate:"1958-09-14", deathDate:"", birthPlace:"Рязань, СССР", bio:"Архитектор, преподаёт в университете.", contacts:"olga@example.com", notes:"", spouseIds:["p3"], gallery:["📐"]},
{id:"p5", firstName:"Елена", lastName:"Кузнецова", middleName:"Ивановна", gender:"female", birthDate:"1959-12-02", deathDate:"", birthPlace:"Тула, СССР", bio:"Младшая дочь, экономист.", contacts:"", notes:"Сменила фамилию после замужества.", fatherId:"p1", motherId:"p2", spouseIds:["p6"], gallery:["💼"]},
{id:"p6", firstName:"Дмитрий", lastName:"Кузнецов", middleName:"Олегович", gender:"male", birthDate:"1956-04-18", deathDate:"", birthPlace:"Москва, СССР", bio:"Журналист.", contacts:"", notes:"", spouseIds:["p5"], gallery:["📰"]},
{id:"p7", firstName:"Алексей", lastName:"Соколов", middleName:"Сергеевич", gender:"male", birthDate:"1982-08-08", deathDate:"", birthPlace:"Тула, Россия", bio:"Программист, основатель IT-компании.", contacts:"alex@example.com", notes:"", fatherId:"p3", motherId:"p4", spouseIds:["p8"], gallery:["💻","🚀"]},
{id:"p8", firstName:"Анна", lastName:"Соколова", middleName:"Павловна", gender:"female", birthDate:"1985-01-25", deathDate:"", birthPlace:"Санкт-Петербург, Россия", bio:"Дизайнер, иллюстратор.", contacts:"", notes:"", spouseIds:["p7"], gallery:["🎨"]},
{id:"p9", firstName:"Наталья", lastName:"Соколова", middleName:"Сергеевна", gender:"female", birthDate:"1986-11-30", deathDate:"", birthPlace:"Тула, Россия", bio:"Биолог-генетик.", contacts:"", notes:"", fatherId:"p3", motherId:"p4", spouseIds:[], gallery:["🧬"]},
{id:"p10", firstName:"Павел", lastName:"Кузнецов", middleName:"Дмитриевич", gender:"male", birthDate:"1984-06-11", deathDate:"", birthPlace:"Москва, Россия", bio:"Музыкант, композитор.", contacts:"", notes:"", fatherId:"p6", motherId:"p5", spouseIds:["p11"], gallery:["🎼","🎹"]},
{id:"p11", firstName:"Светлана", lastName:"Кузнецова", middleName:"Игоревна", gender:"female", birthDate:"1987-03-03", deathDate:"", birthPlace:"Москва, Россия", bio:"Врач-педиатр.", contacts:"", notes:"", spouseIds:["p10"], gallery:[]},
{id:"p12", firstName:"Максим", lastName:"Соколов", middleName:"Алексеевич", gender:"male", birthDate:"2010-02-14", deathDate:"", birthPlace:"Москва, Россия", bio:"Школьник, увлекается робототехникой.", contacts:"", notes:"", fatherId:"p7", motherId:"p8", spouseIds:[], gallery:["🤖"]},
{id:"p13", firstName:"София", lastName:"Соколова", middleName:"Алексеевна", gender:"female", birthDate:"2013-09-09", deathDate:"", birthPlace:"Москва, Россия", bio:"Школьница, занимается балетом.", contacts:"", notes:"Удочерена в 2015 году.", fatherId:"p7", motherId:"p8", adopted:true, spouseIds:[], gallery:["🩰"]},
{id:"p14", firstName:"Артём", lastName:"Кузнецов", middleName:"Павлович", gender:"male", birthDate:"2012-12-20", deathDate:"", birthPlace:"Москва, Россия", bio:"Школьник.", contacts:"", notes:"", fatherId:"p10", motherId:"p11", spouseIds:[], gallery:[]}
];
/* ====================== Утилиты ====================== */
const $ = s => document.querySelector(s);
const byId = {};
function reindex(){ for(const k in byId) delete byId[k]; PEOPLE.forEach(p=>byId[p.id]=p); }
reindex();
const NODE_W = 168, NODE_H = 96, V_GAP = 78, COUPLE_GAP = 26, H_GAP = 34;
let collapsed = new Set();
/* Настройки визуализации */
const settings = {
orientation: "vertical", // vertical | horizontal
edgeStyle: "ortho", // straight | ortho (примагниченные) | curve
colors: { parent:"#7c8aa5", spouse:"#c9a14a", adopt:"#e06aa3" }
};
function fullName(p){ return [p.lastName, p.firstName, p.middleName].filter(Boolean).join(" "); }
function shortName(p){ return [p.firstName, p.lastName].filter(Boolean).join(" "); }
function years(p){ const b=p.birthDate?p.birthDate.slice(0,4):"?"; const d=p.deathDate?p.deathDate.slice(0,4):""; return d?`${b} ${d}`:`р. ${b}`; }
function initials(p){ return ((p.firstName||" ")[0]+(p.lastName||" ")[0]).toUpperCase(); }
function genderColor(p){ return p.gender==="female" ? "var(--female)" : "var(--male)"; }
function childrenOf(id){ return PEOPLE.filter(c => c.fatherId===id || c.motherId===id); }
function primaryParentChildren(coupleA, coupleB){
return PEOPLE.filter(c => {
const pr = c.fatherId || c.motherId; // «владелец» раскладки ребёнка — основной родитель
return pr===coupleA || (coupleB && pr===coupleB);
});
}
/* ====================== Раскладка дерева (поддержка ориентаций) ====================== */
let positioned = {};
let cursor = 0;
function curConf(){
const horiz = settings.orientation==="horizontal";
return {
horiz,
BW: horiz ? NODE_H : NODE_W, // размер узла вдоль оси «ширины»
CG: horiz ? 22 : COUPLE_GAP, // зазор между супругами
FG: horiz ? 30 : H_GAP, // зазор между семьями
DEP: horiz ? (NODE_W + 120) : (NODE_H + V_GAP) // расстояние между поколениями
};
}
function setPos(id, b, depth, C){
positioned[id] = C.horiz ? {x:depth*C.DEP, y:b, depth, b} : {x:b, y:depth*C.DEP, depth, b};
}
function layout(id, depth, C){
if(positioned[id]) return;
const a = byId[id];
const bId = (a.spouseIds||[]).find(s => byId[s] && !positioned[s]);
let kids = [];
if(!collapsed.has(id) && !(bId && collapsed.has(bId))){
kids = primaryParentChildren(id, bId).map(c=>c.id);
}
if(kids.length===0){
setPos(id, cursor, depth, C); cursor += C.BW;
if(bId){ cursor += C.CG; setPos(bId, cursor, depth, C); cursor += C.BW; }
cursor += C.FG;
} else {
kids.forEach(k => layout(k, depth+1, C));
const centers = kids.map(coupleCenterB);
const mid = (Math.min(...centers)+Math.max(...centers))/2;
const coupleW = bId ? C.BW*2+C.CG : C.BW;
const aB = mid - coupleW/2;
setPos(id, aB, depth, C);
if(bId) setPos(bId, aB+C.BW+C.CG, depth, C);
}
}
function coupleCenterB(id){
const a = positioned[id]; if(!a) return 0;
const C = curConf();
const bId = (byId[id].spouseIds||[]).find(s=>positioned[s]);
if(bId){ const b = positioned[bId]; return (a.b + b.b + C.BW)/2; }
return a.b + C.BW/2;
}
/* ====================== Отрисовка ====================== */
const SVGNS = "http://www.w3.org/2000/svg";
function el(tag, attrs){ const e=document.createElementNS(SVGNS, tag); for(const k in attrs) e.setAttribute(k, attrs[k]); return e; }
function render(){
positioned = {}; cursor = 0;
const C = curConf();
const roots = PEOPLE.filter(p => !p.fatherId && !p.motherId);
roots.forEach(r => layout(r.id, 0, C));
PEOPLE.forEach(p => { if(!positioned[p.id]) layout(p.id, 0, C); });
const linksG = $("#links"), nodesG = $("#nodes");
linksG.innerHTML = ""; nodesG.innerHTML = "";
// супружеские связи
const drawn = new Set();
PEOPLE.forEach(p => {
const a = positioned[p.id]; if(!a) return;
(p.spouseIds||[]).forEach(sid => {
const b = positioned[sid]; if(!b) return;
const key = [p.id,sid].sort().join("-"); if(drawn.has(key)) return; drawn.add(key);
let lk;
if(C.horiz){
const top = a.y<b.y?a:b, bot = a.y<b.y?b:a;
lk = el("line", {x1:top.x+NODE_W/2, y1:top.y+NODE_H, x2:bot.x+NODE_W/2, y2:bot.y});
} else {
const L = a.x<b.x?a:b, R = a.x<b.x?b:a;
lk = el("line", {x1:L.x+NODE_W, y1:L.y+NODE_H/2, x2:R.x, y2:R.y+NODE_H/2});
}
lk.setAttribute("class","link spouse");
lk.setAttribute("stroke", settings.colors.spouse);
lk.setAttribute("data-a", p.id); lk.setAttribute("data-b", sid);
linksG.appendChild(lk);
});
});
// связи родитель-ребёнок
const handledCouple = new Set();
PEOPLE.forEach(p => {
if(collapsed.has(p.id)) return;
const a = positioned[p.id]; if(!a) return;
const bId = (p.spouseIds||[]).find(s=>positioned[s]);
const coupleKey = [p.id, bId||""].sort().join("-");
if(handledCouple.has(coupleKey)) return; handledCouple.add(coupleKey);
const kids = primaryParentChildren(p.id, bId);
if(!kids.length) return;
const cB = coupleCenterB(p.id);
let sx, sy;
if(C.horiz){ sx = a.x+NODE_W; sy = cB; } else { sx = cB; sy = a.y+NODE_H; }
kids.forEach(c => {
const cc = positioned[c.id]; if(!cc) return;
let ex, ey;
if(C.horiz){ ex = cc.x; ey = cc.y+NODE_H/2; } else { ex = cc.x+NODE_W/2; ey = cc.y; }
const path = el("path", {class:"link", d:edgePath(sx,sy,ex,ey,C), fill:"none"});
path.setAttribute("stroke", c.adopted ? settings.colors.adopt : settings.colors.parent);
if(c.adopted) path.setAttribute("stroke-dasharray","6 5");
path.setAttribute("data-a", p.id); path.setAttribute("data-b", c.id);
if(bId) path.setAttribute("data-c", bId);
linksG.appendChild(path);
});
});
drawNodes(C);
$("#count").textContent = PEOPLE.length + " чел.";
}
function edgePath(sx,sy,ex,ey,C){
if(settings.edgeStyle==="straight") return `M ${sx} ${sy} L ${ex} ${ey}`;
if(settings.edgeStyle==="curve"){
if(C.horiz){ const mx=(sx+ex)/2; return `M ${sx} ${sy} C ${mx} ${sy} ${mx} ${ey} ${ex} ${ey}`; }
const my=(sy+ey)/2; return `M ${sx} ${sy} C ${sx} ${my} ${ex} ${my} ${ex} ${ey}`;
}
// ortho «примагниченные»
if(C.horiz){ const mx=(sx+ex)/2; return `M ${sx} ${sy} L ${mx} ${sy} L ${mx} ${ey} L ${ex} ${ey}`; }
const my=(sy+ey)/2; return `M ${sx} ${sy} L ${sx} ${my} L ${ex} ${my} L ${ex} ${ey}`;
}
function drawNodes(C){
const nodesG = $("#nodes");
PEOPLE.forEach(p => {
const pos = positioned[p.id]; if(!pos) return;
const g = el("g", {class:`node ${p.gender}`, transform:`translate(${pos.x},${pos.y})`, "data-id":p.id});
g.appendChild(el("rect", {class:"card", x:0, y:0, width:NODE_W, height:NODE_H, rx:14}));
g.appendChild(el("rect", {class:"accent", x:0, y:0, width:NODE_W, height:6, rx:3}));
// аватар
const av = el("g", {transform:"translate(14,18)"});
av.appendChild(el("circle", {cx:26, cy:26, r:26, fill:genderColor(p)}));
const t = el("text", {class:"avatar-txt", x:26, y:31, "text-anchor":"middle"}); t.textContent = initials(p);
av.appendChild(t);
g.appendChild(av);
// имя + годы
const nm = el("text", {class:"nm", x:72, y:38}); nm.textContent = trim(shortName(p), 14);
const pt = el("text", {class:"nm", x:72, y:55, "font-size":"11", "font-weight":"500", fill:"var(--muted)"}); pt.textContent = trim(p.middleName||"", 16);
const yr = el("text", {class:"yr", x:72, y:74}); yr.textContent = years(p);
g.appendChild(nm); g.appendChild(pt); g.appendChild(yr);
// кнопка сворачивания, если есть дети
if(childrenOf(p.id).length){
const cg = el("g", {class:"collapse-wrap", transform:`translate(${NODE_W-18},${NODE_H-18})`, "data-collapse":p.id});
cg.appendChild(el("circle", {class:"collapse", cx:0, cy:0, r:11}));
const ct = el("text", {class:"collapse-txt", x:0, y:4, "text-anchor":"middle"}); ct.textContent = collapsed.has(p.id) ? "+" : "";
cg.appendChild(ct);
g.appendChild(cg);
}
nodesG.appendChild(g);
});
$("#count").textContent = PEOPLE.length + " чел.";
}
function trim(s, n){ return s.length>n ? s.slice(0,n-1)+"…" : s; }
/* ====================== Pan / Zoom ====================== */
let scale = 1, tx = 60, ty = 40;
const vp = $("#viewport"), stage = $("#stage");
function applyTransform(){ vp.setAttribute("transform", `translate(${tx},${ty}) scale(${scale})`); }
applyTransform();
stage.addEventListener("wheel", e => {
e.preventDefault();
const rect = stage.getBoundingClientRect();
const mx = e.clientX-rect.left, my = e.clientY-rect.top;
const factor = e.deltaY<0 ? 1.12 : 1/1.12;
const ns = Math.min(2.5, Math.max(0.15, scale*factor));
tx = mx - (mx-tx)*(ns/scale); ty = my - (my-ty)*(ns/scale);
scale = ns; applyTransform();
}, {passive:false});
let dragging=false, lastX, lastY, moved=false;
stage.addEventListener("mousedown", e => { dragging=true; moved=false; lastX=e.clientX; lastY=e.clientY; stage.classList.add("grabbing"); });
window.addEventListener("mousemove", e => {
if(!dragging) return;
const dx=e.clientX-lastX, dy=e.clientY-lastY;
if(Math.abs(dx)+Math.abs(dy)>3) moved=true;
tx+=dx; ty+=dy; lastX=e.clientX; lastY=e.clientY; applyTransform();
});
window.addEventListener("mouseup", ()=>{ dragging=false; stage.classList.remove("grabbing"); });
// touch
let pinchDist=0;
stage.addEventListener("touchstart", e=>{
if(e.touches.length===1){ dragging=true; moved=false; lastX=e.touches[0].clientX; lastY=e.touches[0].clientY; }
else if(e.touches.length===2){ pinchDist = dist2(e.touches); }
},{passive:true});
stage.addEventListener("touchmove", e=>{
if(e.touches.length===1 && dragging){
const dx=e.touches[0].clientX-lastX, dy=e.touches[0].clientY-lastY;
if(Math.abs(dx)+Math.abs(dy)>3) moved=true;
tx+=dx; ty+=dy; lastX=e.touches[0].clientX; lastY=e.touches[0].clientY; applyTransform();
} else if(e.touches.length===2){
const d=dist2(e.touches); const factor=d/pinchDist; pinchDist=d;
const ns=Math.min(2.5,Math.max(0.15,scale*factor)); scale=ns; applyTransform();
}
},{passive:true});
stage.addEventListener("touchend", ()=> dragging=false);
function dist2(t){ return Math.hypot(t[0].clientX-t[1].clientX, t[0].clientY-t[1].clientY); }
$("#zoomIn").onclick = ()=>{ scale=Math.min(2.5,scale*1.2); applyTransform(); };
$("#zoomOut").onclick = ()=>{ scale=Math.max(0.15,scale/1.2); applyTransform(); };
$("#fit").onclick = fitView;
function fitView(){
const xs=[], ys=[];
for(const k in positioned){ xs.push(positioned[k].x, positioned[k].x+NODE_W); ys.push(positioned[k].y, positioned[k].y+NODE_H); }
if(!xs.length) return;
const minX=Math.min(...xs), maxX=Math.max(...xs), minY=Math.min(...ys), maxY=Math.max(...ys);
const rect=stage.getBoundingClientRect();
const sx=(rect.width-80)/(maxX-minX), sy=(rect.height-80)/(maxY-minY);
scale=Math.min(1.2, Math.max(0.15, Math.min(sx,sy)));
tx=(rect.width-(maxX-minX)*scale)/2 - minX*scale;
ty=(rect.height-(maxY-minY)*scale)/2 - minY*scale;
applyTransform();
}
/* клики по узлам */
$("#nodes").addEventListener("click", e=>{
if(moved) return;
const col = e.target.closest("[data-collapse]");
if(col){ const id=col.getAttribute("data-collapse"); collapsed.has(id)?collapsed.delete(id):collapsed.add(id); render(); return; }
const node = e.target.closest(".node");
if(node){ openCard(node.getAttribute("data-id")); }
});
function centerOn(id){
const pos=positioned[id]; if(!pos) return;
const rect=stage.getBoundingClientRect();
scale=Math.max(scale,0.8);
tx=rect.width/2 - (pos.x+NODE_W/2)*scale;
ty=rect.height/2 - (pos.y+NODE_H/2)*scale;
applyTransform();
document.querySelectorAll(".node.highlight").forEach(n=>n.classList.remove("highlight"));
const n=document.querySelector(`.node[data-id="${id}"]`); if(n) n.classList.add("highlight");
document.querySelectorAll(".link.magfix").forEach(l=>l.classList.remove("magfix"));
document.querySelectorAll(`.link[data-a="${id}"],.link[data-b="${id}"],.link[data-c="${id}"]`).forEach(l=>l.classList.add("magfix"));
}
/* ====================== Поиск ====================== */
const searchInput=$("#searchInput"), results=$("#results");
searchInput.addEventListener("input", ()=>{
const q=searchInput.value.trim().toLowerCase();
if(!q){ results.classList.remove("show"); return; }
const found=PEOPLE.filter(p => fullName(p).toLowerCase().includes(q)).slice(0,8);
results.innerHTML = found.map(p=>`<div class="row" data-id="${p.id}">
<span style="width:9px;height:9px;border-radius:50%;background:${genderColor(p)}"></span>
<span>${fullName(p)}</span><span style="margin-left:auto;color:var(--muted);font-size:12px">${years(p)}</span></div>`).join("") || `<div class="row">Ничего не найдено</div>`;
results.classList.add("show");
});
results.addEventListener("click", e=>{
const row=e.target.closest("[data-id]"); if(!row) return;
const id=row.getAttribute("data-id");
results.classList.remove("show"); searchInput.value="";
centerOn(id); setTimeout(()=>openCard(id), 250);
});
document.addEventListener("click", e=>{ if(!e.target.closest(".search")) results.classList.remove("show"); });
/* ====================== Карточка человека ====================== */
const overlay=$("#overlay");
function relChips(list){ return list.map(p=>`<span class="chip" data-go="${p.id}"><span class="d" style="background:${genderColor(p)}"></span>${shortName(p)}</span>`).join("") || '<span style="color:var(--muted)"></span>'; }
function openCard(id){
const p=byId[id]; if(!p) return;
const parents=[p.fatherId,p.motherId].map(x=>byId[x]).filter(Boolean);
const spouses=(p.spouseIds||[]).map(x=>byId[x]).filter(Boolean);
const kids=childrenOf(id);
const sibs=PEOPLE.filter(s=>s.id!==id && ((p.fatherId&&s.fatherId===p.fatherId)||(p.motherId&&s.motherId===p.motherId)));
const gallery=(p.gallery||[]).map(g=>`<div class="ph">${g}</div>`).join("") || '<span style="color:var(--muted)">Нет фотографий</span>';
overlay.innerHTML=`<div class="modal">
<div class="head">
<div class="avatar" style="background:${genderColor(p)}">${initials(p)}</div>
<div>
<h2>${fullName(p)}</h2>
<div class="sub">${p.gender==="female"?"Женщина":"Мужчина"} · ${years(p)} ${p.adopted?' · усыновл./удочерён':''}</div>
<div class="sub">${p.birthPlace||""}</div>
</div>
<button class="close" data-close></button>
</div>
<div class="body">
<div class="full"><div class="field-label">Биография</div><div class="field-val">${p.bio||"—"}</div></div>
<div><div class="field-label">Дата рождения</div><div class="field-val">${p.birthDate||"—"}</div></div>
<div><div class="field-label">Дата смерти</div><div class="field-val">${p.deathDate||"—"}</div></div>
<div><div class="field-label">Место рождения</div><div class="field-val">${p.birthPlace||"—"}</div></div>
<div><div class="field-label">Контакты</div><div class="field-val">${p.contacts||"—"}</div></div>
<div class="full"><div class="field-label">Заметки</div><div class="field-val">${p.notes||"—"}</div></div>
<div class="full"><div class="field-label">Контекст / дополнительная информация</div><div class="field-val">${p.context||"—"}</div></div>
${p.resourceUrl?`<div class="full"><div class="field-label">Подробный ресурс</div><div class="field-val">🔗 <a href="${p.resourceUrl}" target="_blank" rel="noopener" style="color:var(--accent)">${p.resourceUrl}</a></div></div>`:""}
<div class="full"><div class="field-label">Родственники</div>
<div class="rel-group"><b>Родители:</b> ${relChips(parents)}</div>
<div class="rel-group"><b>Супруг(а):</b> ${relChips(spouses)}</div>
<div class="rel-group"><b>Дети:</b> ${relChips(kids)}</div>
<div class="rel-group"><b>Братья/сёстры:</b> ${relChips(sibs)}</div>
</div>
<div class="full"><div class="field-label">Галерея фотографий</div><div class="gallery">${gallery}</div></div>
</div>
<div class="actions">
<button class="btn primary" data-edit="${id}">✎ Редактировать</button>
<button class="btn" data-locate="${id}">🎯 Показать в дереве</button>
<button class="btn" data-del="${id}" style="margin-left:auto;color:#e0556b">🗑 Удалить</button>
</div>
</div>`;
overlay.classList.add("show");
}
overlay.addEventListener("click", e=>{
if(e.target===overlay || e.target.closest("[data-close]")){ overlay.classList.remove("show"); return; }
const go=e.target.closest("[data-go]"); if(go){ openCard(go.getAttribute("data-go")); return; }
const loc=e.target.closest("[data-locate]"); if(loc){ overlay.classList.remove("show"); centerOn(loc.getAttribute("data-locate")); return; }
const ed=e.target.closest("[data-edit]"); if(ed){ openForm(ed.getAttribute("data-edit")); return; }
const del=e.target.closest("[data-del]"); if(del){ deletePerson(del.getAttribute("data-del")); return; }
});
/* ====================== Добавление / Редактирование ====================== */
function openForm(id){
const p = id ? byId[id] : {id:"p"+Date.now(), gender:"male", spouseIds:[], gallery:[]};
const opt = (val,sel)=>`<option value="${val}" ${val===sel?"selected":""}>`;
const personOpts = sel => `<option value=""></option>`+PEOPLE.filter(x=>x.id!==p.id).map(x=>`<option value="${x.id}" ${sel===x.id?"selected":""}>${fullName(x)}</option>`).join("");
const spouseSel = (p.spouseIds||[])[0]||"";
overlay.innerHTML=`<div class="modal"><div class="head"><h2>${id?"Редактирование":"Новый человек"}</h2><button class="close" data-close></button></div>
<div class="form-grid">
<div><label>Имя</label><input id="f_first" value="${p.firstName||""}"></div>
<div><label>Фамилия</label><input id="f_last" value="${p.lastName||""}"></div>
<div><label>Отчество</label><input id="f_mid" value="${p.middleName||""}"></div>
<div><label>Пол</label><select id="f_gender">${opt("male",p.gender)}Мужской</option>${opt("female",p.gender)}Женский</option></select></div>
<div><label>Дата рождения</label><input id="f_birth" type="date" value="${p.birthDate||""}"></div>
<div><label>Дата смерти</label><input id="f_death" type="date" value="${p.deathDate||""}"></div>
<div><label>Отец</label><select id="f_father">${personOpts(p.fatherId)}</select></div>
<div><label>Мать</label><select id="f_mother">${personOpts(p.motherId)}</select></div>
<div><label>Супруг(а)</label><select id="f_spouse">${personOpts(spouseSel)}</select></div>
<div><label>Место рождения</label><input id="f_place" value="${p.birthPlace||""}"></div>
<div class="full"><label>Контакты</label><input id="f_contacts" value="${p.contacts||""}"></div>
<div class="full"><label>Биография</label><textarea id="f_bio">${p.bio||""}</textarea></div>
<div class="full"><label>Заметки</label><textarea id="f_notes">${p.notes||""}</textarea></div>
<div class="full"><label>Контекст / дополнительная информация</label><textarea id="f_context" placeholder="Любой дополнительный контекст о человеке…">${p.context||""}</textarea></div>
<div class="full"><label>Ссылка на подробный ресурс</label><input id="f_resource" type="url" placeholder="https://…" value="${p.resourceUrl||""}"></div>
</div>
<div class="actions"><button class="btn primary" id="saveBtn">💾 Сохранить</button><button class="btn" data-close>Отмена</button></div>
</div>`;
overlay.classList.add("show");
$("#saveBtn").onclick = ()=>{
const np = {...p,
firstName:$("#f_first").value.trim(), lastName:$("#f_last").value.trim(), middleName:$("#f_mid").value.trim(),
gender:$("#f_gender").value, birthDate:$("#f_birth").value, deathDate:$("#f_death").value,
fatherId:$("#f_father").value||undefined, motherId:$("#f_mother").value||undefined,
birthPlace:$("#f_place").value.trim(), contacts:$("#f_contacts").value.trim(),
bio:$("#f_bio").value.trim(), notes:$("#f_notes").value.trim(),
context:$("#f_context").value.trim(), resourceUrl:$("#f_resource").value.trim(),
gallery:p.gallery||[]
};
const sp=$("#f_spouse").value;
np.spouseIds = sp ? [sp] : [];
const exists = byId[np.id];
if(exists){ Object.assign(exists, np); } else { PEOPLE.push(np); }
// двунаправленная супружеская связь
PEOPLE.forEach(x=>{ if(x.spouseIds) x.spouseIds = x.spouseIds.filter(s=>s!==np.id); });
if(sp){ const s=byId[sp]; if(s){ s.spouseIds=s.spouseIds||[]; if(!s.spouseIds.includes(np.id)) s.spouseIds.push(np.id); } }
reindex(); collapsed.clear(); render();
overlay.classList.remove("show");
toast(exists?"Изменения сохранены":"Человек добавлен");
};
}
$("#addBtn").onclick = ()=>openForm(null);
function deletePerson(id){
if(!confirm("Удалить этого человека из дерева?")) return;
PEOPLE = PEOPLE.filter(p=>p.id!==id);
PEOPLE.forEach(p=>{
if(p.fatherId===id) p.fatherId=undefined;
if(p.motherId===id) p.motherId=undefined;
if(p.spouseIds) p.spouseIds=p.spouseIds.filter(s=>s!==id);
});
reindex(); render(); overlay.classList.remove("show"); toast("Запись удалена");
}
/* ====================== Тема ====================== */
const themeBtn=$("#themeBtn");
function setTheme(t){ document.documentElement.setAttribute("data-theme", t); themeBtn.textContent = t==="dark"?"☀️":"🌙"; }
themeBtn.onclick = ()=>{ const cur=document.documentElement.getAttribute("data-theme")==="dark"?"light":"dark"; setTheme(cur); };
setTheme(window.matchMedia && matchMedia("(prefers-color-scheme: dark)").matches ? "dark":"light");
/* ====================== Импорт / Экспорт ====================== */
const ioMenu=$("#ioMenu");
$("#ioBtn").onclick = e=>{ e.stopPropagation(); ioMenu.classList.toggle("open"); };
document.addEventListener("click", e=>{ if(!e.target.closest("#ioMenu")) ioMenu.classList.remove("open"); });
ioMenu.querySelector(".dropdown").addEventListener("click", e=>{
const b=e.target.closest("[data-act]"); if(!b) return;
ioMenu.classList.remove("open");
const act=b.getAttribute("data-act");
if(act==="exp-json") download("family-tree.json", JSON.stringify(PEOPLE,null,2));
if(act==="exp-csv") download("family-tree.csv", toCSV());
if(act==="exp-gedcom") download("family-tree.ged", toGEDCOM());
if(act==="print") window.print();
if(act.startsWith("imp-")) importFile(act.split("-")[1]);
});
function download(name, content){
const blob=new Blob([content],{type:"text/plain;charset=utf-8"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download=name; a.click();
toast("Файл «"+name+"» экспортирован");
}
function toCSV(){
const cols=["id","firstName","lastName","middleName","gender","birthDate","deathDate","birthPlace","contacts","bio","notes","fatherId","motherId","spouseIds"];
const esc=v=>`"${String(v??"").replace(/"/g,'""')}"`;
const rows=PEOPLE.map(p=>cols.map(c=>esc(c==="spouseIds"?(p.spouseIds||[]).join(";"):p[c])).join(","));
return cols.join(",")+"\n"+rows.join("\n");
}
function toGEDCOM(){
let g="0 HEAD\n1 SOUR FamilyTreePreview\n1 GEDC\n2 VERS 5.5.1\n1 CHAR UTF-8\n";
const fams=[]; const famKey={};
PEOPLE.forEach(p=>{
g+=`0 @${p.id.toUpperCase()}@ INDI\n1 NAME ${p.firstName} /${p.lastName}/\n1 SEX ${p.gender==="female"?"F":"M"}\n`;
if(p.birthDate||p.birthPlace){ g+=`1 BIRT\n`; if(p.birthDate)g+=`2 DATE ${p.birthDate}\n`; if(p.birthPlace)g+=`2 PLAC ${p.birthPlace}\n`; }
if(p.deathDate){ g+=`1 DEAT\n2 DATE ${p.deathDate}\n`; }
});
// семьи по парам родителей
let fi=1;
PEOPLE.forEach(c=>{
if(c.fatherId||c.motherId){
const key=(c.fatherId||"")+"|"+(c.motherId||"");
if(!famKey[key]){ famKey[key]={id:"F"+(fi++), husb:c.fatherId, wife:c.motherId, chil:[]}; fams.push(famKey[key]); }
famKey[key].chil.push(c.id);
}
});
fams.forEach(f=>{
g+=`0 @${f.id}@ FAM\n`;
if(f.husb)g+=`1 HUSB @${f.husb.toUpperCase()}@\n`;
if(f.wife)g+=`1 WIFE @${f.wife.toUpperCase()}@\n`;
f.chil.forEach(c=>g+=`1 CHIL @${c.toUpperCase()}@\n`);
});
g+="0 TRLR\n"; return g;
}
let importKind="json";
function importFile(kind){ importKind=kind; $("#fileInput").value=""; $("#fileInput").click(); }
$("#fileInput").addEventListener("change", e=>{
const file=e.target.files[0]; if(!file) return;
const r=new FileReader();
r.onload = ()=>{
try{
if(importKind==="json") PEOPLE=normalize(JSON.parse(r.result));
else if(importKind==="csv") PEOPLE=fromCSV(r.result);
else if(importKind==="gedcom") PEOPLE=fromGEDCOM(r.result);
reindex(); collapsed.clear(); render(); fitView();
toast("Импортировано: "+PEOPLE.length+" чел.");
}catch(err){ alert("Ошибка импорта: "+err.message); }
};
r.readAsText(file);
});
function normalize(arr){ return arr.map(p=>({...p, spouseIds:p.spouseIds||[], gallery:p.gallery||[]})); }
function fromCSV(text){
const lines=text.split(/\r?\n/).filter(l=>l.trim());
const cols=parseCSVLine(lines[0]);
return lines.slice(1).map(l=>{
const vals=parseCSVLine(l); const o={};
cols.forEach((c,i)=>o[c]=vals[i]);
o.spouseIds = o.spouseIds ? o.spouseIds.split(";").filter(Boolean) : [];
o.gallery=[]; return o;
});
}
function parseCSVLine(line){
const out=[]; let cur="",q=false;
for(let i=0;i<line.length;i++){ const ch=line[i];
if(q){ if(ch==='"'){ if(line[i+1]==='"'){cur+='"';i++;} else q=false; } else cur+=ch; }
else { if(ch===','){out.push(cur);cur="";} else if(ch==='"')q=true; else cur+=ch; }
} out.push(cur); return out;
}
function fromGEDCOM(text){
const lines=text.split(/\r?\n/); const indi={}; const fam={}; let cur=null, ctx=null, curFam=null;
lines.forEach(line=>{
const m=line.match(/^(\d+)\s+(@\w+@\s+)?(\w+)(\s+(.*))?$/); if(!m) return;
const level=+m[1], tagOrId=m[2]?m[2].trim():null, kw=m[3], val=m[5]||"";
if(level===0){
if(val==="INDI"){ const id=tagOrId.replace(/@/g,"").toLowerCase(); cur=indi[id]={id, spouseIds:[], gallery:[]}; ctx=null; curFam=null; }
else if(val==="FAM"){ const id=tagOrId.replace(/@/g,""); curFam=fam[id]={chil:[]}; cur=null; }
else { cur=null; curFam=null; }
} else if(cur){
if(kw==="NAME"){ const nm=val.match(/(.*)\/(.*)\//); if(nm){ cur.firstName=nm[1].trim(); cur.lastName=nm[2].trim(); } else cur.firstName=val.trim(); }
else if(kw==="SEX") cur.gender = val.trim()==="F"?"female":"male";
else if(kw==="BIRT") ctx="BIRT"; else if(kw==="DEAT") ctx="DEAT";
else if(kw==="DATE"){ if(ctx==="BIRT")cur.birthDate=val.trim(); else if(ctx==="DEAT")cur.deathDate=val.trim(); }
else if(kw==="PLAC"){ if(ctx==="BIRT")cur.birthPlace=val.trim(); }
} else if(curFam){
if(kw==="HUSB") curFam.husb=val.replace(/@/g,"").toLowerCase();
else if(kw==="WIFE") curFam.wife=val.replace(/@/g,"").toLowerCase();
else if(kw==="CHIL") curFam.chil.push(val.replace(/@/g,"").toLowerCase());
}
});
Object.values(fam).forEach(f=>{
if(f.husb&&f.wife){ if(indi[f.husb])indi[f.husb].spouseIds=[f.wife]; if(indi[f.wife])indi[f.wife].spouseIds=[f.husb]; }
f.chil.forEach(c=>{ if(indi[c]){ if(f.husb)indi[c].fatherId=f.husb; if(f.wife)indi[c].motherId=f.wife; } });
});
return Object.values(indi);
}
/* ====================== Toast ====================== */
let toastTimer;
function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove("show"),2200); }
/* ====================== Настройки визуализации ====================== */
const vizMenu=$("#vizMenu");
$("#vizBtn").onclick=e=>{ e.stopPropagation(); vizMenu.classList.toggle("open"); };
document.addEventListener("click", e=>{ if(!e.target.closest("#vizMenu")) vizMenu.classList.remove("open"); });
function syncViz(){
vizMenu.querySelectorAll("[data-orient]").forEach(b=>b.classList.toggle("active", b.dataset.orient===settings.orientation));
vizMenu.querySelectorAll("[data-edge]").forEach(b=>b.classList.toggle("active", b.dataset.edge===settings.edgeStyle));
}
vizMenu.addEventListener("click", e=>{
const o=e.target.closest("[data-orient]");
if(o){ settings.orientation=o.dataset.orient; syncViz(); render(); setTimeout(fitView,30); return; }
const ed=e.target.closest("[data-edge]");
if(ed){ settings.edgeStyle=ed.dataset.edge; syncViz(); render(); return; }
const av=e.target.closest("[data-act-viz]");
if(av){
if(av.dataset.actViz==="collapse") collapsed=new Set(PEOPLE.filter(p=>childrenOf(p.id).length).map(p=>p.id));
else collapsed.clear();
render();
}
});
vizMenu.querySelectorAll("[data-color]").forEach(inp=>{
inp.addEventListener("input", ()=>{ settings.colors[inp.dataset.color]=inp.value; render(); });
});
syncViz();
/* подсветка «примагниченных» лучей при наведении */
function magEdges(id, on){
document.querySelectorAll(`.link[data-a="${id}"],.link[data-b="${id}"],.link[data-c="${id}"]`).forEach(l=>l.classList.toggle("mag", on));
}
$("#nodes").addEventListener("mouseover", e=>{ const n=e.target.closest(".node"); if(n) magEdges(n.dataset.id, true); });
$("#nodes").addEventListener("mouseout", e=>{ const n=e.target.closest(".node"); if(n) magEdges(n.dataset.id, false); });
/* ====================== Старт ====================== */
render();
setTimeout(fitView, 60);
window.addEventListener("resize", ()=>{ /* viewport авто */ });
</script>
</body>
</html>