strucmotif.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. /**
  2. * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
  5. */
  6. import { CollapsableControls, PurePluginUIComponent } from 'molstar/lib/mol-plugin-ui/base';
  7. import { Button, IconButton, ToggleButton } from 'molstar/lib/mol-plugin-ui/controls/common';
  8. import {
  9. ArrowDownwardSvg,
  10. ArrowUpwardSvg,
  11. DeleteOutlinedSvg,
  12. HelpOutlineSvg,
  13. Icon,
  14. TuneSvg
  15. } from 'molstar/lib/mol-plugin-ui/controls/icons';
  16. import { ActionMenu } from 'molstar/lib/mol-plugin-ui/controls/action-menu';
  17. import { StructureSelectionHistoryEntry } from 'molstar/lib/mol-plugin-state/manager/structure/selection';
  18. import { StructureElement, StructureProperties } from 'molstar/lib/mol-model/structure/structure';
  19. import { ToggleSelectionModeButton } from 'molstar/lib/mol-plugin-ui/structure/selection';
  20. import { OrderedSet } from 'molstar/lib/mol-data/int';
  21. import { ExchangesControl } from './exchanges';
  22. import { Vec3 } from 'molstar/lib/mol-math/linear-algebra/3d/vec3';
  23. import { Structure } from 'molstar/lib/mol-model/structure/structure/structure';
  24. import { Unit } from 'molstar/lib/mol-model/structure/structure/unit';
  25. import { UnitIndex } from 'molstar/lib/mol-model/structure/structure/element/element';
  26. import { ViewerState } from '../types';
  27. const ABSOLUTE_ADVANCED_SEARCH_URL = 'https://rcsb.org/search?query=';
  28. const RELATIVE_ADVANCED_SEARCH_URL = '/search?query=';
  29. const RETURN_TYPE = '&return_type=assembly';
  30. const MIN_MOTIF_SIZE = 3;
  31. const MAX_MOTIF_SIZE = 10;
  32. export const MAX_EXCHANGES = 4;
  33. const MAX_MOTIF_EXTENT = 15;
  34. const MAX_MOTIF_EXTENT_SQUARED = MAX_MOTIF_EXTENT * MAX_MOTIF_EXTENT;
  35. /**
  36. * The top-level component that exposes the strucmotif search.
  37. */
  38. export class StrucmotifSubmitControls extends CollapsableControls {
  39. protected defaultState() {
  40. return {
  41. header: 'Structure Motif Search',
  42. isCollapsed: false,
  43. brand: { accent: 'gray' as const, svg: SearchIconSvg }
  44. };
  45. }
  46. renderControls() {
  47. return <>
  48. <SubmitControls />
  49. </>;
  50. }
  51. }
  52. const _SearchIcon = <svg width='24px' height='24px' viewBox='0 0 12 12'>
  53. <g strokeWidth='1.5' fill='none'>
  54. <path d='M11.29 11.71l-4-4' />
  55. <circle cx='5' cy='5' r='4' />
  56. </g>
  57. </svg>;
  58. export function SearchIconSvg() { return _SearchIcon; }
  59. const location = StructureElement.Location.create(void 0);
  60. type ExchangeState = number;
  61. type ResidueSelection = { label_asym_id: string, struct_oper_id: string, label_seq_id: number }
  62. type Exchange = { residue_id: ResidueSelection, allowed: string[] }
  63. /**
  64. * The inner component of strucmotif search that can be collapsed.
  65. */
  66. class SubmitControls extends PurePluginUIComponent<{}, { isBusy: boolean, residueMap: Map<StructureSelectionHistoryEntry, Residue>, action?: ExchangeState }> {
  67. state = {
  68. isBusy: false,
  69. // map between selection entries of Mol* and additional exchange state
  70. residueMap: new Map<StructureSelectionHistoryEntry, Residue>(),
  71. action: void 0 as ExchangeState | undefined
  72. };
  73. componentDidMount() {
  74. this.subscribe(this.selection.events.additionsHistoryUpdated, () => {
  75. // invalidate potentially expanded exchange panel
  76. this.setState({ action: void 0 });
  77. this.forceUpdate();
  78. });
  79. this.subscribe(this.plugin.behaviors.state.isBusy, v => {
  80. this.setState({ isBusy: v });
  81. });
  82. }
  83. get selection() {
  84. return this.plugin.managers.structure.selection;
  85. }
  86. submitSearch = () => {
  87. const { label_atom_id, x, y, z } = StructureProperties.atom;
  88. // keep track of seen pdbIds, space-groups, and NCS operators - motifs can only have a single value
  89. const pdbId: Set<string> = new Set();
  90. const sg: Set<number> = new Set();
  91. const ncs: Set<number> = new Set();
  92. const residueIds: ResidueSelection[] = [];
  93. const exchanges: Exchange[] = [];
  94. const coordinates: { coords: Vec3, residueId: ResidueSelection }[] = [];
  95. /**
  96. * This sets the 'location' to the backbone atom (CA or C4').
  97. * @param structure context
  98. * @param element wraps atom indices of this residue
  99. */
  100. const determineBackboneAtom = (structure: Structure, element: { unit: Unit; indices: OrderedSet<UnitIndex> }) => {
  101. const { indices } = element;
  102. for (let i = 0, il = OrderedSet.size(indices); i < il; i++) {
  103. StructureElement.Location.set(location, structure, element.unit, element.unit.elements[OrderedSet.getAt(indices, i)]);
  104. const atomLabelId = label_atom_id(location);
  105. if ('CA' === atomLabelId || `C4'` === atomLabelId) {
  106. return true;
  107. }
  108. }
  109. return false;
  110. };
  111. function join(opers: any[]) {
  112. // this makes the assumptions that '1' is the identity operator
  113. if (!opers || !opers.length) return '1';
  114. if (opers.length > 1) {
  115. // Mol* operators are right-to-left
  116. return opers[1] + 'x' + opers[0];
  117. }
  118. return opers[0];
  119. }
  120. const loci = this.plugin.managers.structure.selection.additionsHistory;
  121. for (let i = 0; i < Math.min(MAX_MOTIF_SIZE, loci.length); i++) {
  122. const l = loci[i];
  123. const { structure, elements } = l.loci;
  124. pdbId.add(structure.model.entry);
  125. sg.add(StructureProperties.unit.spgrOp(location));
  126. ncs.add(StructureProperties.unit.struct_ncs_oper_id(location));
  127. const struct_oper_list_ids = StructureProperties.unit.pdbx_struct_oper_list_ids(location);
  128. const struct_oper_id = join(struct_oper_list_ids);
  129. // only first element and only first index will be considered (ignoring multiple residues)
  130. if (!determineBackboneAtom(structure, elements[0])) {
  131. alert(`No CA or C4' atom for ${StructureProperties.residue.label_seq_id(location)} | ${StructureProperties.chain.label_asym_id(location)} | ${struct_oper_id}`);
  132. return;
  133. }
  134. // handle pure residue-info
  135. const residueId = {
  136. label_asym_id: StructureProperties.chain.label_asym_id(location),
  137. // can be empty array if model is selected
  138. struct_oper_id,
  139. label_seq_id: StructureProperties.residue.label_seq_id(location)
  140. };
  141. residueIds.push(residueId);
  142. // retrieve CA/C4', used to compute residue distance
  143. const coords = [x(location), y(location), z(location)] as Vec3;
  144. coordinates.push({ coords, residueId });
  145. // handle potential exchanges - can be empty if deselected by users
  146. const residueMapEntry = this.state.residueMap.get(l)!;
  147. if (residueMapEntry.exchanges?.size > 0) {
  148. if (residueMapEntry.exchanges.size > MAX_EXCHANGES) {
  149. alert(`Maximum number of exchanges per position is ${MAX_EXCHANGES} - Please remove some exchanges from residue ${residueId.label_seq_id} | ${residueId.label_asym_id} | ${residueId.struct_oper_id}.`);
  150. return;
  151. }
  152. exchanges.push({ residue_id: residueId, allowed: Array.from(residueMapEntry.exchanges.values()) });
  153. }
  154. }
  155. if (pdbId.size > 1) {
  156. alert('Motifs can only be extracted from a single model!');
  157. return;
  158. }
  159. if (sg.size > 1) {
  160. alert('Motifs can only appear in a single space-group!');
  161. return;
  162. }
  163. if (ncs.size > 1) {
  164. alert('All motif residues must have matching NCS operators!');
  165. return;
  166. }
  167. if (residueIds.length > MAX_MOTIF_SIZE) {
  168. alert(`Maximum motif size is ${MAX_MOTIF_SIZE} residues!`);
  169. return;
  170. }
  171. if (residueIds.filter(v => v.label_seq_id === 0).length > 0) {
  172. alert('Selections may only contain polymeric entities!');
  173. return;
  174. }
  175. // warn if >15 A
  176. const a = Vec3();
  177. const b = Vec3();
  178. // this is not efficient but is good enough for up to 10 residues
  179. for (let i = 0, il = coordinates.length; i < il; i++) {
  180. Vec3.set(a, coordinates[i].coords[0], coordinates[i].coords[1], coordinates[i].coords[2]);
  181. let contact = false;
  182. for (let j = 0, jl = coordinates.length; j < jl; j++) {
  183. if (i === j) continue;
  184. Vec3.set(b, coordinates[j].coords[0], coordinates[j].coords[1], coordinates[j].coords[2]);
  185. const d = Vec3.squaredDistance(a, b);
  186. if (d < MAX_MOTIF_EXTENT_SQUARED) {
  187. contact = true;
  188. }
  189. }
  190. if (!contact) {
  191. const { residueId } = coordinates[i];
  192. alert(`Residue ${residueId.label_seq_id} | ${residueId.label_asym_id} | ${residueId.struct_oper_id} needs to be less than 15 \u212B from another residue - Consider adding more residues to connect far-apart residues.`);
  193. return;
  194. }
  195. }
  196. const query = {
  197. type: 'terminal',
  198. service: 'strucmotif',
  199. parameters: {
  200. value: {
  201. entry_id: pdbId.values().next().value as string,
  202. residue_ids: residueIds.sort((a, b) => this.sortResidueIds(a, b))
  203. },
  204. rmsd_cutoff: 2,
  205. atom_pairing_scheme: 'ALL',
  206. exchanges: exchanges
  207. }
  208. };
  209. // console.log(query);
  210. const sierraUrl = (this.plugin.customState as ViewerState).detachedFromSierra ? ABSOLUTE_ADVANCED_SEARCH_URL : RELATIVE_ADVANCED_SEARCH_URL;
  211. const url = sierraUrl + encodeURIComponent(JSON.stringify(query)) + RETURN_TYPE;
  212. // console.log(url);
  213. window.open(url, '_blank');
  214. };
  215. sortResidueIds(a: ResidueSelection, b: ResidueSelection): number {
  216. if (a.label_asym_id !== b.label_asym_id) {
  217. return a.label_asym_id.localeCompare(b.label_asym_id);
  218. } else if (a.struct_oper_id !== b.struct_oper_id) {
  219. return a.struct_oper_id.localeCompare(b.struct_oper_id);
  220. } else {
  221. return a.label_seq_id < b.label_seq_id ? -1 : a.label_seq_id > b.label_seq_id ? 1 : 0;
  222. }
  223. }
  224. get actions(): ActionMenu.Items {
  225. const history = this.selection.additionsHistory;
  226. return [
  227. {
  228. kind: 'item',
  229. label: `Submit Search ${history.length < MIN_MOTIF_SIZE ? ' (' + MIN_MOTIF_SIZE + ' selections required)' : ''}`,
  230. value: this.submitSearch,
  231. disabled: history.length < MIN_MOTIF_SIZE
  232. },
  233. ];
  234. }
  235. selectAction: ActionMenu.OnSelect = item => {
  236. if (!item) return;
  237. (item?.value as any)();
  238. };
  239. toggleExchanges = (idx: number) => this.setState({ action: (this.state.action === idx ? void 0 : idx) as ExchangeState });
  240. highlight(loci: StructureElement.Loci) {
  241. this.plugin.managers.interactivity.lociHighlights.highlightOnly({ loci }, false);
  242. }
  243. moveHistory(e: Residue, direction: 'up' | 'down') {
  244. this.setState({ action: void 0 });
  245. this.plugin.managers.structure.selection.modifyHistory(e.entry, direction, MAX_MOTIF_SIZE);
  246. this.updateResidues();
  247. }
  248. modifyHistory(e: Residue, a: 'remove') {
  249. this.setState({ action: void 0 });
  250. this.plugin.managers.structure.selection.modifyHistory(e.entry, a);
  251. this.updateResidues();
  252. }
  253. updateResidues() {
  254. const newResidueMap = new Map<StructureSelectionHistoryEntry, Residue>();
  255. this.selection.additionsHistory.forEach(entry => {
  256. newResidueMap.set(entry, this.state.residueMap.get(entry)!);
  257. });
  258. this.setState({ residueMap: newResidueMap });
  259. }
  260. focusLoci(loci: StructureElement.Loci) {
  261. this.plugin.managers.camera.focusLoci(loci);
  262. }
  263. historyEntry(e: Residue, idx: number) {
  264. const history = this.plugin.managers.structure.selection.additionsHistory;
  265. return <div key={e.entry.id}>
  266. <div className='msp-flex-row'>
  267. <Button noOverflow title='Click to focus. Hover to highlight.' onClick={() => this.focusLoci(e.entry.loci)} style={{ width: 'auto', textAlign: 'left' }} onMouseEnter={() => this.highlight(e.entry.loci)} onMouseLeave={() => this.plugin.managers.interactivity.lociHighlights.clearHighlights()}>
  268. {idx}. <span dangerouslySetInnerHTML={{ __html: e.entry.label }} />
  269. </Button>
  270. <ToggleButton icon={TuneSvg} className='msp-form-control' title='Define exchanges' toggle={() => this.toggleExchanges(idx)} isSelected={this.state.action === idx} disabled={this.state.isBusy} style={{ flex: '0 0 40px', padding: 0 }} />
  271. {history.length > 1 && <IconButton svg={ArrowUpwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'up')} flex='20px' title={'Move up'} />}
  272. {history.length > 1 && <IconButton svg={ArrowDownwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'down')} flex='20px' title={'Move down'} />}
  273. <IconButton svg={DeleteOutlinedSvg} small={true} className='msp-form-control' onClick={() => this.modifyHistory(e, 'remove')} flex title={'Remove'} />
  274. </div>
  275. { this.state.action === idx && <ExchangesControl handler={e} /> }
  276. </div>;
  277. }
  278. add() {
  279. const history = this.plugin.managers.structure.selection.additionsHistory;
  280. const entries: JSX.Element[] = [];
  281. for (let i = 0, _i = Math.min(history.length, 10); i < _i; i++) {
  282. let residue: Residue;
  283. if (this.state.residueMap.has(history[i])) {
  284. residue = this.state.residueMap.get(history[i])!;
  285. } else {
  286. residue = new Residue(history[i], this);
  287. this.state.residueMap.set(history[i], residue);
  288. }
  289. entries.push(this.historyEntry(residue, i + 1));
  290. }
  291. return <>
  292. <ActionMenu items={this.actions} onSelect={this.selectAction} />
  293. {entries.length > 0 && <div className='msp-control-offset'>
  294. {entries}
  295. </div>}
  296. {entries.length === 0 && <div className='msp-control-offset msp-help-text'>
  297. <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections (toggle <ToggleSelectionModeButton inline /> mode)</div>
  298. </div>}
  299. </>;
  300. }
  301. render() {
  302. return <>
  303. {this.add()}
  304. </>;
  305. }
  306. }
  307. export class Residue {
  308. readonly exchanges: Set<string>;
  309. constructor(readonly entry: StructureSelectionHistoryEntry, readonly parent: SubmitControls) {
  310. this.exchanges = new Set<string>();
  311. // by default: explicitly 'activate' original residue type
  312. const structure = entry.loci.structure;
  313. const e = entry.loci.elements[0];
  314. StructureElement.Location.set(location, structure, e.unit, e.unit.elements[OrderedSet.getAt(e.indices, 0)]);
  315. this.exchanges.add(StructureProperties.atom.label_comp_id(location));
  316. }
  317. toggleExchange(val: string): void {
  318. if (this.hasExchange(val)) {
  319. this.exchanges.delete(val);
  320. } else {
  321. if (this.exchanges.size < MAX_EXCHANGES) {
  322. this.exchanges.add(val);
  323. } else {
  324. alert(`Maximum number of exchanges per position is ${MAX_EXCHANGES}`);
  325. }
  326. }
  327. // this will update state of parent component
  328. this.parent.forceUpdate();
  329. }
  330. hasExchange(val: string): boolean {
  331. return this.exchanges.has(val);
  332. }
  333. }