/** * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author Sebastian Bittrich */ import * as React from 'react'; import {CollapsableControls, PurePluginUIComponent} from 'molstar/lib/mol-plugin-ui/base'; import {Button, IconButton, ToggleButton} from 'molstar/lib/mol-plugin-ui/controls/common'; import { ArrowDownwardSvg, ArrowUpwardSvg, DeleteOutlinedSvg, HelpOutlineSvg, Icon, TuneSvg } from 'molstar/lib/mol-plugin-ui/controls/icons'; import {ActionMenu} from 'molstar/lib/mol-plugin-ui/controls/action-menu'; import {StructureSelectionHistoryEntry} from 'molstar/lib/mol-plugin-state/manager/structure/selection'; import {StructureElement, StructureProperties} from 'molstar/lib/mol-model/structure/structure'; import {ToggleSelectionModeButton} from 'molstar/lib/mol-plugin-ui/structure/selection'; import {OrderedSet} from 'molstar/lib/mol-data/int'; import {ExchangesControl} from './exchanges'; // TODO use prod // const ADVANCED_SEARCH_URL = 'https://localhost:8080/search?request='; const ADVANCED_SEARCH_URL = 'https://strucmotif-dev.rcsb.org/search?request='; // TODO consider 2 as value const MIN_MOTIF_SIZE = 3; const MAX_MOTIF_SIZE = 10; /** * The top-level component that exposes the strucmotif search. */ export class StrucmotifSubmitControls extends CollapsableControls { protected defaultState() { return { header: 'Structural Motif Search', isCollapsed: false, brand: { accent: 'gray' as const, svg: SearchIconSvg } }; } renderControls() { return <> ; } } // TODO nice svg - magnifying glass or something search-y const _SearchIcon = ; export function SearchIconSvg() { return _SearchIcon; } const location = StructureElement.Location.create(void 0); type ExchangeState = 'exchanges-0' | 'exchanges-1' | 'exchanges-2' | 'exchanges-3' | 'exchanges-4' | 'exchanges-5' | 'exchanges-6' | 'exchanges-7' | 'exchanges-8' | 'exchanges-9'; type ResidueSelection = { label_asym_id: string, struct_oper_id: string, label_seq_id: number } /** * The inner component of strucmotif search that can be collapsed. */ class SubmitControls extends PurePluginUIComponent<{}, { isBusy: boolean, residueMap: Map, action?: ExchangeState }> { state = { isBusy: false, // map between Mol* of selection entries and additional exchange state residueMap: new Map(), action: void 0 as ExchangeState | undefined }; componentDidMount() { this.subscribe(this.selection.events.additionsHistoryUpdated, () => { this.forceUpdate(); }); this.subscribe(this.plugin.behaviors.state.isBusy, v => { this.setState({ isBusy: v }); }); } get selection() { return this.plugin.managers.structure.selection; } submitSearch = () => { const pdbId: Set = new Set(); const residueIds: ResidueSelection[] = []; const loci = this.plugin.managers.structure.selection.additionsHistory; let structure; for (let i = 0; i < Math.min(MAX_MOTIF_SIZE, loci.length); i++) { const l = loci[i]; structure = l.loci.structure; pdbId.add(structure.model.entry); // only first element and only first index will be considered (ignoring multiple residues) const e = l.loci.elements[0]; StructureElement.Location.set(location, structure, e.unit, e.unit.elements[OrderedSet.getAt(e.indices, 0)]); // handle pure residue-info const struct_oper_list_ids = StructureProperties.unit.pdbx_struct_oper_list_ids(location); residueIds.push({ label_asym_id: StructureProperties.chain.label_asym_id(location), // can be empty array if model is selected struct_oper_id: struct_oper_list_ids?.length ? struct_oper_list_ids.join('x') : '1', label_seq_id: StructureProperties.residue.label_seq_id(location) }); // handle potential exchanges console.log(this.state.residueMap); const residueMapEntry = this.state.residueMap.get(l); if (residueMapEntry?.exchanges) { console.log(residueMapEntry.exchanges); } } if (pdbId.size > 1) { this.plugin.log.warn('Motifs can only be extracted from a single model!'); return; } if (residueIds.length > MAX_MOTIF_SIZE) { this.plugin.log.warn(`Maximum motif size is ${MAX_MOTIF_SIZE} residues!`); return; } if (residueIds.filter(v => v.label_seq_id === 0).length > 0) { this.plugin.log.warn('Selections may only contain polymeric entities!'); return; } const query = { query: { type: 'group', logical_operator: 'and', nodes: [{ type: 'terminal', service: 'strucmotif', parameters: { value: { data: pdbId.values().next().value as string, residue_ids: residueIds }, score_cutoff: 5, // TODO add UI to define exchanges exchanges: [] }, label: 'strucmotif', node_id: 0 }], label: 'query-builder' }, return_type: 'assembly', request_options: { pager: { start: 0, rows: 100 }, scoring_strategy: 'combined', sort: [{ sort_by: 'score', direction: 'desc' }] }, 'request_info': { 'src': 'ui' } }; // TODO figure out if Mol* can compose sierra/BioJava operator window.open(ADVANCED_SEARCH_URL + encodeURIComponent(JSON.stringify(query)), '_blank'); } get actions(): ActionMenu.Items { const history = this.selection.additionsHistory; return [ { kind: 'item', label: `Submit Search ${history.length < MIN_MOTIF_SIZE ? ' (' + MIN_MOTIF_SIZE + ' selections required)' : ''}`, value: this.submitSearch, disabled: history.length < MIN_MOTIF_SIZE }, ]; } selectAction: ActionMenu.OnSelect = item => { if (!item) return; (item?.value as any)(); } toggleExchanges = (idx: number) => this.setState({ action: this.state.action === `exchanges-${idx}` ? void 0 : `exchanges-${idx}` as ExchangeState }); highlight(loci: StructureElement.Loci) { this.plugin.managers.interactivity.lociHighlights.highlightOnly({ loci }, false); } moveHistory(e: Residue, direction: 'up' | 'down') { this.setState({ action: void 0 }); this.plugin.managers.structure.selection.modifyHistory(e.entry, direction, MAX_MOTIF_SIZE); this.updateResidues(); } modifyHistory(e: Residue, a: 'remove', idx: number) { this.setState({ action: void 0 }); this.plugin.managers.structure.selection.modifyHistory(e.entry, a); this.updateResidues(); } updateResidues() { const newResidueMap = new Map(); this.selection.additionsHistory.forEach(entry => { newResidueMap.set(entry, this.state.residueMap.get(entry)!); }); this.setState({ residueMap: newResidueMap }); } updateExchanges = (key: StructureSelectionHistoryEntry, ex: Set) => { this.setState({ }); } focusLoci(loci: StructureElement.Loci) { this.plugin.managers.camera.focusLoci(loci); } historyEntry(e: Residue, idx: number) { const history = this.plugin.managers.structure.selection.additionsHistory; return
this.toggleExchanges(idx)} isSelected={this.state.action === `exchanges-${idx}`} disabled={this.state.isBusy} style={{ flex: '0 0 40px', padding: 0 }} /> {history.length > 1 && this.moveHistory(e, 'up')} flex='20px' title={'Move up'} />} {history.length > 1 && this.moveHistory(e, 'down')} flex='20px' title={'Move down'} />} this.modifyHistory(e, 'remove', idx)} flex title={'Remove'} />
{ this.state.action === `exchanges-${idx}` && }
; } add() { const history = this.plugin.managers.structure.selection.additionsHistory; const entries: JSX.Element[] = []; for (let i = 0, _i = Math.min(history.length, 10); i < _i; i++) { entries.push(this.historyEntry(new Residue(history[i]), i + 1)); } return <> {entries.length > 0 &&
{entries}
} {entries.length === 0 &&
Add one or more selections (toggle mode)
} ; } render() { return <> {this.add()} ; } } export class Residue { readonly exchanges: Set; constructor(readonly entry: StructureSelectionHistoryEntry) { this.exchanges = new Set(); } toggleExchange(val: string): void { if (this.hasExchange(val)) { this.exchanges.delete(val); } else { this.exchanges.add(val); } } hasExchange(val: string): boolean { return this.exchanges.has(val); } }