@mf/tree-layout (0.0.1)
Installation
@mf:registry=npm install @mf/tree-layout@0.0.1"@mf/tree-layout": "0.0.1"About this package
@mf/tree-layout — прототип движка раскладки родословной
Реализует решение ADR-0019 §8: один движок раскладки, два бэкенда вывода.
Движок принимает граф (Person / Union / ChildLink) и выдаёт сцену в
миллиметрах; SVG-бэкенд рисует её на экране, PDF-бэкенд печати (pdf-lib)
получит ту же сцену. Ни одного решения о геометрии за пределами движка — это и
есть гарантия, что напечатанное совпадает с экранным.
Раскладок две — линейная (layout) и веерная (layoutRadial); обе отдают одну
и ту же сцену, поэтому бэкенды о них не знают.
Статус: прототип, задача которого — ответить на вопрос реализуемости, а не стать финальным кодом. Оба бэкенда рабочие: SVG (экран) и PDF (печать).
Запуск
npm install
npm test # 139 тестов: раскладки, печать, контролы, GEDCOM
npx tsx scripts/render.ts # SVG всех фикстур в out/ + таблица метрик
npx tsx scripts/print.ts # печатные PDF (A1/A0), включая веер, в out/
Что гарантируется (проверяется тестами на каждой фикстуре)
| Инвариант | Как обеспечен |
|---|---|
| Ребёнок строго ниже каждого родителя | продавливание слоёв + финальная проверка |
| Карточки не пересекаются | прямой проход с обязательным зазором при неизменном порядке |
| Ни одна линия не идёт сквозь карточку | обходы через зазоры и боковые каналы; проверка — отсечением Лианга–Барски, то есть для любого наклона |
| Все персоны попадают в сцену | висячие ссылки отбрасываются, а не роняют движок |
Проверяется на обеих раскладках, на семи углах сектора и в двух режимах
карточек. До аудита 2026-08-12 тесты веера не проверяли порядок поколений
вовсе — и веер его нарушал: кольцо брал из глубины обхода, из-за чего на браке
с потомком внучка оказывалась в кольце 0 рядом с основателем. Теперь кольцо
берётся из общей разметки assignLayers — единственного источника истины про
«кто ниже кого».
Уродливые случаи и что с ними стало
Каждый — отдельная фикстура в test/fixtures.ts.
| Случай | Результат |
|---|---|
| Двойной брак той же пары | планки сиблингов разведены по высоте — два брака читаются как два, а не сливаются в один выводок |
| «Десяток жён» (5 союзов у одного) | цепочка «жена — муж — жена…»; 3 пары неизбежно не рядом (в одномерном ряду для звезды из n супругов минимум n−2) |
| Брак двоюродных | выравнивается штатно, откатов нет: в направленном графе это не цикл |
| Дядя женат на племяннице | партнёры выравниваются, дядя уезжает в младший ряд — перекос не нужен |
| Брак с собственным потомком | выровнять невозможно в принципе; союз исключается из выравнивания адресно, остальное дерево выравнивание сохраняет |
| Приёмность через 3 поколения | фиктивные узлы резервируют каналы, линия идёт штриховкой типа связи |
| Цикл в данных («сам себе предок») | ребро рвётся и помечается, битая связь уходит в боковой канал |
| Неизвестный родитель, сирота, союз без партнёров | рисуются, движок не падает |
| Две несвязанные семьи | раскладываются рядом |
Замеры печати — главный вывод прототипа
Карточка 44 мм, порог читаемости 2 мм кегля, поля 20 мм:
| Поколений | Персон | Габарит, мм | Соотн. | Кегль на A0 | Влезает на A0 |
|---|---|---|---|---|---|
| 3 | 10 | 221×94 | 2.4:1 | 16.6 мм | да |
| 4 | 22 | 457×134 | 3.4:1 | 8.1 мм | да |
| 5 | 46 | 929×174 | 5.3:1 | 4.0 мм | да |
| 6 | 94 | 1873×214 | 8.8:1 | 1.96 мм | нет |
| 7 | 190 | 3761×254 | 14.8:1 | 0.98 мм | нет |
A0 держит примерно пять поколений (~46 персон). Дальше упирается не в площадь, а в соотношение сторон: ширина удваивается с каждым поколением, а лист остаётся 1.41:1, и вся экономия высоты простаивает.
Обходные пути, все проверены тестами:
- сузить карточки: при 28 мм шестое поколение (94 персоны) снова читается на A0;
- перейти на веер (см. ниже) — снимает ограничение на порядок величины;
- разрезание на несколько листов в прототипе не реализовано.
gapYToFillSheet() растягивает вертикальные зазоры так, чтобы плакат занимал
лист целиком, а не лежал полосой посередине. Важно: границу вместимости это
не сдвигает — масштаб ограничен шириной, кегль от растягивания по вертикали
не растёт. Это вид плаката, а не вместимость.
Веерная раскладка — снимает ограничение вместимости
layoutRadial(graph, opts) — вторая раскладка, отдающая ту же сцену:
пара-основатель в центре, поколения кольцами, потомки расходятся по углу.
Линейная растёт полосой, веер занимает диск, и пропорция остаётся близкой к
листу.
Замер на A0 (карточка 44 мм, порог 2 мм):
| Поколений | Персон | Линейно | Веер 360° | Веер 240° |
|---|---|---|---|---|
| 5 | 46 | 3.96 мм ✓ | 4.45 мм ✓ | 6.23 мм ✓ |
| 6 | 94 | 1.96 мм ✗ | 3.65 мм ✓ | 4.96 мм ✓ |
| 7 | 190 | 0.98 мм ✗ | 3.11 мм ✓ | 4.17 мм ✓ |
| 8 | 382 | 0.49 мм ✗ | 2.71 мм ✓ | 2.75 мм ✓ |
Веер держит 382 персоны там, где линейная сдаётся на 94 — то есть разница не в проценты, а в порядок. Полный круг даёт пропорцию 1:1, а листы A-серии — 1.41:1, поэтому у круга простаивают бока, и сектор выигрывает.
Оговорка про «оптимальные 240°», которую стоит знать: 240° оптимальны только
на восьми поколениях. На 5–7 замер даёт лучший результат при 210°
(6 поколений: 5.18 мм против 4.96 мм), и это выяснилось лишь на аудите
2026-08-12 — исходное «240° оптимальны» было обобщением одного случая.
Поэтому угол стоит подбирать bestFanAngle(), а не хардкодить; формула тут
врёт, радиусы зависят от тесноты нелинейно.
Чем веер расплачивается
- Он строится от одного корня. Всё, что не является потомком корня или
супругом потомка, в сцену не попадает и перечисляется в
diagnostics.omitted. Это свойство формы, а не дефект: полный граф с несвязанными семьями веером не покажешь. - Классический «веер предков» (пробанд в центре, родители и деды кольцами) — другая раскладка, здесь не реализована: она бинарна по устройству и не вмещает ни братьев, ни несколько браков, ни приёмных детей.
Допустимый угол сектора — от 120°
Уже 120° веер отклоняется с ошибкой, и это не капризы: ниже длинная связь (приёмность через поколения) режет карточки — в вееере нет аналога «каналов», которыми линейная раскладка проводит такие связи через промежуточные слои. Сектор уже 120° и так вырождается в полосу, ради которой есть линейная раскладка.
Как обеспечен инвариант «карточки не пересекаются»
Оговорка: подбор радиуса ограничен счётчиком. Если он исчерпан (это возможно
только при абсурдных параметрах), кольцо попадает в diagnostics.ringOverflow
— и тогда гарантии нет, о чём сцена сообщает прямо. Угол сектора меньше 30°
отклоняется с ошибкой: при угле около нуля прежний код молча выдавал радиусы
порядка 1e15 мм.
Формула по длине дуги оказалась неверной: при большом угловом шаге карточки тычутся углами из-за взаимного поворота, а хорда короче дуги — на пяти жёнах в центральном кольце это давало реальные наложения. Поэтому радиус кольца расширяется в цикле до тех пор, пока тот же предикат пересечения выпуклых фигур, которым пользуется метрика, не скажет «чисто». Раскладка и проверка не имеют права расходиться в понимании слова «наложились».
Оформление (src/theme.ts)
Тема — одна на оба бэкенда. Цвета, гарнитура и веса линий живут в одном месте: как только они разъезжаются по svg.ts и pdf.ts, экран с печатью расходятся (уже поймано дважды — на скруглениях и на заглушке портрета). Геометрию тема не задаёт: размеры карточек и зазоры — дело раскладки.
| Тема | Для чего |
|---|---|
portrait-dark |
Экран. Строй по образцу family-chart: круглые портреты с обводкой, подпись под портретом, тёмный фон |
portrait-light |
Печать. Тот же строй на светлой бумаге |
archival |
Тёплая бумага, сепия, засечки — «архивный» вид |
plain |
Служебная, ею отлаживался прототип |
Палитра — наша, из docs/ui-prototype.html: пол различается не голубым с
розовым, а парой акцентов проекта — зелёный --accent и золото --warn.
Люди без портрета получают плашку в цвет акцента (иначе они теряются среди
кружков), и её высота считается по фактическому тексту.
Печатать лучше portrait-light: тёмный фон на A0 — это заливка почти
квадратного метра краской, дорого и тяжело.
Горизонтальные подписи в веере
uprightCards: true не вращает карточки — имена остаются горизонтальными, как
в образце. У круглого портрета своей ориентации нет, вращать его незачем.
Цена, о которой стоит знать: невращённая карточка занимает по дуге больше места, кольца расходятся шире, и кегль выходит мельче (на демо 2.4 мм против 2.9 мм у повёрнутых). Плюс маршрутизацию пришлось пересчитать — связи доводятся до фактической кромки карточки, а не до «половины ширины по радиусу».
Метрики (measure)
Жёсткие, обязаны быть нулём: boxOverlaps, edgeThroughBox, layerViolations.
Мягкие, для сравнения вариантов между собой:
edgeCrossings— пересечения линий. Только для линейной раскладки: там 0 на всех фикстурах, кроме «десятка жён» (3 — минимум для одномерного ряда). В веере их на порядок больше (до 919 на той же фикстуре при полном круге, 94 при 240°): скобы пяти союзов одного человека идут по близким радиусам и неизбежно перекрываются. ДокстрингbuildBlocksпро «линии не пересекаются по построению» относится к порядку ветвей, а не к скобам партнёров.partnerNonAdjacent— партнёры одного слоя с посторонней карточкой между.parentOffCenterMm— насколько союз смещён от центра масс своих детей (метрика линейной раскладки; для веера смысла не имеет). На регулярном дереве 0.0; ненулевые значения дают повторные браки и союзы с разнесёнными супругами, где общая точка на планке физически одна. Диагностика, не критерий приёмки.
PDF-бэкенд печати
renderPdf(scene, opts) даёт файл для типографии. Шрифт и ICC-профиль
принимаются байтами: модуль обязан работать в браузере, обращений к
файловой системе внутри src/ нет.
Что сделано и проверено тестами:
- физический размер страницы в пунктах;
TrimBox— точный обрезной формат,BleedBox— плюс вылеты,MediaBox— плюс место под метки реза; - вылеты 5 мм и метки реза по углам;
- шрифт встроен с сабсеттингом, составной (
CIDFontType2/Identity-H) — без этого кириллица не выйдет; - подмножеству шрифта дописывается шестибуквенный тег (
ABCDEF+Имя): pdf-lib ставит вместо него числовой суффикс, на что ругается preflight. Из-за того, что словари шрифтов pdf-lib создаёт только внутриsave(), переименование идёт вторым проходом: сохранить → перезагрузить → поправить; OutputIntentс ICC и XMP сGTS_PDFXVersion— только если профиль передан; без него PDF/X-4 не заявляется, чтобы не врать в метаданных;- вид совпадает с SVG-бэкендом: скругления карточек рисуются путём (pdf-lib не
умеет
rx), заглушка портрета — та же.
Чего в печати НЕ сделано:
- Конформность PDF/X-4 не подтверждена preflight'ом — на машине нет ни Acrobat, ни pdfToolbox. Структурно объявлено всё требуемое, но проверять надо до отправки в типографию, а не после.
- ICC-профиль в примере взят системный (ghostscript
ps_rgb.icc). В продукте нужен настоящий sRGB IEC61966-2.1, и это отдельный вопрос лицензии на распространение профиля. - Растровые портреты (сейчас заглушка), многолистовая разрезка.
Контролы наполнения (controls/)
Прототип интерфейса ввода: npm run controls, затем открыть
controls/index.html (сборка самодостаточная, сервер не нужен).
Проверяет ровно спорные решения из UI.md §7, а не «интерфейс вообще»:
| Решение | Где проверяется |
|---|---|
| Союзов в интерфейсе не существует — действия глагольные | controls/ops.ts, тесты «союзов в интерфейсе не существует» |
| Брак спрашивается только когда их больше одного | needsMarriageChoice, тесты «выбор брака» |
| Человек без фото — кружок с инициалами | тема portrait-dark, avatarForAll |
| Конфликт версий: предупредить и переналожить | reapply() — настоящее трёхстороннее слияние со списком конфликтов; тесты «слияние не теряет данные молча» |
| Персону с детьми удалить нельзя | deletePerson(), тест «удаление» |
Вся логика живёт в controls/ops.ts без DOM и покрыта тестами;
controls/app.ts — только обвязка кнопок. Хит-тест клика (hitTest +
toSceneMm) тоже вынесен и проверен: ошибиться в переводе координат легко, а
симптом — «клики не попадают».
Чего в прототипе нет: загрузки настоящих фотографий, зума и пана, поиска, свёртки ветвей, слияния дублей, нескольких родословных, шифрования и сети. Это обвязка приложения, которого в репозитории пока нет.
GEDCOM (src/gedcom/)
Свой лексер, чтение и запись — готового поддерживаемого GEDCOM-7 парсера на JS/TS нет (проверено 2026-08-12), а грамматика построчная и примитивная.
- Пишем строго 7.0, читаем и 5.5.1. Старого формата «в природе» большинство, отказ от него закрыл бы импорт из настольных программ.
- Не падаем на чужих данных: незнакомые теги игнорируются, битые ссылки
отбрасываются, повторы идентификаторов отсекаются — всё копится в
warningsи предъявляется пользователю, а не глотается. - Год берётся первым четырёхзначным числом из даты: в живых файлах встречается
ABT 1900,BET 1900 AND 1905,1900/1901. - Круговая проверка (
экспорт → импорт == исходный граф) прогоняется по всем фикстурам: она и ловит несогласованность двух половин. Чего она не видит: того, что теряется при чтении файла. Точность дат и полные даты проверяются отдельными тестами — именно там жила регрессия, которую круговая проверка пропустила (аудит 2026-08-12). - Даты хранят модификатор точности:
≈1900,до 1850,1899–1905,44 до н.э.. Год берётся последним четырёхзначным числом, а не первым: в живых файлах дата почти всегда полная, и первым идёт день.
Две честные потери на экспорте, обе помечаются в самом файле: портреты не
уходят (нужен GEDZIP) и партнёры сверх двух пишутся как ASSO, потому что
GEDCOM знает только HUSB/WIFE.
Чего здесь нет
- Экранная интерактивность: зум/пан, свёртка ветвей, ленивая подгрузка.
- Портреты рисуются заглушкой; настоящие миниатюры (ADR-0019 §3) не встроены.
- Типографская вёрстка длинных имён: сейчас имя переносится на две строки и режется многоточием, переносов по слогам и подгонки кегля нет.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @pdf-lib/fontkit | ^1.1.1 |
| pdf-lib | ^1.17.1 |
Development Dependencies
| ID | Version |
|---|---|
| @types/node | ^22.0.0 |
| esbuild | ^0.28.2 |
| tsx | ^4.19.0 |
| typescript | ^5.6.0 |
| vitest | ^2.1.0 |