first commit
This commit is contained in:
commit
0e854110d3
49 files changed
+2292
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# Build context is the repository root (see docker-compose.yml) so this
|
||||
# stage can reach both frontend/ and nginx/.
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Atlas 3D de Anatomia</title>
|
||||
</head>
|
||||
<body class="bg-ink text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "anatomia-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.114.3",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"fuse.js": "^7.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"three": "^0.169.0",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Layout } from "./components/Layout.js";
|
||||
import AtlasPage from "./routes/AtlasPage.js";
|
||||
import PlanesPage from "./routes/PlanesPage.js";
|
||||
import QuizPage from "./routes/QuizPage.js";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<AtlasPage />} />
|
||||
<Route path="/planos-anatomicos" element={<PlanesPage />} />
|
||||
<Route path="/quiz" element={<QuizPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useAtlasStore, type ClipAxis } from "../store/useAtlasStore.js";
|
||||
|
||||
const AXES: { value: ClipAxis; label: string }[] = [
|
||||
{ value: "sagittal", label: "Sagital (esq./dir.)" },
|
||||
{ value: "coronal", label: "Coronal (ant./post.)" },
|
||||
{ value: "transverse", label: "Transversal (sup./inf.)" },
|
||||
];
|
||||
|
||||
export function ClippingControls() {
|
||||
const clipping = useAtlasStore((s) => s.clipping);
|
||||
const setClippingEnabled = useAtlasStore((s) => s.setClippingEnabled);
|
||||
const setClipAxis = useAtlasStore((s) => s.setClipAxis);
|
||||
const setClipPosition = useAtlasStore((s) => s.setClipPosition);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clipping.enabled}
|
||||
onChange={(e) => setClippingEnabled(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
Dissecção virtual (plano de corte)
|
||||
</label>
|
||||
|
||||
{clipping.enabled && (
|
||||
<div className="space-y-2 pl-1">
|
||||
<select
|
||||
value={clipping.axis}
|
||||
onChange={(e) => setClipAxis(e.target.value as ClipAxis)}
|
||||
className="w-full text-sm bg-white/5 border border-white/10 rounded px-2 py-1"
|
||||
>
|
||||
{AXES.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="range"
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={clipping.position}
|
||||
onChange={(e) => setClipPosition(Number(e.target.value))}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useSystems } from "../hooks/useStructures.js";
|
||||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||
|
||||
export function LayerToggle() {
|
||||
const systems = useSystems();
|
||||
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||||
const toggleSystem = useAtlasStore((s) => s.toggleSystem);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-xs uppercase tracking-wide text-slate-400 mb-2">Camadas</h3>
|
||||
{systems.map((sys) => (
|
||||
<label
|
||||
key={sys.slug}
|
||||
className="flex items-center gap-2 text-sm cursor-pointer select-none rounded px-2 py-1 hover:bg-white/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleSystems.has(sys.slug)}
|
||||
onChange={() => toggleSystem(sys.slug)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span
|
||||
className="inline-block w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: sys.colorHex }}
|
||||
/>
|
||||
<span>{sys.namePt}</span>
|
||||
{sys._count && (
|
||||
<span className="ml-auto text-xs text-slate-500">{sys._count.structures}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`px-3 py-1.5 rounded text-sm ${isActive ? "bg-accent text-white" : "text-slate-300 hover:bg-white/5"}`;
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-white/10 bg-panel">
|
||||
<h1 className="text-sm font-semibold tracking-wide text-white">🫀 Atlas 3D de Anatomia</h1>
|
||||
<nav className="flex gap-1">
|
||||
<NavLink to="/" end className={linkClass}>
|
||||
Atlas
|
||||
</NavLink>
|
||||
<NavLink to="/planos-anatomicos" className={linkClass}>
|
||||
Planos e Eixos
|
||||
</NavLink>
|
||||
<NavLink to="/quiz" className={linkClass}>
|
||||
Quiz
|
||||
</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 min-h-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
import type { Structure } from "../lib/api.js";
|
||||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||
|
||||
export function SearchNav({ structures }: { structures: Structure[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
||||
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
|
||||
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
|
||||
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(structures, {
|
||||
keys: ["namePt", "nameEn", "synonyms"],
|
||||
threshold: 0.35,
|
||||
}),
|
||||
[structures],
|
||||
);
|
||||
|
||||
const results = query.trim() ? fuse.search(query).slice(0, 20).map((r) => r.item) : [];
|
||||
|
||||
const tree = useMemo(() => {
|
||||
const root = new Map<string, Map<string, Structure[]>>();
|
||||
for (const s of structures) {
|
||||
const system = s.system.namePt;
|
||||
const region = s.regionPath[s.regionPath.length - 1] ?? "Geral";
|
||||
if (!root.has(system)) root.set(system, new Map());
|
||||
const regionMap = root.get(system)!;
|
||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||
regionMap.get(region)!.push(s);
|
||||
}
|
||||
return root;
|
||||
}, [structures]);
|
||||
|
||||
function goTo(structure: Structure) {
|
||||
if (!visibleSystems.has(structure.system.slug)) {
|
||||
setVisibleSystems([...visibleSystems, structure.system.slug]);
|
||||
}
|
||||
selectStructure(structure.id);
|
||||
focusOnStructure(structure.slug);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full min-h-0">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar estrutura (ex: fêmur, nervo ciático)..."
|
||||
className="w-full rounded bg-white/5 border border-white/10 px-3 py-2 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0 pr-1">
|
||||
{query.trim() ? (
|
||||
<ul className="space-y-1">
|
||||
{results.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
onClick={() => goTo(s)}
|
||||
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
|
||||
>
|
||||
{s.namePt}
|
||||
<span className="text-slate-500 text-xs"> — {s.system.namePt}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{results.length === 0 && (
|
||||
<li className="text-sm text-slate-500 px-2">Nenhuma estrutura encontrada.</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{[...tree.entries()].map(([system, regions]) => (
|
||||
<details key={system} open={false} className="text-sm">
|
||||
<summary className="cursor-pointer text-slate-300 hover:text-white">{system}</summary>
|
||||
<div className="pl-3 mt-1 space-y-2">
|
||||
{[...regions.entries()].map(([region, items]) => (
|
||||
<details key={region}>
|
||||
<summary className="cursor-pointer text-slate-400 hover:text-white text-xs uppercase tracking-wide">
|
||||
{region}
|
||||
</summary>
|
||||
<ul className="pl-3 mt-1 space-y-0.5">
|
||||
{items.map((s) => (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
onClick={() => goTo(s)}
|
||||
className="w-full text-left text-sm rounded px-2 py-1 hover:bg-white/5"
|
||||
>
|
||||
{s.namePt}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||
import type { Structure } from "../lib/api.js";
|
||||
|
||||
export function StructureInfoPanel({ bySlug }: { bySlug: Map<string, Structure> }) {
|
||||
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
||||
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
||||
|
||||
const structure = selectedStructureId
|
||||
? [...bySlug.values()].find((s) => s.id === selectedStructureId)
|
||||
: null;
|
||||
|
||||
if (!structure) {
|
||||
return (
|
||||
<div className="text-sm text-slate-400">
|
||||
Clique em uma estrutura no modelo 3D para ver os detalhes aqui.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{structure.namePt}</h2>
|
||||
<p className="text-sm text-slate-400 italic">{structure.nameEn}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => selectStructure(null)}
|
||||
className="text-slate-400 hover:text-white text-sm"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="inline-block text-xs px-2 py-1 rounded-full"
|
||||
style={{ backgroundColor: structure.system.colorHex + "33", color: structure.system.colorHex }}
|
||||
>
|
||||
{structure.system.namePt}
|
||||
</span>
|
||||
|
||||
{structure.description && <p className="text-sm text-slate-300">{structure.description}</p>}
|
||||
|
||||
{structure.regionPath.length > 0 && (
|
||||
<p className="text-xs text-slate-500">{structure.regionPath.join(" › ")}</p>
|
||||
)}
|
||||
|
||||
{structure.synonyms.length > 0 && (
|
||||
<p className="text-xs text-slate-500">Sinônimos: {structure.synonyms.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Suspense, useEffect, useMemo, useRef } from "react";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { CameraControls, Grid } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { useAtlasStore, type ClipAxis } from "../../store/useAtlasStore.js";
|
||||
import { SystemModel } from "./SystemModel.js";
|
||||
import type { Structure } from "../../lib/api.js";
|
||||
|
||||
const AXIS_CONFIG: Record<ClipAxis, { normal: [number, number, number]; min: number; max: number }> = {
|
||||
sagittal: { normal: [1, 0, 0], min: -0.4, max: 0.4 },
|
||||
coronal: { normal: [0, 0, 1], min: -0.3, max: 0.3 },
|
||||
transverse: { normal: [0, -1, 0], min: -1.8, max: 0 },
|
||||
};
|
||||
|
||||
function useClippingPlanes(): THREE.Plane[] {
|
||||
const { enabled, axis, position } = useAtlasStore((s) => s.clipping);
|
||||
return useMemo(() => {
|
||||
if (!enabled) return [];
|
||||
const cfg = AXIS_CONFIG[axis];
|
||||
const t = (position + 1) / 2; // normalize -1..1 -> 0..1
|
||||
const constant = cfg.min + t * (cfg.max - cfg.min);
|
||||
const normal = new THREE.Vector3(...cfg.normal);
|
||||
return [new THREE.Plane(normal, -constant)];
|
||||
}, [enabled, axis, position]);
|
||||
}
|
||||
|
||||
function FocusController() {
|
||||
const controlsRef = useRef<CameraControls>(null);
|
||||
const { scene } = useThree();
|
||||
const focusRequest = useAtlasStore((s) => s.focusRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest || !controlsRef.current) return;
|
||||
const obj = scene.getObjectByName(focusRequest.slug);
|
||||
if (!obj) return;
|
||||
const box = new THREE.Box3().setFromObject(obj);
|
||||
if (box.isEmpty()) return;
|
||||
controlsRef.current.fitToBox(box, true, {
|
||||
paddingLeft: 0.4,
|
||||
paddingRight: 0.4,
|
||||
paddingTop: 0.4,
|
||||
paddingBottom: 0.4,
|
||||
});
|
||||
}, [focusRequest, scene]);
|
||||
|
||||
return (
|
||||
<CameraControls
|
||||
ref={controlsRef}
|
||||
makeDefault
|
||||
minDistance={0.15}
|
||||
maxDistance={4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Scene({ bySlug }: { bySlug: Map<string, Structure> }) {
|
||||
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||||
const selectedStructureId = useAtlasStore((s) => s.selectedStructureId);
|
||||
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
||||
const clippingPlanes = useClippingPlanes();
|
||||
|
||||
const selectedSlug = selectedStructureId
|
||||
? [...bySlug.values()].find((s) => s.id === selectedStructureId)?.slug ?? null
|
||||
: null;
|
||||
|
||||
const modelFiles = useMemo(
|
||||
() => [...visibleSystems].map((slug) => `${slug}.glb`),
|
||||
[visibleSystems],
|
||||
);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
shadows
|
||||
camera={{ position: [1.2, 1.4, 1.6], fov: 45, near: 0.01, far: 20 }}
|
||||
gl={{ localClippingEnabled: true }}
|
||||
>
|
||||
<color attach="background" args={["#0b1220"]} />
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[2, 3, 2]} intensity={1.1} castShadow />
|
||||
<directionalLight position={[-2, 1, -2]} intensity={0.4} />
|
||||
<Suspense fallback={null}>
|
||||
{modelFiles.map((modelFile) => (
|
||||
<SystemModel
|
||||
key={modelFile}
|
||||
modelFile={modelFile}
|
||||
selectedSlug={selectedSlug}
|
||||
clippingPlanes={clippingPlanes}
|
||||
onSelect={(slug) => {
|
||||
const structure = bySlug.get(slug);
|
||||
if (structure) selectStructure(structure.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Suspense>
|
||||
<Grid
|
||||
position={[0, 0, 0]}
|
||||
args={[4, 4]}
|
||||
cellColor="#1e293b"
|
||||
sectionColor="#334155"
|
||||
fadeDistance={6}
|
||||
infiniteGrid
|
||||
/>
|
||||
<FocusController />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import { modelUrl } from "../../lib/api.js";
|
||||
|
||||
interface Props {
|
||||
modelFile: string;
|
||||
selectedSlug: string | null;
|
||||
onSelect: (slug: string) => void;
|
||||
clippingPlanes: THREE.Plane[];
|
||||
}
|
||||
|
||||
const HIGHLIGHT = new THREE.Color("#22d3ee");
|
||||
|
||||
export function SystemModel({ modelFile, selectedSlug, onSelect, clippingPlanes }: Props) {
|
||||
const { scene } = useGLTF(modelUrl(modelFile));
|
||||
const clonedRef = useRef<THREE.Group>();
|
||||
|
||||
if (!clonedRef.current) {
|
||||
const clone = scene.clone(true);
|
||||
clone.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh;
|
||||
if (mesh.isMesh && mesh.material) {
|
||||
mesh.material = (mesh.material as THREE.MeshStandardMaterial).clone();
|
||||
}
|
||||
});
|
||||
clonedRef.current = clone;
|
||||
}
|
||||
const root = clonedRef.current!;
|
||||
|
||||
useEffect(() => {
|
||||
root.traverse((obj) => {
|
||||
const mesh = obj as THREE.Mesh;
|
||||
if (!mesh.isMesh) return;
|
||||
const mat = mesh.material as THREE.MeshStandardMaterial;
|
||||
mat.clippingPlanes = clippingPlanes;
|
||||
mat.clipShadows = true;
|
||||
const isSelected = mesh.name === selectedSlug;
|
||||
mat.emissive = isSelected ? HIGHLIGHT : new THREE.Color(0x000000);
|
||||
mat.emissiveIntensity = isSelected ? 0.6 : 0;
|
||||
});
|
||||
}, [root, selectedSlug, clippingPlanes]);
|
||||
|
||||
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
const name = e.object.name;
|
||||
if (name) onSelect(name);
|
||||
};
|
||||
|
||||
return <primitive object={root} onClick={handleClick} />;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type AnatomicalSystem, type Structure } from "../lib/api.js";
|
||||
|
||||
export function useSystems() {
|
||||
const [systems, setSystems] = useState<AnatomicalSystem[]>([]);
|
||||
useEffect(() => {
|
||||
api.systems().then(setSystems).catch(console.error);
|
||||
}, []);
|
||||
return systems;
|
||||
}
|
||||
|
||||
export function useAllStructures() {
|
||||
const [structures, setStructures] = useState<Structure[]>([]);
|
||||
useEffect(() => {
|
||||
api.structures().then(setStructures).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const bySlug = useMemo(() => {
|
||||
const map = new Map<string, Structure>();
|
||||
for (const s of structures) map.set(s.slug, s);
|
||||
return map;
|
||||
}, [structures]);
|
||||
|
||||
return { structures, bySlug };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface AnatomicalSystem {
|
||||
id: string;
|
||||
slug: string;
|
||||
namePt: string;
|
||||
nameEn: string;
|
||||
colorHex: string;
|
||||
order: number;
|
||||
_count?: { structures: number };
|
||||
}
|
||||
|
||||
export interface Structure {
|
||||
id: string;
|
||||
fmaId: string | null;
|
||||
slug: string;
|
||||
namePt: string;
|
||||
nameEn: string;
|
||||
synonyms: string[];
|
||||
description: string | null;
|
||||
regionPath: string[];
|
||||
modelFile: string;
|
||||
meshName: string;
|
||||
systemId: string;
|
||||
system: AnatomicalSystem;
|
||||
}
|
||||
|
||||
export interface QuizCard {
|
||||
id: string;
|
||||
structureId: string;
|
||||
easeFactor: number;
|
||||
intervalDays: number;
|
||||
repetitions: number;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`GET ${url} failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
systems: () => getJson<AnatomicalSystem[]>(`${BASE}/systems`),
|
||||
|
||||
structures: (params: { system?: string; q?: string } = {}) => {
|
||||
const usp = new URLSearchParams();
|
||||
if (params.system) usp.set("system", params.system);
|
||||
if (params.q) usp.set("q", params.q);
|
||||
const qs = usp.toString();
|
||||
return getJson<Structure[]>(`${BASE}/structures${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
structure: (id: string) => getJson<Structure>(`${BASE}/structures/${id}`),
|
||||
|
||||
quizNext: (params: { system?: string; limit?: number } = {}) => {
|
||||
const usp = new URLSearchParams();
|
||||
if (params.system) usp.set("system", params.system);
|
||||
if (params.limit) usp.set("limit", String(params.limit));
|
||||
const qs = usp.toString();
|
||||
return getJson<(Structure & { quizCard: QuizCard | null })[]>(
|
||||
`${BASE}/quiz/next${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
quizReview: async (structureId: string, quality: number): Promise<QuizCard> => {
|
||||
const res = await fetch(`${BASE}/quiz/${structureId}/review`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ quality }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`quizReview failed: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
export function modelUrl(modelFile: string): string {
|
||||
return `/models/${modelFile}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.js";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ClipAxis = "sagittal" | "coronal" | "transverse";
|
||||
|
||||
interface AtlasState {
|
||||
visibleSystems: Set<string>;
|
||||
toggleSystem: (slug: string) => void;
|
||||
setVisibleSystems: (slugs: string[]) => void;
|
||||
|
||||
selectedStructureId: string | null;
|
||||
selectStructure: (id: string | null) => void;
|
||||
|
||||
focusRequest: { slug: string; nonce: number } | null;
|
||||
focusOnStructure: (slug: string) => void;
|
||||
|
||||
clipping: { enabled: boolean; axis: ClipAxis; position: number };
|
||||
setClippingEnabled: (enabled: boolean) => void;
|
||||
setClipAxis: (axis: ClipAxis) => void;
|
||||
setClipPosition: (position: number) => void;
|
||||
}
|
||||
|
||||
export const useAtlasStore = create<AtlasState>((set) => ({
|
||||
visibleSystems: new Set(["esqueletico"]),
|
||||
toggleSystem: (slug) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.visibleSystems);
|
||||
if (next.has(slug)) next.delete(slug);
|
||||
else next.add(slug);
|
||||
return { visibleSystems: next };
|
||||
}),
|
||||
setVisibleSystems: (slugs) => set({ visibleSystems: new Set(slugs) }),
|
||||
|
||||
selectedStructureId: null,
|
||||
selectStructure: (id) => set({ selectedStructureId: id }),
|
||||
|
||||
focusRequest: null,
|
||||
focusOnStructure: (slug) =>
|
||||
set((state) => ({ focusRequest: { slug, nonce: (state.focusRequest?.nonce ?? 0) + 1 } })),
|
||||
|
||||
clipping: { enabled: false, axis: "sagittal", position: 0 },
|
||||
setClippingEnabled: (enabled) => set((state) => ({ clipping: { ...state.clipping, enabled } })),
|
||||
setClipAxis: (axis) => set((state) => ({ clipping: { ...state.clipping, axis } })),
|
||||
setClipPosition: (position) => set((state) => ({ clipping: { ...state.clipping, position } })),
|
||||
}));
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#0b1220",
|
||||
panel: "#121a2b",
|
||||
accent: "#4f7cff",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:3333",
|
||||
"/models": "http://localhost:3333",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user