diff --git a/backend/src/routes/structures.ts b/backend/src/routes/structures.ts index 975921d..489f54c 100644 --- a/backend/src/routes/structures.ts +++ b/backend/src/routes/structures.ts @@ -25,6 +25,12 @@ export async function structuresRoutes(app: FastifyInstance) { }, include: { system: true }, 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, }); }); diff --git a/docker-compose.yml b/docker-compose.yml index a3ec953..dfb2777 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,14 +30,14 @@ services: anatomia-api: container_name: anatomia-api build: - context: ./backend + context: ./anatomia/backend restart: unless-stopped depends_on: anatomia-db: condition: service_healthy environment: 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 MODELS_DIR: /app/models volumes: @@ -52,7 +52,7 @@ services: anatomia-web: container_name: anatomia-web build: - context: . + context: ./anatomia dockerfile: frontend/Dockerfile restart: unless-stopped depends_on: @@ -79,7 +79,7 @@ services: anatomia-pipeline: container_name: anatomia-pipeline build: - context: ./data-pipeline + context: ./anatomia/data-pipeline profiles: ["tools"] depends_on: anatomia-db: @@ -87,7 +87,7 @@ services: anatomia-api: condition: service_started 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: - anatomia_models_data:/app/seed/models networks: diff --git a/frontend/src/components/SearchNav.tsx b/frontend/src/components/SearchNav.tsx index 6bc5722..ff4b225 100644 --- a/frontend/src/components/SearchNav.tsx +++ b/frontend/src/components/SearchNav.tsx @@ -1,38 +1,45 @@ -import { useMemo, useState } from "react"; -import Fuse from "fuse.js"; -import type { Structure } from "../lib/api.js"; +import { useEffect, useRef, useState } from "react"; +import { api, type Structure } from "../lib/api.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 [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + const systems = useSystems(); + const [expanded, setExpanded] = 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 debounceRef = useRef>(); - const results = query.trim() ? fuse.search(query).slice(0, 20).map((r) => r.item) : []; - - const tree = useMemo(() => { - const root = new Map>(); - 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); + useEffect(() => { + const q = query.trim(); + if (!q) { + setResults([]); + setSearching(false); + return; } - return root; - }, [structures]); + setSearching(true); + 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) { if (!visibleSystems.has(structure.system.slug)) { @@ -42,6 +49,28 @@ export function SearchNav({ structures }: { structures: Structure[] }) { 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 (
{query.trim() ? (
    - {results.map((s) => ( -
  • - -
  • - ))} - {results.length === 0 && ( + {searching &&
  • Buscando...
  • } + {!searching && + results.map((s) => ( +
  • + +
  • + ))} + {!searching && results.length === 0 && (
  • Nenhuma estrutura encontrada.
  • )}
) : ( -
- {[...tree.entries()].map(([system, regions]) => ( -
- {system} -
- {[...regions.entries()].map(([region, items]) => - // Estruturas reais (Z-Anatomy) não têm sub-região própria - // — 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 ? ( -
    - {items.map((s) => ( -
  • - -
  • - ))} -
- ) : ( -
- - {region} - -
    - {items.map((s) => ( -
  • - -
  • - ))} -
-
- ), +
+ {systems.map((sys) => ( +
{ + if ((e.target as HTMLDetailsElement).open) toggleExpand(sys.slug); + }} + > + + + {sys.namePt} + {sys._count && ( + ({sys._count.structures}) + )} + +
+ {expanded[sys.slug] === "loading" && ( +

Carregando...

+ )} + {Array.isArray(expanded[sys.slug]) && ( +
    + {(expanded[sys.slug] as Structure[]).map((s) => ( +
  • + +
  • + ))} +
)}
diff --git a/frontend/src/hooks/useStructures.ts b/frontend/src/hooks/useStructures.ts index 8ea2a2b..cd58456 100644 --- a/frontend/src/hooks/useStructures.ts +++ b/frontend/src/hooks/useStructures.ts @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useState } from "react"; -import { api, type AnatomicalSystem, type Structure } from "../lib/api.js"; +import { useEffect, useState } from "react"; +import { api, type AnatomicalSystem } from "../lib/api.js"; export function useSystems() { const [systems, setSystems] = useState([]); @@ -8,18 +8,3 @@ export function useSystems() { }, []); return systems; } - -export function useAllStructures() { - const [structures, setStructures] = useState([]); - useEffect(() => { - api.structures().then(setStructures).catch(console.error); - }, []); - - const bySlug = useMemo(() => { - const map = new Map(); - for (const s of structures) map.set(s.slug, s); - return map; - }, [structures]); - - return { structures, bySlug }; -} diff --git a/frontend/src/hooks/useSystemStructures.ts b/frontend/src/hooks/useSystemStructures.ts new file mode 100644 index 0000000..8be17a1 --- /dev/null +++ b/frontend/src/hooks/useSystemStructures.ts @@ -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) { + const cacheRef = useRef>(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(); + for (const s of structures) map.set(s.slug, s); + return map; + }, [structures]); + + return { structures, bySlug }; +} diff --git a/frontend/src/routes/AtlasPage.tsx b/frontend/src/routes/AtlasPage.tsx index e067255..6e66072 100644 --- a/frontend/src/routes/AtlasPage.tsx +++ b/frontend/src/routes/AtlasPage.tsx @@ -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 { LayerToggle } from "../components/LayerToggle.js"; import { ClippingControls } from "../components/ClippingControls.js"; @@ -6,7 +7,8 @@ import { SearchNav } from "../components/SearchNav.js"; import { StructureInfoPanel } from "../components/StructureInfoPanel.js"; export default function AtlasPage() { - const { structures, bySlug } = useAllStructures(); + const visibleSystems = useAtlasStore((s) => s.visibleSystems); + const { bySlug } = useSystemStructures(visibleSystems); return (
@@ -14,7 +16,7 @@ export default function AtlasPage() {
- +
diff --git a/frontend/src/routes/QuizPage.tsx b/frontend/src/routes/QuizPage.tsx index b5845cd..1800009 100644 --- a/frontend/src/routes/QuizPage.tsx +++ b/frontend/src/routes/QuizPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; 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 { LayerToggle } from "../components/LayerToggle.js"; import { useAtlasStore } from "../store/useAtlasStore.js"; @@ -8,7 +8,8 @@ import { useAtlasStore } from "../store/useAtlasStore.js"; type Deck = (Structure & { quizCard: QuizCard | null })[]; export default function QuizPage() { - const { bySlug } = useAllStructures(); + const visibleSystems = useAtlasStore((s) => s.visibleSystems); + const { bySlug } = useSystemStructures(visibleSystems); const [deck, setDeck] = useState([]); const [index, setIndex] = useState(0); const [feedback, setFeedback] = useState<"correct" | "wrong" | null>(null);