diff --git a/@types/bionym-components.d.ts b/@types/bionym-components.d.ts new file mode 100644 index 000000000..df5fb12a1 --- /dev/null +++ b/@types/bionym-components.d.ts @@ -0,0 +1,58 @@ +// bionym-components ships plain JavaScript with no types. These cover the +// parts the gene page's graph explorer uses; see the package's README. +declare module "bionym-components" { + export interface BionymComponentsNode { + id: string; + label: string; + type: string; + url: string | null; + } + + export interface BionymComponentsEdge { + confidence: number; + object: string; + predicate: string; + subject: string; + } + + export interface BionymComponentsGraph { + edges: BionymComponentsEdge[]; + metadata: { query: string }; + nodes: BionymComponentsNode[]; + } + + export type BionymNetworkLayout = "columns" | "force"; + + export interface BionymNetworkOptions { + layout?: BionymNetworkLayout; + minConfidence?: number; + onSelect?: (node: BionymComponentsNode | null) => void; + } + + export interface BionymNetworkInstance { + destroy(): void; + fit(): void; + setMinConfidence(minConfidence: number): void; + zoomBy(factor: number): void; + } + + export function BionymNetwork( + el: HTMLElement, + graph: BionymComponentsGraph, + opts?: BionymNetworkOptions + ): BionymNetworkInstance; + + export interface BionymOverviewOptions { + onBarClick?: (nodeType: string) => void; + } + + export interface BionymOverviewInstance { + destroy(): void; + } + + export function BionymOverview( + el: HTMLElement, + graph: BionymComponentsGraph, + opts?: BionymOverviewOptions + ): BionymOverviewInstance; +} diff --git a/backend/api/app/api/v1/genes.py b/backend/api/app/api/v1/genes.py index 15e1a97c8..3ca1a3063 100644 --- a/backend/api/app/api/v1/genes.py +++ b/backend/api/app/api/v1/genes.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException from app.core.dependencies import get_bionym_graphs, get_gene_catalog_service -from app.models.genes import BionymClaims, GeneResponse +from app.models.genes import BionymClaims, BionymGraph, GeneResponse from app.services.bionym_graphs import BionymGraphStore from app.services.gene_catalog import GeneCatalogService @@ -42,3 +42,18 @@ def get_gene_bionym( status_code=404, detail=f"Gene {gene_uid} has no BioNym graph" ) return claims + + +@router.get("/{gene_uid}/bionym/graph", response_model=BionymGraph) +def get_gene_bionym_graph( + gene_uid: UUID, + graphs: BionymGraphStore = Depends(get_bionym_graphs), +) -> BionymGraph: + """Every node and edge in the gene's BioNym graph, for the gene page's + graph explorer. Like the claims, read from the graph alone.""" + graph = graphs.get_graph(gene_uid) + if graph is None: + raise HTTPException( + status_code=404, detail=f"Gene {gene_uid} has no BioNym graph" + ) + return graph diff --git a/backend/api/app/models/genes.py b/backend/api/app/models/genes.py index 6a67bbfeb..33868ae86 100644 --- a/backend/api/app/models/genes.py +++ b/backend/api/app/models/genes.py @@ -115,6 +115,38 @@ class BionymSource(_CamelModel): claims: List[BionymClaim] +class BionymGraphNode(_CamelModel): + """A node in a gene's BioNym graph: a gene, protein, paper, dataset, + claim or another kind of thing BioNym found.""" + + id: str + type: str + label: str + # Null unless the graph gives an https: link. + url: Optional[str] + + +class BionymGraphEdge(_CamelModel): + """An edge in a gene's BioNym graph, with BioNym's confidence in it, + from 0 to 1.""" + + subject: str + predicate: str + object: str + confidence: float + + +class BionymGraph(_CamelModel): + """A gene's BioNym graph: every node and edge, with only the fields the + gene page's graph explorer draws.""" + + gene_uid: UUID + # The id of the gene's own node, the graph's root. + query: str + nodes: List[BionymGraphNode] + edges: List[BionymGraphEdge] + + class BionymClaims(_CamelModel): """The claims in a gene's BioNym graph, grouped by source.""" diff --git a/backend/api/app/services/bionym_graphs.py b/backend/api/app/services/bionym_graphs.py index 2dd0cf005..50ffae7f5 100644 --- a/backend/api/app/services/bionym_graphs.py +++ b/backend/api/app/services/bionym_graphs.py @@ -1,9 +1,12 @@ -"""BioNym's claims about a gene, read from the gene's BioNym graph. +"""BioNym's claims about a gene, and the graph itself, read from the gene's +BioNym graph. A BioNym graph is BioNym's JSON output for one gene: nodes, edges between them, and metadata about the run. Each claim is a Claim node that a Publication or Dataset node `reports`; the edge holds BioNym's confidence in the claim, and its one piece of evidence holds the quote the claim rests on. +The graph view keeps every node and edge, but only the fields the gene +page's graph explorer draws. The graphs are checked in, gzipped and named by gene UID, under app/data/bionym (see its README) until they are published to the gene data @@ -21,7 +24,14 @@ from pydantic import BaseModel -from app.models.genes import BionymClaim, BionymClaims, BionymSource +from app.models.genes import ( + BionymClaim, + BionymClaims, + BionymGraph, + BionymGraphEdge, + BionymGraphNode, + BionymSource, +) BIONYM_GRAPHS_DIR = Path(__file__).resolve().parent.parent / "data" / "bionym" @@ -53,6 +63,7 @@ class _Edge(BaseModel): class _Metadata(BaseModel): input: str + query: str class _Graph(BaseModel): @@ -124,22 +135,66 @@ def build_claims(gene_uid: UUID, graph: _Graph) -> BionymClaims: ) +def build_graph(gene_uid: UUID, graph: _Graph) -> BionymGraph: + """Every node and edge in the graph, in BioNym's order, with only the + fields the graph explorer draws and only https: links. + + Raises: + ValueError: The graph's query isn't a node, or an edge ends at an id + that isn't a node. + """ + if graph.metadata.query not in graph.nodes: + raise ValueError(f"the graph's query {graph.metadata.query!r} isn't a node") + for edge in graph.edges: + for end in (edge.subject, edge.object): + if end not in graph.nodes: + raise ValueError( + f"an edge {edge.predicate!r} ends at {end!r}, not a node" + ) + return BionymGraph( + gene_uid=gene_uid, + query=graph.metadata.query, + nodes=[ + BionymGraphNode( + id=node_id, type=node.type, label=node.label, url=_https_url(node.url) + ) + for node_id, node in graph.nodes.items() + ], + edges=[ + BionymGraphEdge( + subject=edge.subject, + predicate=edge.predicate, + object=edge.object, + confidence=edge.confidence, + ) + for edge in graph.edges + ], + ) + + class BionymGraphStore: """The BioNym graphs in a directory, each named .json.gz. - Every graph's claims are built when the store is made: on the first - BioNym request, and in the tests over every checked-in graph, so a graph - of the wrong shape fails the tests rather than reaching a gene's page. + Every graph's claims and graph view are built when the store is made: on + the first BioNym request, and in the tests over every checked-in graph, so + a graph of the wrong shape fails the tests rather than reaching a gene's + page. """ def __init__(self, directory: Path): self._claims: Dict[UUID, BionymClaims] = {} + self._graphs: Dict[UUID, BionymGraph] = {} for path in directory.glob("*.json.gz"): gene_uid = UUID(path.name.removesuffix(".json.gz")) with gzip.open(path, "rb") as f: graph = _Graph.model_validate_json(f.read()) self._claims[gene_uid] = build_claims(gene_uid, graph) + self._graphs[gene_uid] = build_graph(gene_uid, graph) def get_claims(self, gene_uid: UUID) -> Optional[BionymClaims]: """The gene's claims, or None if it has no graph.""" return self._claims.get(gene_uid) + + def get_graph(self, gene_uid: UUID) -> Optional[BionymGraph]: + """The gene's graph view, or None if it has no graph.""" + return self._graphs.get(gene_uid) diff --git a/backend/api/tests/test_genes_api.py b/backend/api/tests/test_genes_api.py index 4d838812f..4c74168d3 100644 --- a/backend/api/tests/test_genes_api.py +++ b/backend/api/tests/test_genes_api.py @@ -280,9 +280,10 @@ def test_transcripts_are_in_genome_order(client, gene_uid): def write_graph(directory, gene_uid: str, sources: dict) -> None: """Write a BioNym graph in which each source reports one claim. - sources maps a source's node ID to its node type and URL. + sources maps a source's node ID to its node type and URL. The graph's + query is a gene node, PF3D7_0000000, with no edges. """ - nodes = {} + nodes = {"PF3D7_0000000": {"type": "Gene", "label": "PF3D7_0000000", "url": None}} edges = [] for i, (source_id, (node_type, url)) in enumerate(sources.items()): claim_id = f"claim:claim {i}" @@ -302,7 +303,11 @@ def write_graph(directory, gene_uid: str, sources: dict) -> None: ], } ) - graph = {"nodes": nodes, "edges": edges, "metadata": {"input": "PF3D7_0000000"}} + graph = { + "nodes": nodes, + "edges": edges, + "metadata": {"input": "PF3D7_0000000", "query": "PF3D7_0000000"}, + } with gzip.open(directory / f"{gene_uid}.json.gz", "wt") as f: json.dump(graph, f) @@ -354,9 +359,38 @@ def test_every_checked_in_bionym_graph_is_read(checked_in_graphs, gene_uid, locu assert {s.kind for s in claims.sources} == {"paper", "dataset"} -def test_gene_without_a_bionym_graph_is_404(tmp_path): +def test_bionym_graph(checked_in_graphs): + response = make_client(graphs=checked_in_graphs).get( + f"/api/v1/genes/{KELCH13_UID}/bionym/graph" + ) + assert response.status_code == 200 + body = response.json() + assert body["geneUid"] == KELCH13_UID + # The whole graph, ortholog-group members included. + assert len(body["nodes"]) == 5239 + assert len(body["edges"]) == 6536 + assert sum(e["predicate"] == "has_member" for e in body["edges"]) == 2460 + [root] = [n for n in body["nodes"] if n["id"] == body["query"]] + assert (root["id"], root["type"]) == ("PF3D7_1343700", "Gene") + # Only the fields the explorer draws. + assert set(root) == {"id", "type", "label", "url"} + assert set(body["edges"][0]) == {"subject", "predicate", "object", "confidence"} + + +@pytest.mark.parametrize("gene_uid, locus_tag", BIONYM_GENES.items()) +def test_every_checked_in_bionym_graph_has_its_gene_as_root( + checked_in_graphs, gene_uid, locus_tag +): + graph = checked_in_graphs.get_graph(uuid.UUID(gene_uid)) + assert graph is not None + assert graph.query == locus_tag + assert {n.type for n in graph.nodes if n.id == locus_tag} == {"Gene"} + + +@pytest.mark.parametrize("path", ["bionym", "bionym/graph"]) +def test_gene_without_a_bionym_graph_is_404(tmp_path, path): response = make_client(graphs=BionymGraphStore(tmp_path)).get( - f"/api/v1/genes/{uuid.uuid4()}/bionym" + f"/api/v1/genes/{uuid.uuid4()}/{path}" ) assert response.status_code == 404 @@ -391,6 +425,18 @@ def test_bionym_links_other_than_https_are_dropped(tmp_path): "dataset:3": ("dataset", None), "dataset:4": ("dataset", None), } + nodes = ( + make_client(graphs=BionymGraphStore(tmp_path)) + .get(f"/api/v1/genes/{uid}/bionym/graph") + .json()["nodes"] + ) + assert {n["id"]: n["url"] for n in nodes if n["type"] != "Claim"} == { + "PF3D7_0000000": None, + "pubmed:1": "https://pubmed.ncbi.nlm.nih.gov/1/", + "pubmed:2": None, + "dataset:3": None, + "dataset:4": None, + } def test_bionym_claim_from_a_node_that_is_not_a_source_is_an_error(tmp_path): @@ -398,3 +444,34 @@ def test_bionym_claim_from_a_node_that_is_not_a_source_is_an_error(tmp_path): write_graph(tmp_path, str(uid), {"assembly:1": ("Assembly", None)}) with pytest.raises(ValueError, match="isn't a source"): BionymGraphStore(tmp_path) + + +def _rewrite_graph(directory, gene_uid: str, change) -> None: + path = directory / f"{gene_uid}.json.gz" + with gzip.open(path, "rt") as f: + graph = json.load(f) + change(graph) + with gzip.open(path, "wt") as f: + json.dump(graph, f) + + +def test_bionym_graph_whose_query_is_not_a_node_is_an_error(tmp_path): + uid = str(uuid.uuid4()) + write_graph(tmp_path, uid, {"pubmed:1": ("Publication", None)}) + _rewrite_graph(tmp_path, uid, lambda g: g["metadata"].update(query="PF3D7_9999999")) + with pytest.raises(ValueError, match="isn't a node"): + BionymGraphStore(tmp_path) + + +def test_bionym_graph_edge_to_a_missing_node_is_an_error(tmp_path): + uid = str(uuid.uuid4()) + write_graph(tmp_path, uid, {"pubmed:1": ("Publication", None)}) + _rewrite_graph( + tmp_path, + uid, + lambda g: g["edges"].append( + {**g["edges"][0], "predicate": "cited_in", "object": "pubmed:missing"} + ), + ) + with pytest.raises(ValueError, match="not a node"): + BionymGraphStore(tmp_path) diff --git a/package-lock.json b/package-lock.json index 08ad2d004..b86d71900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", + "bionym-components": "0.1.1", "copy-to-clipboard": "^3.3.3", "d3-color": "^3.1.0", "d3-hierarchy": "^3.1.2", @@ -8064,6 +8065,15 @@ "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", "license": "MIT" }, + "node_modules/bionym-components": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/bionym-components/-/bionym-components-0.1.1.tgz", + "integrity": "sha512-uYrAAgSrSJDbfoAbkr1j2XnsOeVR+b2uzXOrGSTbvSbfS05efVVFxNBSa6blwcatZKQeil0IRRTeqFI5pzBnkA==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0" + } + }, "node_modules/boxen": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", diff --git a/package.json b/package.json index 043cc54e0..234e91fc6 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.13", + "bionym-components": "0.1.1", "copy-to-clipboard": "^3.3.3", "d3-color": "^3.1.0", "d3-hierarchy": "^3.1.2", diff --git a/packages/shared/hooks/useGeneBionymGraph.ts b/packages/shared/hooks/useGeneBionymGraph.ts new file mode 100644 index 000000000..b2488d205 --- /dev/null +++ b/packages/shared/hooks/useGeneBionymGraph.ts @@ -0,0 +1,26 @@ +import { fetchGeneBionymGraph } from "@repo/shared/services/genes/api"; +import { type BionymGraph } from "@repo/shared/services/genes/types"; +import { useQuery } from "@tanstack/react-query"; + +export type GeneBionymGraphResult = + | { graph: BionymGraph; status: "found" } + | { status: "loading" } + | { status: "unavailable" }; + +/** + * Fetch a gene's BioNym graph, for the graph explorer. + * @param geneUid - The gene's UID. + * @returns The graph; loading while it loads; or unavailable when the gene + * has no graph or the API can't be reached. + */ +export function useGeneBionymGraph(geneUid: string): GeneBionymGraphResult { + const { data, isError, isPending } = useQuery({ + queryFn: ({ signal }) => fetchGeneBionymGraph(geneUid, signal), + queryKey: ["gene-bionym-graph", geneUid], + // The graphs only change at a deploy, so a loaded graph isn't refetched. + staleTime: Infinity, + }); + if (isPending) return { status: "loading" }; + if (isError || !data) return { status: "unavailable" }; + return { graph: data, status: "found" }; +} diff --git a/packages/shared/services/api-client/api-client.ts b/packages/shared/services/api-client/api-client.ts index 529dd5ab4..76a1fd25a 100644 --- a/packages/shared/services/api-client/api-client.ts +++ b/packages/shared/services/api-client/api-client.ts @@ -1,6 +1,7 @@ import { API_BASE_URL } from "@repo/shared/config/api"; import { type BionymClaims, + type BionymGraph, type GeneResponse, } from "@repo/shared/services/genes/types"; import ky, { type HTTPError } from "ky"; @@ -91,6 +92,19 @@ export const apiClient = { .json(); }, + getGeneBionymGraph: async ( + geneUid: string, + signal?: AbortSignal + ): Promise => { + // ky doesn't retry; useGeneBionymGraph's query does. + return httpClient + .get(`genes/${encodeURIComponent(geneUid)}/bionym/graph`, { + retry: 0, + signal, + }) + .json(); + }, + getLoganSearches: async ( offset = 0, limit = 20 diff --git a/packages/shared/services/genes/api.ts b/packages/shared/services/genes/api.ts index 7f0bd869f..86e6e7a1c 100644 --- a/packages/shared/services/genes/api.ts +++ b/packages/shared/services/genes/api.ts @@ -1,6 +1,6 @@ import { apiClient } from "@repo/shared/services/api-client/api-client"; import { HTTPError } from "ky"; -import { type BionymClaims, type Gene } from "./types"; +import { type BionymClaims, type BionymGraph, type Gene } from "./types"; export type GeneResult = | { gene: Gene; status: "found" } @@ -45,6 +45,25 @@ export async function fetchGeneBionym( } } +/** + * Fetch a gene's BioNym graph. + * @param geneUid - The gene's UID. + * @param signal - Aborts the request. + * @returns The graph, or null when the gene has no BioNym graph. + * @throws When the API can't be reached. + */ +export async function fetchGeneBionymGraph( + geneUid: string, + signal?: AbortSignal +): Promise { + try { + return await apiClient.getGeneBionymGraph(geneUid, signal); + } catch (error) { + if (isNotFound(error)) return null; + throw error; + } +} + /** * Whether a gene API request failed because what it asked for doesn't exist. * @param error - The request's error. diff --git a/packages/shared/services/genes/types.ts b/packages/shared/services/genes/types.ts index 3407127c5..56183c165 100644 --- a/packages/shared/services/genes/types.ts +++ b/packages/shared/services/genes/types.ts @@ -120,3 +120,38 @@ export interface BionymClaims { runDate: string; sources: BionymSource[]; } + +/** + * A node in a gene's BioNym graph: a gene, protein, paper, dataset, claim or + * another kind of thing BioNym found. + */ +export interface BionymGraphNode { + id: string; + label: string; + type: string; + // Null unless the graph gives an https: link. + url: string | null; +} + +/** + * An edge in a gene's BioNym graph, with BioNym's confidence in it, from 0 + * to 1. + */ +export interface BionymGraphEdge { + confidence: number; + object: string; + predicate: string; + subject: string; +} + +/** + * A gene's BioNym graph: every node and edge, with only the fields the graph + * explorer draws. + */ +export interface BionymGraph { + edges: BionymGraphEdge[]; + geneUid: string; + nodes: BionymGraphNode[]; + // The id of the gene's own node, the graph's root. + query: string; +} diff --git a/sites/brc-analytics/tsconfig.json b/sites/brc-analytics/tsconfig.json index d98256ad4..cdfc557ec 100644 --- a/sites/brc-analytics/tsconfig.json +++ b/sites/brc-analytics/tsconfig.json @@ -16,6 +16,7 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", + "../../@types/bionym-components.d.ts", "../../@types/findable-ui.d.ts", "../../packages/shared/@types/theme.d.ts", "../../@types/vega-embed.d.ts" diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.styles.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.styles.ts index 71676ac21..79613fc83 100644 --- a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.styles.ts +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.styles.ts @@ -2,7 +2,7 @@ import styled from "@emotion/styled"; // The label, slider and count on one row, wrapping onto more on a narrow // screen rather than overflowing the panel. -export const ConfidenceControl = styled.div` +export const ConfidenceRow = styled.div` align-items: center; display: flex; flex-wrap: wrap; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.tsx index 3c78e7bf7..c00ddcbae 100644 --- a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.tsx +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/bionymSection.tsx @@ -1,60 +1,47 @@ import { GenesPanel } from "@brc/components/Genes/genes.styles"; +import { Tabs } from "@databiosphere/findable-ui/lib/components/common/Tabs/tabs"; import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link"; import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; -import { Slider, Typography } from "@mui/material"; -import { Tooltip } from "@repo/shared/components/Tooltip/tooltip"; +import { Typography } from "@mui/material"; import { formatDate } from "@repo/shared/utils/date-fns/utils"; import { SectionTitle } from "@repo/shared/views/EntityView/ui/SectionTitle/sectionTitle"; import { parseISO } from "date-fns"; -import { - type JSX, - memo, - useDeferredValue, - useId, - useMemo, - useState, -} from "react"; -import { - ClaimList, - ConfidenceControl, - Source, - SourceList, -} from "./bionymSection.styles"; -import { - BIONYM_URL, - CONFIDENCE_STEP, - DEFAULT_MIN_CONFIDENCE, - SOURCE_KIND_LABEL, -} from "./constants"; -import { - type BionymSectionProps, - type ClaimItemProps, - type SourceListViewProps, -} from "./types"; -import { countClaims, roundToStep, visibleSources } from "./utils"; +import dynamic from "next/dynamic"; +import { type JSX, useId, useState } from "react"; +import { ClaimsPanel } from "./components/ClaimsPanel/claimsPanel"; +import { GraphLoading } from "./components/GraphLoading/graphLoading"; +import { BIONYM_URL, DEFAULT_MIN_CONFIDENCE, TAB, TABS } from "./constants"; +import { useBionymTab } from "./hooks/useBionymTab"; +import { type BionymSectionProps } from "./types"; +import { getActiveTab } from "./utils"; + +// The graph explorer brings D3 and BioNym's components with it, so it loads +// only when its tab is first opened, and only in the browser. +const GraphPanel = dynamic( + () => import("./components/GraphPanel/graphPanel").then((m) => m.GraphPanel), + { + loading: GraphLoading, + ssr: false, + } +); /** - * The claims BioNym drew about a gene, grouped by source, with a slider that - * sets the lowest confidence shown. Kept apart from the gene's facts, and - * labelled as BioNym's scored judgments. + * What BioNym gathered about a gene, kept apart from the gene's facts and + * labelled as BioNym's scored judgments: its claims on one tab and its graph + * on another, the tab chosen by the URL hash. The tabs share one minimum + * confidence. * @param props - Component props. * @param props.bionym - The claims in the gene's BioNym graph. * @returns The section. */ export const BionymSection = ({ bionym }: BionymSectionProps): JSX.Element => { const headingId = useId(); - const sliderLabelId = useId(); + const { selectTab, tab } = useBionymTab(); const [minConfidence, setMinConfidence] = useState(DEFAULT_MIN_CONFIDENCE); - // The list follows the slider without holding up its drag: a gene can have - // over a thousand claims, and the list is memoized, so a slider move - // re-renders it only once the deferred value catches up. - const deferredMinConfidence = useDeferredValue(minConfidence); - const totalCount = countClaims(bionym.sources); - const sources = useMemo( - () => visibleSources(bionym.sources, deferredMinConfidence), - [bionym, deferredMinConfidence] - ); - const shownCount = countClaims(sources); + const [graphOpened, setGraphOpened] = useState(tab === TAB.GRAPH); + // Set during render, not in an effect, so the graph shows on the same render + // as the tab that opens it. + if (tab === TAB.GRAPH && !graphOpened) setGraphOpened(true); return ( @@ -70,104 +57,34 @@ export const BionymSection = ({ bionym }: BionymSectionProps): JSX.Element => { language model, and each was scored by{" "} with a confidence from 0 to 1. They are BioNym's judgments, not facts about the gene. BioNym - gathered them on {formatDate(parseISO(bionym.runDate))}. Hover over a - claim to see the quote it was drawn from. + gathered them on {formatDate(parseISO(bionym.runDate))}. - - - Minimum confidence - - - setMinConfidence(roundToStep(value as number)) - } - size="small" - step={CONFIDENCE_STEP} - value={minConfidence} - valueLabelDisplay="auto" + { + selectTab(getActiveTab(String(value))); + }} + tabs={TABS} + value={tab} + /> + {tab === TAB.CLAIMS && ( + - - {shownCount} of {totalCount} claims - - - + )} + {/* Once opened, the graph stays on the page, hidden while the Claims tab + shows, so its filters, layout and selection survive a tab switch and + the network isn't drawn again. */} + {graphOpened && ( + + )} ); }; - -/** - * One claim, showing its text, with its quote in a tooltip on hover. - * Memoized: filtering keeps each claim object, so a slider move renders only - * the claims it adds. - * @param props - Component props. - * @param props.claim - The claim. - * @returns The claim. - */ -const ClaimItem = memo(function ClaimItem({ - claim, -}: ClaimItemProps): JSX.Element { - return ( -
  • - {/* describeChild keeps the claim text as the item's accessible name, with - the quote as its description, rather than the quote replacing it. */} - - - {claim.text} - - -
  • - ); -}); - -/** - * The sources shown, each with its claims. Memoized, so a slider move doesn't - * re-render it until the sources shown change. - * @param props - Component props. - * @param props.sources - The sources to show, each with the claims to show. - * @returns The list. - */ -const SourceListView = memo(function SourceListView({ - sources, -}: SourceListViewProps): JSX.Element { - return ( - - {sources.map((source) => ( - - - {source.url ? ( - - ) : ( - source.title - )} - - - {SOURCE_KIND_LABEL[source.kind]} - - - {source.claims.map((claim) => ( - - ))} - - - ))} - - ); -}); diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ClaimsPanel/claimsPanel.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ClaimsPanel/claimsPanel.tsx new file mode 100644 index 000000000..f3266e95c --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ClaimsPanel/claimsPanel.tsx @@ -0,0 +1,135 @@ +import { + ClaimList, + Source, + SourceList, +} from "@brc/views/GeneDetailView/components/BionymSection/bionymSection.styles"; +import { ConfidenceControl } from "@brc/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/confidenceControl"; +import { SOURCE_KIND_LABEL } from "@brc/views/GeneDetailView/components/BionymSection/constants"; +import { + countClaims, + formatConfidence, + visibleSources, +} from "@brc/views/GeneDetailView/components/BionymSection/utils"; +import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { Typography } from "@mui/material"; +import { Tooltip } from "@repo/shared/components/Tooltip/tooltip"; +import { type JSX, memo, useDeferredValue, useMemo } from "react"; +import { + type ClaimItemProps, + type ClaimsPanelProps, + type SourceListViewProps, +} from "./types"; + +/** + * The Claims tab: the claims BioNym drew about the gene, grouped by source, + * at or above the section's minimum confidence. + * @param props - Component props. + * @param props.bionym - The claims in the gene's BioNym graph. + * @param props.minConfidence - The lowest confidence shown. + * @param props.onMinConfidenceChange - Called with a new minimum confidence. + * @returns The panel. + */ +export const ClaimsPanel = ({ + bionym, + minConfidence, + onMinConfidenceChange, +}: ClaimsPanelProps): JSX.Element => { + // The list follows the slider without holding up its drag: a gene can have + // over a thousand claims, and the list is memoized, so a slider move + // re-renders it only once the deferred value catches up. + const deferredMinConfidence = useDeferredValue(minConfidence); + const totalCount = countClaims(bionym.sources); + const sources = useMemo( + () => visibleSources(bionym.sources, deferredMinConfidence), + [bionym, deferredMinConfidence] + ); + return ( + <> + + Hover over a claim to see the quote it was drawn from. + + + + + ); +}; + +/** + * One claim, showing its text and confidence, with its quote in a tooltip on + * hover. Memoized: filtering keeps each claim object, so a slider move + * renders only the claims it adds. + * @param props - Component props. + * @param props.claim - The claim. + * @returns The claim. + */ +const ClaimItem = memo(function ClaimItem({ + claim, +}: ClaimItemProps): JSX.Element { + return ( +
  • + {/* describeChild keeps the claim text as the item's accessible name, with + the quote as its description, rather than the quote replacing it. */} + + + {claim.text}{" "} + + (confidence {formatConfidence(claim.confidence)}) + + + +
  • + ); +}); + +/** + * The sources shown, each with its claims. Memoized, so a slider move doesn't + * re-render it until the sources shown change. + * @param props - Component props. + * @param props.sources - The sources to show, each with the claims to show. + * @returns The list. + */ +const SourceListView = memo(function SourceListView({ + sources, +}: SourceListViewProps): JSX.Element { + return ( + + {sources.map((source) => ( + + + {source.url ? ( + + ) : ( + source.title + )} + + + {SOURCE_KIND_LABEL[source.kind]} + + + {source.claims.map((claim) => ( + + ))} + + + ))} + + ); +}); diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ClaimsPanel/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ClaimsPanel/types.ts new file mode 100644 index 000000000..1dbf50752 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ClaimsPanel/types.ts @@ -0,0 +1,18 @@ +import { type MinConfidenceProps } from "@brc/views/GeneDetailView/components/BionymSection/types"; +import { + type BionymClaim, + type BionymClaims, + type BionymSource, +} from "@repo/shared/services/genes/types"; + +export interface ClaimsPanelProps extends MinConfidenceProps { + bionym: BionymClaims; +} + +export interface ClaimItemProps { + claim: BionymClaim; +} + +export interface SourceListViewProps { + sources: BionymSource[]; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/confidenceControl.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/confidenceControl.tsx new file mode 100644 index 000000000..394bc67a5 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/confidenceControl.tsx @@ -0,0 +1,48 @@ +import { ConfidenceRow } from "@brc/views/GeneDetailView/components/BionymSection/bionymSection.styles"; +import { CONFIDENCE_STEP } from "@brc/views/GeneDetailView/components/BionymSection/constants"; +import { roundToStep } from "@brc/views/GeneDetailView/components/BionymSection/utils"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { Slider, Typography } from "@mui/material"; +import { type JSX, useId } from "react"; +import { type ConfidenceControlProps } from "./types"; + +/** + * The minimum-confidence slider, with a count of what it lets through. + * @param props - Component props. + * @param props.count - What the slider lets through, as "N of M claims". + * @param props.minConfidence - The slider's value. + * @param props.onMinConfidenceChange - Called with the slider's new value. + * @returns The control. + */ +export const ConfidenceControl = ({ + count, + minConfidence, + onMinConfidenceChange, +}: ConfidenceControlProps): JSX.Element => { + const labelId = useId(); + return ( + + + Minimum confidence + + + onMinConfidenceChange(roundToStep(value as number)) + } + size="small" + step={CONFIDENCE_STEP} + value={minConfidence} + valueLabelDisplay="auto" + /> + + {count} + + + ); +}; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/types.ts new file mode 100644 index 000000000..fa2f5e353 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/types.ts @@ -0,0 +1,5 @@ +import { type MinConfidenceProps } from "@brc/views/GeneDetailView/components/BionymSection/types"; + +export interface ConfidenceControlProps extends MinConfidenceProps { + count: string; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphLoading/graphLoading.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphLoading/graphLoading.tsx new file mode 100644 index 000000000..49582122b --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphLoading/graphLoading.tsx @@ -0,0 +1,20 @@ +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { Typography } from "@mui/material"; +import { type JSX } from "react"; +import { type GraphLoadingProps } from "./types"; + +/** + * What the Graph tab shows while its code or its graph loads, or if its code + * fails to load, as it can for a page left open across a deploy. Kept apart + * from the Graph tab's code, which loads only when the tab opens. + * @param props - Component props. + * @param props.error - The error loading the tab's code, if it failed. + * @returns The message. + */ +export const GraphLoading = ({ error }: GraphLoadingProps): JSX.Element => ( + + {error + ? "The graph couldn't be loaded. Please reload the page." + : "Loading the graph…"} + +); diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphLoading/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphLoading/types.ts new file mode 100644 index 000000000..27a5885ce --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphLoading/types.ts @@ -0,0 +1,4 @@ +export interface GraphLoadingProps { + // Set by next/dynamic when the tab's code failed to load. + error?: Error | null; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/EdgeKindSwitches/edgeKindSwitches.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/EdgeKindSwitches/edgeKindSwitches.tsx new file mode 100644 index 000000000..e00d4349d --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/EdgeKindSwitches/edgeKindSwitches.tsx @@ -0,0 +1,48 @@ +import { SwitchGrid } from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.styles"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { FormControlLabel, Switch, Typography } from "@mui/material"; +import { type JSX, useId } from "react"; +import { type EdgeKindSwitchesProps } from "./types"; + +/** + * A switch for each kind of edge in the graph, with its count, to show or + * hide it. + * @param props - Component props. + * @param props.counts - Each kind of edge and its count, most first. + * @param props.hidden - The kinds of edge hidden. + * @param props.onToggle - Called with the kind of edge switched. + * @returns The switches. + */ +export const EdgeKindSwitches = ({ + counts, + hidden, + onToggle, +}: EdgeKindSwitchesProps): JSX.Element => { + const labelId = useId(); + return ( +
    + + Kinds of edge + + + {counts.map(([predicate, count]) => ( + onToggle(predicate)} + size="small" + /> + } + key={predicate} + label={`${predicate} (${count})`} + /> + ))} + +
    + ); +}; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/EdgeKindSwitches/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/EdgeKindSwitches/types.ts new file mode 100644 index 000000000..fe4914d62 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/EdgeKindSwitches/types.ts @@ -0,0 +1,6 @@ +export interface EdgeKindSwitchesProps { + // Each kind of edge in the graph and its count, most first. + counts: [string, number][]; + hidden: ReadonlySet; + onToggle: (predicate: string) => void; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NetworkView/networkView.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NetworkView/networkView.tsx new file mode 100644 index 000000000..82c1da084 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NetworkView/networkView.tsx @@ -0,0 +1,109 @@ +import { ZOOM_STEP } from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/constants"; +import { NetworkToolbar } from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.styles"; +import { toComponentsGraph } from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/utils"; +import FitScreenRoundedIcon from "@mui/icons-material/FitScreenRounded"; +import ZoomInRoundedIcon from "@mui/icons-material/ZoomInRounded"; +import ZoomOutRoundedIcon from "@mui/icons-material/ZoomOutRounded"; +import { IconButton, ToggleButton, ToggleButtonGroup } from "@mui/material"; +import { + BionymNetwork, + type BionymNetworkInstance, + type BionymNetworkLayout, +} from "bionym-components"; +import { type JSX, useEffect, useRef, useState } from "react"; +import { type NetworkViewProps } from "./types"; + +/** + * BioNym's network, drawn into a div, with a layout switch and zoom buttons, + * since the network has no buttons of its own. + * @param props - Component props. + * @param props.graph - The graph to draw. + * @param props.minConfidence - The lowest confidence drawn. + * @param props.onSelect - Called with the node clicked, or null when no node + * is selected, including after each redraw. + * @returns The network. + */ +export const NetworkView = ({ + graph, + minConfidence, + onSelect, +}: NetworkViewProps): JSX.Element => { + const elRef = useRef(null); + const networkRef = useRef(null); + const minConfidenceRef = useRef(minConfidence); + const onSelectRef = useRef(onSelect); + const [layout, setLayout] = useState("columns"); + + useEffect(() => { + onSelectRef.current = onSelect; + }, [onSelect]); + + // Drawn afresh when the graph or layout changes, which resets pan and zoom, + // as the network's own update does. Each drawing starts with no node + // selected, and the network doesn't report that, so this does. + useEffect(() => { + const el = elRef.current; + if (!el) return; + const network = BionymNetwork(el, toComponentsGraph(graph), { + layout, + minConfidence: minConfidenceRef.current, + onSelect: (node) => onSelectRef.current(node?.id ?? null), + }); + networkRef.current = network; + onSelectRef.current(null); + return (): void => { + network.destroy(); + networkRef.current = null; + }; + }, [graph, layout]); + + // A new minimum confidence redraws the network, keeping pan and zoom but + // dropping its selection. + useEffect(() => { + if (minConfidenceRef.current === minConfidence) return; + minConfidenceRef.current = minConfidence; + networkRef.current?.setMinConfidence(minConfidence); + onSelectRef.current(null); + }, [minConfidence]); + + return ( +
    + + { + if (value) setLayout(value); + }} + size="small" + value={layout} + > + Columns + Force + + networkRef.current?.zoomBy(ZOOM_STEP)} + size="small" + > + + + networkRef.current?.zoomBy(1 / ZOOM_STEP)} + size="small" + > + + + networkRef.current?.fit()} + size="small" + > + + + +
    +
    + ); +}; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NetworkView/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NetworkView/types.ts new file mode 100644 index 000000000..51fde22e1 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NetworkView/types.ts @@ -0,0 +1,9 @@ +import { type BionymGraph } from "@repo/shared/services/genes/types"; + +export interface NetworkViewProps { + graph: BionymGraph; + minConfidence: number; + // Called with the id of the node clicked, or null when no node is selected: + // after a click that clears it, and after each redraw, which clears it too. + onSelect: (nodeId: string | null) => void; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NodeDetail/nodeDetail.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NodeDetail/nodeDetail.tsx new file mode 100644 index 000000000..b3ff60c8c --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NodeDetail/nodeDetail.tsx @@ -0,0 +1,81 @@ +import { + DetailPanel, + EdgeList, +} from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.styles"; +import { + httpsUrl, + nodeEdges, +} from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/utils"; +import { formatConfidence } from "@brc/views/GeneDetailView/components/BionymSection/utils"; +import { Link } from "@databiosphere/findable-ui/lib/components/Links/components/Link/link"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { Typography } from "@mui/material"; +import { type JSX, useId, useMemo } from "react"; +import { type NodeDetailProps } from "./types"; + +/** + * The node clicked in the network: its label and type, a link to its source + * when that is an https: link, and its edges shown at the minimum confidence, + * each with its confidence. It has no close button: the network can drop its + * selection only by redrawing, which moves the nodes in the Force layout, so + * the panel closes when a click on empty space or another node changes the + * network's selection. + * @param props - Component props. + * @param props.graph - The graph drawn. + * @param props.minConfidence - The lowest confidence shown. + * @param props.node - The node. + * @returns The panel. + */ +export const NodeDetail = ({ + graph, + minConfidence, + node, +}: NodeDetailProps): JSX.Element => { + const headingId = useId(); + const url = httpsUrl(node); + // The panel re-renders as the slider moves, but its threshold changes only + // once the slider settles. + const edges = useMemo( + () => nodeEdges(graph, node.id, minConfidence), + [graph, node.id, minConfidence] + ); + return ( + + ); +}; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NodeDetail/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NodeDetail/types.ts new file mode 100644 index 000000000..9175a3d02 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/NodeDetail/types.ts @@ -0,0 +1,10 @@ +import { + type BionymGraph, + type BionymGraphNode, +} from "@repo/shared/services/genes/types"; + +export interface NodeDetailProps { + graph: BionymGraph; + minConfidence: number; + node: BionymGraphNode; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/OverviewView/overviewView.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/OverviewView/overviewView.tsx new file mode 100644 index 000000000..ae7e6824c --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/OverviewView/overviewView.tsx @@ -0,0 +1,35 @@ +import { toComponentsGraph } from "@brc/views/GeneDetailView/components/BionymSection/components/GraphPanel/utils"; +import { BionymOverview } from "bionym-components"; +import { type JSX, useEffect, useRef } from "react"; +import { type OverviewViewProps } from "./types"; + +/** + * BioNym's bar chart of the graph's nodes by type, drawn into a div. A bar + * click hides or shows that type of node. + * @param props - Component props. + * @param props.graph - The whole graph, so every type keeps its bar. + * @param props.onTypeClick - Called with the type whose bar was clicked. + * @returns The chart. + */ +export const OverviewView = ({ + graph, + onTypeClick, +}: OverviewViewProps): JSX.Element => { + const elRef = useRef(null); + const onTypeClickRef = useRef(onTypeClick); + + useEffect(() => { + onTypeClickRef.current = onTypeClick; + }, [onTypeClick]); + + useEffect(() => { + const el = elRef.current; + if (!el) return; + const overview = BionymOverview(el, toComponentsGraph(graph), { + onBarClick: (nodeType) => onTypeClickRef.current(nodeType), + }); + return (): void => overview.destroy(); + }, [graph]); + + return
    ; +}; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/OverviewView/types.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/OverviewView/types.ts new file mode 100644 index 000000000..a250c46fd --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/components/OverviewView/types.ts @@ -0,0 +1,7 @@ +import { type BionymGraph } from "@repo/shared/services/genes/types"; + +export interface OverviewViewProps { + graph: BionymGraph; + // Called with the type of node whose bar was clicked. + onTypeClick: (nodeType: string) => void; +} diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/constants.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/constants.ts new file mode 100644 index 000000000..d716d8524 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/constants.ts @@ -0,0 +1,23 @@ +// The kinds of edge hidden when the graph first opens, chosen on the three +// checked-in graphs at the default 0.8. Each adds many nodes that crowd out +// the rest: +// - has_member links the gene's ortholog group to every protein in it +// (2,460 for Kelch13); +// - same_as links aliases of one entity; +// - has_candidate_gene links candidate genes in related assemblies; +// - annotated_in links those genes to the assemblies they're annotated in. +// Hidden, they take Kelch13 from 5,111 nodes to 1,002, CRT from 2,386 to +// 1,452 and AMA1 from 1,475 to 1,061, and its gene column from 1,590 genes to +// 35. Each can be shown again with its switch. +export const DEFAULT_HIDDEN_PREDICATES: ReadonlySet = new Set([ + "annotated_in", + "has_candidate_gene", + "has_member", + "same_as", +]); + +// How long the slider must rest before the network is redrawn at its value. +export const SLIDER_SETTLE_MS = 150; + +// How far each zoom button zooms. +export const ZOOM_STEP = 1.4; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.styles.ts b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.styles.ts new file mode 100644 index 000000000..b6b157c07 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.styles.ts @@ -0,0 +1,59 @@ +import { RoundedPaper } from "@databiosphere/findable-ui/lib/components/common/Paper/components/RoundedPaper/roundedPaper"; +import { bpDown1024 } from "@databiosphere/findable-ui/lib/styles/common/mixins/breakpoints"; +import styled from "@emotion/styled"; + +// The network, with the detail panel beside it, or under it on a narrow +// screen. +export const GraphLayout = styled.div` + align-items: start; + display: grid; + gap: 16px; + grid-template-columns: minmax(0, 1fr) 320px; + + ${bpDown1024} { + grid-template-columns: minmax(0, 1fr); + } +`; + +export const NetworkToolbar = styled.div` + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +`; + +// findable-ui's bordered, rounded card, kept to its content's height rather +// than stretched to the network's. +export const DetailPanel = styled(RoundedPaper)` + align-self: start; + display: grid; + gap: 8px; + overflow-wrap: anywhere; + padding: 12px 16px; +`; + +// A node such as the gene itself can have hundreds of edges, so the list +// scrolls within the panel. +export const EdgeList = styled.ul` + display: grid; + gap: 4px; + margin: 0; + max-height: 480px; + overflow-y: auto; + padding-left: 0; + list-style: none; +`; + +export const HiddenTypes = styled.div` + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +`; + +export const SwitchGrid = styled.div` + display: grid; + gap: 0 16px; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +`; diff --git a/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.tsx b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.tsx new file mode 100644 index 000000000..8dc9531c2 --- /dev/null +++ b/sites/brc-analytics/views/GeneDetailView/components/BionymSection/components/GraphPanel/graphPanel.tsx @@ -0,0 +1,150 @@ +import { ConfidenceControl } from "@brc/views/GeneDetailView/components/BionymSection/components/ConfidenceControl/confidenceControl"; +import { GraphLoading } from "@brc/views/GeneDetailView/components/BionymSection/components/GraphLoading/graphLoading"; +import { TYPOGRAPHY_PROPS } from "@databiosphere/findable-ui/lib/styles/common/mui/typography"; +import { Chip, Typography } from "@mui/material"; +import { useGeneBionymGraph } from "@repo/shared/hooks/useGeneBionymGraph"; +import "bionym-components/styles.css"; +import { type JSX, useMemo, useState } from "react"; +import { EdgeKindSwitches } from "./components/EdgeKindSwitches/edgeKindSwitches"; +import { NetworkView } from "./components/NetworkView/networkView"; +import { NodeDetail } from "./components/NodeDetail/nodeDetail"; +import { OverviewView } from "./components/OverviewView/overviewView"; +import { DEFAULT_HIDDEN_PREDICATES, SLIDER_SETTLE_MS } from "./constants"; +import { GraphLayout, HiddenTypes } from "./graphPanel.styles"; +import { useSettledValue } from "./hooks/useSettledValue"; +import { type GraphExplorerProps, type GraphPanelProps } from "./types"; +import { countPredicates, countShown, filterGraph, toggled } from "./utils"; + +/** + * The Graph tab: the gene's BioNym graph, once it has loaded. + * @param props - Component props. + * @param props.geneUid - The gene's UID. + * @param props.minConfidence - The lowest confidence shown. + * @param props.onMinConfidenceChange - Called with a new minimum confidence. + * @returns The panel. + */ +export const GraphPanel = ({ + geneUid, + minConfidence, + onMinConfidenceChange, +}: GraphPanelProps): JSX.Element => { + const result = useGeneBionymGraph(geneUid); + if (result.status === "loading") return ; + if (result.status === "unavailable") + return ( + + The graph is unavailable right now. Please try again later. + + ); + return ( + + ); +}; + +/** + * The graph explorer: an overview of the graph's nodes by type, BioNym's + * network, and a panel for the node clicked. Kinds of edge and types of node + * can be hidden; the slider filters the network by confidence. + * @param props - Component props. + * @param props.graph - The gene's whole graph. + * @param props.minConfidence - The lowest confidence shown. + * @param props.onMinConfidenceChange - Called with a new minimum confidence. + * @returns The explorer. + */ +const GraphExplorer = ({ + graph, + minConfidence, + onMinConfidenceChange, +}: GraphExplorerProps): JSX.Element => { + const [hiddenPredicates, setHiddenPredicates] = useState( + DEFAULT_HIDDEN_PREDICATES + ); + const [hiddenTypes, setHiddenTypes] = useState>( + () => new Set() + ); + const [selectedId, setSelectedId] = useState(null); + const drawn = useMemo( + () => filterGraph(graph, hiddenPredicates, hiddenTypes), + [graph, hiddenPredicates, hiddenTypes] + ); + const predicateCounts = useMemo(() => countPredicates(graph), [graph]); + // The network, the panel and the count take the slider's value once it + // settles, so the count always describes the network on screen. + const settledMinConfidence = useSettledValue(minConfidence, SLIDER_SETTLE_MS); + const shown = countShown(drawn, settledMinConfidence); + const selected = selectedId + ? drawn.nodes.find((node) => node.id === selectedId) + : undefined; + + const togglePredicate = (predicate: string): void => + setHiddenPredicates((hidden) => toggled(hidden, predicate)); + const toggleType = (nodeType: string): void => + setHiddenTypes((hidden) => toggled(hidden, nodeType)); + + return ( + <> + + The graph links the gene to what BioNym found about it: its relatives, + assemblies, papers, datasets and the claims drawn from them. Click a + node to see its edges. + + + {/* The bars count the whole graph, not the network drawn below, so + every type keeps a bar to click. */} + + Every node in BioNym's graph, by type. Click a bar to hide or show + that type. + + + {hiddenTypes.size > 0 && ( + + + Hidden types of node: + + {[...hiddenTypes].sort().map((nodeType) => ( + toggleType(nodeType)} + size="small" + /> + ))} + + )} + + + {selected ? ( + + ) : ( + + Click a node to see its edges. + + )} + +