first commit
This commit is contained in:
commit
0e854110d3
49 files changed
+2292
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import { useAllStructures } from "../hooks/useStructures.js";
|
||||
import { Scene } from "../components/viewer/Scene.js";
|
||||
import { LayerToggle } from "../components/LayerToggle.js";
|
||||
import { ClippingControls } from "../components/ClippingControls.js";
|
||||
import { SearchNav } from "../components/SearchNav.js";
|
||||
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
|
||||
|
||||
export default function AtlasPage() {
|
||||
const { structures, bySlug } = useAllStructures();
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-cols-[280px_1fr_320px]">
|
||||
<aside className="border-r border-white/10 bg-panel p-3 flex flex-col min-h-0">
|
||||
<LayerToggle />
|
||||
<ClippingControls />
|
||||
<div className="flex-1 min-h-0 mt-4">
|
||||
<SearchNav structures={structures} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<Scene bySlug={bySlug} />
|
||||
</div>
|
||||
|
||||
<aside className="border-l border-white/10 bg-panel p-4 overflow-y-auto">
|
||||
<StructureInfoPanel bySlug={bySlug} />
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from "react";
|
||||
import { Canvas } from "@react-three/fiber";
|
||||
import { CameraControls, Html, Line } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface PlaneDef {
|
||||
key: "sagittal" | "coronal" | "transverse";
|
||||
label: string;
|
||||
color: string;
|
||||
args: [number, number];
|
||||
position: [number, number, number];
|
||||
rotation: [number, number, number];
|
||||
}
|
||||
|
||||
const PLANES: PlaneDef[] = [
|
||||
{ key: "sagittal", label: "Plano Sagital", color: "#4f7cff", args: [1.2, 2.2], position: [0, 0.9, 0], rotation: [0, Math.PI / 2, 0] },
|
||||
{ key: "coronal", label: "Plano Coronal (Frontal)", color: "#22d3ee", args: [1.2, 2.2], position: [0, 0.9, 0], rotation: [0, 0, 0] },
|
||||
{ key: "transverse", label: "Plano Transversal (Axial)", color: "#f2c14e", args: [1.2, 1.2], position: [0, 0.9, 0], rotation: [Math.PI / 2, 0, 0] },
|
||||
];
|
||||
|
||||
const AXES: { label: string; from: [number, number, number]; to: [number, number, number]; color: string }[] = [
|
||||
{ label: "Superior", from: [0, 0.9, 0], to: [0, 1.9, 0], color: "#f87171" },
|
||||
{ label: "Inferior", from: [0, 0.9, 0], to: [0, -0.1, 0], color: "#f87171" },
|
||||
{ label: "Anterior", from: [0, 0.9, 0], to: [0, 0.9, 0.9], color: "#34d399" },
|
||||
{ label: "Posterior", from: [0, 0.9, 0], to: [0, 0.9, -0.9], color: "#34d399" },
|
||||
{ label: "Direita (do paciente)", from: [0, 0.9, 0], to: [-0.7, 0.9, 0], color: "#a78bfa" },
|
||||
{ label: "Esquerda (do paciente)", from: [0, 0.9, 0], to: [0.7, 0.9, 0], color: "#a78bfa" },
|
||||
];
|
||||
|
||||
const GLOSSARY: { term: string; def: string }[] = [
|
||||
{ term: "Anterior / Posterior", def: "Em direção à frente (ventral) ou para trás (dorsal) do corpo." },
|
||||
{ term: "Superior / Inferior", def: "Em direção à cabeça (cranial) ou aos pés (caudal)." },
|
||||
{ term: "Medial / Lateral", def: "Mais próximo (medial) ou mais distante (lateral) do plano mediano/sagital." },
|
||||
{ term: "Proximal / Distal", def: "Mais próximo (proximal) ou mais distante (distal) do tronco/raiz do membro." },
|
||||
{ term: "Superficial / Profundo", def: "Mais próximo (superficial) ou mais distante (profundo) da superfície do corpo." },
|
||||
{ term: "Plano Sagital", def: "Divide o corpo em partes esquerda e direita (é 'mediano' quando passa exatamente pelo centro)." },
|
||||
{ term: "Plano Coronal (Frontal)", def: "Divide o corpo em partes anterior e posterior." },
|
||||
{ term: "Plano Transversal (Axial)", def: "Divide o corpo em partes superior e inferior." },
|
||||
];
|
||||
|
||||
function Mannequin() {
|
||||
const skin = "#8fa3c8";
|
||||
return (
|
||||
<group>
|
||||
<mesh position={[0, 1.65, 0]}>
|
||||
<sphereGeometry args={[0.12]} />
|
||||
<meshStandardMaterial color={skin} />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.2, 0]}>
|
||||
<boxGeometry args={[0.4, 0.55, 0.22]} />
|
||||
<meshStandardMaterial color={skin} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.75, 0]}>
|
||||
<boxGeometry args={[0.36, 0.3, 0.2]} />
|
||||
<meshStandardMaterial color={skin} />
|
||||
</mesh>
|
||||
{[1, -1].map((side) => (
|
||||
<group key={`arm-${side}`}>
|
||||
<mesh position={[side * 0.28, 1.1, 0]}>
|
||||
<cylinderGeometry args={[0.05, 0.045, 0.55, 10]} />
|
||||
<meshStandardMaterial color={skin} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
{[1, -1].map((side) => (
|
||||
<group key={`leg-${side}`}>
|
||||
<mesh position={[side * 0.11, 0.3, 0]}>
|
||||
<cylinderGeometry args={[0.065, 0.05, 0.65, 10]} />
|
||||
<meshStandardMaterial color={skin} />
|
||||
</mesh>
|
||||
</group>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanesPage() {
|
||||
const [visible, setVisible] = useState<Record<PlaneDef["key"], boolean>>({
|
||||
sagittal: true,
|
||||
coronal: false,
|
||||
transverse: false,
|
||||
});
|
||||
const [showAxes, setShowAxes] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-cols-[280px_1fr_300px]">
|
||||
<aside className="border-r border-white/10 bg-panel p-4 space-y-3">
|
||||
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-2">Planos Anatômicos</h3>
|
||||
{PLANES.map((p) => (
|
||||
<label key={p.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible[p.key]}
|
||||
onChange={() => setVisible((v) => ({ ...v, [p.key]: !v[p.key] }))}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: p.color }} />
|
||||
{p.label}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer pt-2 border-t border-white/10 mt-3">
|
||||
<input type="checkbox" checked={showAxes} onChange={() => setShowAxes((v) => !v)} className="accent-accent" />
|
||||
Eixos e termos de direção
|
||||
</label>
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<Canvas camera={{ position: [1.8, 1.3, 2.2], fov: 45 }}>
|
||||
<color attach="background" args={["#0b1220"]} />
|
||||
<ambientLight intensity={0.6} />
|
||||
<directionalLight position={[2, 3, 2]} intensity={1} />
|
||||
|
||||
<Mannequin />
|
||||
|
||||
{PLANES.filter((p) => visible[p.key]).map((p) => (
|
||||
<mesh key={p.key} position={p.position} rotation={p.rotation}>
|
||||
<planeGeometry args={p.args} />
|
||||
<meshBasicMaterial color={p.color} transparent opacity={0.18} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
))}
|
||||
|
||||
{showAxes &&
|
||||
AXES.map((a) => (
|
||||
<group key={a.label}>
|
||||
<Line points={[a.from, a.to]} color={a.color} lineWidth={2} />
|
||||
<Html position={a.to} center distanceFactor={4}>
|
||||
<div
|
||||
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap"
|
||||
style={{ background: a.color, color: "#0b1220" }}
|
||||
>
|
||||
{a.label}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
))}
|
||||
|
||||
<CameraControls makeDefault />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
<aside className="border-l border-white/10 bg-panel p-4 overflow-y-auto space-y-3">
|
||||
<h3 className="text-xs uppercase tracking-wide text-slate-400">Glossário de Direções</h3>
|
||||
{GLOSSARY.map((g) => (
|
||||
<div key={g.term}>
|
||||
<p className="text-sm font-medium text-white">{g.term}</p>
|
||||
<p className="text-xs text-slate-400">{g.def}</p>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, type Structure, type QuizCard } from "../lib/api.js";
|
||||
import { useAllStructures } from "../hooks/useStructures.js";
|
||||
import { Scene } from "../components/viewer/Scene.js";
|
||||
import { LayerToggle } from "../components/LayerToggle.js";
|
||||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||
|
||||
type Deck = (Structure & { quizCard: QuizCard | null })[];
|
||||
|
||||
export default function QuizPage() {
|
||||
const { bySlug } = useAllStructures();
|
||||
const [deck, setDeck] = useState<Deck>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
|
||||
const [score, setScore] = useState({ correct: 0, total: 0 });
|
||||
|
||||
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
||||
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
||||
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
|
||||
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
|
||||
|
||||
async function loadDeck() {
|
||||
const next = await api.quizNext({ limit: 10 });
|
||||
setDeck(next);
|
||||
setIndex(0);
|
||||
setScore({ correct: 0, total: 0 });
|
||||
selectStructure(null);
|
||||
if (next.length > 0) {
|
||||
setVisibleSystems([...new Set(next.map((s) => s.system.slug))]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDeck();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const current = deck[index];
|
||||
const ignoreNextSelectRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!current || !selectedStructureId || feedback) return;
|
||||
if (ignoreNextSelectRef.current) {
|
||||
ignoreNextSelectRef.current = false;
|
||||
return;
|
||||
}
|
||||
const isCorrect = selectedStructureId === current.id;
|
||||
setFeedback(isCorrect ? "correct" : "wrong");
|
||||
setScore((s) => ({ correct: s.correct + (isCorrect ? 1 : 0), total: s.total + 1 }));
|
||||
api.quizReview(current.id, isCorrect ? 5 : 2).catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedStructureId]);
|
||||
|
||||
function next() {
|
||||
selectStructure(null);
|
||||
setFeedback(null);
|
||||
if (index + 1 < deck.length) setIndex(index + 1);
|
||||
else loadDeck();
|
||||
}
|
||||
|
||||
function giveUp() {
|
||||
if (!current) return;
|
||||
setFeedback("wrong");
|
||||
setScore((s) => ({ ...s, total: s.total + 1 }));
|
||||
api.quizReview(current.id, 2).catch(console.error);
|
||||
ignoreNextSelectRef.current = true;
|
||||
focusOnStructure(current.slug);
|
||||
selectStructure(current.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-cols-[280px_1fr]">
|
||||
<aside className="border-r border-white/10 bg-panel p-3 flex flex-col gap-4 min-h-0">
|
||||
<LayerToggle />
|
||||
</aside>
|
||||
|
||||
<div className="relative">
|
||||
<Scene bySlug={bySlug} />
|
||||
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 bg-panel/95 border border-white/10 rounded-lg px-5 py-3 text-center shadow-lg max-w-md">
|
||||
{!current ? (
|
||||
<p className="text-sm text-slate-300">
|
||||
Nenhuma estrutura pendente de revisão agora. 🎉
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">
|
||||
Onde fica esta estrutura?
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-white">{current.namePt}</p>
|
||||
<p className="text-xs text-slate-500 mb-2">{current.system.namePt}</p>
|
||||
|
||||
{feedback === "correct" && (
|
||||
<p className="text-sm text-emerald-400 mb-2">Correto! ✅</p>
|
||||
)}
|
||||
{feedback === "wrong" && (
|
||||
<p className="text-sm text-rose-400 mb-2">Não foi essa estrutura.</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-center mt-2">
|
||||
{feedback ? (
|
||||
<button
|
||||
onClick={next}
|
||||
className="text-sm px-3 py-1 rounded bg-accent text-white"
|
||||
>
|
||||
Próxima
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={giveUp}
|
||||
className="text-sm px-3 py-1 rounded bg-white/10 text-slate-200"
|
||||
>
|
||||
Mostrar resposta
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{index + 1} / {deck.length} · acertos: {score.correct}/{score.total}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user