Files
anatomia/frontend/src/routes/PhysicalExamPage.tsx
T

207 lines
8.1 KiB
TypeScript
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.
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>
);
}