measurements.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. /**
  2. * Copyright (c) 2020-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author Alexander Rose <alexander.rose@weirdbyte.de>
  5. * @author David Sehnal <david.sehnal@gmail.com>
  6. */
  7. import * as React from 'react';
  8. import { Loci } from '../../mol-model/loci';
  9. import { StructureElement } from '../../mol-model/structure';
  10. import { StructureMeasurementCell, StructureMeasurementOptions, StructureMeasurementParams } from '../../mol-plugin-state/manager/structure/measurement';
  11. import { StructureSelectionHistoryEntry } from '../../mol-plugin-state/manager/structure/selection';
  12. import { PluginCommands } from '../../mol-plugin/commands';
  13. import { AngleData } from '../../mol-repr/shape/loci/angle';
  14. import { DihedralData } from '../../mol-repr/shape/loci/dihedral';
  15. import { DistanceData } from '../../mol-repr/shape/loci/distance';
  16. import { LabelData } from '../../mol-repr/shape/loci/label';
  17. import { OrientationData } from '../../mol-repr/shape/loci/orientation';
  18. import { angleLabel, dihedralLabel, distanceLabel, lociLabel, structureElementLociLabelMany } from '../../mol-theme/label';
  19. import { FiniteArray } from '../../mol-util/type-helpers';
  20. import { CollapsableControls, PurePluginUIComponent } from '../base';
  21. import { ActionMenu } from '../controls/action-menu';
  22. import { Button, ExpandGroup, IconButton, ToggleButton } from '../controls/common';
  23. import { AddSvg, ArrowDownwardSvg, ArrowUpwardSvg, DeleteOutlinedSvg, HelpOutlineSvg, Icon, MoreHorizSvg, PencilRulerSvg, SetSvg, TuneSvg, VisibilityOffOutlinedSvg, VisibilityOutlinedSvg } from '../controls/icons';
  24. import { ParameterControls } from '../controls/parameters';
  25. import { UpdateTransformControl } from '../state/update-transform';
  26. import { ToggleSelectionModeButton } from './selection';
  27. // TODO details, options (e.g. change text for labels)
  28. export class StructureMeasurementsControls extends CollapsableControls {
  29. defaultState() {
  30. return {
  31. isCollapsed: false,
  32. header: 'Measurements',
  33. brand: { accent: 'gray' as const, svg: PencilRulerSvg }
  34. };
  35. }
  36. renderControls() {
  37. return <>
  38. <MeasurementControls />
  39. <MeasurementList />
  40. </>;
  41. }
  42. }
  43. export class MeasurementList extends PurePluginUIComponent {
  44. componentDidMount() {
  45. this.subscribe(this.plugin.managers.structure.measurement.behaviors.state, () => {
  46. this.forceUpdate();
  47. });
  48. }
  49. renderGroup(cells: ReadonlyArray<StructureMeasurementCell>, header: string) {
  50. const group: JSX.Element[] = [];
  51. for (const cell of cells) {
  52. if (cell.obj) group.push(<MeasurementEntry key={cell.obj.id} cell={cell} />);
  53. }
  54. return group.length ? <ExpandGroup header={header} initiallyExpanded={true}>{group}</ExpandGroup> : null;
  55. }
  56. render() {
  57. const measurements = this.plugin.managers.structure.measurement.state;
  58. return <div style={{ marginTop: '6px' }}>
  59. {this.renderGroup(measurements.labels, 'Labels')}
  60. {this.renderGroup(measurements.distances, 'Distances')}
  61. {this.renderGroup(measurements.angles, 'Angles')}
  62. {this.renderGroup(measurements.dihedrals, 'Dihedrals')}
  63. {this.renderGroup(measurements.orientations, 'Orientations')}
  64. {this.renderGroup(measurements.planes, 'Planes')}
  65. </div>;
  66. }
  67. }
  68. export class MeasurementControls extends PurePluginUIComponent<{}, { isBusy: boolean, action?: 'add' | 'options' }> {
  69. state = { isBusy: false, action: void 0 as 'add' | 'options' | undefined };
  70. componentDidMount() {
  71. this.subscribe(this.selection.events.additionsHistoryUpdated, () => {
  72. this.forceUpdate();
  73. this.updateOrderLabels();
  74. });
  75. this.subscribe(this.plugin.behaviors.state.isBusy, v => {
  76. this.setState({ isBusy: v });
  77. });
  78. }
  79. componentWillUnmount() {
  80. this.clearOrderLabels();
  81. super.componentWillUnmount();
  82. }
  83. componentDidUpdate(prevProps: {}, prevState: { isBusy: boolean, action?: 'add' | 'options' }) {
  84. if (this.state.action !== prevState.action)
  85. this.updateOrderLabels();
  86. }
  87. clearOrderLabels() {
  88. this.plugin.managers.structure.measurement.addOrderLabels([]);
  89. }
  90. updateOrderLabels() {
  91. if (this.state.action !== 'add') {
  92. this.clearOrderLabels();
  93. return;
  94. }
  95. const locis = [];
  96. const history = this.selection.additionsHistory;
  97. for (let idx = 0; idx < history.length && idx < 4; idx++)
  98. locis.push(history[idx].loci);
  99. this.plugin.managers.structure.measurement.addOrderLabels(locis);
  100. }
  101. get selection() {
  102. return this.plugin.managers.structure.selection;
  103. }
  104. measureDistance = () => {
  105. const loci = this.plugin.managers.structure.selection.additionsHistory;
  106. this.plugin.managers.structure.measurement.addDistance(loci[0].loci, loci[1].loci);
  107. };
  108. measureAngle = () => {
  109. const loci = this.plugin.managers.structure.selection.additionsHistory;
  110. this.plugin.managers.structure.measurement.addAngle(loci[0].loci, loci[1].loci, loci[2].loci);
  111. };
  112. measureDihedral = () => {
  113. const loci = this.plugin.managers.structure.selection.additionsHistory;
  114. this.plugin.managers.structure.measurement.addDihedral(loci[0].loci, loci[1].loci, loci[2].loci, loci[3].loci);
  115. };
  116. addLabel = () => {
  117. const loci = this.plugin.managers.structure.selection.additionsHistory;
  118. this.plugin.managers.structure.measurement.addLabel(loci[0].loci);
  119. };
  120. addOrientation = () => {
  121. const locis: StructureElement.Loci[] = [];
  122. this.plugin.managers.structure.selection.entries.forEach(v => {
  123. locis.push(v.selection);
  124. });
  125. this.plugin.managers.structure.measurement.addOrientation(locis);
  126. };
  127. addPlane = () => {
  128. const locis: StructureElement.Loci[] = [];
  129. this.plugin.managers.structure.selection.entries.forEach(v => {
  130. locis.push(v.selection);
  131. });
  132. this.plugin.managers.structure.measurement.addPlane(locis);
  133. };
  134. get actions(): ActionMenu.Items {
  135. const history = this.selection.additionsHistory;
  136. const ret: ActionMenu.Item[] = [
  137. { kind: 'item', label: `Label ${history.length === 0 ? ' (1 selection item required)' : ' (1st selection item)'}`, value: this.addLabel, disabled: history.length === 0 },
  138. { kind: 'item', label: `Distance ${history.length < 2 ? ' (2 selection items required)' : ' (top 2 selection items)'}`, value: this.measureDistance, disabled: history.length < 2 },
  139. { kind: 'item', label: `Angle ${history.length < 3 ? ' (3 selection items required)' : ' (top 3 items)'}`, value: this.measureAngle, disabled: history.length < 3 },
  140. { kind: 'item', label: `Dihedral ${history.length < 4 ? ' (4 selection items required)' : ' (top 4 selection items)'}`, value: this.measureDihedral, disabled: history.length < 4 },
  141. { kind: 'item', label: `Orientation ${history.length === 0 ? ' (selection required)' : ' (current selection)'}`, value: this.addOrientation, disabled: history.length === 0 },
  142. { kind: 'item', label: `Plane ${history.length === 0 ? ' (selection required)' : ' (current selection)'}`, value: this.addPlane, disabled: history.length === 0 },
  143. ];
  144. return ret;
  145. }
  146. selectAction: ActionMenu.OnSelect = item => {
  147. this.toggleAdd();
  148. if (!item) return;
  149. (item?.value as any)();
  150. };
  151. toggleAdd = () => this.setState({ action: this.state.action === 'add' ? void 0 : 'add' });
  152. toggleOptions = () => this.setState({ action: this.state.action === 'options' ? void 0 : 'options' });
  153. highlight(loci: StructureElement.Loci) {
  154. this.plugin.managers.interactivity.lociHighlights.highlightOnly({ loci }, false);
  155. }
  156. moveHistory(e: StructureSelectionHistoryEntry, direction: 'up' | 'down') {
  157. this.plugin.managers.structure.selection.modifyHistory(e, direction, 4);
  158. }
  159. focusLoci(loci: StructureElement.Loci) {
  160. this.plugin.managers.camera.focusLoci(loci);
  161. }
  162. historyEntry(e: StructureSelectionHistoryEntry, idx: number) {
  163. const history = this.plugin.managers.structure.selection.additionsHistory;
  164. return <div className='msp-flex-row' key={e.id} onMouseEnter={() => this.highlight(e.loci)} onMouseLeave={() => this.plugin.managers.interactivity.lociHighlights.clearHighlights()}>
  165. <Button noOverflow title='Click to focus. Hover to highlight.' onClick={() => this.focusLoci(e.loci)} style={{ width: 'auto', textAlign: 'left' }}>
  166. {idx}. <span dangerouslySetInnerHTML={{ __html: e.label }} />
  167. </Button>
  168. {history.length > 1 && <IconButton svg={ArrowUpwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'up')} flex='20px' title={'Move up'} />}
  169. {history.length > 1 && <IconButton svg={ArrowDownwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'down')} flex='20px' title={'Move down'} />}
  170. <IconButton svg={DeleteOutlinedSvg} small={true} className='msp-form-control' onClick={() => this.plugin.managers.structure.selection.modifyHistory(e, 'remove')} flex title={'Remove'} />
  171. </div>;
  172. }
  173. add() {
  174. const history = this.plugin.managers.structure.selection.additionsHistory;
  175. const entries: JSX.Element[] = [];
  176. for (let i = 0, _i = Math.min(history.length, 4); i < _i; i++) {
  177. entries.push(this.historyEntry(history[i], i + 1));
  178. }
  179. return <>
  180. <ActionMenu items={this.actions} onSelect={this.selectAction} />
  181. {entries.length > 0 && <div className='msp-control-offset'>
  182. {entries}
  183. </div>}
  184. {entries.length === 0 && <div className='msp-control-offset msp-help-text'>
  185. <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections (toggle <ToggleSelectionModeButton inline /> mode)</div>
  186. </div>}
  187. </>;
  188. }
  189. render() {
  190. return <>
  191. <div className='msp-flex-row'>
  192. <ToggleButton icon={AddSvg} label='Add' toggle={this.toggleAdd} isSelected={this.state.action === 'add'} disabled={this.state.isBusy} className='msp-btn-apply-simple' />
  193. <ToggleButton icon={TuneSvg} label='' title='Options' toggle={this.toggleOptions} isSelected={this.state.action === 'options'} disabled={this.state.isBusy} style={{ flex: '0 0 40px', padding: 0 }} />
  194. </div>
  195. {this.state.action === 'add' && this.add()}
  196. {this.state.action === 'options' && <MeasurementsOptions />}
  197. </>;
  198. }
  199. }
  200. class MeasurementsOptions extends PurePluginUIComponent<{}, { isDisabled: boolean }> {
  201. state = { isDisabled: false };
  202. componentDidMount() {
  203. this.subscribe(this.plugin.managers.structure.measurement.behaviors.state, () => {
  204. this.forceUpdate();
  205. });
  206. this.subscribe(this.plugin.behaviors.state.isBusy, v => {
  207. this.setState({ isDisabled: v });
  208. });
  209. }
  210. changed = (options: StructureMeasurementOptions) => {
  211. this.plugin.managers.structure.measurement.setOptions(options);
  212. };
  213. render() {
  214. const measurements = this.plugin.managers.structure.measurement.state;
  215. return <div className='msp-control-offset'>
  216. <ParameterControls params={StructureMeasurementParams} values={measurements.options} onChangeValues={this.changed} isDisabled={this.state.isDisabled} />
  217. </div>;
  218. }
  219. }
  220. class MeasurementEntry extends PurePluginUIComponent<{ cell: StructureMeasurementCell }, { showUpdate: boolean }> {
  221. state = { showUpdate: false };
  222. componentDidMount() {
  223. this.subscribe(this.plugin.state.events.cell.stateUpdated, e => {
  224. this.forceUpdate();
  225. });
  226. }
  227. get selections() {
  228. return this.props.cell.obj?.data.sourceData as Partial<DistanceData & AngleData & DihedralData & LabelData & OrientationData> | undefined;
  229. }
  230. delete = () => {
  231. PluginCommands.State.RemoveObject(this.plugin, { state: this.props.cell.parent!, ref: this.props.cell.transform.parent, removeParentGhosts: true });
  232. };
  233. toggleVisibility = (e: React.MouseEvent<HTMLElement>) => {
  234. e.preventDefault();
  235. PluginCommands.State.ToggleVisibility(this.plugin, { state: this.props.cell.parent!, ref: this.props.cell.transform.parent });
  236. e.currentTarget.blur();
  237. };
  238. highlight = () => {
  239. const selections = this.selections;
  240. if (!selections) return;
  241. this.plugin.managers.interactivity.lociHighlights.clearHighlights();
  242. for (const loci of this.lociArray) {
  243. this.plugin.managers.interactivity.lociHighlights.highlight({ loci }, false);
  244. }
  245. const reprLocis = this.props.cell.obj?.data.repr.getAllLoci();
  246. if (reprLocis) {
  247. for (const loci of reprLocis) {
  248. this.plugin.managers.interactivity.lociHighlights.highlight({ loci }, false);
  249. }
  250. }
  251. };
  252. clearHighlight = () => {
  253. this.plugin.managers.interactivity.lociHighlights.clearHighlights();
  254. };
  255. toggleUpdate = () => this.setState({ showUpdate: !this.state.showUpdate });
  256. focus = () => {
  257. const selections = this.selections;
  258. if (!selections) return;
  259. const sphere = Loci.getBundleBoundingSphere({ loci: this.lociArray });
  260. if (sphere) {
  261. this.plugin.managers.camera.focusSphere(sphere);
  262. }
  263. };
  264. private get lociArray(): FiniteArray<Loci> {
  265. const selections = this.selections;
  266. if (!selections) return [];
  267. if (selections.infos) return [selections.infos[0].loci];
  268. if (selections.pairs) return selections.pairs[0].loci;
  269. if (selections.triples) return selections.triples[0].loci;
  270. if (selections.quads) return selections.quads[0].loci;
  271. if (selections.locis) return selections.locis;
  272. return [];
  273. }
  274. get label() {
  275. const selections = this.selections;
  276. if (!selections) return '<empty>';
  277. if (selections.infos) return lociLabel(selections.infos[0].loci, { condensed: true });
  278. if (selections.pairs) return distanceLabel(selections.pairs[0], { condensed: true, unitLabel: this.plugin.managers.structure.measurement.state.options.distanceUnitLabel });
  279. if (selections.triples) return angleLabel(selections.triples[0], { condensed: true });
  280. if (selections.quads) return dihedralLabel(selections.quads[0], { condensed: true });
  281. if (selections.locis) return structureElementLociLabelMany(selections.locis, { countsOnly: true });
  282. return '<empty>';
  283. }
  284. get actions(): ActionMenu.Items {
  285. this.props.cell.sourceRef;
  286. return [ActionMenu.Item('Select This', () => this.plugin.managers.structure.selection.fromSelections(this.props.cell.sourceRef!), { icon: SetSvg })];
  287. }
  288. selectAction: ActionMenu.OnSelect = item => {
  289. if (!item) return;
  290. this.setState({ showUpdate: false });
  291. (item?.value as any)();
  292. };
  293. render() {
  294. const { cell } = this.props;
  295. const { obj } = cell;
  296. if (!obj) return null;
  297. return <>
  298. <div className='msp-flex-row' key={obj.id} onMouseEnter={this.highlight} onMouseLeave={this.clearHighlight}>
  299. <button className='msp-form-control msp-control-button-label msp-no-overflow' title='Click to focus. Hover to highlight.' onClick={this.focus} style={{ width: 'auto', textAlign: 'left' }}>
  300. <span dangerouslySetInnerHTML={{ __html: this.label }} />
  301. </button>
  302. <IconButton svg={cell.state.isHidden ? VisibilityOffOutlinedSvg : VisibilityOutlinedSvg} toggleState={false} small className='msp-form-control' onClick={this.toggleVisibility} flex title={cell.state.isHidden ? 'Show' : 'Hide'} />
  303. <IconButton svg={DeleteOutlinedSvg} small className='msp-form-control' onClick={this.delete} flex title='Delete' toggleState={false} />
  304. <IconButton svg={MoreHorizSvg} className='msp-form-control' onClick={this.toggleUpdate} flex title='Actions' toggleState={this.state.showUpdate} />
  305. </div>
  306. {this.state.showUpdate && cell.parent && <>
  307. <div className='msp-accent-offset'>
  308. <ActionMenu items={this.actions} onSelect={this.selectAction} noOffset />
  309. <ExpandGroup header='Options' noOffset>
  310. <UpdateTransformControl state={cell.parent} transform={cell.transform} customHeader='none' autoHideApply />
  311. </ExpandGroup>
  312. </div>
  313. </>}
  314. </>;
  315. }
  316. }