strucmotif.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  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, 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. // TODO use prod
  23. // const ADVANCED_SEARCH_URL = 'https://localhost:8080/search?request=';
  24. const ADVANCED_SEARCH_URL = 'https://strucmotif-dev.rcsb.org/search?request=';
  25. // TODO consider 2 as value
  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. // TODO nice svg - magnifying glass or something search-y
  46. const _SearchIcon = <svg width='24px' height='24px' viewBox='0 0 24 24'><path d='M8 5v14l11-7z' /></svg>;
  47. export function SearchIconSvg() { return _SearchIcon; }
  48. const location = StructureElement.Location.create(void 0);
  49. type ExchangeState = 'exchanges-0' | 'exchanges-1' | 'exchanges-2' | 'exchanges-3' | 'exchanges-4' | 'exchanges-5' | 'exchanges-6' | 'exchanges-7' | 'exchanges-8' | 'exchanges-9';
  50. type ResidueSelection = { label_asym_id: string, struct_oper_id: string, label_seq_id: number }
  51. /**
  52. * The inner component of strucmotif search that can be collapsed.
  53. */
  54. class SubmitControls extends PurePluginUIComponent<{}, { isBusy: boolean, residueMap: Map<StructureSelectionHistoryEntry, Residue>, action?: ExchangeState }> {
  55. state = {
  56. isBusy: false,
  57. // map between Mol* of selection entries and additional exchange state
  58. residueMap: new Map<StructureSelectionHistoryEntry, Residue>(),
  59. action: void 0 as ExchangeState | undefined
  60. };
  61. componentDidMount() {
  62. this.subscribe(this.selection.events.additionsHistoryUpdated, () => {
  63. this.forceUpdate();
  64. });
  65. this.subscribe(this.plugin.behaviors.state.isBusy, v => {
  66. this.setState({ isBusy: v });
  67. });
  68. }
  69. get selection() {
  70. return this.plugin.managers.structure.selection;
  71. }
  72. submitSearch = () => {
  73. const pdbId: Set<string> = new Set();
  74. const residueIds: ResidueSelection[] = [];
  75. const loci = this.plugin.managers.structure.selection.additionsHistory;
  76. let structure;
  77. for (let i = 0; i < Math.min(MAX_MOTIF_SIZE, loci.length); i++) {
  78. const l = loci[i];
  79. structure = l.loci.structure;
  80. pdbId.add(structure.model.entry);
  81. // only first element and only first index will be considered (ignoring multiple residues)
  82. const e = l.loci.elements[0];
  83. StructureElement.Location.set(location, structure, e.unit, e.unit.elements[OrderedSet.getAt(e.indices, 0)]);
  84. // handle pure residue-info
  85. const struct_oper_list_ids = StructureProperties.unit.pdbx_struct_oper_list_ids(location);
  86. residueIds.push({
  87. label_asym_id: StructureProperties.chain.label_asym_id(location),
  88. // can be empty array if model is selected
  89. struct_oper_id: struct_oper_list_ids?.length ? struct_oper_list_ids.join('x') : '1',
  90. label_seq_id: StructureProperties.residue.label_seq_id(location)
  91. });
  92. // handle potential exchanges
  93. console.log(this.state.residueMap);
  94. const residueMapEntry = this.state.residueMap.get(l);
  95. if (residueMapEntry?.exchanges) {
  96. console.log(residueMapEntry.exchanges);
  97. }
  98. }
  99. if (pdbId.size > 1) {
  100. this.plugin.log.warn('Motifs can only be extracted from a single model!');
  101. return;
  102. }
  103. if (residueIds.length > MAX_MOTIF_SIZE) {
  104. this.plugin.log.warn(`Maximum motif size is ${MAX_MOTIF_SIZE} residues!`);
  105. return;
  106. }
  107. if (residueIds.filter(v => v.label_seq_id === 0).length > 0) {
  108. this.plugin.log.warn('Selections may only contain polymeric entities!');
  109. return;
  110. }
  111. const query = {
  112. query: {
  113. type: 'group',
  114. logical_operator: 'and',
  115. nodes: [{
  116. type: 'terminal',
  117. service: 'strucmotif',
  118. parameters: {
  119. value: {
  120. data: pdbId.values().next().value as string,
  121. residue_ids: residueIds
  122. },
  123. score_cutoff: 5,
  124. // TODO add UI to define exchanges
  125. exchanges: []
  126. },
  127. label: 'strucmotif',
  128. node_id: 0
  129. }],
  130. label: 'query-builder'
  131. },
  132. return_type: 'assembly',
  133. request_options: {
  134. pager: {
  135. start: 0,
  136. rows: 100
  137. },
  138. scoring_strategy: 'combined',
  139. sort: [{
  140. sort_by: 'score',
  141. direction: 'desc'
  142. }]
  143. },
  144. 'request_info': {
  145. 'src': 'ui'
  146. }
  147. };
  148. // TODO figure out if Mol* can compose sierra/BioJava operator
  149. window.open(ADVANCED_SEARCH_URL + encodeURIComponent(JSON.stringify(query)), '_blank');
  150. }
  151. get actions(): ActionMenu.Items {
  152. const history = this.selection.additionsHistory;
  153. return [
  154. {
  155. kind: 'item',
  156. label: `Submit Search ${history.length < MIN_MOTIF_SIZE ? ' (' + MIN_MOTIF_SIZE + ' selections required)' : ''}`,
  157. value: this.submitSearch,
  158. disabled: history.length < MIN_MOTIF_SIZE
  159. },
  160. ];
  161. }
  162. selectAction: ActionMenu.OnSelect = item => {
  163. if (!item) return;
  164. (item?.value as any)();
  165. }
  166. toggleExchanges = (idx: number) => this.setState({ action: this.state.action === `exchanges-${idx}` ? void 0 : `exchanges-${idx}` as ExchangeState });
  167. highlight(loci: StructureElement.Loci) {
  168. this.plugin.managers.interactivity.lociHighlights.highlightOnly({ loci }, false);
  169. }
  170. moveHistory(e: Residue, direction: 'up' | 'down') {
  171. this.setState({ action: void 0 });
  172. this.plugin.managers.structure.selection.modifyHistory(e.entry, direction, MAX_MOTIF_SIZE);
  173. this.updateResidues();
  174. }
  175. modifyHistory(e: Residue, a: 'remove', idx: number) {
  176. this.setState({ action: void 0 });
  177. this.plugin.managers.structure.selection.modifyHistory(e.entry, a);
  178. this.updateResidues();
  179. }
  180. updateResidues() {
  181. const newResidueMap = new Map<StructureSelectionHistoryEntry, Residue>();
  182. this.selection.additionsHistory.forEach(entry => {
  183. newResidueMap.set(entry, this.state.residueMap.get(entry)!);
  184. });
  185. this.setState({ residueMap: newResidueMap });
  186. }
  187. updateExchanges = (key: StructureSelectionHistoryEntry, ex: Set<string>) => {
  188. this.setState({
  189. });
  190. }
  191. focusLoci(loci: StructureElement.Loci) {
  192. this.plugin.managers.camera.focusLoci(loci);
  193. }
  194. historyEntry(e: Residue, idx: number) {
  195. const history = this.plugin.managers.structure.selection.additionsHistory;
  196. return <div key={e.entry.id}>
  197. <div className='msp-flex-row'>
  198. <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}>
  199. {idx}. <span dangerouslySetInnerHTML={{ __html: e.entry.label }} />
  200. </Button>
  201. <ToggleButton icon={TuneSvg} className='msp-form-control' title='Define Exchanges' toggle={() => this.toggleExchanges(idx)} isSelected={this.state.action === `exchanges-${idx}`} disabled={this.state.isBusy} style={{ flex: '0 0 40px', padding: 0 }} />
  202. {history.length > 1 && <IconButton svg={ArrowUpwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'up')} flex='20px' title={'Move up'} />}
  203. {history.length > 1 && <IconButton svg={ArrowDownwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'down')} flex='20px' title={'Move down'} />}
  204. <IconButton svg={DeleteOutlinedSvg} small={true} className='msp-form-control' onClick={() => this.modifyHistory(e, 'remove', idx)} flex title={'Remove'} />
  205. </div>
  206. { this.state.action === `exchanges-${idx}` && <ExchangesControl exchanges={e.exchanges} /> }
  207. </div>;
  208. }
  209. add() {
  210. const history = this.plugin.managers.structure.selection.additionsHistory;
  211. const entries: JSX.Element[] = [];
  212. for (let i = 0, _i = Math.min(history.length, 10); i < _i; i++) {
  213. entries.push(this.historyEntry(new Residue(history[i]), i + 1));
  214. }
  215. return <>
  216. <ActionMenu items={this.actions} onSelect={this.selectAction} />
  217. {entries.length > 0 && <div className='msp-control-offset'>
  218. {entries}
  219. </div>}
  220. {entries.length === 0 && <div className='msp-control-offset msp-help-text'>
  221. <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections (toggle <ToggleSelectionModeButton inline /> mode)</div>
  222. </div>}
  223. </>;
  224. }
  225. render() {
  226. return <>
  227. {this.add()}
  228. </>;
  229. }
  230. }
  231. export class Residue {
  232. readonly exchanges: Set<string>;
  233. constructor(readonly entry: StructureSelectionHistoryEntry) {
  234. this.exchanges = new Set<string>();
  235. }
  236. toggleExchange(val: string): void {
  237. if (this.hasExchange(val)) {
  238. this.exchanges.delete(val);
  239. } else {
  240. this.exchanges.add(val);
  241. }
  242. }
  243. hasExchange(val: string): boolean {
  244. return this.exchanges.has(val);
  245. }
  246. }