From 3597019a7da5a8c6078632e79456d8be1c095450 Mon Sep 17 00:00:00 2001 From: dsk-dev-ai Date: Mon, 10 Aug 2026 13:25:42 +0530 Subject: [PATCH 1/4] feat(visualization): add variant track (Phase 6.4) --- .../app/visualization/GenomeBrowserDemo.tsx | 5 +- apps/web/src/app/visualization/page.tsx | 8 +- .../src/components/genome/GenomeBrowser.tsx | 42 ++--- .../components/genome/VariantTrack.test.tsx | 136 ++++++++++++++ .../src/components/genome/VariantTrack.tsx | 156 ++++++++++++++++ apps/web/src/lib/genome/api.test.ts | 38 ++++ apps/web/src/lib/genome/api.ts | 8 +- apps/web/src/lib/genome/types.ts | 12 ++ apps/web/src/lib/genome/variant.test.ts | 140 +++++++++++++++ apps/web/src/lib/genome/variant.ts | 136 ++++++++++++++ apps/web/src/lib/genome/variantApi.test.ts | 86 +++++++++ apps/web/src/lib/genome/variantApi.ts | 45 +++++ .../src/lib/genome/variantGeometry.test.ts | 144 +++++++++++++++ apps/web/src/lib/genome/variantGeometry.ts | 112 ++++++++++++ docs/visualization/README.md | 23 ++- docs/visualization/roadmap.md | 40 ++++- docs/visualization/variant.md | 168 ++++++++++++++++++ 17 files changed, 1255 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/genome/VariantTrack.test.tsx create mode 100644 apps/web/src/components/genome/VariantTrack.tsx create mode 100644 apps/web/src/lib/genome/variant.test.ts create mode 100644 apps/web/src/lib/genome/variant.ts create mode 100644 apps/web/src/lib/genome/variantApi.test.ts create mode 100644 apps/web/src/lib/genome/variantApi.ts create mode 100644 apps/web/src/lib/genome/variantGeometry.test.ts create mode 100644 apps/web/src/lib/genome/variantGeometry.ts create mode 100644 docs/visualization/variant.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..e3f23ac 100644 --- a/apps/web/src/components/genome/GenomeBrowser.tsx +++ b/apps/web/src/components/genome/GenomeBrowser.tsx @@ -12,7 +12,6 @@ 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, @@ -24,6 +23,8 @@ import { } 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 +67,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) => { @@ -235,7 +215,9 @@ 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. + * satisfied as the track set grows, shrinks, or reorders. Span tracks + * (genes/transcripts) render arrow glyphs; point variants render through the + * reusable `VariantTrack` component. */ function BrowserTrack({ track, @@ -244,6 +226,10 @@ function BrowserTrack({ track: GenomeTrackDefinition debouncedViewport: GenomeViewport }) { + if (track.kind === 'variants') { + return + } + const data = useGenomeTrack(track, debouncedViewport) const viewport = debouncedViewport const features = featuresInViewport(data.data ?? [], viewport) @@ -259,7 +245,7 @@ function BrowserTrack({ > 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..ebdd356 --- /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 { GenomeTrackDefinition } from '@/lib/genome/useGenomeBrowser' +import { VariantTrack } from './VariantTrack' + +const variantsLoader = vi.fn() + +function trackDefinition(loader: typeof variantsLoader): GenomeTrackDefinition { + 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..7f4059c --- /dev/null +++ b/apps/web/src/components/genome/VariantTrack.tsx @@ -0,0 +1,156 @@ +'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 GenomeTrackDefinition, 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. + */ +export function VariantTrack({ + track, + debouncedViewport, +}: { + track: GenomeTrackDefinition + debouncedViewport: GenomeViewport +}) { + const data = useGenomeTrack(track, debouncedViewport) + const viewport = debouncedViewport + const scale = createScale(viewport.start, viewport.end, SVG_WIDTH - TRACK_HEADER_WIDTH) + // The loader for a variant track returns VariantFeature records; the track + // hook types them as GenomicFeature, so narrow at the adapter boundary. + const variants = variantsInViewport((data.data ?? []) as VariantFeature[], viewport) + const marks = layoutVariantMarks(scale, variants) + + const [selectedId, setSelectedId] = useState<string | null>(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 ( + <VisualizationContainer + title={data.label} + status={data.status} + error={data.errorMessage ? { message: data.errorMessage } : undefined} + loadingLabel={`Loading ${data.label.toLowerCase()}...`} + emptyMessage={`No ${data.label.toLowerCase()} found in region.`} + onRetry={data.refetch} + > + {data.status === 'success' ? ( + <> + <svg + viewBox={`0 0 ${SVG_WIDTH} ${height}`} + className="w-full" + // biome-ignore lint/a11y/useSemanticElements: role="group" on an SVG would + // otherwise be flagged as a <fieldset> candidate; group is correct here so + // the interactive variant controls below stay in the a11y tree. + role="group" + aria-label={title} + data-testid="variant-track" + > + {marks.map((mark) => { + const isSelected = mark.variant.id === selectedId + const x = TRACK_HEADER_WIDTH + mark.x + const y = variantRowY(mark.row) + return ( + <g key={mark.variant.id} data-testid={`variant-mark-${mark.variant.id}`}> + <title>{variantAccessibleLabel(mark.variant)} + + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleSelect(mark.variant) + } + }} + onClick={() => handleSelect(mark.variant)} + /> + + ) + })} + + {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..f2de7a0 100644 --- a/apps/web/src/lib/genome/api.ts +++ b/apps/web/src/lib/genome/api.ts @@ -135,6 +135,7 @@ 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), type: 'variant', @@ -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/variant.test.ts b/apps/web/src/lib/genome/variant.test.ts new file mode 100644 index 0000000..c129f37 --- /dev/null +++ b/apps/web/src/lib/genome/variant.test.ts @@ -0,0 +1,140 @@ +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) + }) +}) + +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..185aa14 --- /dev/null +++ b/apps/web/src/lib/genome/variant.ts @@ -0,0 +1,136 @@ +/** + * 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 type { RawSearchItem } from './api' +import type { VariantFeature } from './types' + +/** The typed variant record used by the variant visualization. */ +export type Variant = VariantFeature + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined +} + +function idOf(value: unknown): string { + if (typeof value === 'string' && value.length > 0) return value + if (typeof value === 'object' && value !== null && 'id' in value) { + return idOf((value as { id: unknown }).id) + } + return '' +} + +/** + * Normalizes a raw variant record from the coordinate-search API. + * + * Mirrors `toVariantFeature` in `lib/genome/api.ts` so the model module is + * self-contained; the shared request pipeline remains in `api.ts`. + */ +export function toVariant(item: RawSearchItem): Variant { + const position = asNumber(item.position) + const chromosome = asString(item.chromosome) + if (chromosome === undefined || position === undefined || position < 1) { + return { id: '', type: 'variant', chromosome: '', start: 0, end: 0, position: 0 } + } + const ref = asString(item.ref) + const alt = asString(item.alt) + const quality = asNumber(item.quality) + return { + id: idOf(item.id) || asString(item.variant_id) || '', + type: 'variant', + chromosome, + start: position, + end: position, + position, + ref, + alt, + name: ref && alt ? `${ref}>${alt}` : undefined, + 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), + } +} + +/** True when `variant` carries a usable 1-based point coordinate. */ +export function isValidVariant(variant: Variant): boolean { + return ( + 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..94f77da --- /dev/null +++ b/apps/web/src/lib/genome/variantApi.test.ts @@ -0,0 +1,86 @@ +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') + }) +}) 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/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..077fad7 --- /dev/null +++ b/docs/visualization/variant.md @@ -0,0 +1,168 @@ +# 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`. + +## 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 <label>, <chrom>:<pos>[, + <type>][, filter <status>]`); Enter or Space toggles selection, and the + selected mark is announced via `aria-pressed` and visually highlighted. +- Hover reveals a native `<title>` tooltip with the same readable detail. +- The detail panel is a labelled `<section>` with a `<dl>` of typed fields. + +## Tests + +`apps/web` root test run (`pnpm --filter @genomeai/web test`) covers: + +| Layer | File(s) | Focus | +|-------|---------|-------| +| model | `variant.test.ts` | normalization, id fallback, validators, labels, detail lines | +| geometry | `variantGeometry.test.ts` | in-viewport filtering, pixel mapping, row stacking, separation, height | +| api | `variantApi.test.ts` | endpoint/body/interval, optional fields, invalid-item filtering | +| component | `VariantTrack.test.tsx` | rendering, viewport clipping, stacking, selection (click + keyboard), detail panel, error/retry | + +The Genome Browser suite (`GenomeBrowser.test.tsx`) continues to pass with the +variants lane now delegated to `VariantTrack`. + +## Files + +- `apps/web/src/lib/genome/variant.ts` +- `apps/web/src/lib/genome/variantGeometry.ts` +- `apps/web/src/lib/genome/variantApi.ts` +- `apps/web/src/components/genome/VariantTrack.tsx` +- `apps/web/src/lib/genome/types.ts` (enriched `VariantFeature` with + `variantId`, `variantType`, `quality`, `filterStatus`, `geneId`, + `description`) +- `apps/web/src/lib/genome/api.ts` (enriched `toVariantFeature` mapping) +- `apps/web/src/components/genome/GenomeBrowser.tsx` (delegates variants kind + to `VariantTrack`) +- `apps/web/src/app/visualization/GenomeBrowserDemo.tsx` (uses `fetchVariants`) +- `apps/web/src/app/visualization/page.tsx` (already 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 +``` From 13287afeb21776ec8f0de6e544f177cd9b02e847 Mon Sep 17 00:00:00 2001 From: dsk-dev-ai <darshan.kachare.dev@gmail.com> Date: Mon, 10 Aug 2026 14:08:22 +0530 Subject: [PATCH 2/4] docs(external-data): add official External Data & API Master Plan --- README.md | 1 + docs/external-data/MASTER_PLAN.md | 458 ++++++++++++++++++++++++++++++ docs/external-data/README.md | 15 + 3 files changed, 474 insertions(+) create mode 100644 docs/external-data/MASTER_PLAN.md create mode 100644 docs/external-data/README.md 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/docs/external-data/MASTER_PLAN.md b/docs/external-data/MASTER_PLAN.md new file mode 100644 index 0000000..5d6ced7 --- /dev/null +++ b/docs/external-data/MASTER_PLAN.md @@ -0,0 +1,458 @@ +# 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 + +``` + 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. + +``` +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. + +``` +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 + +``` +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 + +``` +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 + +``` +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 + +``` +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 + +``` +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 + +``` +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 + +``` +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: + +``` +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. + +``` +Gene +| ++-- source = Ensembl ++-- source_id = ENSG... ++-- source_version ++-- retrieved_at ++-- release ++-- checksum ++-- source_url +``` + +Required for scientific reproducibility. + +--- + +## 14. Database architecture + +``` + 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 + +``` +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 (next)** → use the GenomeAI API backed by ClinVar / gnomAD / dbSNP / Ensembl, 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: + +``` +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: + +``` +Phase 6.4 Variant Viewer + | +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..baf9a3d --- /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. + +``` +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. From 4ddd0c0cfa74ddedea2d65ec8382dd0f628f4e9d Mon Sep 17 00:00:00 2001 From: dsk-dev-ai <darshan.kachare.dev@gmail.com> Date: Thu, 13 Aug 2026 08:12:39 +0530 Subject: [PATCH 3/4] fix(visualization): address variant track review findings (hooks, types, identity) --- .../src/components/genome/GenomeBrowser.tsx | 48 ++++++++++----- .../components/genome/VariantTrack.test.tsx | 4 +- .../src/components/genome/VariantTrack.tsx | 12 ++-- apps/web/src/lib/genome/api.ts | 2 +- apps/web/src/lib/genome/useGenomeBrowser.ts | 52 ++++++++++++----- apps/web/src/lib/genome/variant.test.ts | 4 ++ apps/web/src/lib/genome/variant.ts | 58 +++++-------------- apps/web/src/lib/genome/variantApi.test.ts | 16 +++++ 8 files changed, 114 insertions(+), 82 deletions(-) diff --git a/apps/web/src/components/genome/GenomeBrowser.tsx b/apps/web/src/components/genome/GenomeBrowser.tsx index e3f23ac..91189a7 100644 --- a/apps/web/src/components/genome/GenomeBrowser.tsx +++ b/apps/web/src/components/genome/GenomeBrowser.tsx @@ -18,6 +18,7 @@ import { type GenomeBrowserResult, type GenomeTrackData, type GenomeTrackDefinition, + type SpanTrackDefinition, useGenomeBrowser, useGenomeTrack, } from '@/lib/genome/useGenomeBrowser' @@ -212,26 +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. Span tracks - * (genes/transcripts) render arrow glyphs; point variants render through the - * reusable `VariantTrack` component. + * 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 }) { - if (track.kind === 'variants') { - return <VariantTrack track={track} debouncedViewport={debouncedViewport} /> - } - - const data = useGenomeTrack(track, debouncedViewport) - const viewport = debouncedViewport + const data = useGenomeTrack(track, viewport) const features = featuresInViewport(data.data ?? [], viewport) return ( @@ -252,6 +244,30 @@ 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 <VariantTrack track={track} debouncedViewport={debouncedViewport} /> + } + + return <SpanTrackLane track={track} viewport={debouncedViewport} /> +} + 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 index ebdd356..f435dee 100644 --- a/apps/web/src/components/genome/VariantTrack.test.tsx +++ b/apps/web/src/components/genome/VariantTrack.test.tsx @@ -2,12 +2,12 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import type { VariantFeature } from '@/lib/genome/types' -import type { GenomeTrackDefinition } from '@/lib/genome/useGenomeBrowser' +import type { VariantTrackDefinition } from '@/lib/genome/useGenomeBrowser' import { VariantTrack } from './VariantTrack' const variantsLoader = vi.fn() -function trackDefinition(loader: typeof variantsLoader): GenomeTrackDefinition { +function trackDefinition(loader: typeof variantsLoader): VariantTrackDefinition { return { id: 'variants', label: 'Variants', kind: 'variants', loader } } diff --git a/apps/web/src/components/genome/VariantTrack.tsx b/apps/web/src/components/genome/VariantTrack.tsx index 7f4059c..62d143d 100644 --- a/apps/web/src/components/genome/VariantTrack.tsx +++ b/apps/web/src/components/genome/VariantTrack.tsx @@ -5,7 +5,7 @@ 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 GenomeTrackDefinition, useGenomeTrack } from '@/lib/genome/useGenomeBrowser' +import { type VariantTrackDefinition, useGenomeTrack } from '@/lib/genome/useGenomeBrowser' import { variantAccessibleLabel, variantDetailLines, variantLabel } from '@/lib/genome/variant' import { VARIANT_MARK_HALF_HEIGHT, @@ -29,20 +29,22 @@ const SELECTED_MARK_COLOR = '#db2777' * Dense regions are stacked onto rows when marks would overlap. Each mark * carries a hover `<title>` 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: GenomeTrackDefinition + track: VariantTrackDefinition debouncedViewport: GenomeViewport }) { const data = useGenomeTrack(track, debouncedViewport) const viewport = debouncedViewport const scale = createScale(viewport.start, viewport.end, SVG_WIDTH - TRACK_HEADER_WIDTH) - // The loader for a variant track returns VariantFeature records; the track - // hook types them as GenomicFeature, so narrow at the adapter boundary. - const variants = variantsInViewport((data.data ?? []) as VariantFeature[], viewport) + const variants = variantsInViewport(data.data ?? [], viewport) const marks = layoutVariantMarks(scale, variants) const [selectedId, setSelectedId] = useState<string | null>(null) diff --git a/apps/web/src/lib/genome/api.ts b/apps/web/src/lib/genome/api.ts index f2de7a0..d79bb31 100644 --- a/apps/web/src/lib/genome/api.ts +++ b/apps/web/src/lib/genome/api.ts @@ -137,7 +137,7 @@ export function toVariantFeature(item: RawSearchItem): VariantFeature { 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, 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<T extends GenomicFeature = GenomicFeature> = ( interval: GenomicInterval, signal: AbortSignal, -) => Promise<GenomicFeature[]> +) => Promise<T[]> -/** 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<TrackKind, 'variants'> + loader: GenomeTrackLoader<GenomicFeature> +} + +/** Definition of a point-variant track lane. */ +export interface VariantTrackDefinition { + id: string + label: string + kind: 'variants' + loader: GenomeTrackLoader<VariantFeature> } +/** + * 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<T extends GenomicFeature> { + id: string + label: string + kind: TrackKind + loader: GenomeTrackLoader<T> 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 GenomeTrackDefinition = GenomeTrackDefinition> = + T extends VariantTrackDefinition + ? GenomeTrackDataBase<VariantFeature> & { kind: 'variants' } + : GenomeTrackDataBase<GenomicFeature> & { kind: Exclude<TrackKind, 'variants'> } + 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<T extends GenomeTrackDefinition>( + definition: T, debouncedViewport: GenomeViewport, -): GenomeTrackData { +): GenomeTrackData<T> { 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<T> } /** 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 index c129f37..100acb7 100644 --- a/apps/web/src/lib/genome/variant.test.ts +++ b/apps/web/src/lib/genome/variant.test.ts @@ -79,6 +79,10 @@ describe('isValidVariant', () => { 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', () => { diff --git a/apps/web/src/lib/genome/variant.ts b/apps/web/src/lib/genome/variant.ts index 185aa14..c1ea286 100644 --- a/apps/web/src/lib/genome/variant.ts +++ b/apps/web/src/lib/genome/variant.ts @@ -17,66 +17,34 @@ * 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 -function asString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined -} - -function asNumber(value: unknown): number | undefined { - return typeof value === 'number' ? value : undefined -} - -function idOf(value: unknown): string { - if (typeof value === 'string' && value.length > 0) return value - if (typeof value === 'object' && value !== null && 'id' in value) { - return idOf((value as { id: unknown }).id) - } - return '' -} - /** * Normalizes a raw variant record from the coordinate-search API. * - * Mirrors `toVariantFeature` in `lib/genome/api.ts` so the model module is - * self-contained; the shared request pipeline remains in `api.ts`. + * 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 { - const position = asNumber(item.position) - const chromosome = asString(item.chromosome) - if (chromosome === undefined || position === undefined || position < 1) { - return { id: '', type: 'variant', chromosome: '', start: 0, end: 0, position: 0 } - } - const ref = asString(item.ref) - const alt = asString(item.alt) - const quality = asNumber(item.quality) - return { - id: idOf(item.id) || asString(item.variant_id) || '', - type: 'variant', - chromosome, - start: position, - end: position, - position, - ref, - alt, - name: ref && alt ? `${ref}>${alt}` : undefined, - 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), - } + return toVariantFeature(item) } -/** True when `variant` carries a usable 1-based point coordinate. */ +/** + * 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.chromosome.length > 0 && Number.isSafeInteger(variant.position) && variant.position >= 1 + variant.id.length > 0 && + variant.chromosome.length > 0 && + Number.isSafeInteger(variant.position) && + variant.position >= 1 ) } diff --git a/apps/web/src/lib/genome/variantApi.test.ts b/apps/web/src/lib/genome/variantApi.test.ts index 94f77da..db80a12 100644 --- a/apps/web/src/lib/genome/variantApi.test.ts +++ b/apps/web/src/lib/genome/variantApi.test.ts @@ -83,4 +83,20 @@ describe('fetchVariants', () => { 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') + }) }) From b434dd8ac0916801d8ee6ec3f338adf055ca78ed Mon Sep 17 00:00:00 2001 From: dsk-dev-ai <darshan.kachare.dev@gmail.com> Date: Thu, 13 Aug 2026 08:16:55 +0530 Subject: [PATCH 4/4] docs: address review comments on external-data plan and variant docs --- docs/external-data/MASTER_PLAN.md | 39 +++++++++++++++++-------------- docs/external-data/README.md | 2 +- docs/visualization/variant.md | 12 ++++++++++ 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/external-data/MASTER_PLAN.md b/docs/external-data/MASTER_PLAN.md index 5d6ced7..bfbd6da 100644 --- a/docs/external-data/MASTER_PLAN.md +++ b/docs/external-data/MASTER_PLAN.md @@ -8,7 +8,7 @@ ## 0. The architecture we will build -``` +```text PUBLIC SCIENTIFIC SOURCES | +---------------------+---------------------+ @@ -85,7 +85,7 @@ Notes: One NCBI connector, not scattered clients. -``` +```text GenomeAI NCBI Connector | +-- Datasets @@ -130,7 +130,7 @@ ClinVar is reachable through E-utilities via `esearch`, `esummary`, `elink`, `ef Major gene/transcript/variant reference. -``` +```text Ensembl | +-- Gene @@ -159,7 +159,7 @@ Visualization/reference-track source. Do not hammer it. ## 5. Protein data -``` +```text Gene -> Transcript -> Protein -> UniProt (sequence, annotation, domains, identifiers) |-- RCSB PDB (experimental structure) +-- AlphaFold DB (predicted structure) @@ -172,7 +172,7 @@ Gene -> Transcript -> Protein -> UniProt (sequence, annotation, domains, identif ## 6. Variant data -``` +```text Variant | +-- NCBI dbSNP @@ -190,7 +190,7 @@ Enables the GenomeAI variant story: Genome position → Gene → Transcript → ## 7. Expression -``` +```text Gene -> GTEx (tissue expression, eQTL, sQTL) -> ENCODE (regulatory regions, TF binding, chromatin, epigenomics) ``` @@ -201,7 +201,7 @@ GTEx exposes a documented v2 OpenAPI service with eQTL/sQTL and related endpoint ## 8. Disease / phenotype -``` +```text Disease | +-- Disease Ontology @@ -219,7 +219,7 @@ Disease ## 9. Pathways / networks -``` +```text Gene / Protein -> Reactome, STRING, BioGRID, IntAct, Gene Ontology ``` @@ -229,7 +229,7 @@ Feeds Phase 6 network visualization and Phase 9 analysis. Reactome has a REST Co ## 10. Literature -``` +```text LiteratureConnector -> PubMed, PubMed Central, Europe PMC, Crossref, OpenAlex paper -> metadata -> full text -> chunking -> embeddings -> vector DB -> RAG -> Research Agent @@ -241,7 +241,7 @@ Belongs primarily to Phase 8, not Phase 6. ## 11. Drug / chemical layer -``` +```text Compound -> PubChem, ChEMBL, BindingDB, DrugCentral, PharmGKB Drug -> Target -> Gene -> Variant -> Disease @@ -259,7 +259,7 @@ Canonical GenomeAI entities: Genome, Assembly, Chromosome, Gene, Transcript, Pro Plus **ExternalIdentifier** — identifier federation: -``` +```text GenomeAI Gene | +-- internal_id @@ -280,7 +280,7 @@ GenomeAI Gene Every imported record must be traceable. -``` +```text Gene | +-- source = Ensembl @@ -298,7 +298,7 @@ Required for scientific reproducibility. ## 14. Database architecture -``` +```text GenomeAI | +------------+------------+ @@ -321,7 +321,7 @@ Required for scientific reproducibility. ## 15. Data ingestion pipeline -``` +```text Connector -> Fetcher -> Raw Artifact -> Parser -> Validator -> Normalizer -> Identifier Mapper -> Deduplicator -> Database Writer -> Search Indexer ``` @@ -348,7 +348,10 @@ Three distinct layers — do not confuse them: - **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 (next)** → use the GenomeAI API backed by ClinVar / gnomAD / dbSNP / Ensembl, never direct browser → external API. + - **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. @@ -412,7 +415,7 @@ Locked integration sequence (strategic dependency order, not "connect all at onc Every external source gets a record: -``` +```text DataSource +-- name +-- provider @@ -439,8 +442,8 @@ Phase 4 (Biological Domains) near-complete · Phase 5 (Search & Query) done · P Do **not** jump to Phase 7 yet. Sequence: -``` -Phase 6.4 Variant Viewer +```text +Phase 6.4 Variant Viewer (frontend done; external data in Phase 7) | Phase 6.5 Protein Viewer | diff --git a/docs/external-data/README.md b/docs/external-data/README.md index baf9a3d..78ec5f4 100644 --- a/docs/external-data/README.md +++ b/docs/external-data/README.md @@ -6,7 +6,7 @@ This area governs how GenomeAI integrates external scientific sources. It is the > 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 ``` diff --git a/docs/visualization/variant.md b/docs/visualization/variant.md index 077fad7..2bdb728 100644 --- a/docs/visualization/variant.md +++ b/docs/visualization/variant.md @@ -9,6 +9,18 @@ conventions. Selecting a variant reveals a readable detail panel. 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