diff --git a/docs/explore/structures.md b/docs/explore/structures.md index 853a51972..4b1134672 100644 --- a/docs/explore/structures.md +++ b/docs/explore/structures.md @@ -11,23 +11,27 @@ When you select a protein with a UniProt accession: 1. The structure viewer appears in the sidebar below the legend 2. Links to [AlphaFold Database](https://alphafold.ebi.ac.uk/), [UniProt](https://www.uniprot.org/), and [InterPro](https://www.interpro.org/) appear at the top - click them anytime 3. The AlphaFold structure file is fetched directly from the [AlphaFold Database API](https://alphafold.ebi.ac.uk/api-docs); the [3D-Beacons API](https://www.ebi.ac.uk/pdbe/pdbe-kb/3dbeacons/) is used only to look up the model page link +4. Optional TED domain annotations are requested from `https://alphafold.ebi.ac.uk/api/domains/{accession}`; the structure still loads if annotations are unavailable ::: tip Supported Structures Currently, ProtSpace supports **AlphaFold structures** only. PDB experimental structures are not yet integrated. ::: -## Confidence Coloring (pLDDT) +## Confidence and Domain Coloring -Structures are colored by **predicted Local Distance Difference Test (pLDDT)** confidence scores—the same scheme used on the [AlphaFold Database](https://alphafold.ebi.ac.uk/). Regions in **blue** are high-confidence, **yellow** moderate, and **red** low-confidence. This helps you quickly spot which parts of the model are more reliable. +Structures initially use **predicted Local Distance Difference Test (pLDDT)** confidence scores—the same scheme used on the [AlphaFold Database](https://alphafold.ebi.ac.uk/). Regions in **blue** are high-confidence, **yellow** moderate, and **red** low-confidence. This helps you quickly spot which parts of the model are more reliable. + +Use the **Color by** control to switch between **pLDDT** and **TED domains** without reloading the structure. TED mode assigns a consistent categorical color to every segment of a domain and shows residues without a TED assignment in gray. The TED domains option is disabled when valid annotations are unavailable for the selected protein. ## Viewer Controls -| Action | Effect | -| ---------------- | -------------------- | -| **Left drag** | Rotate the structure | -| **Right drag** | Pan the view | -| **Scroll** | Zoom in/out | -| **Double-click** | Reset the view | +| Action | Effect | +| ------------------------------ | ----------------------------------------- | +| **Color by pLDDT/TED domains** | Switch the loaded structure's color theme | +| **Left drag** | Rotate the structure | +| **Right drag** | Pan the view | +| **Scroll** | Zoom in/out | +| **Double-click** | Reset the view | ## When Structures Aren't Available diff --git a/openspec/changes/add-ted-domain-structure-coloring/.openspec.yaml b/openspec/changes/add-ted-domain-structure-coloring/.openspec.yaml new file mode 100644 index 000000000..3e8b013df --- /dev/null +++ b/openspec/changes/add-ted-domain-structure-coloring/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-08-01 +goal: Allow AlphaFold structures to switch between pLDDT and TED domain coloring. diff --git a/openspec/changes/add-ted-domain-structure-coloring/design.md b/openspec/changes/add-ted-domain-structure-coloring/design.md new file mode 100644 index 000000000..b6d2579aa --- /dev/null +++ b/openspec/changes/add-ted-domain-structure-coloring/design.md @@ -0,0 +1,67 @@ +## Context + +The structure viewer downloads an AlphaFold mmCIF through `StructureService` and passes a blob URL to a dynamically loaded Mol* 3.44 viewer. Mol* automatically uses AlphaFold quality metadata for pLDDT coloring, but the application neither requests the separate AlphaFold DB TED domains endpoint nor exposes a way to change representation themes. TED responses contain an `annotations` array whose domains each have one or more inclusive `{ af_start, af_end }` residue segments. + +The change crosses the shared data service, the Mol\* adapter, and the Lit structure-viewer component. TED is supplementary annotation: a missing or malformed TED response must not prevent the underlying structure from loading. + +## Goals / Non-Goals + +**Goals:** + +- Preserve the current pLDDT view as the default. +- Retrieve and validate TED domain residue ranges for the displayed accession. +- Apply stable categorical colors by TED domain number, including discontinuous ranges. +- Let users switch between pLDDT and TED colors without reloading the structure. +- Degrade gracefully when TED annotations are unavailable. + +**Non-Goals:** + +- Editing or persisting domain annotations. +- Adding TED annotations to embedding bundle columns or the Python annotation pipeline. +- Replacing Mol*, changing the pinned Mol* version, or adding a Mol\* package dependency. +- Adding a domain legend, selection filters, or new structure representations. + +## Decisions + +### Treat TED annotations as optional sidecar structure data + +`StructureService.loadStructure` will start a request to the AlphaFold DB `/api/domains/{accession}` endpoint while it loads the existing prediction and structure file. It will parse valid domain numbers and inclusive residue intervals into typed data attached to `StructureData`. The optional request has a five-second client timeout backed by an `AbortSignal`; timeout, request, shape, or segment failures produce an empty domain list while structure failures retain their current error behavior. + +This keeps the component on one data-loading path and avoids a second UI-owned network lifecycle. Racing the sidecar request against the bound ensures even a non-settling transport cannot gate the primary result; aborting also releases a conforming fetch implementation. Making TED a required or unbounded request was rejected because annotation availability must not regress structure viewing. + +### Encapsulate Mol\* theme details in the existing adapter + +The Mol* loader will wrap the CDN viewer with a `setColorTheme` method. It will register one custom color-theme provider against the viewer's public plugin theme registry and use the structure component manager to update loaded representations. The adapter will switch back with Mol*'s built-in `plddt-confidence` theme name. + +Using a custom provider was chosen over rewriting mmCIF or applying permanent overpaint because it maps residues at render time, preserves pLDDT data, and supports reversible switching. Adding the npm Mol\* package was rejected because the runtime is already intentionally pinned and dynamically loaded from the CDN. + +New AlphaFold structures retain Mol\*'s existing automatic pLDDT preset. The component does not reapply pLDDT after the asynchronous structure load, because a completion from a disposed or replaced viewer must not write a theme to the current viewer. Explicit user-initiated mode changes continue to go through the adapter. + +### Map residue sequence numbers to stable categorical colors + +The TED theme will read each atomic element's `label_seq_id`, find the containing inclusive interval, and derive a color from the TED domain number using a fixed accessible categorical palette. All segments of the same domain therefore share a color. Residues outside valid TED intervals use a neutral gray. + +Domain number, rather than response order or segment index, is the palette key so color assignment remains stable when discontinuous segments are present. + +### Keep the component control explicit and conservative + +After a structure loads, the viewer will render a two-option “Color by” segmented control for `pLDDT` and `TED domains`. pLDDT remains selected on every newly loaded protein. The TED option is disabled when the parsed domain list is empty, and the explanatory tip follows the active mode. + +The control remains visible when TED is unavailable so users can distinguish unavailable annotation from a missing feature. + +Theme changes are queued per viewer so the most recently requested mode is applied last even when a previous Mol\* update is still in progress. Each request also captures the current viewer and a monotonic request identifier; cleanup invalidates both the pending request and queue so a completion from a replaced viewer cannot update the newly loaded structure's control state. + +## Risks / Trade-offs + +- **[TED endpoint latency delays complete structure data]** → Start the optional request in parallel with existing structure work, cap it at five seconds, abort on timeout, and absorb failures into an empty list. +- **[Mol* global API changes]** → Keep all untyped CDN integration in `molstar-loader.ts`, pin the existing 3.44 version, and cover the adapter contract with focused tests. +- **[Residue numbering mismatch]** → Use `label_seq_id`, which matches AlphaFold model residue numbering and TED segment coordinates; color unmapped residues neutrally. +- **[More domains than palette colors]** → Cycle the fixed palette deterministically by domain number; distinct adjacent domains can repeat only after the palette is exhausted. + +## Migration Plan + +No data migration is required. Deploy the additive client change normally. Rollback consists of reverting the service field, adapter method, and component control; existing AlphaFold loading remains otherwise unchanged. + +## Open Questions + +None. diff --git a/openspec/changes/add-ted-domain-structure-coloring/proposal.md b/openspec/changes/add-ted-domain-structure-coloring/proposal.md new file mode 100644 index 000000000..92cb30790 --- /dev/null +++ b/openspec/changes/add-ted-domain-structure-coloring/proposal.md @@ -0,0 +1,26 @@ +## Why + +Protein structures currently show only AlphaFold pLDDT confidence colors, so users cannot see where TED predicts structural domain boundaries. Adding TED domain coloring makes the structure viewer useful for comparing domain organization with the embedding while preserving the existing confidence view. + +## What Changes + +- Fetch TED domain assignments alongside AlphaFold structure metadata without making TED availability a prerequisite for viewing a structure. +- Add a structure-viewer control that switches between the existing pLDDT confidence theme and categorical TED domain colors. +- Color every inclusive residue segment belonging to the same TED domain consistently, including discontinuous domains, and render unassigned residues neutrally. +- Keep pLDDT as the default and disable TED coloring when no valid domain assignments are available. + +## Capabilities + +### New Capabilities + +- `structure-coloring`: Defines structure-viewer color modes, TED domain retrieval, residue mapping, and unavailable-data behavior. + +### Modified Capabilities + +None. + +## Impact + +- Affects the shared structure data service and its public `StructureData` result. +- Extends the Mol\* adapter and structure-viewer component UI. +- Adds requests to the existing AlphaFold DB TED domains endpoint; no new package dependency is required. diff --git a/openspec/changes/add-ted-domain-structure-coloring/specs/structure-coloring/spec.md b/openspec/changes/add-ted-domain-structure-coloring/specs/structure-coloring/spec.md new file mode 100644 index 000000000..0be4a6b7b --- /dev/null +++ b/openspec/changes/add-ted-domain-structure-coloring/specs/structure-coloring/spec.md @@ -0,0 +1,87 @@ +## ADDED Requirements + +### Requirement: pLDDT remains the default structure color mode + +The structure viewer SHALL display each newly loaded AlphaFold structure with the existing pLDDT confidence color theme selected. + +#### Scenario: Structure loads successfully + +- **WHEN** an AlphaFold structure finishes loading +- **THEN** the color control identifies pLDDT as the active mode +- **AND** the viewer uses Mol\*'s pLDDT confidence theme + +#### Scenario: A prior structure finishes after its viewer is replaced + +- **WHEN** a structure load finishes after its Mol\* viewer has been closed or replaced +- **THEN** the stale completion does not report a structure error +- **AND** the stale completion does not change the current viewer's color theme + +### Requirement: TED domain annotations are optional + +The system SHALL request TED domain annotations for the displayed accession and SHALL NOT fail or indefinitely delay structure loading when the TED request fails, stalls, returns no domains, or contains no valid residue segments. + +#### Scenario: TED annotations are available + +- **WHEN** the TED endpoint returns domains with valid inclusive residue segments +- **THEN** the structure data exposes those domains and segments to the viewer +- **AND** the TED domain color option is enabled + +#### Scenario: TED annotations are unavailable + +- **WHEN** the TED request fails or produces no valid domain segments +- **THEN** the AlphaFold structure still loads normally +- **AND** the TED domain color option is disabled + +#### Scenario: TED annotation request stalls + +- **WHEN** the TED request does not settle within five seconds +- **THEN** the system aborts the optional request +- **AND** the AlphaFold structure still loads normally with no TED domains + +### Requirement: User can switch structure color modes + +The structure viewer SHALL provide pLDDT and TED domains color options after a structure loads and SHALL update the existing Mol\* representations without reloading the structure. + +#### Scenario: User selects TED domain coloring + +- **WHEN** valid TED domains exist and the user activates the TED domains option +- **THEN** the viewer applies TED domain colors to the loaded representations +- **AND** the control and explanatory text identify TED domains as active + +#### Scenario: User returns to pLDDT coloring + +- **WHEN** the user activates pLDDT after viewing TED domain colors +- **THEN** the viewer reapplies Mol\*'s built-in pLDDT confidence theme +- **AND** the control and explanatory text identify pLDDT as active + +#### Scenario: User changes mode while a theme update is in progress + +- **WHEN** a TED domain theme update is still in progress and the user activates pLDDT +- **THEN** the viewer applies pLDDT after the in-progress update finishes +- **AND** the control and explanatory text identify pLDDT as the final active mode + +#### Scenario: Structure changes while a theme update is in progress + +- **WHEN** a new protein replaces the viewer before the prior viewer's theme update finishes +- **THEN** the prior update completion does not change the new structure's color mode +- **AND** the new structure retains the default pLDDT control state + +### Requirement: TED residue colors are consistent by domain + +The TED color theme SHALL assign one deterministic categorical color per TED domain number across every valid inclusive segment and SHALL color residues outside TED assignments with a neutral color. + +#### Scenario: Domain contains discontinuous segments + +- **WHEN** one TED domain contains multiple non-contiguous residue intervals +- **THEN** residues in every interval receive the same domain color + +#### Scenario: Residue is not assigned to a domain + +- **WHEN** a rendered residue sequence number falls outside every TED interval +- **THEN** the residue receives the neutral unassigned color + +#### Scenario: Mol\* supplies different location kinds + +- **WHEN** Mol\* requests a TED color for an atomic element or bond location +- **THEN** the theme resolves the location to its `label_seq_id` residue number +- **AND** a coarse or unmappable location receives the neutral unassigned color diff --git a/openspec/changes/add-ted-domain-structure-coloring/tasks.md b/openspec/changes/add-ted-domain-structure-coloring/tasks.md new file mode 100644 index 000000000..5c4da2ad1 --- /dev/null +++ b/openspec/changes/add-ted-domain-structure-coloring/tasks.md @@ -0,0 +1,31 @@ +## 1. TED Structure Data + +- [x] 1.1 Add a failing StructureService regression test for valid TED domains and discontinuous segments +- [x] 1.2 Parse optional TED domains into typed StructureData without failing structure loading +- [x] 1.3 Cover unavailable and malformed TED responses +- [x] 1.4 Add a failing regression for a TED request that never settles +- [x] 1.5 Bound the optional TED request and fall back to no domains on timeout + +## 2. Mol\* Color Themes + +- [x] 2.1 Add failing tests for deterministic domain colors, discontinuous ranges, and neutral unassigned residues +- [x] 2.2 Register the TED color provider and expose reversible pLDDT/TED theme switching in the Mol\* adapter +- [x] 2.3 Exercise the registered provider for atomic element, bond, and coarse locations + +## 3. Structure Viewer Control + +- [x] 3.1 Add failing component tests for the color control's default, enabled, disabled, and switching states +- [x] 3.2 Add the accessible two-mode color control and mode-specific explanatory text +- [x] 3.3 Add failing regressions for rapid reverse selection and stale viewer completion +- [x] 3.4 Sequence theme updates and ignore requests invalidated by viewer cleanup +- [x] 3.5 Add a failing stale-load regression and remove the redundant post-load theme write + +## 4. Documentation + +- [x] 4.1 Document TED retrieval, coloring, availability, and the color-mode control + +## 5. Verification + +- [x] 5.1 Re-run focused unit tests and the original browser reproduction +- [x] 5.2 Run the repository test suite and mandated `pnpm precommit` gate +- [x] 5.3 Re-run strict OpenSpec, focused and broad tests, browser proof, and bundle contract diff --git a/packages/core/src/components/structure-viewer/molstar-loader.test.ts b/packages/core/src/components/structure-viewer/molstar-loader.test.ts new file mode 100644 index 000000000..d5087913c --- /dev/null +++ b/packages/core/src/components/structure-viewer/molstar-loader.test.ts @@ -0,0 +1,102 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TedDomain } from '@protspace/utils'; +import { createMolstarViewer } from './molstar-loader'; +import { getTedDomainColor } from './ted-domain-coloring'; + +const domains: TedDomain[] = [ + { + domainNumber: 1, + segments: [ + { start: 33, end: 42 }, + { start: 54, end: 76 }, + ], + }, + { domainNumber: 2, segments: [{ start: 100, end: 120 }] }, +]; + +describe('TED domain color mapping', () => { + it('uses one deterministic color across all segments of a domain', () => { + expect(getTedDomainColor(35, domains)).toBe(getTedDomainColor(60, domains)); + expect(getTedDomainColor(35, domains)).not.toBe(getTedDomainColor(105, domains)); + expect(getTedDomainColor(50, domains)).toBe(0x9ca3af); + }); +}); + +describe('Mol* color theme adapter', () => { + beforeEach(() => { + const script = document.createElement('script'); + script.id = 'molstar-script'; + document.head.appendChild(script); + const style = document.createElement('link'); + style.id = 'molstar-style'; + document.head.appendChild(style); + }); + + afterEach(() => { + document.head.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('registers TED coloring and switches loaded representations without reloading', async () => { + const addTheme = vi.fn<(provider: unknown) => void>(); + const updateTheme = vi.fn(async () => undefined); + const components = [{ id: 'polymer' }]; + const rawViewer = { + loadStructureFromUrl: vi.fn(async () => undefined), + dispose: vi.fn(), + plugin: { + representation: { + structure: { themes: { colorThemeRegistry: { add: addTheme } } }, + }, + managers: { + structure: { + hierarchy: { current: { structures: [{ components }] } }, + component: { updateRepresentationsTheme: updateTheme }, + }, + }, + }, + }; + window.molstar = { + Viewer: { create: vi.fn(async () => rawViewer) }, + } as unknown as typeof window.molstar; + + const viewer = await createMolstarViewer(document.createElement('div')); + + expect(addTheme).toHaveBeenCalledOnce(); + expect(viewer.setColorTheme).toBeTypeOf('function'); + + await viewer.setColorTheme('ted-domains', domains); + expect(updateTheme).toHaveBeenLastCalledWith(components, { color: 'protspace-ted-domain' }); + expect(rawViewer.loadStructureFromUrl).not.toHaveBeenCalled(); + + const provider = addTheme.mock.calls[0]?.[0] as + | { + factory: ( + context: unknown, + props: Record, + ) => { color: (location: unknown) => number }; + } + | undefined; + expect(provider).toBeDefined(); + const theme = provider!.factory({}, {}); + const atomicUnit = { + kind: 0, + elements: [7], + model: { + atomicHierarchy: { + residueAtomSegments: { index: { 7: 3 } }, + residues: { label_seq_id: { value: (index: number) => (index === 3 ? 35 : 200) } }, + }, + }, + }; + expect(theme.color({ kind: 'element-location', unit: atomicUnit, element: 7 })).toBe(0x0072b2); + expect(theme.color({ kind: 'bond-location', aUnit: atomicUnit, aIndex: 0 })).toBe(0x0072b2); + expect( + theme.color({ kind: 'element-location', unit: { ...atomicUnit, kind: 1 }, element: 7 }), + ).toBe(0x9ca3af); + + await viewer.setColorTheme('plddt'); + expect(updateTheme).toHaveBeenLastCalledWith(components, { color: 'plddt-confidence' }); + }); +}); diff --git a/packages/core/src/components/structure-viewer/molstar-loader.ts b/packages/core/src/components/structure-viewer/molstar-loader.ts index 6f4d72ca7..4aaaa873a 100644 --- a/packages/core/src/components/structure-viewer/molstar-loader.ts +++ b/packages/core/src/components/structure-viewer/molstar-loader.ts @@ -1,8 +1,14 @@ // Mol* dynamic loader and viewer factory +import type { TedDomain } from '@protspace/utils'; +import { getTedDomainColor, TED_UNASSIGNED_COLOR } from './ted-domain-coloring'; + const MOLSTAR_VERSION = '3.44.0'; const MOLSTAR_SCRIPT_URL = `https://cdn.jsdelivr.net/npm/molstar@${MOLSTAR_VERSION}/build/viewer/molstar.js`; const MOLSTAR_CSS_URL = `https://cdn.jsdelivr.net/npm/molstar@${MOLSTAR_VERSION}/build/viewer/molstar.css`; +const TED_COLOR_THEME_NAME = 'protspace-ted-domain'; + +export type StructureColorMode = 'plddt' | 'ted-domains'; export interface MolstarViewer { loadStructureFromUrl: ( @@ -11,7 +17,62 @@ export interface MolstarViewer { isBinary?: boolean, options?: Record, ) => Promise; + setColorTheme: (mode: StructureColorMode, tedDomains?: TedDomain[]) => Promise; + dispose: () => void; +} + +interface MolstarColumn { + value: (index: number) => number; +} + +interface MolstarUnit { + kind: number; + elements: ArrayLike; + model: { + atomicHierarchy: { + residueAtomSegments: { index: ArrayLike }; + residues: { label_seq_id: MolstarColumn }; + }; + }; +} + +interface MolstarLocation { + kind?: string; + unit?: MolstarUnit; + element?: number; + aUnit?: MolstarUnit; + aIndex?: number; +} + +interface MolstarStructureRef { + components: unknown[]; +} + +interface MolstarPlugin { + representation: { + structure: { + themes: { + colorThemeRegistry: { add: (provider: unknown) => void }; + }; + }; + }; + managers: { + structure: { + hierarchy: { current: { structures: MolstarStructureRef[] } }; + component: { + updateRepresentationsTheme: ( + components: unknown[], + params: { color: string }, + ) => Promise | undefined; + }; + }; + }; +} + +interface RawMolstarViewer { + loadStructureFromUrl: MolstarViewer['loadStructureFromUrl']; dispose: () => void; + plugin: MolstarPlugin; } declare global { @@ -35,12 +96,54 @@ declare global { validationProvider?: string; extensions?: unknown[]; }, - ) => Promise; + ) => Promise; }; }; } } +function getResidueSequenceNumber(location: MolstarLocation): number | null { + const unit = location.kind === 'bond-location' ? location.aUnit : location.unit; + const element = + location.kind === 'bond-location' && unit && location.aIndex !== undefined + ? unit.elements[location.aIndex] + : location.element; + + // Mol* Unit.Kind.Atomic is 0. Coarse units do not expose atomic residue numbering. + if (!unit || unit.kind !== 0 || element === undefined) return null; + + const residueIndex = unit.model.atomicHierarchy.residueAtomSegments.index[element]; + if (residueIndex === undefined) return null; + + const sequenceNumber = unit.model.atomicHierarchy.residues.label_seq_id.value(residueIndex); + return Number.isFinite(sequenceNumber) ? sequenceNumber : null; +} + +function createTedColorThemeProvider(getDomains: () => TedDomain[]) { + const factory = (_context: unknown, props: Record) => ({ + factory, + granularity: 'group' as const, + color: (location: MolstarLocation) => { + const residueSequenceNumber = getResidueSequenceNumber(location); + return residueSequenceNumber === null + ? TED_UNASSIGNED_COLOR + : getTedDomainColor(residueSequenceNumber, getDomains()); + }, + props, + description: 'Assigns categorical colors to TED domains.', + }); + + return { + name: TED_COLOR_THEME_NAME, + label: 'TED Domains', + category: 'Custom', + factory, + getParams: () => ({}), + defaultValues: {}, + isApplicable: () => true, + }; +} + async function ensureMolstarResourcesLoaded(): Promise { if (!document.getElementById('molstar-script')) { await new Promise((resolve, reject) => { @@ -110,7 +213,7 @@ export async function createMolstarViewer(container: HTMLElement): Promise tedDomains), + ); + + return { + loadStructureFromUrl: (...args) => viewer.loadStructureFromUrl(...args), + setColorTheme: async (mode, domains = []) => { + tedDomains = domains; + const color = mode === 'ted-domains' ? TED_COLOR_THEME_NAME : 'plddt-confidence'; + const structures = viewer.plugin.managers.structure.hierarchy.current.structures; + + for (const structure of structures) { + await viewer.plugin.managers.structure.component.updateRepresentationsTheme( + structure.components, + { color }, + ); + } + }, + dispose: () => viewer.dispose(), + }; } diff --git a/packages/core/src/components/structure-viewer/structure-viewer.coloring.test.ts b/packages/core/src/components/structure-viewer/structure-viewer.coloring.test.ts new file mode 100644 index 000000000..560b03385 --- /dev/null +++ b/packages/core/src/components/structure-viewer/structure-viewer.coloring.test.ts @@ -0,0 +1,243 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StructureData, TedDomain } from '@protspace/utils'; + +const mocks = vi.hoisted(() => ({ + loadStructure: vi.fn(), + createViewer: vi.fn(), + loadStructureFromUrl: vi.fn(), + setColorTheme: vi.fn(), + dispose: vi.fn(), +})); + +vi.mock('@protspace/utils', () => ({ + StructureService: { loadStructure: mocks.loadStructure }, +})); + +vi.mock('./molstar-loader', () => ({ + createMolstarViewer: mocks.createViewer, +})); + +import './structure-viewer'; +import type { ProtspaceStructureViewer } from './structure-viewer'; + +const domains: TedDomain[] = [ + { domainNumber: 1, segments: [{ start: 10, end: 50 }] }, + { domainNumber: 2, segments: [{ start: 80, end: 120 }] }, +]; + +function structureData(tedDomains: TedDomain[]): StructureData { + return { + proteinId: 'A0A0B4U9L8', + source: 'alphafold', + url: 'blob:structure', + format: 'mmcif', + isBinary: false, + tedDomains, + metadata: { confidence: 'high', method: 'predicted', version: 'v6' }, + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +async function renderViewer(tedDomains: TedDomain[]) { + mocks.loadStructure.mockResolvedValue(structureData(tedDomains)); + const element = document.createElement('protspace-structure-viewer') as ProtspaceStructureViewer; + element.autoSync = false; + element.proteinId = 'A0A0B4U9L8'; + document.body.appendChild(element); + + await vi.waitFor(() => expect(mocks.loadStructureFromUrl).toHaveBeenCalledOnce()); + await element.updateComplete; + return element; +} + +describe('structure viewer color control', () => { + beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)); + return 1; + }); + mocks.createViewer.mockImplementation(async () => ({ + loadStructureFromUrl: mocks.loadStructureFromUrl, + setColorTheme: mocks.setColorTheme, + dispose: mocks.dispose, + })); + mocks.loadStructureFromUrl.mockResolvedValue(undefined); + mocks.setColorTheme.mockResolvedValue(undefined); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('defaults to pLDDT and enables TED coloring when domains exist', async () => { + const element = await renderViewer(domains); + const plddtButton = element.shadowRoot?.querySelector( + '[data-color-mode="plddt"]', + ); + const tedButton = element.shadowRoot?.querySelector( + '[data-color-mode="ted-domains"]', + ); + + expect(plddtButton?.getAttribute('aria-pressed')).toBe('true'); + expect(tedButton?.getAttribute('aria-pressed')).toBe('false'); + expect(tedButton?.disabled).toBe(false); + expect(element.shadowRoot?.querySelector('.color-description')?.textContent).toContain( + 'pLDDT confidence', + ); + }); + + it('disables TED coloring when no assignments are available', async () => { + const element = await renderViewer([]); + const tedButton = element.shadowRoot?.querySelector( + '[data-color-mode="ted-domains"]', + ); + + expect(tedButton?.disabled).toBe(true); + expect(tedButton?.title).toContain('unavailable'); + }); + + it('switches the loaded representation to TED and back to pLDDT', async () => { + const element = await renderViewer(domains); + const plddtButton = element.shadowRoot?.querySelector( + '[data-color-mode="plddt"]', + ); + const tedButton = element.shadowRoot?.querySelector( + '[data-color-mode="ted-domains"]', + ); + + tedButton?.click(); + await vi.waitFor(() => + expect(mocks.setColorTheme).toHaveBeenCalledWith('ted-domains', domains), + ); + await element.updateComplete; + expect(tedButton?.getAttribute('aria-pressed')).toBe('true'); + expect(element.shadowRoot?.querySelector('.color-description')?.textContent).toContain( + 'TED domains', + ); + + plddtButton?.click(); + await vi.waitFor(() => expect(mocks.setColorTheme).toHaveBeenLastCalledWith('plddt')); + await element.updateComplete; + expect(plddtButton?.getAttribute('aria-pressed')).toBe('true'); + expect(tedButton?.getAttribute('aria-pressed')).toBe('false'); + expect(element.shadowRoot?.querySelector('.color-description')?.textContent).toContain( + 'pLDDT confidence', + ); + }); + + it('honors a rapid return to pLDDT while TED coloring is still applying', async () => { + const element = await renderViewer(domains); + const tedChange = deferred(); + mocks.setColorTheme.mockImplementation((mode) => + mode === 'ted-domains' ? tedChange.promise : Promise.resolve(), + ); + const plddtButton = element.shadowRoot?.querySelector( + '[data-color-mode="plddt"]', + ); + const tedButton = element.shadowRoot?.querySelector( + '[data-color-mode="ted-domains"]', + ); + + tedButton?.click(); + await vi.waitFor(() => + expect(mocks.setColorTheme).toHaveBeenLastCalledWith('ted-domains', domains), + ); + plddtButton?.click(); + tedChange.resolve(); + + await vi.waitFor(() => expect(mocks.setColorTheme).toHaveBeenLastCalledWith('plddt')); + await element.updateComplete; + expect(plddtButton?.getAttribute('aria-pressed')).toBe('true'); + expect(tedButton?.getAttribute('aria-pressed')).toBe('false'); + expect(element.shadowRoot?.querySelector('.color-description')?.textContent).toContain( + 'pLDDT confidence', + ); + }); + + it('ignores a completed color change from a replaced viewer', async () => { + const element = await renderViewer(domains); + const tedChange = deferred(); + mocks.setColorTheme.mockImplementation((mode) => + mode === 'ted-domains' ? tedChange.promise : Promise.resolve(), + ); + const tedButton = element.shadowRoot?.querySelector( + '[data-color-mode="ted-domains"]', + ); + + tedButton?.click(); + await vi.waitFor(() => + expect(mocks.setColorTheme).toHaveBeenLastCalledWith('ted-domains', domains), + ); + + mocks.loadStructure.mockResolvedValueOnce(structureData([])); + element.proteinId = 'P12345'; + await vi.waitFor(() => expect(mocks.loadStructureFromUrl).toHaveBeenCalledTimes(2)); + await element.updateComplete; + + tedChange.resolve(); + await tedChange.promise; + await Promise.resolve(); + await element.updateComplete; + + const replacementPlddtButton = element.shadowRoot?.querySelector( + '[data-color-mode="plddt"]', + ); + const replacementTedButton = element.shadowRoot?.querySelector( + '[data-color-mode="ted-domains"]', + ); + expect(replacementPlddtButton?.getAttribute('aria-pressed')).toBe('true'); + expect(replacementTedButton?.getAttribute('aria-pressed')).toBe('false'); + expect(replacementTedButton?.disabled).toBe(true); + }); + + it('does not report an error when a structure finishes after the viewer closes', async () => { + const structureLoad = deferred(); + mocks.loadStructureFromUrl.mockReturnValueOnce(structureLoad.promise); + const element = await renderViewer(domains); + const handleError = vi.fn(); + element.addEventListener('structure-error', handleError); + + element.close(); + structureLoad.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(handleError).not.toHaveBeenCalled(); + }); + + it('does not reset a replacement viewer theme when a stale structure load finishes', async () => { + const staleStructureLoad = deferred(); + mocks.loadStructureFromUrl.mockReturnValueOnce(staleStructureLoad.promise); + const element = await renderViewer(domains); + + element.proteinId = 'P12345'; + await vi.waitFor(() => expect(mocks.loadStructureFromUrl).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => + expect( + element.shadowRoot?.querySelector('[data-color-mode="ted-domains"]'), + ).not.toBeNull(), + ); + + element.shadowRoot + ?.querySelector('[data-color-mode="ted-domains"]') + ?.click(); + await vi.waitFor(() => + expect(mocks.setColorTheme).toHaveBeenLastCalledWith('ted-domains', domains), + ); + + staleStructureLoad.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.setColorTheme).toHaveBeenLastCalledWith('ted-domains', domains); + }); +}); diff --git a/packages/core/src/components/structure-viewer/structure-viewer.styles.ts b/packages/core/src/components/structure-viewer/structure-viewer.styles.ts index 9cfa1b838..2f13c8ff6 100644 --- a/packages/core/src/components/structure-viewer/structure-viewer.styles.ts +++ b/packages/core/src/components/structure-viewer/structure-viewer.styles.ts @@ -194,9 +194,64 @@ const structureViewerStylesCore = css` border-radius: 0 0 6px 6px; } + .color-toolbar { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + background: var(--disabled-bg); + border-top: 1px solid var(--protspace-viewer-border); + color: var(--protspace-viewer-text-muted); + font-size: 0.75rem; + } + + .color-toolbar-label { + font-weight: 600; + } + + .color-mode-group { + display: inline-flex; + overflow: hidden; + border: 1px solid var(--protspace-viewer-border); + border-radius: 0.3rem; + } + + .color-mode-button { + border: 0; + border-left: 1px solid var(--protspace-viewer-border); + padding: 0.25rem 0.55rem; + background: var(--protspace-viewer-bg); + color: var(--protspace-viewer-text-muted); + font: inherit; + cursor: pointer; + } + + .color-mode-button:first-child { + border-left: 0; + } + + .color-mode-button[aria-pressed='true'] { + background: var(--primary); + color: white; + } + + .color-mode-button:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid var(--protspace-viewer-loading); + outline-offset: -2px; + } + + .color-mode-button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + .tips { display: flex; - align-items: flex-start; + flex-direction: column; + align-items: center; justify-content: center; padding: 0.2rem 0.5rem; background: var(--disabled-bg); diff --git a/packages/core/src/components/structure-viewer/structure-viewer.ts b/packages/core/src/components/structure-viewer/structure-viewer.ts index f405b76fe..4ada089ff 100644 --- a/packages/core/src/components/structure-viewer/structure-viewer.ts +++ b/packages/core/src/components/structure-viewer/structure-viewer.ts @@ -4,7 +4,7 @@ import { customElement } from '../../utils/safe-custom-element'; import { StructureService } from '@protspace/utils'; import type { StructureData } from '@protspace/utils'; import { structureViewerStyles } from './structure-viewer.styles'; -import { createMolstarViewer, type MolstarViewer } from './molstar-loader'; +import { createMolstarViewer, type MolstarViewer, type StructureColorMode } from './molstar-loader'; import { buildAlphaFoldUrl, buildUniProtUrl, buildInterProUrl } from './header-links'; import { createStructureErrorEventDetail, @@ -37,6 +37,10 @@ export class ProtspaceStructureViewer extends LitElement { @state() private _error: string | null = null; @state() private _viewer: MolstarViewer | null = null; @state() private _structureData: StructureData | null = null; + @state() private _colorMode: StructureColorMode = 'plddt'; + private _requestedColorMode: StructureColorMode = 'plddt'; + private _colorModeRequestId = 0; + private _colorModeChangeQueue: Promise = Promise.resolve(); private _scatterplotElement: Element | null = null; // Refs @@ -155,6 +159,7 @@ export class ProtspaceStructureViewer extends LitElement { this._isLoading = true; this._error = null; this._structureData = null; + this._colorMode = 'plddt'; // Dispatch loading event this._dispatchStructureLoadEvent('loading'); @@ -231,6 +236,10 @@ export class ProtspaceStructureViewer extends LitElement { } private _cleanup() { + this._requestedColorMode = 'plddt'; + this._colorModeRequestId += 1; + this._colorModeChangeQueue = Promise.resolve(); + if (this._viewer) { try { this._viewer.dispose(); @@ -290,6 +299,41 @@ export class ProtspaceStructureViewer extends LitElement { this.close(); // Use internal close method } + private async _handleColorModeChange(mode: StructureColorMode) { + const viewer = this._viewer; + if (!viewer || this._requestedColorMode === mode) return; + if (mode === 'ted-domains' && !this._structureData?.tedDomains.length) return; + + const tedDomains = this._structureData?.tedDomains ?? []; + this._requestedColorMode = mode; + const requestId = ++this._colorModeRequestId; + + const applyColorMode = async () => { + if (this._viewer !== viewer || requestId !== this._colorModeRequestId) return; + + try { + if (mode === 'ted-domains') { + await viewer.setColorTheme(mode, tedDomains); + } else { + await viewer.setColorTheme(mode); + } + + if (this._viewer === viewer && requestId === this._colorModeRequestId) { + this._colorMode = mode; + } + } catch (error) { + if (this._viewer === viewer && requestId === this._colorModeRequestId) { + this._requestedColorMode = this._colorMode; + } + console.warn('[StructureViewer] Failed to change structure color mode:', error); + } + }; + + const colorModeChange = this._colorModeChangeQueue.then(applyColorMode, applyColorMode); + this._colorModeChangeQueue = colorModeChange; + await colorModeChange; + } + render() { if (!this.proteinId) { return html` @@ -371,11 +415,49 @@ export class ProtspaceStructureViewer extends LitElement {
+ ${this._structureData && !this._isLoading && !this._error + ? html` +
+ Color by +
+ + +
+
+ ` + : ''} ${this.showTips && !this._error ? html`
- Tip: Left-click and drag to rotate. Click and drag to move. Scroll to - zoom.
Colors show pLDDT confidence (blue = high, red = low). + + Tip: Left-click and drag to rotate. Click and drag to move. Scroll + to zoom. + + + ${this._colorMode === 'ted-domains' + ? 'Colors distinguish TED domains; gray residues are unassigned.' + : 'Colors show pLDDT confidence (blue = high, red = low).'} +
` : ''} diff --git a/packages/core/src/components/structure-viewer/ted-domain-coloring.ts b/packages/core/src/components/structure-viewer/ted-domain-coloring.ts new file mode 100644 index 000000000..c663d668f --- /dev/null +++ b/packages/core/src/components/structure-viewer/ted-domain-coloring.ts @@ -0,0 +1,17 @@ +import type { TedDomain } from '@protspace/utils'; + +export const TED_UNASSIGNED_COLOR = 0x9ca3af; +const TED_DOMAIN_PALETTE = [ + 0x0072b2, 0xe69f00, 0x009e73, 0xcc79a7, 0xd55e00, 0x56b4e9, 0xf0e442, 0x6a3d9a, 0xb15928, +]; + +export function getTedDomainColor(residueSequenceNumber: number, domains: TedDomain[]): number { + const domain = domains.find((candidate) => + candidate.segments.some( + ({ start, end }) => residueSequenceNumber >= start && residueSequenceNumber <= end, + ), + ); + + if (!domain) return TED_UNASSIGNED_COLOR; + return TED_DOMAIN_PALETTE[(domain.domainNumber - 1) % TED_DOMAIN_PALETTE.length]; +} diff --git a/packages/utils/src/structure/structure-service.test.ts b/packages/utils/src/structure/structure-service.test.ts new file mode 100644 index 000000000..b8fe68446 --- /dev/null +++ b/packages/utils/src/structure/structure-service.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { StructureService } from './structure-service'; + +const prediction = { + cifUrl: 'https://models.example/A0A0B4U9L8.cif', + modelVersion: 'v6', +}; + +describe('StructureService TED domains', () => { + beforeEach(() => { + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:structure'), + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('loads valid TED domains and preserves discontinuous residue segments', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/api/prediction/')) { + return Response.json([prediction]); + } + if (url.includes('/api/domains/')) { + return Response.json({ + total: 2, + annotations: [ + { + ted_domain_no: 1, + cath_label: '-', + segments: [ + { af_start: 33, af_end: 42, segment_id: 1 }, + { af_start: 54, af_end: 76, segment_id: 2 }, + { af_start: 107, af_end: 160, segment_id: 3 }, + ], + }, + { + ted_domain_no: 2, + cath_label: '3.40.390.10', + segments: [{ af_start: 194, af_end: 396, segment_id: 1 }], + }, + ], + }); + } + if (url === prediction.cifUrl) { + return new Response('data_AFDB_model'); + } + return new Response(null, { status: 404 }); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await StructureService.loadStructure('A0A0B4U9L8.1'); + + expect(result.tedDomains).toEqual([ + { + domainNumber: 1, + segments: [ + { start: 33, end: 42 }, + { start: 54, end: 76 }, + { start: 107, end: 160 }, + ], + }, + { domainNumber: 2, segments: [{ start: 194, end: 396 }] }, + ]); + expect(fetchMock).toHaveBeenCalledWith('https://alphafold.ebi.ac.uk/api/domains/A0A0B4U9L8', { + signal: expect.any(AbortSignal), + }); + }); + + it.each([ + ['an unavailable response', new Response(null, { status: 503 })], + [ + 'malformed segments', + Response.json({ + total: 1, + annotations: [ + { + ted_domain_no: 1, + segments: [ + { af_start: 'not-a-number', af_end: 10 }, + { af_start: 90, af_end: 20 }, + ], + }, + ], + }), + ], + ])('keeps the structure available with no domains for %s', async (_label, domainResponse) => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/api/prediction/')) return Response.json([prediction]); + if (url.includes('/api/domains/')) return domainResponse; + if (url === prediction.cifUrl) return new Response('data_AFDB_model'); + return new Response(null, { status: 404 }); + }), + ); + + await expect(StructureService.loadStructure('A0A0B4U9L8')).resolves.toMatchObject({ + url: 'blob:structure', + tedDomains: [], + }); + }); + + it('keeps the structure available when the TED request never settles', async () => { + vi.useFakeTimers(); + let tedSignal: AbortSignal | null = null; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/prediction/')) { + return Promise.resolve(Response.json([prediction])); + } + if (url.includes('/api/domains/')) { + tedSignal = init?.signal instanceof AbortSignal ? init.signal : null; + return new Promise(() => {}); + } + if (url === prediction.cifUrl) { + return Promise.resolve(new Response('data_AFDB_model')); + } + return Promise.resolve(new Response(null, { status: 404 })); + }); + vi.stubGlobal('fetch', fetchMock); + + let result: Awaited> | undefined; + void StructureService.loadStructure('A0A0B4U9L8').then((value) => { + result = value; + }); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(result).toMatchObject({ url: 'blob:structure', tedDomains: [] }); + expect(tedSignal?.aborted).toBe(true); + }); +}); diff --git a/packages/utils/src/structure/structure-service.ts b/packages/utils/src/structure/structure-service.ts index a57519db3..c4f7ed5da 100644 --- a/packages/utils/src/structure/structure-service.ts +++ b/packages/utils/src/structure/structure-service.ts @@ -8,11 +8,23 @@ interface AlphaFoldPrediction { modelVersion: string; } +interface TedDomainApiEntry { + ted_domain_no?: number | string; + segments?: unknown; +} + +interface TedDomainApiSegment { + af_start?: number | string; + af_end?: number | string; +} + /** * Service for handling protein structure loading from various sources */ export class StructureService { private static readonly ALPHAFOLD_API_URL = 'https://www.alphafold.ebi.ac.uk/api/prediction'; + private static readonly TED_DOMAINS_API_URL = 'https://alphafold.ebi.ac.uk/api/domains'; + private static readonly TED_DOMAINS_TIMEOUT_MS = 5_000; private static readonly THREE_D_BEACONS_SUMMARY_URL = 'https://www.ebi.ac.uk/pdbe/pdbe-kb/3dbeacons/api/uniprot/summary'; private static readonly alphaFoldModelPageCache: Map = new Map(); @@ -24,6 +36,7 @@ export class StructureService { */ public static async loadStructure(proteinId: string): Promise { const formattedId = this.formatProteinId(proteinId); + const tedDomainsPromise = this.loadTedDomains(formattedId); // Fetch prediction data from AlphaFold API const apiUrl = `${this.ALPHAFOLD_API_URL}/${formattedId}`; @@ -76,6 +89,7 @@ export class StructureService { type: isBinary ? 'application/octet-stream' : 'text/plain', }); const blobUrl = URL.createObjectURL(blob); + const tedDomains = await tedDomainsPromise; return { proteinId: formattedId, @@ -83,6 +97,7 @@ export class StructureService { url: blobUrl, format, isBinary, + tedDomains, metadata: { confidence: 'high', method: 'predicted', @@ -101,6 +116,66 @@ export class StructureService { } } + private static async loadTedDomains(proteinId: string): Promise { + const abortController = new AbortController(); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + abortController.abort(); + resolve([]); + }, this.TED_DOMAINS_TIMEOUT_MS); + }); + + const requestPromise = this.requestTedDomains(proteinId, abortController.signal); + try { + return await Promise.race([requestPromise, timeoutPromise]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + } + + private static async requestTedDomains( + proteinId: string, + signal: AbortSignal, + ): Promise { + try { + const response = await fetch(`${this.TED_DOMAINS_API_URL}/${proteinId}`, { signal }); + if (!response.ok) return []; + + const payload: unknown = await response.json(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []; + const annotations = (payload as { annotations?: unknown }).annotations; + if (!Array.isArray(annotations)) return []; + + return annotations + .map((entry) => this.parseTedDomain(entry)) + .filter((domain): domain is TedDomain => domain !== null) + .sort((left, right) => left.domainNumber - right.domainNumber); + } catch { + return []; + } + } + + private static parseTedDomain(value: unknown): TedDomain | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + + const entry = value as TedDomainApiEntry; + const domainNumber = Number(entry.ted_domain_no); + if (!Number.isInteger(domainNumber) || domainNumber < 1 || !Array.isArray(entry.segments)) { + return null; + } + + const segments = entry.segments + .filter( + (segment): segment is TedDomainApiSegment => + !!segment && typeof segment === 'object' && !Array.isArray(segment), + ) + .map((segment) => ({ start: Number(segment.af_start), end: Number(segment.af_end) })) + .filter(({ start, end }) => start > 0 && start <= end); + + return segments.length > 0 ? { domainNumber, segments } : null; + } + /** * Check if structure is available from AlphaFold * @param proteinId - The protein identifier @@ -181,6 +256,7 @@ export interface StructureData { url: string | null; format: 'pdb' | 'mmcif'; isBinary: boolean; + tedDomains: TedDomain[]; metadata: { confidence: 'high' | 'medium' | 'low' | 'experimental'; method: 'predicted' | 'experimental'; @@ -188,6 +264,16 @@ export interface StructureData { }; } +export interface TedDomainSegment { + start: number; + end: number; +} + +export interface TedDomain { + domainNumber: number; + segments: TedDomainSegment[]; +} + /** * Structure loading events */