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
10 changes: 6 additions & 4 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"three": "^0.185.1"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
Expand All @@ -23,12 +24,13 @@
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.185.4",
"@vitejs/plugin-react": "^4.4.0",
"autoprefixer": "^10.0.0",
"jsdom": "^26.0.0",
"typescript": "^5.7.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.0.0",
"autoprefixer": "^10.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
22 changes: 4 additions & 18 deletions apps/web/src/app/visualization/VisualizationDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,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 maps the
* full Phase 6.2–6.11 platform, each entry resolving to the viewer(s) shown
* above on this page.
* Shows the module catalog flowing through the reusable
* `VisualizationContainer`. The catalog maps the full Phase 6.2–6.12
* platform, each entry resolving to the viewer(s) shown above on this page.
*/
export function VisualizationDemo() {
const { status, data, error, refetch } = useVisualizationData(
Expand All @@ -22,7 +21,7 @@ export function VisualizationDemo() {
)

return (
<div className="grid w-full grid-cols-1 gap-6 lg:grid-cols-2">
<div className="flex w-full flex-col gap-6">
<VisualizationContainer
title="Delivered visualization modules"
description="Foundation demo loading the visualization module catalog."
Expand All @@ -45,19 +44,6 @@ export function VisualizationDemo() {
))}
</ul>
</VisualizationContainer>

<div className="flex w-full flex-col gap-6">
<VisualizationContainer
title="Empty state"
status="empty"
emptyMessage="No samples match the current selection."
/>
<VisualizationContainer
title="Error state"
status="error"
error={{ message: 'The visualization service could not be reached.' }}
/>
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'

import { MolecularStructureViewer } from '@/components/molecular/MolecularStructureViewer'
import { P53_HELIX_STRUCTURE_FIXTURE } from '@/lib/molecular/molecular.fixtures'
import { useMolecularStructureViewer } from '@/lib/molecular/useMolecularStructureViewer'

/**
* Phase 6.12 demo: Molecular Structure Viewer over the development fixture.
*
* The backend does not yet expose a molecular structure endpoint, so this
* demo feeds the synthetic typed fixture through the same normalizer the
* production adapter uses. See `docs/visualization/molecular-structure.md`.
*/
export function MolecularStructureDemo() {
const result = useMolecularStructureViewer({
loader: async () => P53_HELIX_STRUCTURE_FIXTURE,
})
return <MolecularStructureViewer result={result} />
}
36 changes: 36 additions & 0 deletions apps/web/src/app/visualization/molecular-structure/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Metadata } from 'next'
import Link from 'next/link'

import { MolecularStructureDemo } from './MolecularStructureDemo'

export const metadata: Metadata = {
title: 'Molecular Structure Viewer — GenomeAI',
description:
'Interactive 3D molecular structure viewer (Phase 6.12): cartoon, ball-and-stick, and space-filling representations over a synthetic development fixture.',
}

