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
4 changes: 2 additions & 2 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
)
}
8 changes: 4 additions & 4 deletions apps/web/src/app/visualization/VisualizationDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import {
* Client-side demonstration of the Phase 6.1 visualization foundation.
*
* Shows the data lifecycle (loading → success / empty / error) flowing
* through the reusable `VisualizationContainer`. The module catalog itself
* is a placeholder — the individual visualizations arrive in later
* milestones.
* through the reusable `VisualizationContainer`. The module catalog maps the
* full Phase 6.2–6.11 platform, each entry resolving to the viewer(s) shown
* above on this page.
*/
export function VisualizationDemo() {
const { status, data, error, refetch } = useVisualizationData(
Expand All @@ -24,7 +24,7 @@ export function VisualizationDemo() {
return (
<div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-2">
<VisualizationContainer
title="Planned visualization modules"
title="Delivered visualization modules"
description="Foundation demo loading the visualization module catalog."
status={status}
error={error}
Expand Down
13 changes: 7 additions & 6 deletions apps/web/src/app/visualization/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { VisualizationDemo } from './VisualizationDemo'
export const metadata: Metadata = {
title: 'Visualization — GenomeAI',
description:
'Visualization foundation, Genome Browser, Gene / Transcript viewer, Variant track, Protein Viewer, Biological Network Viewer, Scientific Charts, and Advanced Scientific Charts (Phase 6.1–6.8) for GenomeAI.',
'Visualization foundation, Genome Browser, Gene / Transcript viewer, Variant track, Protein Viewer, Biological Network Viewer, Scientific Charts, Advanced Scientific Charts, and the Integrated Research Workspace (Phase 6.1–6.11) for GenomeAI.',
}

export default function VisualizationPage() {
Expand All @@ -23,11 +23,12 @@ export default function VisualizationPage() {
<p className="text-sm text-gray-600">
Phase 6.1 foundation, the Phase 6.2 Genome Browser, the Phase 6.3 Gene / Transcript
viewer, the Phase 6.4 Variant track, the Phase 6.5 Protein Viewer, the Phase 6.6
Biological Network Viewer, the Phase 6.7 Scientific Charts, and the Phase 6.8 Advanced
Scientific Charts — region parsing, viewport navigation, track rendering, gene/transcript
structure, point variants, protein sequence + annotation windows, deterministic
relationship networks, expression charts, and heatmap / volcano / coverage / distribution
charts over the GenomeAI API and development fixtures.
Biological Network Viewer, the Phase 6.7 Scientific Charts, the Phase 6.8 Advanced
Scientific Charts, the Phase 6.9 Integrated Research Workspace, the Phase 6.10 performance
work, and the Phase 6.11 testing & documentation pass — region parsing, viewport
navigation, track rendering, gene/transcript structure, point variants, protein sequence +
annotation windows, deterministic relationship networks, expression charts, and heatmap /
volcano / coverage / distribution charts over the GenomeAI API and development fixtures.
</p>
</div>
<nav aria-label="Visualization pages" className="flex w-full flex-wrap gap-3">
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/components/scientific/ChartPrimitives.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'

import type { PlotArea } from '@/lib/scientific/geometry'
import { createCategoryScale, createContinuousScale } from '@/lib/scientific/scale'

import { ChartAxes } from './ChartAxes'
import { ChartLegend } from './ChartLegend'
import { ChartTooltip, TOOLTIP_WIDTH } from './ChartTooltip'

const plot: PlotArea = { x0: 40, y0: 10, width: 300, height: 200 }

function categoryScale() {
return createCategoryScale(['A', 'B'], [plot.x0, plot.x0 + plot.width])
}

function continuousScale() {
return createContinuousScale([-2, 2], [plot.x0, plot.x0 + plot.width])
}

afterEach(() => {
cleanup()
})

describe('ChartAxes', () => {
it('renders gridlines, y ticks, and the x-axis baseline', () => {
render(
<ChartAxes
plot={plot}
xScale={categoryScale()}
yScale={continuousScale()}
yTicks={[0, 1, 2]}
/>,
)
expect(screen.getByTestId('chart-axes')).toBeInTheDocument()
expect(screen.getByTestId('chart-grid')).toBeInTheDocument()
expect(screen.getByTestId('chart-y-ticks')).toBeInTheDocument()
expect(screen.getByTestId('chart-x-labels')).toBeInTheDocument()
})

it('renders optional axis captions', () => {
render(
<ChartAxes
plot={plot}
xScale={categoryScale()}
yScale={continuousScale()}
yTicks={[]}
xLabel="Sample"
yLabel="Expression value"
/>,
)
expect(screen.getByTestId('chart-x-label')).toHaveTextContent('Sample')
expect(screen.getByTestId('chart-y-label')).toHaveTextContent('Expression value')
})

it('formats tick values through the provided formatter', () => {
render(
<ChartAxes
plot={plot}
xContinuousScale={continuousScale()}
yScale={continuousScale()}
yTicks={[1_000_000]}
xTicks={[2_000_000]}
formatValue={(value) => `${value / 1_000_000}M`}
/>,
)
expect(screen.getByTestId('chart-y-ticks')).toHaveTextContent('1M')
expect(screen.getByTestId('chart-x-labels')).toHaveTextContent('2M')
})
})

describe('ChartLegend', () => {
it('renders each series as a labelled list item', () => {
render(
<ChartLegend
items={[
{ id: 'tp53', label: 'TP53', color: '#red' },
{ id: 'brca1', label: 'BRCA1', color: '#blue' },
]}
/>,
)
const list = screen.getByRole('list', { name: 'Series legend' })
expect(list).toBeInTheDocument()
const items = screen.getAllByRole('listitem')
expect(items.map((item) => item.textContent)).toEqual(['TP53', 'BRCA1'])
})

it('renders nothing for an empty legend', () => {
const { container } = render(<ChartLegend items={[]} />)
expect(container.firstChild).toBeNull()
})
})

describe('ChartTooltip', () => {
const tooltip = {
title: 'TP53',
subtitle: 'Tumor-1',
rows: [
{ label: 'Value', value: '4.2' },
{ label: 'Group', value: 'Tumor' },
],
}

it('renders as a tooltip with the point summary and labelled rows', () => {
render(<ChartTooltip tooltip={tooltip} x={100} y={50} width={400} />)
expect(screen.getByRole('tooltip')).toBeInTheDocument()
expect(screen.getByText('TP53')).toBeInTheDocument()
expect(screen.getByText('Tumor-1')).toBeInTheDocument()
expect(screen.getByText('Value')).toBeInTheDocument()
expect(screen.getByText('4.2')).toBeInTheDocument()
})

it('keeps the tooltip on-screen by clamping to the canvas width', () => {
const { rerender } = render(<ChartTooltip tooltip={tooltip} x={1000} y={50} width={400} />)
const nearEdge = screen.getByRole('tooltip')
// Clamped so the tooltip's right edge sits on the canvas edge.
expect(Number.parseInt(nearEdge.style.left, 10)).toBe(400 - TOOLTIP_WIDTH)

rerender(<ChartTooltip tooltip={tooltip} x={-50} y={-50} width={400} />)
const nearOrigin = screen.getByRole('tooltip')
expect(Number.parseInt(nearOrigin.style.left, 10)).toBeGreaterThanOrEqual(4)
expect(Number.parseInt(nearOrigin.style.top, 10)).toBeGreaterThanOrEqual(4)
})
})
38 changes: 38 additions & 0 deletions apps/web/src/components/workspace/ResearchWorkspace.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,42 @@ describe('ResearchWorkspace', () => {
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Network unavailable'))
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})

it('shows a loading state while a panel loader is pending', () => {
const dataSource: WorkspaceDataSource = {
...fixtureWorkspaceDataSource,
loadGenes: () => new Promise(() => undefined),
}
render(<ResearchWorkspace dataSource={dataSource} />)

expect(
screen
.getAllByRole('status')
.some((node) => node.textContent?.includes('Loading gene structure...')),
).toBe(true)
})

it('keeps whole-dataset panels independent when the context region changes', async () => {
const loadNetwork = vi.fn(fixtureWorkspaceDataSource.loadNetwork)
const loadProtein = vi.fn(fixtureWorkspaceDataSource.loadProtein)
const dataSource: WorkspaceDataSource = {
...fixtureWorkspaceDataSource,
loadNetwork,
loadProtein,
}
render(<ResearchWorkspace dataSource={dataSource} />)

await waitFor(() => expect(loadNetwork).toHaveBeenCalledTimes(1))
await waitFor(() => expect(loadProtein).toHaveBeenCalledTimes(1))

fireEvent.change(screen.getByRole('combobox', { name: 'Research context' }), {
target: { value: 'brca1-locus' },
})
await waitFor(() =>
expect(screen.getByTestId('active-context')).toHaveTextContent('BRCA1 locus (chr17)'),
)

expect(loadNetwork).toHaveBeenCalledTimes(1)
expect(loadProtein).toHaveBeenCalledTimes(1)
})
})
16 changes: 10 additions & 6 deletions apps/web/src/components/workspace/ResearchWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,17 @@ export function ResearchWorkspace({
dataSource={dataSource}
/>
</div>
<GeneTranscriptPanel
key={regionKey}
region={activeContext.region}
dataSource={dataSource}
/>
<div className="lg:col-span-2">
<GeneTranscriptPanel
key={regionKey}
region={activeContext.region}
dataSource={dataSource}
/>
</div>
<NetworkPanel dataSource={dataSource} />
<ProteinPanel dataSource={dataSource} />
<div className="lg:col-span-2">
<ProteinPanel dataSource={dataSource} />
</div>
<ExpressionPanel dataSource={dataSource} />
<HeatmapPanel dataSource={dataSource} />
<VolcanoPanel dataSource={dataSource} />
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/lib/genome/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
GenomeApiError,
fetchIntervalFeatures,
fetchVariantFeatures,
requestCoordinateSearch,
toGeneFeature,
toTranscriptFeature,
toVariantFeature,
Expand Down Expand Up @@ -142,6 +143,108 @@ describe('toVariantFeature', () => {
const feature = toVariantFeature({ id: 'var-2', chromosome: 'chr7', position: -5 })
expect(feature.position).toBe(0)
})

it('treats a string-typed position as invalid', () => {
const feature = toVariantFeature({ id: 'var-5', chromosome: 'chr7', position: '100' })
expect(feature.position).toBe(0)
})

it('leaves the strand undefined for unknown strands on genes', () => {
const feature = toGeneFeature({
id: 'gene-9',
chromosome: 'chr1',
start_position: 1,
end_position: 10,
strand: '?',
})
expect(feature.strand).toBeUndefined()
})
})

describe('requestCoordinateSearch', () => {
const interval = { chromosome: 'chr1', start: 1, end: 100 }

it('throws an AbortError when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
requestCoordinateSearch('gene', interval, controller.signal, 100),
).rejects.toMatchObject({ name: 'AbortError' })
expect(globalThis.fetch).toBe(rawFetch)
})

it('stops immediately on an empty items page', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ items: [], pagination: { total_count: 50 } }))
globalThis.fetch = fetchMock as unknown as typeof fetch

const items = await requestCoordinateSearch('gene', interval, undefined, 100)
expect(items).toEqual([])
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('stops after the first page when pagination is missing', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
items: [{ id: 'a' }, { id: 'b' }],
}),
)
globalThis.fetch = fetchMock as unknown as typeof fetch

const items = await requestCoordinateSearch('gene', interval, undefined, 100)
expect(items).toHaveLength(2)
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('stops paging once the result cap is reached', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
items: Array.from({ length: 100 }, () => ({ id: 'x' })),
pagination: { page: 1, page_size: 100, total_count: 999_999 },
}),
)
globalThis.fetch = fetchMock as unknown as typeof fetch

