diff --git a/README.md b/README.md
index f8a51c2..c981d5e 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ See [docs/development/](docs/development/) for detailed setup instructions, prer
| Database Schema | [docs/database/](docs/database/) |
| AI/ML Guide | [docs/ai/](docs/ai/) |
| Visualization | [docs/visualization/](docs/visualization/) |
+| External Data & API | [docs/external-data/](docs/external-data/) |
| Plugin Development | [docs/plugins/](docs/plugins/) |
| Deployment | [docs/deployment/](docs/deployment/) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
diff --git a/apps/web/src/app/visualization/GenomeBrowserDemo.tsx b/apps/web/src/app/visualization/GenomeBrowserDemo.tsx
index fcdfee2..4e4e28c 100644
--- a/apps/web/src/app/visualization/GenomeBrowserDemo.tsx
+++ b/apps/web/src/app/visualization/GenomeBrowserDemo.tsx
@@ -3,9 +3,10 @@
import { useMemo } from 'react'
import { GenomeBrowser } from '@/components/genome/GenomeBrowser'
-import { fetchIntervalFeatures, fetchVariantFeatures } from '@/lib/genome/api'
+import { fetchIntervalFeatures } from '@/lib/genome/api'
import { TP53_WINDOW } from '@/lib/genome/geneTranscript.fixtures'
import type { GenomeTrackDefinition } from '@/lib/genome/useGenomeBrowser'
+import { fetchVariants } from '@/lib/genome/variantApi'
/**
* Client-side Genome Browser demo (Phase 6.2).
@@ -28,7 +29,7 @@ export function GenomeBrowserDemo() {
id: 'variants',
label: 'Variants',
kind: 'variants',
- loader: (interval, signal) => fetchVariantFeatures(interval, signal),
+ loader: (interval, signal) => fetchVariants(interval, signal),
},
],
[],
diff --git a/apps/web/src/app/visualization/page.tsx b/apps/web/src/app/visualization/page.tsx
index 796acc4..018c2fc 100644
--- a/apps/web/src/app/visualization/page.tsx
+++ b/apps/web/src/app/visualization/page.tsx
@@ -7,7 +7,7 @@ import { VisualizationDemo } from './VisualizationDemo'
export const metadata: Metadata = {
title: 'Visualization — GenomeAI',
description:
- 'Visualization foundation, Genome Browser, and Gene / Transcript viewer (Phase 6.1–6.3) for GenomeAI.',
+ 'Visualization foundation, Genome Browser, Gene / Transcript viewer, and Variant track (Phase 6.1–6.4) for GenomeAI.',
}
export default function VisualizationPage() {
@@ -16,9 +16,9 @@ export default function VisualizationPage() {
Visualization
- Phase 6.1 foundation, the Phase 6.2 Genome Browser, and the Phase 6.3 Gene / Transcript
- viewer — region parsing, viewport navigation, track rendering, and gene/transcript
- structure over the coordinate-search API.
+ Phase 6.1 foundation, the Phase 6.2 Genome Browser, the Phase 6.3 Gene / Transcript
+ viewer, and the Phase 6.4 Variant track — region parsing, viewport navigation, track
+ rendering, gene/transcript structure, and point variants over the coordinate-search API.
diff --git a/apps/web/src/components/genome/GenomeBrowser.tsx b/apps/web/src/components/genome/GenomeBrowser.tsx
index 8bedc59..91189a7 100644
--- a/apps/web/src/components/genome/GenomeBrowser.tsx
+++ b/apps/web/src/components/genome/GenomeBrowser.tsx
@@ -12,18 +12,20 @@ import {
} from '@/lib/genome/geometry'
import { type RegionValidationError, parseGenomeRegion } from '@/lib/genome/region'
import { featuresInViewport } from '@/lib/genome/tracks'
-import type { TrackKind } from '@/lib/genome/tracks'
import type { GenomeViewport, GenomicFeature } from '@/lib/genome/types'
import {
type GenomeBrowserOptions,
type GenomeBrowserResult,
type GenomeTrackData,
type GenomeTrackDefinition,
+ type SpanTrackDefinition,
useGenomeBrowser,
useGenomeTrack,
} from '@/lib/genome/useGenomeBrowser'
import { viewportBaseCount } from '@/lib/genome/viewport'
+import { VariantTrack } from './VariantTrack'
+
const SVG_WIDTH = 1000
const AXIS_HEIGHT = 28
const TRACK_HEADER_WIDTH = 96
@@ -66,42 +68,21 @@ function AxisSvg({ viewport }: { viewport: GenomeViewport }) {
}
/**
- * Draws one lane of genomic features.
+ * Draws one lane of span features (genes/transcripts).
*
- * `kind` selects the glyph: genes/transcripts are strand-aware arrow
- * rectangles, variants are point marks. Features are clipped to the
- * viewport before their pixel geometry is computed so glyphs never spill
- * into the reserved header column.
+ * Features are clipped to the viewport before their pixel geometry is
+ * computed so glyphs never spill into the reserved header column. Point
+ * variants are rendered by the reusable `VariantTrack` component instead.
*/
function GenomeTrackSvg({
viewport,
features,
- kind,
}: {
viewport: GenomeViewport
features: readonly GenomicFeature[]
- kind: TrackKind
}) {
const scale = createScale(viewport.start, viewport.end, SVG_WIDTH - TRACK_HEADER_WIDTH)
- if (kind === 'variants') {
- return (
-
- {features.map((feature, index) => {
- const position = Math.min(Math.max(feature.start, viewport.start), viewport.end)
- const x = TRACK_HEADER_WIDTH + scale.toX(position)
- const y = ROW_HEIGHT / 2
- return (
-
-
- {feature.name ?? feature.id}
-
- )
- })}
-
- )
- }
-
return (
{features.map((feature, index) => {
@@ -232,20 +213,17 @@ function BrowserStatus({ viewport }: { viewport: GenomeViewport }) {
}
/**
- * Renders one track lane as its own stable component instance.
- *
- * Being a component (not a loop inside a hook) keeps the Rules of Hooks
- * satisfied as the track set grows, shrinks, or reorders.
+ * Renders one span-track lane (genes/transcripts). Owns its own track hook
+ * (called unconditionally) and draws arrow glyphs for in-viewport features.
*/
-function BrowserTrack({
+function SpanTrackLane({
track,
- debouncedViewport,
+ viewport,
}: {
- track: GenomeTrackDefinition
- debouncedViewport: GenomeViewport
+ track: SpanTrackDefinition
+ viewport: GenomeViewport
}) {
- const data = useGenomeTrack(track, debouncedViewport)
- const viewport = debouncedViewport
+ const data = useGenomeTrack(track, viewport)
const features = featuresInViewport(data.data ?? [], viewport)
return (
@@ -259,13 +237,37 @@ function BrowserTrack({
>
)
}
+/**
+ * Renders one track lane as its own stable component instance.
+ *
+ * Being a component (not a loop inside a hook) keeps the Rules of Hooks
+ * satisfied as the track set grows, shrinks, or reorders. Each lane is a
+ * separate component that calls `useGenomeTrack` unconditionally, so a
+ * retained instance that changes `track.kind` never trips a hook-order error
+ * — the definition union is narrowed on `kind`, so the variant lane receives
+ * its exact `VariantFeature` data type with no cast.
+ */
+function BrowserTrack({
+ track,
+ debouncedViewport,
+}: {
+ track: GenomeTrackDefinition
+ debouncedViewport: GenomeViewport
+}) {
+ if (track.kind === 'variants') {
+ return
+ }
+
+ return
+}
+
export interface GenomeBrowserProps extends GenomeBrowserOptions {
/** Tracks to fetch and render, in display order. */
tracks: GenomeTrackDefinition[]
diff --git a/apps/web/src/components/genome/VariantTrack.test.tsx b/apps/web/src/components/genome/VariantTrack.test.tsx
new file mode 100644
index 0000000..f435dee
--- /dev/null
+++ b/apps/web/src/components/genome/VariantTrack.test.tsx
@@ -0,0 +1,136 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import type { VariantFeature } from '@/lib/genome/types'
+import type { VariantTrackDefinition } from '@/lib/genome/useGenomeBrowser'
+import { VariantTrack } from './VariantTrack'
+
+const variantsLoader = vi.fn()
+
+function trackDefinition(loader: typeof variantsLoader): VariantTrackDefinition {
+ return { id: 'variants', label: 'Variants', kind: 'variants', loader }
+}
+
+const viewport = { chromosome: 'chr17', start: 7_650_000, end: 7_700_000 }
+
+function variant(overrides: Partial & { id: string }): VariantFeature {
+ return {
+ type: 'variant',
+ chromosome: 'chr17',
+ start: 7_668_000,
+ end: 7_668_000,
+ position: 7_668_000,
+ ...overrides,
+ }
+}
+
+function renderTrack(loader = variantsLoader) {
+ return render()
+}
+
+afterEach(() => {
+ cleanup()
+ variantsLoader.mockReset().mockImplementation(() => Promise.resolve([]))
+})
+
+describe('VariantTrack', () => {
+ it('renders the container with the track label', async () => {
+ renderTrack()
+ expect(screen.getByText('Variants')).toBeInTheDocument()
+ await waitFor(() => expect(variantsLoader).toHaveBeenCalled())
+ })
+
+ it('requests variants for the debounced viewport interval', async () => {
+ renderTrack()
+ await waitFor(() => expect(variantsLoader).toHaveBeenCalled())
+ expect(variantsLoader.mock.calls[0][0]).toEqual({
+ chromosome: 'chr17',
+ start: 7_650_000,
+ end: 7_700_000,
+ })
+ })
+
+ it('shows the empty state when no variants are in the region', async () => {
+ renderTrack()
+ expect(await screen.findByText(/no variants found in region/i)).toBeInTheDocument()
+ })
+
+ it('renders a labelled SVG lane with a point mark per variant', async () => {
+ variantsLoader.mockResolvedValue([
+ variant({ id: 'var-a', position: 7_668_000, ref: 'C', alt: 'T', variantType: 'snv' }),
+ variant({ id: 'var-b', position: 7_669_000, ref: 'A', alt: 'G' }),
+ ])
+ renderTrack()
+ const track = await screen.findByTestId('variant-track')
+ expect(track).toHaveAttribute('aria-label', 'Variants: 2 variants')
+ expect(screen.getByTestId('variant-mark-var-a')).toBeInTheDocument()
+ expect(screen.getByTestId('variant-mark-var-b')).toBeInTheDocument()
+ })
+
+ it('only renders variants that fall inside the viewport', async () => {
+ variantsLoader.mockResolvedValue([
+ variant({ id: 'inside', position: 7_660_000 }),
+ variant({ id: 'outside', position: 7_700_001 }),
+ variant({ id: 'wrong-chrom', position: 7_660_000, chromosome: 'chr18' }),
+ ])
+ renderTrack()
+ await screen.findByTestId('variant-track')
+ expect(screen.getByTestId('variant-mark-inside')).toBeInTheDocument()
+ expect(screen.queryByTestId('variant-mark-outside')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('variant-mark-wrong-chrom')).not.toBeInTheDocument()
+ })
+
+ it('stacks adjacent variants on separate rows so marks never overlap', async () => {
+ variantsLoader.mockResolvedValue([
+ variant({ id: 'v1', position: 7_650_000 }),
+ variant({ id: 'v2', position: 7_650_000 }),
+ ])
+ renderTrack()
+ await screen.findByTestId('variant-track')
+ expect(screen.getByTestId('variant-mark-v1')).toBeInTheDocument()
+ expect(screen.getByTestId('variant-mark-v2')).toBeInTheDocument()
+ })
+
+ it('provides a keyboard-accessible selection control per variant', async () => {
+ variantsLoader.mockResolvedValue([variant({ id: 'var-a', ref: 'C', alt: 'T' })])
+ renderTrack()
+ await screen.findByTestId('variant-track')
+ const control = screen.getByRole('button', { name: /select C>T, chr17:/i })
+ expect(control).toHaveAttribute('tabindex', '0')
+ expect(control).toHaveAttribute('aria-pressed', 'false')
+ })
+
+ it('reveals a detail panel when a variant is selected', async () => {
+ variantsLoader.mockResolvedValue([
+ variant({ id: 'var-a', ref: 'C', alt: 'T', variantType: 'snv', filterStatus: 'PASS' }),
+ ])
+ renderTrack()
+ const control = await screen.findByRole('button', { name: /select C>T, chr17:/i })
+ fireEvent.click(control)
+ expect(await screen.findByTestId('variant-detail-var-a')).toBeInTheDocument()
+ expect(screen.getByText('snv')).toBeInTheDocument()
+ expect(screen.getByText('PASS')).toBeInTheDocument()
+ expect(control).toHaveAttribute('aria-pressed', 'true')
+ })
+
+ it('toggles the detail panel off on a second selection', async () => {
+ variantsLoader.mockResolvedValue([variant({ id: 'var-a', ref: 'C', alt: 'T' })])
+ renderTrack()
+ const control = await screen.findByRole('button', { name: /select C>T, chr17:/i })
+ fireEvent.click(control)
+ expect(await screen.findByTestId('variant-detail-var-a')).toBeInTheDocument()
+ fireEvent.click(control)
+ await waitFor(() =>
+ expect(screen.queryByTestId('variant-detail-var-a')).not.toBeInTheDocument(),
+ )
+ })
+
+ it('shows the error state and retries on a failed load', async () => {
+ variantsLoader.mockRejectedValueOnce(new Error('boom'))
+ variantsLoader.mockResolvedValueOnce([variant({ id: 'var-a' })])
+ renderTrack()
+ expect(await screen.findByText(/failed to load visualization/i)).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /retry/i }))
+ expect(await screen.findByTestId('variant-mark-var-a')).toBeInTheDocument()
+ })
+})
diff --git a/apps/web/src/components/genome/VariantTrack.tsx b/apps/web/src/components/genome/VariantTrack.tsx
new file mode 100644
index 0000000..62d143d
--- /dev/null
+++ b/apps/web/src/components/genome/VariantTrack.tsx
@@ -0,0 +1,158 @@
+'use client'
+
+import { useState } from 'react'
+
+import { VisualizationContainer } from '@/components/visualization/VisualizationContainer'
+import { createScale } from '@/lib/genome/geometry'
+import type { GenomeViewport, VariantFeature } from '@/lib/genome/types'
+import { type VariantTrackDefinition, useGenomeTrack } from '@/lib/genome/useGenomeBrowser'
+import { variantAccessibleLabel, variantDetailLines, variantLabel } from '@/lib/genome/variant'
+import {
+ VARIANT_MARK_HALF_HEIGHT,
+ layoutVariantMarks,
+ variantRowY,
+ variantTrackHeight,
+ variantsInViewport,
+} from '@/lib/genome/variantGeometry'
+
+const SVG_WIDTH = 1000
+const TRACK_HEADER_WIDTH = 96
+const MARK_WIDTH = 2
+const MARK_COLOR = '#0891b2'
+const SELECTED_MARK_COLOR = '#db2777'
+
+/**
+ * Renders one lane of point variants (Phase 6.4).
+ *
+ * Variants are single-position records drawn as vertical marks positioned
+ * with the shared Genome Browser scale, so they stay aligned with the axis.
+ * Dense regions are stacked onto rows when marks would overlap. Each mark
+ * carries a hover `` and is a keyboard-focusable selection control;
+ * selecting a variant reveals a readable detail panel beneath the lane.
+ *
+ * Owns its own track hook (called unconditionally); the browser passes the
+ * discriminated `VariantTrackDefinition`, so `data` resolves to
+ * `VariantFeature` records with no cast.
+ */
+export function VariantTrack({
+ track,
+ debouncedViewport,
+}: {
+ track: VariantTrackDefinition
+ debouncedViewport: GenomeViewport
+}) {
+ const data = useGenomeTrack(track, debouncedViewport)
+ const viewport = debouncedViewport
+ const scale = createScale(viewport.start, viewport.end, SVG_WIDTH - TRACK_HEADER_WIDTH)
+ const variants = variantsInViewport(data.data ?? [], viewport)
+ const marks = layoutVariantMarks(scale, variants)
+
+ const [selectedId, setSelectedId] = useState(null)
+ const selected = selectedId !== null ? variants.find((v) => v.id === selectedId) : undefined
+ const rows = marks.length > 0 ? Math.max(...marks.map((mark) => mark.row)) + 1 : 0
+ const height = variantTrackHeight(rows)
+ const title = `${data.label}: ${variants.length} ${variants.length === 1 ? 'variant' : 'variants'}`
+
+ const handleSelect = (variant: VariantFeature) => {
+ setSelectedId((current) => (current === variant.id ? null : variant.id))
+ }
+
+ return (
+
+ {data.status === 'success' ? (
+ <>
+
+ {selected ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ )
+}
+
+function VariantDetail({
+ label,
+ lines,
+ testId,
+}: {
+ label: string
+ lines: { label: string; value: string }[]
+ testId: string
+}) {
+ return (
+
+ {label}
+
+ {lines.map((line) => (
+
+
{line.label}
+
{line.value}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/src/lib/genome/api.test.ts b/apps/web/src/lib/genome/api.test.ts
index 9782f0b..ed00818 100644
--- a/apps/web/src/lib/genome/api.test.ts
+++ b/apps/web/src/lib/genome/api.test.ts
@@ -98,6 +98,44 @@ describe('toVariantFeature', () => {
expect(feature.end).toBe(140_453_136)
expect(feature.ref).toBe('C')
expect(feature.alt).toBe('T')
+ expect(feature.name).toBe('C>T')
+ expect(feature.variantId).toBe('rs113488022')
+ })
+
+ it('carries optional variant attributes when present', () => {
+ const feature = toVariantFeature({
+ id: 'var-3',
+ variant_id: 'rs1',
+ chromosome: 'chr7',
+ position: 100,
+ ref: 'A',
+ alt: 'G',
+ type: 'snv',
+ quality: 99.5,
+ filter_status: 'PASS',
+ gene_id: 'gene-1',
+ description: 'missense',
+ })
+ expect(feature.variantType).toBe('snv')
+ expect(feature.quality).toBe(99.5)
+ expect(feature.filterStatus).toBe('PASS')
+ expect(feature.geneId).toBe('gene-1')
+ expect(feature.description).toBe('missense')
+ })
+
+ it('leaves optional variant attributes undefined when absent', () => {
+ const feature = toVariantFeature({
+ id: 'var-4',
+ chromosome: 'chr7',
+ position: 50,
+ ref: 'C',
+ alt: 'T',
+ })
+ expect(feature.variantType).toBeUndefined()
+ expect(feature.quality).toBeUndefined()
+ expect(feature.filterStatus).toBeUndefined()
+ expect(feature.geneId).toBeUndefined()
+ expect(feature.description).toBeUndefined()
})
it('returns position 0 when invalid', () => {
diff --git a/apps/web/src/lib/genome/api.ts b/apps/web/src/lib/genome/api.ts
index 3328a0f..d79bb31 100644
--- a/apps/web/src/lib/genome/api.ts
+++ b/apps/web/src/lib/genome/api.ts
@@ -135,8 +135,9 @@ export function toVariantFeature(item: RawSearchItem): VariantFeature {
}
const ref = asString(item.ref)
const alt = asString(item.alt)
+ const quality = asNumber(item.quality)
return {
- id: idOf(item.id),
+ id: idOf(item.id) || asString(item.variant_id) || '',
type: 'variant',
chromosome,
start: position,
@@ -145,7 +146,12 @@ export function toVariantFeature(item: RawSearchItem): VariantFeature {
ref,
alt,
name: ref && alt ? `${ref}>${alt}` : undefined,
- metadata: { ...withOptional('variantId', asString(item.variant_id)) },
+ variantId: asString(item.variant_id),
+ variantType: asString(item.type),
+ ...(quality !== undefined ? { quality } : {}),
+ filterStatus: asString(item.filter_status),
+ geneId: asString(item.gene_id),
+ description: asString(item.description),
}
}
diff --git a/apps/web/src/lib/genome/types.ts b/apps/web/src/lib/genome/types.ts
index 0e21ce5..8a72057 100644
--- a/apps/web/src/lib/genome/types.ts
+++ b/apps/web/src/lib/genome/types.ts
@@ -72,6 +72,18 @@ export interface VariantFeature extends GenomicFeature<
position: number
ref?: string
alt?: string
+ /** Source accession, e.g. `rs113488022`. */
+ variantId?: string
+ /** Biological variant class as reported by the API, e.g. `snv`. */
+ variantType?: string
+ /** Variant call quality score where the source reports one. */
+ quality?: number
+ /** Variant filter status, e.g. `PASS`. */
+ filterStatus?: string
+ /** Identifier of the linked gene where the source provides one. */
+ geneId?: string
+ /** Optional free-text description. */
+ description?: string
}
/** Known contig extents used to clamp navigation. */
diff --git a/apps/web/src/lib/genome/useGenomeBrowser.ts b/apps/web/src/lib/genome/useGenomeBrowser.ts
index 3ee41e3..2749c19 100644
--- a/apps/web/src/lib/genome/useGenomeBrowser.ts
+++ b/apps/web/src/lib/genome/useGenomeBrowser.ts
@@ -24,33 +24,56 @@ import type { VisualizationStatus } from '@/lib/visualization/types'
import { useVisualizationData } from '@/lib/visualization/useVisualizationData'
import type { TrackKind } from './tracks'
-import type { GenomeViewport, GenomicFeature, GenomicInterval } from './types'
+import type { GenomeViewport, GenomicFeature, GenomicInterval, VariantFeature } from './types'
import { PAN_FRACTION, ZOOM_FACTOR, panViewport, viewportBaseCount, zoomViewport } from './viewport'
/** Loads a track's visible features for a one-based inclusive interval. */
-export type GenomeTrackLoader = (
+export type GenomeTrackLoader = (
interval: GenomicInterval,
signal: AbortSignal,
-) => Promise
+) => Promise
-/** Definition of a visible track (kind maps to rendering + colours). */
-export interface GenomeTrackDefinition {
+/** Definition of a span track lane (genes, transcripts, ...). */
+export interface SpanTrackDefinition {
id: string
label: string
- kind: TrackKind
- loader: GenomeTrackLoader
+ kind: Exclude
+ loader: GenomeTrackLoader
+}
+
+/** Definition of a point-variant track lane. */
+export interface VariantTrackDefinition {
+ id: string
+ label: string
+ kind: 'variants'
+ loader: GenomeTrackLoader
}
+/**
+ * Definition of a visible track; discriminated on `kind` so each lane
+ * receives the exact feature type its loader resolves to.
+ */
+export type GenomeTrackDefinition = SpanTrackDefinition | VariantTrackDefinition
+
/** A track's data-layer result, keyed by `id`. */
-export interface GenomeTrackData extends GenomeTrackDefinition {
+export interface GenomeTrackDataBase {
+ id: string
+ label: string
+ kind: TrackKind
+ loader: GenomeTrackLoader
status: VisualizationStatus
- data: GenomicFeature[] | undefined
+ data: T[] | undefined
/** Present when `status === 'error'`. */
errorMessage: string | undefined
/** Re-runs the track's request (used by the retry UI). */
refetch: () => void
}
+export type GenomeTrackData =
+ T extends VariantTrackDefinition
+ ? GenomeTrackDataBase & { kind: 'variants' }
+ : GenomeTrackDataBase & { kind: Exclude }
+
export interface GenomeBrowserOptions {
/** Viewport shown on first render. */
initialViewport: GenomeViewport
@@ -99,10 +122,10 @@ function useDebouncedViewport(viewport: GenomeViewport, debounceMs: number): Gen
* when the debounced viewport settles on a new region; the initial request
* is fired by `useVisualizationData`.
*/
-export function useGenomeTrack(
- definition: GenomeTrackDefinition,
+export function useGenomeTrack(
+ definition: T,
debouncedViewport: GenomeViewport,
-): GenomeTrackData {
+): GenomeTrackData {
const definitionRef = useRef(definition)
definitionRef.current = definition
@@ -143,6 +166,9 @@ export function useGenomeTrack(
}
}, [refetch, regionKey])
+ // `data` is narrowed to the definition's exact feature type by the
+ // `GenomeTrackData` mapped type; the hook is the single adapter boundary
+ // where the shared `GenomicFeature` result becomes the lane's typed data.
return {
id: definition.id,
label: definition.label,
@@ -152,7 +178,7 @@ export function useGenomeTrack(
data,
errorMessage: error?.message,
refetch,
- }
+ } as GenomeTrackData
}
/** Clamps an interval to contig bounds (inclusive 1..length). */
diff --git a/apps/web/src/lib/genome/variant.test.ts b/apps/web/src/lib/genome/variant.test.ts
new file mode 100644
index 0000000..100acb7
--- /dev/null
+++ b/apps/web/src/lib/genome/variant.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+
+import type { Variant } from './variant'
+import {
+ isValidVariant,
+ toVariant,
+ variantAccessibleLabel,
+ variantDetailLines,
+ variantLabel,
+} from './variant'
+
+function variant(overrides: Partial & { id: string }): Variant {
+ return {
+ type: 'variant',
+ chromosome: 'chr7',
+ start: 100,
+ end: 100,
+ position: 100,
+ ...overrides,
+ }
+}
+
+describe('toVariant', () => {
+ it('normalizes a raw variant record', () => {
+ const result = toVariant({
+ id: 'var-1',
+ variant_id: 'rs113488022',
+ chromosome: 'chr7',
+ position: 140_453_136,
+ ref: 'C',
+ alt: 'T',
+ type: 'snv',
+ quality: 99.5,
+ filter_status: 'PASS',
+ gene_id: 'gene-1',
+ description: 'missense',
+ })
+ expect(result).toMatchObject({
+ id: 'var-1',
+ variantId: 'rs113488022',
+ chromosome: 'chr7',
+ position: 140_453_136,
+ start: 140_453_136,
+ end: 140_453_136,
+ ref: 'C',
+ alt: 'T',
+ name: 'C>T',
+ variantType: 'snv',
+ quality: 99.5,
+ filterStatus: 'PASS',
+ geneId: 'gene-1',
+ description: 'missense',
+ })
+ })
+
+ it('falls back to variant_id for id when the record id is missing', () => {
+ const result = toVariant({ variant_id: 'rs1', chromosome: 'chr7', position: 10 })
+ expect(result.id).toBe('rs1')
+ })
+
+ it('returns an empty variant when coordinates are invalid', () => {
+ const result = toVariant({ variant_id: 'rs2', chromosome: 'chr7', position: -1 })
+ expect(result.position).toBe(0)
+ expect(result.chromosome).toBe('')
+ })
+})
+
+describe('isValidVariant', () => {
+ it('accepts a usable 1-based point', () => {
+ expect(isValidVariant(variant({ id: 'v1', position: 7_688_456 }))).toBe(true)
+ })
+
+ it('rejects zero, negative, and missing positions', () => {
+ expect(isValidVariant(variant({ id: 'v2', position: 0 }))).toBe(false)
+ expect(isValidVariant(variant({ id: 'v3', position: -5 }))).toBe(false)
+ expect(isValidVariant(variant({ id: 'v4', position: 1.5 }))).toBe(false)
+ })
+
+ it('rejects variants without a chromosome', () => {
+ expect(isValidVariant(variant({ id: 'v5', chromosome: '' }))).toBe(false)
+ })
+
+ it('rejects variants without a non-empty identity', () => {
+ expect(isValidVariant({ ...variant({ id: 'v6', position: 10 }), id: '' })).toBe(false)
+ })
+})
+
+describe('variantLabel', () => {
+ it('uses ref>alt when both alleles are known', () => {
+ expect(variantLabel(variant({ id: 'v1', ref: 'C', alt: 'T' }))).toBe('C>T')
+ })
+
+ it('falls back to the accession then the id', () => {
+ expect(variantLabel(variant({ id: 'v2', variantId: 'rs9' }))).toBe('rs9')
+ expect(variantLabel(variant({ id: 'v3' }))).toBe('v3')
+ expect(variantLabel(variant({ id: '', position: 42 }))).toBe('chr7:42')
+ })
+})
+
+describe('variantAccessibleLabel', () => {
+ it('describes position, type, and filter', () => {
+ const label = variantAccessibleLabel(
+ variant({ id: 'v1', ref: 'C', alt: 'T', variantType: 'snv', filterStatus: 'PASS' }),
+ )
+ expect(label).toBe('C>T, chr7:100, snv, filter PASS')
+ })
+
+ it('works with minimal data', () => {
+ expect(variantAccessibleLabel(variant({ id: 'v2', position: 5 }))).toBe('v2, chr7:5')
+ })
+})
+
+describe('variantDetailLines', () => {
+ it('lists position, alleles, type, quality, filter, and metadata', () => {
+ const lines = variantDetailLines(
+ variant({
+ id: 'v1',
+ ref: 'C',
+ alt: 'T',
+ variantType: 'snv',
+ quality: 99.5,
+ filterStatus: 'PASS',
+ variantId: 'rs1',
+ geneId: 'gene-1',
+ description: 'missense',
+ }),
+ )
+ expect(lines).toEqual([
+ { label: 'Position', value: 'chr7:100' },
+ { label: 'Alleles', value: 'C>T' },
+ { label: 'Type', value: 'snv' },
+ { label: 'Quality', value: '99.5' },
+ { label: 'Filter', value: 'PASS' },
+ { label: 'Accession', value: 'rs1' },
+ { label: 'Gene', value: 'gene-1' },
+ { label: 'Description', value: 'missense' },
+ ])
+ })
+
+ it('omits absent fields', () => {
+ const lines = variantDetailLines(variant({ id: 'v2' }))
+ expect(lines).toEqual([{ label: 'Position', value: 'chr7:100' }])
+ })
+})
diff --git a/apps/web/src/lib/genome/variant.ts b/apps/web/src/lib/genome/variant.ts
new file mode 100644
index 0000000..c1ea286
--- /dev/null
+++ b/apps/web/src/lib/genome/variant.ts
@@ -0,0 +1,104 @@
+/**
+ * Variant domain model (Phase 6.4).
+ *
+ * Variants in the GenomeAI data model are **single-position** records:
+ * the coordinate-search API returns one 1-based `position` (there is no
+ * `end_position` and no strand). This module defines the typed surface the
+ * variant visualization consumes, reusing the existing `VariantFeature`
+ * type from `lib/genome/types.ts`, and provides the pure helpers (label
+ * formatting, accessibility text, validation) that the renderer builds on.
+ *
+ * ## Type representation
+ *
+ * The API exposes `type` as a free-form, nullable string (e.g. `snv`) with
+ * no enumerated vocabulary on the backend. The model therefore carries it
+ * as opaque text and renders it verbatim — it never infers a variant class
+ * from arbitrary strings or from `ref`/`alt` lengths. `ref > alt` is used
+ * only as a display convenience, never as a classification.
+ */
+
+import { toVariantFeature } from './api'
+import type { RawSearchItem } from './api'
+import type { VariantFeature } from './types'
+
+/** The typed variant record used by the variant visualization. */
+export type Variant = VariantFeature
+
+/**
+ * Normalizes a raw variant record from the coordinate-search API.
+ *
+ * Delegates to the single shared mapper in `lib/genome/api.ts`
+ * (`toVariantFeature`) so the model and the data adapter can never drift
+ * apart when the variant schema changes.
+ */
+export function toVariant(item: RawSearchItem): Variant {
+ return toVariantFeature(item)
+}
+
+/**
+ * True when `variant` carries a usable 1-based point coordinate and a
+ * non-empty identity (used as the React key and selection identity).
+ */
+export function isValidVariant(variant: Variant): boolean {
+ return (
+ variant.id.length > 0 &&
+ variant.chromosome.length > 0 &&
+ Number.isSafeInteger(variant.position) &&
+ variant.position >= 1
+ )
+}
+
+/**
+ * Short display label for a variant: `ref>alt` when both alleles are known,
+ * otherwise the accession (`variantId`) or the internal id.
+ */
+export function variantLabel(variant: Variant): string {
+ if (variant.ref && variant.alt) return `${variant.ref}>${variant.alt}`
+ if (variant.variantId) return variant.variantId
+ return variant.id || `${variant.chromosome}:${variant.position}`
+}
+
+/**
+ * Accessible name describing one variant, e.g.
+ * `C>T at chr17:7,688,456 (rs113488022, snv)`.
+ */
+export function variantAccessibleLabel(variant: Variant): string {
+ const parts = [
+ variantLabel(variant),
+ `${variant.chromosome}:${variant.position.toLocaleString('en-US')}`,
+ ]
+ if (variant.variantType) parts.push(variant.variantType)
+ if (variant.filterStatus) parts.push(`filter ${variant.filterStatus}`)
+ return parts.join(', ')
+}
+
+/** A labelled detail line shown in the variant detail panel. */
+export interface VariantDetailLine {
+ label: string
+ value: string
+}
+
+/**
+ * Readable detail lines for a selected variant. Only fields the API
+ * actually reports are included, in a stable order.
+ */
+export function variantDetailLines(variant: Variant): VariantDetailLine[] {
+ const lines: VariantDetailLine[] = [
+ {
+ label: 'Position',
+ value: `${variant.chromosome}:${variant.position.toLocaleString('en-US')}`,
+ },
+ ]
+ if (variant.ref || variant.alt) {
+ lines.push({ label: 'Alleles', value: `${variant.ref ?? '?'}>${variant.alt ?? '?'}` })
+ }
+ if (variant.variantType) lines.push({ label: 'Type', value: variant.variantType })
+ if (variant.quality !== undefined) {
+ lines.push({ label: 'Quality', value: String(variant.quality) })
+ }
+ if (variant.filterStatus) lines.push({ label: 'Filter', value: variant.filterStatus })
+ if (variant.variantId) lines.push({ label: 'Accession', value: variant.variantId })
+ if (variant.geneId) lines.push({ label: 'Gene', value: variant.geneId })
+ if (variant.description) lines.push({ label: 'Description', value: variant.description })
+ return lines
+}
diff --git a/apps/web/src/lib/genome/variantApi.test.ts b/apps/web/src/lib/genome/variantApi.test.ts
new file mode 100644
index 0000000..db80a12
--- /dev/null
+++ b/apps/web/src/lib/genome/variantApi.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { API_BASE_URL } from './api'
+import { fetchVariants } from './variantApi'
+
+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('fetchVariants', () => {
+ it('POSTs to the variant coordinate endpoint and normalizes point features', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ items: [
+ {
+ id: 'var-1',
+ variant_id: 'rs113488022',
+ chromosome: 'chr7',
+ position: 140_453_136,
+ ref: 'C',
+ alt: 'T',
+ type: 'snv',
+ quality: 99.5,
+ filter_status: 'PASS',
+ },
+ ],
+ pagination: { page: 1, page_size: 100, total_count: 1 },
+ }),
+ )
+ globalThis.fetch = fetchMock as unknown as typeof fetch
+
+ const { signal } = new AbortController()
+ const variants = await fetchVariants(
+ { chromosome: 'chr7', start: 140_000_000, end: 141_000_000 },
+ signal,
+ )
+
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
+ expect(url).toContain(`${API_BASE_URL}/search/variant/coordinate`)
+ const body = JSON.parse(String(init.body)) as {
+ interval: { chromosome: string; start: number; end: number }
+ match_type: string
+ }
+ expect(body.interval).toEqual({ chromosome: 'chr7', start: 140_000_000, end: 141_000_000 })
+ expect(body.match_type).toBe('overlap')
+ expect(init.signal).toBe(signal)
+
+ expect(variants).toHaveLength(1)
+ expect(variants[0]).toMatchObject({
+ id: 'var-1',
+ variantId: 'rs113488022',
+ chromosome: 'chr7',
+ position: 140_453_136,
+ variantType: 'snv',
+ quality: 99.5,
+ filterStatus: 'PASS',
+ })
+ })
+
+ it('filters out items without usable coordinates', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ jsonResponse({
+ items: [
+ { id: 'bad', chromosome: 'chr7', position: -1 },
+ { id: 'good', chromosome: 'chr7', position: 10, ref: 'C', alt: 'T' },
+ ],
+ pagination: { page: 1, page_size: 100, total_count: 2 },
+ }),
+ )
+
+ const variants = await fetchVariants({ chromosome: 'chr7', start: 1, end: 100 })
+ expect(variants).toHaveLength(1)
+ expect(variants[0].id).toBe('good')
+ })
+
+ it('filters out records with neither id nor variant_id so selection stays stable', async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ jsonResponse({
+ items: [
+ { chromosome: 'chr7', position: 10 },
+ { id: 'named', variant_id: 'rs1', chromosome: 'chr7', position: 20 },
+ ],
+ pagination: { page: 1, page_size: 100, total_count: 2 },
+ }),
+ )
+
+ const variants = await fetchVariants({ chromosome: 'chr7', start: 1, end: 100 })
+ expect(variants).toHaveLength(1)
+ expect(variants[0].id).toBe('named')
+ })
+})
diff --git a/apps/web/src/lib/genome/variantApi.ts b/apps/web/src/lib/genome/variantApi.ts
new file mode 100644
index 0000000..0c9c968
--- /dev/null
+++ b/apps/web/src/lib/genome/variantApi.ts
@@ -0,0 +1,45 @@
+/**
+ * Variant data adapter (Phase 6.4).
+ *
+ * Thin typed adapter over the existing Phase 5 coordinate-search API. It
+ * reuses the shared request pipeline in `lib/genome/api.ts`
+ * (`POST /search/variant/coordinate` with one-based-inclusive intervals) —
+ * no duplicate HTTP logic and no backend changes.
+ *
+ * The backend treats variants as single-position records: the variant domain
+ * maps both `start` and `end` columns to the `position` column, so an
+ * "overlap" query returns every variant whose single position falls inside
+ * the requested interval.
+ */
+
+import { requestCoordinateSearch } from './api'
+import type { RawSearchItem } from './api'
+import type { GenomicInterval } from './types'
+import { isValidVariant, toVariant } from './variant'
+import type { Variant } from './variant'
+
+export interface FetchVariantsOptions {
+ /** Page size forwarded to the coordinate-search API. */
+ pageSize?: number
+}
+
+function asRawItems(items: unknown[]): RawSearchItem[] {
+ return items.filter(
+ (value): value is RawSearchItem =>
+ typeof value === 'object' && value !== null && !Array.isArray(value),
+ )
+}
+
+/**
+ * Fetches variants whose single position overlaps `interval` and returns
+ * them as typed `Variant` records. Reuses the shared coordinate-search
+ * pipeline and the caller's `AbortSignal`.
+ */
+export async function fetchVariants(
+ interval: GenomicInterval,
+ signal?: AbortSignal,
+ options: FetchVariantsOptions = {},
+): Promise {
+ const items = await requestCoordinateSearch('variant', interval, signal, options.pageSize ?? 100)
+ return asRawItems(items).map(toVariant).filter(isValidVariant)
+}
diff --git a/apps/web/src/lib/genome/variantGeometry.test.ts b/apps/web/src/lib/genome/variantGeometry.test.ts
new file mode 100644
index 0000000..95e6665
--- /dev/null
+++ b/apps/web/src/lib/genome/variantGeometry.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+
+import { createScale } from './geometry'
+import type { GenomeViewport, VariantFeature } from './types'
+import {
+ VARIANT_MIN_SEPARATION,
+ layoutVariantMarks,
+ variantInViewport,
+ variantRowY,
+ variantTrackHeight,
+ variantX,
+ variantsInViewport,
+} from './variantGeometry'
+
+const viewport: GenomeViewport = { chromosome: 'chr17', start: 7_650_000, end: 7_700_000 }
+const width = 1000
+
+function variant(overrides: Partial & { id: string }): VariantFeature {
+ return {
+ type: 'variant',
+ chromosome: 'chr17',
+ start: 7_668_000,
+ end: 7_668_000,
+ position: 7_668_000,
+ ...overrides,
+ }
+}
+
+function scale() {
+ return createScale(viewport.start, viewport.end, width)
+}
+
+describe('variantInViewport', () => {
+ it('keeps variants on the same chromosome inside the inclusive window', () => {
+ expect(variantInViewport(variant({ id: 'v1', position: 7_650_000 }), viewport)).toBe(true)
+ expect(variantInViewport(variant({ id: 'v2', position: 7_700_000 }), viewport)).toBe(true)
+ expect(variantInViewport(variant({ id: 'v3', position: 7_675_000 }), viewport)).toBe(true)
+ })
+
+ it('excludes variants outside the window or on another chromosome', () => {
+ expect(variantInViewport(variant({ id: 'v4', position: 7_649_999 }), viewport)).toBe(false)
+ expect(variantInViewport(variant({ id: 'v5', position: 7_700_001 }), viewport)).toBe(false)
+ expect(
+ variantInViewport(variant({ id: 'v6', position: 7_675_000, chromosome: 'chr18' }), viewport),
+ ).toBe(false)
+ })
+})
+
+describe('variantsInViewport', () => {
+ it('filters a list to the visible window', () => {
+ const result = variantsInViewport(
+ [
+ variant({ id: 'a', position: 7_649_000 }),
+ variant({ id: 'b', position: 7_660_000 }),
+ variant({ id: 'c', position: 7_701_000 }),
+ ],
+ viewport,
+ )
+ expect(result.map((v) => v.id)).toEqual(['b'])
+ })
+})
+
+describe('variantX', () => {
+ it('maps the window start to 0 and each base to a proportional pixel', () => {
+ const s = scale()
+ expect(variantX(s, viewport.start)).toBeCloseTo(0)
+ expect(variantX(s, viewport.start + 1)).toBeCloseTo(s.pxPerBase)
+ })
+
+ it('maps the final base just inside the canvas edge', () => {
+ const s = scale()
+ expect(variantX(s, viewport.end)).toBeCloseTo(width - s.pxPerBase)
+ })
+
+ it('maps a mid-window position proportionally', () => {
+ const s = scale()
+ const mid = Math.floor((viewport.start + viewport.end) / 2)
+ expect(variantX(s, mid)).toBeCloseTo((mid - viewport.start) * s.pxPerBase)
+ })
+})
+
+describe('layoutVariantMarks', () => {
+ it('places non-overlapping variants on a single row', () => {
+ const s = scale()
+ const marks = layoutVariantMarks(s, [
+ variant({ id: 'a', position: viewport.start }),
+ variant({ id: 'b', position: viewport.start + 1_000 }),
+ ])
+ expect(marks).toHaveLength(2)
+ expect(marks.every((mark) => mark.row === 0)).toBe(true)
+ })
+
+ it('stacks variants that land on the same pixel', () => {
+ const s = scale()
+ const marks = layoutVariantMarks(s, [
+ variant({ id: 'a', position: viewport.start }),
+ variant({ id: 'b', position: viewport.start }),
+ variant({ id: 'c', position: viewport.start }),
+ ])
+ expect(marks.map((mark) => mark.row)).toEqual([0, 1, 2])
+ })
+
+ it('sorts by position then id deterministically', () => {
+ const s = scale()
+ const marks = layoutVariantMarks(s, [
+ variant({ id: 'z', position: viewport.start + 5 }),
+ variant({ id: 'a', position: viewport.start }),
+ ])
+ expect(marks.map((mark) => mark.variant.id)).toEqual(['a', 'z'])
+ })
+
+ it('honours a custom minimum separation', () => {
+ // 10 bases drawn into 20 px → 2 px per base, so adjacent bases land 2 px
+ // apart and stack under the default separation of 5 px.
+ const s = createScale(1, 10, 20)
+ const tight = layoutVariantMarks(s, [
+ variant({ id: 'a', position: 1 }),
+ variant({ id: 'b', position: 2 }),
+ ])
+ expect(tight[1].row).toBeGreaterThan(0)
+
+ // With separation 1 they share a row.
+ const loose = layoutVariantMarks(
+ s,
+ [variant({ id: 'a', position: 1 }), variant({ id: 'b', position: 2 })],
+ 1,
+ )
+ expect(loose[1].row).toBe(0)
+ expect(VARIANT_MIN_SEPARATION).toBe(5)
+ })
+})
+
+describe('variantTrackHeight / variantRowY', () => {
+ it('keeps at least one row and grows with row count', () => {
+ expect(variantTrackHeight(0)).toBe(12)
+ expect(variantTrackHeight(1)).toBe(12)
+ expect(variantTrackHeight(3)).toBe(36)
+ })
+
+ it('centres each stacked row', () => {
+ expect(variantRowY(0)).toBe(6)
+ expect(variantRowY(1)).toBe(18)
+ })
+})
diff --git a/apps/web/src/lib/genome/variantGeometry.ts b/apps/web/src/lib/genome/variantGeometry.ts
new file mode 100644
index 0000000..b21dfb2
--- /dev/null
+++ b/apps/web/src/lib/genome/variantGeometry.ts
@@ -0,0 +1,112 @@
+/**
+ * Pixel geometry for the variant track (Phase 6.4).
+ *
+ * Variants are single-position records, so their geometry is a point mark
+ * on the shared Genome Browser scale (`lib/genome/geometry.ts`) rather than
+ * a span. All functions map one-based-inclusive positions onto the pixel
+ * canvas so the marks stay aligned with any Genome Browser axis built from
+ * the same viewport. Pure functions, unit-testable without a DOM.
+ */
+
+import type { GenomeScale } from './geometry'
+import type { GenomeViewport, VariantFeature } from './types'
+
+/** Half-height of a variant mark above/below the lane centre (px). */
+export const VARIANT_MARK_HALF_HEIGHT = 4
+
+/** Vertical stride used between stacked variant marks (px). */
+export const VARIANT_ROW_HEIGHT = 12
+
+/**
+ * Minimum pixel separation before two marks are treated as overlapping and
+ * pushed onto separate rows, so dense regions stay readable.
+ */
+export const VARIANT_MIN_SEPARATION = 5
+
+/** A variant placed in pixel space, ready for SVG rendering. */
+export interface VariantMark {
+ variant: VariantFeature
+ /** Pixel x within the drawing area (header offset not included). */
+ x: number
+ /** Row index within the lane (0 = top). */
+ row: number
+}
+
+/**
+ * True when the variant's point lies inside the viewport on the same
+ * chromosome (one-based inclusive `start <= position <= end`).
+ */
+export function variantInViewport(variant: VariantFeature, viewport: GenomeViewport): boolean {
+ return (
+ variant.chromosome === viewport.chromosome &&
+ variant.position >= viewport.start &&
+ variant.position <= viewport.end
+ )
+}
+
+/** Keeps only variants whose single position is inside the viewport. */
+export function variantsInViewport(
+ variants: readonly VariantFeature[],
+ viewport: GenomeViewport,
+): VariantFeature[] {
+ return variants.filter((variant) => variantInViewport(variant, viewport))
+}
+
+/**
+ * Pixel x of a variant's point mark, without the header offset. The caller
+ * adds the lane gutter so marks line up with the browser axis.
+ */
+export function variantX(scale: GenomeScale, position: number): number {
+ return scale.toX(position)
+}
+
+/**
+ * Greedy pixel-space row packing for point marks.
+ *
+ * Variants are sorted deterministically by position, then id. Each variant
+ * is placed on the first row whose rightmost mark lies at least
+ * `VARIANT_MIN_SEPARATION` pixels to the left, so adjacent or identical
+ * positions never fully obscure one another. Deterministic and O(n log n).
+ */
+export function layoutVariantMarks(
+ scale: GenomeScale,
+ variants: readonly VariantFeature[],
+ minSeparation: number = VARIANT_MIN_SEPARATION,
+): VariantMark[] {
+ const sorted = [...variants].sort((a, b) => a.position - b.position || a.id.localeCompare(b.id))
+
+ const marks: VariantMark[] = []
+ const rowEnds: number[] = []
+
+ for (const variant of sorted) {
+ const x = variantX(scale, variant.position)
+ let assignedRow = -1
+ for (let row = 0; row < rowEnds.length; row += 1) {
+ if (x >= rowEnds[row] + minSeparation) {
+ assignedRow = row
+ break
+ }
+ }
+ if (assignedRow === -1) {
+ assignedRow = rowEnds.length
+ rowEnds.push(0)
+ }
+ rowEnds[assignedRow] = Math.max(rowEnds[assignedRow], x)
+ marks.push({ variant, x, row: assignedRow })
+ }
+
+ return marks
+}
+
+/**
+ * Total pixel height needed for a variant lane: one row minimum, growing by
+ * `VARIANT_ROW_HEIGHT` per extra row so stacked marks never clip.
+ */
+export function variantTrackHeight(rowCount: number): number {
+ return Math.max(1, rowCount) * VARIANT_ROW_HEIGHT
+}
+
+/** Pixel y of a stacked row's centre line within the lane. */
+export function variantRowY(row: number): number {
+ return row * VARIANT_ROW_HEIGHT + VARIANT_ROW_HEIGHT / 2
+}
diff --git a/docs/external-data/MASTER_PLAN.md b/docs/external-data/MASTER_PLAN.md
new file mode 100644
index 0000000..bfbd6da
--- /dev/null
+++ b/docs/external-data/MASTER_PLAN.md
@@ -0,0 +1,461 @@
+# GenomeAI External Data & API Master Plan
+
+**Status:** Accepted — official governing reference for Phases 4–9
+**Owner:** GenomeAI core
+**Scope:** Every external scientific source, API, connector, ingestion path, and storage decision.
+
+---
+
+## 0. The architecture we will build
+
+```text
+ PUBLIC SCIENTIFIC SOURCES
+ |
+ +---------------------+---------------------+
+ | | |
+ NCBI Ensembl UCSC
+ UniProt ClinVar gnomAD
+ GTEx ENCODE Reactome
+ STRING PDB AlphaFold
+ Open Targets HPO PubChem
+ ChEMBL PubMed Europe PMC
+ | | |
+ +---------------------+---------------------+
+ |
+ GenomeAI CONNECTOR LAYER
+ |
+ INGESTION PIPELINE
+ |
+ VALIDATION + NORMALIZATION
+ |
+ +---------------+---------------+
+ | |
+ PostgreSQL Object Storage
+ metadata/entities large files
+ | |
+ +---------------+---------------+
+ |
+ SEARCH / INDEX LAYER
+ |
+ GenomeAI API
+ |
+ +---------------+---------------+
+ | | |
+ Web CLI AI
+ Visualization Python SDK Agents
+```
+
+**Rule:** the frontend must never call external databases directly. External sources → GenomeAI connectors → ingestion → GenomeAI API → Web/AI.
+
+---
+
+## 1. Tier 1 — Core sources
+
+| Source | Main data | API | Bulk | Priority |
+|--------|-----------|-----|------|----------|
+| NCBI Datasets | genomes, genes, assemblies | yes | yes | Critical |
+| NCBI E-utilities | Gene, Protein, PubMed, ClinVar, etc. | yes | — | Critical |
+| Ensembl | genes, transcripts, variants | yes | yes | Critical |
+| UCSC | genome tracks/regions | yes | yes | Critical |
+| GENCODE | gene/transcript annotation | — | yes | Critical |
+| UniProt | proteins | yes | yes | Critical |
+| ClinVar | clinical variants | yes | yes | Critical |
+| gnomAD | population variation | limited | yes | Critical |
+| GTEx | expression/eQTL | yes | yes | Critical |
+| ENCODE | regulatory genomics | yes | yes | Critical |
+| RCSB PDB | structures | yes | yes | Critical |
+| AlphaFold DB | predicted structures | yes | yes | Critical |
+| Reactome | pathways | yes | yes | Critical |
+| STRING | protein networks | yes | yes | Critical |
+| HPO | phenotypes | yes | yes | Critical |
+| Disease Ontology | diseases | yes | yes | Critical |
+| Open Targets | target/disease/variant | GraphQL | yes | Critical |
+| PubMed | literature | yes | — | Critical |
+| Europe PMC | literature/full text | yes | yes | Critical |
+| PubChem | compounds | yes | yes | Critical |
+| ChEMBL | drug/target bioactivity | yes | yes | Critical |
+
+Notes:
+- NCBI Datasets v2 REST API: default 5 req/s; 10 req/s with an API key.
+- Tier 2/3 sources (BioGRID, IntAct, InterPro, Pfam, cBioPortal, COSMIC, PRIDE, Expression Atlas, DrugCentral, BindingDB, PharmGKB, OpenAlex, Crossref, KEGG, OMIM, DrugBank, ICGC, Orphanet, BRENDA, SIDER) come later. Licensing/access must be reviewed — "public" does not mean unrestricted commercial redistribution.
+
+---
+
+## 2. NCBI connector
+
+One NCBI connector, not scattered clients.
+
+```text
+GenomeAI NCBI Connector
+|
++-- Datasets
+| +-- genome
+| +-- gene
+| +-- virus
+| +-- taxonomy
+|
++-- E-utilities
+| +-- Gene
+| +-- Protein
+| +-- Nucleotide
+| +-- PubMed
+| +-- PMC
+| +-- ClinVar
+| +-- PubChem
+|
++-- Downloads
+ +-- assemblies
+ +-- annotations
+ +-- sequence data
+```
+
+Planned interface (do **not** implement until the data-integration phase):
+
+```python
+class NCBIConnector:
+ async def get_gene(...)
+ async def get_genome(...)
+ async def get_assembly(...)
+ async def search_gene(...)
+ async def search_variant(...)
+ async def get_clinvar_record(...)
+ async def search_pubmed(...)
+```
+
+ClinVar is reachable through E-utilities via `esearch`, `esummary`, `elink`, `efetch`.
+
+---
+
+## 3. Ensembl
+
+Major gene/transcript/variant reference.
+
+```text
+Ensembl
+|
++-- Gene
++-- Transcript
++-- Exon
++-- Variant
++-- Region
++-- Sequence
++-- Comparative genomics
++-- Identifier mapping
+```
+
+GenomeAI use: Phase 4 validate/augment domains · Phase 5 search/index · Phase 6 genome browser · Phase 9 scientific analysis.
+
+---
+
+## 4. UCSC
+
+Visualization/reference-track source. Do not hammer it.
+
+- Small interactive query → UCSC API.
+- Large dataset → UCSC download → GenomeAI ingestion.
+- UCSC guidance: ~1 request/sec normal use; stricter limits for some programmatic uses.
+
+---
+
+## 5. Protein data
+
+```text
+Gene -> Transcript -> Protein -> UniProt (sequence, annotation, domains, identifiers)
+ |-- RCSB PDB (experimental structure)
+ +-- AlphaFold DB (predicted structure)
+```
+
+- RCSB APIs: Data, Search, ModelServer, VolumeServer, Sequence Coordinates, Alignment.
+- PDB archive + API data are under CC0 dedication (favorable licensing).
+
+---
+
+## 6. Variant data
+
+```text
+Variant
+|
++-- NCBI dbSNP
++-- ClinVar
++-- gnomAD
++-- GWAS Catalog
++-- ClinGen
++-- Open Targets
++-- cBioPortal / cancer sources
+```
+
+Enables the GenomeAI variant story: Genome position → Gene → Transcript → Protein consequence → Population frequency → Clinical significance → Disease associations → Research evidence.
+
+---
+
+## 7. Expression
+
+```text
+Gene -> GTEx (tissue expression, eQTL, sQTL)
+ -> ENCODE (regulatory regions, TF binding, chromatin, epigenomics)
+```
+
+GTEx exposes a documented v2 OpenAPI service with eQTL/sQTL and related endpoints.
+
+---
+
+## 8. Disease / phenotype
+
+```text
+Disease
+|
++-- Disease Ontology
++-- HPO
++-- Monarch
++-- Open Targets
++-- ClinGen
++-- Orphanet
+```
+
+- Disease Ontology: public OpenAPI 3.1 REST service.
+- Monarch: knowledge graph with FastAPI interface (entities, associations, semantic similarity).
+
+---
+
+## 9. Pathways / networks
+
+```text
+Gene / Protein -> Reactome, STRING, BioGRID, IntAct, Gene Ontology
+```
+
+Feeds Phase 6 network visualization and Phase 9 analysis. Reactome has a REST Content Service. STRING has an HTTP API for mapping, networks, enrichment; bulk downloads recommended for complete datasets.
+
+---
+
+## 10. Literature
+
+```text
+LiteratureConnector -> PubMed, PubMed Central, Europe PMC, Crossref, OpenAlex
+
+paper -> metadata -> full text -> chunking -> embeddings -> vector DB -> RAG -> Research Agent
+```
+
+Belongs primarily to Phase 8, not Phase 6.
+
+---
+
+## 11. Drug / chemical layer
+
+```text
+Compound -> PubChem, ChEMBL, BindingDB, DrugCentral, PharmGKB
+
+Drug -> Target -> Gene -> Variant -> Disease
+```
+
+Future "Drug Agent" foundation.
+
+---
+
+## 12. What gets stored in PostgreSQL
+
+Do **not** dump entire external databases into PostgreSQL blindly.
+
+Canonical GenomeAI entities: Genome, Assembly, Chromosome, Gene, Transcript, Protein, Variant, Sample, Experiment, Dataset, Study, Project, Disease, Phenotype, Drug, Publication.
+
+Plus **ExternalIdentifier** — identifier federation:
+
+```text
+GenomeAI Gene
+|
++-- internal_id
++-- symbol = TP53
++-- chromosome = 17
++-- start
++-- end
+|
++-- ncbi_gene_id
++-- ensembl_gene_id
++-- hgnc_id
++-- gencode_id
+```
+
+---
+
+## 13. Provenance
+
+Every imported record must be traceable.
+
+```text
+Gene
+|
++-- source = Ensembl
++-- source_id = ENSG...
++-- source_version
++-- retrieved_at
++-- release
++-- checksum
++-- source_url
+```
+
+Required for scientific reproducibility.
+
+---
+
+## 14. Database architecture
+
+```text
+ GenomeAI
+ |
+ +------------+------------+
+ | | |
+ PostgreSQL Object Store Search
+ | | |
+ entities FASTA/BAM PostgreSQL FTS
+ metadata VCF/GFF OpenSearch
+ relations datasets later
+ |
+ Analytics
+```
+
+- **PostgreSQL:** entities, relationships, metadata, users, projects, experiments, searchable structured records.
+- **Object storage:** FASTA, FASTQ, BAM, CRAM, VCF, GFF/GTF, PDB, large datasets, model artifacts.
+- **Search:** gene symbols, variant IDs, protein names, publications, full-text, faceted search, autocomplete.
+- **Vector DB:** later (Phase 8: Qdrant / pgvector / Milvus).
+
+---
+
+## 15. Data ingestion pipeline
+
+```text
+Connector -> Fetcher -> Raw Artifact -> Parser -> Validator -> Normalizer
+-> Identifier Mapper -> Deduplicator -> Database Writer -> Search Indexer
+```
+
+Example (Ensembl): download gene annotation → parse GTF/JSON → validate coordinates → map identifiers → Gene/Transcript/Exon → PostgreSQL → search index.
+
+---
+
+## 16. Accuracy / validation
+
+Three distinct layers — do not confuse them:
+
+1. **Software correctness** (every phase): pytest, ruff, pyright, Biome, Turbo, build.
+2. **Data correctness** (when ingestion starts): schema validation, coordinate validation, identifier validation, FK validation, duplicate detection, record counts, checksum validation, source release validation.
+3. **Scientific correctness** (Phase 9+): known reference datasets, gold-standard results, published benchmarks, cross-source agreement, expected biological relationships.
+
+"All tests passing" does not equal "GenomeAI's biological data is scientifically accurate."
+
+---
+
+## 17. When do we actually connect APIs (roadmap)
+
+- **Phase 4 — Biological Domains** (near-term): NCBI, Ensembl, GENCODE, HGNC. Establish canonical biological identifiers and relationships.
+- **Phase 5 — Search** (largely complete): full text, domain APIs, advanced query, suggestions, coordinates, DSL, backend abstraction. Next: external-source-aware search — but do not turn Phase 5 into a giant ingestion project.
+- **Phase 6 — Visualization** (CURRENT):
+ - 6.1 Foundation · 6.2 Genome Browser · 6.3 Gene/Transcript Viewer — done.
+ - **6.4 Variant Viewer** — frontend done (Phase 6.4 variant track, merged to
+ `main`); live external-source integration is deferred until Phase 7
+ ingestion, so the track currently reads the GenomeAI API — never direct
+ browser → external API.
+ - 6.5 Protein Viewer → UniProt, RCSB PDB, AlphaFold DB.
+ - 6.6 Biological Network Viewer → STRING, Reactome, Gene Ontology, BioGRID.
+ - 6.7 Expression Visualization → GTEx, ENCODE.
+ - 6.8 Publication/Evidence Visualization → PubMed, Europe PMC, OpenAlex.
+ - 6.9 Visualization integration + quality.
+- **Phase 7 — Workflow Engine**: serious ingestion begins. Connector, scheduler, queue, worker, retry, cache, checkpoint, dataset version. Example: nightly Ensembl sync → worker → download → validate → normalize → PostgreSQL → search index.
+- **Phase 8 — AI Platform**: AI Gateway + Provider Interface (OpenAI, Anthropic, Gemini, OpenRouter, Groq, Together, NVIDIA, Ollama, vLLM, LM Studio); Model/Embedding/Prompt/Tool/Agent registries. Hugging Face Hub is an ecosystem/registry layer, not hard-wired models.
+- **Phase 9 — Scientific Analysis**: sequence alignment, variant calling, genome annotation, protein prediction, structure prediction, variant effect prediction, population genetics, statistical analysis. C++ only where a real performance bottleneck demands it (Python → benchmark → Rust/C++ → Python binding).
+- **Phase 10 — Plugin ecosystem**: external integrations become plugins (NCBI, Ensembl, UniProt, ClinVar, PDB, Reactome, STRING, PubMed, NVIDIA, custom institutional sources).
+
+---
+
+## 18. API classification
+
+Every external source is classified as one of:
+
+- **A. Live query** — user request → GenomeAI → external API → response. For small metadata, current information, interactive lookup.
+- **B. Cached API** — external API only on cache miss. For repeated lookups, expensive APIs, rate-limited sources.
+- **C. Ingested dataset** — scheduled ingestion into GenomeAI DB. For genes, variants, annotations, relationships, large metadata.
+- **D. Bulk dataset** — external download → object storage → processing → derived database/index. For FASTA, FASTQ, BAM/CRAM, VCF, large expression datasets, complete PDB/other archives.
+
+---
+
+## 19. Priority order
+
+Locked integration sequence (strategic dependency order, not "connect all at once"):
+
+1. NCBI
+2. Ensembl
+3. GENCODE
+4. HGNC
+5. UCSC
+6. ClinVar
+7. dbSNP
+8. gnomAD
+9. UniProt
+10. RCSB PDB
+11. AlphaFold DB
+12. GTEx
+13. ENCODE
+14. HPO
+15. Disease Ontology
+16. Open Targets
+17. Reactome
+18. STRING
+19. PubMed
+20. Europe PMC
+21. PubChem
+22. ChEMBL
+23. Monarch
+24. GWAS Catalog
+25. cBioPortal
+26. BioGRID
+27. IntAct
+28. Human Protein Atlas
+29. specialized databases
+
+---
+
+## 20. DataSource registry
+
+Every external source gets a record:
+
+```text
+DataSource
++-- name
++-- provider
++-- type
++-- API URL
++-- documentation URL
++-- authentication
++-- rate limit
++-- license
++-- access_mode (live / cached / ingested / bulk)
++-- current release
++-- last synchronization
++-- sync status
++-- enabled
+```
+
+Pipeline: `DataSource → Connector → IngestionJob → RawData → NormalizedEntity → ExternalIdentifier → GenomeAI Entity`.
+
+---
+
+## 21. What we build now
+
+Phase 4 (Biological Domains) near-complete · Phase 5 (Search & Query) done · Phase 6 (Visualization) CURRENT.
+
+Do **not** jump to Phase 7 yet. Sequence:
+
+```text
+Phase 6.4 Variant Viewer (frontend done; external data in Phase 7)
+ |
+Phase 6.5 Protein Viewer
+ |
+Phase 6.6 Biological Network Viewer
+ |
+Phase 6.7 Expression Viewer
+ |
+Phase 6.8 Literature / Evidence Viewer
+ |
+Phase 6.9 Visualization integration + quality
+ |
+Phase 7 Workflow / ingestion infrastructure
+```
+
+Finish the visualization layer first, designed against GenomeAI's own API contracts. Phase 7 is where external databases, ingestion, synchronization, provenance, and accuracy validation are built properly.
diff --git a/docs/external-data/README.md b/docs/external-data/README.md
new file mode 100644
index 0000000..78ec5f4
--- /dev/null
+++ b/docs/external-data/README.md
@@ -0,0 +1,15 @@
+# External Data & API
+
+This area governs how GenomeAI integrates external scientific sources. It is the controlling reference for everything built in Phases 4–9.
+
+## Rule
+
+> GenomeAI must never make the frontend depend directly on 20–30 external APIs.
+
+```text
+External sources → connectors/ingestion → normalized GenomeAI data → GenomeAI API → Web/AI
+```
+
+## Contents
+
+- [MASTER_PLAN.md](./MASTER_PLAN.md) — the official GenomeAI External Data & API Master Plan: architecture, tiers, connectors, storage, ingestion, accuracy, and phase integration order.
diff --git a/docs/visualization/README.md b/docs/visualization/README.md
index 4e15dac..1539a1a 100644
--- a/docs/visualization/README.md
+++ b/docs/visualization/README.md
@@ -4,16 +4,16 @@ This directory documents the GenomeAI visualization platform (Phase 6).
## Status
-**Phase 6.3 — Gene / Transcript Visualization** is the current milestone. It
-adds isoform structure (gene lanes, transcript lanes, exon blocks) on top of
-the Phase 6.2 Genome Browser foundation.
+**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.
| Milestone | Description | Status |
|-----------|-------------|--------|
| 6.1 | Visualization Foundation | ✅ Implemented |
| 6.2 | Genome Browser | ✅ Implemented |
| 6.3 | Gene / Transcript Visualization | ✅ Implemented |
-| 6.4 | Variant Visualization | 📋 Planned |
+| 6.4 | Variant Visualization | ✅ Implemented |
| 6.5 | Protein Structure Viewer | 📋 Planned |
| 6.6 | Biological Network Visualization | 📋 Planned |
| 6.7 | Scientific Charts | 📋 Planned |
@@ -51,6 +51,20 @@ the Phase 6.2 Genome Browser foundation.
demo uses a dev-only fixture while production callers return no exons.
- Demo integrated at `/visualization`.
+## What Phase 6.4 Provides
+
+- Variant visualization (see [Variant](./variant.md)): a typed domain model,
+ pure point geometry, and a thin adapter over the Phase 5 coordinate-search
+ API — no backend changes.
+- A reusable `VariantTrack` component that the Genome Browser renders for
+ `kind: 'variants'` tracks: coordinate-accurate point marks stacked onto
+ rows when they would overlap, hover titles, and keyboard-accessible variant
+ selection with a readable detail panel.
+- Rich variant detail surfaced per record when the API reports it: position,
+ `ref>alt`, variant `type`, quality, filter status, accession, gene, and
+ description — never inferred from arbitrary strings.
+- Demo integrated at `/visualization`.
+
## Documents
| Document | Description |
@@ -58,6 +72,7 @@ the Phase 6.2 Genome Browser foundation.
| [Architecture](architecture.md) | Component structure, data flow, and how future modules integrate |
| [Genome Browser](genome-browser.md) | Phase 6.2 Genome Browser: scope, data flow, API, a11y, tests |
| [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 |
| [Roadmap](roadmap.md) | Detailed phase tracking and future work |
## Technology Notes
diff --git a/docs/visualization/roadmap.md b/docs/visualization/roadmap.md
index f4cf8cd..156b741 100644
--- a/docs/visualization/roadmap.md
+++ b/docs/visualization/roadmap.md
@@ -4,7 +4,40 @@ Tracks the Phase 6 visualization platform milestones. See
[Phase 6 of the project ROADMAP]() for
the authoritative milestone list.
-## Current Milestone: 6.3 — Gene / Transcript Visualization ✅
+## Current Milestone: 6.4 — Variant Visualization ✅
+
+Implemented on branch `feat/visualization-variant`, on top of 6.3.
+
+Delivered:
+
+- Typed domain model (`lib/genome/variant.ts`) — `Variant` (reusing the
+ existing `VariantFeature` type), normalization from raw search items,
+ validation, and display helpers (label, accessible label, detail lines)
+- Pure point geometry (`lib/genome/variantGeometry.ts`) — in-viewport filtering
+ for single positions, pixel mapping via the shared scale, deterministic row
+ stacking for dense/identical marks, lane height
+- Thin typed adapter (`lib/genome/variantApi.ts`) over the Phase 5
+ coordinate-search API (no backend changes)
+- `VariantTrack` SVG component — coordinate-accurate point marks, hover titles,
+ keyboard-accessible selection (`role="button"`, Enter/Space, `aria-pressed`),
+ and a readable detail panel
+- The Genome Browser now routes `kind: 'variants'` tracks through the reusable
+ `VariantTrack` (no inline variant branch)
+- Enriched `VariantFeature` / `toVariantFeature` with `variantId`,
+ `variantType`, `quality`, `filterStatus`, `geneId`, `description`
+- Demo integrated at `/visualization` (variants track uses `fetchVariants`)
+- Tests and docs (see [Variant](variant.md))
+
+Constraints honored:
+
+- Variant `type` is carried as opaque text and never inferred from arbitrary
+ strings or `ref`/`alt`; only API-reported fields are displayed
+- No C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js
+- Phase 5 search untouched; no new runtime dependencies
+
+## Previous milestones
+
+### 6.3 — Gene / Transcript Visualization ✅
Implemented on branch `feat/visualization-gene-transcript`, on top of 6.2.
@@ -35,8 +68,6 @@ Constraints honored:
- No C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js
- Phase 5 search untouched; no new runtime dependencies
-## Previous milestones
-
### 6.2 — Genome Browser ✅
Implemented on branch `feat/visualization-genome-browser`, on top of 6.1.
@@ -89,10 +120,9 @@ Constraints honored:
| # | Milestone | Notes |
|---|-----------|-------|
-| 6.4 | Variant Visualization | Variant tables + dense tracks; D3-based |
| 6.5 | Protein Structure Viewer | 3D; Three.js only if 3D is truly required |
| 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.4–6.7 into a UI |
-| 6.9 | Visualization Performance & Optimization | Virtualization for large data |
+| 6.9 | Visualization Performance & Optimization | Virtualization / density rendering for large data |
| 6.10 | Visualization Testing & Documentation | Stabilization + docs pass |
\ No newline at end of file
diff --git a/docs/visualization/variant.md b/docs/visualization/variant.md
new file mode 100644
index 0000000..2bdb728
--- /dev/null
+++ b/docs/visualization/variant.md
@@ -0,0 +1,180 @@
+# Variant Visualization (Phase 6.4)
+
+Renders variants as coordinate-accurate point marks in a reusable track lane,
+integrated with the [Phase 6.2 Genome Browser](genome-browser.md) track
+architecture and reusing its one-based-inclusive coordinate and scale
+conventions. Selecting a variant reveals a readable detail panel.
+
+## Status
+
+Implemented on branch `feat/visualization-variant`.
+
+> **Known limitation — live data blocked by the backend (pre-existing, not
+> from this phase).** The Phase 5 coordinate-search endpoints return raw
+> SQLAlchemy ORM objects in `items: list[Any]`, so any search that returns
+> rows fails JSON serialization with a `PydanticSerializationError` (HTTP 500),
+> and `/search/suggestions` fails on a `SELECT DISTINCT ... ORDER BY` error.
+> The demo therefore calls the **real** `/search/variant/coordinate` endpoint
+> (no fixtures or mocked responses) and renders the track's error state until
+> the backend is fixed; the fixture-based gene/transcript viewer is unaffected.
+> A backend fix is tracked as a separate issue in the repository (search
+> endpoints must serialize ORM rows to `RawSearchItem` records before
+> responding).
+
+## Scope (delivered)
+
+- Typed domain model (`lib/genome/variant.ts`): `Variant` (reuses the existing
+ `VariantFeature` type), normalizer `toVariant`, `isValidVariant` validator,
+ display helpers (`variantLabel`, `variantAccessibleLabel`,
+ `variantDetailLines`)
+- Pure point geometry (`lib/genome/variantGeometry.ts`): in-viewport filtering
+ for single positions, pixel mapping via the shared scale, deterministic row
+ stacking so dense or identical marks never fully overlap, lane height
+- Thin typed data adapter (`lib/genome/variantApi.ts`) over the Phase 5
+ coordinate-search API (`fetchVariants`) — no backend changes
+- `VariantTrack` component: coordinate-accurate point marks, hover ``
+ tips, keyboard-accessible variant selection (`role="button"`,
+ Enter/Space, `aria-pressed`), and a detail panel
+- The Genome Browser now renders any `kind: 'variants'` track through the
+ reusable `VariantTrack` instead of an inline branch
+- Demo integrated at `/visualization` (`GenomeBrowserDemo` variants track)
+- Tests and docs
+
+## Out of scope (later milestones or explicitly excluded)
+
+- D3.js / Three.js / Cytoscape.js / WebAssembly / WebGPU / C++ (see
+ [README](README.md) technology notes)
+- Dense variant-call / histogram density rendering at population scale
+ (deferred to 6.9 performance work)
+- Protein / network / chart views (6.5–6.7)
+
+## Architecture and data flow
+
+```text
+GenomeBrowser (component) apps/web/src/components/genome/
+ BrowserTrack dispatches by kind
+ kind === 'variants' -> VariantTrack apps/web/src/components/genome/VariantTrack.tsx
+ useGenomeTrack (data lifecycle) + reuse lib/genome/useGenomeBrowser.ts
+ scale = createScale(viewport) + reuse lib/genome/geometry.ts
+ variantsInViewport / layoutVariantMarks + lib/genome/variantGeometry.ts
+ domain model / labels + lib/genome/variant.ts
+ fetchVariants + lib/genome/variantApi.ts
+ -> POST /search/variant/coordinate + reuse lib/genome/api.ts (Phase 5)
+```
+
+The viewport is the same one-based-inclusive `GenomeViewport` used by the
+browser. `VariantTrack` follows the same track lifecycle as the built-in
+lanes (`useGenomeTrack` + `useVisualizationData`), so loading, empty, error,
+and retry states are handled consistently by `VisualizationContainer`.
+
+## Point coordinate semantics
+
+The GenomeAI variant model is a **single-position** record:
+
+- One `position` (1-based, `>= 1`) — there is no `end_position`.
+- No strand.
+- A variant is "in viewport" when it is on the same chromosome and
+ `viewport.start <= position <= viewport.end` (one-based inclusive), matching
+ the overlap semantics of the coordinate-search API.
+
+The frontend never fabricates `end` or strand values; `start === end ===
+position` in the feature record.
+
+## API contract used
+
+```text
+POST /search/variant/coordinate
+```
+
+Request body (one-based-inclusive, same shape as the browser):
+
+```json
+{
+ "interval": { "chromosome": "chr17", "start": 7650000, "end": 7700000 },
+ "match_type": "overlap",
+ "pagination": { "page": 1, "page_size": 100 }
+}
+```
+
+The variant domain maps both the `start` and `end` search columns to the
+`position` column, so an overlap query returns every variant whose single
+position falls inside the interval.
+
+Records are normalized into the typed model: `variant_id` → `variantId`
+(also used as the fallback `id`), `type` → `variantType`, `quality` (number),
+`filter_status` → `filterStatus`, `gene_id` → `geneId`, `description`,
+`ref`/`alt`, and `position` (1-based).
+
+### Type representation
+
+`type` is a free-form, nullable string on the backend (e.g. `snv`) with no
+enumerated vocabulary. The frontend therefore:
+
+- Carries `variantType` as opaque text and renders it verbatim in labels,
+ tooltips, and the detail panel.
+- Never infers a variant class from arbitrary strings or from `ref`/`alt`
+ lengths. `ref>alt` is used only as a display label, not a classification.
+
+Only fields the API actually reports appear in the detail panel.
+
+## Mark layout
+
+Marks are positioned with the shared scale (`createScale`), so they align with
+the browser axis. `layoutVariantMarks` then stacks marks greedily onto rows:
+variants are sorted by position (then id), and each mark is placed on the
+first row whose rightmost mark lies at least `VARIANT_MIN_SEPARATION` (5 px)
+to the left. Identical or adjacent positions therefore remain individually
+visible. The lane height grows by `VARIANT_ROW_HEIGHT` per row so stacked
+marks never clip.
+
+## Accessibility
+
+- The SVG is a labelled group (`role="group"`, `aria-label` summarizing the
+ track label and variant count) so the interactive variant controls stay in
+ the accessibility tree.
+- Each variant mark is a keyboard-focusable control (`role="button"`,
+ `tabIndex=0`) with an accessible name (`Select