# Heart Atlas. Этап 2: часы сердца

Дата: 11.10.2026
Статус: принята (PR #26)
Связано: этап 2 «Часы сердца» из плана MVP, спека этапа 0 `docs/plans/2026-10-11-stage-0-foundation.md`

## Цель

Один таймер сердечного цикла, от фазы которого зависят ЭКГ, тоны сердца, кривые давления и объёма, а позже анимация 3D-модели. Болезнь в будущем задаётся набором параметров этого таймера, а не отдельной анимацией.

Результат этапа: пакет `packages/heart-clock` (чистый TypeScript с юнит-тестами) и демо-страница `/<locale>/heart-clock/` с синтетической ЭКГ, тонами сердца по кнопке и диаграммой Виггерса.

Это схема для обучения, а не физиологическая модель: длительности и формы кривых упрощены. На странице это сказано рядом с графиками.

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

- Пресеты болезней. Только параметры API, которыми пресеты потом будут пользоваться (раздел «Параметры для болезней»).
- Синхронизация с 3D. Вьюер делает тред «3D-модель сердца» в `packages/viewer`; он будет читать фазу из того же таймера.
- Правое сердце (ПЖ, лёгочная артерия), шумы и расщепление тонов, несколько отведений ЭКГ. Показываем одно отведение, похожее на II.

## Источники и допущения

Числа модели либо взяты из источника ниже, либо помечены как допущение модели. Допущения меняются параметрами и в тексте страницы не выдаются за норму.

| # | Источник | Что берём | Как проверено |
|---|---|---|---|
| 1 | StatPearls, Electrocardiogram ([statpearls.com/point-of-care/20969](https://www.statpearls.com/point-of-care/20969)), обновлено 05.06.2023 | Сетка 25 мм/с, большая клетка 0,2 с, малая 0,04 с; 1 мВ = 10 мм. P шириной 3 малые клетки и высотой до 2,5 малых клеток. PR 120–200 мс, QRS обычно 60–100 мс, QT в норме меньше 400–440 мс. T от 1/8 до 2/3 высоты R и ниже 10 мм. ЧСС в норме 60–99 | Страница открыта 11.10.2026 |
| 2 | StatPearls, Physiology, Cardiac Cycle ([statpearls.com/point-of-care/786](https://www.statpearls.com/point-of-care/786)), обновлено 03.10.2022 | Давление (макс./мин., мм рт. ст.): ЛЖ 120/15, аорта 120/80, «лёгочные артерии / левое предсердие» 25/10. Здоровый желудочек выбрасывает больше 60% объёма. S1 при закрытии митрального клапана в начале систолы, S2 при закрытии аортального в конце. Фазы: изоволюмическое сокращение, изгнание, изоволюмическое расслабление, наполнение | Страница открыта 11.10.2026 |
| 3 | Weissler AM, Harris WS, Schoenfeld CD. Systolic time intervals in heart failure in man. Circulation. 1968;37(2):149–159. DOI [10.1161/01.CIR.37.2.149](https://doi.org/10.1161/01.CIR.37.2.149) | Регрессии для мужчин: QS2 = 546 − 2,1·ЧСС мс, LVET = 413 − 1,7·ЧСС мс; PEP = QS2 − LVET | Библиографию сверили с Crossref. Полный текст не открылся (ahajournals), коэффициенты сверены по цитате таблиц 1–2 статьи в патенте US 7904141. Перед использованием в статье открыть оригинал |
| 4 | McSharry PE, Clifford GD, Tarassenko L, Smith LA. A dynamical model for generating synthetic electrocardiogram signals. IEEE Trans Biomed Eng. 2003;50(3):289–294. DOI [10.1109/TBME.2003.808805](https://doi.org/10.1109/TBME.2003.808805) | Идея: ЭКГ как сумма гауссиан P, Q, R, S, T. Угловые положения и ширины волн растягиваются на √(1/RR) ([описание ECGSYN на PhysioNet](https://physionet.org/content/ecgsyn/1.0.0/)); в области времени это значит, что положения зубцов P–T от начала удара растут как √RR | Crossref, страница PhysioNet |

Код ECGSYN распространяется под GPL-3.0, поэтому мы его не копируем: модель пишется заново по описанию в статье, в области времени, а не как система ОДУ.

Допущения модели (не из источников, помечены в коде комментарием `assumption`):

- R = 1,0 мВ задаёт масштаб. P 0,15 мВ и T 0,3 мВ выбраны внутри пределов источника 1; Q −0,1 мВ и S −0,25 мВ источник не задаёт, это выбор модели.
- PR 160 мс, QRS 90 мс, QTc 400 мс и изоволюмическое расслабление (IVRT) 80 мс при ЧСС 60. PR и QT растягиваются как √RR, вслед за положениями зубцов в ECGSYN; для QT это форма поправки Базетта, которую мы отдельным источником не подтверждали. IVRT растягивается так же, это чистое допущение. Ширина QRS от ЧСС не зависит.
- Регрессии Weissler получены для мужчин и для ограниченного диапазона ЧСС; на 40–180 мы их экстраполируем. Из-за того, что систола считается линейно по ЧСС, а QT по √RR, конец T и S2 расходятся: при ЧСС 60–75 T кончается за 20–30 мс до S2, при 180 на 60 мс позже. Это известное упрощение; на странице под графиками написано, что модель схематична.
- Митральный клапан закрывается (S1) на пике R; изгнание начинается через PEP после начала Q.
- Объём ЛЖ показываем в процентах от конечно-диастолического, а не в миллилитрах: источник даёт только фракцию выброса. По умолчанию ФВ 65%, вклад предсердий в наполнение 20%.
- Давление ЛП 4–10 мм рт. ст. и конечно-диастолическое давление ЛЖ 10 мм рт. ст. это допущения модели. Источник 2 даёт 25/10 для лёгочных артерий и ЛП вместе и минимум 15 для ЛЖ, но при ЛЖ 15 и ЛП 10 кровь не наполняла бы желудочек, поэтому эти числа мы не берём. Из источника 2 берём только 120 (ЛЖ и аорта) и 80 (аорта).
- Тоны сердца синтезируются (затухающие синусоиды с шумом), это не запись. Высота тона выбрана так, чтобы его было слышно на динамиках телефона.

## Пакет `@heart-atlas/heart-clock`

Чистый TypeScript без DOM и без зависимостей. Время в миллисекундах, давление в мм рт. ст., ЭКГ в мВ.

### Тайминг одного удара

`beatTiming(rrMs, params)` возвращает моменты внутри удара. Начало удара (t = 0) это начало зубца P.

| Момент | Формула |
|---|---|
| `qOnset` | PR·√(RR/1000) |
| `rPeak` (закрытие митрального, S1) | `qOnset` + QRS/2 |
| `ejectionStart` (открытие аортального) | `qOnset` + PEP |
| `aorticClosure` (S2) | `qOnset` + QS2 |
| `mitralOpening` | `aorticClosure` + IVRT·√(RR/1000) |
| `tEnd` | `qOnset` + QTc·√(RR/1000) |

Фазы удара по порядку: `atrial-systole` [0, `rPeak`), `isovolumic-contraction` [`rPeak`, `ejectionStart`), `ejection` [`ejectionStart`, `aorticClosure`), `isovolumic-relaxation` [`aorticClosure`, `mitralOpening`), `filling` [`mitralOpening`, RR).

Допустимая ЧСС 40–180. При параметрах по умолчанию наполнение короче всего около ЧСС 150 (около 17 мс). Если фазы не помещаются в RR, `beatTiming` бросает `RangeError`. Тест проверяет порядок моментов, `tEnd < RR` и наполнение не короче 10 мс на всём диапазоне.

Проверка сочетаний: широкий QRS, длинный PR или нерегулярный ритм при высокой ЧСС тоже могут не поместиться. Поэтому `setParams` и `createHeartClock` проверяют сразу весь диапазон RR, который могут дать параметры (RR·(1 ± `rrIrregularity`), ограниченный ЧСС 40–180), и бросают `RangeError` при установке, а не посреди воспроизведения. RR отдельного удара всегда ограничивается диапазоном ЧСС 40–180.

### ЭКГ

`ecgAt(t, beat, params)`: сумма гауссиан P, Q, R, S, T текущего и предыдущего удара (хвост T может зайти в следующий удар), плюс смещение ST и волны f. Центр и ширина каждой волны привязаны к моментам из таблицы выше, поэтому при смене ЧСС форма остаётся согласованной с фазами.

### Часы

`createHeartClock(params)` строит расписание ударов от t = 0. Все методы часов принимают абсолютное время часов `t` в мс; чистые функции (`beatWaves`, `hemodynamics`) принимают время внутри удара. Удар это `{ index, start, rr, timing, params }`.

- `sample(t)` возвращает удар, время внутри удара, фазу и значение ЭКГ;
- `beatAt(t)` и `beatsBetween(from, to)` для отрисовки окна ЭКГ;
- `setParams(patch, t)`: новые параметры действуют с первого удара, который начинается после `t`, поэтому кривая не рвётся (волны f предыдущего удара плавно переходят в новые за 50 мс). Удары после текущего перестраиваются; метод возвращает индекс первого перестроенного удара, чтобы звук, запланированный на них заранее, можно было отменить;
- `ecgBetween(from, to, count)` считает полосу ЭКГ за один проход, без поиска удара для каждой точки;
- расписание хранит удары от `t − 10 с` и на два удара вперёд: следующий удар нужен для заранее запланированного звука и для кривых, которые к концу удара должны прийти к значениям следующего. Более старые удары отбрасываются.

Нерегулярный ритм использует детерминированный генератор с `seed`, чтобы тесты и e2e были воспроизводимы.

### Диаграмма Виггерса

`hemodynamics(tInBeat, beat, previous, next)` (предыдущий удар нужен для спада давления в аорте до начала изгнания) и метод часов `hemodynamicsAt(t)` возвращают давление ЛЖ, аорты, ЛП и объём ЛЖ (% от КДО). Кривые кусочные по фазам: в изоволюмические фазы объём постоянен, в изгнание давление аорты равно давлению ЛЖ, после закрытия аортального клапана аорта спадает экспоненциально с дикротической выемкой до диастолического значения к началу следующего изгнания. Кривые удара в конце приходят к начальным значениям следующего удара, поэтому смена параметров через `setParams` не даёт скачка. Тест проверяет непрерывность на границах фаз и ударов (в том числе после `setParams`), постоянство объёма в изоволюмические фазы и что максимум и минимум совпадают с параметрами.

### Тоны сердца

`soundEvents(beat)` возвращает `S1` в `rPeak` и `S2` в `aorticClosure`. Синтез звука в пакет не входит.

### Параметры для болезней

| Параметр | По умолчанию | Для чего |
|---|---|---|
| `heartRate` | 75 | Тахи- и брадикардия |
| `rrIrregularity` (0–0,3), `seed` | 0 | Фибрилляция предсердий |
| `pAmplitude` | 0,15 мВ | 0 при фибрилляции предсердий |
| `fWaves` { амплитуда, частота } | нет | Волны f при фибрилляции |
| `prInterval`, `qrsDuration`, `qtc` | 160, 90, 400 мс | Блокады, удлинённый QT |
| `stShift`, `tAmplitude` | 0, 0,3 мВ | Инфаркт с подъёмом ST, инверсия T |
| `lvSystolic`, `aorticDiastolic`, `lvEndDiastolic` | 120, 80, 10 мм рт. ст. | Гипертензия, аортальный стеноз, сердечная недостаточность |
| `ejectionFraction`, `atrialContribution` | 0,65, 0,2 | Сердечная недостаточность, фибрилляция |

Параметры проверяются: значение вне диапазона даёт `RangeError` с именем параметра.

## Веб-часть

Остров React `HeartClockDemo` на странице `apps/web/src/pages/[locale]/heart-clock.astro`, `client:visible`. Строки и параметры часов приходят пропсами (строки из `messages/<locale>.json`), остров не читает реестр локалей сам и не зависит от шапки и подвала `BaseLayout`: этап 4 встраивает его через `/<locale>/embed/<module>/`.

- **ЭКГ на Canvas 2D**: бегущая развёртка, сетка 25 мм/с и 10 мм/мВ, малые и большие клетки. 1 мм = 4 CSS-пикселя (масштаб экранный, не физический). Учитываем `devicePixelRatio`. Цвета берём из CSS-переменных, тёмная тема работает.
- **Управление**: старт и пауза, ЧСС 40–180 (слайдер), скорость ×0,25 / ×0,5 / ×1, текущая фаза текстом. При `prefers-reduced-motion` анимация и ЭКГ на паузе по умолчанию; без этой настройки развёртка стартует сама. Звук только по кнопке, без автозапуска.
- **Тоны сердца**: кнопка «Включить звук». `AudioContext` создаётся только по нажатию. Звуки планируются заранее по `soundEvents` через `currentTime` аудиоконтекста, а не по `setTimeout`.
- **Диаграмма Виггерса**: Canvas 2D со своей простой сеткой и осями: давление ЛЖ, аорты и ЛП, объём ЛЖ и ЭКГ под ними, курсор текущего момента, затенённая систола желудочков. Текущая фаза подписана текстом над графиками, кривые ЛП и объёма пунктирные, чтобы их различали без цвета. uPlot из плана не берём: кривые строятся из функции, своего кода меньше, чем интеграции.
- **Доступность**: у каждого canvas `role="img"` и `aria-label`, а рядом текстовое описание ЭКГ для экранных дикторов (ритм, ЧСС, какие зубцы видны), которое обновляется при смене параметров; кнопки и слайдер с подписями; текущая фаза не в `aria-live`, чтобы не зачитывать её каждые 100 мс.
- **Ось времени** ЭКГ и диаграммы всегда идёт слева направо, в том числе на языках с письмом справа налево (`dir="ltr"` на canvas и его подписях), как на бумажной ЭКГ. Остальная вёрстка на логических CSS-свойствах.
- **Над графиками** заметка, что модель схематична, не для диагностики и врачом не проверена; **под графиками** список источников из таблицы выше. `ReviewNotice` не используем: его текст говорит «основано на клинических рекомендациях», а модель опирается на справочники и статьи.
- Маршруты страницы из реестра локалей (`getStaticPaths` по `localeCodes`), ссылка с главной через `localePath(import.meta.env.BASE_URL, …)`.

## План PR

| PR | Что | Issue | Готово, когда |
|---|---|---|---|
| PR 1 | Эта спека | отдельное | Спека в `main` |
| PR 2 | Пакет: параметры, тайминг, фазы, ЭКГ, часы, тесты | отдельное | Тесты зелёные, `pnpm typecheck` проходит |
| PR 3 | Остров ЭКГ, страница `/<locale>/heart-clock/`, ссылка с главной, e2e | отдельное | Страница на двух языках, e2e и axe зелёные |
| PR 4 | `soundEvents` и Web Audio | отдельное | Звук включается кнопкой, e2e проверяет кнопку |
| PR 5 | `hemodynamicsAt` и диаграмма Виггерса | отдельное | Диаграмма на странице, тесты непрерывности зелёные |
