Browse Source

Merge branch 'master' into saguaro-molstar

# Conflicts:
#	package-lock.json
bioinsilico 4 years ago
parent
commit
21c1ea80d2
12 changed files with 863 additions and 270 deletions
  1. 2 0
      .eslintignore
  2. 18 0
      .github/workflows/lint.yml
  3. 19 0
      CHANGELOG.md
  4. 2 0
      README.md
  5. 408 233
      package-lock.json
  6. 19 19
      package.json
  7. 3 3
      src/index.ts
  8. 38 15
      src/viewer/index.ts
  9. 1 0
      src/viewer/types.ts
  10. 2 0
      src/viewer/ui/controls.tsx
  11. 67 0
      src/viewer/ui/exchanges.tsx
  12. 284 0
      src/viewer/ui/strucmotif.tsx

+ 2 - 0
.eslintignore

@@ -0,0 +1,2 @@
+node_modules/*
+build/*

+ 18 - 0
.github/workflows/lint.yml

@@ -0,0 +1,18 @@
+on:
+  push:
+  pull_request:
+
+jobs:
+  eslint:
+    name: eslint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v1
+      - name: install node v12
+        uses: actions/setup-node@v1
+        with:
+          node-version: 12
+      - name: yarn install
+        run: yarn install
+      - name: eslint
+        uses: icrawl/action-eslint@v1

+ 19 - 0
CHANGELOG.md

@@ -0,0 +1,19 @@
+# RCSB Mol* Changelog
+
+[Semantic Versioning](https://semver.org/)
+
+## [1.1.0] - 2021-02-08
+### General
+- structural motif search wizard
+
+## [1.0.35] - 2021-02-08
+### General
+- Mol* 1.3.0
+
+## [1.0.34] - 2021-02-05
+### General
+- bumps dependencies
+
+## [1.0.33] - 2021-02-02
+### General
+- let's call this the initial release

+ 2 - 0
README.md

@@ -1,3 +1,5 @@
+[![npm version](https://badge.fury.io/js/%40rcsb%2Frcsb-molstar.svg)](https://www.npmjs.com/package/@rcsb/rcsb-molstar)
+[![Changelog](https://img.shields.io/badge/changelog--lightgrey.svg?style=flat)](https://github.com/rcsb/rcsb-molstar/blob/master/CHANGELOG.md)
 [![License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](./LICENSE)
 
 RCSB PDB implementation of [Mol* (/'mol-star/)](https://github.com/molstar/molstar).

File diff suppressed because it is too large
+ 408 - 233
package-lock.json


+ 19 - 19
package.json

@@ -1,6 +1,6 @@
 {
     "name": "@rcsb/rcsb-molstar",
-    "version": "1.0.33",
+    "version": "1.1.0",
     "description": "RCSB PDB apps and props based on Mol*.",
     "homepage": "https://github.com/rcsb/rcsb-molstar#readme",
     "repository": {
@@ -37,27 +37,27 @@
     "author": "RCSB PDB and Mol* Contributors",
     "license": "MIT",
     "devDependencies": {
-        "@types/react": "^16.9.49",
-        "@types/react-dom": "^16.9.8",
-        "@typescript-eslint/eslint-plugin": "^3.10.1",
-        "@typescript-eslint/parser": "^3.10.1",
+        "@types/react": "^17.0.0",
+        "@types/react-dom": "^17.0.0",
+        "@typescript-eslint/eslint-plugin": "^4.9.1",
+        "@typescript-eslint/parser": "^4.9.1",
         "concurrently": "^5.3.0",
-        "cpx2": "^2.0.0",
-        "css-loader": "^3.6.0",
-        "eslint": "^7.8.1",
+        "cpx2": "^3.0.0",
+        "css-loader": "^5.0.1",
+        "eslint": "^7.15.0",
         "extra-watch-webpack-plugin": "^1.0.3",
-        "file-loader": "^6.1.0",
-        "mini-css-extract-plugin": "^0.9.0",
-        "molstar": "^1.2.3",
-        "node-sass": "^4.14.1",
-        "raw-loader": "^4.0.1",
-        "react": "^16.13.1",
-        "react-dom": "^16.13.1",
+        "file-loader": "^6.2.0",
+        "mini-css-extract-plugin": "^1.3.2",
+        "molstar": "^1.3.0",
+        "node-sass": "^5.0.0",
+        "raw-loader": "^4.0.2",
+        "react": "^17.0.1",
+        "react-dom": "^17.0.1",
         "rxjs": "^6.6.3",
-        "sass-loader": "^8.0.2",
-        "style-loader": "^1.2.1",
-        "tslib": "^2.0.1",
-        "typescript": "4.0.2",
+        "sass-loader": "^10.1.0",
+        "style-loader": "^2.0.0",
+        "tslib": "^2.0.3",
+        "typescript": "4.1.2",
         "webpack": "^4.44.1",
         "webpack-cli": "^3.3.12"
     },

+ 3 - 3
src/index.ts

@@ -4,8 +4,8 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import * as path from 'path'
+import * as path from 'path';
 
 export const getRcsbMolstarViewerAbsoluteFSPath = function () {
-    return path.resolve(path.join(__dirname, '..', 'dist', 'viewer'))
-}
+    return path.resolve(path.join(__dirname, '..', 'dist', 'viewer'));
+};

+ 38 - 15
src/viewer/index.ts

@@ -141,6 +141,7 @@ export class Viewer {
             modelLoader: new ModelLoader(this.plugin),
             collapsed: new BehaviorSubject<CollapsedState>({
                 selection: true,
+                strucmotifSubmit: true,
                 measurements: true,
                 superposition: true,
                 component: false,
@@ -154,7 +155,7 @@ export class Viewer {
         // TODO Check why this.plugin.canvas3d can be null
         // this.plugin.canvas3d can be null. The value is not assigned until React Plugin component is mounted
         // Next wait Promise guarantees that its value is defined
-        const wait: Promise<null> = new Promise<null>((resolve, reject)=>{
+        const wait: Promise<void> = new Promise<void>((resolve, reject)=>{
             const recursive: () => void = () => {
                 if(this.plugin.canvas3d != null){
                     resolve();
@@ -230,6 +231,21 @@ export class Viewer {
         return this.plugin;
     }
 
+    public setFocus(modelId: string, asymId: string, begin: number, end: number): void;
+    public setFocus(...args: any[]): void{
+        if(args.length === 4)
+            this.setFocusFromRange(args[0], args[1], args[2], args[3]);
+    }
+    private setFocusFromRange(modelId: string, asymId: string, begin: number, end: number): void{
+        const loci: Loci | undefined = getLociFromRange(this.plugin, modelId, asymId, begin, end);
+        if(loci == null)
+            return;
+        this.plugin.managers.structure.focus.setFromLoci(loci);
+    }
+    public clearFocus(): void {
+        this.plugin.managers.structure.focus.clear();
+    }
+
     public select(selection: Array<{modelId: string; asymId: string; position: number;}>, mode: 'select'|'hover', modifier: 'add'|'set'): void;
     public select(modelId: string, asymId: string, position: number, mode: 'select'|'hover', modifier: 'add'|'set'): void;
     public select(modelId: string, asymId: string, begin: number, end: number, mode: 'select'|'hover', modifier: 'add'|'set'): void;
@@ -247,21 +263,14 @@ export class Viewer {
         }
     }
     private selectSegment(modelId: string, asymId: string, begin: number, end: number, mode: 'select'|'hover', modifier: 'add'|'set'): void {
-        const data: Structure | undefined = getStructureWithModelId(this.plugin.managers.structure.hierarchy.current.structures, modelId);
-        if (data == null) return;
-        const seq_id: Array<number> = new Array<number>();
-        for(let n = begin; n <= end; n++){
-            seq_id.push(n);
-        }
-        const sel: StructureSelection = Script.getStructureSelection(Q => Q.struct.generator.atomGroups({
-            'chain-test': Q.core.rel.eq([asymId, MolScriptBuilder.ammp('label_asym_id')]),
-            'residue-test': Q.core.set.has([MolScriptBuilder.set(...SetUtils.toArray(new Set(seq_id))), MolScriptBuilder.ammp('label_seq_id')])
-        }), data);
-        const loci: Loci = StructureSelection.toLociWithSourceUnits(sel);
-        if(mode == null || mode === 'select')
+        const loci: Loci | undefined = getLociFromRange(this.plugin, modelId, asymId, begin, end);
+        if(loci == null)
+            return;
+        if(mode == null || mode === 'select') {
             this.plugin.managers.structure.selection.fromLoci(modifier, loci);
-        else if(mode === 'hover')
-            this.plugin.managers.interactivity.lociHighlights.highlight({ loci });
+        }else if(mode === 'hover') {
+            this.plugin.managers.interactivity.lociHighlights.highlight({loci});
+        }
     }
     public clearSelection(mode: 'select'|'hover', options?: {modelId: string; labelAsymId: string;}): void {
         if(mode == null || mode === 'select') {
@@ -368,4 +377,18 @@ function getStructureWithModelId(structures: StructureRef[], modelId: string): S
     const structureRef: StructureRef | undefined = getStructureRefWithModelId(structures, modelId);
     if(structureRef != null)
         return structureRef.cell?.obj?.data;
+}
+
+function getLociFromRange(plugin: PluginContext, modelId: string, asymId: string, begin: number, end: number): Loci | undefined {
+    const data: Structure | undefined = getStructureWithModelId(plugin.managers.structure.hierarchy.current.structures, modelId);
+    if (data == null) return;
+    const seq_id: Array<number> = new Array<number>();
+    for (let n = begin; n <= end; n++) {
+        seq_id.push(n);
+    }
+    const sel: StructureSelection = Script.getStructureSelection(Q => Q.struct.generator.atomGroups({
+        'chain-test': Q.core.rel.eq([asymId, MolScriptBuilder.ammp('label_asym_id')]),
+        'residue-test': Q.core.set.has([MolScriptBuilder.set(...SetUtils.toArray(new Set(seq_id))), MolScriptBuilder.ammp('label_seq_id')])
+    }), data);
+    return StructureSelection.toLociWithSourceUnits(sel);
 }

+ 1 - 0
src/viewer/types.ts

@@ -34,6 +34,7 @@ export interface ParseParams extends SharedParams {
 
 export type CollapsedState = {
     selection: boolean
+    strucmotifSubmit: boolean
     measurements: boolean
     superposition: boolean
     component: boolean

+ 2 - 0
src/viewer/ui/controls.tsx

@@ -15,6 +15,7 @@ import { StructureSuperpositionControls } from 'molstar/lib/mol-plugin-ui/struct
 import { StructureComponentControls } from 'molstar/lib/mol-plugin-ui/structure/components';
 import { VolumeStreamingControls } from 'molstar/lib/mol-plugin-ui/structure/volume';
 import { SessionControls } from './session';
+import {StrucmotifSubmitControls} from './strucmotif';
 
 export class StructureTools extends PluginUIComponent {
     get customState() {
@@ -31,6 +32,7 @@ export class StructureTools extends PluginUIComponent {
             <StructureSourceControls />
             <StructureMeasurementsControls initiallyCollapsed={collapsed.measurements} />
             <StructureSuperpositionControls initiallyCollapsed={collapsed.superposition} />
+            <StrucmotifSubmitControls initiallyCollapsed={collapsed.strucmotifSubmit} />
             <StructureComponentControls initiallyCollapsed={collapsed.component} />
             <VolumeStreamingControls header='Density' initiallyCollapsed={collapsed.volume} />
 

+ 67 - 0
src/viewer/ui/exchanges.tsx

@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
+ */
+import * as React from 'react';
+import {Button} from 'molstar/lib/mol-plugin-ui/controls/common';
+import {Residue} from './strucmotif';
+
+export const DefaultExchanges = [
+    ['ALA', 'Alanine'],
+    ['CYS', 'Cysteine'],
+    ['ASP', 'Aspartic Acid'],
+    ['GLU', 'Glutamic Acid'],
+    ['PHE', 'Phenylalanine'],
+    ['GLY', 'Glycine'],
+    ['HIS', 'Histidine'],
+    ['ILE', 'Isoleucine'],
+    ['LYS', 'Lysine'],
+    ['LEU', 'Leucine'],
+    ['MET', 'Methionine'],
+    ['ASN', 'Asparagine'],
+    ['PRO', 'Proline'],
+    ['GLN', 'Glutamine'],
+    ['ARG', 'Arginine'],
+    ['SER', 'Serine'],
+    ['THR', 'Threonine'],
+    ['VAL', 'Valine'],
+    ['TRP', 'Tryptophan'],
+    ['TYR', 'Tyrosine'],
+    ['A', 'Adenosine'],
+    ['C', 'Cytidine'],
+    ['DA', 'Deoxyadenosine'],
+    ['DC', 'Deoxycytidine'],
+    ['DG', 'Deoxyguanosine'],
+    ['G', ',Guanosine'],
+    ['T', 'Thymidine'],
+    ['U', 'Uridine']
+];
+
+export class ExchangesControl extends React.Component<{ handler: Residue }> {
+    onClickSwatch = (e: React.MouseEvent<HTMLButtonElement>) => {
+        const tlc = e.currentTarget.getAttribute('data-id')!;
+        this.props.handler.toggleExchange(tlc);
+    }
+
+    swatch() {
+        // TODO update of isSelected style is delayed - this seems to be a Chrome-related bug
+        return <div className='msp-combined-color-swatch'>
+            {DefaultExchanges.map(e => {
+                const isSelected = this.props.handler.hasExchange(e[0]);
+                const className = isSelected ? 'msp-control-current' : '';
+                return <Button key={e[0]} title={e[1]} inline data-id={e[0]} onClick={this.onClickSwatch} style={{ padding: 0, fontSize: '13px' }} className={className}>
+                    {e[0] && isSelected ? <b>{e[0]}</b> : e[0]}
+                </Button>;
+            })}
+        </div>;
+    }
+
+    render() {
+        return <>
+            <div className='msp-control-offset'>
+                {this.swatch()}
+            </div>
+        </>;
+    }
+}

