Family Tree: превью + full-stack (frontend, backend, SQL, Docker)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
Binary file not shown.
@@ -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, добавление/редактирование/удаление,
|
||||
переключение ориентации дерева, стили и цвета связей («лучей»), «примагниченные» линии
|
||||
при наведении, поля контекста и ссылки на подробный ресурс в карточке.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
@@ -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}`));
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
@@ -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:
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
.react-flow__attribution { display: none; }
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user