207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import { useSystemStructures } from "../hooks/useSystemStructures.js";
|
||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||
import { Scene } from "../components/viewer/Scene.js";
|
||
import { ExamMarkers } from "../components/viewer/ExamMarkers.js";
|
||
import { LayerToggle } from "../components/LayerToggle.js";
|
||
import { QuickRegionNav } from "../components/QuickRegionNav.js";
|
||
import { EXAM_POINTS, TECHNIQUES, type ExamTechnique, type ExamSide } from "../data/physicalExam.js";
|
||
|
||
// Módulo de estudo pro exame físico, sobre o corpo 3D REAL do Z-Anatomy
|
||
// (mesmo visualizador do Atlas — camadas, navegação por região, câmera
|
||
// tudo igual), com pontos clicáveis mostrando ONDE e COMO se faz cada
|
||
// manobra (inspeção/palpação/percussão/ausculta). Reaproveita o mesmo
|
||
// <Scene> do Atlas (via prop "children", ver Scene.tsx) em vez de um corpo
|
||
// geométrico à parte.
|
||
// O esqueleto serve de referência pra posicionar os pontos de exame (ver
|
||
// ExamMarkers.tsx) mesmo que o usuário não tenha marcado essa camada —
|
||
// por isso é sempre buscado/carregado aqui, independente do toggle.
|
||
const ALWAYS_LOAD = ["esqueletico"];
|
||
// Visualmente, um corpo com a pele/regiões de superfície faz muito mais
|
||
// sentido pra exame físico do que o esqueleto nu (é na pele que se
|
||
// ausculta/palpa/percute) — esse é o layer mostrado por padrão ao entrar
|
||
// nesta página; o usuário pode trocar/adicionar outras camadas no painel.
|
||
const DEFAULT_VISIBLE = ["regioes"];
|
||
|
||
export default function PhysicalExamPage() {
|
||
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
|
||
const fetchSystems = useMemo(
|
||
() => new Set([...visibleSystems, ...ALWAYS_LOAD]),
|
||
[visibleSystems],
|
||
);
|
||
const { bySlug } = useSystemStructures(fetchSystems);
|
||
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
||
|
||
const [activeTechniques, setActiveTechniques] = useState<Set<ExamTechnique>>(
|
||
() => new Set(TECHNIQUES.map((t) => t.key)),
|
||
);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [viewSide, setViewSide] = useState<ExamSide>("anterior");
|
||
const [leftOpen, setLeftOpen] = useState(false);
|
||
const [rightOpen, setRightOpen] = useState(false);
|
||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
useEffect(() => setVisibleSystems(DEFAULT_VISIBLE), []);
|
||
|
||
useEffect(() => {
|
||
if (selectedStructureId) {
|
||
setRightOpen(true);
|
||
setLeftOpen(false);
|
||
}
|
||
}, [selectedStructureId]);
|
||
|
||
function toggleTechnique(key: ExamTechnique) {
|
||
setActiveTechniques((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(key)) next.delete(key);
|
||
else next.add(key);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
const visiblePoints = useMemo(
|
||
() => EXAM_POINTS.filter((p) => activeTechniques.has(p.technique)),
|
||
[activeTechniques],
|
||
);
|
||
|
||
const selected = EXAM_POINTS.find((p) => p.id === selectedId) ?? null;
|
||
const techniqueColor = (t: ExamTechnique) => TECHNIQUES.find((x) => x.key === t)?.color ?? "#fff";
|
||
|
||
return (
|
||
<div className="h-full relative md:grid md:grid-cols-[260px_1fr_320px]">
|
||
<aside
|
||
className={`fixed md:static inset-y-0 left-0 z-30 w-[86vw] max-w-[280px] md:w-auto
|
||
border-r border-white/10 bg-panel p-3 flex flex-col min-h-0 overflow-y-auto
|
||
transition-transform duration-200 md:translate-x-0
|
||
${leftOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||
>
|
||
<button
|
||
onClick={() => setLeftOpen(false)}
|
||
className="md:hidden self-end text-slate-400 hover:text-white text-sm mb-2 px-2 py-1"
|
||
aria-label="Fechar painel"
|
||
>
|
||
✕ Fechar
|
||
</button>
|
||
|
||
<LayerToggle />
|
||
|
||
<div className="pt-3 mt-3 border-t border-white/10">
|
||
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-2">Técnicas de Exame</h3>
|
||
<div className="space-y-1.5">
|
||
{TECHNIQUES.map((t) => (
|
||
<label key={t.key} className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={activeTechniques.has(t.key)}
|
||
onChange={() => toggleTechnique(t.key)}
|
||
className="accent-accent"
|
||
/>
|
||
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.color }} />
|
||
{t.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<p className="text-[11px] text-slate-500 mt-4 leading-relaxed">
|
||
Marque as técnicas que quer estudar e clique nos pontos coloridos
|
||
sobre o corpo. Use o menu de regiões pra focar rápido numa parte
|
||
do corpo, e gire o modelo pra ver os pontos da frente ou de trás
|
||
— só aparecem os do lado que você está olhando.
|
||
</p>
|
||
</aside>
|
||
|
||
{(leftOpen || rightOpen) && (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 z-20 md:hidden"
|
||
onClick={() => {
|
||
setLeftOpen(false);
|
||
setRightOpen(false);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<div className="relative h-full min-h-0">
|
||
<Scene bySlug={bySlug} alwaysLoadSystems={ALWAYS_LOAD}>
|
||
<ExamMarkers
|
||
points={visiblePoints}
|
||
selectedId={selectedId}
|
||
onSelect={(id) => {
|
||
setSelectedId(id);
|
||
setRightOpen(true);
|
||
}}
|
||
onSideChange={setViewSide}
|
||
/>
|
||
</Scene>
|
||
<QuickRegionNav />
|
||
|
||
<div className="absolute bottom-3 left-3 z-10 bg-panel/90 border border-white/10 rounded-lg px-3 py-1.5 text-xs text-slate-300 shadow-lg">
|
||
Vista: <span className="font-semibold text-white">{viewSide === "anterior" ? "Anterior" : "Posterior"}</span>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => setLeftOpen(true)}
|
||
className="md:hidden absolute top-3 left-3 z-10 bg-panel/90 border border-white/10 rounded-lg px-3 py-2 text-xs shadow-lg"
|
||
>
|
||
☰ Técnicas
|
||
</button>
|
||
<button
|
||
onClick={() => setRightOpen(true)}
|
||
className="md:hidden absolute top-3 right-3 z-10 bg-panel/90 border border-white/10 rounded-lg px-3 py-2 text-xs shadow-lg"
|
||
>
|
||
ℹ️ Detalhes
|
||
</button>
|
||
</div>
|
||
|
||
<aside
|
||
className={`fixed md:static inset-y-0 right-0 z-30 w-[86vw] max-w-[320px] md:w-auto
|
||
border-l border-white/10 bg-panel p-4 overflow-y-auto space-y-3
|
||
transition-transform duration-200 md:translate-x-0
|
||
${rightOpen ? "translate-x-0" : "translate-x-full"}`}
|
||
>
|
||
<button
|
||
onClick={() => setRightOpen(false)}
|
||
className="md:hidden text-slate-400 hover:text-white text-sm mb-2 px-2 py-1"
|
||
aria-label="Fechar painel"
|
||
>
|
||
✕ Fechar
|
||
</button>
|
||
|
||
{!selected ? (
|
||
<p className="text-sm text-slate-400">
|
||
Clique em um ponto colorido sobre o corpo pra ver a técnica, o
|
||
local exato e o que ela avalia.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<span
|
||
className="inline-block text-xs px-2 py-1 rounded-full mb-2"
|
||
style={{
|
||
backgroundColor: techniqueColor(selected.technique) + "33",
|
||
color: techniqueColor(selected.technique),
|
||
}}
|
||
>
|
||
{TECHNIQUES.find((t) => t.key === selected.technique)?.label}
|
||
</span>
|
||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||
<p className="text-xs text-slate-500">{selected.system}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-1">Como fazer</h3>
|
||
<p className="text-sm text-slate-300 leading-relaxed">{selected.howTo}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-1">O que avalia</h3>
|
||
<p className="text-sm text-slate-300 leading-relaxed">{selected.whatFor}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|