# Heart Atlas. Этап 1: 3D-вьюер и страница анатомии

Дата: 11.10.2026
Статус: согласована (работаем автономно до MVP по решению владельца)
Основная спека этапа: [2026-10-11-stage-1-heart-model.md](2026-10-11-stage-1-heart-model.md)

## Цель

Показать модель из `packages/models` на сайте: повернуть, выбрать структуру, заглянуть внутрь. Вьюер должен работать как остров на странице анатомии, в статьях и без шапки и подвала на странице встраивания `/<locale>/embed/<module>/` из этапа 4.

## Пакет `packages/viewer`

`@heart-atlas/viewer` (MIT): React-компонент `HeartViewer` на `@react-three/fiber` 9, `@react-three/drei` 10 и `three` 0.186. Сайт ничего не знает о three.js: он передаёт данные через пропы.

| Проп | Что это |
|---|---|
| `models` | `{ high, low }`: URL `heart-lod0.glb` и `heart-lod1.glb`, собранные через `BASE_URL` |
| `poster` | URL постера `heart-poster.png` |
| `structures` | Список из `structures.yaml` с локализованными названиями (`structure.<id>`) |
| `messages` | Строки `viewer.*` текущего языка |
| `highlight` | Необязательно: id, подсвеченные при загрузке |
| `loadOn` | `'visible'` (по умолчанию), `'click'` или `'auto'`: как `'visible'` на десктопе и как `'click'` на телефонах и узких экранах |
| `headingLevel` | Уровень заголовка списка структур (2–4), чтобы вьюер вписался в структуру заголовков страницы |

Данные для пропов готовит `apps/web/src/lib/heart-model.ts`: читает `structures.yaml` и `LICENSES.yaml` (импорт `?raw` при сборке), берёт атрибуцию источника ровно из `LICENSES.yaml` (обе строки, `attribution` и `attribution_alt`, потому что не решено, какая из двух лицензий BodyParts3D применяется) и подставляет её в строку `viewer.attribution` вместо `{source}`.

## Загрузка

- До загрузки показываем постер. three.js и сцена лежат в отдельном чанке (`React.lazy`), он грузится, только когда вьюер подходит к области просмотра (остров гидрируется по `client:visible` с `rootMargin: '200px'`, дальше `IntersectionObserver` с тем же запасом) или по кнопке «Показать 3D-модель» при `loadOn: 'click'`. Кнопка есть в режиме `'click'` и в режиме `'auto'` на телефонах; после нажатия фокус остаётся во вьюере и переходит на модель, когда она загрузится. Статьям этапа 3 подходит `'click'`, чтобы не тянуть 3D ради картинки сбоку.
- Страница анатомии использует `'auto'`: на телефоне 3D грузится по нажатию. Замер Lighthouse в мобильной эмуляции (замедление CPU в 4 раза) при автозагрузке дал производительность 0,72: разбор three.js, декодирование модели и компиляция шейдеров занимают около 2 с основного потока. Встраивание использует `'click'`, чтобы не нагружать чужую страницу.
- Canvas рисует кадры только при изменениях (`frameloop="demand"`). Постоянный цикл рендера в той же эмуляции занимал основной поток на минуты.
- Чанк сцены весит около 1 МБ без сжатия и 265 КБ после gzip, модель 400–580 КБ. Это укладывается в бюджет плана «до 3 МБ».
- Телефоны и узкие экраны (`pointer: coarse` или ширина до 48rem) получают `heart-lod1.glb`, остальные `heart-lod0.glb`.
- Meshopt декодирует `MeshoptDecoder` из three-stdlib, встроенный в чанк: внешний CDN не нужен. Draco не подключаем.
- Если WebGL недоступен, сцена не грузится вовсе: остаются постер с подписью и список структур. Ошибка загрузки модели ловится границей ошибок, вместо сцены остаётся постер с сообщением.

## Возможности

- Вращение мышью и касанием (`OrbitControls`). Масштаб колесом и щипком работает, только пока модель в фокусе (после щелчка по ней), иначе колесо над вьюером высотой до 80vh не давало бы прокрутить страницу. С клавиатуры: стрелки поворачивают, `+` и `-` меняют масштаб, `0` или `Home` возвращают исходный вид. Подсказка связана с областью модели через `aria-describedby`.
- Выбор структуры щелчком по модели или кнопкой в списке, сгруппированном по `group`. Щелчок выбирает первую видимую структуру: скрытые режимом «только выбранное», отрезанные разрезом и прозрачные камеры пропускаются. Выбранная структура подсвечивается и подписывается рядом с собой. Название выбранного объявляется через `aria-live`.
- Перегородки (`kind: zone`) подсвечивают обе соседние камеры.
- Прозрачные камеры, режим «только выбранное», разрез по трём осям с ползунком положения.
- Для структур проводящей системы показывается примечание, что она нарисована схематично.
- Страница может подсветить структуры из текста: `highlightStructures(ids)` отправляет событие `heart-atlas:highlight` на `window`, его слушают все вьюеры на странице. Подсвечиваются все переданные известные id, неизвестные отбрасываются. Каждый вьюер работает со своей копией сцены и своими материалами, поэтому два вьюера с одной моделью на странице не мешают друг другу. Так статьи из статического HTML управляют вьюером без импорта three.js.
- Состояние загрузки, ошибка и отсутствие WebGL объявляются через одну постоянную область `role="status"`.
- `prefers-reduced-motion`: инерция вращения выключена. Автоматических анимаций у вьюера нет; сердцебиение включает только читатель.

