mf

@mf/tree-layout (0.0.3)

Published 2026-08-13 19:06:31 +00:00 by lucky

Installation

@mf:registry=
npm install @mf/tree-layout@0.0.3
"@mf/tree-layout": "0.0.3"

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
Details
npm
2026-08-13 19:06:31 +00:00
50
latest
76 KiB
Assets (1)
Versions (2) View all
0.0.3 2026-08-13
0.0.1 2026-08-13