emergency-cases/app.js

1047 lines
64 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:{
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:{
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:{
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:{
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:{
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:{
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};
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");
speakState.queue=[]; speakState.idx=0;
}
function splitPhrases(text){
return text
.split(/(?<=[.!?…])\s+|(?<=…)\s*/)
.map(s=>s.trim())
.filter(Boolean);
}
function speakPatient(text, opts, btn, avatar){
const synth=window.speechSynthesis;
if(!synth){ alert("Озвучка не поддерживается этим браузером."); 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.playing=true;
btn.classList.add("is-playing"); btn.textContent="⏹ Остановить";
if(avatar) avatar.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];
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 grid=document.getElementById("caseGrid");
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"){
grid.innerHTML="";
CASES.filter(c=>filter==="all"||c.theme===filter).forEach(c=>{
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-foot">
<span class="severity" title="Тяжесть">${sevDots(c.severity)}</span>
<button class="case-btn" data-id="${c.id}">Решить кейс →</button>
</div>`;
el.querySelector(".case-btn").addEventListener("click",e=>{e.stopPropagation(); startExam("single",c.id);});
grid.appendChild(el);
});
}
document.getElementById("filters").addEventListener("click",e=>{
const b=e.target.closest(".filter"); if(!b) return;
document.querySelectorAll(".filter").forEach(f=>f.classList.remove("is-active"));
b.classList.add("is-active");
renderCards(b.dataset.filter);
});
/* =========================================================
Экзамен (полноэкранный wizard)
========================================================= */
const exam=document.getElementById("exam");
const examBody=document.getElementById("examBody");
const examProgressLabel=document.getElementById("examProgressLabel");
const examDots=document.getElementById("examDots");
const stageTrack=document.getElementById("stageTrack");
const STAGES=[
{key:"patient", label:"Пациент"},
{key:"investigations", label:"Обследование"},
{key:"medications", label:"Лечение"},
{key:"diagnosis", label:"Диагноз"},
{key:"emergency", label:"Помощь ⏱"},
{key:"result", label:"Результат"}
];
const STEP_TAGS=["Пациент","Шаг 1","Шаг 2","Шаг 3","Шаг 4","Результат"];
let state=null;
let 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:[], emergencyAuto:false };
exam.hidden=false;
document.body.style.overflow="hidden";
loadCase();
}
function closeExam(){
stopSpeak();
if(timerHandle){ clearInterval(timerHandle); timerHandle=null; }
exam.hidden=true;
document.body.style.overflow="";
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.emergencyAuto=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("");
}
/* ---- SVG кольцо ---- */
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="speak-bar">
<span class="speak-ico">🔊</span>
<span class="speak-text"><b>Жалобы пациента</b><small>Включите звук и нажмите — пациент говорит голосом</small></span>
<button class="speak-btn" id="speakBtn">▶ Прослушать</button>
</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");
const av=root.querySelector("#pAvatar");
if(btn) btn.addEventListener("click",()=>speakPatient(c.complaint, c.patient.voice, btn, av));
}
/* ---- Шаг 0: Пациент ---- */
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);
examBody.querySelector("#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;
const isRadio = false;
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 `<label class="opt ${isRadio?"is-radio":""}" data-i="${i}">
<input type="checkbox" ${checked} ${submitted?"disabled":""}>
<span class="opt-box"></span>
<span class="opt-text">${it.t}</span>
</label>`;
}).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; stopTimer(); }
renderMulti(kind);
if(kind==="emergency"){} // таймер уже не нужен
};
}
if(kind==="emergency" && !submitted){ startTimer(c); }
if(kind==="emergency" && submitted){ /* таймер снят */ }
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];
const labels=examBody.querySelectorAll(".opt");
labels.forEach(lbl=>{
const i=+lbl.dataset.i; const it=items[i]; const 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 sc=multiScore(items,ans);
const ss=examBody.querySelector("#stepScore");
if(ss) ss.textContent=sc+"%";
}
/* ---- Шаг 3: Диагноз ---- */
function renderDiagnosis(){
const c=curCase();
const submitted=state.answers.submitted.d;
const ans=state.answers.diagnosis;
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=ans===i?"checked":"";
return `<label class="opt is-radio" data-i="${i}">
<input type="radio" name="dx" ${checked} ${submitted?"disabled":""}>
<span class="opt-box"></span>
<span class="opt-text">${o.t}</span>
</label>`;
}).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();
const labels=examBody.querySelectorAll(".opt");
labels.forEach(lbl=>{
const i=+lbl.dataset.i; const 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-50)"; 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(){
const pct=left/total*100;
const color = left<=10 ? "var(--red)" : "var(--cyan)";
const danger = left<=10 ? ' <span class="danger">осталось мало времени!</span>' : "";
tw.innerHTML=`
${ring(pct,74,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.emergencyAuto=true;
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 renderCaseResult(){
const c=curCase();
const sc=computeCaseScore(c);
state.caseScores.push({id:c.id, title:c.title, theme:c.theme, themeLabel:c.themeLabel, badge:c.badge, score:sc.total, breakdown:sc});
const grade=gradeFor(sc.total);
const col=colorFor(sc.total);
examBody.innerHTML=`
<div class="exam-wrap">
<div class="result">
<div class="result-head">
<div class="score-ring">${ring(sc.total,150,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="breakdown">
${breakRow("Обследования",sc.investigations,"var(--blue)")}
${breakRow("Лечение",sc.medications,"var(--purple)")}
${breakRow("Диагноз",sc.diagnosis,"var(--cyan)")}
${breakRow("Экстренная помощь",sc.emergency,"var(--red)")}
</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>`;
examBody.querySelector("#actBtn").onclick=nextAfterCase;
examBody.scrollTop=0;
}
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 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){
if(pct>=85) return "var(--green)";
if(pct>=70) return "var(--cyan)";
if(pct>=55) return "var(--amber)";
return "var(--red)";
}
function gradeClass(pct){ return pct>=85?"g5":pct>=70?"g4":pct>=55?"g3":"g2"; }
/* ---- Итоговый экран экзамена ---- */
function renderFinal(){
stopTimer();
const scores=state.caseScores;
const avg=Math.round(scores.reduce((a,s)=>a+s.score,0)/scores.length);
const col=colorFor(avg);
const grade=gradeFor(avg);
// по темам
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);
return `<div class="ts"><b style="color:${k==="aks"?"var(--red)":k==="odn"?"var(--blue)":"var(--orange)"}">${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,180,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} из ${state.queue.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>`;
examBody.querySelector("#again").onclick=()=>{ startExam("full"); };
examBody.querySelector("#closeFin").onclick=closeExam;
examBody.scrollTop=0;
}
/* ---- Кнопки запуска экзамена ---- */
document.getElementById("startFullExam").addEventListener("click",()=>startExam("full"));
document.getElementById("startFullExam2").addEventListener("click",()=>startExam("full"));
document.getElementById("examExit").addEventListener("click",()=>{
if(confirm("Выйти из экзамена? Текущий прогресс будет потерян.")) closeExam();
});
document.addEventListener("keydown",e=>{ if(e.key==="Escape" && !exam.hidden){ if(confirm("Выйти из экзамена?")) closeExam(); } });
/* ---- старт ---- */
renderCards();