export default function MolecularStructurePage() {
return (
<main className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-6 p-4 sm:p-8">
<div className="flex w-full flex-col gap-2">
<h1 className="text-2xl font-bold text-gray-900">Molecular Structure Viewer</h1>
<p className="text-sm text-gray-600">
Phase 6.12 interactive 3D molecular structure rendering: orbit, zoom, and pan the camera;
switch between cartoon / ribbon, ball-and-stick, and space-filling representations; and
reset or fit the view. The demo renders a synthetic development fixture through the same
typed normalizer the production structure adapter will use — no structure endpoint exists
yet (see the molecular structure documentation).
</p>
</div>
<nav aria-label="Visualization pages" className="flex w-full flex-wrap gap-3">
<Link
href="/visualization"
className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
Back to Visualization
</Link>
</nav>
<MolecularStructureDemo />
</main>
)
}
6 changes: 6 additions & 0 deletions apps/web/src/app/visualization/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export default function VisualizationPage() {
>
Open Integrated Research Workspace
</Link>
<Link
href="/visualization/molecular-structure"
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Open Molecular Structure Viewer
</Link>
</nav>
<GenomeBrowserDemo />
<GeneTranscriptDemo />
Expand Down
183 changes: 183 additions & 0 deletions apps/web/src/components/molecular/MolecularStructureViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { P53_HELIX_STRUCTURE_FIXTURE } from '@/lib/molecular/molecular.fixtures'
import type { MolecularViewer } from '@/lib/molecular/render/types'
import type { MolecularStructureViewerResult } from '@/lib/molecular/useMolecularStructureViewer'

import { MolecularStructureViewer } from './MolecularStructureViewer'

function result(
overrides: Partial<MolecularStructureViewerResult> = {},
): MolecularStructureViewerResult {
const base: MolecularStructureViewerResult = {
status: 'success',
error: undefined,
refetch: vi.fn(),
structure: P53_HELIX_STRUCTURE_FIXTURE,
summary: {
name: 'p53 N-terminal helix (synthetic fixture)',
chains: 1,
residues: 15,
atoms: 60,
bonds: 75,
},
representation: 'cartoon',
setRepresentation: vi.fn(),
visible: true,
setVisible: vi.fn(),
focus: { target: { x: 0, y: 0, z: 0 }, radius: 5, version: 0 },
resetView: vi.fn(),
fitToView: vi.fn(),
}
return { ...base, ...overrides }
}

function fakeViewer() {
const viewer: MolecularViewer = {
setStructure: vi.fn(),
setRepresentation: vi.fn(),
setVisible: vi.fn(),
focusCamera: vi.fn(),
resize: vi.fn(),
dispose: vi.fn(),
}
const createViewer = vi.fn(() => viewer)
return { viewer, createViewer }
}

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

describe('MolecularStructureViewer', () => {
it('renders the canvas with a labelled image and a live summary', () => {
const { createViewer } = fakeViewer()
render(<MolecularStructureViewer result={result()} createViewer={createViewer} />)
const canvas = screen.getByRole('img')
expect(canvas).toBeInTheDocument()
expect(canvas.getAttribute('aria-label')).toContain('15 residues')
expect(screen.getByTestId('structure-status').textContent).toContain('60 atoms')
})

it('renders the loading state without creating a viewer', () => {
const { createViewer } = fakeViewer()
render(
<MolecularStructureViewer
result={result({ status: 'loading', structure: undefined })}
createViewer={createViewer}
/>,
)
expect(screen.getByText('Loading molecular structure...')).toBeInTheDocument()
expect(createViewer).not.toHaveBeenCalled()
})

it('renders the empty state message', () => {
const { createViewer } = fakeViewer()
render(
<MolecularStructureViewer
result={result({ status: 'empty', structure: undefined })}
createViewer={createViewer}
/>,
)
expect(screen.getByText('No molecular structure to display.')).toBeInTheDocument()
expect(createViewer).not.toHaveBeenCalled()
})

it('renders the error state and retries', () => {
const { createViewer } = fakeViewer()
const refetch = vi.fn()
render(
<MolecularStructureViewer
result={result({
status: 'error',
error: { message: 'down' },
structure: undefined,
refetch,
})}
createViewer={createViewer}
/>,
)
expect(screen.getByText('Failed to load molecular structure')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /retry/i }))
expect(refetch).toHaveBeenCalledTimes(1)
expect(createViewer).not.toHaveBeenCalled()
})

it('creates the viewer once and feeds it the structure and focus', () => {
const { viewer, createViewer } = fakeViewer()
render(<MolecularStructureViewer result={result()} createViewer={createViewer} />)
expect(createViewer).toHaveBeenCalledTimes(1)
expect(viewer.setStructure).toHaveBeenCalledWith(P53_HELIX_STRUCTURE_FIXTURE, 'cartoon')
expect(viewer.focusCamera).toHaveBeenCalledWith({ x: 0, y: 0, z: 0 }, 5)
})

it('switches representation through the labelled select', () => {
const { viewer, createViewer } = fakeViewer()
const setRepresentation = vi.fn()
render(
<MolecularStructureViewer
result={result({ setRepresentation })}
createViewer={createViewer}
/>,
)
const select = screen.getByRole('combobox', { name: 'Structure representation' })
fireEvent.change(select, { target: { value: 'space-filling' } })
expect(setRepresentation).toHaveBeenCalledWith('space-filling')
expect(viewer.setStructure).toHaveBeenCalledWith(P53_HELIX_STRUCTURE_FIXTURE, 'cartoon')
})

it('toggles visibility with an accessible pressed state', () => {
const { viewer, createViewer } = fakeViewer()
const setVisible = vi.fn()
const { unmount } = render(
<MolecularStructureViewer result={result({ setVisible })} createViewer={createViewer} />,
)
const toggle = screen.getByRole('button', { name: 'Hide structure' })
expect(toggle.getAttribute('aria-pressed')).toBe('true')

fireEvent.click(toggle)
expect(setVisible).toHaveBeenCalledWith(false)
expect(viewer.setVisible).toHaveBeenCalledWith(true)

unmount()
})

it('re-frames the camera when the focus version changes', async () => {
const { viewer, createViewer } = fakeViewer()
const first = result()
const { rerender } = render(
<MolecularStructureViewer result={first} createViewer={createViewer} />,
)
expect(viewer.focusCamera).toHaveBeenCalledTimes(1)

const refocused = result({ focus: { target: { x: 1, y: 1, z: 1 }, radius: 6, version: 1 } })
rerender(<MolecularStructureViewer result={refocused} createViewer={createViewer} />)
await waitFor(() => expect(viewer.focusCamera).toHaveBeenCalledTimes(2))
})

it('exposes reset and fit controls', () => {
const { createViewer } = fakeViewer()
const resetView = vi.fn()
const fitToView = vi.fn()
render(
<MolecularStructureViewer
result={result({ resetView, fitToView })}
createViewer={createViewer}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Reset view' }))
fireEvent.click(screen.getByRole('button', { name: 'Fit to structure' }))
expect(resetView).toHaveBeenCalledTimes(1)
expect(fitToView).toHaveBeenCalledTimes(1)
})

it('disposes the viewer on unmount', () => {
const { viewer, createViewer } = fakeViewer()
const { unmount } = render(
<MolecularStructureViewer result={result()} createViewer={createViewer} />,
)
unmount()
expect(viewer.dispose).toHaveBeenCalledTimes(1)
})
})
Loading
Loading