const items = await requestCoordinateSearch('gene', interval, undefined, 100)
expect(items).toHaveLength(10_000)
expect(fetchMock).toHaveBeenCalledTimes(100)
})

it('forwards the page size into every request body', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
jsonResponse({ items: [], pagination: { page: 1, page_size: 25, total_count: 0 } }),
)
globalThis.fetch = fetchMock as unknown as typeof fetch

await requestCoordinateSearch('gene', interval, undefined, 25)
const body = JSON.parse(String((fetchMock.mock.calls[0] as [string, RequestInit])[1].body))
expect(body.pagination.page_size).toBe(25)
})

it('aborts between pages when the signal fires', async () => {
const controller = new AbortController()
const firstPage = Array.from({ length: 100 }, () => ({ id: 'x' }))
const fetchMock = vi.fn().mockImplementation(() => {
if (!controller.signal.aborted) {
controller.abort()
}
return Promise.resolve(
jsonResponse({
items: firstPage,
pagination: { page: 1, page_size: 100, total_count: 500 },
}),
)
})
globalThis.fetch = fetchMock as unknown as typeof fetch

await expect(
requestCoordinateSearch('gene', interval, controller.signal, 100),
).rejects.toMatchObject({ name: 'AbortError' })
expect(fetchMock).toHaveBeenCalledTimes(1)
})
})

describe('fetchIntervalFeatures', () => {
Expand Down
Loading
Loading