strucmotif.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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 * as React from 'react';
  7. import {CollapsableControls, PurePluginUIComponent} from 'molstar/lib/mol-plugin-ui/base';
  8. import {Button, IconButton, ToggleButton} from 'molstar/lib/mol-plugin-ui/controls/common';
  9. import {
  10. ArrowDownwardSvg,
  11. ArrowUpwardSvg,
  12. DeleteOutlinedSvg,
  13. HelpOutlineSvg,
  14. Icon,
  15. TuneSvg
  16. } from 'molstar/lib/mol-plugin-ui/controls/icons';
  17. import {ActionMenu} from 'molstar/lib/mol-plugin-ui/controls/action-menu';
  18. import {StructureSelectionHistoryEntry} from 'molstar/lib/mol-plugin-state/manager/structure/selection';
  19. import {StructureElement, StructureProperties} from 'molstar/lib/mol-model/structure/structure';
  20. import {ToggleSelectionModeButton} from 'molstar/lib/mol-plugin-ui/structure/selection';
  21. import {OrderedSet} from 'molstar/lib/mol-data/int';
  22. import {ExchangesControl} from './exchanges';
  23. // TODO use prod
  24. const ADVANCED_SEARCH_URL = 'https://strucmotif-dev.rcsb.org/search?query=';
  25. const RETURN_TYPE = '&return_type=assembly';
  26. const MIN_MOTIF_SIZE = 3;
  27. const MAX_MOTIF_SIZE = 10;
  28. /**
  29. * The top-level component that exposes the strucmotif search.
  30. */
  31. export class StrucmotifSubmitControls extends CollapsableControls {
  32. protected defaultState() {
  33. return {
  34. header: 'Structural Motif Search',
  35. isCollapsed: false,
  36. brand: { accent: 'gray' as const, svg: SearchIconSvg }
  37. };
  38. }
  39. renderControls() {
  40. return <>
  41. <SubmitControls />
  42. </>;
  43. }
  44. }
  45. const _SearchIcon = <svg width='24px' height='24px' viewBox='0 0 12 12'>
  46. <g strokeWidth='1.5' fill='none'>
  47. <path d='M11.29 11.71l-4-4' />
  48. <circle cx='5' cy='5' r='4' />
  49. </g>
  50. </svg>;
  51. export function SearchIconSvg() { return _SearchIcon; }
  52. // const _ExchangeIcon = <svg width='24px' height='24px' viewBox='0 0 24 24'>
  53. // <g>
  54. // <path d="m 15.684325,4 -1.41,1.41 5.58,5.59 H 7.6843275 v 2 H 19.854325 l -5.59,5.58 1.42,1.42 8,-8 z" />
  55. // <path d="m 8.3156725,20 1.41,-1.41 -5.58,-5.59 H 16.315675 v -2 H 4.1456725 l 5.59,-5.58 -1.42,-1.42 -8,8 z" />
  56. // </g>
  57. // </svg>;
  58. // export function ExchangeIconSvg() { return _ExchangeIcon; }
  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 pdbId: Set<string> = new Set();
  88. const residueIds: ResidueSelection[] = [];
  89. const exchanges: Exchange[] = [];
  90. const loci = this.plugin.managers.structure.selection.additionsHistory;
  91. let structure;
  92. for (let i = 0; i < Math.min(MAX_MOTIF_SIZE, loci.length); i++) {
  93. const l = loci[i];
  94. structure = l.loci.structure;
  95. pdbId.add(structure.model.entry);
  96. // only first element and only first index will be considered (ignoring multiple residues)
  97. const e = l.loci.elements[0];
  98. StructureElement.Location.set(location, structure, e.unit, e.unit.elements[OrderedSet.getAt(e.indices, 0)]);
  99. // handle pure residue-info
  100. const struct_oper_list_ids = StructureProperties.unit.pdbx_struct_oper_list_ids(location);
  101. const residueId = {
  102. label_asym_id: StructureProperties.chain.label_asym_id(location),
  103. // can be empty array if model is selected
  104. struct_oper_id: struct_oper_list_ids?.length ? struct_oper_list_ids.join('x') : '1',
  105. label_seq_id: StructureProperties.residue.label_seq_id(location)
  106. };
  107. residueIds.push(residueId);
  108. // handle potential exchanges - can be empty if deselected by users
  109. const residueMapEntry = this.state.residueMap.get(l)!;
  110. if (residueMapEntry.exchanges?.size > 0) {
  111. exchanges.push({ residue_id: residueId, allowed: Array.from(residueMapEntry.exchanges.values()) });
  112. }
  113. }
  114. if (pdbId.size > 1) {
  115. this.plugin.log.warn('Motifs can only be extracted from a single model!');
  116. return;
  117. }
  118. if (residueIds.length > MAX_MOTIF_SIZE) {
  119. this.plugin.log.warn(`Maximum motif size is ${MAX_MOTIF_SIZE} residues!`);
  120. return;
  121. }
  122. if (residueIds.filter(v => v.label_seq_id === 0).length > 0) {
  123. this.plugin.log.warn('Selections may only contain polymeric entities!');
  124. return;
  125. }
  126. const query = {
  127. type: 'terminal',
  128. service: 'strucmotif',
  129. parameters: {
  130. value: {
  131. data: pdbId.values().next().value as string,
  132. residue_ids: residueIds.sort((a, b) => this.sortResidueIds(a, b))
  133. },
  134. score_cutoff: 0,
  135. exchanges: exchanges
  136. }
  137. };
  138. console.log(query);
  139. const url = ADVANCED_SEARCH_URL + encodeURIComponent(JSON.stringify(query)) + RETURN_TYPE;
  140. console.log(url);
  141. window.open(url, '_blank');
  142. }
  143. sortResidueIds(a: ResidueSelection, b: ResidueSelection): number {
  144. if (a.label_asym_id !== b.label_asym_id) {
  145. return a.label_asym_id.localeCompare(b.label_asym_id);
  146. } else if (a.struct_oper_id !== b.struct_oper_id) {
  147. return a.struct_oper_id.localeCompare(b.struct_oper_id);
  148. } else {
  149. return a.label_seq_id < b.label_seq_id ? -1 : a.label_seq_id > b.label_seq_id ? 1 : 0;
  150. }
  151. }
  152. get actions(): ActionMenu.Items {
  153. const history = this.selection.additionsHistory;
  154. return [
  155. {
  156. kind: 'item',
  157. label: `Submit Search ${history.length < MIN_MOTIF_SIZE ? ' (' + MIN_MOTIF_SIZE + ' selections required)' : ''}`,
  158. value: this.submitSearch,
  159. disabled: history.length < MIN_MOTIF_SIZE
  160. },
  161. ];
  162. }
  163. selectAction: ActionMenu.OnSelect = item => {
  164. if (!item) return;
  165. (item?.value as any)();
  166. }
  167. toggleExchanges = (idx: number) => this.setState({ action: (this.state.action === idx ? void 0 : idx) as ExchangeState });
  168. highlight(loci: StructureElement.Loci) {
  169. this.plugin.managers.interactivity.lociHighlights.highlightOnly({ loci }, false);
  170. }
  171. moveHistory(e: Residue, direction: 'up' | 'down') {
  172. this.setState({ action: void 0 });
  173. this.plugin.managers.structure.selection.modifyHistory(e.entry, direction, MAX_MOTIF_SIZE);
  174. this.updateResidues();
  175. }
  176. modifyHistory(e: Residue, a: 'remove') {
  177. this.setState({ action: void 0 });
  178. this.plugin.managers.structure.selection.modifyHistory(e.entry, a);
  179. this.updateResidues();
  180. }
  181. updateResidues() {
  182. const newResidueMap = new Map<StructureSelectionHistoryEntry, Residue>();
  183. this.selection.additionsHistory.forEach(entry => {
  184. newResidueMap.set(entry, this.state.residueMap.get(entry)!);
  185. });
  186. this.setState({ residueMap: newResidueMap });
  187. }
  188. focusLoci(loci: StructureElement.Loci) {
  189. this.plugin.managers.camera.focusLoci(loci);
  190. }
  191. historyEntry(e: Residue, idx: number) {
  192. const history = this.plugin.managers.structure.selection.additionsHistory;
  193. return <div key={e.entry.id}>
  194. <div className='msp-flex-row'>
  195. <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}>
  196. {idx}. <span dangerouslySetInnerHTML={{ __html: e.entry.label }} />
  197. </Button>
  198. <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 }} />
  199. {history.length > 1 && <IconButton svg={ArrowUpwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'up')} flex='20px' title={'Move up'} />}
  200. {history.length > 1 && <IconButton svg={ArrowDownwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'down')} flex='20px' title={'Move down'} />}
  201. <IconButton svg={DeleteOutlinedSvg} small={true} className='msp-form-control' onClick={() => this.modifyHistory(e, 'remove')} flex title={'Remove'} />
  202. </div>
  203. { this.state.action === idx && <ExchangesControl handler={e} /> }
  204. </div>;
  205. }
  206. add() {
  207. const history = this.plugin.managers.structure.selection.additionsHistory;
  208. const entries: JSX.Element[] = [];
  209. for (let i = 0, _i = Math.min(history.length, 10); i < _i; i++) {
  210. let residue: Residue;
  211. if (this.state.residueMap.has(history[i])) {
  212. residue = this.state.residueMap.get(history[i])!;
  213. } else {
  214. residue = new Residue(history[i], this);
  215. this.state.residueMap.set(history[i], residue);
  216. }
  217. entries.push(this.historyEntry(residue, i + 1));
  218. }
  219. return <>
  220. <ActionMenu items={this.actions} onSelect={this.selectAction} />
  221. {entries.length > 0 && <div className='msp-control-offset'>
  222. {entries}
  223. </div>}
  224. {entries.length === 0 && <div className='msp-control-offset msp-help-text'>
  225. <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections (toggle <ToggleSelectionModeButton inline /> mode)</div>
  226. </div>}
  227. </>;
  228. }
  229. render() {
  230. return <>
  231. {this.add()}
  232. </>;
  233. }
  234. }
  235. export class Residue {
  236. readonly exchanges: Set<string>;
  237. constructor(readonly entry: StructureSelectionHistoryEntry, readonly parent: SubmitControls) {
  238. this.exchanges = new Set<string>();
  239. // by default: explicitly 'activate' original residue type
  240. const structure = entry.loci.structure;
  241. const e = entry.loci.elements[0];
  242. StructureElement.Location.set(location, structure, e.unit, e.unit.elements[OrderedSet.getAt(e.indices, 0)]);
  243. this.exchanges.add(StructureProperties.atom.label_comp_id(location));
  244. }
  245. toggleExchange(val: string): void {
  246. if (this.hasExchange(val)) {
  247. this.exchanges.delete(val);
  248. } else {
  249. this.exchanges.add(val);
  250. }
  251. // this will update state of parent component
  252. this.parent.forceUpdate();
  253. }
  254. hasExchange(val: string): boolean {
  255. return this.exchanges.has(val);
  256. }
  257. }