emergency-cases/app.js

1253 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =========================================================
Данные клинических кейсов
n:true — назначить/выбрать нужно
n:false — лишнее/противопоказано (выбирать не надо)
voice: pitch/rate/preferMale/breath — параметры озвучки
========================================================= */
const CASES = [
{
id:"stemi", theme:"aks", themeLabel:"ОКС / инфаркт", badge:"b-aks",
title:"Острый инфаркт миокарда с подъёмом ST", severity:3,
patient:{avatar:"👨‍🦰", name:"Пациент А.", age:"58 лет, мужчина", ctx:"Поступил по скорой, ~2 ч от начала боли",
voice:{pitch:0.85, rate:0.95, preferMale:true, breath:230, volume:1}},
complaint:"Доктор, часа два назад как прижало за грудиной. Давит сильно, будто слон на грудь сел. Отдаёт в левую руку и в челюсть. Дышать тяжело, бросило в холодный пот, стало страшно. Таблетку нитроглицерина под язык клал — не отпускает.",
anamnesis:[
"Боль возникла в покое, после эмоционального стресса на работе.",
"Курит 30 лет по пачке в день. Артериальная гипертензия 8 лет.",
"Отец умер от инфаркта миокарда в 60 лет.",
"Ранее подобных приступов не было. Регулярной терапии не получает."
],
objective:{
vitals:{hr:"102", bp:"150/95", rr:"22", spo2:"93%"},
exam:{general:"Кожа бледная, влажная, холодный пот. Пациент тревожен.", heart:"Тоны приглушены, тахикардия, ритм правильный.", lungs:"В нижних отделах с обеих сторон — мелкопузырчатые незвучные хрипы.", other:"Отёков нет."}
},
investigations:[
{t:"ЭКГ в 12 отведениях", n:true},
{t:"Тропонин I/T (количественно)", n:true},
{t:"Общий анализ крови", n:true},
{t:"Коагулограмма (МНО, АЧТВ, фибриноген)", n:true},
{t:"Креатинин, мочевина, электролиты, глюкоза", n:true},
{t:"Рентгенография органов грудной клетки", n:true},
{t:"Эхокардиография", n:true},
{t:"Компьютерная томография головного мозга", n:false},
{t:"Люмбальная пункция", n:false},
{t:"Фиброгастроскопия", n:false}
],
medications:[
{t:"Аспирин 250325 мг разжевать", n:true},
{t:"Ингибитор P2Y12 (тикагрелор/прасугрел) нагрузочно", n:true},
{t:"Антикоагулянт (эноксапарин / НФГ)", n:true},
{t:"Морфин при выраженном болевом синдроме", n:true},
{t:"Кислород при SpO₂ < 90%", n:true},
{t:"Нитраты при боли и АД > 100", n:true},
{t:"Бета-блокатор (после стабилизации)", n:true},
{t:"Статин в высокой дозе", n:true},
{t:"Антибиотики широкого спектра", n:false},
{t:"Нестероидные противовоспалительные (ибупрофен)", n:false}
],
diagnosis:{
options:[
{t:"Острый инфаркт миокарда с подъёмом ST (STEMI), передне-перегородочный", c:true},
{t:"Острый перикардит", c:false},
{t:"Тромбоэмболия лёгочной артерии", c:false},
{t:"Расслоение аорты", c:false},
{t:"Нестабильная стенокардия", c:false}
],
explanation:"Типичный ангинозный синдром, подъём ST в передних отведениях и реципрокная депрессия — STEMI. Время = гибнущий миокард, показана экстренная реперфузия."
},
emergency:{
time:60,
intro:"Пациент в острейшей стадии. У вас 60 секунд, чтобы оказать экстренную помощь.",
actions:[
{t:"Вызвать реанимационную бригаду, активировать STEMI-маршрут (door-to-balloon ≤ 90 мин)", n:true},
{t:"Двойная антитромбоцитарная терапия (аспирин + P2Y12)", n:true},
{t:"Ввести антикоагулянт", n:true},
{t:"Обеспечить венозный доступ и мониторинг ЭКГ", n:true},
{t:"Обезболивание (морфин) + нитраты при АД > 100", n:true},
{t:"Принять решение о реперфузии (ЧКВ / тромболизис)", n:true},
{t:"Ввести антибиотики широкого спектра", n:false},
{t:"Назначить НПВС", n:false},
{t:"Выполнить люмбальную пункцию", n:false}
]
},
extra:"ЭКГ: подъём ST в I, aVL, V1V4; реципрокная депрессия в II, III, aVF. Тропонин I повышен.",
protocol:"Клинический протокол МЗ РК «Острый коронарный синдром»",
protocolNote:"Немедленная реперфузия: первичное ЧКВ (цель door-to-balloon ≤ 90 мин); при недоступности ЧКВ ≤ 120 мин — фибринолитическая терапия (алтеплаза/тенектеплаза), в т.ч. на догоспитальном этапе. Обязательны: ацетилсалициловая кислота + ингибитор P2Y12 (клопидогрел/тикагрелор), антикоагулянт, морфин при боли, кислород при SpO₂ < 90%.",
keyPoints:[
{t:"good", x:"Время = миокард: чем раньше реперфузия, тем больше спасённой мышцы."},
{t:"bad", x:"Не откладывайте реперфузию, ожидая тропонин — ЭКГ и клиника достаточны для STEMI."},
{t:"bad", x:"НПВС противопоказаны при ОКС — усиливают риск разрыва и кровотечения."}
]
},
{
id:"nstemi", theme:"aks", themeLabel:"ОКС / инфаркт", badge:"b-aks",
title:"Острый коронарный синдром без подъёма ST", severity:2,
patient:{avatar:"👩‍🦳", name:"Пациентка Б.", age:"65 лет, женщина", ctx:"Поступила через сутки от начала симптомов",
voice:{pitch:1.12, rate:0.96, preferMale:false, breath:240, volume:1}},
complaint:"Вчера ночью проснулась от боли за грудиной. Давящая, минут двадцать держалась, отдала в спину. Сегодня повторилось дважды, уже при небольшой нагрузке — просто по квартире походила. Раньше такого никогда не было. Как-то страшно, думаю, это сердце.",
anamnesis:[
"Сахарный диабет 2 типа 12 лет, ожирение, гиперхолестеринемия.",
"Гипертония, препараты принимает нерегулярно.",
"Боли провоцируются нагрузкой, купируются в покое за 1015 мин (прогрессирующая стенокардия).",
"За последний месяц снизилась толерантность к нагрузке."
],
objective:{
vitals:{hr:"88", bp:"140/85", rr:"18", spo2:"96%"},
exam:{general:"Состояние средней тяжести. Кожные покровы обычной окраски. ИМТ 32.", heart:"Тоны ритмичные, приглушены. Шумов нет.", lungs:"Дыхание везикулярное, хрипов нет.", other:"Отёков нет."}
},
investigations:[
{t:"ЭКГ в 12 отведениях (повторно)", n:true},
{t:"Тропонин с оценкой динамики через 36 ч", n:true},
{t:"Общий анализ крови, биохимия, коагулограмма", n:true},
{t:"Глюкоза, липидограмма", n:true},
{t:"Креатинин, электролиты", n:true},
{t:"Эхокардиография", n:true},
{t:"Оценка риска по шкале GRACE", n:true},
{t:"Компьютерная томография грудной клетки с контрастом", n:false},
{t:"Люмбальная пункция", n:false},
{t:"Фиброгастроскопия", n:false}
],
medications:[
{t:"Аспирин", n:true},
{t:"Ингибитор P2Y12 (тикагрелор/клопидогрел)", n:true},
{t:"Антикоагулянт (фондапаринукс/эноксапарин/НФГ)", n:true},
{t:"Бета-блокатор (при отсутствии противопоказаний)", n:true},
{t:"Статин в высокой дозе", n:true},
{t:"Нитраты при боли", n:true},
{t:"Морфин при сильной боли (осторожно)", n:true},
{t:"Тромболизис (алтеплаза)", n:false},
{t:"Нестероидные противовоспалительные", n:false},
{t:"Антибиотики", n:false}
],
diagnosis:{
options:[
{t:"Острый коронарный синдром без подъёма ST (NSTEMI), высокий риск", c:true},
{t:"Стабильная стенокардия напряжения", c:false},
{t:"Костохондрит", c:false},
{t:"Гастроэзофагеальная рефлюксная болезнь", c:false},
{t:"Острый перикардит", c:false}
],
explanation:"Депрессия ST + подъём тропонина + высокий риск по GRACE = NSTEMI. Тромболизис при NSTEMI НЕ показан — показана ранняя инвазивная стратегия."
},
emergency:{
time:60,
intro:"Пациентка с NSTEMI высокого риска. У вас 60 секунд на оказание экстренной помощи.",
actions:[
{t:"Двойная антитромбоцитарная терапия + антикоагулянт", n:true},
{t:"Обеспечить мониторинг и венозный доступ", n:true},
{t:"Провести стратификацию риска (GRACE)", n:true},
{t:"Принять решение о ранней инвазивной стратегии (ЧКВ ≤ 24 ч)", n:true},
{t:"Нитраты/обезболивание при боли", n:true},
{t:"Кислород при SpO₂ < 90%", n:true},
{t:"Провести тромболизис алтеплазой", n:false},
{t:"Ввести антибиотики", n:false},
{t:"Назначить НПВС", n:false}
]
},
extra:"ЭКГ: горизонтальная депрессия ST в V4V6, инверсия T. Тропонин повышен с динамикой. GRACE — высокий риск.",
protocol:"Клинический протокол МЗ РК «Острый коронарный синдром»",
protocolNote:"При ОКС без подъёма ST тромболизис НЕ показан. Стратификация риска (шкала GRACE) определяет раннюю инвазивную стратегию (ЧКВ ≤ 24 ч при высоком риске). Двойная антитромбоцитарная терапия + антикоагулянт, статины, бета-блокатор при отсутствии противопоказаний.",
keyPoints:[
{t:"good", x:"Женщины и диабетики часто имеют атипичную клинику — не сбрасывайте со счетов."},
{t:"bad", x:"Не ставьте «стабильную стенокардию» при недавней прогрессии симптомов — это ОКС."},
{t:"bad", x:"Тромболизис при NSTEMI не показан — это частая и опасная ошибка."}
]
},
{
id:"asthma", theme:"odn", themeLabel:"ОДН / астма / ХОБЛ", badge:"b-odn",
title:"Острый тяжёлый приступ бронхиальной астмы", severity:3,
patient:{avatar:"👩", name:"Пациентка В.", age:"24 года, женщина", ctx:"Доставлена СМП, одышка 1 час",
voice:{pitch:1.25, rate:1.05, preferMale:false, breath:420, volume:0.9}},
complaint:"Не могу... выдохнуть... свистит вся грудь... началось час назад... ингалятор уже третий раз пшикала — не помогает... тяжело... страшно задохнуться.",
anamnesis:[
"Бронхиальная астма с детства, базисную терапию (ИГКС) принимает нерегулярно.",
"Неделю назад перенесла ОРВИ с кашлем.",
"За последние дни использовала сальбутамол более 10 раз в сутки.",
"Аллергия на пыльцу, домашнюю пыль."
],
objective:{
vitals:{hr:"120", bp:"135/85", rr:"30", spo2:"91%"},
exam:{general:"Положение ортопноэ, опирается на руки. Речь прерывистая, отдельные фразы. Работа вспомогательной мускулатуры.", heart:"Тахикардия.", lungs:"Выдох резко удлинён, свистящие хрипы по всем полям.", other:"Втянутые межрёберные промежутки, парадоксальный пульс."}
},
investigations:[
{t:"Пульсоксиметрия", n:true},
{t:"Пикфлоуметрия (пиковая скорость выдоха)", n:true},
{t:"Газометрия артериальной крови (при тяжёлом течении)", n:true},
{t:"ЭКГ (исключить аритмию/ишемию)", n:true},
{t:"Рентгенография органов грудной клетки", n:true},
{t:"Общий анализ крови", n:true},
{t:"Компьютерная томография головного мозга", n:false},
{t:"Люмбальная пункция", n:false},
{t:"Бронхоскопия", n:false},
{t:"Эхокардиография (первоочерёдно)", n:false}
],
medications:[
{t:"Сальбутамол через небулайзер (повторно/непрерывно)", n:true},
{t:"Ипратропия бромид через небулайзер", n:true},
{t:"Системные ГКС (преднизолон/гидрокортизон)", n:true},
{t:"Магния сульфат в/в при тяжёлом приступе", n:true},
{t:"Кислород (до SpO₂ 9498%)", n:true},
{t:"Седативные препараты (диазепам)", n:false},
{t:"Опиоиды (морфин)", n:false},
{t:"Бета-блокаторы (пропранолол)", n:false},
{t:"Нестероидные противовоспалительные", n:false}
],
diagnosis:{
options:[
{t:"Острый тяжёлый приступ бронхиальной астмы (угроза астматического статуса)", c:true},
{t:"Паническая атака", c:false},
{t:"Обострение ХОБЛ", c:false},
{t:"Тромбоэмболия лёгочной артерии", c:false},
{t:"Инородное тело дыхательных путей", c:false}
],
explanation:"Экспираторная одышка, свистящие хрипы, SpO₂ 91%, ПСВ < 50% — острый тяжёлый приступ астмы. Основа: бронходилататоры и системные ГКС."
},
emergency:{
time:60,
intro:"Пациентка задыхается. У вас 60 секунд оказать экстренную помощь.",
actions:[
{t:"Кислород до SpO₂ 9498%", n:true},
{t:"Сальбутамол небулайзер 5 мг, повторно", n:true},
{t:"Ипратропия бромид небулайзер", n:true},
{t:"Системные ГКС как можно раньше", n:true},
{t:"Магния сульфат в/в", n:true},
{t:"Мониторинг, подготовка к ИВЛ", n:true},
{t:"Ввести седативное (диазепам)", n:false},
{t:"Ввести морфин", n:false},
{t:"Дать бета-блокатор", n:false}
]
},
extra:"ПСВ < 50% от должного. При жизнеугрожающем статусе — «немое лёгкое» (исчезновение хрипов при нарастающей ОДН).",
protocol:"Клинический протокол МЗ РК «Бронхиальная астма»",
protocolNote:"Острый тяжёлый приступ: кислород (SpO₂ 9498%), β2-агонисты короткого действия (сальбутамол) через небулайзер повторно/непрерывно, ипратропия бромид, системные ГКС как можно раньше, магния сульфат в/в. Седативные и опиоиды противопоказаны (угнетение дыхания). При жизнеугрожающем статусе — ИВЛ.",
keyPoints:[
{t:"bad", x:"Никогда не давайте седативные/опиоиды при астме — это угнетает дыхание и ведёт к остановке."},
{t:"good", x:"Системные ГКС работают через 46 ч, поэтому вводите их сразу, не ждите ухудшения."},
{t:"bad", x:"«Немое лёгкое» = плохо: хрипы исчезли из-за отсутствия резерва — предлетальное состояние."}
]
},
{
id:"copd", theme:"odn", themeLabel:"ОДН / астма / ХОБЛ", badge:"b-odn",
title:"Обострение ХОБЛ с острой дыхательной недостаточностью", severity:2,
patient:{avatar:"👴", name:"Пациент Г.", age:"68 лет, мужчина", ctx:"Направлен врачом общей практики",
voice:{pitch:0.82, rate:0.9, preferMale:true, breath:300, volume:1}},
complaint:"Уже неделю кашель усилился, мокрота стала густая, жёлто-зелёная. Дышать совсем тяжело, особенно со вчера. Раньше хоть по дому ходил, а сейчас до туалета не дойти — задыхаюсь. Сплю полусидя, две подушки подкладываю. Лихорадит три дня.",
anamnesis:[
"ХОБЛ диагностирована 10 лет назад. Курит 40 лет (40 пачко-лет).",
"Последние 3 дня — лихорадка до 38.5 °C, гнойная мокрота.",
"Целевая SpO₂ 8892% (компенсированная гиперкапния).",
"Дома — ипратропия бромид/фенотерол."
],
objective:{
vitals:{hr:"110", bp:"160/90", rr:"28", spo2:"86%", temp:"38.3°C"},
exam:{general:"Состояние тяжёлое. Цианоз губ, акроцианоз.", heart:"Громкий II тон над лёгочной артерией. Тахикардия.", lungs:"Грудная клетка бочкообразная, коробочный звук. Сухие свистящие хрипы.", other:"Периферические отёки голеней — cor pulmonale."}
},
investigations:[
{t:"Пульсоксиметрия", n:true},
{t:"Газометрия артериальной крови", n:true},
{t:"Рентгенография органов грудной клетки", n:true},
{t:"ЭКГ", n:true},
{t:"Общий анализ крови, СРБ, прокальцитонин", n:true},
{t:"Мокрота на флору и чувствительность", n:true},
{t:"Биохимия, электролиты", n:true},
{t:"Компьютерная томография головного мозга", n:false},
{t:"Люмбальная пункция", n:false},
{t:"Бронхоскопия", n:false}
],
medications:[
{t:"Бронходилататоры небулайзер (ипратропий + фенотерол)", n:true},
{t:"Системные ГКС (преднизолон 3040 мг)", n:true},
{t:"Антибиотики при гнойной мокроте/лихорадке", n:true},
{t:"Контролируемая О₂-терапия (цель SpO₂ 8892%)", n:true},
{t:"NIV/BiPAP при тяжёлой гиперкапнической ОДН", n:true},
{t:"Максимальный O₂ до SpO₂ 99100%", n:false},
{t:"Седативные препараты", n:false},
{t:"Наркотические анальгетики", n:false}
],
diagnosis:{
options:[
{t:"Обострение ХОБЛ, ОДН 2 типа (гиперкапническая)", c:true},
{t:"Острый приступ бронхиальной астмы", c:false},
{t:"Отёк лёгких", c:false},
{t:"Внебольничная пневмония без ОДН", c:false},
{t:"Тромбоэмболия лёгочной артерии", c:false}
],
explanation:"ХОБЛ в анамнезе, гнойная мокрота, лихорадка, SpO₂ 86%, гиперкапния по газометрии — обострение ХОБЛ с ОДН 2 типа. Кислород титровать до 8892%."
},
emergency:{
time:60,
intro:"Пациент с гиперкапнической ОДН. У вас 60 секунд оказать экстренную помощь.",
actions:[
{t:"Контролируемая О₂-терапия (Venturi 2428%, цель SpO₂ 8892%)", n:true},
{t:"Бронходилататоры через небулайзер", n:true},
{t:"Системные ГКС", n:true},
{t:"Антибиотики при признаках инфекции", n:true},
{t:"NIV/BiPAP при pH<7.35 / гиперкапнии", n:true},
{t:"Мониторинг газометрии", n:true},
{t:"Дать максимальный O₂ до SpO₂ 99100%", n:false},
{t:"Седация для комфорта", n:false},
{t:"Сразу интубировать без попытки NIV", n:false}
]
},
extra:"Газометрия: PaO₂ 52 ↓, PaCO₂ 68 ↑, pH 7.31 (дыхательный ацидоз). Гиперкапническая ОДН 2 типа.",
protocol:"Клинический протокол МЗ РК «Хроническая обструктивная болезнь легких»",
protocolNote:"Контролируемая оксигенотерапия (целевой SpO₂ 8892%) — избыток O₂ вызывает CO₂-наркоз. Бронходилататоры через небулайзер, системные ГКС, антибиотики при признаках инфекции. При гиперкапнической ОДН (pH < 7.35) — неинвазивная вентиляция лёгких (NIV).",
keyPoints:[
{t:"bad", x:"Не давайте 100% O₂ «на всякий случай» при ХОБЛ — это ведёт к CO₂-наркозу."},
{t:"good", x:"NIV снижает потребность в интубации и летальность при обострении ХОБЛ с гиперкапнией."},
{t:"good", x:"Целевой SpO₂ для пациента с ХОБЛ — 8892%, а не 9498%."}
]
},
{
id:"hemoshock", theme:"shock", themeLabel:"Шок и травма", badge:"b-shock",
title:"Травматический геморрагический шок", severity:3,
patient:{avatar:"🧑", name:"Пациент Д.", age:"30 лет, мужчина", ctx:"ДТП, доставлен бригадой СМП",
voice:{pitch:0.92, rate:0.85, preferMale:true, breath:520, volume:0.7}},
complaint:"Всё кружится... холодно почему-то... пить очень хочу... ногу не чувствую... где я... ничего не вижу ясно...",
anamnesis:[
"ДТП: лобовое столкновение, пациент за рулём, не пристёгнут.",
"Закрытый перелом бедра, подозрение на перелом таза.",
"Наружное кровотечение из раны бедра, обширная гематома.",
"Время от травмы до поступления ~40 минут."
],
objective:{
vitals:{hr:"130", bp:"80/40", rr:"28", spo2:"95%"},
exam:{general:"Сознание оглушение (GCS 13). Резкая бледность, холодный липкий пот.", heart:"Тоны глухие, нитевидный пульс.", lungs:"Дыхание проводится по всем полям, хрипов нет.", other:"Левое бедро деформировано, укорочено, обширная гематома. Кровь на одежде."}
},
investigations:[
{t:"Группа крови, Rh, кросс-матч", n:true},
{t:"Общий анализ крови (Hb, Ht)", n:true},
{t:"Коагулограмма", n:true},
{t:"Лактат, газометрия (дефицит базы)", n:true},
{t:"Креатинин, электролиты", n:true},
{t:"Рентгенография органов грудной клетки", n:true},
{t:"УЗИ FAST (поиск внутреннего кровотечения)", n:true},
{t:"Люмбальная пункция", n:false},
{t:"Фиброгастроскопия", n:false},
{t:"ЭЭГ", n:false}
],
medications:[
{t:"Кристаллоиды (солевые растворы)", n:true},
{t:"Эритроцитарная масса", n:true},
{t:"Свежезамороженная плазма", n:true},
{t:"Тромбоцитный концентрат при коагулопатии", n:true},
{t:"Транексамовая кислота", n:true},
{t:"Норадреналин временно при рефрактерном шоке", n:true},
{t:"Антикоагулянты (гепарин/эноксапарин)", n:false},
{t:"Тромболизис", n:false},
{t:"Диуретики", n:false}
],
diagnosis:{
options:[
{t:"Травматический геморрагический шок IIIIV степени", c:true},
{t:"Кардиогенный шок", c:false},
{t:"Септический шок", c:false},
{t:"Анафилактический шок", c:false},
{t:"Нейрогенный шок", c:false}
],
explanation:"Травма, массивная кровопотеря, ЧСС 130, АД 80/40, шоковый индекс 1.6, бледность, олигурия — геморрагический шок IIIIV. Приоритет — остановка кровотечения и восстановление ОЦК."
},
emergency:{
time:60,
intro:"Пациент в геморрагическом шоке, нестабилен. У вас 60 секунд на оказание экстренной помощи.",
actions:[
{t:"Немедленная остановка наружного кровотечения (прямое давление/жгут)", n:true},
{t:"ABCDE, иммобилизация шейного отдела", n:true},
{t:"Два крупных венозных доступа", n:true},
{t:"Согревание пациента (борьба с гипотермией)", n:true},
{t:"Инфузия кристаллоидов + ранняя гемотрансфузия (1:1:1)", n:true},
{t:"Тазовый бинт при переломе таза", n:true},
{t:"Экстренный хирургический гемостаз", n:true},
{t:"Начать антикоагулянтную терапию", n:false},
{t:"Провести тромболизис", n:false}
]
},
extra:"Класс шока IIIIV (потеря > 30% ОЦК). Лактат повышен, дефицит базы > 6 ммоль/л. Шоковый индекс 1.6.",
protocol:"Клинические протоколы МЗ РК «Шок», «Острое кровотечение»",
protocolNote:"Приоритет — остановка кровотечения (damage control). ABCDE, иммобилизация. Сбалансированная инфузия кристаллоидов + ранняя гемотрансфузия (эритроциты/плазма/тромбоциты), транексамовая кислота. Борьба с триадой смерти (гипотермия, ацидоз, коагулопатия). Экстренный хирургический гемостаз. Антикоагулянты и тромболизис противопоказаны.",
keyPoints:[
{t:"bad", x:"Не тратьте время на полное обследование при нестабильной гемодинамике — пациент гибнет от кровопотери."},
{t:"good", x:"Боритесь с триадой смерти: гипотермия + ацидоз + коагулопатия."},
{t:"bad", x:"Избыток кристаллоидов до гемотрансфузии усугубляет коагулопатию и гипотермию."}
]
},
{
id:"sepsis", theme:"shock", themeLabel:"Шок и травма", badge:"b-shock",
title:"Септический шок", severity:3,
patient:{avatar:"🧔", name:"Пациент Е.", age:"55 лет, мужчина", ctx:"Направлен из приёмного покоя",
voice:{pitch:0.9, rate:0.92, preferMale:true, breath:340, volume:0.9}},
complaint:"Три дня назад начался озноб, температура под тридцать девять, кашель с мокротой. Со вчера стало хуже — давление упало, в себя прихожу с трудом. Раньше ноги никогда так не отекали, а сейчас задыхаюсь. Пить хочу, но тошнит.",
anamnesis:[
"Сахарный диабет 2 типа, плохо компенсирован.",
"Заболел как ОРВИ 5 дней назад, затем кашель с гнойной мокротой.",
"Самостоятельно принимал парацетамол и «антибиотик из аптечки».",
"Аллергия на пенициллин."
],
objective:{
vitals:{hr:"125", bp:"85/50", rr:"26", spo2:"92%", temp:"38.9°C"},
exam:{general:"Состояние тяжёлое. Кожа горячая, пятнистая сыпь. Сознание спутано.", heart:"Тоны глухие, тахикардия. САД 85 после инфузии.", lungs:"В правой нижней доле — ослабленное дыхание, влажные хрипы, притупление.", other:"Олигурия. Капиллярное наполнение > 3 сек."}
},
investigations:[
{t:"Гемокультуры (до антибиотиков!)", n:true},
{t:"Лактат", n:true},
{t:"Общий анализ крови с формулой, СРБ, прокальцитонин", n:true},
{t:"Культура мокроты и мочи", n:true},
{t:"Газометрия, коагулограмма (ДВС-скрининг)", n:true},
{t:"Креатинин, печёночные пробы, электролиты", n:true},
{t:"Рентгенография органов грудной клетки", n:true},
{t:"ЭКГ", n:true},
{t:"Люмбальная пункция", n:false},
{t:"Фиброгастроскопия", n:false}
],
medications:[
{t:"Антибиотики широкого спектра в/в в 1-й час", n:true},
{t:"Кристаллоиды 30 мл/кг", n:true},
{t:"Норадреналин (вазопрессор 1-й линии)", n:true},
{t:"Вазопрессин (2-я линия)", n:true},
{t:"Инсулин (контроль гликемии)", n:true},
{t:"Гидрокортизон при рефрактерном шоке", n:true},
{t:"Глюкокортикоиды рутинно в высоких дозах", n:false},
{t:"Седативные препараты", n:false},
{t:"Диуретики в начале терапии", n:false}
],
diagnosis:{
options:[
{t:"Септический шок (на фоне внебольничной пневмонии)", c:true},
{t:"Гиповолемический шок", c:false},
{t:"Кардиогенный шок", c:false},
{t:"Анафилактический шок", c:false},
{t:"Тромбоэмболия лёгочной артерии", c:false}
],
explanation:"Инфекционный очаг (пневмония), лихорадка, гипотензия после инфузии, лактат 4.2, олигурия, спутанность сознания — септический шок. Выполняйте hour-1 bundle."
},
emergency:{
time:60,
intro:"Пациент в септическом шоке. У вас 60 секунд выполнить Sepsis hour-1 bundle.",
actions:[
{t:"Измерить лактат", n:true},
{t:"Взять гемокультуры до антибиотиков", n:true},
{t:"Ввести антибиотики широкого спектра в/в в 1-й час", n:true},
{t:"Инфузия кристаллоидов 30 мл/кг", n:true},
{t:"Норадреналин при стойкой гипотензии (САД ≥ 65)", n:true},
{t:"Мониторинг диуреза, АД, лактата", n:true},
{t:"Ждать посевов 24 ч перед антибиотиками", n:false},
{t:"Только вазопрессоры без инфузии", n:false},
{t:"Только жаропонижающие и наблюдение", n:false}
]
},
extra:"Лактат 4.2 ↑. Лейкоциты 22×10⁹/л, сдвиг. Креатинин повышен. Рентген: инфильтрация нижней доли справа.",
protocol:"Клинический протокол МЗ РК «Сепсис и септический шок»",
protocolNote:"Часовой пакет (hour-1 bundle): измерить лактат, взять гемокультуры до антибиотиков, ввести антибиотики широкого спектра в/в в 1-й час, инфузия кристаллоидов 30 мл/кг, норадреналин при стойкой гипотензии (целевое САД ≥ 65 мм рт.ст.). Контроль источника инфекции.",
keyPoints:[
{t:"bad", x:"Не ждите посевов, чтобы начать антибиотики — каждый час задержки повышает летальность."},
{t:"good", x:"Норадреналин — препарат первого ряда при септическом шоке после адекватной инфузии."},
{t:"good", x:"Контроль источника инфекции так же важен, как антибиотики."}
]
}
];
/* =========================================================
Озвучка (Web Speech API) — человеческий голос
========================================================= */
let voices=[];
function loadVoices(){ if("speechSynthesis" in window) voices=window.speechSynthesis.getVoices(); }
if("speechSynthesis" in window){ loadVoices(); window.speechSynthesis.onvoiceschanged=loadVoices; }
function pickVoice(preferMale){
const ru = voices.filter(v=>v.lang && v.lang.toLowerCase().startsWith("ru"));
if(!ru.length) return null;
const maleRe=/pavel|yuri|maxim|dmitri|alexander|male|мужской/i;
const femaleRe=/irina|katya|milena|tatiana|anna|maria|female|женский/i;
if(preferMale){ const m=ru.find(v=>maleRe.test(v.name)); if(m) return m; }
else { const f=ru.find(v=>femaleRe.test(v.name)); if(f) return f; }
const q=ru.find(v=>/google|yandex|microsoft|neural|premium/i.test(v.name)); if(q) return q;
return ru[0];
}
let speakState={playing:false, queue:[], idx:0, timer:null, btn:null, avatar:null, opts:null, player:null, subsEl:null, onPhrase:null, onEnd:null};
function stopSpeak(){
if("speechSynthesis" in window) window.speechSynthesis.cancel();
if(speakState.timer){ clearTimeout(speakState.timer); speakState.timer=null; }
speakState.playing=false;
if(speakState.btn){ speakState.btn.classList.remove("is-playing"); speakState.btn.textContent="▶ Прослушать жалобы"; }
if(speakState.avatar) speakState.avatar.classList.remove("is-speaking");
if(speakState.player) speakState.player.classList.remove("is-speaking");
if(speakState.onEnd){ const cb=speakState.onEnd; speakState.onEnd=null; cb(); }
speakState.queue=[]; speakState.idx=0; speakState.player=null; speakState.subsEl=null; speakState.onPhrase=null;
}
function splitPhrases(text){
return text.split(/(?<=[.!?…])\s+|(?<=…)\s*/).map(s=>s.trim()).filter(Boolean);
}
function speakPatient(text, opts, btn, avatar, ext){
const synth=window.speechSynthesis;
if(!synth){ return; }
if(speakState.playing){ stopSpeak(); return; }
stopSpeak();
const phrases=splitPhrases(text);
if(!phrases.length) return;
speakState.queue=phrases; speakState.idx=0; speakState.btn=btn; speakState.avatar=avatar; speakState.opts=opts;
speakState.player=ext&&ext.player||null;
speakState.subsEl=ext&&ext.subsEl||null;
speakState.onPhrase=ext&&ext.onPhrase||null;
speakState.onEnd=ext&&ext.onEnd||null;
speakState.playing=true;
btn.classList.add("is-playing"); btn.textContent="⏹ Остановить";
if(avatar) avatar.classList.add("is-speaking");
if(speakState.player) speakState.player.classList.add("is-speaking");
playNext();
}
function playNext(){
const synth=window.speechSynthesis;
if(!speakState.playing || speakState.idx>=speakState.queue.length){ stopSpeak(); return; }
const phrase=speakState.queue[speakState.idx];
if(speakState.subsEl) speakState.subsEl.textContent=phrase;
if(speakState.onPhrase) speakState.onPhrase(phrase, speakState.idx);
const opts=speakState.opts||{};
const u=new SpeechSynthesisUtterance(phrase);
u.lang="ru-RU";
const v=pickVoice(opts.preferMale);
if(v) u.voice=v;
u.rate=opts.rate!=null?opts.rate:0.97;
u.pitch=opts.pitch!=null?opts.pitch:1;
u.volume=opts.volume!=null?opts.volume:1;
u.onend=()=>{
if(!speakState.playing) return;
speakState.idx++;
if(speakState.idx>=speakState.queue.length){ stopSpeak(); return; }
speakState.timer=setTimeout(playNext, opts.breath!=null?opts.breath:230);
};
u.onerror=()=>{ stopSpeak(); };
synth.speak(u);
}
window.addEventListener("beforeunload",()=>{ if("speechSynthesis" in window) window.speechSynthesis.cancel(); });
/* =========================================================
Геймификация: профиль, уровни, достижения, рейтинг
========================================================= */
const STORE={profile:"medigra_profile_v1", progress:"medigra_progress_v1", results:"medigra_results_v1"};
const AVATARS=["🧑‍⚕️","👨‍⚕️","👩‍⚕️","🧑‍🎓","👨‍🎓","👩‍🎓","🩺","⚕️"];
const LEVELS=[
{lvl:1,name:"Стажёр",xp:0},
{lvl:2,name:"Студент",xp:500},
{lvl:3,name:"Интерн",xp:1500},
{lvl:4,name:"Ординатор",xp:3000},
{lvl:5,name:"Врач",xp:5200},
{lvl:6,name:"Специалист",xp:8000},
{lvl:7,name:"Эксперт",xp:12000},
{lvl:8,name:"Профессор",xp:18000}
];
const ACHIEVEMENTS=[
{id:"first", name:"Первый шаг", desc:"Решить первый кейс", icon:"👣", check:s=>s.completedCases>=1},
{id:"dx100", name:"Точный диагноз", desc:"100% за шаг диагноза", icon:"🎯", check:s=>s.perfectDxCount>=1},
{id:"perfect", name:"Безупречный кейс", desc:"100% за весь кейс", icon:"💎", check:s=>s.perfectCases>=1},
{id:"noerr", name:"Чистая работа", desc:"Кейс без лишних назначений", icon:"✨", check:s=>s.noErrorCases>=1},
{id:"fast", name:"Молниеносная реакция", desc:"Завершить помощь с запасом > 30 сек", icon:"⚡", check:s=>s.fastCases>=1},
{id:"aks", name:"Мастер ОКС", desc:"Пройти все кейсы темы ОКС", icon:"❤️", check:s=>s.themeDone.aks},
{id:"odn", name:"Мастер дыхания", desc:"Пройти все кейсы темы ОДН", icon:"🫁", check:s=>s.themeDone.odn},
{id:"shock", name:"Мастер шока", desc:"Пройти все кейсы темы шок", icon:"🩸", check:s=>s.themeDone.shock},
{id:"alldx", name:"Великий диагност", desc:"Все диагнозы верны", icon:"🧠", check:s=>s.allDxCases>=6},
{id:"all", name:"Полное прохождение", desc:"Пройти все 6 кейсов", icon:"🗺️", check:s=>s.completedCases>=6},
{id:"prof", name:"Профессор", desc:"Достичь максимального уровня", icon:"👑", check:s=>s.maxLevel}
];
function load(key, def){ try{ const v=JSON.parse(localStorage.getItem(key)); return v||def; }catch(e){ return def; } }
function save(key, val){ try{ localStorage.setItem(key, JSON.stringify(val)); }catch(e){} }
let profile=load(STORE.profile, {name:"", avatar:"🧑‍⚕️", xp:0, achievements:[]});
let progress=load(STORE.progress, {});
let results=load(STORE.results, []);
function saveAll(){ save(STORE.profile,profile); save(STORE.progress,progress); save(STORE.results,results); }
function levelInfo(xp){
let cur=LEVELS[0], idx=0;
for(let i=0;i<LEVELS.length;i++){ if(xp>=LEVELS[i].xp){ cur=LEVELS[i]; idx=i; } }
const next=LEVELS[idx+1];
const into=xp-cur.xp;
const span=next?(next.xp-cur.xp):1;
const pct=next?Math.min(100,Math.round(into/span*100)):100;
return {cur, next, idx, into, span, pct, isMax:!next};
}
function starsFor(score){ return score>=85?3:score>=70?2:score>=55?1:0; }
function computeStats(){
let completed=0, perfectCases=0, noErrorCases=0, fastCases=0, perfectDxCount=0, allDx=0;
const themeDone={aks:false,odn:false,shock:false};
CASES.forEach(c=>{
const p=progress[c.id];
if(p&&p.completed){
completed++;
if(p.stars>=3) perfectCases++;
if(p.noErrors) noErrorCases++;
if(p.fast) fastCases++;
if(p.perfectDx) perfectDxCount++;
if(p.dxCorrect) allDx++;
}
});
["aks","odn","shock"].forEach(t=>{
const arr=CASES.filter(c=>c.theme===t);
themeDone[t]=arr.every(c=>progress[c.id]&&progress[c.id].completed);
});
return {completedCases:completed, perfectCases, noErrorCases, fastCases, perfectDxCount, allDxCases:allDx, themeDone, maxLevel:levelInfo(profile.xp).isMax};
}
function countFP(items,picked){ let n=0; items.forEach((it,i)=>{ if(!it.n && picked.has(i)) n++; }); return n; }
function checkAchievements(){
const stats=computeStats();
const newly=[];
ACHIEVEMENTS.forEach(a=>{
if(!profile.achievements.includes(a.id) && a.check(stats)){
profile.achievements.push(a.id);
newly.push(a);
}
});
if(newly.length) save(STORE.profile,profile);
return newly;
}
function awardXp(c, score){
const prev=progress[c.id]||{bestScore:0};
const delta=Math.max(0, Math.round((score-(prev.bestScore||0))*3));
if(delta>0){ profile.xp+=delta; save(STORE.profile,profile); }
return delta;
}
function recordCase(c, score, meta){
const prev=progress[c.id]||{completed:false,bestScore:0,stars:0};
progress[c.id]={
completed:true,
bestScore:Math.max(prev.bestScore||0,score),
stars:Math.max(prev.stars||0,starsFor(score)),
dxCorrect: prev.dxCorrect||meta.dxCorrect,
perfectDx: prev.perfectDx||meta.perfectDx,
noErrors: prev.noErrors||meta.noErrors,
fast: prev.fast||meta.fast,
played:(prev.played||0)+1
};
save(STORE.progress,progress);
}
/* ---- тосты ---- */
function toast(kind, title, sub){
const wrap=document.getElementById("toasts");
const el=document.createElement("div");
el.className="toast "+kind;
const ico = kind==="lvl"?"⬆️": kind==="achv"?"🏅":"✨";
el.innerHTML=`<div class="t-ico">${ico}</div><div class="t-tx"><b>${title}</b><small>${sub}</small></div>`;
wrap.appendChild(el);
setTimeout(()=>{ el.style.transition="opacity .4s, transform .4s"; el.style.opacity="0"; el.style.transform="translateX(120%)"; setTimeout(()=>el.remove(),420); }, 4200);
}
/* =========================================================
Рендер интерфейса
========================================================= */
const $=id=>document.getElementById(id);
function renderPlayerBar(){
const li=levelInfo(profile.xp);
const bar=$("playerBar");
bar.innerHTML=`
<div class="p-ava">${profile.avatar}</div>
<div class="p-mid">
<div class="p-name">${profile.name||"Гость"} <span class="xp-num">· ${li.cur.name}</span></div>
<div class="p-lvl">Уровень ${li.cur.lvl}${li.next?` · до ${li.next.name} ${li.next.xp-profile.xp} XP`:" · максимум"}</div>
</div>
<div class="xpbar"><i style="width:${li.pct}%"></i></div>`;
}
function renderHeroStats(){
const stats=computeStats();
const li=levelInfo(profile.xp);
$("heroStats").innerHTML=`
<div class="hs"><b>${li.cur.lvl}</b><span>уровень · ${li.cur.name}</span></div>
<div class="hs"><b>${profile.xp}</b><span>опыт (XP)</span></div>
<div class="hs"><b>${stats.completedCases}/6</b><span>кейсов пройдено</span></div>
<div class="hs"><b>${profile.achievements.length}/${ACHIEVEMENTS.length}</b><span>достижений</span></div>`;
}
function renderThemes(){
const wrap=$("themes");
const themes=[
{key:"aks", ico:"❤️", name:"ОКС и инфаркт", desc:"STEMI, NSTEMI — диагностика и реперфузия.", cls:"t-aks"},
{key:"odn", ico:"🫁", name:"ОДН · астма · ХОБЛ", desc:"Астматический статус, обострение ХОБЛ.", cls:"t-odn"},
{key:"shock", ico:"🩸", name:"Шок и травма", desc:"Геморрагический и септический шок.", cls:"t-shock"}
];
wrap.innerHTML=themes.map(t=>{
const arr=CASES.filter(c=>c.theme===t.key);
const done=arr.filter(c=>progress[c.id]&&progress[c.id].completed).length;
const pct=Math.round(done/arr.length*100);
const stars=arr.reduce((a,c)=>a+((progress[c.id]&&progress[c.id].stars)||0),0);
const maxStars=arr.length*3;
return `<div class="theme ${t.cls}" data-theme="${t.key}">
<div class="theme-top"><div class="theme-ico">${t.ico}</div><div><h3>${t.name}</h3></div></div>
<p class="theme-desc">${t.desc}</p>
<div class="theme-prog"><div class="tp-track"><i style="width:${pct}%"></i></div></div>
<div class="theme-meta"><span>${done}/${arr.length} кейсов</span><span class="theme-stars">${"★".repeat(stars)}${"☆".repeat(maxStars-stars)}</span></div>
</div>`;
}).join("");
wrap.querySelectorAll(".theme").forEach(el=>el.addEventListener("click",()=>{
setFilter(el.dataset.theme);
document.getElementById("cases").scrollIntoView({behavior:"smooth"});
}));
}
function sevDots(n){let s="";for(let i=0;i<3;i++)s+=`<span class="sev-dot ${i<n?"on":""}"></span>`;return s;}
function renderCards(filter="all"){
const grid=$("caseGrid");
grid.innerHTML="";
CASES.filter(c=>filter==="all"||c.theme===filter).forEach(c=>{
const p=progress[c.id];
const done=p&&p.completed;
const stars=done?p.stars:0;
const starsHTML=[0,1,2].map(i=>i<stars?"★":`<span class="empty">★</span>`).join("");
const el=document.createElement("article");
el.className=`case-card t-${c.theme}`;
el.innerHTML=`
<div class="case-head">
<div class="case-avatar">${c.patient.avatar}</div>
<div>
<h3>${c.title}</h3>
<div class="case-sub">${c.patient.name}, ${c.patient.age.split(",")[0]}</div>
</div>
</div>
<p class="case-note">Говорящий пациент. Назначьте обследования и лечение, поставьте диагноз, окажите экстренную помощь на время.</p>
<div class="case-stars">${starsHTML}</div>
<div class="case-best">${done?`Лучший результат: <b>${p.bestScore}%</b> · сыграно ${p.played||1}`:"Ещё не пройден"}</div>
<div class="case-foot">
${done?'<span class="done-tag">✓ Пройден</span>':'<span></span>'}
<button class="case-btn" data-id="${c.id}">${done?"Повторить":"Играть"} →</button>
</div>`;
el.querySelector(".case-btn").addEventListener("click",e=>{e.stopPropagation(); startExam("single",c.id);});
grid.appendChild(el);
});
}
function setFilter(filter){
document.querySelectorAll(".filter").forEach(f=>f.classList.toggle("is-active", f.dataset.filter===filter));
renderCards(filter);
}
function renderAchv(){
const stats=computeStats();
const grid=$("achvGrid");
grid.innerHTML=ACHIEVEMENTS.map(a=>{
const unlocked=profile.achievements.includes(a.id);
return `<div class="achv ${unlocked?"":"locked"}">
${unlocked?"":'<span class="lock">🔒</span>'}
<div class="achv-ico">${a.icon}</div>
<h4>${a.name}</h4>
<p>${a.desc}</p>
</div>`;
}).join("");
$("achvCount").textContent=`Открыто ${profile.achievements.length} из ${ACHIEVEMENTS.length}.`;
}
function renderBoard(){
const wrap=$("boardList");
const sorted=[...results].sort((a,b)=>b.score-a.score).slice(0,10);
if(!sorted.length){ wrap.innerHTML=`<div class="board-empty">Пока нет результатов. Пройдите полный экзамен, чтобы попасть в рейтинг.</div>`; return; }
wrap.innerHTML=sorted.map((r,i)=>{
const me = r.name===profile.name && r.avatar===profile.avatar;
return `<div class="board-row r${i+1} ${me?'me':''}">
<div class="br-rank">${i+1}</div>
<div class="br-ava">${r.avatar}</div>
<div class="br-name">${r.name}<small>${r.date}</small></div>
<div class="br-score">${r.score}%</div>
</div>`;
}).join("");
}
function refreshAll(){
renderPlayerBar(); renderHeroStats(); renderThemes(); renderCards(currentFilter); renderAchv(); renderBoard();
}
let currentFilter="all";
/* =========================================================
Профиль (модал)
========================================================= */
let profOnDone=null;
function openProfile(onDone){
profOnDone=onDone||null;
$("profName").value=profile.name||"";
const av=$("profAvatars");
av.innerHTML=AVATARS.map(a=>`<div class="pm-av ${a===profile.avatar?'sel':''}" data-a="${a}">${a}</div>`).join("");
av.querySelectorAll(".pm-av").forEach(e=>e.addEventListener("click",()=>{
av.querySelectorAll(".pm-av").forEach(x=>x.classList.remove("sel"));
e.classList.add("sel");
}));
$("modalProfile").hidden=false;
}
function closeProfile(){ $("modalProfile").hidden=true; if(profOnDone){ const cb=profOnDone; profOnDone=null; cb(); } }
$("profSave").addEventListener("click",()=>{
profile.name=($("profName").value||"").trim().slice(0,20)||"Гость";
const sel=document.querySelector(".pm-av.sel");
if(sel) profile.avatar=sel.dataset.a;
save(STORE.profile,profile);
renderPlayerBar(); renderHeroStats(); renderBoard();
closeProfile();
});
$("modalProfile").addEventListener("click",e=>{ if(e.target.hasAttribute("data-close-p")){ $("modalProfile").hidden=true; } });
$("btnProfile").addEventListener("click",()=>openProfile());
$("btnAchv").addEventListener("click",()=>document.getElementById("achv").scrollIntoView({behavior:"smooth"}));
$("btnBoard").addEventListener("click",()=>document.getElementById("board").scrollIntoView({behavior:"smooth"}));
/* =========================================================
Экзамен (полноэкранный wizard)
========================================================= */
const exam=$("exam"), examBody=$("examBody");
const STAGES=[
{key:"patient", label:"Пациент"},
{key:"investigations", label:"Обследование"},
{key:"medications", label:"Лечение"},
{key:"diagnosis", label:"Диагноз"},
{key:"emergency", label:"Помощь ⏱"},
{key:"result", label:"Результат"}
];
let state=null, timerHandle=null;
function startExam(mode, caseId){
const queue = mode==="full" ? CASES.map(c=>c.id) : [caseId];
state={mode, queue, qIdx:0, stage:0, answers:{}, caseScores:[], emergencyLeft:0, emergencyManual:false};
exam.hidden=false; document.body.style.overflow="hidden";
loadCase();
}
function closeExam(){
stopSpeak(); stopTimer();
exam.hidden=true; document.body.style.overflow="";
refreshAll();
state=null;
}
function curCase(){ return CASES.find(c=>c.id===state.queue[state.qIdx]); }
function loadCase(){
stopTimer();
state.stage=0;
state.answers={investigations:new Set(), medications:new Set(), diagnosis:null, emergency:new Set(), submitted:{i:false,m:false,d:false,e:false}};
state.emergencyLeft=0; state.emergencyManual=false;
stopSpeak();
renderExamBar(); renderStageTrack(); renderPatient();
}
function renderExamBar(){
$("examProgressLabel").textContent=`Кейс ${state.qIdx+1} из ${state.queue.length}`;
$("examDots").innerHTML=state.queue.map((_,i)=>{
const done=i<state.qIdx, cur=i===state.qIdx;
return `<span class="exam-dot ${done?"done":""} ${cur?"current":""}"></span>`;
}).join("");
}
function renderStageTrack(){
$("stageTrack").innerHTML=STAGES.map((s,i)=>{
let cls=""; if(i<state.stage) cls="done"; else if(i===state.stage) cls="active";
const mark=cls==="done"?"✓":(i+1);
return `<div class="stage-pill ${cls}"><span class="sp-n">${mark}</span><span class="sp-label">${s.label}</span></div>${i<STAGES.length-1?'<span class="stage-sep">→</span>':""}`;
}).join("");
}
function ring(pct, size, stroke, color, track, num){
const r=(size-stroke)/2, c=2*Math.PI*r, off=c*(1-Math.max(0,Math.min(100,pct))/100);
return `<div class="timer-ring" style="width:${size}px;height:${size}px">
<svg width="${size}" height="${size}">
<circle cx="${size/2}" cy="${size/2}" r="${r}" stroke="${track}" stroke-width="${stroke}" fill="none"/>
<circle cx="${size/2}" cy="${size/2}" r="${r}" stroke="${color}" stroke-width="${stroke}" fill="none" stroke-dasharray="${c}" stroke-dashoffset="${off}" stroke-linecap="round" style="transition:stroke-dashoffset .3s"/>
</svg><div class="timer-num">${num}</div></div>`;
}
function patientBlock(c){
return `<div class="patient">
<div class="patient-top">
<div class="patient-avatar" id="pAvatar">${c.patient.avatar}</div>
<div class="patient-info">
<h2>${c.title}</h2>
<div class="meta">${c.patient.name} · ${c.patient.age} · ${c.patient.ctx}</div>
<div class="patient-badges"><span class="badge ${c.badge}">${c.themeLabel}</span></div>
</div>
</div>
<div class="vplayer" id="vplayer">
<div class="vp-screen">
<div class="vp-waves"><i></i><i></i><i></i></div>
<div class="vp-avatar">${c.patient.avatar}</div>
<span class="vp-live">● ВИДЕО · LIVE</span>
<span class="vp-photo">📷 Фото пациента</span>
</div>
<div class="vp-subs" id="vpSubs">Нажмите «Прослушать», чтобы пациент рассказал жалобы</div>
<div class="vp-ctrl">
<button class="speak-btn" id="speakBtn">▶ Прослушать жалобы</button>
<span class="vp-hint">🔊 Включите звук на устройстве</span>
</div>
</div>
<div class="complaint">«${c.complaint}»</div>
</div>`;
}
function vitalsHTML(v){
let extra="";
if(v.temp) extra=`<div class="vital v-temp"><b>${v.temp}</b><span>Температура</span></div>`;
return `<div class="vitals">
<div class="vital v-hr"><b>${v.hr}</b><span>ЧСС</span></div>
<div class="vital v-bp"><b>${v.bp}</b><span>АД</span></div>
<div class="vital v-rr"><b>${v.rr}</b><span>ЧДД</span></div>
<div class="vital v-spo"><b>${v.spo2}</b><span>SpO₂</span></div>
${extra}</div>`;
}
function bindSpeak(c, root){
const btn=root.querySelector("#speakBtn"),
av=root.querySelector(".vp-avatar"),
player=root.querySelector("#vplayer"),
subs=root.querySelector("#vpSubs");
if(!btn) return;
btn.addEventListener("click",()=>speakPatient(c.complaint, c.patient.voice, btn, av, {
player, subsEl:subs,
onEnd:()=>{ if(subs) subs.textContent="Нажмите «Прослушать», чтобы пациент рассказал жалобы"; }
}));
}
function renderPatient(){
const c=curCase();
examBody.innerHTML=`<div class="exam-wrap">
${patientBlock(c)}
<details class="acc" open><summary>📋 Анамнез</summary><div class="acc-body"><ul>${c.anamnesis.map(a=>`<li>${a}</li>`).join("")}</ul></div></details>
<details class="acc" open><summary>🩺 Объективный осмотр</summary><div class="acc-body">
${vitalsHTML(c.objective.vitals)}
<h4>Общий осмотр</h4><p>${c.objective.exam.general}</p>
<h4>Сердечно-сосудистая система</h4><p>${c.objective.exam.heart}</p>
<h4>Дыхательная система</h4><p>${c.objective.exam.lungs}</p>
<h4>Прочее</h4><p>${c.objective.exam.other}</p>
</div></details>
<div class="step-foot">
<span class="score-chip">Знакомство с пациентом</span>
<button class="btn btn-primary" id="goNext">Начать решение →</button>
</div>
</div>`;
bindSpeak(c, examBody);
$("goNext").onclick=()=>{ state.stage=1; renderStageTrack(); renderMulti("investigations"); };
examBody.scrollTop=0;
}
const MULTI_CFG={
investigations:{tag:"Шаг 1 из 4", title:"Назначьте обследования", sub:"Выберите необходимые исследования. Не назначайте лишнее — за ошибки снижается балл.", btn:"Подтвердить назначения"},
medications:{tag:"Шаг 2 из 4", title:"Назначьте лечение", sub:"Выберите препараты из перечня. Избегайте противопоказанных и неэффективных.", btn:"Подтвердить лечение"},
emergency:{tag:"Шаг 4 из 4", title:"Экстренная помощь ⏱", sub:"", btn:"Завершить помощь"}
};
function renderMulti(kind){
const c=curCase();
const cfg=MULTI_CFG[kind];
const items = kind==="investigations"?c.investigations : kind==="medications"?c.medications : c.emergency.actions;
const ans=state.answers[kind];
const submitted = kind==="investigations"?state.answers.submitted.i : kind==="medications"?state.answers.submitted.m : state.answers.submitted.e;
let emergencyTop="";
if(kind==="emergency" && !submitted) emergencyTop=`<div class="timer-wrap" id="timerWrap"></div>`;
examBody.innerHTML=`<div class="exam-wrap">
<div class="step-head">
<span class="step-tag">${cfg.tag}</span>
<h3>${cfg.title}</h3>
<p>${kind==="emergency"?c.emergency.intro:cfg.sub}</p>
</div>
${emergencyTop}
<div class="opt-list" id="optList">
${items.map((it,i)=>{
const checked=ans.has(i)?"checked":"";
return `<div class="opt ${submitted?'disabled':''}" data-i="${i}">
<input type="checkbox" ${checked} ${submitted?"disabled":""}>
<span class="opt-box"></span>
<span class="opt-text">${it.t}</span>
</div>`;
}).join("")}
</div>
<div class="step-foot">
<span class="score-chip" id="stepChip">${submitted?`Балл за шаг: <b id="stepScore"></b>`:"Выберите варианты"}</span>
<button class="btn btn-primary" id="actBtn">${submitted?(kind==="emergency"?"К результату кейса →":"Следующий шаг →"):cfg.btn}</button>
</div>
</div>`;
const optList=examBody.querySelector("#optList");
optList.addEventListener("click",e=>{
if(submitted) return;
const lbl=e.target.closest(".opt"); if(!lbl) return;
const i=+lbl.dataset.i;
if(ans.has(i)){ ans.delete(i); lbl.querySelector("input").checked=false; }
else { ans.add(i); lbl.querySelector("input").checked=true; }
});
const actBtn=examBody.querySelector("#actBtn");
if(submitted){
highlightMulti(kind);
actBtn.onclick=()=>{
if(kind==="investigations"){ state.stage=2; renderStageTrack(); renderMulti("medications"); }
else if(kind==="medications"){ state.stage=3; renderStageTrack(); renderDiagnosis(); }
else { state.stage=5; renderStageTrack(); renderCaseResult(); }
};
} else {
actBtn.onclick=()=>{
if(kind==="investigations") state.answers.submitted.i=true;
else if(kind==="medications") state.answers.submitted.m=true;
else { state.answers.submitted.e=true; state.emergencyManual=true; stopTimer(); }
renderMulti(kind);
};
}
if(kind==="emergency" && !submitted) startTimer(c);
examBody.scrollTop=0;
}
function highlightMulti(kind){
const c=curCase();
const items = kind==="investigations"?c.investigations : kind==="medications"?c.medications : c.emergency.actions;
const ans=state.answers[kind];
examBody.querySelectorAll(".opt").forEach(lbl=>{
const i=+lbl.dataset.i, it=items[i], got=ans.has(i);
if(it.n&&got){ lbl.classList.add("correct"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`<span class="opt-mark good">верно</span>`); }
else if(!it.n&&got){ lbl.classList.add("wrong-pick"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`<span class="opt-mark bad">лишнее</span>`); }
else if(it.n&&!got){ lbl.classList.add("missed"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`<span class="opt-mark warn">пропущено</span>`); }
});
const ss=examBody.querySelector("#stepScore"); if(ss) ss.textContent=multiScore(items,ans)+"%";
}
function renderDiagnosis(){
const c=curCase();
const submitted=state.answers.submitted.d;
examBody.innerHTML=`<div class="exam-wrap">
<div class="step-head">
<span class="step-tag">Шаг 3 из 4</span>
<h3>Поставьте диагноз</h3>
<p>Выберите наиболее вероятный диагноз из вариантов.</p>
</div>
<div class="opt-list" id="optList">
${c.diagnosis.options.map((o,i)=>{
const checked=state.answers.diagnosis===i?"checked":"";
return `<div class="opt is-radio ${submitted?'disabled':''}" data-i="${i}">
<input type="radio" name="dx" ${checked} ${submitted?"disabled":""}>
<span class="opt-box"></span>
<span class="opt-text">${o.t}</span>
</div>`;
}).join("")}
</div>
<div class="step-foot">
<span class="score-chip" id="stepChip">${submitted?`Балл за шаг: <b id="stepScore"></b>`:"Выберите один вариант"}</span>
<button class="btn btn-primary" id="actBtn">${submitted?"Следующий шаг →":"Подтвердить диагноз"}</button>
</div>
</div>`;
const optList=examBody.querySelector("#optList");
optList.addEventListener("click",e=>{
if(submitted) return;
const lbl=e.target.closest(".opt"); if(!lbl) return;
state.answers.diagnosis=+lbl.dataset.i;
optList.querySelectorAll("input").forEach((inp,ii)=>inp.checked=ii===state.answers.diagnosis);
});
const actBtn=examBody.querySelector("#actBtn");
if(submitted){
highlightDiagnosis();
actBtn.onclick=()=>{ state.stage=4; renderStageTrack(); renderMulti("emergency"); };
} else {
actBtn.onclick=()=>{ if(state.answers.diagnosis===null){ flashChip(); return; } state.answers.submitted.d=true; renderDiagnosis(); };
}
examBody.scrollTop=0;
}
function highlightDiagnosis(){
const c=curCase();
examBody.querySelectorAll(".opt").forEach(lbl=>{
const i=+lbl.dataset.i, o=c.diagnosis.options[i];
if(o.c){ lbl.classList.add("correct"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`<span class="opt-mark good">верный диагноз</span>`); }
else if(i===state.answers.diagnosis){ lbl.classList.add("wrong-pick"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`<span class="opt-mark bad">неверно</span>`); }
});
const sc=state.answers.diagnosis!==null&&c.diagnosis.options[state.answers.diagnosis].c?100:0;
const ss=examBody.querySelector("#stepScore"); if(ss) ss.textContent=sc+"%";
}
function flashChip(){
const chip=examBody.querySelector("#stepChip"); if(!chip) return;
chip.style.background="var(--red-soft)"; chip.style.color="var(--red)";
setTimeout(()=>{ chip.style.background=""; chip.style.color=""; },700);
}
function startTimer(c){
const total=c.emergency.time||60; let left=total;
const tw=examBody.querySelector("#timerWrap"); if(!tw) return;
function tick(){
state.emergencyLeft=left;
const pct=left/total*100;
const color=left<=10?"var(--red)":"var(--gold)";
const danger=left<=10?' <span class="danger">осталось мало времени!</span>':"";
tw.innerHTML=`${ring(pct,72,7,color,"rgba(255,255,255,.15)",left)}
<div class="timer-info"><h4>⏱ Экстренная помощь</h4><p>Отмечайте действия. Время пошло.${danger}</p>
<div class="timer-bar"><i class="${left<=10?'low':''}" style="width:${pct}%"></i></div></div>`;
if(left<=0){ stopTimer(); if(!state.answers.submitted.e){ state.answers.submitted.e=true; state.emergencyManual=false; renderMulti("emergency"); } return; }
left--;
}
tick();
timerHandle=setInterval(tick,1000);
}
function stopTimer(){ if(timerHandle){ clearInterval(timerHandle); timerHandle=null; } }
function multiScore(items,picked){
let tp=0,tn=0,fp=0,fn=0;
items.forEach((it,i)=>{ const need=it.n, got=picked.has(i);
if(need&&got)tp++; else if(need&&!got)fn++; else if(!need&&got)fp++; else tn++; });
return Math.round(((tp+tn)/items.length)*100);
}
function computeCaseScore(c){
const s={
investigations:multiScore(c.investigations,state.answers.investigations),
medications:multiScore(c.medications,state.answers.medications),
diagnosis:(state.answers.diagnosis!==null&&c.diagnosis.options[state.answers.diagnosis].c)?100:0,
emergency:multiScore(c.emergency.actions,state.answers.emergency)
};
s.total=Math.round(s.investigations*0.2+s.medications*0.2+s.diagnosis*0.3+s.emergency*0.3);
return s;
}
function breakRow(name,val,color){
return `<div class="bd-row"><div class="bd-name">${name}</div>
<div class="bd-track"><i style="width:${val}%;background:${color}"></i></div>
<div class="bd-val">${val}%</div></div>`;
}
function renderCaseResult(){
const c=curCase();
const sc=computeCaseScore(c);
const meta={
dxCorrect:state.answers.diagnosis!==null&&c.diagnosis.options[state.answers.diagnosis].c,
perfectDx:sc.diagnosis===100,
noErrors:countFP(c.investigations,state.answers.investigations)===0&&countFP(c.medications,state.answers.medications)===0&&countFP(c.emergency.actions,state.answers.emergency)===0,
fast:state.emergencyManual&&state.emergencyLeft>30
};
// награды
const prevLevel=levelInfo(profile.xp).idx;
const xpGain=awardXp(c, sc.total);
recordCase(c, sc.total, meta);
const newAchv=checkAchievements();
const newLevel=levelInfo(profile.xp).idx;
const stars=starsFor(sc.total);
state.caseScores.push({id:c.id,title:c.title,theme:c.theme,themeLabel:c.themeLabel,badge:c.badge,score:sc.total});
if(newLevel>prevLevel){ const li=levelInfo(profile.xp); toast("lvl","Новый уровень!",`${li.cur.name} — уровень ${li.cur.lvl}`); }
newAchv.forEach(a=>toast("achv","Достижение!",a.name));
const grade=gradeFor(sc.total), col=colorFor(sc.total);
const starsHTML=[0,1,2].map(i=>i<stars?"★":`<span class="empty">★</span>`).join("");
examBody.innerHTML=`<div class="exam-wrap">
<div class="result">
<div class="result-head">
<div class="score-ring">${ring(sc.total,146,12,col,"var(--gray-100)",`<b>${sc.total}%</b><span>оценка за кейс</span>`)}</div>
<h3>${grade.title}</h3>
<div class="verdict">${grade.text}</div>
</div>
<div class="reward-row">
<div class="reward"><div class="r-ico">⭐</div><div class="r-lbl">Звёзды</div><div class="stars-earned">${starsHTML}</div></div>
<div class="reward"><div class="r-ico">✨</div><div class="r-lbl">Опыт</div><div class="r-val">+${xpGain} XP</div></div>
${newAchv.length?`<div class="reward"><div class="r-ico">🏅</div><div class="r-lbl">Награды</div><div class="r-val">${newAchv.map(a=>a.icon).join(" ")}</div></div>`:""}
</div>
<div class="breakdown">
${breakRow("Обследования",sc.investigations,"var(--burgundy)")}
${breakRow("Лечение",sc.medications,"var(--teal)")}
${breakRow("Диагноз",sc.diagnosis,"var(--gold)")}
${breakRow("Экстренная помощь",sc.emergency,"var(--rust)")}
</div>
<div class="review">
<h4>📝 Правильный диагноз</h4>
<div class="review-block"><b>${c.diagnosis.options.find(o=>o.c).t}</b>${c.diagnosis.explanation}</div>
<h4>📋 Протокол МЗ РК</h4>
<div class="review-block"><b>${c.protocol}</b>${c.protocolNote}</div>
<h4>🔬 Ключевые данные</h4>
<div class="review-block">${c.extra}</div>
<h4>💡 Ключевые моменты и типичные ошибки</h4>
<div class="keypoints">${c.keyPoints.map(p=>`<div class="kp kp-${p.t==="good"?"good":"bad"}"><span class="kp-ico">${p.t==="good"?"✅":"⚠️"}</span><span>${p.x}</span></div>`).join("")}</div>
</div>
</div>
<div class="step-foot">
<span class="score-chip">Кейс ${state.qIdx+1} из ${state.queue.length} · ${sc.total}%</span>
<button class="btn btn-primary" id="actBtn">${nextLabel()}</button>
</div>
</div>`;
$("actBtn").onclick=nextAfterCase;
examBody.scrollTop=0;
}
function nextLabel(){
if(state.mode==="single") return "Готово";
if(state.qIdx<state.queue.length-1) return "Следующий кейс →";
return "Итог экзамена →";
}
function nextAfterCase(){
stopSpeak();
if(state.mode==="single"){ closeExam(); return; }
if(state.qIdx<state.queue.length-1){ state.qIdx++; loadCase(); return; }
renderFinal();
}
function gradeFor(pct){
if(pct>=85) return {title:"Отлично!",text:"Уверенное владение алгоритмом неотложной помощи."};
if(pct>=70) return {title:"Хорошо",text:"Хороший уровень, но есть моменты для повторения."};
if(pct>=55) return {title:"Удовлетворительно",text:"Базовые навыки есть, но много ошибок — повторите тему."};
return {title:"Неудовлетворительно",text:"Алгоритмы требуют серьёзной проработки."};
}
function colorFor(pct){ return pct>=85?"var(--green)":pct>=70?"var(--teal)":pct>=55?"var(--amber)":"var(--red)"; }
function gradeClass(pct){ return pct>=85?"g5":pct>=70?"g4":pct>=55?"g3":"g2"; }
function renderFinal(){
stopTimer(); stopSpeak();
const scores=state.caseScores;
const avg=Math.round(scores.reduce((a,s)=>a+s.score,0)/scores.length);
const col=colorFor(avg), grade=gradeFor(avg);
// запись в рейтинг
results.push({name:profile.name||"Гость", avatar:profile.avatar, score:avg, date:new Date().toLocaleDateString("ru-RU")});
if(results.length>50) results=results.slice(results.length-50);
save(STORE.results,results);
const themes={};
scores.forEach(s=>{ (themes[s.theme]=themes[s.theme]||[]).push(s.score); });
const themeRows=Object.entries(themes).map(([k,arr])=>{
const t=CASES.find(c=>c.theme===k);
const col=k==="aks"?"var(--burgundy)":k==="odn"?"var(--teal)":"var(--rust)";
return `<div class="ts"><b style="color:${col}">${Math.round(arr.reduce((a,b)=>a+b,0)/arr.length)}%</b><span>${t.themeLabel}</span></div>`;
}).join("");
examBody.innerHTML=`<div class="exam-wrap">
<div class="final">
<div class="final-score">${ring(avg,174,14,col,"var(--gray-100)",`<b>${avg}%</b>`)}</div>
<h2>Итог экзамена</h2>
<div class="grade ${gradeClass(avg)}">${grade.title}</div>
<p class="sub">${grade.text} Вы прошли ${scores.length} кейсов. Результат добавлен в рейтинг.</p>
<div class="theme-summary">${themeRows}</div>
<div class="final-cases">
${scores.map((s,i)=>`<div class="fc-row">
<span class="fc-badge ${s.badge}">${i+1}</span>
<span class="fc-name">${s.title}</span>
<span class="fc-score" style="color:${colorFor(s.score)}">${s.score}%</span>
</div>`).join("")}
</div>
<div class="final-actions">
<button class="btn btn-primary" id="again">🔄 Пройти заново</button>
<button class="btn btn-dark" id="closeFin">Закрыть</button>
</div>
</div>
</div>`;
$("again").onclick=()=>{ startExam("full"); };
$("closeFin").onclick=closeExam;
examBody.scrollTop=0;
}
/* ---- обработчики ---- */
$("filters").addEventListener("click",e=>{ const b=e.target.closest(".filter"); if(!b) return; setFilter(b.dataset.filter); });
$("heroStart").addEventListener("click",()=>document.getElementById("map").scrollIntoView({behavior:"smooth"}));
$("heroFull").addEventListener("click",()=>startExam("full"));
$("startFullExam2").addEventListener("click",()=>startExam("full"));
$("examExit").addEventListener("click",()=>{ if(confirm("Выйти из экзамена? Прогресс кейса не сохранится.")) closeExam(); });
document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&!exam.hidden){ if(confirm("Выйти из экзамена?")) closeExam(); } });
/* ---- старт ---- */
refreshAll();
if(!profile.name){ /* мягко: ничего не навязываем */ }