From 9107fc2027de22ac7b051946755178076f38d880 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 15:03:28 +0000 Subject: [PATCH 1/3] Add species browse page with filtering and pagination Users can now browse species at /browse with filters for COI sequence availability, MI edge presence, extinct status, and sort by name or edge count. Defaults to showing species with MI edges sorted by most connections, making it easy to find species with genetic similarity data. - GET /v1/species endpoint with has_sequences, has_edges, is_extinct, clade, and sort query params - Frontend browse page with filter buttons, pagination, and species cards showing COI/MI badges - Nav link "Species" added to header, "Browse Species" CTA on home page - 13 backend tests, 12 frontend tests for the new feature https://claude.ai/code/session_015FCj2G9CHpnsJqHcF3n8aw --- apps/api/src/evograph/api/routes/species.py | 160 +++++++++ apps/api/src/evograph/api/schemas/taxa.py | 17 + apps/api/src/evograph/main.py | 3 +- apps/api/tests/conftest.py | 10 + apps/api/tests/test_species.py | 134 +++++++ apps/web/src/__tests__/BrowsePage.test.tsx | 187 ++++++++++ apps/web/src/app/browse/page.tsx | 365 ++++++++++++++++++++ apps/web/src/app/layout.tsx | 1 + apps/web/src/app/page.tsx | 20 +- apps/web/src/lib/api.ts | 25 +- apps/web/src/lib/types.ts | 17 + 11 files changed, 935 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/evograph/api/routes/species.py create mode 100644 apps/api/tests/test_species.py create mode 100644 apps/web/src/__tests__/BrowsePage.test.tsx create mode 100644 apps/web/src/app/browse/page.tsx diff --git a/apps/api/src/evograph/api/routes/species.py b/apps/api/src/evograph/api/routes/species.py new file mode 100644 index 0000000..0b7a245 --- /dev/null +++ b/apps/api/src/evograph/api/routes/species.py @@ -0,0 +1,160 @@ +"""Browse species endpoint with filtering and pagination.""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from evograph.api.schemas.taxa import SpeciesBrowsePage, SpeciesSummary +from evograph.db.models import Edge, NodeMedia, Sequence, Taxon +from evograph.db.session import get_db + +router = APIRouter(tags=["species"]) + + +@router.get("/species", response_model=SpeciesBrowsePage) +def browse_species( + offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=100), + has_sequences: bool | None = Query(None, description="Filter to species with/without COI sequences"), + has_edges: bool | None = Query(None, description="Filter to species with/without MI edges"), + is_extinct: bool | None = Query(None, description="Filter by extinct status"), + clade: int | None = Query(None, description="Filter to descendants of this ott_id"), + sort: str = Query("name", pattern="^(name|edges)$", description="Sort by name or edge count"), + db: Session = Depends(get_db), +) -> SpeciesBrowsePage: + """Browse species with optional filters. + + Supports filtering by sequence availability, MI edge presence, + extinct status, and clade membership. Paginated with offset/limit. + """ + # Base filter: only species-rank taxa + filters = [Taxon.rank == "species"] + + # Filter: extinct status + if is_extinct is not None: + filters.append(Taxon.is_extinct == is_extinct) + + # Filter: clade membership via lineage array contains + if clade is not None: + filters.append(Taxon.lineage.any(clade)) + + # Subqueries for sequence/edge existence + has_seq_subq = ( + db.query(Sequence.ott_id) + .filter(Sequence.ott_id == Taxon.ott_id, Sequence.is_canonical.is_(True)) + .correlate(Taxon) + .exists() + ) + + has_edge_subq = ( + db.query(Edge.src_ott_id) + .filter( + or_( + Edge.src_ott_id == Taxon.ott_id, + Edge.dst_ott_id == Taxon.ott_id, + ) + ) + .correlate(Taxon) + .exists() + ) + + if has_sequences is True: + filters.append(has_seq_subq) + elif has_sequences is False: + filters.append(~has_seq_subq) + + if has_edges is True: + filters.append(has_edge_subq) + elif has_edges is False: + filters.append(~has_edge_subq) + + # Count total matching + total = ( + db.query(func.count(Taxon.ott_id)) + .filter(*filters) + .scalar() + ) or 0 + + # Build query for fetching rows + base = db.query(Taxon).filter(*filters) + + # Sort order + if sort == "edges": + # Subquery for edge count per species + edge_count_sq = ( + db.query(func.count()) + .filter( + or_( + Edge.src_ott_id == Taxon.ott_id, + Edge.dst_ott_id == Taxon.ott_id, + ) + ) + .correlate(Taxon) + .scalar_subquery() + ) + base = base.order_by(edge_count_sq.desc(), Taxon.name) + else: + base = base.order_by(Taxon.name) + + # Fetch page + rows = base.offset(offset).limit(limit).all() + + if not rows: + return SpeciesBrowsePage(items=[], total=total, offset=offset, limit=limit) + + ott_ids = [t.ott_id for t in rows] + + # Batch: images + images: dict[int, str] = {} + media_rows = ( + db.query(NodeMedia.ott_id, NodeMedia.image_url) + .filter(NodeMedia.ott_id.in_(ott_ids)) + .all() + ) + images = {ott: url for ott, url in media_rows} + + # Batch: which have canonical sequences + seq_ott_ids: set[int] = set() + seq_rows = ( + db.query(Sequence.ott_id) + .filter(Sequence.ott_id.in_(ott_ids), Sequence.is_canonical.is_(True)) + .all() + ) + seq_ott_ids = {r[0] for r in seq_rows} + + # Batch: edge counts per species + edge_counts: dict[int, int] = {} + src_counts = ( + db.query(Edge.src_ott_id, func.count()) + .filter(Edge.src_ott_id.in_(ott_ids)) + .group_by(Edge.src_ott_id) + .all() + ) + for ott, cnt in src_counts: + edge_counts[ott] = edge_counts.get(ott, 0) + cnt + dst_counts = ( + db.query(Edge.dst_ott_id, func.count()) + .filter(Edge.dst_ott_id.in_(ott_ids)) + .group_by(Edge.dst_ott_id) + .all() + ) + for ott, cnt in dst_counts: + edge_counts[ott] = edge_counts.get(ott, 0) + cnt + + return SpeciesBrowsePage( + items=[ + SpeciesSummary( + ott_id=t.ott_id, + name=t.name, + rank=t.rank, + image_url=images.get(t.ott_id), + is_extinct=t.is_extinct, + has_sequence=t.ott_id in seq_ott_ids, + edge_count=edge_counts.get(t.ott_id, 0), + ) + for t in rows + ], + total=total, + offset=offset, + limit=limit, + ) diff --git a/apps/api/src/evograph/api/schemas/taxa.py b/apps/api/src/evograph/api/schemas/taxa.py index cc6293b..7998e52 100644 --- a/apps/api/src/evograph/api/schemas/taxa.py +++ b/apps/api/src/evograph/api/schemas/taxa.py @@ -37,3 +37,20 @@ class SearchPage(BaseModel): items: list[TaxonSummary] total: int limit: int + + +class SpeciesSummary(BaseModel): + ott_id: int + name: str + rank: str + image_url: str | None = None + is_extinct: bool | None = None + has_sequence: bool = False + edge_count: int = 0 + + +class SpeciesBrowsePage(BaseModel): + items: list[SpeciesSummary] + total: int + offset: int + limit: int diff --git a/apps/api/src/evograph/main.py b/apps/api/src/evograph/main.py index ba82bc4..4689a73 100644 --- a/apps/api/src/evograph/main.py +++ b/apps/api/src/evograph/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.gzip import GZipMiddleware from sqlalchemy import text -from evograph.api.routes import graph, jobs, search, sequences, stats, taxa +from evograph.api.routes import graph, jobs, search, sequences, species, stats, taxa from evograph.db.session import SessionLocal, engine from evograph.logging_config import configure_logging from evograph.middleware.rate_limit import RateLimitMiddleware @@ -62,6 +62,7 @@ async def lifespan(app: FastAPI): app.include_router(taxa.router, prefix="/v1") app.include_router(graph.router, prefix="/v1") app.include_router(sequences.router, prefix="/v1") +app.include_router(species.router, prefix="/v1") app.include_router(stats.router, prefix="/v1") app.include_router(jobs.router, prefix="/v1") diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py index e51eeb5..932c6a1 100644 --- a/apps/api/tests/conftest.py +++ b/apps/api/tests/conftest.py @@ -113,6 +113,16 @@ def group_by(self, *args, **kwargs): def select_from(self, *args, **kwargs): return self + def correlate(self, *args, **kwargs): + return self + + def scalar_subquery(self): + return self + + def desc(self): + """Support ORDER BY ... DESC on scalar subqueries.""" + return self + def exists(self): """Return an exists clause marker for use in outer query.""" return MockExistsClause() diff --git a/apps/api/tests/test_species.py b/apps/api/tests/test_species.py new file mode 100644 index 0000000..fd37877 --- /dev/null +++ b/apps/api/tests/test_species.py @@ -0,0 +1,134 @@ +"""Tests for the /v1/species endpoint.""" + +from evograph.db.models import Edge, Sequence, Taxon +from tests.conftest import _make_taxon + + +class TestBrowseSpecies: + def test_returns_species_list(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + _make_taxon(893498, "Corvus corone", "species"), + ]) + + resp = client.get("/v1/species") + assert resp.status_code == 200 + + data = resp.json() + assert len(data["items"]) == 2 + assert data["items"][0]["name"] == "Corvus corax" + assert data["items"][0]["ott_id"] == 700118 + assert data["items"][0]["rank"] == "species" + + def test_returns_empty_for_no_species(self, client, mock_db): + mock_db.set(Taxon, []) + + resp = client.get("/v1/species") + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_response_includes_pagination_fields(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species", params={"offset": 10, "limit": 25}) + assert resp.status_code == 200 + data = resp.json() + assert data["offset"] == 10 + assert data["limit"] == 25 + assert "total" in data + + def test_species_includes_has_sequence_field(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + mock_db.set(Sequence, []) + + resp = client.get("/v1/species") + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert "has_sequence" in item + assert isinstance(item["has_sequence"], bool) + + def test_species_includes_edge_count_field(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + mock_db.set(Edge, []) + + resp = client.get("/v1/species") + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert "edge_count" in item + assert isinstance(item["edge_count"], int) + + def test_accepts_has_sequences_filter(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species", params={"has_sequences": "true"}) + assert resp.status_code == 200 + + def test_accepts_has_edges_filter(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species", params={"has_edges": "true"}) + assert resp.status_code == 200 + + def test_accepts_is_extinct_filter(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species", params={"is_extinct": "false"}) + assert resp.status_code == 200 + + def test_accepts_sort_param(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species", params={"sort": "edges"}) + assert resp.status_code == 200 + + resp = client.get("/v1/species", params={"sort": "name"}) + assert resp.status_code == 200 + + def test_rejects_invalid_sort_param(self, client, mock_db): + resp = client.get("/v1/species", params={"sort": "invalid"}) + assert resp.status_code == 422 + + def test_limit_validation(self, client, mock_db): + resp = client.get("/v1/species", params={"limit": 0}) + assert resp.status_code == 422 + + resp = client.get("/v1/species", params={"limit": 101}) + assert resp.status_code == 422 + + def test_species_image_url_defaults_to_null(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species") + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert "image_url" in item + assert item["image_url"] is None + + def test_species_fields_match_schema(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species") + data = resp.json() + assert set(data.keys()) >= {"items", "total", "offset", "limit"} + item = data["items"][0] + assert set(item.keys()) >= {"ott_id", "name", "rank", "has_sequence", "edge_count"} diff --git a/apps/web/src/__tests__/BrowsePage.test.tsx b/apps/web/src/__tests__/BrowsePage.test.tsx new file mode 100644 index 0000000..7826eb3 --- /dev/null +++ b/apps/web/src/__tests__/BrowsePage.test.tsx @@ -0,0 +1,187 @@ +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import BrowsePage from "../app/browse/page"; + +const mockSpeciesData = { + items: [ + { + ott_id: 700118, + name: "Corvus corax", + rank: "species", + image_url: "https://example.com/corax.jpg", + is_extinct: false, + has_sequence: true, + edge_count: 12, + }, + { + ott_id: 893498, + name: "Corvus corone", + rank: "species", + image_url: null, + is_extinct: false, + has_sequence: false, + edge_count: 0, + }, + ], + total: 2, + offset: 0, + limit: 50, +}; + +jest.mock("../lib/api", () => ({ + browseSpecies: jest.fn(), +})); + +jest.mock("next/link", () => { + return function MockLink({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) { + return {children}; + }; +}); + +import { browseSpecies } from "../lib/api"; +const mockBrowseSpecies = browseSpecies as jest.MockedFunction; + +describe("BrowsePage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("shows loading skeleton initially", () => { + mockBrowseSpecies.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText("Browse Species")).toBeInTheDocument(); + }); + + it("renders species after loading", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("Corvus corax")).toBeInTheDocument(); + }); + expect(screen.getByText("Corvus corone")).toBeInTheDocument(); + }); + + it("shows COI badge for species with sequences", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("COI")).toBeInTheDocument(); + }); + }); + + it("shows MI edges badge with count", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("12 MI edges")).toBeInTheDocument(); + }); + }); + + it("shows total count", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("2 species found")).toBeInTheDocument(); + }); + }); + + it("shows error state", async () => { + mockBrowseSpecies.mockRejectedValue(new Error("Network error")); + render(); + + await waitFor(() => { + expect(screen.getByText("Failed to load species: Network error")).toBeInTheDocument(); + }); + }); + + it("shows empty state when no results", async () => { + mockBrowseSpecies.mockResolvedValue({ + items: [], + total: 0, + offset: 0, + limit: 50, + }); + render(); + + await waitFor(() => { + expect(screen.getByText("No species match the current filters.")).toBeInTheDocument(); + }); + }); + + it("renders filter buttons", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + expect(screen.getByText("With MI edges")).toBeInTheDocument(); + expect(screen.getByText("With COI")).toBeInTheDocument(); + expect(screen.getByText("All species")).toBeInTheDocument(); + }); + + it("renders sort dropdown", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + expect(screen.getByText("Sort: A-Z")).toBeInTheDocument(); + expect(screen.getByText("Sort: Most edges")).toBeInTheDocument(); + }); + + it("renders include extinct checkbox", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + expect(screen.getByText("Include extinct")).toBeInTheDocument(); + }); + + it("links to taxon detail page", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("Corvus corax")).toBeInTheDocument(); + }); + + const link = screen.getByText("Corvus corax").closest("a"); + expect(link).toHaveAttribute("href", "/taxa/700118"); + }); + + it("shows pagination for large results", async () => { + mockBrowseSpecies.mockResolvedValue({ + ...mockSpeciesData, + total: 150, + }); + render(); + + await waitFor(() => { + expect(screen.getByText("Previous")).toBeInTheDocument(); + }); + expect(screen.getByText("Next")).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + }); + + it("calls API with filter params when clicking filter buttons", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("Corvus corax")).toBeInTheDocument(); + }); + + // Click "With COI" filter + fireEvent.click(screen.getByText("With COI")); + + await waitFor(() => { + expect(mockBrowseSpecies).toHaveBeenCalledWith( + expect.objectContaining({ has_sequences: true }) + ); + }); + }); +}); diff --git a/apps/web/src/app/browse/page.tsx b/apps/web/src/app/browse/page.tsx new file mode 100644 index 0000000..a7ff0e2 --- /dev/null +++ b/apps/web/src/app/browse/page.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import Link from "next/link"; +import { browseSpecies } from "@/lib/api"; +import type { SpeciesSummary, SpeciesBrowsePage } from "@/lib/types"; + +const RANK_COLORS: Record = { + species: "#4fc3f7", +}; + +const PAGE_SIZE = 50; + +type FilterKey = "all" | "with_sequences" | "with_edges"; + +function SpeciesCard({ species }: { species: SpeciesSummary }) { + const accent = RANK_COLORS[species.rank] ?? "#888"; + + return ( + +
+ {species.image_url && ( + {species.name} + )} +
+
{species.name}
+
+ {species.is_extinct && ( + + extinct + + )} + {species.has_sequence && ( + + COI + + )} + {species.edge_count > 0 && ( + + {species.edge_count} MI edges + + )} +
+
+
+ + ); +} + +export default function BrowsePage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [offset, setOffset] = useState(0); + const [filter, setFilter] = useState("with_edges"); + const [showExtinct, setShowExtinct] = useState(false); + const [sort, setSort] = useState<"name" | "edges">("edges"); + + const fetchData = useCallback(() => { + setLoading(true); + setError(null); + + const params: Record = { + offset, + limit: PAGE_SIZE, + sort, + }; + + if (filter === "with_sequences") { + params.has_sequences = true; + } else if (filter === "with_edges") { + params.has_edges = true; + } + + if (!showExtinct) { + params.is_extinct = false; + } + + browseSpecies(params as Parameters[0]) + .then(setData) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, [offset, filter, showExtinct, sort]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + // Reset offset when filters change + useEffect(() => { + setOffset(0); + }, [filter, showExtinct, sort]); + + const totalPages = data ? Math.ceil(data.total / PAGE_SIZE) : 0; + const currentPage = Math.floor(offset / PAGE_SIZE) + 1; + + return ( +
+
+

Browse Species

+
+ +

+ Explore species in the database. Filter to those with COI sequences or + MI similarity edges to find species with genetic data. +

+ + {/* Filter controls */} +
+
+ {( + [ + ["with_edges", "With MI edges"], + ["with_sequences", "With COI"], + ["all", "All species"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ +
+ + + +
+
+ + {/* Results count */} + {data && !loading && ( +
+ {data.total.toLocaleString()} species found + {totalPages > 1 && ` · Page ${currentPage} of ${totalPages}`} +
+ )} + + {/* Error */} + {error && ( +
+ Failed to load species: {error} +
+ )} + + {/* Loading skeleton */} + {loading && ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ ))} +
+ )} + + {/* Results */} + {!loading && data && ( + <> + {data.items.length === 0 ? ( +
+ No species match the current filters. +
+ ) : ( +
+ {data.items.map((sp) => ( + + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 68cc084..6c2d41a 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -23,6 +23,7 @@ export default function RootLayout({
Home + Species Graph Stats
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 2281b11..1c492ea 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -25,9 +25,9 @@ export default function HomePage() {

-
+
+ Browse Species + + Open Graph Explorer diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c546199..47a924b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { TaxonDetail, SearchPage, ChildrenPage, SequencePage, GraphResponse, NeighborOut, StatsResponse } from "./types"; +import type { TaxonDetail, SearchPage, ChildrenPage, SequencePage, GraphResponse, NeighborOut, StatsResponse, SpeciesBrowsePage } from "./types"; const API_BASE = process.env.NEXT_PUBLIC_API_BASE || "http://localhost:8000"; @@ -43,3 +43,26 @@ export function getSequences(ottId: number, offset = 0, limit = 50) { export function getStats() { return getJSON(`/v1/stats`); } + +export interface BrowseSpeciesParams { + offset?: number; + limit?: number; + has_sequences?: boolean; + has_edges?: boolean; + is_extinct?: boolean; + clade?: number; + sort?: "name" | "edges"; +} + +export function browseSpecies(params: BrowseSpeciesParams = {}) { + const searchParams = new URLSearchParams(); + if (params.offset !== undefined) searchParams.set("offset", String(params.offset)); + if (params.limit !== undefined) searchParams.set("limit", String(params.limit)); + if (params.has_sequences !== undefined) searchParams.set("has_sequences", String(params.has_sequences)); + if (params.has_edges !== undefined) searchParams.set("has_edges", String(params.has_edges)); + if (params.is_extinct !== undefined) searchParams.set("is_extinct", String(params.is_extinct)); + if (params.clade !== undefined) searchParams.set("clade", String(params.clade)); + if (params.sort !== undefined) searchParams.set("sort", params.sort); + const qs = searchParams.toString(); + return getJSON(`/v1/species${qs ? `?${qs}` : ""}`); +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index e862c6a..ca8b3f0 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -33,6 +33,23 @@ export interface SearchPage { limit: number; } +export interface SpeciesSummary { + ott_id: number; + name: string; + rank: string; + image_url: string | null; + is_extinct?: boolean | null; + has_sequence: boolean; + edge_count: number; +} + +export interface SpeciesBrowsePage { + items: SpeciesSummary[]; + total: number; + offset: number; + limit: number; +} + export interface SequenceOut { id: string; ott_id: number; From 31e584f3c23d476fcb806d0a313517be144ec7b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 15:15:13 +0000 Subject: [PATCH 2/3] Show taxonomy context on species cards, sort children by rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for navigability: 1. Species browse cards now show "Order > Family" (e.g. "Passeriformes > Corvidae") so you can see where a species fits without clicking through. Uses batch lineage lookup — one query for all ancestor family/order names. 2. Taxon detail children are now sorted by rank priority (orders first, then families, genera, species, subspecies) instead of purely alphabetical. For Aves with 729 children, this means the first 100 shown are the orders and families you actually want to navigate, not a random mix of subspecies and environmental samples. https://claude.ai/code/session_015FCj2G9CHpnsJqHcF3n8aw --- apps/api/src/evograph/api/routes/species.py | 35 +++++++++++++++++++++ apps/api/src/evograph/api/routes/taxa.py | 21 +++++++++++-- apps/api/src/evograph/api/schemas/taxa.py | 2 ++ apps/api/tests/test_species.py | 16 +++++++++- apps/web/src/__tests__/BrowsePage.test.tsx | 16 ++++++++++ apps/web/src/app/browse/page.tsx | 12 ++++++- apps/web/src/lib/types.ts | 2 ++ 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/apps/api/src/evograph/api/routes/species.py b/apps/api/src/evograph/api/routes/species.py index 0b7a245..0c487b1 100644 --- a/apps/api/src/evograph/api/routes/species.py +++ b/apps/api/src/evograph/api/routes/species.py @@ -141,6 +141,39 @@ def browse_species( for ott, cnt in dst_counts: edge_counts[ott] = edge_counts.get(ott, 0) + cnt + # Batch: family and order names from lineage arrays + # Collect all ancestor ott_ids from lineages + all_ancestor_ids: set[int] = set() + for t in rows: + if t.lineage: + all_ancestor_ids.update(t.lineage) + + # Fetch only family/order ancestors in one query + ancestor_map: dict[int, tuple[str, str]] = {} # ott_id -> (name, rank) + if all_ancestor_ids: + ancestor_rows = ( + db.query(Taxon.ott_id, Taxon.name, Taxon.rank) + .filter( + Taxon.ott_id.in_(all_ancestor_ids), + Taxon.rank.in_(["family", "order"]), + ) + .all() + ) + ancestor_map = {ott: (name, rank) for ott, name, rank in ancestor_rows} + + # Build per-species family/order lookup + species_family: dict[int, str] = {} + species_order: dict[int, str] = {} + for t in rows: + if t.lineage: + for anc_id in t.lineage: + info = ancestor_map.get(anc_id) + if info: + if info[1] == "family": + species_family[t.ott_id] = info[0] + elif info[1] == "order": + species_order[t.ott_id] = info[0] + return SpeciesBrowsePage( items=[ SpeciesSummary( @@ -151,6 +184,8 @@ def browse_species( is_extinct=t.is_extinct, has_sequence=t.ott_id in seq_ott_ids, edge_count=edge_counts.get(t.ott_id, 0), + family_name=species_family.get(t.ott_id), + order_name=species_order.get(t.ott_id), ) for t in rows ], diff --git a/apps/api/src/evograph/api/routes/taxa.py b/apps/api/src/evograph/api/routes/taxa.py index 0d1a502..f43979f 100644 --- a/apps/api/src/evograph/api/routes/taxa.py +++ b/apps/api/src/evograph/api/routes/taxa.py @@ -1,7 +1,7 @@ """Taxon detail endpoint with paginated children.""" from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func, text +from sqlalchemy import case, func, text from sqlalchemy.orm import Session from evograph.api.schemas.taxa import ChildrenPage, TaxonDetail, TaxonSummary @@ -12,6 +12,20 @@ _INLINE_CHILDREN_LIMIT = 100 +# Higher-rank children appear first so navigating the tree starts with the +# most useful groupings (orders, families) rather than a random alphabetical +# mix of species and subspecies. +_RANK_SORT_ORDER = case( + (Taxon.rank == "class", 0), + (Taxon.rank == "order", 1), + (Taxon.rank == "family", 2), + (Taxon.rank == "subfamily", 3), + (Taxon.rank == "genus", 4), + (Taxon.rank == "species", 5), + (Taxon.rank == "subspecies", 6), + else_=7, +) + def _fetch_lineage(db: Session, ott_id: int) -> list[TaxonSummary]: """Fetch full lineage (root → ... → parent) using a recursive CTE. @@ -62,10 +76,11 @@ def get_taxon( ) or 0 # Get children (limited for inline display) + # Sort by rank importance so orders/families appear before species/subspecies children = ( db.query(Taxon) .filter(Taxon.parent_ott_id == ott_id) - .order_by(Taxon.name) + .order_by(_RANK_SORT_ORDER, Taxon.name) .limit(_INLINE_CHILDREN_LIMIT) .all() ) @@ -159,7 +174,7 @@ def get_children( children = ( db.query(Taxon) .filter(Taxon.parent_ott_id == ott_id) - .order_by(Taxon.name) + .order_by(_RANK_SORT_ORDER, Taxon.name) .offset(offset) .limit(limit) .all() diff --git a/apps/api/src/evograph/api/schemas/taxa.py b/apps/api/src/evograph/api/schemas/taxa.py index 7998e52..aa9b1d5 100644 --- a/apps/api/src/evograph/api/schemas/taxa.py +++ b/apps/api/src/evograph/api/schemas/taxa.py @@ -47,6 +47,8 @@ class SpeciesSummary(BaseModel): is_extinct: bool | None = None has_sequence: bool = False edge_count: int = 0 + family_name: str | None = None + order_name: str | None = None class SpeciesBrowsePage(BaseModel): diff --git a/apps/api/tests/test_species.py b/apps/api/tests/test_species.py index fd37877..57e380c 100644 --- a/apps/api/tests/test_species.py +++ b/apps/api/tests/test_species.py @@ -131,4 +131,18 @@ def test_species_fields_match_schema(self, client, mock_db): data = resp.json() assert set(data.keys()) >= {"items", "total", "offset", "limit"} item = data["items"][0] - assert set(item.keys()) >= {"ott_id", "name", "rank", "has_sequence", "edge_count"} + assert set(item.keys()) >= { + "ott_id", "name", "rank", "has_sequence", "edge_count", + "family_name", "order_name", + } + + def test_species_taxonomy_defaults_to_null(self, client, mock_db): + mock_db.set(Taxon, [ + _make_taxon(700118, "Corvus corax", "species"), + ]) + + resp = client.get("/v1/species") + item = resp.json()["items"][0] + # Without lineage data, family/order default to null + assert item["family_name"] is None + assert item["order_name"] is None diff --git a/apps/web/src/__tests__/BrowsePage.test.tsx b/apps/web/src/__tests__/BrowsePage.test.tsx index 7826eb3..49da9f6 100644 --- a/apps/web/src/__tests__/BrowsePage.test.tsx +++ b/apps/web/src/__tests__/BrowsePage.test.tsx @@ -11,6 +11,8 @@ const mockSpeciesData = { is_extinct: false, has_sequence: true, edge_count: 12, + family_name: "Corvidae", + order_name: "Passeriformes", }, { ott_id: 893498, @@ -20,6 +22,8 @@ const mockSpeciesData = { is_extinct: false, has_sequence: false, edge_count: 0, + family_name: "Corvidae", + order_name: "Passeriformes", }, ], total: 2, @@ -167,6 +171,18 @@ describe("BrowsePage", () => { expect(screen.getByText("1 / 3")).toBeInTheDocument(); }); + it("shows family and order taxonomy context", async () => { + mockBrowseSpecies.mockResolvedValue(mockSpeciesData); + render(); + + await waitFor(() => { + expect(screen.getByText("Corvus corax")).toBeInTheDocument(); + }); + // Both species are in Passeriformes > Corvidae + const taxonomyLabels = screen.getAllByText("Passeriformes > Corvidae"); + expect(taxonomyLabels.length).toBe(2); + }); + it("calls API with filter params when clicking filter buttons", async () => { mockBrowseSpecies.mockResolvedValue(mockSpeciesData); render(); diff --git a/apps/web/src/app/browse/page.tsx b/apps/web/src/app/browse/page.tsx index a7ff0e2..a2f1c5c 100644 --- a/apps/web/src/app/browse/page.tsx +++ b/apps/web/src/app/browse/page.tsx @@ -15,6 +15,9 @@ type FilterKey = "all" | "with_sequences" | "with_edges"; function SpeciesCard({ species }: { species: SpeciesSummary }) { const accent = RANK_COLORS[species.rank] ?? "#888"; + const taxonomy = [species.order_name, species.family_name] + .filter(Boolean) + .join(" > "); return ( )}
-
{species.name}
+
+ {species.name} + {taxonomy && ( + + {taxonomy} + + )} +
Date: Sun, 1 Mar 2026 15:18:11 +0000 Subject: [PATCH 3/3] Spread out graph layout for large networks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MI network (~620 nodes, 10k edges) was too tightly clustered. ForceAtlas2 parameters now scale with node count: - scalingRatio 40→120 for >300 nodes (stronger repulsion) - gravity 0.08→0.02 (less center-pull) - Initial positions spread 200→1200 (wider start) - 200 iterations instead of 100 (more time to converge) - adjustSizes enabled (prevents node overlap) Small graphs (<300 nodes) keep the previous settings. https://claude.ai/code/session_015FCj2G9CHpnsJqHcF3n8aw --- apps/web/src/components/GraphViewSigma.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/GraphViewSigma.tsx b/apps/web/src/components/GraphViewSigma.tsx index cb81390..084482e 100644 --- a/apps/web/src/components/GraphViewSigma.tsx +++ b/apps/web/src/components/GraphViewSigma.tsx @@ -120,25 +120,29 @@ function applyLayout(g: Graph, mode: "force" | "radial"): void { return; } - // Random initial positions + const nodeCount = g.order; + + // Spread initial positions proportional to graph size + const spread = Math.max(500, nodeCount * 2); g.forEachNode((node) => { - g.setNodeAttribute(node, "x", (Math.random() - 0.5) * 200); - g.setNodeAttribute(node, "y", (Math.random() - 0.5) * 200); + g.setNodeAttribute(node, "x", (Math.random() - 0.5) * spread); + g.setNodeAttribute(node, "y", (Math.random() - 0.5) * spread); }); - // Fast layout: 100 iterations is plenty for 200 nodes + // Scale layout parameters with graph size so large networks spread out const inferred = forceAtlas2.inferSettings(g); + const iterations = nodeCount > 300 ? 200 : 100; forceAtlas2.assign(g, { - iterations: 100, + iterations, settings: { ...inferred, linLogMode: true, - scalingRatio: 40, - gravity: 0.08, + scalingRatio: nodeCount > 300 ? 120 : 40, + gravity: nodeCount > 300 ? 0.02 : 0.08, strongGravityMode: false, barnesHutOptimize: true, - adjustSizes: false, - slowDown: 1, + adjustSizes: true, + slowDown: 2, }, }); }