diff --git a/package-lock.json b/package-lock.json index 4f19a42..8ae8e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@3dbionotes/pdbe-molstar", - "version": "3.1.0-est-2", + "version": "3.1.0-est-3-beta.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@3dbionotes/pdbe-molstar", - "version": "3.1.0-est-2", + "version": "3.1.0-est-3-beta.2", "license": "Apache-2.0", "dependencies": { "d3-axis": "^3.0.0", diff --git a/package.json b/package.json index 6e907a6..c6755cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@3dbionotes/pdbe-molstar", - "version": "3.1.0-est-2", + "version": "3.1.0-est-3-beta.2", "description": "Molstar implementation for PDBe", "main": "index.js", "scripts": { diff --git a/src/app/index.ts b/src/app/index.ts index 2728b6a..38a34a3 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -1,7 +1,5 @@ import { createPluginUI, DefaultPluginUISpec, InitParams, DefaultParams } from './spec'; -import { PluginContext } from 'Molstar/mol-plugin/context'; import { PluginCommands } from 'Molstar/mol-plugin/commands'; -import { SequenceView } from "Molstar/mol-plugin-ui/sequence" import { PluginStateObject } from 'Molstar/mol-plugin-state/objects'; import { StateTransform } from 'Molstar/mol-state'; import { Loci, EmptyLoci } from 'Molstar/mol-model/loci'; @@ -46,6 +44,9 @@ import { AnimateAssemblyUnwind } from 'Molstar/mol-plugin-state/animation/built- import { DownloadDensity, EmdbDownloadProvider } from 'molstar/lib/mol-plugin-state/actions/volume'; import { ControlsWrapper } from 'molstar/lib/mol-plugin-ui/plugin'; import { PluginToast } from 'molstar/lib/mol-plugin/util/toast'; +import { getEntityChainPairs } from './ui/sequence'; +import { initSequenceView } from './ui/sequence-wrapper'; +import { PluginContext } from 'molstar/lib/mol-plugin/context'; require("Molstar/mol-plugin-ui/skin/dark.scss"); @@ -64,6 +65,13 @@ class PDBeMolstarPlugin { readonly events = { loadComplete: this._ev(), updateComplete: this._ev(), + sequenceComplete: this._ev(), + chainUpdate: this._ev(), // chainId + ligandUpdate: this._ev<{ ligandId: string, chainId: string }>(), + dependencyChanged: { + onChainUpdate: this._ev<(chainId: string) => void>(), + isLigandView: this._ev<() => boolean>() + }, }; plugin: PluginContext; @@ -75,6 +83,7 @@ class PDBeMolstarPlugin { isHighlightColorUpdated = false; isSelectedColorUpdated = false; toasts: string[] = []; + proteinId: string | undefined = undefined; async render(target: string | HTMLElement, options: InitParams) { if (!options) return; @@ -158,7 +167,15 @@ class PDBeMolstarPlugin { left: showDebugPanels ? LeftPanelControls : "none", right: showDebugPanels ? ControlsWrapper : "none", top: "none", - bottom: SequenceView, + bottom: + this.initParams.onChainUpdate && + this.initParams.isLigandView + ? initSequenceView( + this, + this.initParams.onChainUpdate, + this.initParams.isLigandView + ).component + : "none", }, viewport: { controls: PDBeViewportControls, @@ -343,7 +360,7 @@ class PDBeMolstarPlugin { // Load Molecule CIF or coordQuery and Parse let dataSource = this.getMoleculeSrcUrl(); if (dataSource) { - this.load({ + await this.load({ url: dataSource.url, label: this.initParams.moleculeId, format: dataSource.format as BuiltInTrajectoryFormat, @@ -680,9 +697,18 @@ class PDBeMolstarPlugin { await this.createLigandStructure(isBranchedView); } + // Sequence Viewer + try { + const sequenceOptions = getEntityChainPairs(this.plugin.state.data, { onlyPolymers: true }); + this.events.sequenceComplete.next(sequenceOptions); + } catch (error) { + console.error(error); + this.events.sequenceComplete.error(error); + } + this.events.loadComplete.next(true); } - + applyVisualParams = () => { const TagRefs: any = { "structure-component-static-polymer": "polymer", @@ -843,6 +869,13 @@ class PDBeMolstarPlugin { return color; } + updateState = { + /* undefined for those cases where there is no uniprot */ + proteinId: (id: string | undefined) => { + this.proteinId = id; + } + }; + visual = { highlight: (params: { data: QueryParam[]; @@ -1090,7 +1123,7 @@ class PDBeMolstarPlugin { // Load Molecule CIF or coordQuery and Parse let dataSource = this.getMoleculeSrcUrl(); if (dataSource) { - this.load( + await this.load( { url: dataSource.url, label: this.initParams.moleculeId, @@ -1104,6 +1137,15 @@ class PDBeMolstarPlugin { this.events.updateComplete.next(true); }, + updateChain: (chainId: string) => this.events.chainUpdate.next(chainId), + updateLigand: (options: { chainId: string; ligandId: string }) => + this.events.ligandUpdate.next(options), + updateDependency: { + onChainUpdate: (callback: (chainId: string) => void) => + this.events.dependencyChanged.onChainUpdate.next(callback), + isLigandView: (callback: () => boolean) => + this.events.dependencyChanged.isLigandView.next(callback), + }, visibility: (data: { polymer?: boolean; het?: boolean; diff --git a/src/app/spec.ts b/src/app/spec.ts index e6edd83..5aba907 100644 --- a/src/app/spec.ts +++ b/src/app/spec.ts @@ -68,6 +68,8 @@ export type InitParams = { selectColor?: {r: number, g: number, b: number}, highlightColor?: {r: number, g: number, b: number}, superpositionParams?: {matrixAccession?: string, segment?: number, cluster?: number[], superposeCompleteCluster?: boolean, ligandView?: boolean}, hideStructure?: ['polymer', 'het', 'water', 'carbs', 'nonStandard', 'coarse'], visualStyle?: 'cartoon' | 'ball-and-stick', encoding: 'cif' | 'bcif' granularity?: Loci.Granularity, selection?: { data: QueryParam[], nonSelectedColor?: any, clearPrevious?: boolean }, mapSettings: any, [key: string]: any; + onChainUpdate?: (chainId: string) => void; + isLigandView?: () => boolean; } export const DefaultParams: InitParams = { @@ -105,5 +107,7 @@ export const DefaultParams: InitParams = { landscape: false, subscribeEvents: false, alphafoldView: false, - sequencePanel: false + sequencePanel: false, + onChainUpdate: undefined, + isLigandView: undefined, }; diff --git a/src/app/subscribe-events.ts b/src/app/subscribe-events.ts index a68b863..46c3a83 100644 --- a/src/app/subscribe-events.ts +++ b/src/app/subscribe-events.ts @@ -1,6 +1,7 @@ +import { PDBeMolstarPlugin } from '.'; import { QueryParam } from './helpers'; -export function subscribeToComponentEvents(wrapperCtx: any) { +export function subscribeToComponentEvents(wrapperCtx: PDBeMolstarPlugin) { document.addEventListener('PDB.interactions.click', function(e: any){ if(typeof e.detail !== 'undefined'){ const data = e.detail.interacting_nodes ? { data: e.detail.interacting_nodes } : { data: [e.detail.selected_node] }; @@ -62,17 +63,23 @@ export function subscribeToComponentEvents(wrapperCtx: any) { let highlightQuery: any = undefined; + const proteinId = wrapperCtx.proteinId; + // Create query object from event data if(e.detail.start && e.detail.end){ - highlightQuery = { - start_residue_number: parseInt(e.detail.start), - end_residue_number: parseInt(e.detail.end) + highlightQuery = proteinId ? { + uniprot_accession: proteinId, + start_uniprot_residue_number: parseInt(e.detail.start), + end_uniprot_residue_number: parseInt(e.detail.end) + } : { + start_auth_residue_number: parseInt(e.detail.start), + end_auth_residue_number: parseInt(e.detail.end) }; } if(e.detail.feature && e.detail.feature.entityId) highlightQuery['entity_id'] = e.detail.feature.entityId + ''; - if(e.detail.feature && e.detail.feature.bestChainId) highlightQuery['struct_asym_id'] = e.detail.feature.bestChainId; - if(e.detail.feature && e.detail.feature.chainId) highlightQuery['struct_asym_id'] = e.detail.feature.chainId; + if(e.detail.feature && e.detail.feature.bestChainId) highlightQuery['auth_asym_id'] = e.detail.feature.bestChainId; + if(e.detail.feature && e.detail.feature.chainId) highlightQuery['auth_asym_id'] = e.detail.feature.chainId; if(highlightQuery) wrapperCtx.visual.highlight({data: [highlightQuery]}); } @@ -82,13 +89,27 @@ export function subscribeToComponentEvents(wrapperCtx: any) { const { detail } = (ev as unknown as ({ detail: MultiSelectDetail | undefined })); if (detail === undefined) return; + const proteinId = wrapperCtx.proteinId; + const params = (detail.fragments || []).map((fragment): QueryParam => { - return { - start_residue_number: (fragment.start), - end_residue_number: (fragment.end), - color: fragment.color, - entity_id: fragment.feature?.entityId, - struct_asym_id: fragment.feature?.bestChainId, + if (proteinId) { + return { + uniprot_accession: proteinId, + start_uniprot_residue_number: fragment.start, + end_uniprot_residue_number: fragment.end, + color: fragment.color, + entity_id: fragment.feature?.entityId, + auth_asym_id: fragment.feature?.bestChainId, + } + } + else { + return { + start_auth_residue_number: fragment.start, + end_auth_residue_number: fragment.end, + color: fragment.color, + entity_id: fragment.feature?.entityId, + auth_asym_id: fragment.feature?.bestChainId, + } }; }); @@ -112,17 +133,23 @@ export function subscribeToComponentEvents(wrapperCtx: any) { let showInteraction = false; let highlightQuery: any = undefined; + const proteinId = wrapperCtx.proteinId; + // Create query object from event data if(e.detail.start && e.detail.end){ - highlightQuery = { - start_residue_number: parseInt(e.detail.start), - end_residue_number: parseInt(e.detail.end) + highlightQuery = proteinId ? { + uniprot_accession: proteinId, + start_uniprot_residue_number: parseInt(e.detail.start), + end_uniprot_residue_number: parseInt(e.detail.end) + } : { + start_auth_residue_number: parseInt(e.detail.start), + end_auth_residue_number: parseInt(e.detail.end) }; } if(e.detail.feature && e.detail.feature.entityId) highlightQuery['entity_id'] = e.detail.feature.entityId + ''; - if(e.detail.feature && e.detail.feature.bestChainId) highlightQuery['struct_asym_id'] = e.detail.feature.bestChainId; - if(e.detail.feature && e.detail.feature.chainId) highlightQuery['struct_asym_id'] = e.detail.feature.chainId; + if(e.detail.feature && e.detail.feature.bestChainId) highlightQuery['auth_asym_id'] = e.detail.feature.bestChainId; + if(e.detail.feature && e.detail.feature.chainId) highlightQuery['auth_asym_id'] = e.detail.feature.chainId; if(e.detail.feature && e.detail.feature.accession && e.detail.feature.accession.split(' ')[0] === 'Chain' || e.detail.feature.tooltipContent === 'Ligand binding site') { showInteraction = true; diff --git a/src/app/ui/sequence-wrapper.tsx b/src/app/ui/sequence-wrapper.tsx new file mode 100644 index 0000000..a88c156 --- /dev/null +++ b/src/app/ui/sequence-wrapper.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { SequenceView } from "./sequence"; +import { PDBeMolstarPlugin } from ".."; + +export function initSequenceView( + plugin: PDBeMolstarPlugin, + onChainUpdate: (chainId: string) => void, + isLigandView: () => boolean +) { + return { + component: class SequenceViewWrapper extends React.Component<{}> { + render() { + return ( + + ); + } + }, + }; +} diff --git a/src/app/ui/sequence.tsx b/src/app/ui/sequence.tsx new file mode 100644 index 0000000..1664d10 --- /dev/null +++ b/src/app/ui/sequence.tsx @@ -0,0 +1,976 @@ +/* File from molstar, modified in order to synchronize with viewer */ +/* Extracted from: Commit df3a7e5 (change access modifiers) MKampfrath, Dec 12 2022 */ +/* Import with no modifications: Commit fcc0c6c (Add SequenceView component) p3rcypj, Dec 4 2024 */ + +/** + * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose + * @author David Sehnal + */ + +import _ from 'lodash'; +import * as React from 'react'; +import { PluginUIComponent } from 'Molstar/mol-plugin-ui/base'; +import { PluginStateObject as PSO } from 'Molstar/mol-plugin-state/objects'; +import { Sequence } from 'Molstar/mol-plugin-ui/sequence/sequence'; +import { Structure, StructureElement, StructureProperties as SP, Unit, StructureProperties } from 'Molstar/mol-model/structure'; +import { SequenceWrapper } from 'Molstar/mol-plugin-ui/sequence/wrapper'; +import { PolymerSequenceWrapper } from 'Molstar/mol-plugin-ui/sequence/polymer'; +import { MarkerAction } from 'Molstar/mol-util/marker-action'; +import { PureSelectControl } from 'Molstar/mol-plugin-ui/controls/parameters'; +import { ParamDefinition as PD } from 'Molstar/mol-util/param-definition'; +import { HeteroSequenceWrapper } from 'Molstar/mol-plugin-ui/sequence/hetero'; +import { State, StateSelection } from 'Molstar/mol-state'; +import { ChainSequenceWrapper } from 'Molstar/mol-plugin-ui/sequence/chain'; +import { ElementSequenceWrapper } from 'Molstar/mol-plugin-ui/sequence/element'; +import { elementLabel } from 'Molstar/mol-theme/label'; +import { Icon, HelpOutlineSvg } from 'Molstar/mol-plugin-ui/controls/icons'; +import { StructureSelectionManager } from 'Molstar/mol-plugin-state/manager/structure/selection'; +import { arrayEqual } from 'Molstar/mol-util/array'; +import { PDBeMolstarPlugin } from '..'; + +const MaxDisplaySequenceLength = 5000; +// TODO: add virtualized Select controls (at best with a search box)? +const MaxSelectOptionsCount = 1000; +const MaxSequenceWrappersCount = 30; + +export function opKey(l: StructureElement.Location) { + const ids = SP.unit.pdbx_struct_oper_list_ids(l); + const ncs = SP.unit.struct_ncs_oper_id(l); + const hkl = SP.unit.hkl(l); + const spgrOp = SP.unit.spgrOp(l); + return `${ids.sort().join(',')}|${ncs}|${hkl}|${spgrOp}`; +} + +export function splitModelEntityId(modelEntityId: string) { + const [modelIdx, entityId] = modelEntityId.split('|'); + return [parseInt(modelIdx), entityId]; +} + +export function getSequenceWrapper( + state: { + structure: Structure; + modelEntityId: string; + chainGroupId: number; + operatorKey: string; + }, + structureSelection: StructureSelectionManager +): SequenceWrapper.Any | string { + const { structure, modelEntityId, chainGroupId, operatorKey } = state; + const l = StructureElement.Location.create(structure); + const [modelIdx, entityId] = splitModelEntityId(modelEntityId); + + const units: Unit[] = []; + + for (const unit of structure.units) { + StructureElement.Location.set(l, structure, unit, unit.elements[0]); + if (structure.getModelIndex(unit.model) !== modelIdx) continue; + if (SP.entity.id(l) !== entityId) continue; + if (unit.chainGroupId !== chainGroupId) continue; + if (opKey(l) !== operatorKey) continue; + + units.push(unit); + } + + if (units.length > 0) { + const data = { structure, units }; + const unit = units[0]; + + let sw: SequenceWrapper; + if (unit.polymerElements.length) { + const l = StructureElement.Location.create(structure, unit, unit.elements[0]); + const entitySeq = unit.model.sequence.byEntityKey[SP.entity.key(l)]; + // check if entity sequence is available + if (entitySeq && entitySeq.sequence.length <= MaxDisplaySequenceLength) { + sw = new PolymerSequenceWrapper(data); + } else { + const polymerElementCount = units.reduce((a, v) => a + v.polymerElements.length, 0); + if (Unit.isAtomic(unit) || polymerElementCount > MaxDisplaySequenceLength) { + sw = new ChainSequenceWrapper(data); + } else { + sw = new ElementSequenceWrapper(data); + } + } + } else if (Unit.isAtomic(unit)) { + const residueCount = units.reduce((a, v) => a + (v as Unit.Atomic).residueCount, 0); + if (residueCount > MaxDisplaySequenceLength) { + sw = new ChainSequenceWrapper(data); + } else { + sw = new HeteroSequenceWrapper(data); + } + } else { + console.warn('should not happen, expecting coarse units to be polymeric'); + sw = new ChainSequenceWrapper(data); + } + + sw.markResidue(structureSelection.getLoci(structure), MarkerAction.Select); + return sw; + } else { + return 'No sequence available'; + } +} + +export function getLigand(options: { chainId: string; ligandId: string }, structure: Structure) { + const ligands = structure.units.flatMap((unit: Unit) => + Array.from(unit.elements).flatMap(element => { + const location = { + kind: "element-location" as const, + structure, + unit, + element, + }; + + const compIds = StructureProperties.residue.microheterogeneityCompIds(location); + const type = StructureProperties.residue.group_PDB(location); + const authSeqId = StructureProperties.residue.auth_seq_id(location); + const chainId = StructureProperties.chain.auth_asym_id(location); + const structAsymId = StructureProperties.chain.label_asym_id(location); + + // Debug ligands without previous filtering + // const ligand = [compIds[0], authSeqId].join("-"); + + return { type, compIds, authSeqId, chainId, structAsymId, id: [compIds[0], authSeqId].join("-") }; + }) + ); + + const groupedLigandsById = _.groupBy(ligands, 'id'); + return groupedLigandsById[options.ligandId].filter(ligand => ligand.chainId === options.chainId)[0]; +} + +export function getModelEntityOptions(structure: Structure, options: { onlyPolymers: boolean}): [string, string][] { + const entityOptions: [string, string][] = []; + const l = StructureElement.Location.create(structure); + const seen = new Set(); + + for (const unit of structure.units) { + StructureElement.Location.set(l, structure, unit, unit.elements[0]); + const id = SP.entity.id(l); + const modelIdx = structure.getModelIndex(unit.model); + const key = `${modelIdx}|${id}`; + if (seen.has(key)) continue; + if (options.onlyPolymers && SP.entity.type(l) !== 'polymer') continue; + + let description = SP.entity.pdbx_description(l).join(', '); + if (structure.models.length) { + if (structure.representativeModel) { // indicates model trajectory + description += ` (Model ${structure.models[modelIdx].modelNum})`; + } else if (description.startsWith('Polymer ')) { // indicates generic entity name + description += ` (${structure.models[modelIdx].entry})`; + } + } + const label = `${id}: ${description}`; + entityOptions.push([key, label]); + seen.add(key); + + if (entityOptions.length > MaxSelectOptionsCount) { + return [['', 'Too many entities']]; + } + } + + if (entityOptions.length === 0) entityOptions.push(['', 'No entities']); + return entityOptions; +} + +export function getChainOptions(structure: Structure, modelEntityId: string): [number, string][] { + const options: [number, string][] = []; + const l = StructureElement.Location.create(structure); + const seen = new Set(); + const [modelIdx, entityId] = splitModelEntityId(modelEntityId); + + for (const unit of structure.units) { + StructureElement.Location.set(l, structure, unit, unit.elements[0]); + if (structure.getModelIndex(unit.model) !== modelIdx) continue; + if (SP.entity.id(l) !== entityId) continue; + + const id = unit.chainGroupId; + if (seen.has(id)) continue; + + // TODO handle special case + // - more than one chain in a unit + const label = elementLabel(l, { granularity: 'chain', hidePrefix: true, htmlStyling: false }); + + options.push([id, label]); + seen.add(id); + + if (options.length > MaxSelectOptionsCount) { + return [[-1, 'Too many chains']]; + } + } + + if (options.length === 0) options.push([-1, 'No chains']); + return options; +} + +export function getEntityChainPairs( + state: State, + options: { onlyPolymers: boolean } +): EntityChainPairs { + const structureOptions = getStructureOptions(state); + const structureRef = structureOptions.options[0][0]; + const structure = getStructure(state, structureRef); + const entityOptions = getModelEntityOptions(structure, options); + const chainOptions = entityOptions.map(([modelEntityId, _eLabel]) => ({ + entityId: modelEntityId, + chains: getChainOptions(structure, modelEntityId), + })); + + const chainIdOptions = chainOptions.flatMap((c) => + c.chains.map(([_id, label]) => label.replace(chainIdRegex, "$1$2")) + ); + const duplicates = chainIdOptions.filter( + (item, index) => chainIdOptions.indexOf(item) !== index + ); + + if (duplicates.length > 0) { + const unique = Array.from(new Set(duplicates)); + const duplicationsPerEntityPerDuplicatedChain = unique.map( + (duplicatedChainId) => { + const coincidencesPerEntity = chainOptions.flatMap((c) => { + const coincidencesChain = c.chains + .filter( + ([_id, label]) => + label.replace(chainIdRegex, "$1$2") === + duplicatedChainId + ) + .map(([_id, label]) => + label.replace(chainIdRegex, "$1 [auth $2]") + ); + if (coincidencesChain.length > 0) + return { + entityId: c.entityId, + chainsDuplicated: coincidencesChain.join(", "), + }; + else return []; + }); + + return { duplicatedChainId, coincidencesPerEntity }; + } + ); + + // Maintenance Note: Should make tests for all PDBs for this + throw new Error( + duplicationsPerEntityPerDuplicatedChain + .map((i) => { + const coincidencesStr = i.coincidencesPerEntity + .map( + (opt) => + `EntityId: ${opt.entityId}, Chains: ${opt.chainsDuplicated}` + ) + .join("; "); + return `Duplicated chains for: ${i.duplicatedChainId} -> ${coincidencesStr}`; + }) + .join("\n") + ); + } + + return { entityOptions, chainOptions }; +} + +// const chainIdRegex = /(?:(\w+) ){0,1}(?:\[auth (\w+)\]){0,1}/; if both ids are present this +// regex will match (which is not intended for later substitutions). +// With next regex, if some id is not present will not match, but intended for later substitutions; +// because if there is no id is because they are the same or there is no struct asym id for formats +// like "pdb" or "ent" +const chainIdRegex = /^(?:(\w+)\s{0,1}){0,1}(?:\[auth (\w+)\])$/; // $1 structAsymId, $2 chainId (auth) + +export function getEntityIdFromChainId( + chainOptions: EntityChainPairs["chainOptions"], + chainId: string +): string { + const entityId = chainOptions.find((c) => + c.chains.find( + ([_id, label]) => label.replace(chainIdRegex, "$2") === chainId + ) + )?.entityId; + if (!entityId) throw new Error(`Entity not found for chain ${chainId}`); + + return entityId; +} + +export function getChainNumberedIdFromChainId( + chainOptions: EntityChainPairs["chainOptions"], + chainId: string +): number { + const chainNumberedId = chainOptions.reduce( + (numberedId, opt) => { + if (typeof numberedId === "number") return numberedId; + const chain = opt.chains.find( + ([_id, label]) => label.replace(chainIdRegex, "$2") === chainId + ); + + return chain && (chain[0] ?? undefined); + }, + undefined + ); + + if (chainNumberedId === undefined) + throw new Error(`Chain not found for chain ${chainId}`); + + return chainNumberedId; +} + +export function getChainIdFromNumberedId( + chainOptions: EntityChainPairs["chainOptions"], + chainNumberedId: string +): string { + const chainId = chainOptions.reduce((chainId, opt) => { + if (chainId) return chainId; + const chain = opt.chains.find(([id]) => String(id) === chainNumberedId); + + return chain && (chain[1]?.replace(chainIdRegex, "$2") ?? undefined); + }, undefined); + + if (chainId === undefined) + throw new Error(`Chain not found for chain ${chainNumberedId}`); + + return chainId; +} + +export function getStructAsymIdFromChainId( + chainOptions: EntityChainPairs["chainOptions"], + chainId: string +): string { + const structAsymId = chainOptions.reduce( + (structAsymId, opt) => { + if (structAsymId) return structAsymId; + const chain = opt.chains.find( + ([id, label]) => label.replace(chainIdRegex, "$2") === chainId + ); + + return ( + chain && (chain[1]?.replace(chainIdRegex, "$1") ?? undefined) + ); + }, + undefined + ); + + if (structAsymId === undefined) + throw new Error(`Chain not found for chain ${chainId}`); + + return structAsymId; +} + +export function getEntityIdFromStructAsymId( + chainOptions: EntityChainPairs["chainOptions"], + structAsymId: string +): string { + const entityId = chainOptions.find((c) => + c.chains.find( + ([_id, label]) => label.replace(chainIdRegex, "$1") === structAsymId + ) + )?.entityId; + if (!entityId) + throw new Error( + `Entity not found for chain STRUCT_ASYM_ID ${structAsymId}` + ); + + return entityId; +} + +export function getChainNumberedIdFromStructAsymId( + chainOptions: EntityChainPairs["chainOptions"], + structAsymId: string +): number { + const chainNumberedId = chainOptions.reduce( + (numberedId, opt) => { + if (typeof numberedId === "number") return numberedId; + const chain = opt.chains.find( + ([_id, label]) => + label.replace(chainIdRegex, "$1") === structAsymId + ); + + return chain && (chain[0] ?? undefined); + }, + undefined + ); + + if (chainNumberedId === undefined) + throw new Error( + `Chain not found for chain STRUCT_ASYM_ID ${structAsymId}` + ); + + return chainNumberedId; +} + + +export function getOperatorOptions(structure: Structure, modelEntityId: string, chainGroupId: number): [string, string][] { + const options: [string, string][] = []; + const l = StructureElement.Location.create(structure); + const seen = new Set(); + const [modelIdx, entityId] = splitModelEntityId(modelEntityId); + + for (const unit of structure.units) { + StructureElement.Location.set(l, structure, unit, unit.elements[0]); + if (structure.getModelIndex(unit.model) !== modelIdx) continue; + if (SP.entity.id(l) !== entityId) continue; + if (unit.chainGroupId !== chainGroupId) continue; + + const id = opKey(l); + if (seen.has(id)) continue; + + const label = unit.conformation.operator.name; + options.push([id, label]); + seen.add(id); + + if (options.length > MaxSelectOptionsCount) { + return [['', 'Too many operators']]; + } + } + + if (options.length === 0) options.push(['', 'No operators']); + return options; +} + +export function getStructureOptions(state: State) { + const options: [string, string][] = []; + const all: Structure[] = []; + + const structures = state.select(StateSelection.Generators.rootsOfType(PSO.Molecule.Structure)); + for (const s of structures) { + if (!s.obj?.data) continue; + + all.push(s.obj.data); + options.push([s.transform.ref, s.obj!.data.label]); + } + + if (options.length === 0) options.push(['', 'No structure']); + return { options, all }; +} + +export function getStructure(state: State, ref: string) { + const cell = state.select(ref)[0]; + if (!ref || !cell || !cell.obj) return Structure.Empty; + return (cell.obj as PSO.Molecule.Structure).data; +} + +export type SequenceViewMode = 'single' | 'polymers' | 'all'; + +const SequenceViewModeParam = PD.Select("single", [ + ["single", "Chain"], + ["polymers", "Polymers"], + ["all", "Everything"], +]); + +type EntityChainPairs = { + entityOptions: [string, string][]; + chainOptions: { + entityId: string; + chains: [number, string][]; + }[]; +}; + +type SequenceViewState = { + structureOptions: { options: [string, string][], all: Structure[] }, + structure: Structure, + structureRef: string, + modelEntityId: string, + chainGroupId: number, + operatorKey: string, + mode: SequenceViewMode +} + +type Props = { + defaultMode?: SequenceViewMode; + plugin: PDBeMolstarPlugin; + onChainUpdate: (chainId: string) => void; + isLigandView: () => boolean; +}; + +export class SequenceView extends PluginUIComponent { + updateViewerChain: (chainId: string) => void; + isLigandView: () => boolean; + state: SequenceViewState = { + structureOptions: { options: [], all: [] }, + structure: Structure.Empty, + structureRef: "", + modelEntityId: "", + chainGroupId: -1, + operatorKey: "", + mode: "single", + }; + + entityChainPairs: EntityChainPairs | undefined; + notPolymerEntityChainPairs: EntityChainPairs | undefined; + lastValidChainId: string | undefined; + + componentDidMount() { + this.updateViewerChain = this.props.onChainUpdate; + this.isLigandView = this.props.isLigandView; + + this.props.plugin.events.chainUpdate.subscribe({ + next: chainId => { + console.debug("molstar.events.chainUpdate", chainId); + + if (!this.entityChainPairs) return; + + const entityId = getEntityIdFromChainId(this.entityChainPairs.chainOptions, chainId) + const chainNumber = getChainNumberedIdFromChainId(this.entityChainPairs.chainOptions, chainId) + + console.debug("Updating sequence selected options", entityId, chainNumber); + + this.setParamProps({ + name: "entity", + param: this.params.entity, + value: entityId + }); + + this.setParamProps({ + name: "chain", + param: this.params.chain, + value: chainNumber + }); + }, + error: err => { + console.error(err); + }, + }); + + this.props.plugin.events.ligandUpdate.subscribe({ + next: (options) => { + console.debug("molstar.events.ligandUpdate", options); + + if (!this.entityChainPairs) return; + if (!this.notPolymerEntityChainPairs) return; + + const ligand = getLigand(options, this.state.structure); + + if (!ligand) return; + if (!ligand.structAsymId) return; + + const entityId = getEntityIdFromStructAsymId( + this.notPolymerEntityChainPairs.chainOptions, + ligand.structAsymId + ); + + const chainNumber = getChainNumberedIdFromStructAsymId( + this.notPolymerEntityChainPairs.chainOptions, + ligand.structAsymId + ); + + console.debug("Updating sequence selected options", entityId, chainNumber); + + this.setParamProps({ + name: "entity", + param: this.params.entity, + value: entityId + }); + + this.setParamProps({ + name: "chain", + param: this.params.chain, + value: chainNumber + }); + + this.props.plugin.canvas.hideToasts(); + }, + error: err => { + console.error(err); + }, + }); + + this.props.plugin.events.dependencyChanged.onChainUpdate.subscribe({ + next: callback => { + console.debug("molstar.events.dependencyChanged.onChainUpdate"); + this.updateViewerChain = callback; + }, + error: err => { + console.error(err); + }, + }); + + this.props.plugin.events.dependencyChanged.isLigandView.subscribe({ + next: callback => { + console.debug("molstar.events.dependencyChanged.isLigandView"); + this.isLigandView = callback; + }, + error: err => { + console.error(err); + }, + }); + + if ( + this.plugin.state.data.select( + StateSelection.Generators.rootsOfType(PSO.Molecule.Structure) + ).length > 0 + ) + this.setState(this.getInitialState()); + + this.subscribe( + this.plugin.state.events.object.updated, + ({ ref, obj }) => { + if ( + ref === this.state.structureRef && + obj && + obj.type === PSO.Molecule.Structure.type && + obj.data !== this.state.structure + ) { + this.sync(); + } + } + ); + + this.subscribe(this.plugin.state.events.object.created, ({ obj }) => { + if (obj && obj.type === PSO.Molecule.Structure.type) { + this.sync(); + } + }); + + this.subscribe(this.plugin.state.events.object.removed, ({ obj }) => { + if (obj && obj.type === PSO.Molecule.Structure.type && obj.data === this.state.structure) { + this.sync(); + } + }); + } + + private sync() { + const structureOptions = getStructureOptions(this.plugin.state.data); + if (arrayEqual(structureOptions.all, this.state.structureOptions.all)) return; + this.setState(this.getInitialState()); + } + + private getStructure(ref: string) { + const state = this.plugin.state.data; + return getStructure(state, ref); + } + + private getSequenceWrapper(params: SequenceView['params']) { + return { + wrapper: getSequenceWrapper( + this.state, + this.plugin.managers.structure.selection + ), + label: `${PD.optionLabel( + params.chain, + this.state.chainGroupId + )} | ${PD.optionLabel(params.entity, this.state.modelEntityId)}`, + }; + } + + private getSequenceWrappers(params: SequenceView['params']) { + if (this.state.mode === 'single') return [this.getSequenceWrapper(params)]; + + const structure = this.getStructure(this.state.structureRef); + const wrappers: { wrapper: (string | SequenceWrapper.Any), label: string }[] = []; + + for (const [modelEntityId, eLabel] of getModelEntityOptions(structure, { + onlyPolymers: this.state.mode === "polymers", + })) { + for (const [chainGroupId, cLabel] of getChainOptions( + structure, + modelEntityId + )) { + for (const [operatorKey] of getOperatorOptions( + structure, + modelEntityId, + chainGroupId + )) { + wrappers.push({ + wrapper: getSequenceWrapper( + { + structure, + modelEntityId, + chainGroupId, + operatorKey, + }, + this.plugin.managers.structure.selection + ), + label: `${cLabel} | ${eLabel}`, + }); + if (wrappers.length > MaxSequenceWrappersCount) return []; + } + } + } + return wrappers; + } + + private getInitialState(): SequenceViewState { + const structureOptions = getStructureOptions(this.plugin.state.data); + const structureRef = structureOptions.options[0][0]; + const structure = this.getStructure(structureRef); + let modelEntityId = getModelEntityOptions(structure, { onlyPolymers: false })[0][0]; + let chainGroupId = getChainOptions(structure, modelEntityId)[0][0]; + let operatorKey = getOperatorOptions(structure, modelEntityId, chainGroupId)[0][0]; + + try { + this.entityChainPairs = getEntityChainPairs(this.plugin.state.data, { onlyPolymers: true }); + this.notPolymerEntityChainPairs = getEntityChainPairs(this.plugin.state.data, { onlyPolymers: false}); + + if (this.entityChainPairs) { + try { + const chainId = getChainIdFromNumberedId(this.entityChainPairs.chainOptions, String(chainGroupId)); + this.lastValidChainId = chainId; + } catch (error) { + console.error("Unable to set the first state of lastValidChain", error); + } + } + } catch (error) { + console.error(error); + } + + if (this.state.structure && this.state.structure === structure) { + modelEntityId = this.state.modelEntityId; + chainGroupId = this.state.chainGroupId; + operatorKey = this.state.operatorKey; + } + return { + structureOptions, + structure, + structureRef, + modelEntityId, + chainGroupId, + operatorKey, + mode: this.props.defaultMode ?? "single", + }; + } + + private get params() { + const { structureOptions, structure, modelEntityId, chainGroupId } = this.state; + const entityOptions = getModelEntityOptions(structure, { onlyPolymers: false }); + const chainOptions = getChainOptions(structure, modelEntityId); + const operatorOptions = getOperatorOptions(structure, modelEntityId, chainGroupId); + return { + structure: PD.Select(structureOptions.options[0][0], structureOptions.options, { shortLabel: true }), + entity: PD.Select(entityOptions[0][0], entityOptions, { shortLabel: true }), + chain: PD.Select(chainOptions[0][0], chainOptions, { shortLabel: true, twoColumns: true, label: 'Chain' }), + operator: PD.Select(operatorOptions[0][0], operatorOptions, { shortLabel: true, twoColumns: true }), + mode: SequenceViewModeParam + }; + } + + private get values(): PD.Values { + return { + structure: this.state.structureRef, + entity: this.state.modelEntityId, + chain: this.state.chainGroupId, + operator: this.state.operatorKey, + mode: this.state.mode + }; + } + + private setParamProps = (p: { param: PD.Base, name: string, value: any }) => { + const state = { ...this.state }; + switch (p.name) { + case 'mode': + state.mode = p.value; + if (this.state.mode === state.mode) return; + + if (state.mode === 'all' || state.mode === 'polymers') { + break; + } + case 'structure': + if (p.name === 'structure') state.structureRef = p.value; + state.structure = this.getStructure(state.structureRef); + state.modelEntityId = getModelEntityOptions(state.structure, { onlyPolymers: false })[0][0]; + state.chainGroupId = getChainOptions(state.structure, state.modelEntityId)[0][0]; + state.operatorKey = getOperatorOptions(state.structure, state.modelEntityId, state.chainGroupId)[0][0]; + break; + case 'entity': + if (state.modelEntityId === p.value) return; + state.modelEntityId = p.value; + state.chainGroupId = getChainOptions( + state.structure, + state.modelEntityId + )[0][0]; + this.updateChainInViewer(state.chainGroupId, state); + state.operatorKey = getOperatorOptions( + state.structure, + state.modelEntityId, + state.chainGroupId + )[0][0]; + break; + case 'chain': + if (state.chainGroupId === p.value) return; + state.chainGroupId = p.value; + this.updateChainInViewer(p.value, state); + state.operatorKey = getOperatorOptions(state.structure, state.modelEntityId, state.chainGroupId)[0][0]; + break; + case 'operator': + state.operatorKey = p.value; + break; + } + this.setState(state); + }; + + private updateChainInViewer( + value: any, + state: { + structureRef: string; + modelEntityId: string; + chainGroupId: number; + } + ) { + if (this.entityChainPairs && !this.isLigandView()) { + try { + const chainId = getChainIdFromNumberedId( + this.entityChainPairs.chainOptions, + String(value) + ); + this.lastValidChainId = chainId; + this.updateViewerChain(chainId); + this.props.plugin.canvas.hideToasts(); + } catch (error) { + this.handleInvalidChain( + error, + this.entityChainPairs.chainOptions, + state + ); + } + } + } + + // Can happen only if user is changing the entity through molstar sequence because he wants to look for surrounding residues + private handleInvalidChain( + error: any, + chainOptions: EntityChainPairs["chainOptions"], + state: { + structureRef: string; + modelEntityId: string; + chainGroupId: number; + } + ) { + console.error("Chain to change is not in one of the polymers.", error); + if (this.lastValidChainId) { + try { + const previousStructAsymId = getStructAsymIdFromChainId( + chainOptions, + this.lastValidChainId + ); + const key = `${state.structureRef}-${state.modelEntityId}-${state.chainGroupId}`; + this.props.plugin.canvas.showToast({ + title: "Chain not in entry", + message: `Still showing previous chain: ${previousStructAsymId} [auth ${this.lastValidChainId}]`, + key, + }); + } catch (error) { + console.error( + "Previous valid chain is not in one of the polymers. This should not happen.", + error + ); + } + } + } + + render() { + if (this.getStructure(this.state.structureRef) === Structure.Empty) { + return
+
+ + + SequenceNo structure available +
+
; + } + + const params = this.params; + const values = this.values; + const sequenceWrappers = this.getSequenceWrappers(params); + + return ( +
+
+ + + Sequence of + + + + + {values.mode === "single" && ( + + )} + + {values.mode === "single" && ( + + )} + + {params.operator.options.length > 1 && ( + + )} +
+ + + {sequenceWrappers.map((s, i) => { + const elem = + typeof s.wrapper === "string" ? ( +
+ {s.wrapper} +
+ ) : ( + + ); + + if (values.mode === "single") return elem; + + return ( + +
+ {s.label} +
+ {elem} +
+ ); + })} +
+
+ ); + } +} + +function NonEmptySequenceWrapper({ children }: { children: React.ReactNode }) { + return
+ {children} +
; +}