# Этап 2, PR 3. Часы сердца двигают 3D-модель

Статус: принята (PR #74) · 11.10.2026 · issue #73
План MVP: этап 2 «Часы сердца», PR 3. Опирается на спеку часов сердца ([2026-10-11-stage-2-heart-clock.md](2026-10-11-stage-2-heart-clock.md)), спеку модели ([2026-10-11-stage-1-heart-model.md](2026-10-11-stage-1-heart-model.md)) и пресеты ([2026-10-11-stage-3-presets.md](2026-10-11-stage-3-presets.md)).

## Цель

3D-модель сердца бьётся: камеры сжимаются и расширяются, клапаны открываются и закрываются, по проводящей системе пробегает возбуждение. Это просьба Никиты от 11.10.2026: «запустить сердцебиение, чтобы видеть как сердце работает». Источник времени один: те же часы, от которых работают ЭКГ, тоны и диаграмма Виггерса. Пресет болезни меняет картинку сам: при фибрилляции предсердий предсердия не сжимаются и нет вспышки синусового узла, при сердечной недостаточности желудочки сжимаются слабее.

## Что входит

- Чистая функция `cycleState(clock, t)` в `packages/heart-clock`: что возбуждено, насколько сжата каждая камера, насколько открыт каждый клапан в момент `t`.
- Модуль `@heart-atlas/heart-clock/cycle-source`: остров часов публикует на странице «источник цикла», вьюер его читает. Это мост между двумя островами на одной странице.
- На странице `/<locale>/heart-clock/` под монитором 3D-вьюер (загрузка по кнопке), который бьётся в такт монитору.

## Что не входит

- Анимация во вьюере: её делает тред «3D-модель сердца» в `packages/viewer` отдельным PR. План у них такой: деформация в вершинном шейдере (`onBeforeCompile`); камеры сжимаются к центру на `contraction`, клапаны раскрываются радиально на `valveOpen`, по проводящей системе пробегает импульс. Выделение и изоляция пользователя важнее слоя цикла. Кнопка «Запустить сердцебиение» и регулятор ЧСС на `/anatomy/`, где вьюер заводит свои часы, тоже их.
- Отдельные признаки болезней на модели (зона инфаркта, гипертрофия). Это следующий шаг после этого PR.

## Состояние цикла

```ts
type ChamberId = 'right-atrium' | 'left-atrium' | 'right-ventricle' | 'left-ventricle';
type ValveId = 'tricuspid-valve' | 'mitral-valve' | 'pulmonary-valve' | 'aortic-valve';

interface CycleState {
  phase: Phase;
  /** Участки проводящей системы, по которым сейчас идёт возбуждение. */
  excited: string[];
  /** Сжатие камеры от 0 (расслаблена) до 1. */
  contraction: Record<ChamberId, number>;
  /** Раскрытие клапана от 0 (закрыт) до 1. */
  valveOpen: Record<ValveId, number>;
}
```

Значения плавные внутри фазы и без скачков между ударами, потому что берутся из тех же кривых, что рисует диаграмма Виггерса:

- Желудочки: `1 − volume / 100`, где `volume` это объём ЛЖ из `hemodynamicsAt` (% КДО). В норме максимум 0,65 (ФВ), при сердечной недостаточности 0,30, и сердце на экране сжимается слабее без отдельной логики. Вьюер сам выбирает, сколько миллиметров даёт единица: амплитуда это наглядность, а не анатомия. В диастазе значение не 0, а около 0,13 в норме: желудочек ещё не дополнен сокращением предсердий, и до 0 его доводит волна a.
- Предсердия: подъём давления в ЛП в фазе `atrial-systole` от его значения в начале удара (не ниже `lvEndDiastolic`) до пика волны a `lvEndDiastolic · (1 + atrialContribution)`, с обрезкой в [0, 1]; вне фазы 0. Отсчёт от начала удара нужен, чтобы при малом вкладе предсердий не было скачка на стыке ударов. При ФП `atrialContribution = 0`, и предсердия не сжимаются. Перевод давления в сжатие камеры это схема модели.
- Клапаны: 1 на отрезке, где клапан открыт по таблице ниже, иначе 0. Плавное открытие и закрытие на стыке рисует вьюер своей анимацией: длительность движения створок источники не дают, и в часы она не попадает.
- Правые камеры и клапаны повторяют левые (допущение модели, см. ниже).

Идентификаторы берутся из `assets/models/heart/structures.yaml`. Отрезки задаются только моментами удара из `BeatTiming` (`pDuration`, `qOnset`, `jPoint`, начала фаз), новых чисел нет.

| Поле | Структуры | Когда | Основание |
|---|---|---|---|
| excited | `sa-node` | зубец P: от начала удара до `pDuration`, только при `pAmplitude > 0` (отрицательный P не синусовый ритм) | StatPearls ECG: P «represents atrial depolarization». Что узел горит весь зубец P, это схема модели |
| excited | `av-node`, `bundle-of-his` | от конца P до `qOnset` | StatPearls ECG: интервал PR «includes the delay at the AV node». Пучок Гиса вместе с узлом и то, что при ФП узел горит в том же окне перед QRS, это схема модели |
| excited | `right-bundle-branch`, `left-bundle-branch`, `purkinje-fibers` | комплекс QRS: от `qOnset` до `jPoint` | StatPearls ECG: QRS «represents ventricular depolarization». Что ножки и волокна Пуркинье горят вместе весь QRS, это схема модели |
| valveOpen = 1 | `mitral-valve`, `tricuspid-valve` | от `mitralOpening` до `rPeak` следующего удара (фазы `filling` и `atrial-systole`) | StatPearls Cardiac Cycle: «The mitral valve opens, signifying the beginning of ventricular filling»; изоволюмическое сокращение это время «between the mitral valve's closing and the aortic valve's opening». Закрытие на пике R это выбор спеки часов |
| valveOpen = 1 | `aortic-valve` | фаза `ejection` | StatPearls Cardiac Cycle: «the aortic valve opens, marking the beginning of rapid ventricular ejection»; изоволюмическое расслабление «when the aortic valve has closed, but the mitral valve has not yet opened» |
| valveOpen = 1 | `pulmonary-valve` | от `ejectionStart` до `aorticClosure + s2Split` | как у аортального; закрытие на `s2Split` позже совпадает с тоном P2 часов (допущение пресета ДМПП, спека пресетов) |

Порядок идентификаторов в `excited` такой, как в таблице, чтобы тесты сравнивали массивы целиком. Значения берутся из параметров удара (`sample.beat.params`), а не из `clock.params`: `setParams` меняет только следующие удары. Имя фазы `atrial-systole` это окно времени часов; сокращаются ли предсердия, говорит `contraction`.

Часы считают только левое сердце. Правые камеры и трёхстворчатый клапан идут с тем же временем, что и левые: это допущение модели, на странице оно покрыто общей пометкой «схема, а не физиологическая модель». StatPearls Cardiac Cycle: «The contraction of the atria (both the right and left) physiologically precedes that of the ventricles».

Цитаты StatPearls ECG ([statpearls.com/point-of-care/20969](https://www.statpearls.com/point-of-care/20969)) и StatPearls Cardiac Cycle ([statpearls.com/point-of-care/786](https://www.statpearls.com/point-of-care/786)) проверены WebFetch 11.10.2026.

## Источник цикла

Событие окна на каждый кадр (60 раз в секунду) слишком часто, поэтому вьюер сам спрашивает состояние в своём `useFrame`, пока сердцебиение включено.

- `type CycleSource = () => CycleState | null`. Источник знает своё текущее время часов (пауза, скорость ×0,25), поэтому аргумента нет; `null` значит «часов нет, сердце в покое».
- Модуль `packages/heart-clock/src/cycle-source.ts`, экспорт `@heart-atlas/heart-clock/cycle-source` (в `package.json` пакета `exports` получает `"./cycle-source"`): `publishCycleSource(source | null)`, `withdrawCycleSource(source)` (снимает источник, только если он всё ещё текущий) и `subscribeCycleSource(listener) → unsubscribe`. Подписчик сразу получает текущий источник, так что вьюер, загруженный позже часов (по кнопке, на паузе, при reduced motion), не ждёт следующей публикации. Состояние модуля общее для всех островов страницы: Vite кладёт общий модуль в один чанк. Модуль импортирует только типы (`import type`), без `window` и DOM, так что вьюер берёт его без кода часов, а тесты идут в Node. Вьюеру нужна зависимость `"@heart-atlas/heart-clock": "workspace:*"`; её добавляет их PR.
- `HeartClockDemo` публикует источник при монтировании и при смене пресета (новые часы), и снимает его через `withdrawCycleSource` при размонтировании и смене часов. Источник читает тот же `nowRef`, что рисует ЭКГ, так что модель и монитор совпадают до кадра. На паузе источник отдаёт замершее состояние.
- Функцию нельзя передать из Astro в остров пропсом, поэтому на `/heart-clock/` вьюер получает строковый проп (предложение: `cycle="page"`) и подписывается на опубликованный источник. На `/anatomy/` вьюер заводит свои часы; это их часть.
- На странице один источник: публикация заменяет предыдущий, `null` от острова снимает только его собственный источник. Монитор в статье (`ArticleClock`) тоже публикует источник; вьюера в статьях пока нет, и это безопасно. Ограничение записано в JSDoc `publishCycleSource`.
- `prefers-reduced-motion`: часы и так стартуют на паузе, вьюер не запускает сердцебиение сам, только по кнопке.
- Просьба к слою вьюера: импульс по проводящей системе мягкий, без резких вспышек целой камеры. При 180 ударах в минуту вспышка раз за удар это 3 в секунду, ровно предел WCAG 2.3.1.

## Страница часов сердца

Под монитором, перед списком источников, секция с h2 «Модель сердца» (`heartClock.model3d`) и `HeartViewer client:visible` с `loadOn="click"` и `headingLevel={3}`, чтобы three.js не грузился без запроса. Страница часов добавляется в список URL Lighthouse CI (`apps/web/lighthouserc.json`), чтобы это проверялось. Данные вьюера берутся так же, как на странице анатомии (`heartModelUrls`, `heartStructures`, `viewerMessages`). Вьюер получает проп `cycle="page"` и бьётся в такт монитору (PR треда «3D-модель сердца», спека вьюера).

## Тесты

- Юнит: `cycleState` по фазам нормального удара (что возбуждено, какие клапаны открыты); при ФП нет `sa-node` и предсердия не сжимаются; сжатие желудочков 0 в начале изгнания и равно ФВ в конце, у пресета СН максимум меньше нормы; сжатие предсердий в [0, 1] и достигает 1 в норме; все значения непрерывны на стыке ударов; при ДМПП лёгочный клапан закрывается на `s2Split` позже аортального; `subscribeCycleSource` сразу получает уже опубликованный источник, потом новый и `null`; `withdrawCycleSource` старого источника не снимает новый; сжатие предсердий без скачка на стыке при `atrialContribution = 0,02`; лёгочный клапан остаётся открытым в следующем ударе, если `s2Split` выходит за его начало.
- e2e: на странице часов есть h2 «Модель сердца» и кнопка загрузки 3D; three.js и `.glb` без нажатия не загружаются (как проверка в `anatomy.spec.ts`); axe без нарушений. Состояние источника живёт в модуле, а не в `window`, поэтому его значения проверяют юнит-тесты; e2e движения модели добавит PR вьюера, где его видно.

## PR

Один PR: функция, источник цикла, публикация из острова, вьюер на странице, строки en/ru и тестовой RTL-локали, тесты.
