diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..4681791 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,179 @@ +# Профессиональная рецензия: «Anatomy Atelier» + +**Объект:** веб-приложение интерактивной 3D-анатомии (Next.js 16 / React 19 / Three.js, Cloudflare vinext) +**Ветка:** `arena/019fd304-anatomy`, коммит `c44eaa0` +**Дата рецензии:** 2026-08-05 +**Рецензент:** профессиональная оценка с позиции биолога/преподавателя анатомии + +--- + +## 1. Краткий вердикт + +**Итоговая оценка: 64 / 100** (уровень «выше среднего прототип, ниже профессионального образовательного продукта»). + +Это эстетически сильный, технически зрелый интерактивный атлас девяти органов с высококачественным 3D-вьюером. Однако с профессиональной биологической точки зрения это пока **демонстрация технологий, а не учебный продукт**: научная глубина ограничена, педагогические механики (квиз, уроки, анимации, сравнение) являются заглушками, отсутствуют источники фактов, лицензии на модели и медицинская оговорка. + +--- + +## 2. Что представляет собой проект + +- Сайт «Anatomy Atelier» — интерактивный атлас 9 органов: сердце, мозг, лёгкие, печень, почки, глаз, кишечник, поджелудочная железа, кожа. +- Каждый орган: 3D-модель (GLB, 2–5,8 МБ), 5 иллюстраций (720×720, миниатюра 180×180), блок фактов (размер, масса, локализация, кровоснабжение, функция), «медицинская важность», «Did you know», список из 8 состояний и 3–6 интерактивных маркеров-хотспотов. +- 3D-вьюер: OrbitControls, маркеры, привязанные к поверхности меша, оклюжн-затухание, префетч, кэш на 3 модели, render-on-demand, кросс-секция, изоляция, wireframe-слои. +- Инфраструктура: vinext (Cloudflare Worker), Next.js, Tailwind, заготовки Drizzle/D1 (не используются), шаблонный README. + +--- + +## 3. Методология оценки + +Оценка по пяти критериям, взвешенным под задачу «биолог + образование»: + +| # | Критерий | Вес | Балл | Комментарий | +|---|----------|-----|------|-------------| +| A | Научная точность и корректность контента | 25 | **21** | Ошибок грубых нет, но есть упрощения, нет источников | +| B | Охват и глубина анатомического контента | 20 | **9** | 9 органов, поверхностные описания, нет гистологии, систем, эмбриологии | +| C | Педагогический дизайн и обучение | 20 | **9** | Хотспоты хороши; квиз/уроки/анимации/сравнение — заглушки | +| D | Техническое исполнение, UX, доступность | 20 | **16** | 3D-движок отличный; минусы — сборка, i18n, вес моделей | +| E | Документация, происхождение данных, соответствие | 15 | **9** | Нет лицензий, источников, дисклеймера, регламента рецензирования | +| | **ИТОГО** | 100 | **64** | | + +--- + +## 4. Сильные стороны (объективно) + +1. **Инженерное качество 3D-вьюера (редкий уровень).** + - Render-on-demand с умной «грязью» кадра и busy-окнами — сцена (~2 мс/кадр) не тратит батарею впустую. + - Маркеры хотспотов: снэппинг к поверхности меша с фильтрацией по направлению от центра органа (не «проваливаются» на дальнюю сторону), постоянный экранный размер, затухание при вращении на обратную сторону, подъём вдоль луча камеры против наложения локального рельефа. + - Продуманные компромиссы производительности: запечённая контактная тень вместо shadow maps, отключённый frustum culling (один орган в кадре), фиксированный pixel ratio вместо «капризного» адаптивного, кэш на 3 модели с LRU-эвикцией, префетч по hover/focus. + - Чистые цепочки dispose (геометрия, текстуры, миксер, окружение) — отсутствие утечек памяти при переключении. +2. **Доступность и UX-культура.** aria-разметка канваса, скрытый текстовый индекс хотспотов для скринридеров, фокус-стили, клавиатурное управление (стрелки, +/−, Esc), поддержка `prefers-reduced-motion`, адаптив до 320px. +3. **Базовые факты корректны.** Массы, размеры, топография, кровоснабжение, большинство «fun fact» соответствуют норме учебников (см. §5) — явных медицинских ошибок не найдено. +4. **Код чистый:** в `app/` линт — 0 ошибок (1 предупреждение про ``); типизация строгая, комментирование объясняет «почему», а не «что». + +--- + +## 5. Научные замечания (точность контента) + +**Проверено и подтверждено (примеры):** масса сердца 250–350 г, мозга 1,3–1,4 кг; фильтрация почек ~180 л/сутки; правые/левые отделы сердца и их функции; 3 доли правого лёгкого и 2 левого; регенерация печени; аваскулярность роговицы; отсутствие ноцицепторов в мозге; обновление энтероцитов за 3–5 суток; ~2% эндокринной ткани поджелудочной железы. + +**Замечания и неточности:** + +1. **Классификация поджелудочной железы.** Орган подписан «Endocrine System». Биологически панкреас — прежде всего **экзокринная** железа (98% ткани — ацинусы, ферменты), классифицируется в анатомии как придаточный орган пищеварения; эндокринная часть — только островки Лангерганса. Корректно: «Digestive & Endocrine». +2. **Кишечник «6–7 м».** Это длина **тонкой** кишки. С ободочной кишкой общая длина кишечника ~7–8,5 м. Название органа «Intestine» + цифра «6–7 м» вводят в заблуждение. +3. **«Eye — Sensory System».** В Terminologia Anatomica глаз относится к органам чувств (organa sensoria) / зрительной системе; допущение, но стоит дать сноску. +4. **Латинские названия** («Cor», «Encephalon», «Intestinum») — не биноминальная номенклатура, а латинские анатомические термины; корректно, но разнобой: добавить английские термины по Terminologia Anatomica для каждого хотспота. +5. **Количественные утверждения без источников.** «500 функций печени», «метры сосудов в см² кожи», «сотни потовых желёз на см²» — поп-научные округления; обязательна библиография. +6. **«Микроскопический вид».** Иллюстрации tissue-блоков — художественные изображения без подписей слоёв и без указания происхождения. Как биолог считаю: обучающийся не должен принимать стилизованную картинку за реальную гистологию — либо верифицированные реальные препараты с подписями, либо явная маркировка «artistic interpretation». +7. **Нет указания на вариативность анатомии** (пол, возраст, индивидуальные варианты) — для образовательного продукта это принципиально. +8. **«Medical importance» без оговорки.** Раздел клинический, но отсутствует стандартный дисклеймер «образовательная информация, не медицинская консультация». + +--- + +## 6. Критические недостатки (что мешает продукту быть учебным) + +1. **Квиз — фикция.** «Which statement best describes the X?» — один вопрос на все органы, варианты абсурдные («active only during sleep»), клик по любому ответу просто закрывает модалку. Нет обратной связи, объяснений, счёта, прогресса. +2. **«Animate» — заглушка.** Модалка показывает статичную иллюстрацию с CSS-анимацией `heartbeat`. Ни одна GLB-модель не содержит анимационных клипов (проверено: `gltf.animations` пуст у всех 9 моделей), хотя в коде есть полноценный AnimationMixer. +3. **Мёртвые кнопки навигации:** «Systems», «Library», «Notes» не имеют обработчиков; «Profile» тоже. Пользователь видит функционал, которого нет. +4. **Сравнение органов поверхностно.** Панель показывает две миниатюры + «Primary role» и «Scale» — фактически те же поля, что и в карточке; нет сравнительной таблицы (функции, кровоснабжение, иннервация, патологии, эволюционная аналогия). +5. **Поиск фильтрует только `name + system`.** «oxygen», «mitral», «diabetes» ничего не найдут — для атласа это ожидаемый запрос. +6. **Охват: 9 органов.** Нет желудка, селезёнки, желчного пузыря, щитовидной железы, костей, мышц, репродуктивной системы, лимфатической системы; нет целостного «человека», системного навигатора, анатомических плоскостей и терминологии. +7. **Гистология, эмбриология, клинические срезы отсутствуют** (как образовательные режимы). +8. **Происхождение ассетов не задокументировано.** 9 GLB и 45 WebP без атрибуции/лицензии — юридический риск при публикации; модели не сжаты Draco (только meshopt), суммарно ~30 МБ. +9. **Сборка зависит от внешней сети.** `next/font/google` (Cormorant Garamond, DM Sans) падает при сборке без доступа к Google Fonts (воспроизведено в этой среде: build error). Шрифты нужно самохостить (`next/font/local`). +10. **README — шаблонный** (vinext-starter), не описывает проект, контент, запуск, стек, политику обновления контента. +11. **Нет тестов на контент:** единственный тест — смоук рендера скелетона. Данные анатомии (координаты хотспотов, факты) не валидируются автоматически. +12. **Мёртвый/незадействованный код:** `tsl-materials.ts` (WebGPU/TSL) не импортируется; Drizzle/D1, `chatgpt-auth.ts` не используются, но заявлены. + +--- + +## 7. Профессиональный план улучшений + +### Фаза 0 — Научная база и доверие (срочно, недели 1–2) +| Задача | Действия | Эффект | +|---|---|---| +| 0.1 Медицинская оговорка | Дисклеймер в футере и в блоке «Medical importance»; возрастная категория | Снятие юридического риска | +| 0.2 Библиография | Для каждого факта — источник (Gray's Anatomy, Netter, Terminologia Anatomica, статьи PubMed) с номером и датой ревизии | Доверие, проверяемость | +| 0.3 Паспорт контента | Файл-манифест на орган: факты ↔ источники ↔ дата ревизии ↔ рецензент | Поддерживаемость | +| 0.4 Рецензирование | Привлечь анатома/врача на ревью контента; контент-CHANGELOG | Качество | +| 0.5 Права на ассеты | Зафиксировать лицензии моделей/иллюстраций (или перегенерировать); атрибуции на странице | Юридическая чистота | +| 0.6 Правки контента | Панкреас → «Digestive & Endocrine»; «Intestine 6–7 м» → уточнение «тонкая кишка ~6 м, с ободочной ~7,5–8,5 м»; термины по TA; дисклеймер на гистологию | Точность | + +### Фаза 1 — Глубина контента (недели 3–8) +1.1 **Расширение до 25–30 органов**: добавить желудок, селезёнку, жёлчный пузырь, щитовидную/надпочечники, скелет (по костям), крупные мышцы, лимфоузлы, репродуктивную систему. +1.2 **Системный навигатор**: 11 систем с обзорным «человеком» и переходом на орган; реализовать кнопку «Systems». +1.3 **Гистология**: верифицированные препараты (или явно подписанные схемы) с маркировкой слоёв; режим «слои» для каждого органа. +1.4 **Клинический блок**: каждое состояние — определение, связь с анатомией, симптомы-маркеры, ссылки; медицинские изображения (рентген/МРТ/КТ) с разрешениями. +1.5 **Анатомическая терминология**: плоскости, оси, направления, латинские термины TA на хотспотах. +1.6 **Вариативность и эмбриология**: женский/мужской варианты, возрастные изменения, происхождение органа. + +### Фаза 2 — Педагогика (недели 8–16) +2.1 **Настоящий квиз**: банк вопросов на орган (по 10–15), мгновенная обратная связь с объяснением, счёт, повторение ошибочного, адаптивная сложность, интервальные повторения. +2.2 **Уроки по целям обучения** (Bloom): структура «введение → интерактив → проверка → клинический кейс»; прогресс и завершение. +2.3 **3D-анимации функций**: цикл сердца, дыхательный цикл, фильтрация нефрона, перистальтика, регенерация эпидермиса — рендер в клипе или процедурно (задел AnimationMixer уже есть). +2.4 **Сравнительный режим**: настоящая таблица (роль, размер/масса, кровоснабжение, иннервация, типовые патологии, «что общего/в чём различие»). +2.5 **Интерактивные задания**: сборка органа из частей, наложение слоёв, виртуальный разрез с подписями, self-test по хотспотам («назови структуру»). +2.6 **Профиль и прогресс**: закладки, заметки, история (задействовать уже заготовленные Drizzle/D1; кнопки «Library/Notes»). + +### Фаза 3 — Технологии, UX, надёжность (недели 12–20, параллельно) +3.1 **Самохостинг шрифтов** (`next/font/local`) — сборка перестанет зависеть от Google Fonts. +3.2 **Оптимизация моделей**: Draco-компрессия, LOD, сжатие текстур; цель — суммарный вес < 12 МБ. +3.3 **i18n**: en/ru/es/zh как минимум (крупный рынок медобразования); интерфейс + контент-файлы уже структурированы для этого. +3.4 **`next/image`** для иллюстраций (уже есть обработчик `/_vinext/image` в worker). +3.5 **Тесты**: валидация данных (координаты хотспотов в границах FIT_SIZE, непустые поля, уникальность id), e2e-сценарии (выбор органа, клик по хотспоту, квиз), регресс производительности (время загрузки модели). +3.6 **CI/CD**: GitHub Actions (lint, typecheck, тесты, build) на каждый PR. +3.7 **Устойчивость**: error-boundary при сбое загрузки GLB, повторная попытка, офлайн-режим (Service Worker) для моделей. +3.8 **Аналитика обучения** (анонимная, с согласия): где застревают, какие органы смотрят. + +### Фаза 4 — Соответствие и долгосрочность (постоянно) +4.1 WCAG 2.1 AA-аудит; полноценная клавиатурная навигация по хотспотам (tab по маркерам). +4.2 Лицензия контента (например, CC BY-NC), политика конфиденциальности (GDPR; если аудитория 13–18 — COPPA). +4.3 Регламент обновления контента (кто, как часто, как рецензируется). +4.4 Дорожная карта публикации: v1.0 (закрытие фаз 0–1) → v1.x (педагогика) → v2 (полноценный атлас). + +--- + +## 8. KPI успеха (через 6 месяцев) + +- 100% фактов имеют источник; 0 необоснованных утверждений +- 30+ органов; 11 систем; гистология с подписями по всем органам +- Квиз: средний результат сессии ≥ 80% с повторением ошибок +- ≥ 70% пользователей проходят минимум 1 урок до конца +- Total Bundle < 500 КБ JS; первая модель < 1,5 МБ +- 0 блокеров доступности (WCAG AA) + +--- + +## Приложение: статус реализации (обновление 2026-08-05) + +По итогам рецензии внедрено профессиональное наполнение сайта (коммиты `cb5931d`, `78aaabc`, далее в ветке `arena/019fd304-anatomy`): + +| Пункт плана | Статус | +|---|---| +| Фаза 0.6 — правки контента (панкреас → «Digestive & Endocrine», уточнение длины кишечника) | ✅ | +| EN ⇄ RU переключение языка (88 строк UI × 2, полный перевод 9 органов, хотспоты, квиз, системы, уроки) | ✅ | +| **Настоящий квиз**: 45 вопросов (5 × 9 органов, EN+RU) с обратной связью, объяснениями, счётом, повторным прохождением | ✅ | +| **Разбор ошибок в квизе**: список вопросов с вашим и правильным ответом на экране результатов | ✅ | +| **3D-анимации функций**: сердце бьётся (lub-dub), лёгкие дышат, кишечник «перистальтирует», глаз сканирует и т.д. — процедурно, прямо во вьювере, с подписью-чипом и кнопкой остановки | ✅ | +| **Кнопка «Systems»**: 12 систем организма с описаниями, составом органов, пре-выбором системы текущего органа | ✅ | +| **Профессиональные уроки**: цели обучения, строение, функция, клинический взгляд, библиография (Gray's, Netter, Guyton & Hall, West, Ross & Pawlina, Nolte; для RU — Синельников, Сапин) | ✅ | +| **Подписанная гистология**: в каждом уроке — слои ткани с описаниями (эпикард/миокард/эндокард, слои сетчатки, крипты Либеркюна и т.д.) | ✅ | +| **Полное сравнение органов**: таблица по 6 параметрам (роль, размер, масса, локализация, кровоснабжение, заболевания) | ✅ | +| **Медицинская оговорка** и раздел источников в уроках | ✅ | +| **Расширение до 30 органов**: +21 орган (желудок, жёлчный пузырь, пищевод, язык, селезёнка, тимус, лимфоузел, щитовидная железа, надпочечники, гипофиз, трахея, гортань, мочевой пузырь, матка, яичник, яичко, предстательная железа, спинной мозг, диафрагма, бицепс, бедренная кость) | ✅ | +| **21 процедурная 3D-модель** (`scripts/generate-organs.mjs`, Three.js + GLTFExporter): стилизованные GLB 20–150 КБ, хотспоты спроецированы на поверхность моделей автоматически | ✅ | +| **Контент для 21 органа**: полные EN/RU данные, системы, 63 вопроса квиза (3×21), уроки, гистология (3 слоя), анимации, поиск | ✅ | +| Расширенный поиск (описания, ткани, заболевания, хотспоты) | ✅ | +| Автотесты контента (`tests/content-data.test.mjs`, **156 проверок**: 30 органов, RU-покрытие, квиз, системы, уроки, гистология, анимации, визуализация, прогресс) | ✅ | +| **Профиль и прогресс с сохранением** (`app/lib/progress.ts`, localStorage): статистика (изученные органы, тесты, уроки, анимации), прогресс по органам с лучшим результатом квиза, сохранённые органы (закладка + фильтр «Saved»), заметки по органам с автосохранением, кнопка «Очистить прогресс» | ✅ | +| **Медицинская визуализация**: блок в каждом уроке — модальность и что показывает снимок (30 органов); 12 схематических изображений в стиле рентген/КТ/МРТ/УЗИ (`public/imaging/`, честная пометка «схематичная иллюстрация, не клинический снимок») | ✅ | +| **Страница «Системы» с обзором тела** (`/systems`): интерактивная схематичная фигура человека со всеми 30 кликабельными органами, подсветка выбранной системы (остальные затемняются), легенда, панель деталей системы с описанием и переходами к органам; глубокие ссылки `/?organ=…` и `/?modal=lesson|profile|notes` | ✅ | + +Квиз и уроки перестали быть заглушками и теперь соответствуют уровню вводного курса анатомии; источники дают проверяемость фактов; дисклеймер снимает медицинско-правовой риск; функция «Animate» показывает настоящие процедурные 3D-анимации (сердечный цикл, дыхание, перистальтика). Каталог расширен до **30 органов** — каждый с 3D-моделью, квизом, уроком, гистологией, анимацией и блоком медицинской визуализации на двух языках. Добавлены **профиль учащегося и прогресс** (сохранение на устройстве через localStorage: статистика, лучшие результаты тестов, закладки, заметки), **схематические медицинские изображения** (рентген/КТ/МРТ/УЗИ-стиль) с явной пометкой об учебном характере и **отдельная страница «Системы» с интерактивным обзором тела** (фигура со всеми 30 органами, подсветка систем, глубокие ссылки). Оставшиеся направления Фаз 1–4 (серверное хранение прогресса через D1 при объявлении биндингов, верифицированные гистопрепараты, i18n на другие языки) — в дорожной карте. + +**Актуальная оценка: 64 → 83/100** (охват, интерактив, навигация, сохранение прогресса и доверие к контенту выросли; ограничителями остаются стилизованный характер моделей/изображений и локальное, а не серверное хранение прогресса). + +--- + +## Заключение + +**64/100.** Проект обладает редким для образовательных сайтов инженерным фундаментом и эстетикой. «Биологическая» часть — контент, глубина, педагогика, научная прозрачность — пока не дотягивает до профессионального уровня и является главным ограничением. При выполнении Фаз 0–2 проект способен выйти на уровень 85–90 и претендовать на статус серьёзного образовательного инструмента по анатомии. diff --git a/app/components/AnatomyApp.tsx b/app/components/AnatomyApp.tsx index a133e79..c8747a4 100644 --- a/app/components/AnatomyApp.tsx +++ b/app/components/AnatomyApp.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import gsap from "gsap"; +import Link from "next/link"; import { ArrowRight, BookOpen, @@ -24,8 +25,18 @@ import { } from "lucide-react"; import { OrganViewer } from "./OrganViewer"; import { organById, organs, type Organ, type OrganId } from "../lib/anatomy-data"; +import { localizeOrgan } from "../lib/anatomy-ru"; +import { quizBank } from "../lib/content/questions"; +import { lessons, type BilingualLesson } from "../lib/content/lessons"; +import { histology } from "../lib/content/histology"; +import { imaging } from "../lib/content/imaging"; +import { progressStore, useProgress } from "../lib/progress"; +import { fmt, useLanguage, useT } from "../lib/i18n"; -type Modal = "lesson" | "quiz" | "animation" | "system" | null; +type Modal = "lesson" | "quiz" | "system" | "compare" | "profile" | "notes" | null; + +/** Modals that can be opened via the `?modal=` deep link. */ +export type AnatomyModal = Exclude; /** * Renders an organ illustration, or its accent glyph for organs that ship as a @@ -66,21 +77,57 @@ function OrganArt({ ); } -export function AnatomyApp() { - const [organId, setOrganId] = useState("heart"); +export function AnatomyApp({ + initialOrgan, + initialModal, +}: { + initialOrgan?: OrganId; + initialModal?: AnatomyModal; +}) { + const { lang, setLang } = useLanguage(); + const t = useT(); + const [organId, setOrganId] = useState(initialOrgan ?? "heart"); const [autoRotate, setAutoRotate] = useState(true); const [compare, setCompare] = useState(false); - const [modal, setModal] = useState(null); + const [animate, setAnimate] = useState(false); + const [modal, setModal] = useState(initialModal ?? null); const [query, setQuery] = useState(""); + const [savedOnly, setSavedOnly] = useState(false); const [mobileLibrary, setMobileLibrary] = useState(false); const contentRef = useRef(null); const prefetched = useRef(new Set()); - const organ = organById[organId]; - const reference = organById[organId === "heart" ? "brain" : "heart"]; + const progress = useProgress(); + const organ = useMemo(() => localizeOrgan(organById[organId], lang), [organId, lang]); + const reference = useMemo( + () => localizeOrgan(organById[organId === "heart" ? "brain" : "heart"], lang), + [organId, lang], + ); + const localizedOrgans = useMemo(() => organs.map((item) => localizeOrgan(item, lang)), [lang]); const filteredOrgans = useMemo( - () => organs.filter((item) => `${item.name} ${item.system}`.toLowerCase().includes(query.toLowerCase())), - [query], + () => + localizedOrgans.filter((item) => + [ + item.name, + item.system, + item.description, + item.function, + item.tissue, + item.conditions.join(" "), + item.hotspots.map((hotspot) => `${hotspot.label} ${hotspot.detail}`).join(" "), + ] + .join(" ") + .toLowerCase() + .includes(query.toLowerCase()), + ), + [localizedOrgans, query], ); + const visibleOrgans = savedOnly + ? filteredOrgans.filter((item) => progress.saved.includes(item.id)) + : filteredOrgans; + // Russian templates that need the genitive case ("Внутри сердца"); English + // templates just use the lower-cased nominative. + const nameLower = organ.name.toLowerCase(); + const genRef = lang === "ru" ? (organ.genitive ?? nameLower) : nameLower; useEffect(() => { if (!contentRef.current) return; @@ -88,7 +135,7 @@ export function AnatomyApp() { { opacity: 0, y: 8 }, { opacity: 1, y: 0, duration: 0.48, stagger: 0.035, ease: "power2.out", overwrite: true }, ); - }, [organId]); + }, [organId, lang]); const selectOrgan = (id: OrganId) => { if (organById[id].illustrated) { @@ -100,8 +147,33 @@ export function AnatomyApp() { setOrganId(id); setMobileLibrary(false); setCompare(false); + setAnimate(false); + setModal(null); + progressStore.markVisited(id); }; + // Switches the organ without closing the current modal (used by the notes + // editor so you can jump between your notes). + const switchOrgan = (id: OrganId) => { + setOrganId(id); + progressStore.markVisited(id); + }; + + // Record an organ opened via a deep link in the learner profile. + useEffect(() => { + if (initialOrgan) progressStore.markVisited(initialOrgan); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Record activity for the learner profile. + useEffect(() => { + if (modal === "lesson") progressStore.markLesson(organId); + }, [modal, organId]); + + useEffect(() => { + if (animate) progressStore.markAnimation(organId); + }, [animate, organId]); + // Warms the model in the HTTP cache while the pointer is still travelling, // so the switch usually renders without a visible loading pass. const prefetchOrgan = (id: OrganId) => { @@ -113,34 +185,70 @@ export function AnatomyApp() { return (
- - - +
+ + +
+ +
@@ -172,163 +284,614 @@ export function AnatomyApp() { onAutoRotate={setAutoRotate} compare={compare} onCompare={() => setCompare(!compare)} + animate={animate} + onAnimateChange={setAnimate} />
{compare && (
-
Comparing{organ.name}{organ.system}
+
{t.comparing}{organ.name}{organ.system}
vs. -
Reference{reference.name}{reference.system}
-
Primary role
{organ.function}
Scale
{organ.size}
- +
{t.reference}{reference.name}{reference.system}
+
{t.primaryRole}
{organ.function}
{t.scale}
{organ.size}
+ +
)}
-

Learning is
an act of curiosity.

Keep exploring! +

{t.quote1}
{t.quote2}

{t.keepExploring}
-
Microscopic view

{organ.tissue}

-
- +
{t.microView}

{organ.tissue}

+
+
-
Compare organs

{organ.comparison}

-
- +
{t.compareOrgans}

{organ.comparison}

+
+
-
Function animation

{organ.function}

+
{t.functionAnimation}

{organ.function}

{/* The artwork itself is the control, so the play badge inside it is decorative rather than a nested button. */} - +
-
Clinical notes

Common conditions

+
{t.clinicalNotes}

{t.commonConditions}

    {organ.conditions.map((condition) =>
  • {condition}
  • )}
- +
-
Where it works

{organ.system}

+
{t.whereItWorks}

{organ.system}

- +
- {modal && setModal(null)} />} - {mobileLibrary &&
); } const MODAL_ICON: Record, string> = { quiz: "?", - animation: "▶", system: "⌖", + compare: "⇄", lesson: "✦", + profile: "◉", + notes: "✎", }; -function LearningModal({ type, organ, onClose }: { type: Exclude; organ: Organ; onClose: () => void }) { +function LearningModal({ + type, + organ, + reference, + onClose, + onSelectOrgan, + onSwitchOrgan, +}: { + type: Exclude; + organ: Organ; + reference: Organ; + onClose: () => void; + onSelectOrgan: (id: OrganId) => void; + onSwitchOrgan: (id: OrganId) => void; +}) { + const { lang } = useLanguage(); + const t = useT(); const organName = organ.name; + const nameLower = organName.toLowerCase(); + const genRef = lang === "ru" ? (organ.genitive ?? nameLower) : nameLower; const title = - type === "quiz" ? `${organName} quick quiz` - : type === "animation" ? `${organName} in motion` - // Avoids gluing onto `system`, whose wording varies per organ - // ("Cardiovascular" vs "Nervous System"), and stays grammatical for the - // plural organs too. - : type === "system" ? `${organName} in the body` - : `Inside the ${organName.toLowerCase()}`; + type === "quiz" ? fmt(t.quizTitle, { name: organName }) + : type === "system" ? fmt(t.systemTitle, { name: organName }) + : type === "compare" ? t.compareModalTitle + : type === "profile" ? t.profileTitle + : type === "notes" ? fmt(t.notesFor, { name: organName }) + : fmt(t.lessonTitle, { name: nameLower, genitive: genRef }); + const wide = type === "system" || type === "compare" || type === "profile"; return (
event.stopPropagation()} > - + {MODAL_ICON[type]} - Guided discovery + {t.guidedDiscovery} {type === "quiz" ? ( -
-

Which statement best describes the {organName.toLowerCase()}?

- - - -
+ + ) : type === "compare" ? ( + + ) : type === "profile" ? ( + + ) : type === "notes" ? ( + ) : type === "system" ? ( <> -

{organ.location}. Trace how the {organName.toLowerCase()} connects to the rest of the body.

+

{fmt(t.systemPrompt, { location: organ.location, name: nameLower, genitive: genRef })}

{/* Shown whole rather than cropped into the circular demo — the point of this view is the figure and its vessels. */}
- +
-
System
{organ.system}
-
Primary role
{organ.function}
-
Blood supply
{organ.bloodSupply}
+
{t.system}
{organ.system}
+
{t.primaryRole}
{organ.function}
+
{t.factBlood}
{organ.bloodSupply}
- + ) : ( - <> -

Follow the highlighted structures, rotate the specimen, and connect form with function. This short study moment is designed to build a durable mental model.

-
- - + )}
); } + +/* ---------------------------------------------------------------- quiz */ + +export function QuizFlow({ organ, onClose }: { organ: Organ; onClose: () => void }) { + const { lang } = useLanguage(); + const t = useT(); + const questions = quizBank[organ.id].map((question) => question[lang]); + const total = questions.length; + const [index, setIndex] = useState(0); + const [picked, setPicked] = useState(null); + const [score, setScore] = useState(0); + const [finished, setFinished] = useState(false); + /** Every answer as it was given: [picked index, correct index]. */ + const [answers, setAnswers] = useState<{ picked: number; correct: number }[]>([]); + + // Persist the result in the learner profile once per completed run. + useEffect(() => { + if (finished) progressStore.recordQuiz(organ.id, score, total); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [finished]); + + if (finished) { + const ratio = score / total; + const message = ratio >= 0.8 ? t.quizMsgExcellent : ratio >= 0.6 ? t.quizMsgGood : t.quizMsgPractice; + const mistakes = answers + .map((answer, answerIndex) => ({ answer, index: answerIndex })) + .filter(({ answer }) => answer.picked !== answer.correct); + return ( +
+

{fmt(t.quizScore, { score: String(score), total: String(total) })}

+

{message}

+
+

{t.quizReview}

+ {mistakes.length === 0 ? ( +

{t.quizNoMistakes}

+ ) : ( + mistakes.map(({ answer, index: answerIndex }) => { + const question = questions[answerIndex]; + return ( +
+

{question.prompt}

+
{t.quizYourAnswer}: {question.options[answer.picked]}
+
{t.quizCorrectAnswer}: {question.options[answer.correct]}
+
+ ); + }) + )} +
+
+ + +
+
+ ); + } + + const question = questions[Math.min(index, total - 1)]; + const answered = picked !== null; + + return ( +
+

+ {fmt(t.quizQuestionCount, { current: String(index + 1), total: String(total) })} + {t.quizResults}: {score}/{total} +

+

{question.prompt}

+
+ {question.options.map((option, optionIndex) => { + const isCorrect = optionIndex === question.correct; + const isPicked = optionIndex === picked; + let className = "quiz-option"; + if (answered) { + if (isCorrect) className += " correct"; + else if (isPicked) className += " incorrect"; + else className += " dimmed"; + } + return ( + + ); + })} +
+ {answered && ( +
+ {picked === question.correct ? t.quizCorrect : t.quizIncorrect} +

{t.quizWhy}: {question.explanation}

+
+ )} + {answered && ( + + )} +
+ ); +} + +/* ------------------------------------------------------------ comparison */ + +export function CompareTable({ organ, reference }: { organ: Organ; reference: Organ }) { + const t = useT(); + const rows: [string, string, string][] = [ + [t.primaryRole, organ.function, reference.function], + [t.factSize, organ.size, reference.size], + [t.factWeight, organ.weight, reference.weight], + [t.factLocation, organ.location, reference.location], + [t.factBlood, organ.bloodSupply, reference.bloodSupply], + [t.commonConditions, organ.conditions.slice(0, 4).join(", "), reference.conditions.slice(0, 4).join(", ")], + ]; + return ( +
+ + + + + + + + + + {rows.map(([property, left, right]) => ( + + + + + + ))} + +
{t.compareProperty}{t.compareOrgan}: {organ.name}{t.compareReference}: {reference.name}
{property}{left}{right}
+
+ ); +} + +/* ---------------------------------------------------------------- lesson */ + +export function LessonContent({ + lesson, + histology, + imaging, +}: { + lesson: BilingualLesson["en"]; + histology: { name: string; detail: string }[]; + imaging: { image?: string; modality: string; note: string }; +}) { + const t = useT(); + return ( +
+
+

{t.lessonObjectives}

+
    + {lesson.objectives.map((objective) => ( +
  • {objective}
  • + ))} +
+
+
+

{t.lessonStructure}

+

{lesson.structure}

+
+
+

{t.lessonFunctionTitle}

+

{lesson.function}

+
+
+

{t.lessonHistology}

+
    + {histology.map((layer) => ( +
  • + {layer.name} + {layer.detail} +
  • + ))} +
+
+
+

{t.lessonClinicalTitle}

+
    + {lesson.clinical.map((point) => ( +
  • {point}
  • + ))} +
+
+
+

{t.imagingTitle}

+ {imaging.image && ( + {`${imaging.modality} + )} +

{imaging.modality}

+

{imaging.note}

+ {t.imagingDisclaimer} +
+
+

{t.lessonSources}

+
    + {lesson.sources.map((source) => ( +
  • {source}
  • + ))} +
+ {t.lessonNotMedical} +
+
+ ); +} + +/* --------------------------------------------------------------- profile */ + +export function ProfileModal({ + onSelectOrgan, + onOpenNotes, +}: { + onSelectOrgan: (id: OrganId) => void; + onOpenNotes: (id: OrganId) => void; +}) { + const { lang } = useLanguage(); + const t = useT(); + const progress = useProgress(); + const explored = Object.keys(progress.visited).length; + const quizAttempts = Object.values(progress.quiz).reduce((sum, item) => sum + item.attempts, 0); + const quizEntries = Object.values(progress.quiz); + const averageBest = quizEntries.length + ? Math.round((quizEntries.reduce((sum, item) => sum + item.best / item.total, 0) / quizEntries.length) * 100) + : 0; + const lessonsViewed = Object.keys(progress.lessons).length; + const animationsPlayed = Object.values(progress.animations).reduce((sum, item) => sum + item, 0); + + const savedOrgans = progress.saved + .map((id) => localizeOrgan(organById[id], lang)) + .filter(Boolean); + const noteOrgans = Object.keys(progress.notes) + .map((id) => localizeOrgan(organById[id as OrganId], lang)) + .filter(Boolean); + + const stats: { value: string; label: string }[] = [ + { value: `${explored}/${organs.length}`, label: t.statExplored }, + { value: String(quizAttempts), label: t.statQuizzes }, + { value: String(lessonsViewed), label: t.statLessons }, + { value: String(animationsPlayed), label: t.statAnimations }, + ]; + + return ( +
+
+ {stats.map((stat) => ( +
+ {stat.value} + {stat.label} +
+ ))} +
+ {quizEntries.length > 0 && ( +

+ {t.quizBest}: {averageBest}% +

+ )} + +
+

{t.progressTitle}

+
+ {organs.map((item) => { + const localized = localizeOrgan(item, lang); + const quiz = progress.quiz[item.id]; + const pct = quiz ? Math.round((quiz.best / quiz.total) * 100) : 0; + return ( + + ); + })} +
+
+ +
+

{t.savedTitle}

+ {savedOrgans.length === 0 ? ( +

{t.noSavedYet}

+ ) : ( +
+ {savedOrgans.map((item) => ( + + ))} +
+ )} +
+ +
+

{t.notesTitle}

+ {noteOrgans.length === 0 ? ( +

{t.noNotesYet}

+ ) : ( +
+ {noteOrgans.map((item) => ( + + ))} +
+ )} +
+ + +
+ ); +} + +/* ----------------------------------------------------------------- notes */ + +export function NotesModal({ organ, onSwitchOrgan }: { organ: Organ; onSwitchOrgan: (id: OrganId) => void }) { + const { lang } = useLanguage(); + const t = useT(); + const progress = useProgress(); + const note = progress.notes[organ.id] ?? ""; + const noteOrgans = Object.keys(progress.notes) + .map((id) => localizeOrgan(organById[id as OrganId], lang)) + .filter(Boolean); + + return ( +
+