## Сердцебиение

Состояние цикла (`CycleState`) задаёт спека [2026-10-11-stage-2-cycle-sync.md](2026-10-11-stage-2-cycle-sync.md); вьюер только рисует его. Сцена принимает источник цикла `cycle: () => CycleState | null` и читает его в каждом кадре, пока он задан.

- Деформация идёт в вершинном шейдере (`onBeforeCompile` у копии материала), геометрия не меняется. Камера сжимается на `contraction × SQUEEZE` (предсердия 0,12, желудочки 0,28: наглядность, а не анатомия). Желудочки сжимаются к общему центру, предсердия к своему, поэтому перегородки двигаются как одно целое.
- Что лежит на стенке, двигается вместе с ней: сосочковые мышцы, коронарные артерии, вены, атриовентрикулярные клапаны, пучок Гиса, ножки и волокна Пуркинье с желудочками; синусовый и АВ-узлы с предсердиями. Магистральные сосуды и полулунные клапаны стоят на месте, поэтому на стыке с ними и в атриовентрикулярной борозде при сжатии видна щель: это допущение схемы.
- Клапан раскрывается в своей плоскости. Ось атриовентрикулярного клапана идёт от центра предсердия к центру желудочка; у полулунных это ось наименьшего разброса вершин (PCA). Радиус отверстия это 60-й перцентиль расстояний от оси, потому что хорды и фиброзное кольцо уходят дальше. Точки внутри радиуса сдвигаются к краю на `valveOpen × 0,8`, точки снаружи стоят. Часы дают клапану 0 или 1, вьюер сглаживает переход (постоянная 40 мс).
- Возбуждённые участки проводящей системы мягко светятся голубым и гаснут с постоянной 120 мс. Это небольшие структуры, и при ЧСС до 180 свечение загорается не чаще 3 раз в секунду, то есть в пределе WCAG 2.3.1. Выделение пользователя важнее свечения.
- Кадр просит следующий, только пока состояние цикла меняется или идёт сглаживание. На паузе часов холст не перерисовывается; пока источник задан, раз в 200 мс один кадр проверяет, не пошли ли часы снова. Без источника сердце возвращается в покой (постоянная 200 мс), и холст снова рисует только по запросу.
- Выбор щелчком и место подписи считаются по недеформированной геометрии; при сжатии и раскрытом клапане щелчок у края может промахнуться.
- Проп `cycle` у `HeartViewer`. `'own'` (страница анатомии): после загрузки модели кнопка «Запустить сердцебиение» и регулятор ЧСС 40–180 (шаг 5). Первое нажатие подгружает код часов (`createHeartClock` и `cycleState` из `@heart-atlas/heart-clock`), регулятор меняет ЧСС со следующего удара, под кнопкой появляется пометка, что сердцебиение схематично. `'page'` (страница часов сердца): вьюер подписывается на `@heart-atlas/heart-clock/cycle-source` и бьётся в такт монитору, своей кнопки нет; пауза монитора останавливает модель. Без пропа модель не двигается. Сама модель никогда не запускает сердцебиение, поэтому `prefers-reduced-motion` соблюдается без отдельной логики; на странице часов при reduced motion часы стартуют на паузе.

## Встраивание

Вьюер не зависит от макета сайта: стили в CSS-модуле на логических свойствах, цвета через переменные сайта с запасными значениями. Страница `/embed/` из этапа 4 рендерит тот же `HeartViewer` с теми же данными из `heart-model.ts`.

## Страница анатомии

`/<locale>/anatomy/`: заголовок, короткое пояснение без медицинских утверждений и вьюер с `loadOn: 'visible'` (это основное содержание страницы). Ссылка с главной. Страница попадает в поиск (`searchable`).

## Проверки

- Юнит-тесты пакета: камера и клавиши, событие подсветки, группировка и раскрытие зон, сжатие камер, плоскость и раскрытие клапана, сглаживание.
- Юнит-тесты сайта: у каждой структуры есть английское название, атрибуция подставлена во всех языках, пути моделей строятся от `BASE_URL`.
- e2e: ссылка с главной, axe без нарушений после загрузки сцены, загрузка `heart-lod0.glb` на десктопе и `heart-lod1.glb` на телефоне, выбор и сброс структуры, подсветка через событие, фокус с клавиатуры без прокрутки страницы, фолбэк без WebGL без загрузки чанка сцены. В CI WebGL работает через SwiftShader.

## Что не входит и риски

- 60 fps на реальном среднем телефоне проверить из облачной среды нельзя. Проверяем вручную по чек-листу доступности этапа 4; если не держим, уменьшаем `ratio` для `heart-lod1.glb`.
- На телефоне жест одним пальцем над моделью вращает её, а не прокручивает страницу. Модель занимает меньше экрана, поэтому страницу можно прокрутить за её пределами.
- Lighthouse для страницы анатомии подключает этап 4.
