Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/visualization/GenomeBrowserDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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),
},
],
[],
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/app/visualization/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -16,9 +16,9 @@ export default function VisualizationPage() {
<div className="flex w-full flex-col gap-2">
<h1 className="text-2xl font-bold text-gray-900">Visualization</h1>
<p className="text-sm text-gray-600">
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.
</p>
</div>
<GenomeBrowserDemo />
Expand Down
76 changes: 39 additions & 37 deletions apps/web/src/components/genome/GenomeBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<g>
{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 (
<g key={`${feature.id}-${index}`}>
<line x1={x} y1={y - 5} x2={x} y2={y + 5} stroke="#0891b2" strokeWidth={2} />
<title>{feature.name ?? feature.id}</title>
</g>
)
})}
</g>
)
}

return (
<g>
{features.map((feature, index) => {
Expand Down Expand Up @@ -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 (
Expand All @@ -259,13 +237,37 @@ function BrowserTrack({
>
<svg viewBox={`0 0 ${SVG_WIDTH} ${ROW_HEIGHT}`} className="w-full" aria-hidden="true">
{data.status === 'success' ? (
<GenomeTrackSvg viewport={viewport} features={features} kind={data.kind} />
<GenomeTrackSvg viewport={viewport} features={features} />
) : null}
</svg>
</VisualizationContainer>
)
}

/**
* 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[]
Expand Down
136 changes: 136 additions & 0 deletions apps/web/src/components/genome/VariantTrack.test.tsx
Original file line number Diff line number Diff line change
@@ -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<VariantFeature> & { 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(<VariantTrack track={trackDefinition(loader)} debouncedViewport={viewport} />)
}

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()
})
})
Loading
Loading