From ab12081fccbad3d1a80c8f71f1a3506a0ba2a88a Mon Sep 17 00:00:00 2001 From: dsk-dev-ai Date: Thu, 13 Aug 2026 10:01:16 +0530 Subject: [PATCH] feat(visualization): add biological network viewer --- .../web/src/app/visualization/NetworkDemo.tsx | 17 + apps/web/src/app/visualization/page.tsx | 11 +- .../components/network/NetworkViewer.test.tsx | 188 ++++++++ .../src/components/network/NetworkViewer.tsx | 416 ++++++++++++++++++ apps/web/src/lib/network/api.test.ts | 150 +++++++ apps/web/src/lib/network/api.ts | 167 +++++++ apps/web/src/lib/network/filter.test.ts | 83 ++++ apps/web/src/lib/network/filter.ts | 50 +++ apps/web/src/lib/network/geometry.test.ts | 70 +++ apps/web/src/lib/network/geometry.ts | 90 ++++ apps/web/src/lib/network/labels.test.ts | 99 +++++ apps/web/src/lib/network/labels.ts | 135 ++++++ apps/web/src/lib/network/layout.test.ts | 141 ++++++ apps/web/src/lib/network/layout.ts | 188 ++++++++ apps/web/src/lib/network/model.test.ts | 127 ++++++ apps/web/src/lib/network/model.ts | 77 ++++ apps/web/src/lib/network/network.fixtures.ts | 205 +++++++++ apps/web/src/lib/network/normalize.test.ts | 69 +++ apps/web/src/lib/network/normalize.ts | 69 +++ apps/web/src/lib/network/types.ts | 147 +++++++ .../src/lib/network/useNetworkViewer.test.tsx | 171 +++++++ apps/web/src/lib/network/useNetworkViewer.ts | 203 +++++++++ apps/web/src/lib/network/viewport.test.ts | 102 +++++ apps/web/src/lib/network/viewport.ts | 97 ++++ .../lib/visualization/visualizationModules.ts | 3 +- docs/visualization/README.md | 36 +- docs/visualization/network-viewer.md | 214 +++++++++ docs/visualization/roadmap.md | 54 ++- 28 files changed, 3366 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/app/visualization/NetworkDemo.tsx create mode 100644 apps/web/src/components/network/NetworkViewer.test.tsx create mode 100644 apps/web/src/components/network/NetworkViewer.tsx create mode 100644 apps/web/src/lib/network/api.test.ts create mode 100644 apps/web/src/lib/network/api.ts create mode 100644 apps/web/src/lib/network/filter.test.ts create mode 100644 apps/web/src/lib/network/filter.ts create mode 100644 apps/web/src/lib/network/geometry.test.ts create mode 100644 apps/web/src/lib/network/geometry.ts create mode 100644 apps/web/src/lib/network/labels.test.ts create mode 100644 apps/web/src/lib/network/labels.ts create mode 100644 apps/web/src/lib/network/layout.test.ts create mode 100644 apps/web/src/lib/network/layout.ts create mode 100644 apps/web/src/lib/network/model.test.ts create mode 100644 apps/web/src/lib/network/model.ts create mode 100644 apps/web/src/lib/network/network.fixtures.ts create mode 100644 apps/web/src/lib/network/normalize.test.ts create mode 100644 apps/web/src/lib/network/normalize.ts create mode 100644 apps/web/src/lib/network/types.ts create mode 100644 apps/web/src/lib/network/useNetworkViewer.test.tsx create mode 100644 apps/web/src/lib/network/useNetworkViewer.ts create mode 100644 apps/web/src/lib/network/viewport.test.ts create mode 100644 apps/web/src/lib/network/viewport.ts create mode 100644 docs/visualization/network-viewer.md diff --git a/apps/web/src/app/visualization/NetworkDemo.tsx b/apps/web/src/app/visualization/NetworkDemo.tsx new file mode 100644 index 0000000..fe4f9de --- /dev/null +++ b/apps/web/src/app/visualization/NetworkDemo.tsx @@ -0,0 +1,17 @@ +'use client' + +import { NetworkViewer } from '@/components/network/NetworkViewer' +import { TP53_NETWORK_FIXTURE } from '@/lib/network/network.fixtures' +import { useNetworkViewer } from '@/lib/network/useNetworkViewer' + +/** + * Phase 6.6 demo: Biological Network Viewer over the development fixture. + * + * The backend does not yet expose a network endpoint, so this demo feeds a + * typed dev fixture through the same normalizers the real adapter will use. + * See `docs/visualization/network-viewer.md`. + */ +export function NetworkDemo() { + const result = useNetworkViewer({ loader: async () => TP53_NETWORK_FIXTURE }) + return +} diff --git a/apps/web/src/app/visualization/page.tsx b/apps/web/src/app/visualization/page.tsx index 90db6f9..7e8e445 100644 --- a/apps/web/src/app/visualization/page.tsx +++ b/apps/web/src/app/visualization/page.tsx @@ -2,13 +2,14 @@ import type { Metadata } from 'next' import { GeneTranscriptDemo } from './GeneTranscriptDemo' import { GenomeBrowserDemo } from './GenomeBrowserDemo' +import { NetworkDemo } from './NetworkDemo' import { ProteinDemo } from './ProteinDemo' import { VisualizationDemo } from './VisualizationDemo' export const metadata: Metadata = { title: 'Visualization — GenomeAI', description: - 'Visualization foundation, Genome Browser, Gene / Transcript viewer, Variant track, and Protein Viewer (Phase 6.1–6.5) for GenomeAI.', + 'Visualization foundation, Genome Browser, Gene / Transcript viewer, Variant track, Protein Viewer, and Biological Network Viewer (Phase 6.1–6.6) for GenomeAI.', } export default function VisualizationPage() { @@ -18,13 +19,15 @@ export default function VisualizationPage() {

Visualization

Phase 6.1 foundation, the Phase 6.2 Genome Browser, the Phase 6.3 Gene / Transcript - viewer, the Phase 6.4 Variant track, and the Phase 6.5 Protein Viewer — region parsing, - viewport navigation, track rendering, gene/transcript structure, point variants, and - protein sequence + annotation windows over the GenomeAI API and development fixtures. + viewer, the Phase 6.4 Variant track, the Phase 6.5 Protein Viewer, and the Phase 6.6 + Biological Network Viewer — region parsing, viewport navigation, track rendering, + gene/transcript structure, point variants, protein sequence + annotation windows, and + deterministic relationship networks over the GenomeAI API and development fixtures.

+ diff --git a/apps/web/src/components/network/NetworkViewer.test.tsx b/apps/web/src/components/network/NetworkViewer.test.tsx new file mode 100644 index 0000000..9d93cdd --- /dev/null +++ b/apps/web/src/components/network/NetworkViewer.test.tsx @@ -0,0 +1,188 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { filterGraph } from '@/lib/network/filter' +import { nodeAccessibleLabel, typeLabel } from '@/lib/network/labels' +import { createLayout } from '@/lib/network/layout' +import { TP53_NETWORK_FIXTURE } from '@/lib/network/network.fixtures' +import type { NetworkViewerResult } from '@/lib/network/useNetworkViewer' +import { fitViewport } from '@/lib/network/viewport' + +import { NetworkViewer } from './NetworkViewer' + +const NETWORK_SVG_WIDTH = 1000 +const NETWORK_SVG_HEIGHT = 620 + +function result(overrides: Partial = {}): NetworkViewerResult { + const graph = TP53_NETWORK_FIXTURE + const layout = createLayout(graph) + const base: NetworkViewerResult = { + status: 'success', + error: undefined, + refetch: vi.fn(), + graph, + filteredGraph: filterGraph(graph, null), + layout, + viewport: fitViewport(layout, NETWORK_SVG_WIDTH, NETWORK_SVG_HEIGHT), + filter: null, + setFilter: vi.fn(), + resetFilter: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + zoomAt: vi.fn(), + panBy: vi.fn(), + fitToView: vi.fn(), + resetView: vi.fn(), + selectedNodeId: null, + selectedEdgeId: null, + selectNode: vi.fn(), + selectEdge: vi.fn(), + clearSelection: vi.fn(), + } + return { ...base, ...overrides } +} + +function requireNode(id: string) { + const node = TP53_NETWORK_FIXTURE.nodes.find((candidate) => candidate.id === id) + if (node === undefined) throw new Error(`Fixture node "${id}" not found`) + return node +} + +afterEach(() => { + cleanup() +}) + +describe('NetworkViewer', () => { + it('renders the network header and summary', () => { + render() + expect( + screen.getByText(/Illustrative gene\/protein\/disease\/drug relationships/), + ).toBeInTheDocument() + expect(screen.getByText(/11 nodes/)).toBeInTheDocument() + expect(screen.getByText(/12 edges/)).toBeInTheDocument() + }) + + it('renders the loading state with an accessible label', () => { + render() + expect(screen.getByText('Loading network...')).toBeInTheDocument() + }) + + it('renders the empty state message', () => { + render() + expect(screen.getByText('No network data to show.')).toBeInTheDocument() + }) + + it('renders the error state and retries', () => { + const refetch = vi.fn() + render( + , + ) + expect(screen.getByText('Failed to load network')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /retry/i })) + expect(refetch).toHaveBeenCalledTimes(1) + }) + + it('renders every node as a keyboard-accessible selection control', () => { + render() + for (const node of TP53_NETWORK_FIXTURE.nodes) { + expect( + screen.getByRole('button', { name: `Select ${nodeAccessibleLabel(node)}` }), + ).toBeInTheDocument() + } + }) + + it('renders edges as keyboard-accessible selection controls', () => { + render() + expect( + screen.getByRole('button', { name: 'Select edge: TP53 encodes P53' }), + ).toBeInTheDocument() + }) + + it('selects a node via click', () => { + const selectNode = vi.fn() + render() + const tp53 = requireNode('n-gene-tp53') + fireEvent.click(screen.getByRole('button', { name: `Select ${nodeAccessibleLabel(tp53)}` })) + expect(selectNode).toHaveBeenCalledWith('n-gene-tp53') + }) + + it('supports keyboard selection via Enter and Space', () => { + const selectNode = vi.fn() + render() + const tp53 = requireNode('n-gene-tp53') + const control = screen.getByRole('button', { name: `Select ${nodeAccessibleLabel(tp53)}` }) + fireEvent.keyDown(control, { key: 'Enter' }) + expect(selectNode).toHaveBeenCalledWith('n-gene-tp53') + + // Re-render with the node selected; Space toggles it back off. + cleanup() + const selectNode2 = vi.fn() + render( + , + ) + const control2 = screen.getByRole('button', { name: `Select ${nodeAccessibleLabel(tp53)}` }) + fireEvent.keyDown(control2, { key: ' ' }) + expect(selectNode2).toHaveBeenCalledWith(null) + }) + + it('renders a detail panel for a controlled node selection', () => { + render() + expect(screen.getByTestId('network-selection-detail')).toBeInTheDocument() + expect(screen.getByText('TP53', { selector: 'h3' })).toBeInTheDocument() + expect(screen.getByText('Type', { selector: 'dt' })).toBeInTheDocument() + }) + + it('renders a detail panel for a controlled edge selection', () => { + render() + expect(screen.getByTestId('network-selection-detail')).toBeInTheDocument() + expect(screen.getByText(typeLabel('encodes'), { selector: 'h3' })).toBeInTheDocument() + expect(screen.getByText('Direction', { selector: 'dt' })).toBeInTheDocument() + }) + + it('clears the selection from the detail panel', () => { + const clearSelection = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /clear selection/i })) + expect(clearSelection).toHaveBeenCalledTimes(1) + }) + + it('exposes zoom and fit controls', () => { + const zoomIn = vi.fn() + const zoomOut = vi.fn() + const resetView = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) + fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) + fireEvent.click(screen.getByRole('button', { name: 'Fit to view' })) + expect(zoomIn).toHaveBeenCalledTimes(1) + expect(zoomOut).toHaveBeenCalledTimes(1) + expect(resetView).toHaveBeenCalledTimes(1) + }) + + it('applies a node-type filter and clears filters', () => { + const setFilter = vi.fn() + const resetFilter = vi.fn() + const activeFilter = { nodeTypes: new Set(['gene']) } + render() + const nodeSelect = screen.getByRole('combobox', { name: 'Filter by node type' }) + fireEvent.change(nodeSelect, { target: { value: 'gene' } }) + expect(setFilter).toHaveBeenCalledTimes(1) + const filter = setFilter.mock.calls[0][0] as { nodeTypes?: Set } + expect(filter.nodeTypes?.has('gene')).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + expect(resetFilter).toHaveBeenCalledTimes(1) + }) + + it('shows a message when the filter removes every node', () => { + render() + expect(screen.getByText('No nodes match the current filter.')).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/components/network/NetworkViewer.tsx b/apps/web/src/components/network/NetworkViewer.tsx new file mode 100644 index 0000000..6c400b4 --- /dev/null +++ b/apps/web/src/components/network/NetworkViewer.tsx @@ -0,0 +1,416 @@ +'use client' + +import { useCallback, useMemo, useRef } from 'react' + +import { VisualizationContainer } from '@/components/visualization/VisualizationContainer' +import { + ARROW_SIZE, + EDGE_HIT_STROKE_WIDTH, + EDGE_STROKE_WIDTH, + NETWORK_SVG_HEIGHT, + NETWORK_SVG_WIDTH, + edgeScreenPoints, + nodeScreenBox, +} from '@/lib/network/geometry' +import { + edgeAccessibleLabel, + edgeDetailLines, + edgeTypeColor, + nodeAccessibleLabel, + nodeDetailLines, + nodeLabel, + nodeTypeColor, + typeLabel, +} from '@/lib/network/labels' +import { NODE_RADIUS } from '@/lib/network/layout' +import { availableEdgeTypes, availableNodeTypes, edgeById, nodeById } from '@/lib/network/model' +import type { GraphEdge, GraphNode } from '@/lib/network/types' +import type { NetworkViewerResult } from '@/lib/network/useNetworkViewer' + +function NetworkSummary({ result }: { result: NetworkViewerResult }) { + const graph = result.graph + if (graph === undefined) return null + const filtered = result.filteredGraph + const filteredOut = graph.nodes.length - filtered.nodes.length > 0 + return ( + + {filtered.nodes.length.toLocaleString('en-US')} nodes ·{' '} + {filtered.edges.length.toLocaleString('en-US')} edges + {filteredOut ? ' (filtered)' : ''} · {graph.nodes.length.toLocaleString('en-US')} total nodes + + ) +} + +function NetworkControls({ result }: { result: NetworkViewerResult }) { + const graph = result.graph + const nodeTypes = useMemo(() => (graph ? availableNodeTypes(graph) : []), [graph]) + const edgeTypes = useMemo(() => (graph ? availableEdgeTypes(graph) : []), [graph]) + + const nodeFilter = result.filter?.nodeTypes + const edgeFilter = result.filter?.edgeTypes + + const setNodeFilter = (value: string) => { + const next = { + ...(result.filter ?? {}), + nodeTypes: value === 'all' ? undefined : new Set([value]), + } + result.setFilter(next.nodeTypes === undefined && next.edgeTypes === undefined ? null : next) + } + + const setEdgeFilter = (value: string) => { + const next = { + ...(result.filter ?? {}), + edgeTypes: value === 'all' ? undefined : new Set([value]), + } + result.setFilter(next.nodeTypes === undefined && next.edgeTypes === undefined ? null : next) + } + + return ( +
+
+ Graph viewport navigation + {[ + { label: 'Zoom in', action: result.zoomIn, glyph: '+' }, + { label: 'Zoom out', action: result.zoomOut, glyph: '\u2212' }, + { label: 'Fit to view', action: result.resetView, glyph: '\u26F6' }, + ].map((control) => ( + + ))} +
+ {nodeTypes.length > 0 ? ( + + ) : null} + {edgeTypes.length > 0 ? ( + + ) : null} + {result.filter !== null ? ( + + ) : null} +
+ ) +} + +function EdgeElement({ edge, result }: { edge: GraphEdge; result: NetworkViewerResult }) { + const graph = result.graph + if (graph === undefined) return null + const points = edgeScreenPoints(edge, result.layout, result.viewport) + const selected = edge.id === result.selectedEdgeId + const label = edgeAccessibleLabel(edge, graph) + const color = edgeTypeColor(edge.type) + return ( + + {label} + + {selected ? ( + + {typeLabel(edge.type)} + + ) : null} + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + result.selectEdge(selected ? null : edge.id) + } + }} + onClick={() => result.selectEdge(selected ? null : edge.id)} + /> + + ) +} + +function NodeElement({ node, result }: { node: GraphNode; result: NetworkViewerResult }) { + const position = result.layout.positions.get(node.id) + if (position === undefined) return null + const selected = node.id === result.selectedNodeId + const label = nodeAccessibleLabel(node) + const box = nodeScreenBox(position, result.viewport) + const scale = result.viewport.scale + const centerX = box.x + box.width / 2 + const centerY = box.y + NODE_RADIUS * scale + return ( + + {nodeLabel(node)} + + + {node.label} + + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + result.selectNode(selected ? null : node.id) + } + }} + onClick={() => result.selectNode(selected ? null : node.id)} + /> + + ) +} + +function NetworkGraph({ result }: { result: NetworkViewerResult }) { + const graph = result.graph + if (graph === undefined) return null + + const nodes = result.filteredGraph.nodes + const edges = result.filteredGraph.edges + const hasNodes = nodes.length > 0 + + const onWheel = useCallback( + (event: React.WheelEvent) => { + event.preventDefault() + const rect = event.currentTarget.getBoundingClientRect() + const cx = event.clientX - rect.left + const cy = event.clientY - rect.top + const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2 + result.zoomAt(factor, cx, cy) + }, + [result], + ) + + const pointerDrag = useRef<{ x: number; y: number } | null>(null) + + return ( + { + pointerDrag.current = { x: event.clientX, y: event.clientY } + }} + onPointerMove={(event) => { + if (pointerDrag.current === null) return + const dx = event.clientX - pointerDrag.current.x + const dy = event.clientY - pointerDrag.current.y + pointerDrag.current = { x: event.clientX, y: event.clientY } + result.panBy(dx, dy) + }} + onPointerUp={() => { + pointerDrag.current = null + }} + onPointerLeave={() => { + pointerDrag.current = null + }} + > + {graph.metadata?.title ?? graph.id} + + + + + + {hasNodes ? ( + + {edges.map((edge) => ( + + ))} + {nodes.map((node) => ( + + ))} + + ) : ( + + + No nodes match the current filter. + + + )} + + ) +} + +function NetworkDetail({ result }: { result: NetworkViewerResult }) { + const graph = result.graph + if (graph === undefined) return null + const node = result.selectedNodeId !== null ? nodeById(graph, result.selectedNodeId) : undefined + const edge = result.selectedEdgeId !== null ? edgeById(graph, result.selectedEdgeId) : undefined + if (node === undefined && edge === undefined) return null + + const title = + node !== undefined ? nodeLabel(node) : edge !== undefined ? typeLabel(edge.type) : '' + const lines = + node !== undefined + ? nodeDetailLines(node) + : edge !== undefined + ? edgeDetailLines(edge, graph) + : [] + return ( +
+

+ {title} +

+
+ {lines.map((line) => ( +
+
{line.label}
+
{line.value}
+
+ ))} +
+ +
+ ) +} + +export interface NetworkViewerProps { + /** View model produced by `useNetworkViewer`. */ + result: NetworkViewerResult + /** Container heading. */ + title?: string +} + +/** + * Biological Network Viewer (Phase 6.6). + * + * Renders a typed relationship graph as an interactive 2D SVG: deterministic + * layout, pan/zoom/fit, node + edge selection, node/edge type filtering, and + * a readable detail panel. Consumes a `NetworkViewerResult` from + * `useNetworkViewer`; all data transformation stays in `lib/network`. + */ +export function NetworkViewer({ result, title = 'Biological Network Viewer' }: NetworkViewerProps) { + return ( + + {result.status === 'success' && result.graph ? ( +
+ + + + +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/lib/network/api.test.ts b/apps/web/src/lib/network/api.test.ts new file mode 100644 index 0000000..e6347bd --- /dev/null +++ b/apps/web/src/lib/network/api.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { GenomeApiError } from '@/lib/genome/api' + +import { fetchNetworkGraph, graphFromRecords, toGraphEdge, toGraphNode } from './api' +import { isValidGraph } from './model' +import { TP53_NETWORK_FIXTURE } from './network.fixtures' + +const rawFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = rawFetch + vi.restoreAllMocks() +}) + +function jsonResponse(payload: unknown) { + return { + ok: true, + status: 200, + json: () => Promise.resolve(payload), + } as Response +} + +describe('toGraphNode', () => { + it('normalizes a raw node record', () => { + const node = toGraphNode({ + id: 'n1', + label: 'TP53', + node_type: 'gene', + description: 'Tumor suppressor', + metadata: { evidence: 'curated' }, + }) + expect(node?.id).toBe('n1') + expect(node?.label).toBe('TP53') + expect(node?.type).toBe('gene') + expect(node?.description).toBe('Tumor suppressor') + expect(node?.metadata?.evidence).toBe('curated') + }) + + it('falls back to `type` and to the `custom` type', () => { + expect(toGraphNode({ id: 'n1', label: 'X', type: 'protein' })?.type).toBe('protein') + expect(toGraphNode({ id: 'n1', label: 'X' })?.type).toBe('custom') + }) + + it('drops records without an id or label', () => { + expect(toGraphNode({ id: 'n1' })).toBeUndefined() + expect(toGraphNode({ label: 'X' })).toBeUndefined() + expect(toGraphNode(null)).toBeUndefined() + }) +}) + +describe('toGraphEdge', () => { + it('normalizes a raw edge record', () => { + const edge = toGraphEdge({ + id: 'e1', + source: 'n1', + target: 'n2', + relationship: 'interacts_with', + directed: true, + }) + expect(edge?.source).toBe('n1') + expect(edge?.target).toBe('n2') + expect(edge?.type).toBe('interacts_with') + expect(edge?.directed).toBe(true) + }) + + it('falls back to `type` and to `related_to`', () => { + expect(toGraphEdge({ id: 'e1', source: 'a', target: 'b', type: 'regulates' })?.type).toBe( + 'regulates', + ) + expect(toGraphEdge({ id: 'e1', source: 'a', target: 'b' })?.type).toBe('related_to') + }) + + it('drops records missing required fields', () => { + expect(toGraphEdge({ id: 'e1', source: 'a' })).toBeUndefined() + expect(toGraphEdge({ source: 'a', target: 'b' })).toBeUndefined() + }) +}) + +describe('graphFromRecords', () => { + it('builds a normalized valid graph', () => { + const graph = graphFromRecords({ + id: 'g1', + title: 'Title', + description: 'Desc', + nodes: [{ id: 'a', label: 'A', node_type: 'gene' }], + edges: [{ id: 'e', source: 'a', target: 'a', relationship: 'self' }], + }) + expect(graph?.id).toBe('g1') + expect(graph?.metadata?.title).toBe('Title') + if (graph !== undefined) expect(isValidGraph(graph)).toBe(true) + expect(graph?.edges).toHaveLength(0) + }) + + it('returns undefined for invalid records', () => { + expect(graphFromRecords(null)).toBeUndefined() + expect(graphFromRecords({ nodes: [], edges: [] })).toBeUndefined() + }) + + it('drops invalid nodes and dangling edges', () => { + const graph = graphFromRecords({ + id: 'g1', + nodes: [{ id: 'a', label: 'A', node_type: 'gene' }, { id: 'b' }], + edges: [{ id: 'e', source: 'a', target: 'missing', relationship: 'x' }], + }) + expect(graph?.nodes.map((node) => node.id)).toEqual(['a']) + expect(graph?.edges).toEqual([]) + }) +}) + +describe('fetchNetworkGraph', () => { + it('GETs the network endpoint and normalizes the response', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: 'network-tp53', + nodes: [{ id: 'a', label: 'A', node_type: 'gene' }], + edges: [], + }), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { signal } = new AbortController() + const graph = await fetchNetworkGraph('network-tp53', signal) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/networks/network-tp53') + expect(init.signal).toBe(signal) + expect(graph.id).toBe('network-tp53') + expect(graph.nodes).toHaveLength(1) + }) + + it('throws a GenomeApiError on a non-2xx response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 } as unknown as Response) + await expect(fetchNetworkGraph('network-tp53')).rejects.toBeInstanceOf(GenomeApiError) + }) + + it('throws a GenomeApiError on a malformed payload', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(null)) + await expect(fetchNetworkGraph('network-tp53')).rejects.toBeInstanceOf(GenomeApiError) + }) +}) + +describe('fixture integrity', () => { + it('fixture flows through the same normalizers', () => { + expect(TP53_NETWORK_FIXTURE.id).toBe('network-tp53') + expect(isValidGraph(TP53_NETWORK_FIXTURE)).toBe(true) + expect(TP53_NETWORK_FIXTURE.nodes).toHaveLength(11) + expect(TP53_NETWORK_FIXTURE.edges).toHaveLength(12) + }) +}) diff --git a/apps/web/src/lib/network/api.ts b/apps/web/src/lib/network/api.ts new file mode 100644 index 0000000..79f1ce5 --- /dev/null +++ b/apps/web/src/lib/network/api.ts @@ -0,0 +1,167 @@ +/** + * Network data adapter (Phase 6.6). + * + * Defines the raw record shapes a future GenomeAI network endpoint is + * expected to return and the normalization seam (`toGraphNode`, + * `toGraphEdge`, `graphFromRecords`) the viewer uses. It reuses the shared + * `API_BASE_URL`, `GenomeApiError`, and guard helpers from + * `lib/genome/api.ts`. + * + * ## API limitation + * + * The GenomeAI backend does **not** yet expose a network endpoint. This + * module documents the expected contract and `fetchNetworkGraph` attempts + * `GET /networks/{id}` (which will 404 today, surfacing the limitation as a + * typed error). The demo therefore uses the isolated deterministic fixture in + * `lib/network/network.fixtures.ts` and flips to the real adapter as soon as + * a network endpoint exists. See `docs/visualization/network-viewer.md`. + * + * The browser never talks to external biological databases (STRING, Reactome, + * BioGRID, IntAct, Open Targets, ...); those feed GenomeAI through the later + * connector/ingestion architecture. + */ + +import { API_BASE_URL, GenomeApiError, asString } from '@/lib/genome/api' + +import { normalizeGraph } from './normalize' +import type { Graph, GraphEdge, GraphNode, GraphNodeType } from './types' + +/** Raw record shape a future network endpoint is expected to return. */ +export interface RawGraphRecord { + id?: unknown + nodes?: unknown + edges?: unknown + title?: unknown + description?: unknown + [key: string]: unknown +} + +/** Raw node record shape. */ +export interface RawGraphNodeRecord { + id?: unknown + label?: unknown + node_type?: unknown + type?: unknown + description?: unknown + metadata?: unknown + [key: string]: unknown +} + +/** Raw edge record shape. */ +export interface RawGraphEdgeRecord { + id?: unknown + source?: unknown + target?: unknown + relationship?: unknown + type?: unknown + label?: unknown + directed?: unknown + metadata?: unknown + [key: string]: unknown +} + +function recordIsObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asMetadata(value: unknown): Record | undefined { + if (!recordIsObject(value)) return undefined + const entries = Object.entries(value).filter( + (entry): entry is [string, string | number | boolean] => { + const [, field] = entry + return typeof field === 'string' || typeof field === 'number' || typeof field === 'boolean' + }, + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} + +/** Normalizes a raw node record into a `GraphNode`, or returns `undefined`. */ +export function toGraphNode(record: unknown): GraphNode | undefined { + if (!recordIsObject(record)) return undefined + const id = asString(record.id) + const label = asString(record.label) + if (id === undefined || id.length === 0) return undefined + if (label === undefined || label.length === 0) return undefined + const rawType = asString(record.node_type) ?? asString(record.type) + const type: GraphNodeType = rawType === undefined || rawType.length === 0 ? 'custom' : rawType + return { + id, + label, + type, + ...(asString(record.description) !== undefined + ? { description: asString(record.description) } + : {}), + ...(asMetadata(record.metadata) !== undefined ? { metadata: asMetadata(record.metadata) } : {}), + } +} + +/** Normalizes a raw edge record into a `GraphEdge`, or returns `undefined`. */ +export function toGraphEdge(record: unknown): GraphEdge | undefined { + if (!recordIsObject(record)) return undefined + const id = asString(record.id) + const source = asString(record.source) + const target = asString(record.target) + if (id === undefined || id.length === 0) return undefined + if (source === undefined || target === undefined) return undefined + const rawType = asString(record.relationship) ?? asString(record.type) + const type = rawType === undefined || rawType.length === 0 ? 'related_to' : rawType + return { + id, + source, + target, + type, + ...(asString(record.label) !== undefined ? { label: asString(record.label) } : {}), + ...(typeof record.directed === 'boolean' ? { directed: record.directed } : {}), + ...(asMetadata(record.metadata) !== undefined ? { metadata: asMetadata(record.metadata) } : {}), + } +} + +/** + * Builds a normalized, valid `Graph` from raw records. Invalid records are + * dropped via `toGraphNode`/`toGraphEdge`; `normalizeGraph` then dedupes ids, + * drops self-loops and dangling edges, and orders deterministically. + */ +export function graphFromRecords(record: unknown): Graph | undefined { + if (!recordIsObject(record)) return undefined + const id = asString(record.id) + if (id === undefined || id.length === 0) return undefined + const rawNodes = Array.isArray(record.nodes) ? record.nodes : [] + const rawEdges = Array.isArray(record.edges) ? record.edges : [] + const nodes = rawNodes.map(toGraphNode).filter((node): node is GraphNode => node !== undefined) + const edges = rawEdges.map(toGraphEdge).filter((edge): edge is GraphEdge => edge !== undefined) + return normalizeGraph({ + id, + nodes, + edges, + metadata: { + ...(asString(record.title) !== undefined ? { title: asString(record.title) } : {}), + ...(asString(record.description) !== undefined + ? { description: asString(record.description) } + : {}), + }, + }) +} + +/** + * Fetches a network by id from the (not-yet-existing) network endpoint. + * Throws `GenomeApiError` on failure today so the limitation is explicit and + * typed. Fixture-based demos bypass this loader. + */ +export async function fetchNetworkGraph(networkId: string, signal?: AbortSignal): Promise { + const response = await fetch(`${API_BASE_URL}/networks/${encodeURIComponent(networkId)}`, { + headers: { 'Content-Type': 'application/json' }, + signal, + }) + if (!response.ok) { + throw new GenomeApiError( + `GenomeAI API returned ${response.status} for network ${networkId}`, + response.status, + ) + } + const payload: unknown = await response.json() + const graph = graphFromRecords(payload) + if (graph === undefined) { + throw new GenomeApiError(`GenomeAI API returned an invalid payload for network ${networkId}`) + } + return graph +} diff --git a/apps/web/src/lib/network/filter.test.ts b/apps/web/src/lib/network/filter.test.ts new file mode 100644 index 0000000..4bb9784 --- /dev/null +++ b/apps/web/src/lib/network/filter.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import { filterGraph, isActiveFilter } from './filter' +import { TP53_NETWORK_FIXTURE } from './network.fixtures' +import type { GraphFilter } from './types' + +describe('filterGraph', () => { + it('returns the same graph for a null filter', () => { + expect(filterGraph(TP53_NETWORK_FIXTURE, null)).toBe(TP53_NETWORK_FIXTURE) + }) + + it('keeps only nodes of the requested types', () => { + const filter: GraphFilter = { nodeTypes: new Set(['gene']) } + const filtered = filterGraph(TP53_NETWORK_FIXTURE, filter) + expect(filtered.nodes.every((node) => node.type === 'gene')).toBe(true) + expect(filtered.nodes).toHaveLength(4) + }) + + it('keeps only edges of the requested relationship types', () => { + const filter: GraphFilter = { edgeTypes: new Set(['interacts_with']) } + const filtered = filterGraph(TP53_NETWORK_FIXTURE, filter) + expect(filtered.edges.every((edge) => edge.type === 'interacts_with')).toBe(true) + }) + + it('drops edges whose endpoints were filtered out (no dangling edges)', () => { + const filter: GraphFilter = { nodeTypes: new Set(['gene', 'protein']) } + const filtered = filterGraph(TP53_NETWORK_FIXTURE, filter) + const nodeIds = new Set(filtered.nodes.map((node) => node.id)) + for (const edge of filtered.edges) { + expect(nodeIds.has(edge.source)).toBe(true) + expect(nodeIds.has(edge.target)).toBe(true) + } + // The gene-only edge e-tp53-mdm2 (gene->gene) survives node filtering. + expect(filtered.edges.some((edge) => edge.id === 'e-tp53-mdm2')).toBe(true) + }) + + it('combines node and edge type filters', () => { + const filter: GraphFilter = { + nodeTypes: new Set(['drug', 'gene']), + edgeTypes: new Set(['targets']), + } + const filtered = filterGraph(TP53_NETWORK_FIXTURE, filter) + expect(filtered.nodes.map((node) => node.label).sort()).toEqual([ + 'BRCA1', + 'CHEK2', + 'Cisplatin', + 'MDM2', + 'Nutlin-3a', + 'TP53', + ]) + expect(filtered.edges.map((edge) => edge.id).sort()).toEqual([ + 'e-cisplatin-tp53', + 'e-nutlin-mdm2', + ]) + }) + + it('returns an empty graph when nothing matches', () => { + const filter: GraphFilter = { nodeTypes: new Set(['study']) } + const filtered = filterGraph(TP53_NETWORK_FIXTURE, filter) + expect(filtered.nodes).toEqual([]) + expect(filtered.edges).toEqual([]) + }) + + it('never mutates the input graph', () => { + const original = TP53_NETWORK_FIXTURE + filterGraph(original, { nodeTypes: new Set(['gene']) }) + expect(original.nodes).toHaveLength(11) + expect(original.edges).toHaveLength(12) + }) +}) + +describe('isActiveFilter', () => { + it('is false for null and for empty sets', () => { + expect(isActiveFilter(null)).toBe(false) + expect(isActiveFilter({ nodeTypes: new Set() })).toBe(false) + expect(isActiveFilter({ edgeTypes: new Set() })).toBe(false) + }) + + it('is true when any dimension restricts', () => { + expect(isActiveFilter({ nodeTypes: new Set(['gene']) })).toBe(true) + expect(isActiveFilter({ edgeTypes: new Set(['targets']) })).toBe(true) + }) +}) diff --git a/apps/web/src/lib/network/filter.ts b/apps/web/src/lib/network/filter.ts new file mode 100644 index 0000000..87144b7 --- /dev/null +++ b/apps/web/src/lib/network/filter.ts @@ -0,0 +1,50 @@ +/** + * Graph filtering (Phase 6.6). + * + * `filterGraph` applies a `GraphFilter` (node types and/or edge types), + * drops edges whose endpoints were filtered out (no dangling edges), and + * preserves the graph id and node/edge order. Deterministic and independent + * of the UI, so it is fully unit-testable. + */ + +import type { Graph, GraphFilter } from './types' + +function nodeMatches(nodeType: string, filter: GraphFilter | null): boolean { + if (filter === null || filter.nodeTypes === undefined) return true + return filter.nodeTypes.has(nodeType) +} + +function edgeMatches(edgeType: string, filter: GraphFilter | null): boolean { + if (filter === null || filter.edgeTypes === undefined) return true + return filter.edgeTypes.has(edgeType) +} + +/** + * Filters a graph by node type and/or edge type. `undefined` on a filter + * dimension means "keep all". Edges are kept only when BOTH endpoints survive + * the node filter and the edge type matches. Returns a new `Graph` (never + * mutates the input). + */ +export function filterGraph(graph: Graph, filter: GraphFilter | null): Graph { + if (filter === null) return graph + + const kept = new Set() + for (const node of graph.nodes) { + if (nodeMatches(node.type, filter)) kept.add(node.id) + } + + const nodes = graph.nodes.filter((node) => kept.has(node.id)) + const edges = graph.edges.filter( + (edge) => kept.has(edge.source) && kept.has(edge.target) && edgeMatches(edge.type, filter), + ) + + return { ...graph, nodes, edges } +} + +/** True when at least one filter dimension restricts the graph. */ +export function isActiveFilter(filter: GraphFilter | null): boolean { + if (filter === null) return false + if (filter.nodeTypes !== undefined && filter.nodeTypes.size > 0) return true + if (filter.edgeTypes !== undefined && filter.edgeTypes.size > 0) return true + return false +} diff --git a/apps/web/src/lib/network/geometry.test.ts b/apps/web/src/lib/network/geometry.test.ts new file mode 100644 index 0000000..4671b27 --- /dev/null +++ b/apps/web/src/lib/network/geometry.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { edgeEndpoints, edgeMidpoint, edgeScreenPoints, nodeScreenBox } from './geometry' +import { createLayout } from './layout' +import { NODE_RADIUS } from './layout' +import { buildTestNetwork } from './network.fixtures' + +describe('edgeEndpoints', () => { + it('shortens the line by the node radius on both ends', () => { + const result = edgeEndpoints({ x: 0, y: 0 }, { x: 100, y: 0 }) + expect(result.x1).toBeCloseTo(NODE_RADIUS + 2) + expect(result.x2).toBeCloseTo(100 - (NODE_RADIUS + 2)) + expect(result.y1).toBe(0) + expect(result.y2).toBe(0) + }) + + it('keeps points intact for coincident endpoints', () => { + const result = edgeEndpoints({ x: 5, y: 5 }, { x: 5, y: 5 }) + expect(result).toEqual({ x1: 5, y1: 5, x2: 5, y2: 5 }) + }) +}) + +describe('edgeMidpoint', () => { + it('returns the arithmetic midpoint', () => { + expect(edgeMidpoint({ x: 0, y: 0 }, { x: 10, y: 20 })).toEqual({ x: 5, y: 10 }) + }) +}) + +describe('edgeScreenPoints', () => { + it('projects endpoints through the viewport and returns a midpoint', () => { + const graph = buildTestNetwork(6, 0) + const layout = createLayout(graph) + const viewport = { x: 0, y: 0, scale: 1 } + const edge = graph.edges[0] + const points = edgeScreenPoints(edge, layout, viewport) + const source = layout.positions.get(edge.source) + const target = layout.positions.get(edge.target) + expect(source).toBeDefined() + expect(target).toBeDefined() + if (source !== undefined && target !== undefined) { + expect(points.mx).toBeCloseTo((source.x + target.x) / 2) + expect(points.my).toBeCloseTo((source.y + target.y) / 2) + } + }) + + it('returns zeros for a dangling edge', () => { + const layout = createLayout({ id: 'g', nodes: [], edges: [] }) + const points = edgeScreenPoints({ id: 'e', source: 'a', target: 'b', type: 'x' }, layout, { + x: 0, + y: 0, + scale: 1, + }) + expect(points).toEqual({ x1: 0, y1: 0, x2: 0, y2: 0, mx: 0, my: 0 }) + }) +}) + +describe('nodeScreenBox', () => { + it('centres the box on the projected node position', () => { + const viewport = { x: 0, y: 0, scale: 1 } + const box = nodeScreenBox({ x: 100, y: 50 }, viewport) + expect(box.x + box.width / 2).toBeCloseTo(100) + expect(box.height).toBeGreaterThan(NODE_RADIUS * 2) + }) + + it('scales with the viewport zoom', () => { + const box1 = nodeScreenBox({ x: 0, y: 0 }, { x: 0, y: 0, scale: 1 }) + const box2 = nodeScreenBox({ x: 0, y: 0 }, { x: 0, y: 0, scale: 2 }) + expect(box2.width).toBeCloseTo(box1.width * 2) + }) +}) diff --git a/apps/web/src/lib/network/geometry.ts b/apps/web/src/lib/network/geometry.ts new file mode 100644 index 0000000..a347783 --- /dev/null +++ b/apps/web/src/lib/network/geometry.ts @@ -0,0 +1,90 @@ +/** + * Render geometry for the Network Viewer (Phase 6.6). + * + * SVG constants and pure helpers used by `NetworkViewer` to turn layout + * positions + a viewport into screen geometry. Kept separate from the + * component so it is unit-testable without a DOM. + */ + +import { NODE_RADIUS } from './layout' +import type { GraphEdge, GraphLayout, NetworkViewport } from './types' +import { projectPoint } from './viewport' + +/** Default SVG drawing width (px). */ +export const NETWORK_SVG_WIDTH = 1000 + +/** Default SVG drawing height (px). */ +export const NETWORK_SVG_HEIGHT = 620 + +/** Screen-space arrowhead size (px) for directed edges. */ +export const ARROW_SIZE = 8 + +/** Stroke width of edges (px). */ +export const EDGE_STROKE_WIDTH = 2 + +/** Invisible hit-target stroke width (px) for selecting edges. */ +export const EDGE_HIT_STROKE_WIDTH = 12 + +/** Extra label width (px) on either side of a node for hits. */ +export const LABEL_PAD = 20 + +/** Screen-space endpoint of an edge, shortened so lines stop at node edges. */ +export function edgeEndpoints( + source: { x: number; y: number }, + target: { x: number; y: number }, +): { x1: number; y1: number; x2: number; y2: number } { + const dx = target.x - source.x + const dy = target.y - source.y + const distance = Math.hypot(dx, dy) + if (distance === 0) return { x1: source.x, y1: source.y, x2: target.x, y2: target.y } + const inset = NODE_RADIUS + 2 + const sx = source.x + (dx / distance) * inset + const sy = source.y + (dy / distance) * inset + const tx = target.x - (dx / distance) * inset + const ty = target.y - (dy / distance) * inset + return { x1: sx, y1: sy, x2: tx, y2: ty } +} + +/** Screen-space midpoint of an edge (used for relationship labels). */ +export function edgeMidpoint( + source: { x: number; y: number }, + target: { x: number; y: number }, +): { x: number; y: number } { + return { x: (source.x + target.x) / 2, y: (source.y + target.y) / 2 } +} + +/** Edge endpoints in screen space for a positioned edge. */ +export function edgeScreenPoints( + edge: GraphEdge, + layout: GraphLayout, + viewport: NetworkViewport, +): { x1: number; y1: number; x2: number; y2: number; mx: number; my: number } { + const source = layout.positions.get(edge.source) + const target = layout.positions.get(edge.target) + if (source === undefined || target === undefined) { + return { x1: 0, y1: 0, x2: 0, y2: 0, mx: 0, my: 0 } + } + const screenSource = projectPoint(source, viewport) + const screenTarget = projectPoint(target, viewport) + const { x1, y1, x2, y2 } = edgeEndpoints(screenSource, screenTarget) + const { x: mx, y: my } = edgeMidpoint(screenSource, screenTarget) + return { x1, y1, x2, y2, mx, my } +} + +/** Bounding box (screen px) of a node's label + body for hit targets. */ +export function nodeScreenBox( + position: { x: number; y: number }, + viewport: NetworkViewport, +): { x: number; y: number; width: number; height: number } { + const point = projectPoint(position, viewport) + const radius = NODE_RADIUS * viewport.scale + const labelWidth = 96 * viewport.scale + const halfWidth = Math.max(radius, labelWidth / 2) + LABEL_PAD * viewport.scale + const height = radius * 2 + 20 * viewport.scale + return { + x: point.x - halfWidth, + y: point.y - radius, + width: halfWidth * 2, + height, + } +} diff --git a/apps/web/src/lib/network/labels.test.ts b/apps/web/src/lib/network/labels.test.ts new file mode 100644 index 0000000..da58602 --- /dev/null +++ b/apps/web/src/lib/network/labels.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_NODE_COLOR, + KNOWN_EDGE_TYPES, + KNOWN_NODE_TYPES, + edgeAccessibleLabel, + edgeDetailLines, + edgeLabel, + edgeTypeColor, + nodeAccessibleLabel, + nodeDetailLines, + nodeLabel, + nodeTypeColor, + typeLabel, +} from './labels' +import { TP53_NETWORK_FIXTURE } from './network.fixtures' + +describe('nodeTypeColor', () => { + it('maps known node types to stable colours', () => { + expect(nodeTypeColor('gene')).toBe('#3b82f6') + expect(nodeTypeColor('disease')).toBe('#ef4444') + for (const type of KNOWN_NODE_TYPES) { + expect(nodeTypeColor(type)).toMatch(/^#[0-9a-f]{6}$/) + } + }) + + it('falls back for unknown types', () => { + expect(nodeTypeColor('mystery_tag')).toBe(DEFAULT_NODE_COLOR) + }) +}) + +describe('edgeTypeColor', () => { + it('maps known relationship types and falls back otherwise', () => { + expect(edgeTypeColor('interacts_with')).toBe('#8b5cf6') + for (const type of KNOWN_EDGE_TYPES) { + expect(edgeTypeColor(type)).toMatch(/^#[0-9a-f]{6}$/) + } + expect(edgeTypeColor('unknown_relation')).toBe('#94a3b8') + }) +}) + +describe('typeLabel', () => { + it('humanizes snake_case types', () => { + expect(typeLabel('interacts_with')).toBe('interacts with') + expect(typeLabel('gene')).toBe('gene') + }) +}) + +describe('node labels', () => { + it('returns display and accessible labels', () => { + const node = TP53_NETWORK_FIXTURE.nodes.find((candidate) => candidate.id === 'n-gene-tp53') + expect(node).toBeDefined() + if (node !== undefined) { + expect(nodeLabel(node)).toBe('TP53') + expect(nodeAccessibleLabel(node)).toBe('TP53, gene') + } + }) +}) + +describe('edge labels', () => { + it('prefers the relationship label over the type', () => { + const edge = TP53_NETWORK_FIXTURE.edges.find((candidate) => candidate.id === 'e-tp53-p53') + expect(edge).toBeDefined() + if (edge !== undefined) { + expect(edgeLabel(edge)).toBe('encodes') + expect(edgeAccessibleLabel(edge, TP53_NETWORK_FIXTURE)).toBe('TP53 encodes P53') + } + }) +}) + +describe('nodeDetailLines', () => { + it('surfaces type, id, description, and metadata', () => { + const node = TP53_NETWORK_FIXTURE.nodes.find((candidate) => candidate.id === 'n-gene-tp53') + expect(node).toBeDefined() + if (node !== undefined) { + const lines = nodeDetailLines(node) + const labels = lines.map((line) => line.label) + expect(labels).toContain('Type') + expect(labels).toContain('Identifier') + expect(labels).toContain('Description') + } + }) +}) + +describe('edgeDetailLines', () => { + it('reports relationship, source, target, and direction', () => { + const edge = TP53_NETWORK_FIXTURE.edges.find((candidate) => candidate.id === 'e-tp53-p53') + expect(edge).toBeDefined() + if (edge !== undefined) { + const lines = edgeDetailLines(edge, TP53_NETWORK_FIXTURE) + const byLabel = new Map(lines.map((line) => [line.label, line.value])) + expect(byLabel.get('Relationship')).toBe('encodes') + expect(byLabel.get('Source')).toBe('TP53') + expect(byLabel.get('Target')).toBe('P53') + expect(byLabel.get('Direction')).toBe('Directed') + } + }) +}) diff --git a/apps/web/src/lib/network/labels.ts b/apps/web/src/lib/network/labels.ts new file mode 100644 index 0000000..16248b0 --- /dev/null +++ b/apps/web/src/lib/network/labels.ts @@ -0,0 +1,135 @@ +/** + * Display helpers for the Network Viewer (Phase 6.6). + * + * Labels, accessible names, colours, and detail-panel rows for graph nodes + * and edges. Pure and UI-free so they are easy to test. Known node/edge type + * literals get stable default colours; anything else uses a fallback and is + * preserved verbatim. + */ + +import { nodeById } from './model' +import type { Graph, GraphEdge, GraphNode } from './types' + +/** Fallback fill for node types the viewer does not special-case. */ +export const DEFAULT_NODE_COLOR = '#94a3b8' + +/** Stable default fill colours for well-known biological node types. */ +const NODE_TYPE_COLORS: Readonly> = { + gene: '#3b82f6', + protein: '#8b5cf6', + variant: '#f59e0b', + disease: '#ef4444', + drug: '#10b981', + transcript: '#06b6d4', + study: '#ec4899', + publication: '#f97316', + pathway: '#6366f1', + compound: '#14b8a6', +} + +/** Stable default stroke colours for well-known relationship types. */ +const EDGE_TYPE_COLORS: Readonly> = { + interacts_with: '#8b5cf6', + associated_with: '#f59e0b', + regulates: '#3b82f6', + expressed_in: '#06b6d4', + causes: '#ef4444', + targets: '#10b981', + encodes: '#6366f1', + participates_in: '#14b8a6', + related_to: '#94a3b8', + binds: '#ec4899', +} + +/** Known node type literals that get a dedicated colour. */ +export const KNOWN_NODE_TYPES = [ + 'gene', + 'protein', + 'variant', + 'disease', + 'drug', + 'transcript', + 'study', + 'publication', +] as const + +/** Known edge type literals that get a dedicated colour. */ +export const KNOWN_EDGE_TYPES = [ + 'interacts_with', + 'associated_with', + 'regulates', + 'expressed_in', + 'causes', + 'targets', + 'encodes', + 'participates_in', + 'related_to', +] as const + +/** Fill colour for a node type (fallback for unknown types). */ +export function nodeTypeColor(type: string): string { + return NODE_TYPE_COLORS[type] ?? DEFAULT_NODE_COLOR +} + +/** Stroke colour for an edge type (fallback for unknown types). */ +export function edgeTypeColor(type: string): string { + return EDGE_TYPE_COLORS[type] ?? '#94a3b8' +} + +/** Human-readable form of a snake_case type, e.g. `interacts_with` -> `interacts with`. */ +export function typeLabel(type: string): string { + return type.replaceAll('_', ' ') +} + +/** Display label of a node. */ +export function nodeLabel(node: GraphNode): string { + return node.label +} + +/** Accessible name of a node, e.g. `TP53, gene`. */ +export function nodeAccessibleLabel(node: GraphNode): string { + return `${node.label}, ${typeLabel(node.type)}` +} + +/** Display label of an edge, preferring the relationship label. */ +export function edgeLabel(edge: GraphEdge): string { + return edge.label ?? typeLabel(edge.type) +} + +/** Accessible name of an edge, e.g. `TP53 interacts with P53`. */ +export function edgeAccessibleLabel(edge: GraphEdge, graph: Graph): string { + const source = nodeById(graph, edge.source) + const target = nodeById(graph, edge.target) + const sourceLabel = source ? source.label : edge.source + const targetLabel = target ? target.label : edge.target + return `${sourceLabel} ${edgeLabel(edge)} ${targetLabel}` +} + +/** Labelled detail rows shown in the selected-node panel. */ +export function nodeDetailLines(node: GraphNode): Array<{ label: string; value: string }> { + const lines: Array<{ label: string; value: string }> = [] + lines.push({ label: 'Type', value: typeLabel(node.type) }) + lines.push({ label: 'Identifier', value: node.id }) + if (node.description) lines.push({ label: 'Description', value: node.description }) + if (node.metadata) { + for (const [key, value] of Object.entries(node.metadata)) { + lines.push({ label: key, value: String(value) }) + } + } + return lines +} + +/** Labelled detail rows shown in the selected-edge panel. */ +export function edgeDetailLines( + edge: GraphEdge, + graph: Graph, +): Array<{ label: string; value: string }> { + const source = nodeById(graph, edge.source) + const target = nodeById(graph, edge.target) + return [ + { label: 'Relationship', value: edgeLabel(edge) }, + { label: 'Source', value: source ? source.label : edge.source }, + { label: 'Target', value: target ? target.label : edge.target }, + { label: 'Direction', value: edge.directed ? 'Directed' : 'Undirected' }, + ] +} diff --git a/apps/web/src/lib/network/layout.test.ts b/apps/web/src/lib/network/layout.test.ts new file mode 100644 index 0000000..d4d9068 --- /dev/null +++ b/apps/web/src/lib/network/layout.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' + +import { LAYOUT_STRATEGIES, NODE_RADIUS, createLayout, layoutFromPositions } from './layout' +import { nodeDegrees } from './model' +import { TP53_NETWORK_FIXTURE, buildTestNetwork } from './network.fixtures' +import type { Graph, GraphLayout, GraphPoint } from './types' + +function positionOf(layout: GraphLayout, id: string): GraphPoint { + const point = layout.positions.get(id) + if (point === undefined) throw new Error(`No position for node "${id}"`) + return point +} + +function radiusOf(point: GraphPoint): number { + return Math.hypot(point.x, point.y) +} + +describe('concentricLayout', () => { + it('places the highest-degree hub at the centre', () => { + const layout = createLayout(TP53_NETWORK_FIXTURE) + const hub = TP53_NETWORK_FIXTURE.nodes.reduce((a, b) => { + const degreeA = nodeDegrees(TP53_NETWORK_FIXTURE).get(a.id) ?? 0 + const degreeB = nodeDegrees(TP53_NETWORK_FIXTURE).get(b.id) ?? 0 + return degreeA >= degreeB ? a : b + }) + expect(radiusOf(positionOf(layout, hub.id))).toBeLessThan(1) + }) + + it('is fully deterministic: same graph, same positions', () => { + const a = createLayout(buildTestNetwork()) + const b = createLayout(buildTestNetwork()) + for (const node of a.positions.keys()) { + expect(a.positions.get(node)).toEqual(b.positions.get(node)) + } + }) + + it('assigns every node a finite position and reports a bounding box', () => { + const network = buildTestNetwork(40) + const layout = createLayout(network) + for (const node of network.nodes) { + const point = positionOf(layout, node.id) + expect(Number.isFinite(point.x)).toBe(true) + expect(Number.isFinite(point.y)).toBe(true) + } + expect(layout.maxX).toBeGreaterThan(layout.minX) + expect(layout.maxY).toBeGreaterThan(layout.minY) + expect(layout.centerX).toBeCloseTo((layout.minX + layout.maxX) / 2) + }) + + it('keeps higher-degree nodes on inner rings than lower-degree nodes', () => { + const network = buildTestNetwork() + const layout = createLayout(network) + const degrees = nodeDegrees(network) + const hubRadius = radiusOf(positionOf(layout, 'n0')) + const leaf = network.nodes.find((node) => degrees.get(node.id) === 1) + expect(leaf).toBeDefined() + const leafRadius = leaf === undefined ? 0 : radiusOf(positionOf(layout, leaf.id)) + expect(hubRadius).toBeLessThan(leafRadius) + }) + + it('keeps isolated nodes on the outermost ring', () => { + const network = buildTestNetwork() + const layout = createLayout(network) + const isolated = network.nodes.filter((node) => (nodeDegrees(network).get(node.id) ?? 0) === 0) + expect(isolated.length).toBeGreaterThan(0) + const isolatedRadius = radiusOf(positionOf(layout, isolated[0].id)) + const hubRadius = radiusOf(positionOf(layout, 'n0')) + expect(isolatedRadius).toBeGreaterThan(hubRadius) + }) + + it('returns an empty layout for an empty graph', () => { + const layout = createLayout({ id: 'empty', nodes: [], edges: [] }) + expect(layout.positions.size).toBe(0) + expect(layout.width).toBe(0) + }) +}) + +describe('layoutFromPositions', () => { + it('derives the bounding box from explicit positions', () => { + const graph: Graph = { + id: 'g', + nodes: [ + { id: 'a', label: 'A', type: 'gene' }, + { id: 'b', label: 'B', type: 'gene' }, + ], + edges: [], + } + const layout = layoutFromPositions( + graph, + new Map([ + ['a', { x: 10, y: -20 }], + ['b', { x: -30, y: 40 }], + ]), + ) + expect(layout.minX).toBe(-30) + expect(layout.maxX).toBe(10) + expect(layout.minY).toBe(-20) + expect(layout.maxY).toBe(40) + expect(layout.width).toBe(40) + expect(layout.height).toBe(60) + expect(layout.centerX).toBe(-10) + expect(layout.centerY).toBe(10) + }) + + it('throws when a node position is missing or non-finite', () => { + const graph: Graph = { + id: 'g', + nodes: [{ id: 'a', label: 'A', type: 'gene' }], + edges: [], + } + expect(() => layoutFromPositions(graph, new Map([['a', { x: Number.NaN, y: 0 }]]))).toThrow( + /finite position/, + ) + expect(() => layoutFromPositions(graph, new Map())).toThrow(/missing a finite position/) + }) +}) + +describe('createLayout', () => { + it('exposes at least the concentric strategy', () => { + expect(Object.keys(LAYOUT_STRATEGIES)).toContain('concentric') + }) + + it('throws for an unknown strategy name', () => { + expect(() => createLayout(TP53_NETWORK_FIXTURE, 'force-directed' as never)).toThrow( + /Unknown graph layout strategy/, + ) + }) + + it('respects layout options for ring spacing', () => { + const network = buildTestNetwork() + const defaultLayout = createLayout(network) + const spaciousLayout = createLayout(network, 'concentric', { ringStep: 200 }) + const defaultLeafRadius = radiusOf(positionOf(defaultLayout, 'n1')) + const spaciousLeafRadius = radiusOf(positionOf(spaciousLayout, 'n1')) + expect(spaciousLeafRadius).toBeGreaterThan(defaultLeafRadius) + }) + + it('uses NODE_RADIUS consistent with render geometry', () => { + expect(NODE_RADIUS).toBeGreaterThan(0) + }) +}) diff --git a/apps/web/src/lib/network/layout.ts b/apps/web/src/lib/network/layout.ts new file mode 100644 index 0000000..d521b12 --- /dev/null +++ b/apps/web/src/lib/network/layout.ts @@ -0,0 +1,188 @@ +/** + * Deterministic graph layout (Phase 6.6). + * + * Implements one strong default layout — a **concentric** layout that places + * high-degree nodes at the centre and fans lower-degree nodes out in rings, + * matching how biological networks usually look (a few hubs, many leaves). + * + * The layout is fully deterministic: nodes are sorted by (degree desc, id + * asc) and placed at evenly spaced angles with a per-ring golden-angle + * offset. No randomness is involved, so the same input graph always produces + * the same positions. This satisfies the "same input, stable output" + * requirement without needing a seeded PRNG. + * + * Future layouts (force-directed, hierarchical, circular, tree, compound) + * plug in behind the `LayoutStrategy` interface in `createLayout`. + */ + +import { nodeDegrees } from './model' +import type { Graph, GraphLayout, GraphPoint } from './types' + +/** Node radius the layout plans for (used to avoid ring overlaps). */ +export const NODE_RADIUS = 16 + +/** Space kept between node edges on a ring (graph units). */ +export const RING_NODE_GAP = 8 + +/** Minimum radius of the innermost ring (graph units). */ +export const MIN_RING_RADIUS = 64 + +/** Radius added per ring of degree (graph units). */ +export const RING_RADIUS_STEP = 72 + +/** Golden angle (radians) used to offset each ring so spokes don't align. */ +const GOLDEN_ANGLE = (Math.PI * 2) / ((1 + Math.sqrt(5)) / 2) + +/** A layout strategy: maps a graph to a deterministic `GraphLayout`. */ +export type LayoutStrategy = (graph: Graph, options?: LayoutOptions) => GraphLayout + +export interface LayoutOptions { + /** Graph units of space kept between neighbour nodes on a ring. */ + nodeGap?: number + /** Radius (graph units) of the innermost ring. */ + minRingRadius?: number + /** Extra radius (graph units) added per ring. */ + ringStep?: number + /** Reserved for future stochastic layouts; ignored by concentric. */ + seed?: number +} + +function emptyLayout(): GraphLayout { + return { + positions: new Map(), + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + centerX: 0, + centerY: 0, + width: 0, + height: 0, + } +} + +/** Ring radius large enough that `count` nodes do not overlap. */ +function ringRadiusFor(count: number, ringIndex: number, options: LayoutOptions): number { + const minRadius = options.minRingRadius ?? MIN_RING_RADIUS + const step = options.ringStep ?? RING_RADIUS_STEP + const nodeGap = options.nodeGap ?? RING_NODE_GAP + // A lone innermost node sits exactly at the centre. + if (ringIndex === 0 && count === 1) return 0 + const base = minRadius + ringIndex * step + const diameter = NODE_RADIUS * 2 + nodeGap + const needed = (count * diameter) / (Math.PI * 2) + return Math.max(base, needed) +} + +/** + * Arranges nodes in degree-descending concentric rings. Deterministic per + * graph. Disconnected components simply share the outer rings, so the layout + * is well-defined for any graph. + */ +export function concentricLayout(graph: Graph, options: LayoutOptions = {}): GraphLayout { + if (graph.nodes.length === 0) return emptyLayout() + + const degrees = nodeDegrees(graph) + const ordered = [...graph.nodes].sort( + (a, b) => (degrees.get(b.id) ?? 0) - (degrees.get(a.id) ?? 0) || a.id.localeCompare(b.id), + ) + + // A distinct ring per distinct degree value (descending = inner rings). + const degreeToRing = new Map() + let ringIndex = 0 + for (const node of ordered) { + const degree = degrees.get(node.id) ?? 0 + if (!degreeToRing.has(degree)) { + degreeToRing.set(degree, ringIndex) + ringIndex += 1 + } + } + + // Group ordered nodes into their ring, preserving order within a ring. + const ringNodes = new Map() + for (const node of ordered) { + const degree = degrees.get(node.id) ?? 0 + const ring = degreeToRing.get(degree) ?? 0 + const list = ringNodes.get(ring) ?? [] + list.push(node) + ringNodes.set(ring, list) + } + + const positions = new Map() + for (const [ring, nodes] of ringNodes) { + const radius = ringRadiusFor(nodes.length, ring, options) + const startAngle = ring * GOLDEN_ANGLE + nodes.forEach((node, index) => { + if (radius === 0) { + positions.set(node.id, { x: 0, y: 0 }) + return + } + const angle = startAngle + (index * Math.PI * 2) / nodes.length + positions.set(node.id, { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + }) + }) + } + + return layoutFromPositions(graph, positions) +} + +/** Builds a `GraphLayout` (bounds + centre) from explicit positions. */ +export function layoutFromPositions( + graph: Graph, + positions: ReadonlyMap, +): GraphLayout { + if (graph.nodes.length === 0) return emptyLayout() + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + for (const node of graph.nodes) { + const point = positions.get(node.id) + if (point === undefined || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Layout is missing a finite position for node "${node.id}".`) + } + minX = Math.min(minX, point.x) + minY = Math.min(minY, point.y) + maxX = Math.max(maxX, point.x) + maxY = Math.max(maxY, point.y) + } + const width = maxX - minX + const height = maxY - minY + return { + positions, + minX, + minY, + maxX, + maxY, + centerX: minX + width / 2, + centerY: minY + height / 2, + width, + height, + } +} + +/** Built-in layout strategies, keyed by name. */ +export const LAYOUT_STRATEGIES: Readonly> = { + concentric: concentricLayout, +} + +/** Names of the built-in layout strategies. */ +export type LayoutName = keyof typeof LAYOUT_STRATEGIES + +/** + * Runs the named layout strategy (defaults to `concentric`). Throws for + * unknown strategy names so callers catch configuration mistakes early. + */ +export function createLayout( + graph: Graph, + name: LayoutName = 'concentric', + options: LayoutOptions = {}, +): GraphLayout { + const strategy = LAYOUT_STRATEGIES[name] + if (strategy === undefined) { + throw new Error(`Unknown graph layout strategy "${String(name)}".`) + } + return strategy(graph, options) +} diff --git a/apps/web/src/lib/network/model.test.ts b/apps/web/src/lib/network/model.test.ts new file mode 100644 index 0000000..08667d4 --- /dev/null +++ b/apps/web/src/lib/network/model.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' + +import { + availableEdgeTypes, + availableNodeTypes, + edgeById, + edgesForNode, + hasNode, + isValidGraph, + nodeById, + nodeDegree, + nodeDegrees, +} from './model' +import { TP53_NETWORK_FIXTURE, buildTestNetwork } from './network.fixtures' +import type { Graph } from './types' + +describe('model lookups', () => { + it('finds a node or edge by id', () => { + const node = nodeById(TP53_NETWORK_FIXTURE, 'n-gene-tp53') + expect(node?.label).toBe('TP53') + expect(nodeById(TP53_NETWORK_FIXTURE, 'missing')).toBeUndefined() + expect(edgeById(TP53_NETWORK_FIXTURE, 'e-tp53-p53')?.type).toBe('encodes') + expect(edgeById(TP53_NETWORK_FIXTURE, 'missing')).toBeUndefined() + }) + + it('checks node membership', () => { + expect(hasNode(TP53_NETWORK_FIXTURE, 'n-gene-tp53')).toBe(true) + expect(hasNode(TP53_NETWORK_FIXTURE, 'missing')).toBe(false) + }) + + it('computes degree as an undirected count', () => { + const degree = nodeDegree(TP53_NETWORK_FIXTURE, 'n-gene-tp53') + expect(degree).toBe(9) + }) + + it('computes degrees for every node', () => { + const degrees = nodeDegrees(TP53_NETWORK_FIXTURE) + expect(degrees.get('n-gene-tp53')).toBe(9) + expect(degrees.get('n-protein-p53')).toBe(2) + expect(degrees.get('n-transcript-tp53')).toBe(1) + }) + + it('lists incident edges of a node', () => { + const edges = edgesForNode(TP53_NETWORK_FIXTURE, 'n-protein-p53') + expect(edges.map((edge) => edge.id).sort()).toEqual(['e-mdm2-p53', 'e-tp53-p53']) + }) + + it('derives sorted unique node and edge types', () => { + expect(availableNodeTypes(TP53_NETWORK_FIXTURE)).toEqual([ + 'disease', + 'drug', + 'gene', + 'protein', + 'transcript', + 'variant', + ]) + expect(availableEdgeTypes(TP53_NETWORK_FIXTURE)).toEqual([ + 'associated_with', + 'encodes', + 'has_variant', + 'interacts_with', + 'regulates', + 'targets', + 'transcribes', + ]) + }) +}) + +describe('isValidGraph', () => { + it('accepts the normalized fixture', () => { + expect(isValidGraph(TP53_NETWORK_FIXTURE)).toBe(true) + }) + + it('rejects empty ids, duplicate node ids, self-loops, and dangling edges', () => { + const base: Graph = { id: '', nodes: [], edges: [] } + expect(isValidGraph(base)).toBe(false) // empty id + expect( + isValidGraph({ + id: 'g', + nodes: [{ id: 'a', label: 'A', type: 'gene' }], + edges: [{ id: 'e', source: 'a', target: 'a', type: 'x' }], + }), + ).toBe(false) + expect( + isValidGraph({ + id: 'g', + nodes: [{ id: 'a', label: 'A', type: 'gene' }], + edges: [{ id: 'e', source: 'a', target: 'b', type: 'x' }], + }), + ).toBe(false) + expect( + isValidGraph({ + id: 'g', + nodes: [ + { id: 'a', label: 'A', type: 'gene' }, + { id: 'a', label: 'A', type: 'gene' }, + ], + edges: [], + }), + ).toBe(false) + }) + + it('accepts a valid small graph', () => { + expect( + isValidGraph({ + id: 'g', + nodes: [ + { id: 'a', label: 'A', type: 'gene' }, + { id: 'b', label: 'B', type: 'gene' }, + ], + edges: [{ id: 'e', source: 'a', target: 'b', type: 'interacts_with' }], + }), + ).toBe(true) + }) +}) + +describe('buildTestNetwork', () => { + it('builds a valid star network with isolated nodes', () => { + const network = buildTestNetwork() + expect(isValidGraph(network)).toBe(true) + expect(network.nodes).toHaveLength(30) + // Star edges run n1..n27 -> n0 (nodeCount - 2 leaves, minus the two isolates). + expect(nodeDegree(network, 'n0')).toBe(27) + expect(nodeDegree(network, 'n28')).toBe(0) + expect(nodeDegree(network, 'n29')).toBe(0) + }) +}) diff --git a/apps/web/src/lib/network/model.ts b/apps/web/src/lib/network/model.ts new file mode 100644 index 0000000..634bc5c --- /dev/null +++ b/apps/web/src/lib/network/model.ts @@ -0,0 +1,77 @@ +/** + * Graph model helpers (Phase 6.6). + * + * Pure lookups and derived sets over a `Graph`. Normalization (dedupe, + * dangling-edge removal, deterministic ordering) lives in `normalize.ts`; + * filtering in `filter.ts`; neither belongs in a React component. + */ + +import type { Graph, GraphEdge, GraphNode, GraphNodeType } from './types' + +/** Node with the given id, or `undefined`. */ +export function nodeById(graph: Graph, id: string): GraphNode | undefined { + return graph.nodes.find((node) => node.id === id) +} + +/** Edge with the given id, or `undefined`. */ +export function edgeById(graph: Graph, edgeId: string): GraphEdge | undefined { + return graph.edges.find((edge) => edge.id === edgeId) +} + +/** True when the graph contains a node with this id. */ +export function hasNode(graph: Graph, id: string): boolean { + return nodeById(graph, id) !== undefined +} + +/** Number of incident edges of a node (undirected count). */ +export function nodeDegree(graph: Graph, nodeId: string): number { + let degree = 0 + for (const edge of graph.edges) { + if (edge.source === nodeId || edge.target === nodeId) degree += 1 + } + return degree +} + +/** Degree (incident edge count) for every node, keyed by node id. */ +export function nodeDegrees(graph: Graph): ReadonlyMap { + const degrees = new Map() + for (const node of graph.nodes) { + degrees.set(node.id, 0) + } + for (const edge of graph.edges) { + degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1) + degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1) + } + return degrees +} + +/** Edges incident to a node (as source or target). */ +export function edgesForNode(graph: Graph, nodeId: string): GraphEdge[] { + return graph.edges.filter((edge) => edge.source === nodeId || edge.target === nodeId) +} + +/** Sorted unique node types present in the graph. */ +export function availableNodeTypes(graph: Graph): GraphNodeType[] { + return [...new Set(graph.nodes.map((node) => node.type))].sort() +} + +/** Sorted unique edge types present in the graph. */ +export function availableEdgeTypes(graph: Graph): string[] { + return [...new Set(graph.edges.map((edge) => edge.type))].sort() +} + +/** True when a graph id, nodes, and edges are all well-formed. */ +export function isValidGraph(graph: Graph): boolean { + if (graph.id.length === 0) return false + const ids = new Set(graph.nodes.map((node) => node.id)) + if (ids.size !== graph.nodes.length) return false + for (const node of graph.nodes) { + if (node.id.length === 0 || node.label.length === 0) return false + } + for (const edge of graph.edges) { + if (edge.id.length === 0) return false + if (edge.source === edge.target) return false + if (!ids.has(edge.source) || !ids.has(edge.target)) return false + } + return true +} diff --git a/apps/web/src/lib/network/network.fixtures.ts b/apps/web/src/lib/network/network.fixtures.ts new file mode 100644 index 0000000..df20f53 --- /dev/null +++ b/apps/web/src/lib/network/network.fixtures.ts @@ -0,0 +1,205 @@ +/** + * Development fixtures for the Network Viewer (Phase 6.6). + * + * The GenomeAI backend does **not** yet expose a network endpoint, so this + * module provides small, clearly isolated, typed fixtures that mimic what a + * future relationship API would return. Records flow through the same + * normalizers (`toGraphNode`, `toGraphEdge`, `graphFromRecords`) the real + * adapter uses, so the seam is exercised exactly as production would. + * + * ## Boundary + * + * These are **development fixtures, not a real API** and not scientific fact. + * The relationships below illustrate the viewer's generic node/edge model + * (gene -> protein, protein -> disease, drug -> target, ...) and must be + * replaced by real GenomeAI network data. See + * `docs/visualization/network-viewer.md`. + */ + +import { graphFromRecords } from './api' +import type { Graph } from './types' + +/** Raw records for a small TP53-centred multi-type network. */ +const TP53_NETWORK_RECORD = { + id: 'network-tp53', + title: 'TP53 interaction network', + description: 'Illustrative gene/protein/disease/drug relationships around TP53 (fixture).', + nodes: [ + { id: 'n-gene-tp53', label: 'TP53', node_type: 'gene', description: 'Tumor suppressor gene' }, + { + id: 'n-protein-p53', + label: 'P53', + node_type: 'protein', + description: 'Cellular tumor antigen p53', + }, + { + id: 'n-gene-mdm2', + label: 'MDM2', + node_type: 'gene', + description: 'E3 ubiquitin-protein ligase', + }, + { + id: 'n-gene-brca1', + label: 'BRCA1', + node_type: 'gene', + description: 'Breast cancer type 1 susceptibility protein', + }, + { + id: 'n-gene-chek2', + label: 'CHEK2', + node_type: 'gene', + description: 'Serine/threonine-protein kinase', + }, + { + id: 'n-variant-r175h', + label: 'R175H', + node_type: 'variant', + description: 'Pathogenic TP53 missense variant', + }, + { + id: 'n-disease-lung', + label: 'Lung cancer', + node_type: 'disease', + description: 'Malignant lung neoplasm', + }, + { + id: 'n-disease-breast', + label: 'Breast cancer', + node_type: 'disease', + description: 'Malignant breast neoplasm', + }, + { + id: 'n-drug-cisplatin', + label: 'Cisplatin', + node_type: 'drug', + description: 'Platinum-based chemotherapy', + }, + { id: 'n-drug-nutlin', label: 'Nutlin-3a', node_type: 'drug', description: 'MDM2 antagonist' }, + { + id: 'n-transcript-tp53', + label: 'TP53-201', + node_type: 'transcript', + description: 'Canonical p53 transcript', + }, + ], + edges: [ + { + id: 'e-tp53-p53', + source: 'n-gene-tp53', + target: 'n-protein-p53', + relationship: 'encodes', + directed: true, + }, + { + id: 'e-tp53-transcript', + source: 'n-gene-tp53', + target: 'n-transcript-tp53', + relationship: 'transcribes', + directed: true, + }, + { + id: 'e-tp53-mdm2', + source: 'n-gene-tp53', + target: 'n-gene-mdm2', + relationship: 'regulates', + directed: true, + }, + { + id: 'e-mdm2-p53', + source: 'n-gene-mdm2', + target: 'n-protein-p53', + relationship: 'interacts_with', + }, + { + id: 'e-tp53-brca1', + source: 'n-gene-tp53', + target: 'n-gene-brca1', + relationship: 'interacts_with', + }, + { + id: 'e-tp53-chek2', + source: 'n-gene-tp53', + target: 'n-gene-chek2', + relationship: 'regulates', + directed: true, + }, + { + id: 'e-tp53-r175h', + source: 'n-gene-tp53', + target: 'n-variant-r175h', + relationship: 'has_variant', + directed: true, + }, + { + id: 'e-tp53-lung', + source: 'n-gene-tp53', + target: 'n-disease-lung', + relationship: 'associated_with', + }, + { + id: 'e-tp53-breast', + source: 'n-gene-tp53', + target: 'n-disease-breast', + relationship: 'associated_with', + }, + { + id: 'e-breast-brca1', + source: 'n-disease-breast', + target: 'n-gene-brca1', + relationship: 'associated_with', + }, + { + id: 'e-cisplatin-tp53', + source: 'n-drug-cisplatin', + target: 'n-gene-tp53', + relationship: 'targets', + directed: true, + }, + { + id: 'e-nutlin-mdm2', + source: 'n-drug-nutlin', + target: 'n-gene-mdm2', + relationship: 'targets', + directed: true, + }, + ], +} + +/** TP53-centred multi-type network used by demos and tests. */ +export const TP53_NETWORK_FIXTURE: Graph = graphFromRecords(TP53_NETWORK_RECORD) ?? { + id: 'network-tp53', + nodes: [], + edges: [], +} + +/** + * A larger deterministic fixture for layout/perf tests: a star hub around + * `n0`, a few cross edges, plus two isolated nodes. Every reference is + * valid, so normalization keeps the graph intact. + */ +export function buildTestNetwork(nodeCount = 30, extraEdges = 4): Graph { + const nodes = Array.from({ length: nodeCount }, (_, i) => ({ + id: `n${i}`, + label: `Node ${i}`, + type: i % 3 === 0 ? 'protein' : i % 3 === 1 ? 'gene' : 'disease', + })) + const edges: Array<{ id: string; source: string; target: string; type: string }> = [] + for (let i = 1; i < nodeCount - 2; i += 1) { + edges.push({ id: `e-star-${i}`, source: `n${i}`, target: 'n0', type: 'regulates' }) + } + for (let i = 0; i < Math.max(0, extraEdges); i += 1) { + edges.push({ + id: `e-cross-${i}`, + source: `n${i + 1}`, + target: `n${nodeCount - 3 - i}`, + type: 'interacts_with', + }) + } + return ( + graphFromRecords({ id: 'network-test', nodes, edges }) ?? { + id: 'network-test', + nodes: [], + edges: [], + } + ) +} diff --git a/apps/web/src/lib/network/normalize.test.ts b/apps/web/src/lib/network/normalize.test.ts new file mode 100644 index 0000000..9abc267 --- /dev/null +++ b/apps/web/src/lib/network/normalize.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeGraph, sortEdges, sortNodes } from './normalize' +import type { Graph } from './types' + +const RAW_GRAPH: Graph = { + id: 'g', + nodes: [ + { id: 'b', label: 'B', type: 'gene' }, + { id: 'a', label: 'A', type: 'gene' }, + { id: 'b', label: 'B', type: 'gene' }, + ], + edges: [ + { id: 'e2', source: 'b', target: 'c', type: 'x' }, + { id: 'e1', source: 'a', target: 'b', type: 'x' }, + { id: 'e3', source: 'a', target: 'a', type: 'y' }, + { id: 'e1', source: 'a', target: 'b', type: 'x' }, + ], +} + +describe('normalizeGraph', () => { + it('dedupes nodes and edges by id, keeping first occurrences', () => { + const graph = normalizeGraph(RAW_GRAPH) + expect(graph.nodes.map((node) => node.id)).toEqual(['a', 'b']) + expect(graph.edges.filter((edge) => edge.id === 'e1')).toHaveLength(1) + }) + + it('drops self-loops and dangling edges', () => { + const graph = normalizeGraph(RAW_GRAPH) + expect(graph.edges.some((edge) => edge.source === edge.target)).toBe(false) + expect(graph.edges.some((edge) => edge.target === 'c')).toBe(false) + }) + + it('orders nodes by id and edges by (source, target, type, id)', () => { + const graph = normalizeGraph(RAW_GRAPH) + expect(graph.nodes.map((node) => node.id)).toEqual(['a', 'b']) + expect(graph.edges.map((edge) => edge.id)).toEqual(['e1']) + }) + + it('never mutates the input graph', () => { + const graph = normalizeGraph(RAW_GRAPH) + expect(RAW_GRAPH.nodes).toHaveLength(3) + expect(RAW_GRAPH.edges).toHaveLength(4) + expect(graph).not.toBe(RAW_GRAPH) + }) + + it('handles empty graphs', () => { + const graph = normalizeGraph({ id: 'g', nodes: [], edges: [] }) + expect(graph.nodes).toEqual([]) + expect(graph.edges).toEqual([]) + }) +}) + +describe('sortNodes / sortEdges', () => { + it('sort without mutating inputs', () => { + const nodes = [ + { id: 'b', label: 'B', type: 'gene' }, + { id: 'a', label: 'A', type: 'gene' }, + ] + expect(sortNodes(nodes).map((node) => node.id)).toEqual(['a', 'b']) + expect(nodes[0].id).toBe('b') + + const edges = [ + { id: 'e2', source: 'a', target: 'b', type: 'x' }, + { id: 'e1', source: 'a', target: 'a', type: 'x' }, + ] + expect(sortEdges(edges).map((edge) => edge.id)).toEqual(['e1', 'e2']) + }) +}) diff --git a/apps/web/src/lib/network/normalize.ts b/apps/web/src/lib/network/normalize.ts new file mode 100644 index 0000000..0adcd07 --- /dev/null +++ b/apps/web/src/lib/network/normalize.ts @@ -0,0 +1,69 @@ +/** + * Graph normalization (Phase 6.6). + * + * Turns an arbitrary `Graph` into the canonical form the viewer renders: + * + * - Node ids de-duplicated (first occurrence wins) + * - Edge ids de-duplicated (first occurrence wins) + * - Self-loops dropped (the viewer does not render them yet) + * - Edges referencing unknown node ids dropped (no dangling references) + * - Nodes ordered by id, edges by (source, target, type, id) so rendering + * and tests are deterministic + */ + +import type { Graph, GraphEdge, GraphNode } from './types' + +function dedupeNodes(nodes: readonly GraphNode[]): GraphNode[] { + const seen = new Set() + const result: GraphNode[] = [] + for (const node of nodes) { + if (seen.has(node.id)) continue + seen.add(node.id) + result.push(node) + } + return result +} + +export function sortNodes(nodes: readonly GraphNode[]): GraphNode[] { + return [...nodes].sort((a, b) => a.id.localeCompare(b.id)) +} + +function dedupeEdges(edges: readonly GraphEdge[]): GraphEdge[] { + const seen = new Set() + const result: GraphEdge[] = [] + for (const edge of edges) { + if (seen.has(edge.id)) continue + seen.add(edge.id) + result.push(edge) + } + return result +} + +export function sortEdges(edges: readonly GraphEdge[]): GraphEdge[] { + return [...edges].sort( + (a, b) => + a.source.localeCompare(b.source) || + a.target.localeCompare(b.target) || + a.type.localeCompare(b.type) || + a.id.localeCompare(b.id), + ) +} + +/** + * Normalizes a graph: dedupes nodes/edges, drops self-loops and dangling + * edges, and orders everything deterministically. Never mutates its input. + */ +export function normalizeGraph(graph: Graph): Graph { + const nodeIds = new Set() + const nodes = sortNodes(dedupeNodes(graph.nodes)) + for (const node of nodes) nodeIds.add(node.id) + + const edges = sortEdges( + dedupeEdges(graph.edges).filter((edge) => { + if (edge.source === edge.target) return false + return nodeIds.has(edge.source) && nodeIds.has(edge.target) + }), + ) + + return { ...graph, nodes, edges } +} diff --git a/apps/web/src/lib/network/types.ts b/apps/web/src/lib/network/types.ts new file mode 100644 index 0000000..fd68650 --- /dev/null +++ b/apps/web/src/lib/network/types.ts @@ -0,0 +1,147 @@ +/** + * TypeScript types for the Biological Network Viewer (Phase 6.6). + * + * ## Coordinate conventions + * + * Graph layout coordinates are **abstract 2D units** in a deterministic + * layout space centred on the origin. The viewer projects them to screen + * pixels with a 2D viewport (`NetworkViewport`: translation + scale), + * mirroring how the genome/protein viewers map one-based intervals through + * a linear scale. Layout is computed once per graph; pan/zoom only move the + * viewport. + * + * ## Domain independence + * + * `GraphNode` / `GraphEdge` are deliberately generic: node and edge `type`s + * are opaque strings (e.g. `'gene'`, `'protein'`, `'disease'`, + * `'interacts_with'`) that the renderer maps to presentation defaults but + * never interprets semantically. The viewer is a visualization layer, not a + * source of scientific relationship data. + */ + +/** Free-form metadata carried by a graph node or edge. */ +export type GraphMetadata = Record + +/** + * Type of a graph node. Known biological literals (gene, protein, variant, + * disease, drug, transcript, study, publication, ...) get default colours; + * any other string is preserved verbatim and rendered with the fallback + * colour, so the viewer is not hard-wired to one annotation source. + */ +export type GraphNodeType = string + +/** + * Type of a graph edge / relationship. Known biological literals + * (interacts_with, associated_with, regulates, expressed_in, causes, + * targets, encodes, participates_in, related_to, ...) are preserved + * verbatim; the viewer never asserts scientific validity. + */ +export type GraphEdgeType = string + +/** A single node in a biological relationship graph. */ +export interface GraphNode { + /** Stable identifier (e.g. a gene record uuid). */ + id: string + /** Short display label (e.g. `TP53`). */ + label: string + /** Node class, e.g. `gene`, `protein`, `disease`, `drug`. */ + type: GraphNodeType + /** Optional human-readable description. */ + description?: string + /** Optional domain-specific metadata. */ + metadata?: GraphMetadata +} + +/** A single relationship between two graph nodes. */ +export interface GraphEdge { + /** Stable identifier (e.g. a relationship record uuid). */ + id: string + /** Id of the source node. */ + source: string + /** Id of the target node. */ + target: string + /** Relationship class, e.g. `interacts_with`, `regulates`. */ + type: GraphEdgeType + /** Optional short display label overriding the relationship type. */ + label?: string + /** When true the edge is drawn with an arrowhead at the target. */ + directed?: boolean + /** Optional domain-specific metadata. */ + metadata?: GraphMetadata +} + +/** A typed relationship graph consumed by the Network Viewer. */ +export interface Graph { + /** Stable identifier of the network (e.g. a network record uuid). */ + id: string + /** Nodes in the graph (already normalized: unique, deterministic order). */ + nodes: GraphNode[] + /** Edges in the graph (already normalized: no dangling references). */ + edges: GraphEdge[] + /** Optional display metadata. */ + metadata?: { + title?: string + description?: string + } +} + +/** A 2D point in layout coordinates. */ +export interface GraphPoint { + x: number + y: number +} + +/** + * Result of a deterministic layout: a position per node plus the layout's + * bounding box, so views can fit-to-view without re-deriving geometry. + */ +export interface GraphLayout { + /** Node id -> position in graph units (deterministic per graph). */ + positions: ReadonlyMap + /** Bounding box of the laid-out nodes (graph units). */ + minX: number + minY: number + maxX: number + maxY: number + /** Centre of the bounding box (graph units). */ + centerX: number + centerY: number + /** Width of the bounding box (may be 0 for single/empty graphs). */ + width: number + /** Height of the bounding box (may be 0 for single/empty graphs). */ + height: number +} + +/** + * The visible 2D window of the graph: a screen-space translation plus a zoom + * scale. A layout point `p` projects to screen `(p.x * scale + x, + * p.y * scale + y)`. + */ +export interface NetworkViewport { + /** Screen x offset (px). */ + x: number + /** Screen y offset (px). */ + y: number + /** Zoom factor applied to layout coordinates. */ + scale: number +} + +/** + * Filter over a graph. `undefined` on a dimension means "keep all"; a set + * means "keep only these node/edge types". Applied by `filterGraph`, which + * also removes edges whose endpoints were filtered out (no dangling edges). + */ +export interface GraphFilter { + /** Node types to keep (undefined = all). */ + nodeTypes?: ReadonlySet + /** Edge/relationship types to keep (undefined = all). */ + edgeTypes?: ReadonlySet +} + +/** Full viewer state (what would persist across a session). */ +export interface GraphViewerState { + viewport: NetworkViewport + selectedNodeId: string | null + selectedEdgeId: string | null + filter: GraphFilter | null +} diff --git a/apps/web/src/lib/network/useNetworkViewer.test.tsx b/apps/web/src/lib/network/useNetworkViewer.test.tsx new file mode 100644 index 0000000..cd5f002 --- /dev/null +++ b/apps/web/src/lib/network/useNetworkViewer.test.tsx @@ -0,0 +1,171 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { TP53_NETWORK_FIXTURE, buildTestNetwork } from './network.fixtures' +import { useNetworkViewer } from './useNetworkViewer' + +function Harness({ + onModel, + options = {}, +}: { + onModel: (model: ReturnType) => void + options?: Parameters[0] +}) { + const model = useNetworkViewer(options) + onModel(model) + return {model.status} +} + +/** Renders the hook and exposes its latest result via a safe getter. */ +function renderHook(options: Parameters[0] = {}) { + let model: ReturnType | undefined + render( + { + model = next + }} + />, + ) + return { + get model(): ReturnType { + if (model === undefined) { + throw new Error('useNetworkViewer did not capture a model (expected after success)') + } + return model + }, + } +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useNetworkViewer', () => { + it('loads a graph and reports success', async () => { + const loader = vi.fn(async () => TP53_NETWORK_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(loader).toHaveBeenCalledTimes(1) + expect(captured.model.graph?.id).toBe('network-tp53') + }) + + it('reports empty for an empty graph', async () => { + const loader = vi.fn(async () => ({ id: 'empty', nodes: [], edges: [] })) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('empty')) + expect(captured.model?.graph).toBeDefined() + }) + + it('reports error when the loader rejects and refetch retries', async () => { + const loader = vi + .fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(TP53_NETWORK_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('error')) + expect(captured.model?.error?.message).toBe('network down') + + captured.model?.refetch() + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('computes a deterministic layout and fits it to the view', async () => { + const loader = vi.fn(async () => buildTestNetwork()) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + const layout = captured.model.layout + expect(layout.positions.size).toBe(30) + // The fit viewport centres the layout bounding box in the SVG. + expect(captured.model.viewport.scale).toBeGreaterThan(0) + }) + + it('keeps layout positions stable while filtering', async () => { + const loader = vi.fn(async () => buildTestNetwork()) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + const before = new Map(captured.model.layout.positions) + + captured.model.setFilter({ nodeTypes: new Set(['gene']) }) + await waitFor(() => { + expect(captured.model.filteredGraph.nodes.every((node) => node.type === 'gene')).toBe(true) + }) + expect(captured.model.layout.positions.size).toBe(before.size) + for (const [id, point] of before) { + expect(captured.model.layout.positions.get(id)).toEqual(point) + } + }) + + it('resets the filter', async () => { + const loader = vi.fn(async () => TP53_NETWORK_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + + captured.model.setFilter({ nodeTypes: new Set(['drug']) }) + await waitFor(() => expect(captured.model.filteredGraph.nodes).toHaveLength(2)) + + captured.model.resetFilter() + await waitFor(() => expect(captured.model.filter).toBeNull()) + expect(captured.model.filteredGraph.nodes).toHaveLength(11) + }) + + it('zooms, pans, and fits to view', async () => { + const loader = vi.fn(async () => buildTestNetwork()) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + + const initial = captured.model.viewport + captured.model.zoomIn() + await waitFor(() => expect(captured.model.viewport.scale).toBeGreaterThan(initial.scale)) + + captured.model.panBy(10, 20) + await waitFor(() => expect(captured.model.viewport.x).toBeGreaterThan(initial.x)) + + captured.model.fitToView(800, 600) + await waitFor(() => expect(captured.model.viewport).not.toEqual(initial)) + }) + + it('tracks node and edge selection exclusively', async () => { + const loader = vi.fn(async () => TP53_NETWORK_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + + captured.model.selectNode('n-gene-tp53') + await waitFor(() => expect(captured.model.selectedNodeId).toBe('n-gene-tp53')) + + captured.model.selectEdge('e-tp53-p53') + await waitFor(() => expect(captured.model.selectedEdgeId).toBe('e-tp53-p53')) + expect(captured.model.selectedNodeId).toBeNull() + + captured.model.clearSelection() + await waitFor(() => expect(captured.model.selectedNodeId).toBeNull()) + expect(captured.model.selectedEdgeId).toBeNull() + }) + + it('resets view, filter, and selection when a new graph loads', async () => { + let current = TP53_NETWORK_FIXTURE + const loader = vi.fn(async () => current) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + + captured.model.selectNode('n-gene-tp53') + captured.model.setFilter({ nodeTypes: new Set(['gene']) }) + await waitFor(() => expect(captured.model.selectedNodeId).toBe('n-gene-tp53')) + + current = { ...buildTestNetwork(8), id: 'network-other' } + captured.model.refetch() + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + await waitFor(() => expect(captured.model.graph?.id).toBe('network-other')) + expect(captured.model.selectedNodeId).toBeNull() + expect(captured.model.filter).toBeNull() + }) + + it('loads through the default fetchNetworkGraph loader when only networkId is given', async () => { + const captured = renderHook({ networkId: 'network-tp53' }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('error')) + // No backend during tests: fetch rejects, which is the expected lifecycle. + expect(captured.model?.error).toBeDefined() + }) +}) diff --git a/apps/web/src/lib/network/useNetworkViewer.ts b/apps/web/src/lib/network/useNetworkViewer.ts new file mode 100644 index 0000000..8c839e3 --- /dev/null +++ b/apps/web/src/lib/network/useNetworkViewer.ts @@ -0,0 +1,203 @@ +/** + * Network Viewer view-model hook (Phase 6.6). + * + * Composes the Phase 6.1 visualization data lifecycle + * (`useVisualizationData`) with a deterministic layout, a 2D viewport + * (pan/zoom/fit), filtering, and node/edge selection. The whole graph is + * loaded once per network id; pan/zoom/filter/selection are client-side only, + * so no per-view refetch is needed. + * + * Layout is computed from the **full** graph and never rebuilt when the + * filter changes, so positions stay stable while filtering. Filtering only + * changes which nodes/edges are rendered. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { ZOOM_FACTOR } from '@/lib/genome/viewport' +import type { VisualizationError, VisualizationStatus } from '@/lib/visualization/types' +import { useVisualizationData } from '@/lib/visualization/useVisualizationData' + +import { fetchNetworkGraph } from './api' +import { filterGraph } from './filter' +import { NETWORK_SVG_HEIGHT, NETWORK_SVG_WIDTH } from './geometry' +import { createLayout } from './layout' +import type { LayoutName, LayoutOptions } from './layout' +import type { Graph, GraphFilter, GraphLayout, NetworkViewport } from './types' +import { fitViewport, identityViewport, panViewport, zoomViewport } from './viewport' + +/** Result shape of `useNetworkViewer`, consumed by `NetworkViewer`. */ +export interface NetworkViewerResult { + status: VisualizationStatus + error: VisualizationError | undefined + /** Re-runs the network load request. */ + refetch: () => void + /** Loaded full graph, or `undefined` until success. */ + graph: Graph | undefined + /** Graph after the active filter (an empty graph when nothing matches). */ + filteredGraph: Graph + /** Deterministic layout of the FULL graph (stable across filters). */ + layout: GraphLayout + /** Current 2D viewport (translation + scale). */ + viewport: NetworkViewport + /** Active filter (null = show everything). */ + filter: GraphFilter | null + setFilter: (filter: GraphFilter | null) => void + resetFilter: () => void + zoomIn: () => void + zoomOut: () => void + /** Zooms by a factor around a screen point (defaults to the SVG centre). */ + zoomAt: (factor: number, cx?: number, cy?: number) => void + /** Pans by screen pixels. */ + panBy: (dx: number, dy: number) => void + /** Fits the whole layout into a screen size (defaults to the SVG size). */ + fitToView: (width?: number, height?: number) => void + /** Same as calling `fitToView()` with defaults. */ + resetView: () => void + selectedNodeId: string | null + selectedEdgeId: string | null + /** Selects a node (clears any edge selection). */ + selectNode: (nodeId: string | null) => void + /** Selects an edge (clears any node selection). */ + selectEdge: (edgeId: string | null) => void + clearSelection: () => void +} + +export interface UseNetworkViewerOptions { + /** Fetches the graph (defaults to `fetchNetworkGraph(networkId)`). */ + loader?: (signal: AbortSignal) => Promise + /** Backend network id used when no custom loader is provided. */ + networkId?: string + /** Layout strategy name (defaults to the deterministic concentric layout). */ + layoutName?: LayoutName + layoutOptions?: LayoutOptions +} + +const EMPTY_GRAPH: Graph = { id: '', nodes: [], edges: [] } + +export function useNetworkViewer(options: UseNetworkViewerOptions = {}): NetworkViewerResult { + const { networkId, layoutName = 'concentric', layoutOptions } = options + + const loaderRef = useRef(options.loader) + const networkIdRef = useRef(networkId) + loaderRef.current = options.loader + networkIdRef.current = networkId + + const loader = useCallback((signal: AbortSignal) => { + const custom = loaderRef.current + if (custom !== undefined) return custom(signal) + if (networkIdRef.current !== undefined) return fetchNetworkGraph(networkIdRef.current, signal) + return Promise.reject(new Error('No network loader provided to useNetworkViewer.')) + }, []) + + const { status, data, error, refetch } = useVisualizationData(loader, { + isEmpty: (graph) => graph.nodes.length === 0, + }) + + const graph = data + const layout = useMemo( + () => (graph === undefined ? emptyLayout() : createLayout(graph, layoutName, layoutOptions)), + [graph, layoutName, layoutOptions], + ) + + const [viewport, setViewport] = useState(identityViewport) + const [filter, setFilter] = useState(null) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) + + // (Re)initialize the view when a new network loads: fit-to-view, clear any + // filter and selection. + const loadedNetworkIdRef = useRef(null) + useEffect(() => { + if (graph !== undefined && graph.id !== loadedNetworkIdRef.current) { + loadedNetworkIdRef.current = graph.id + setViewport(fitViewport(layout, NETWORK_SVG_WIDTH, NETWORK_SVG_HEIGHT)) + setFilter(null) + setSelectedNodeId(null) + setSelectedEdgeId(null) + } + }, [graph, layout]) + + const filteredGraph = useMemo( + () => (graph === undefined ? EMPTY_GRAPH : filterGraph(graph, filter)), + [graph, filter], + ) + + const zoomAt = useCallback( + (factor: number, cx = NETWORK_SVG_WIDTH / 2, cy = NETWORK_SVG_HEIGHT / 2) => { + setViewport((current) => zoomViewport(current, factor, cx, cy)) + }, + [], + ) + + const zoomIn = useCallback(() => zoomAt(ZOOM_FACTOR), [zoomAt]) + const zoomOut = useCallback(() => zoomAt(1 / ZOOM_FACTOR), [zoomAt]) + + const panBy = useCallback((dx: number, dy: number) => { + setViewport((current) => panViewport(current, dx, dy)) + }, []) + + const fitToView = useCallback( + (width = NETWORK_SVG_WIDTH, height = NETWORK_SVG_HEIGHT) => { + setViewport(fitViewport(layout, width, height)) + }, + [layout], + ) + + const resetView = useCallback(() => fitToView(), [fitToView]) + + const selectNode = useCallback((nodeId: string | null) => { + setSelectedNodeId(nodeId) + if (nodeId !== null) setSelectedEdgeId(null) + }, []) + + const selectEdge = useCallback((edgeId: string | null) => { + setSelectedEdgeId(edgeId) + if (edgeId !== null) setSelectedNodeId(null) + }, []) + + const clearSelection = useCallback(() => { + setSelectedNodeId(null) + setSelectedEdgeId(null) + }, []) + + const resetFilter = useCallback(() => setFilter(null), []) + + return { + status, + error, + refetch, + graph, + filteredGraph, + layout, + viewport, + filter, + setFilter, + resetFilter, + zoomIn, + zoomOut, + zoomAt, + panBy, + fitToView, + resetView, + selectedNodeId, + selectedEdgeId, + selectNode, + selectEdge, + clearSelection, + } +} + +function emptyLayout(): GraphLayout { + return { + positions: new Map(), + minX: 0, + minY: 0, + maxX: 0, + maxY: 0, + centerX: 0, + centerY: 0, + width: 0, + height: 0, + } +} diff --git a/apps/web/src/lib/network/viewport.test.ts b/apps/web/src/lib/network/viewport.test.ts new file mode 100644 index 0000000..13a4052 --- /dev/null +++ b/apps/web/src/lib/network/viewport.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' + +import { createLayout } from './layout' +import { buildTestNetwork } from './network.fixtures' +import { + DEFAULT_GRAPH_SCALE, + FIT_PADDING, + MAX_GRAPH_SCALE, + MIN_GRAPH_SCALE, + clampGraphScale, + fitViewport, + identityViewport, + panViewport, + projectPoint, + zoomViewport, +} from './viewport' + +describe('identityViewport', () => { + it('starts at scale 1 at the origin', () => { + expect(identityViewport()).toEqual({ x: 0, y: 0, scale: DEFAULT_GRAPH_SCALE }) + }) +}) + +describe('clampGraphScale', () => { + it('clamps into the allowed range', () => { + expect(clampGraphScale(MIN_GRAPH_SCALE)).toBe(MIN_GRAPH_SCALE) + expect(clampGraphScale(MAX_GRAPH_SCALE)).toBe(MAX_GRAPH_SCALE) + expect(clampGraphScale(0)).toBe(MIN_GRAPH_SCALE) + expect(clampGraphScale(100)).toBe(MAX_GRAPH_SCALE) + expect(clampGraphScale(1)).toBe(1) + }) +}) + +describe('projectPoint', () => { + it('applies translation and scale', () => { + const viewport = { x: 10, y: -5, scale: 2 } + expect(projectPoint({ x: 3, y: 4 }, viewport)).toEqual({ x: 16, y: 3 }) + }) +}) + +describe('panViewport', () => { + it('moves by screen pixels and ignores non-finite deltas', () => { + const viewport = { x: 0, y: 0, scale: 1 } + expect(panViewport(viewport, 10, -20)).toEqual({ x: 10, y: -20, scale: 1 }) + expect(panViewport(viewport, Number.NaN, 0)).toBe(viewport) + }) +}) + +describe('zoomViewport', () => { + it('zooms around a screen point, keeping it fixed', () => { + const viewport = { x: 0, y: 0, scale: 1 } + const result = zoomViewport(viewport, 2, 100, 100) + // Screen point (100,100) maps to layout (0,0) before and after. + expect(result.scale).toBe(2) + expect(result.x).toBeCloseTo(100 - 100 * 2) + expect(result.y).toBeCloseTo(100 - 100 * 2) + }) + + it('clamps the scale to the allowed range', () => { + const viewport = { x: 0, y: 0, scale: 1 } + expect(zoomViewport(viewport, 0.0001).scale).toBe(MIN_GRAPH_SCALE) + expect(zoomViewport(viewport, 1e9).scale).toBe(MAX_GRAPH_SCALE) + }) + + it('returns the viewport unchanged for invalid factors', () => { + const viewport = { x: 0, y: 0, scale: 1 } + expect(zoomViewport(viewport, 0)).toBe(viewport) + expect(zoomViewport(viewport, -1)).toBe(viewport) + expect(zoomViewport(viewport, Number.NaN)).toBe(viewport) + }) +}) + +describe('fitViewport', () => { + it('fits the whole layout centred with padding', () => { + const layout = createLayout(buildTestNetwork()) + const width = 1000 + const height = 620 + const viewport = fitViewport(layout, width, height) + // Centre of the layout projects to the centre of the screen. + const projectedCenter = projectPoint({ x: layout.centerX, y: layout.centerY }, viewport) + expect(projectedCenter.x).toBeCloseTo(width / 2) + expect(projectedCenter.y).toBeCloseTo(height / 2) + // The whole layout fits inside the padded screen area. + const min = projectPoint({ x: layout.minX, y: layout.minY }, viewport) + const max = projectPoint({ x: layout.maxX, y: layout.maxY }, viewport) + expect(min.x).toBeGreaterThanOrEqual(FIT_PADDING - 0.01) + expect(min.y).toBeGreaterThanOrEqual(FIT_PADDING - 0.01) + expect(max.x).toBeLessThanOrEqual(width - FIT_PADDING + 0.01) + expect(max.y).toBeLessThanOrEqual(height - FIT_PADDING + 0.01) + }) + + it('handles single-node layouts with a zero-size bounding box', () => { + const layout = createLayout({ + id: 'g', + nodes: [{ id: 'a', label: 'A', type: 'gene' }], + edges: [], + }) + const viewport = fitViewport(layout, 1000, 620) + expect(Number.isFinite(viewport.scale)).toBe(true) + expect(viewport.scale).toBeGreaterThan(0) + }) +}) diff --git a/apps/web/src/lib/network/viewport.ts b/apps/web/src/lib/network/viewport.ts new file mode 100644 index 0000000..6d96cac --- /dev/null +++ b/apps/web/src/lib/network/viewport.ts @@ -0,0 +1,97 @@ +/** + * 2D graph viewport (Phase 6.6). + * + * Pure pan/zoom/fit math for the Network Viewer. A `NetworkViewport` is a + * screen-space translation `(x, y)` plus a zoom `scale`; a layout point `p` + * projects to screen `(p.x * scale + x, p.y * scale + y)`. Mirroring the + * genome/protein viewers, navigation is stateless: every operation returns a + * new viewport. Fit-to-view is computed from the layout bounding box and a + * target screen size. + */ + +import type { GraphLayout, NetworkViewport } from './types' + +/** Smallest zoom scale (maximum zoom-out). */ +export const MIN_GRAPH_SCALE = 0.05 + +/** Largest zoom scale (maximum zoom-in). */ +export const MAX_GRAPH_SCALE = 4 + +/** Default zoom scale for a fresh viewport. */ +export const DEFAULT_GRAPH_SCALE = 1 + +/** Zoom factor applied per zoom step (mirrors the genome/protein viewers). */ +export const ZOOM_FACTOR = 1.5 + +/** Padding (px) kept around the graph when fitting to view. */ +export const FIT_PADDING = 40 + +/** A fresh viewport showing the layout at scale 1, translated to the origin. */ +export function identityViewport(): NetworkViewport { + return { x: 0, y: 0, scale: DEFAULT_GRAPH_SCALE } +} + +/** Clamps a scale into the allowed range. */ +export function clampGraphScale(scale: number): number { + return Math.min(MAX_GRAPH_SCALE, Math.max(MIN_GRAPH_SCALE, scale)) +} + +/** Projects a layout point to screen coordinates under a viewport. */ +export function projectPoint( + point: { x: number; y: number }, + viewport: NetworkViewport, +): { x: number; y: number } { + return { + x: point.x * viewport.scale + viewport.x, + y: point.y * viewport.scale + viewport.y, + } +} + +/** Pans the viewport by screen pixels. */ +export function panViewport(viewport: NetworkViewport, dx: number, dy: number): NetworkViewport { + if (!Number.isFinite(dx) || !Number.isFinite(dy)) return viewport + return { ...viewport, x: viewport.x + dx, y: viewport.y + dy } +} + +/** + * Zooms around a screen point `(cx, cy)` (defaults to the origin). The + * screen point under the cursor stays fixed while the scale changes. + */ +export function zoomViewport( + viewport: NetworkViewport, + factor: number, + cx = 0, + cy = 0, +): NetworkViewport { + if (!Number.isFinite(factor) || factor <= 0) return viewport + const scale = clampGraphScale(viewport.scale * factor) + const ratio = scale / viewport.scale + if (ratio === 1) return viewport + return { + scale, + x: cx - (cx - viewport.x) * ratio, + y: cy - (cy - viewport.y) * ratio, + } +} + +/** + * Computes a viewport that fits the whole layout into a `width` x `height` + * screen area, centred, with `FIT_PADDING` of breathing room. + */ +export function fitViewport( + layout: GraphLayout, + width: number, + height: number, + padding = FIT_PADDING, +): NetworkViewport { + const usableWidth = Math.max(1, width - padding * 2) + const usableHeight = Math.max(1, height - padding * 2) + const layoutWidth = Math.max(1, layout.width) + const layoutHeight = Math.max(1, layout.height) + const scale = clampGraphScale(Math.min(usableWidth / layoutWidth, usableHeight / layoutHeight)) + return { + scale, + x: width / 2 - layout.centerX * scale, + y: height / 2 - layout.centerY * scale, + } +} diff --git a/apps/web/src/lib/visualization/visualizationModules.ts b/apps/web/src/lib/visualization/visualizationModules.ts index bcd5b9e..7d9388d 100644 --- a/apps/web/src/lib/visualization/visualizationModules.ts +++ b/apps/web/src/lib/visualization/visualizationModules.ts @@ -46,7 +46,8 @@ const MODULES: readonly VisualizationModule[] = [ { id: 'network-viewer', title: 'Biological Network Viewer', - description: 'Gene and protein interaction networks.', + description: + 'Deterministic 2D relationship networks: typed graph model, layout, filtering, and selection.', milestone: '6.6', source: { kind: 'api', reference: '/api/visualization/networks' }, }, diff --git a/docs/visualization/README.md b/docs/visualization/README.md index e3f5a74..bf77d7e 100644 --- a/docs/visualization/README.md +++ b/docs/visualization/README.md @@ -4,9 +4,9 @@ This directory documents the GenomeAI visualization platform (Phase 6). ## Status -**Phase 6.4 — Variant Visualization** is the current milestone. It adds a -reusable, coordinate-accurate variant track on top of the Phase 6.2 Genome -Browser foundation. +**Phase 6.5 — Protein Viewer** and **Phase 6.6 — Biological Network Viewer** +are implemented. Phase 6.6 adds a deterministic, dependency-free relationship +network viewer on top of the Phase 6.1 foundation. | Milestone | Description | Status | |-----------|-------------|--------| @@ -14,8 +14,8 @@ Browser foundation. | 6.2 | Genome Browser | ✅ Implemented | | 6.3 | Gene / Transcript Visualization | ✅ Implemented | | 6.4 | Variant Visualization | ✅ Implemented | -| 6.5 | Protein Structure Viewer | 📋 Planned | -| 6.6 | Biological Network Visualization | 📋 Planned | +| 6.5 | Protein Structure Viewer | ✅ Implemented | +| 6.6 | Biological Network Visualization | ✅ Implemented | | 6.7 | Scientific Charts | 📋 Planned | | 6.8 | Integrated Research Workspace | 📋 Planned | | 6.9 | Visualization Performance & Optimization | 📋 Planned | @@ -85,6 +85,26 @@ Browser foundation. routed through the same normalizers as production. - Demo integrated at `/visualization`. +## What Phase 6.6 Provides + +- Biological network visualization (see [Network Viewer](./network-viewer.md)): + a typed relationship graph model over generic node/edge `type`s, pure + normalization/filtering/model helpers, and a thin adapter that documents the + future `GET /networks/{id}` contract — no backend changes. +- A **deterministic concentric layout** computed in TypeScript (degree- + descending rings, hubs in the centre) behind a pluggable + `LayoutStrategy`/`createLayout` seam — no Cytoscape.js dependency. See the + design decision in [network-viewer.md](./network-viewer.md). +- A reusable `useNetworkViewer` hook composing the shared data lifecycle with + the 2D viewport, filtering, and node/edge selection, and a `NetworkViewer` + component rendering an interactive SVG: pan/zoom/fit, wheel zoom, drag-to- + pan, keyboard-accessible node/edge selection, filter controls, and a + readable detail panel. +- A network-data boundary: the backend does not yet expose a network endpoint, + so the demo uses a clearly isolated dev fixture routed through the same + normalizers as production. +- Demo integrated at `/visualization`. + ## Documents | Document | Description | @@ -94,6 +114,7 @@ Browser foundation. | [Gene / Transcript](gene-transcript.md) | Phase 6.3 Gene / Transcript visualization: scope, data flow, API, a11y, tests | | [Variant](variant.md) | Phase 6.4 Variant visualization: scope, data flow, API, a11y, tests | | [Protein Viewer](protein-viewer.md) | Phase 6.5 Protein Viewer: scope, data flow, API, a11y, tests | +| [Network Viewer](network-viewer.md) | Phase 6.6 Biological Network Viewer: scope, design decision, data flow, API, a11y, tests | | [Roadmap](roadmap.md) | Detailed phase tracking and future work | ## Technology Notes @@ -105,5 +126,8 @@ used — those are introduced only when the milestone that actually requires them arrives: - Three.js → a future 3D molecular structure milestone -- Cytoscape.js → Phase 6.6 (networks) +- Cytoscape.js → deferred: Phase 6.6 ships a deterministic pure-SVG layout + behind the `createLayout` seam instead (see + [network-viewer.md](./network-viewer.md)); Cytoscape.js remains available + for later interactive/manipulation work - D3.js → Phase 6.7 (scientific charts) \ No newline at end of file diff --git a/docs/visualization/network-viewer.md b/docs/visualization/network-viewer.md new file mode 100644 index 0000000..9f0a46c --- /dev/null +++ b/docs/visualization/network-viewer.md @@ -0,0 +1,214 @@ +# Biological Network Viewer (Phase 6.6) + +Renders a typed biological relationship graph (genes, proteins, variants, +diseases, drugs, transcripts, ...) as an interactive 2D SVG: a deterministic +layout, pan/zoom/fit navigation, node/edge selection, and node/edge-type +filtering. It follows the same layered architecture as the earlier +visualization milestones — pure, unit-tested math under `lib/network`, a thin +view-model hook, and a presentation-only component over the shared +[Phase 6.1 foundation](README.md). + +## Status + +Implemented on branch `feat/visualization-network-viewer`. + +## Design decision: deterministic pure-SVG layout (no Cytoscape.js) + +The Phase 6.1 technology notes anticipated Cytoscape.js for Phase 6.6. This +milestone deliberately delivers the network **foundation without that +dependency**: a deterministic **concentric** layout computed in TypeScript and +rendered with plain SVG, consistent with the platform's lightweight stance +("no C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js — those are +introduced only when the milestone that actually requires them arrives"). + +Consequences and rationale: + +- **Determinism first.** "Same input, stable output" is a hard requirement for + scientific visualization and for tests. The concentric layout is fully + deterministic (degree-descending rings, even angle spacing, golden-angle + ring offset) with no PRNG, so screenshots and tests are reproducible. +- **Zero new dependencies.** The web app remains React + TypeScript + Tailwind + + SVG. +- **A clean seam.** `createLayout` / `LAYOUT_STRATEGIES` behind the + `LayoutStrategy` interface; force-directed, hierarchical, or Cytoscape.js + layouts can be added later without touching the component or the hook. +- If interactive graph manipulation (drag-to-rearrange, physics, large graphs + with WebGL) becomes a real requirement, the seam is where Cytoscape.js or a + WebGPU renderer would plug in. + +## Scope (delivered) + +- Typed domain model (`lib/network/types.ts`): `Graph`, `GraphNode`, + `GraphEdge`, `GraphLayout`, `GraphPoint`, `NetworkViewport`, `GraphFilter`, + `GraphViewerState` — node/edge `type`s are opaque strings, so the viewer is + not hard-wired to one annotation source. +- Pure model helpers (`lib/network/model.ts`): lookups, degree, available + node/edge types, graph validation. +- Pure normalization (`lib/network/normalize.ts`): dedupe ids, drop + self-loops and dangling edges, deterministic ordering. +- Pure filtering (`lib/network/filter.ts`): node/edge-type filters with + no-dangling-edge guarantees. +- Pure deterministic layout (`lib/network/layout.ts`): concentric rings by + degree (hubs in the centre), bounding-box computation, pluggable strategy + registry (`createLayout`). +- Pure 2D viewport (`lib/network/viewport.ts`): identity/fit viewports, + clamped pan/zoom, project point; `ZOOM_FACTOR` mirrors the genome/protein + viewers. +- Pure render geometry (`lib/network/geometry.ts`): SVG constants, edge + endpoints (inset to node edges), edge midpoint, node hit boxes. +- Presentation helpers (`lib/network/labels.ts`): default colours for known + node/edge types, display + accessible labels, detail-panel rows. +- Thin typed adapter (`lib/network/api.ts`): documented expected contract for + a future `GET /networks/{id}` endpoint plus normalization seams — + **no backend changes** (see [API limitation](#api-limitation)). +- `useNetworkViewer` hook: load lifecycle (via the shared 6.1 + `useVisualizationData`), deterministic layout, viewport, filter, selection. +- `NetworkViewer` component: pan/zoom/fit SVG, wheel zoom, drag-to-pan, + keyboard-accessible node/edge selection, node/edge-type filter controls, + and a readable detail panel. +- Demo integrated at `/visualization` (`NetworkDemo`). +- Tests (98 across the network modules) and docs. + +## Out of scope (later milestones or explicitly excluded) + +- **Interactive graph manipulation** (drag-to-rearrange, physics, pinning) — + deferred; the layout seam makes this additive. +- Cytoscape.js / D3.js / WebAssembly / WebGPU (see design decision above). +- Backend network/relationship endpoints (see + [API limitation](#api-limitation)). +- Large-graph performance work (deferred to 6.9; see the roadmap). +- Import from external biological databases (STRING, Reactome, BioGRID, + IntAct, Open Targets, ...). The browser never talks to them; they feed + GenomeAI through the later connector/ingestion architecture. + +## Coordinate conventions + +Layout coordinates are **abstract 2D units** in a deterministic layout space +centred on the origin. A `NetworkViewport` projects a layout point `p` to +screen `(p.x * scale + x, p.y * scale + y)`. Layout is computed once per +graph; pan/zoom only move the viewport, so filtering never changes positions +(the layout is always derived from the **full** graph). + +## Architecture and data flow + +```text +NetworkViewer (component) apps/web/src/components/network/NetworkViewer.tsx + useNetworkViewer (view model) apps/web/src/lib/network/useNetworkViewer.ts + useVisualizationData (lifecycle) + reuse lib/visualization/useVisualizationData.ts (6.1) + fetchNetworkGraph (adapter) + lib/network/api.ts + -> GET /networks/{id} + reuse lib/genome/api.ts (API_BASE_URL, errors) + createLayout (deterministic) + lib/network/layout.ts + filterGraph (filtering) + lib/network/filter.ts + fitViewport / pan / zoom + lib/network/viewport.ts + edgeScreenPoints / nodeScreenBox + lib/network/geometry.ts + labels / detail rows + lib/network/labels.ts +``` + +`NetworkViewer` is fully controlled by a `NetworkViewerResult` returned from +`useNetworkViewer`; the component renders nothing but presentation, so the +data lifecycle (loading / success / empty / error / retry) is handled by the +shared `VisualizationContainer`. + +## Navigation, filtering, and selection + +- **Fit-to-view** on load: the viewport is computed from the layout bounding + box and the SVG size (`fitViewport`), so the whole network is visible. +- **Zoom** (`zoomIn` / `zoomOut` / wheel `zoomAt`) scales around a screen + point and clamps to `MIN_GRAPH_SCALE..MAX_GRAPH_SCALE`; **pan** (buttons or + drag) moves in screen pixels. +- **Filtering**: node-type and relationship-type selects (from the graph's + available types) rebuild `filteredGraph` via `filterGraph`; the layout stays + fixed. Edges whose endpoints are filtered out are dropped (no dangling + edges). A "Clear filters" control resets. +- **Selection**: clicking or focusing a node/edge (Enter/Space) toggles its + selection (node and edge selection are mutually exclusive); the selected + item is outlined, announced via `aria-pressed`, and described in a labelled + detail panel (`nodeDetailLines` / `edgeDetailLines`). + +Because the whole graph is loaded once per network id, navigation, filtering, +and selection never refetch. + +## API limitation + +The GenomeAI backend does **not** yet expose a network endpoint. Therefore: + +- `lib/network/api.ts` documents the expected contract (`RawGraphRecord` / + `RawGraphNodeRecord` / `RawGraphEdgeRecord`), provides the normalization + seams (`toGraphNode`, `toGraphEdge`, `graphFromRecords`), and + `fetchNetworkGraph` attempts `GET /networks/{id}` (which 404s today, + surfacing the limitation as a typed `GenomeApiError`). +- The isolated development fixture in `lib/network/network.fixtures.ts` + supplies representative TP53 relationships today. It lives apart from + production adapters, flows through the **same** normalizers the adapters + use, and must be replaced — not treated as a real API — as soon as the + backend exposes a network endpoint. + +## Type representation + +Node/edge `type`s are carried as opaque strings and mapped only for +presentation (`labels.ts`): known literals (gene, protein, variant, disease, +drug, ... for nodes; interacts_with, regulates, targets, ... for edges) get +stable default colours, and any other string is preserved verbatim with a +fallback colour. The viewer never infers a node class or relationship from +labels, and it never asserts scientific validity. + +## Accessibility + +- The SVG is a labelled group (`role="group"`, `aria-label` summarizing the + network and visible node/edge counts) so the interactive node/edge controls + stay in the accessibility tree. +- Each node and edge has a keyboard-focusable selection control + (`role="button"`, `tabIndex=0`) with an accessible name (e.g. `Select TP53, + gene` / `Select edge: TP53 encodes P53`); Enter/Space toggles selection, + `aria-pressed` announces state, and a native `` tooltip mirrors the + detail. +- The detail panel is a labelled `<section>` with a `<dl>` of typed fields. +- Navigation and filter controls have accessible names; the summary is + announced via `aria-live="polite"`. + +## Tests + +`apps/web` root test run (`pnpm --filter @genomeai/web test`) covers: + +| Layer | File(s) | Focus | +|-------|---------|-------| +| model | `model.test.ts` | lookups, degree, available types, graph validation | +| normalize | `normalize.test.ts` | dedupe, self-loop/dangling removal, deterministic ordering | +| filter | `filter.test.ts` | node/edge filters, no dangling edges, combination, active-state | +| layout | `layout.test.ts` | concentric determinism, hub at centre, ring ordering, bounding boxes, strategy registry | +| viewport | `viewport.test.ts` | identity/clamp, projection, pan, zoom-around-point, fit-to-view | +| geometry | `geometry.test.ts` | edge endpoint insets, midpoint, screen projection, node hit boxes | +| labels | `labels.test.ts` | type colours, display/accessible labels, detail rows | +| api | `api.test.ts` | record normalization, invalid-record handling, URL, error mapping, fixture integrity | +| hook | `useNetworkViewer.test.tsx` | lifecycle states, fit-to-view, layout stability under filters, zoom/pan/fit, selection, reset-on-new-graph | +| component | `NetworkViewer.test.tsx` | rendering, node/edge selection (click + keyboard), detail panels, filter controls, empty-filter message | + +The pre-existing genome/protein suites continue to pass unchanged. + +## Files + +- `apps/web/src/lib/network/types.ts` +- `apps/web/src/lib/network/model.ts` +- `apps/web/src/lib/network/normalize.ts` +- `apps/web/src/lib/network/filter.ts` +- `apps/web/src/lib/network/layout.ts` +- `apps/web/src/lib/network/viewport.ts` +- `apps/web/src/lib/network/geometry.ts` +- `apps/web/src/lib/network/labels.ts` +- `apps/web/src/lib/network/api.ts` +- `apps/web/src/lib/network/network.fixtures.ts` +- `apps/web/src/lib/network/useNetworkViewer.ts` +- `apps/web/src/components/network/NetworkViewer.tsx` +- `apps/web/src/app/visualization/NetworkDemo.tsx` +- `apps/web/src/app/visualization/page.tsx` (renders the demo) + +## Validation + +All commands green on the branch: + +```shell +make lint # biome + ruff +make typecheck # pyright + tsc +make test # web vitest + sdk-ts + api pytest +make build # production web build +``` diff --git a/docs/visualization/roadmap.md b/docs/visualization/roadmap.md index ef297d4..1a1accc 100644 --- a/docs/visualization/roadmap.md +++ b/docs/visualization/roadmap.md @@ -4,7 +4,58 @@ Tracks the Phase 6 visualization platform milestones. See [Phase 6 of the project ROADMAP](</ROADMAP.md#phase-6--visualization-platform>) for the authoritative milestone list. -## Current Milestone: 6.5 — Protein Viewer ✅ +## Current Milestone: 6.6 — Biological Network Viewer ✅ + +Implemented on branch `feat/visualization-network-viewer`, on top of 6.5. + +Delivered: + +- Typed domain model (`lib/network/types.ts`) — `Graph`, `GraphNode`, + `GraphEdge`, `GraphLayout`, `NetworkViewport`, `GraphFilter`, + `GraphViewerState`; node/edge `type`s are opaque strings so the viewer is not + hard-wired to one annotation source +- Pure model helpers (`lib/network/model.ts`) — lookups, degree, available + node/edge types, graph validation +- Pure normalization (`lib/network/normalize.ts`) — dedupe ids, drop + self-loops and dangling edges, deterministic ordering +- Pure filtering (`lib/network/filter.ts`) — node/edge-type filters with + no-dangling-edge guarantees +- Pure deterministic layout (`lib/network/layout.ts`) — concentric rings by + degree (hubs in the centre) behind a pluggable `createLayout` / + `LayoutStrategy` seam; no Cytoscape.js (see + [Network Viewer](network-viewer.md#design-decision-deterministic-pure-svg-layout-no-cytoscapejs)) +- Pure 2D viewport (`lib/network/viewport.ts`) — identity/fit viewports, + clamped pan/zoom, projection; `ZOOM_FACTOR` mirrors the genome/protein + viewers +- Pure render geometry (`lib/network/geometry.ts`) — SVG constants, edge + endpoints inset to node edges, node hit boxes +- Presentation helpers (`lib/network/labels.ts`) — type colours, display + + accessible labels, detail-panel rows +- Thin typed adapter (`lib/network/api.ts`) documenting the future + `GET /networks/{id}` contract (no backend changes) +- `useNetworkViewer` hook — load lifecycle (via the shared + `useVisualizationData`) + deterministic layout + viewport + filter + + node/edge selection +- `NetworkViewer` SVG component — pan/zoom/fit, wheel zoom, drag-to-pan, + keyboard-accessible node/edge selection, filter controls, detail panel +- Demo integrated at `/visualization` (`NetworkDemo` uses the dev fixture) +- Tests (98 across the network modules) and docs (see + [Network Viewer](network-viewer.md)) + +Constraints honored: + +- The viewer is a visualization layer, never a source of scientific + relationship data; node/edge `type`s are never inferred from labels +- Layout is deterministic ("same input, stable output") with no PRNG +- Backend network endpoints are not yet exposed, so the demo uses a clearly + isolated dev fixture routed through the same normalizers as production +- No C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js; no new + runtime dependencies +- Phase 5 search untouched + +## Previous milestones + +### 6.5 — Protein Viewer ✅ Implemented on branch `feat/visualization-protein-viewer`, on top of 6.4. @@ -164,7 +215,6 @@ Constraints honored: | # | Milestone | Notes | |---|-----------|-------| -| 6.6 | Biological Network Visualization | Interaction graphs; Cytoscape.js | | 6.7 | Scientific Charts | Trend/QC plots; D3-based | | 6.8 | Integrated Research Workspace | Assembles 6.5–6.7 into a UI | | 6.9 | Visualization Performance & Optimization | Virtualization / density rendering for large data |