novo fix
This commit is contained in:
1 parent
822ee2d557
commit
ad0025ef84
7 files changed
+151
-84
No files matched your search
@@ -25,6 +25,12 @@ export async function structuresRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
include: { system: true },
|
include: { system: true },
|
||||||
orderBy: { namePt: "asc" },
|
orderBy: { namePt: "asc" },
|
||||||
|
// Sem filtro, o dataset real (Z-Anatomy) tem ~5000 estruturas — o
|
||||||
|
// frontend nunca deveria pedir isso sem system/q, mas limitamos aqui
|
||||||
|
// como cinto de segurança contra uma busca de texto muito genérica
|
||||||
|
// e para nunca repetir o problema que travou a API antes (timeout de
|
||||||
|
// proxy por payload gigante).
|
||||||
|
take: q && !system ? 100 : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -30,14 +30,14 @@ services:
|
|||||||
anatomia-api:
|
anatomia-api:
|
||||||
container_name: anatomia-api
|
container_name: anatomia-api
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./anatomia/backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
anatomia-db:
|
anatomia-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
TZ: 'America/Araguaina'
|
TZ: 'America/Araguaina'
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}?connection_limit=10
|
||||||
PORT: 3333
|
PORT: 3333
|
||||||
MODELS_DIR: /app/models
|
MODELS_DIR: /app/models
|
||||||
volumes:
|
volumes:
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
anatomia-web:
|
anatomia-web:
|
||||||
container_name: anatomia-web
|
container_name: anatomia-web
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./anatomia
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -79,7 +79,7 @@ services:
|
|||||||
anatomia-pipeline:
|
anatomia-pipeline:
|
||||||
container_name: anatomia-pipeline
|
container_name: anatomia-pipeline
|
||||||
build:
|
build:
|
||||||
context: ./data-pipeline
|
context: ./anatomia/data-pipeline
|
||||||
profiles: ["tools"]
|
profiles: ["tools"]
|
||||||
depends_on:
|
depends_on:
|
||||||
anatomia-db:
|
anatomia-db:
|
||||||
@@ -87,7 +87,7 @@ services:
|
|||||||
anatomia-api:
|
anatomia-api:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}?connection_limit=10
|
||||||
volumes:
|
volumes:
|
||||||
- anatomia_models_data:/app/seed/models
|
- anatomia_models_data:/app/seed/models
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Fuse from "fuse.js";
|
import { api, type Structure } from "../lib/api.js";
|
||||||
import type { Structure } from "../lib/api.js";
|
|
||||||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||||
|
import { useSystems } from "../hooks/useStructures.js";
|
||||||
|
|
||||||
export function SearchNav({ structures }: { structures: Structure[] }) {
|
// Busca e árvore rodam no servidor (não pré-carregamos as ~5000 estruturas
|
||||||
|
// no cliente — isso estourava timeout de proxy). A busca usa debounce +
|
||||||
|
// `/api/structures?q=`; a árvore carrega os itens de cada sistema só
|
||||||
|
// quando o usuário expande aquela seção.
|
||||||
|
export function SearchNav() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<Structure[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
|
const systems = useSystems();
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, Structure[] | "loading">>({});
|
||||||
|
|
||||||
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
const selectStructure = useAtlasStore((s) => s.selectStructure);
|
||||||
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
|
const focusOnStructure = useAtlasStore((s) => s.focusOnStructure);
|
||||||
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
|
const setVisibleSystems = useAtlasStore((s) => s.setVisibleSystems);
|
||||||
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||||||
|
|
||||||
const fuse = useMemo(
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
() =>
|
|
||||||
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) : [];
|
useEffect(() => {
|
||||||
|
const q = query.trim();
|
||||||
const tree = useMemo(() => {
|
if (!q) {
|
||||||
const root = new Map<string, Map<string, Structure[]>>();
|
setResults([]);
|
||||||
for (const s of structures) {
|
setSearching(false);
|
||||||
const system = s.system.namePt;
|
return;
|
||||||
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;
|
setSearching(true);
|
||||||
}, [structures]);
|
clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.structures({ q })
|
||||||
|
.then((data) => setResults(data.slice(0, 40)))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setSearching(false));
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(debounceRef.current);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
function goTo(structure: Structure) {
|
function goTo(structure: Structure) {
|
||||||
if (!visibleSystems.has(structure.system.slug)) {
|
if (!visibleSystems.has(structure.system.slug)) {
|
||||||
@@ -42,6 +49,28 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
|
|||||||
focusOnStructure(structure.slug);
|
focusOnStructure(structure.slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExpand(systemSlug: string) {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
if (prev[systemSlug]) {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[systemSlug];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return { ...prev, [systemSlug]: "loading" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const [slug, value] of Object.entries(expanded)) {
|
||||||
|
if (value === "loading") {
|
||||||
|
api
|
||||||
|
.structures({ system: slug })
|
||||||
|
.then((data) => setExpanded((prev) => ({ ...prev, [slug]: data })))
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [expanded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 h-full min-h-0">
|
<div className="flex flex-col gap-3 h-full min-h-0">
|
||||||
<input
|
<input
|
||||||
@@ -54,7 +83,9 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
|
|||||||
<div className="overflow-y-auto flex-1 min-h-0 pr-1">
|
<div className="overflow-y-auto flex-1 min-h-0 pr-1">
|
||||||
{query.trim() ? (
|
{query.trim() ? (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{results.map((s) => (
|
{searching && <li className="text-sm text-slate-500 px-2">Buscando...</li>}
|
||||||
|
{!searching &&
|
||||||
|
results.map((s) => (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => goTo(s)}
|
onClick={() => goTo(s)}
|
||||||
@@ -65,41 +96,37 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{results.length === 0 && (
|
{!searching && results.length === 0 && (
|
||||||
<li className="text-sm text-slate-500 px-2">Nenhuma estrutura encontrada.</li>
|
<li className="text-sm text-slate-500 px-2">Nenhuma estrutura encontrada.</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-1">
|
||||||
{[...tree.entries()].map(([system, regions]) => (
|
{systems.map((sys) => (
|
||||||
<details key={system} open={false} className="text-sm">
|
<details
|
||||||
<summary className="cursor-pointer text-slate-300 hover:text-white">{system}</summary>
|
key={sys.slug}
|
||||||
<div className="pl-3 mt-1 space-y-2">
|
className="text-sm"
|
||||||
{[...regions.entries()].map(([region, items]) =>
|
onToggle={(e) => {
|
||||||
// Estruturas reais (Z-Anatomy) não têm sub-região própria
|
if ((e.target as HTMLDetailsElement).open) toggleExpand(sys.slug);
|
||||||
// — regionPath tem só o nome do sistema, então a região
|
}}
|
||||||
// aqui seria idêntica ao título acima. Nesse caso lista
|
|
||||||
// direto, sem um nível de agrupamento redundante.
|
|
||||||
region === system ? (
|
|
||||||
<ul key={region} className="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}
|
<summary className="cursor-pointer text-slate-300 hover:text-white flex items-center gap-2">
|
||||||
</button>
|
<span
|
||||||
</li>
|
className="inline-block w-2 h-2 rounded-full"
|
||||||
))}
|
style={{ backgroundColor: sys.colorHex }}
|
||||||
</ul>
|
/>
|
||||||
) : (
|
{sys.namePt}
|
||||||
<details key={region}>
|
{sys._count && (
|
||||||
<summary className="cursor-pointer text-slate-400 hover:text-white text-xs uppercase tracking-wide">
|
<span className="text-xs text-slate-500">({sys._count.structures})</span>
|
||||||
{region}
|
)}
|
||||||
</summary>
|
</summary>
|
||||||
<ul className="pl-3 mt-1 space-y-0.5">
|
<div className="pl-4 mt-1">
|
||||||
{items.map((s) => (
|
{expanded[sys.slug] === "loading" && (
|
||||||
|
<p className="text-xs text-slate-500 px-2">Carregando...</p>
|
||||||
|
)}
|
||||||
|
{Array.isArray(expanded[sys.slug]) && (
|
||||||
|
<ul className="space-y-0.5 max-h-64 overflow-y-auto">
|
||||||
|
{(expanded[sys.slug] as Structure[]).map((s) => (
|
||||||
<li key={s.id}>
|
<li key={s.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => goTo(s)}
|
onClick={() => goTo(s)}
|
||||||
@@ -110,8 +137,6 @@ export function SearchNav({ structures }: { structures: Structure[] }) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
|
||||||
),
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, type AnatomicalSystem, type Structure } from "../lib/api.js";
|
import { api, type AnatomicalSystem } from "../lib/api.js";
|
||||||
|
|
||||||
export function useSystems() {
|
export function useSystems() {
|
||||||
const [systems, setSystems] = useState<AnatomicalSystem[]>([]);
|
const [systems, setSystems] = useState<AnatomicalSystem[]>([]);
|
||||||
@@ -8,18 +8,3 @@ export function useSystems() {
|
|||||||
}, []);
|
}, []);
|
||||||
return systems;
|
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,48 @@
|
|||||||
|
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
|
import { api, type Structure } from "../lib/api.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carrega estruturas SOMENTE dos sistemas atualmente visíveis (uma
|
||||||
|
* requisição por sistema ainda não cacheado), em vez de buscar as ~5000
|
||||||
|
* estruturas do banco de uma vez — isso não escala (estourava timeout de
|
||||||
|
* proxy e sobrecarregava o pool de conexões do Postgres) desde que o
|
||||||
|
* dataset passou a usar malhas reais do Z-Anatomy.
|
||||||
|
*/
|
||||||
|
export function useSystemStructures(visibleSystems: Set<string>) {
|
||||||
|
const cacheRef = useRef<Map<string, Structure[]>>(new Map());
|
||||||
|
const [version, bump] = useReducer((x: number) => x + 1, 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const missing = [...visibleSystems].filter((slug) => !cacheRef.current.has(slug));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
missing.map((slug) =>
|
||||||
|
api.structures({ system: slug }).then((data) => [slug, data] as const),
|
||||||
|
),
|
||||||
|
).then((pairs) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
for (const [slug, data] of pairs) cacheRef.current.set(slug, data);
|
||||||
|
bump();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [visibleSystems]);
|
||||||
|
|
||||||
|
const structures = useMemo(
|
||||||
|
() => [...cacheRef.current.values()].flat(),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[version, visibleSystems],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useAllStructures } from "../hooks/useStructures.js";
|
import { useSystemStructures } from "../hooks/useSystemStructures.js";
|
||||||
|
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||||
import { Scene } from "../components/viewer/Scene.js";
|
import { Scene } from "../components/viewer/Scene.js";
|
||||||
import { LayerToggle } from "../components/LayerToggle.js";
|
import { LayerToggle } from "../components/LayerToggle.js";
|
||||||
import { ClippingControls } from "../components/ClippingControls.js";
|
import { ClippingControls } from "../components/ClippingControls.js";
|
||||||
@@ -6,7 +7,8 @@ import { SearchNav } from "../components/SearchNav.js";
|
|||||||
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
|
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
|
||||||
|
|
||||||
export default function AtlasPage() {
|
export default function AtlasPage() {
|
||||||
const { structures, bySlug } = useAllStructures();
|
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||||||
|
const { bySlug } = useSystemStructures(visibleSystems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full grid grid-cols-[280px_1fr_320px]">
|
<div className="h-full grid grid-cols-[280px_1fr_320px]">
|
||||||
@@ -14,7 +16,7 @@ export default function AtlasPage() {
|
|||||||
<LayerToggle />
|
<LayerToggle />
|
||||||
<ClippingControls />
|
<ClippingControls />
|
||||||
<div className="flex-1 min-h-0 mt-4">
|
<div className="flex-1 min-h-0 mt-4">
|
||||||
<SearchNav structures={structures} />
|
<SearchNav />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, type Structure, type QuizCard } from "../lib/api.js";
|
import { api, type Structure, type QuizCard } from "../lib/api.js";
|
||||||
import { useAllStructures } from "../hooks/useStructures.js";
|
import { useSystemStructures } from "../hooks/useSystemStructures.js";
|
||||||
import { Scene } from "../components/viewer/Scene.js";
|
import { Scene } from "../components/viewer/Scene.js";
|
||||||
import { LayerToggle } from "../components/LayerToggle.js";
|
import { LayerToggle } from "../components/LayerToggle.js";
|
||||||
import { useAtlasStore } from "../store/useAtlasStore.js";
|
import { useAtlasStore } from "../store/useAtlasStore.js";
|
||||||
@@ -8,7 +8,8 @@ import { useAtlasStore } from "../store/useAtlasStore.js";
|
|||||||
type Deck = (Structure & { quizCard: QuizCard | null })[];
|
type Deck = (Structure & { quizCard: QuizCard | null })[];
|
||||||
|
|
||||||
export default function QuizPage() {
|
export default function QuizPage() {
|
||||||
const { bySlug } = useAllStructures();
|
const visibleSystems = useAtlasStore((s) => s.visibleSystems);
|
||||||
|
const { bySlug } = useSystemStructures(visibleSystems);
|
||||||
const [deck, setDeck] = useState<Deck>([]);
|
const [deck, setDeck] = useState<Deck>([]);
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
|
const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);
|
||||||
|
|||||||
Reference in new issue
Block a user