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) => (
+
+ {control.glyph}
+
+ ))}
+
+ {nodeTypes.length > 0 ? (
+
+ Filter by node type
+ setNodeFilter(event.target.value)}
+ aria-label="Filter by node type"
+ className="rounded-md border border-gray-300 px-2 py-1 text-sm"
+ >
+ All node types
+ {nodeTypes.map((type) => (
+
+ {typeLabel(type)}
+
+ ))}
+
+
+ ) : null}
+ {edgeTypes.length > 0 ? (
+
+ Filter by relationship
+ setEdgeFilter(event.target.value)}
+ aria-label="Filter by relationship"
+ className="rounded-md border border-gray-300 px-2 py-1 text-sm"
+ >
+ All relationships
+ {edgeTypes.map((type) => (
+
+ {typeLabel(type)}
+
+ ))}
+
+
+ ) : null}
+ {result.filter !== null ? (
+
+ Clear filters
+
+ ) : 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}
+
+ ))}
+
+
+ Clear selection
+
+
+ )
+}
+
+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 `` with a `` 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]() 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 |