+ 284 - 0
src/viewer/ui/strucmotif.tsx

@@ -0,0 +1,284 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
+ */
+
+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';
+
+const ADVANCED_SEARCH_URL = 'https://rcsb.org/search?query=';
+const RETURN_TYPE = '&return_type=assembly';
+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 <>
+            <SubmitControls />
+        </>;
+    }
+}
+
+const _SearchIcon = <svg width='24px' height='24px' viewBox='0 0 12 12'>
+    <g strokeWidth='1.5' fill='none'>
+        <path d='M11.29 11.71l-4-4' />
+        <circle cx='5' cy='5' r='4' />
+    </g>
+</svg>;
+export function SearchIconSvg() { return _SearchIcon; }
+
+const location = StructureElement.Location.create(void 0);
+
+type ExchangeState = number;
+type ResidueSelection = { label_asym_id: string, struct_oper_id: string, label_seq_id: number }
+type Exchange = { residue_id: ResidueSelection, allowed: string[] }
+
+/**
+ * The inner component of strucmotif search that can be collapsed.
+ */
+class SubmitControls extends PurePluginUIComponent<{}, { isBusy: boolean, residueMap: Map<StructureSelectionHistoryEntry, Residue>, action?: ExchangeState }> {
+    state = {
+        isBusy: false,
+        // map between selection entries of Mol* and additional exchange state
+        residueMap: new Map<StructureSelectionHistoryEntry, Residue>(),
+        action: void 0 as ExchangeState | undefined
+    };
+
+    componentDidMount() {
+        this.subscribe(this.selection.events.additionsHistoryUpdated, () => {
+            // invalidate potentially expanded exchange panel
+            this.setState({ action: void 0 });
+            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<string> = new Set();
+        const residueIds: ResidueSelection[] = [];
+        const exchanges: Exchange[] = [];
+
+        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);
+            const residueId = {
+                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)
+            };
+            residueIds.push(residueId);
+
+            // handle potential exchanges - can be empty if deselected by users
+            const residueMapEntry = this.state.residueMap.get(l)!;
+            if (residueMapEntry.exchanges?.size > 0) {
+                exchanges.push({ residue_id: residueId, allowed: Array.from(residueMapEntry.exchanges.values()) });
+            }
+        }
+
+        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 = {
+            type: 'terminal',
+            service: 'strucmotif',
+            parameters: {
+                value: {
+                    data: pdbId.values().next().value as string,
+                    residue_ids: residueIds.sort((a, b) => this.sortResidueIds(a, b))
+                },
+                score_cutoff: 0,
+                exchanges: exchanges
+            }
+        };
+        // console.log(query);
+        const url = ADVANCED_SEARCH_URL + encodeURIComponent(JSON.stringify(query)) + RETURN_TYPE;
+        // console.log(url);
+        window.open(url, '_blank');
+    }
+
+    sortResidueIds(a: ResidueSelection, b: ResidueSelection): number {
+        if (a.label_asym_id !== b.label_asym_id) {
+            return a.label_asym_id.localeCompare(b.label_asym_id);
+        } else if (a.struct_oper_id !== b.struct_oper_id) {
+            return a.struct_oper_id.localeCompare(b.struct_oper_id);
+        } else {
+            return a.label_seq_id < b.label_seq_id ? -1 : a.label_seq_id > b.label_seq_id ? 1 : 0;
+        }
+    }
+
+    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 === idx ? void 0 : 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') {
+        this.setState({ action: void 0 });
+        this.plugin.managers.structure.selection.modifyHistory(e.entry, a);
+        this.updateResidues();
+    }
+
+    updateResidues() {
+        const newResidueMap = new Map<StructureSelectionHistoryEntry, Residue>();
+        this.selection.additionsHistory.forEach(entry => {
+            newResidueMap.set(entry, this.state.residueMap.get(entry)!);
+        });
+        this.setState({ residueMap: newResidueMap });
+    }
+
+    focusLoci(loci: StructureElement.Loci) {
+        this.plugin.managers.camera.focusLoci(loci);
+    }
+
+    historyEntry(e: Residue, idx: number) {
+        const history = this.plugin.managers.structure.selection.additionsHistory;
+        return <div key={e.entry.id}>
+            <div className='msp-flex-row'>
+                <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}>
+                    {idx}. <span dangerouslySetInnerHTML={{ __html: e.entry.label }} />
+                </Button>
+                <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 }} />
+                {history.length > 1 && <IconButton svg={ArrowUpwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'up')} flex='20px' title={'Move up'} />}
+                {history.length > 1 && <IconButton svg={ArrowDownwardSvg} small={true} className='msp-form-control' onClick={() => this.moveHistory(e, 'down')} flex='20px' title={'Move down'} />}
+                <IconButton svg={DeleteOutlinedSvg} small={true} className='msp-form-control' onClick={() => this.modifyHistory(e, 'remove')} flex title={'Remove'} />
+            </div>
+            { this.state.action === idx && <ExchangesControl handler={e} /> }
+        </div>;
+    }
+
+    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++) {
+            let residue: Residue;
+            if (this.state.residueMap.has(history[i])) {
+                residue = this.state.residueMap.get(history[i])!;
+            } else {
+                residue = new Residue(history[i], this);
+                this.state.residueMap.set(history[i], residue);
+            }
+            entries.push(this.historyEntry(residue, i + 1));
+        }
+
+        return <>
+            <ActionMenu items={this.actions} onSelect={this.selectAction} />
+            {entries.length > 0 && <div className='msp-control-offset'>
+                {entries}
+            </div>}
+            {entries.length === 0 && <div className='msp-control-offset msp-help-text'>
+                <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />Add one or more selections (toggle <ToggleSelectionModeButton inline /> mode)</div>
+            </div>}
+        </>;
+    }
+
+    render() {
+        return <>
+            {this.add()}
+        </>;
+    }
+}
+
+export class Residue {
+    readonly exchanges: Set<string>;
+
+    constructor(readonly entry: StructureSelectionHistoryEntry, readonly parent: SubmitControls) {
+        this.exchanges = new Set<string>();
+        // by default: explicitly 'activate' original residue type
+        const structure = entry.loci.structure;
+        const e = entry.loci.elements[0];
+        StructureElement.Location.set(location, structure, e.unit, e.unit.elements[OrderedSet.getAt(e.indices, 0)]);
+        this.exchanges.add(StructureProperties.atom.label_comp_id(location));
+    }
+
+    toggleExchange(val: string): void {
+        if (this.hasExchange(val)) {
+            this.exchanges.delete(val);
+        } else {
+            this.exchanges.add(val);
+        }
+        // this will update state of parent component
+        this.parent.forceUpdate();
+    }
+
+    hasExchange(val: string): boolean {
+        return this.exchanges.has(val);
+    }
+}

Some files were not shown because too many files changed in this diff