Browse Source

Merge branch 'master' into dev-yv-alignment

# Conflicts:
#	package-lock.json
#	package.json
#	src/viewer/helpers/preset.ts
#	src/viewer/index.ts
#	src/viewer/ui/controls.tsx
Yana Rose 4 years ago
parent
commit
10e8429427

+ 2 - 0
.eslintignore

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

+ 68 - 44
.eslintrc.json

@@ -3,63 +3,87 @@
         "browser": true,
         "node": true
     },
-    "parser": "@typescript-eslint/parser",
     "parserOptions": {
-        "project": "tsconfig.json",
-        "sourceType": "module"
+        "ecmaVersion": 2018,
+        "sourceType": "module",
+        "ecmaFeatures": {
+            "impliedStrict": true
+        }
     },
-    "plugins": [
-        "@typescript-eslint"
-    ],
     "rules": {
-        "@typescript-eslint/ban-types": "off",
-        "@typescript-eslint/class-name-casing": "off",
-        "@typescript-eslint/indent": [
-            "warn",
-            4
-        ],
-        "@typescript-eslint/member-delimiter-style": [
-            "off",
-            {
-                "multiline": {
-                    "delimiter": "none",
-                    "requireLast": true
-                },
-                "singleline": {
-                    "delimiter": "semi",
-                    "requireLast": false
-                }
-            }
-        ],
-        "@typescript-eslint/prefer-namespace-keyword": "warn",
-        "@typescript-eslint/quotes": [
-            "warn",
-            "single",
-            {
-                "avoidEscape": true,
-                "allowTemplateLiterals": true
-            }
-        ],
-        "@typescript-eslint/semi": [
-            "off",
-            null
-        ],
-        "@typescript-eslint/type-annotation-spacing": "warn",
+        "indent": "off",
         "arrow-parens": [
             "off",
             "as-needed"
         ],
+        "brace-style": "off",
+        "comma-spacing": "off",
+        "space-infix-ops": "error",
         "comma-dangle": "off",
         "eqeqeq": [
-            "warn",
+            "error",
             "smart"
         ],
         "import/order": "off",
         "no-eval": "warn",
         "no-new-wrappers": "warn",
-        "no-trailing-spaces": "warn",
+        "no-trailing-spaces": "error",
         "no-unsafe-finally": "warn",
-        "no-var": "warn",
-        "spaced-comment": "warn"
-    }
+        "no-var": "error",
+        "spaced-comment": "error",
+        "semi": "warn"
+    },
+    "overrides": [
+        {
+            "files": ["**/*.ts", "**/*.tsx"],
+            "parser": "@typescript-eslint/parser",
+            "parserOptions": {
+                "project": ["tsconfig.json"],
+                "sourceType": "module"
+            },
+            "plugins": [
+                "@typescript-eslint"
+            ],
+            "rules": {
+                "@typescript-eslint/ban-types": "off",
+                "@typescript-eslint/class-name-casing": "off",
+                "@typescript-eslint/indent": [
+                    "error",
+                    4
+                ],
+                "@typescript-eslint/member-delimiter-style": [
+                    "off",
+                    {
+                        "multiline": {
+                            "delimiter": "none",
+                            "requireLast": true
+                        },
+                        "singleline": {
+                            "delimiter": "semi",
+                            "requireLast": false
+                        }
+                    }
+                ],
+                "@typescript-eslint/prefer-namespace-keyword": "warn",
+                "@typescript-eslint/quotes": [
+                    "error",
+                    "single",
+                    {
+                        "avoidEscape": true,
+                        "allowTemplateLiterals": true
+                    }
+                ],
+                "@typescript-eslint/semi": [
+                    "off",
+                    null
+                ],
+                "@typescript-eslint/type-annotation-spacing": "error",
+                "@typescript-eslint/brace-style": [
+                    "error",
+                    "1tbs", { "allowSingleLine": true }
+                ],
+                "@typescript-eslint/comma-spacing": "error"
+            }
+        }
+    ]
 }

+ 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

+ 11 - 7
README.md

@@ -1,5 +1,13 @@
+[![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).
+Try it [here](https://rcsb.org/3d-view/).
+
+## Install
+    npm install @rcsb/rcsb-molstar
+
 ## Building & Running
 
 ### Build:
@@ -27,16 +35,12 @@ From the root of the project:
 
     http-server -p PORT-NUMBER
 
-and navigate to `build/dist/structure-viewer/`
-
-## Publish
-
-The publish will send the package to our own registry at http://nexus3.rcsb.org/repository/npm-rcsb/ (not to NPM as this is currently a private project).
+and navigate to `build/dist/viewer/`
 
 ## Prerelease
-    npm version prerelease # asumes the current version ends with '-dev.X'
+    npm version prerelease # assumes the current version ends with '-dev.X'
     npm publish --tag next
 
 ## Release
-    npm version 0.X.0 # provide valid semver string
+    npm version 1.X.0 # provide valid semver string
     npm publish

File diff suppressed because it is too large
+ 198 - 183
package-lock.json


+ 18 - 17
package.json

@@ -1,6 +1,6 @@
 {
-    "name": "rcsb-molstar",
-    "version": "1.0.6-dev.17",
+    "name": "@rcsb/rcsb-molstar",
+    "version": "1.1.0",
     "description": "RCSB PDB apps and props based on Mol*.",
     "homepage": "https://github.com/rcsb/rcsb-molstar#readme",
     "repository": {
@@ -27,36 +27,37 @@
         "postversion": "git push && git push --tags"
     },
     "publishConfig": {
-        "registry": "http://nexus3.rcsb.org/repository/npm-rcsb/"
+        "registry": "https://registry.npmjs.org/"
     },
     "main": "build/src/index.js",
     "files": [
-        "build/dist/"
+        "build/dist/",
+        "build/src/viewer/"
     ],
     "author": "RCSB PDB and Mol* Contributors",
     "license": "MIT",
     "devDependencies": {
         "@types/react": "^17.0.0",
         "@types/react-dom": "^17.0.0",
-        "@typescript-eslint/eslint-plugin": "^3.10.1",
-        "@typescript-eslint/parser": "^3.10.1",
+        "@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",
+        "file-loader": "^6.2.0",
+        "mini-css-extract-plugin": "^1.3.2",
         "molstar": "^1.3.0",
-        "node-sass": "^4.14.1",
-        "raw-loader": "^4.0.1",
+        "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'));
+};

+ 3 - 0
src/viewer/assets.ts

@@ -0,0 +1,3 @@
+import './index.html';
+import './favicon.ico';
+require('./skin/rcsb.scss');

+ 3 - 3
src/viewer/helpers/model.ts

@@ -14,11 +14,11 @@ import { BuiltInTrajectoryFormat } from 'molstar/lib/mol-plugin-state/formats/tr
 
 export class ModelLoader {
     async load(load: LoadParams, props?: PresetProps, matrix?: Mat4) {
-        const { fileOrUrl, format, isBinary } = load
+        const { fileOrUrl, format, isBinary } = load;
 
         const data = fileOrUrl instanceof File
             ? (await this.plugin.builders.data.readFile({ file: Asset.File(fileOrUrl), isBinary })).data
-            : await this.plugin.builders.data.download({ url: fileOrUrl, isBinary })
+            : await this.plugin.builders.data.download({ url: fileOrUrl, isBinary });
         await this.handleTrajectory(data, format, props, matrix);
     }
 
@@ -29,7 +29,7 @@ export class ModelLoader {
     }
 
     async handleTrajectory(data: any, format: BuiltInTrajectoryFormat, props?: PresetProps, matrix?: Mat4) {
-        const trajectory = await this.plugin.builders.structure.parseTrajectory(data, format)
+        const trajectory = await this.plugin.builders.structure.parseTrajectory(data, format);
 
         const selector = await this.plugin.builders.structure.hierarchy.applyPreset(trajectory, RcsbPreset, {
             preset: props || { kind: 'standard', assemblyId: '' }

+ 43 - 25
src/viewer/helpers/preset.ts

@@ -25,6 +25,8 @@ import { CustomStructureProperties } from 'molstar/lib/mol-plugin-state/transfor
 import { FlexibleStructureFromModel as FlexibleStructureFromModel } from './superpose/flexible-structure';
 import { StructureRepresentationRegistry } from 'molstar/lib/mol-repr/structure/registry';
 import { StructureSelectionQueries as Q } from 'molstar/lib/mol-plugin-state/helpers/structure-selection-query';
+import { PluginCommands } from 'molstar/lib/mol-plugin/commands';
+import { InteractivityManager } from 'molstar/lib/mol-plugin-state/manager/interactivity';
 
 type Target = {
     readonly auth_seq_id?: number
@@ -34,41 +36,41 @@ type Target = {
 }
 
 function targetToExpression(target: Target): Expression {
-    const residueTests: Expression[] = []
-    const tests = Object.create(null)
+    const residueTests: Expression[] = [];
+    const tests = Object.create(null);
 
     if (target.auth_seq_id) {
-        residueTests.push(MS.core.rel.eq([target.auth_seq_id, MS.ammp('auth_seq_id')]))
+        residueTests.push(MS.core.rel.eq([target.auth_seq_id, MS.ammp('auth_seq_id')]));
     } else if (target.label_seq_id) {
-        residueTests.push(MS.core.rel.eq([target.label_seq_id, MS.ammp('label_seq_id')]))
+        residueTests.push(MS.core.rel.eq([target.label_seq_id, MS.ammp('label_seq_id')]));
     }
     if (target.label_comp_id) {
-        residueTests.push(MS.core.rel.eq([target.label_comp_id, MS.ammp('label_comp_id')]))
+        residueTests.push(MS.core.rel.eq([target.label_comp_id, MS.ammp('label_comp_id')]));
     }
     if (residueTests.length === 1) {
-        tests['residue-test'] = residueTests[0]
+        tests['residue-test'] = residueTests[0];
     } else if (residueTests.length > 1) {
-        tests['residue-test'] = MS.core.logic.and(residueTests)
+        tests['residue-test'] = MS.core.logic.and(residueTests);
     }
 
     if (target.label_asym_id) {
-        tests['chain-test'] = MS.core.rel.eq([target.label_asym_id, MS.ammp('label_asym_id')])
+        tests['chain-test'] = MS.core.rel.eq([target.label_asym_id, MS.ammp('label_asym_id')]);
     }
 
     if (Object.keys(tests).length > 0) {
         return MS.struct.modifier.union([
             MS.struct.generator.atomGroups(tests)
-        ])
+        ]);
     } else {
-        return MS.struct.generator.empty
+        return MS.struct.generator.empty;
     }
 }
 
 function targetToLoci(target: Target, structure: Structure): StructureElement.Loci {
-    const expression = targetToExpression(target)
-    const query = compile<StructureSelection>(expression)
+    const expression = targetToExpression(target);
+    const query = compile<StructureSelection>(expression);
     const selection = query(new QueryContext(structure));
-    return StructureSelection.toLociWithSourceUnits(selection)
+    return StructureSelection.toLociWithSourceUnits(selection);
 }
 
 type Range = { asymId: string, beg?: number, end?: number }
@@ -188,21 +190,21 @@ export const RcsbPreset = TrajectoryHierarchyPresetProvider({
     id: 'preset-trajectory-rcsb',
     display: { name: 'RCSB' },
     isApplicable: o => {
-        return true
+        return true;
     },
     params: RcsbParams,
     async apply(trajectory, params, plugin) {
         const builder = plugin.builders.structure;
-        const p = params.preset
+        const p = params.preset;
 
-        const modelParams = { modelIndex: p.modelIndex || 0 }
+        const modelParams = { modelIndex: p.modelIndex || 0 };
 
-        const structureParams: RootStructureDefinition.Params = { name: 'model', params: {} }
+        const structureParams: RootStructureDefinition.Params = { name: 'model', params: {} };
         if (p.assemblyId && p.assemblyId !== '' && p.assemblyId !== '0') {
             Object.assign(structureParams, {
                 name: 'assembly',
                 params: { id: p.assemblyId }
-            } as RootStructureDefinition.Params)
+            } as RootStructureDefinition.Params);
         }
 
         const model = await builder.createModel(trajectory, modelParams);
@@ -233,6 +235,7 @@ export const RcsbPreset = TrajectoryHierarchyPresetProvider({
             const _structureProperties = plugin.state.data.build().to(structure)
                 .apply(CustomStructureProperties);
             structureProperties = await _structureProperties.commit();
+        let representation: StructureRepresentationPresetProvider.Result | undefined = undefined;
 
             // adding coloring lookup scheme
             structure.data!.inheritedPropertyData.colors = {};
@@ -281,31 +284,46 @@ export const RcsbPreset = TrajectoryHierarchyPresetProvider({
             ViewerState(plugin).collapsed.next({
                 ...ViewerState(plugin).collapsed.value,
                 custom: false
-            })
+            });
         } else {
             representation = await plugin.builders.structure.representation.applyPreset(structureProperties!, 'auto');
         }
 
         if (p.kind === 'feature' && structure?.obj) {
             const loci = targetToLoci(p.target, structure.obj.data)
+        if (p.kind === 'feature' && structure.obj) {
+            const loci = targetToLoci(p.target, structure.obj.data);
             // if target is only defined by chain: then don't force first residue
             const chainMode = p.target.label_asym_id && !p.target.auth_seq_id && !p.target.label_seq_id && !p.target.label_comp_id;
-            const target = chainMode ? loci : StructureElement.Loci.firstResidue(loci)
-            plugin.managers.structure.focus.setFromLoci(target)
-            plugin.managers.camera.focusLoci(target)
+            const target = chainMode ? loci : StructureElement.Loci.firstResidue(loci);
+            plugin.managers.structure.focus.setFromLoci(target);
+            plugin.managers.camera.focusLoci(target);
         }
 
         if (p.kind === 'density' && structure?.cell?.parent) {
             const volumeRoot = StateSelection.findTagInSubtree(structure.cell.parent.tree, structure.cell.transform.ref, VolumeStreaming.RootTag);
             if (!volumeRoot) {
-                const params = PD.getDefaultValues(InitVolumeStreaming.definition.params!(structure.obj!, plugin))
-                await plugin.runTask(plugin.state.data.applyAction(InitVolumeStreaming, params, structure.ref))
+                const params = PD.getDefaultValues(InitVolumeStreaming.definition.params!(structure.obj!, plugin));
+                await plugin.runTask(plugin.state.data.applyAction(InitVolumeStreaming, params, structure.ref));
             }
 
+            await PluginCommands.Toast.Show(plugin, {
+                title: 'Electron Density',
+                message: 'Click on a residue to display electron density, click background to reset.',
+                key: 'toast-density',
+                timeoutMs: 60000
+            });
+
+            plugin.behaviors.interaction.click.subscribe(async (e: InteractivityManager.ClickEvent) => {
+                if (e.current && e.current.loci && e.current.loci.kind !== 'empty-loci') {
+                    await PluginCommands.Toast.Hide(plugin, { key: 'toast-density' });
+                }
+            });
+
             ViewerState(plugin).collapsed.next({
                 ...ViewerState(plugin).collapsed.value,
                 volume: false
-            })
+            });
         }
 
         return {

+ 204 - 22
src/viewer/index.ts

@@ -6,17 +6,16 @@
 
 import { BehaviorSubject } from 'rxjs';
 import { DefaultPluginSpec } from 'molstar/lib/mol-plugin';
-import { Plugin } from 'molstar/lib/mol-plugin-ui/plugin'
-import './index.html'
-import './favicon.ico'
+import { Plugin } from 'molstar/lib/mol-plugin-ui/plugin';
 import { PluginContext } from 'molstar/lib/mol-plugin/context';
 import { PluginCommands } from 'molstar/lib/mol-plugin/commands';
 import { ViewerState as ViewerState, CollapsedState, ModelUrlProvider } from './types';
 import { PluginSpec } from 'molstar/lib/mol-plugin/spec';
 
 import { ColorNames } from 'molstar/lib/mol-util/color/names';
-import ReactDOM = require('react-dom');
-import React = require('react');
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+
 import { ModelLoader } from './helpers/model';
 import { PresetProps } from './helpers/preset';
 import { ControlsWrapper } from './ui/controls';
@@ -30,15 +29,23 @@ import { ObjectKeys } from 'molstar/lib/mol-util/type-helpers';
 import { PluginLayoutControlsDisplay } from 'molstar/lib/mol-plugin/layout';
 import { SuperposeColorThemeProvider } from './helpers/superpose/color';
 import { encodeStructureData, downloadAsZipFile } from './helpers/export';
-require('./skin/rcsb.scss')
+import {Structure} from 'molstar/lib/mol-model/structure/structure';
+import {Script} from 'molstar/lib/mol-script/script';
+import {MolScriptBuilder} from 'molstar/lib/mol-script/language/builder';
+import {SetUtils} from 'molstar/lib/mol-util/set';
+import {Loci} from 'molstar/lib/mol-model/loci';
+import {StructureSelection} from 'molstar/lib/mol-model/structure/query';
+import {StructureRef} from 'molstar/lib/mol-plugin-state/manager/structure/hierarchy-state';
+import {StructureSelectionQuery} from 'molstar/lib/mol-plugin-state/helpers/structure-selection-query';
+import {StructureRepresentationRegistry} from 'molstar/lib/mol-repr/structure/registry';
 
 /** package version, filled in at bundle build time */
-declare const __RCSB_MOLSTAR_VERSION__: string
-export const RCSB_MOLSTAR_VERSION = __RCSB_MOLSTAR_VERSION__;
+declare const __RCSB_MOLSTAR_VERSION__: string;
+export const RCSB_MOLSTAR_VERSION = typeof __RCSB_MOLSTAR_VERSION__ != 'undefined' ? __RCSB_MOLSTAR_VERSION__ : 'none';
 
 /** unix time stamp, to be filled in at bundle build time */
-declare const __BUILD_TIMESTAMP__: number
-export const BUILD_TIMESTAMP = __BUILD_TIMESTAMP__;
+declare const __BUILD_TIMESTAMP__: number;
+export const BUILD_TIMESTAMP = typeof __BUILD_TIMESTAMP__ != 'undefined' ? __BUILD_TIMESTAMP__ : 'none';
 export const BUILD_DATE = new Date(BUILD_TIMESTAMP);
 
 const Extensions = {
@@ -86,20 +93,20 @@ const DefaultViewerProps = {
         custom: false
     }
 };
-type ViewerProps = typeof DefaultViewerProps
+export type ViewerProps = typeof DefaultViewerProps
 
 export class Viewer {
     private readonly plugin: PluginContext;
     private readonly modelUrlProviders: ModelUrlProvider[];
 
     private get customState() {
-        return this.plugin.customState as ViewerState
+        return this.plugin.customState as ViewerState;
     }
 
     constructor(target: string | HTMLElement, props: Partial<ViewerProps> = {}) {
-        target = typeof target === 'string' ? document.getElementById(target)! : target
+        target = typeof target === 'string' ? document.getElementById(target)! : target;
 
-        const o = { ...DefaultViewerProps, ...props }
+        const o = { ...DefaultViewerProps, ...props };
 
         const spec: PluginSpec = {
             actions: [...DefaultPluginSpec.actions],
@@ -119,7 +126,7 @@ export class Viewer {
                     ...DefaultPluginSpec.layout && DefaultPluginSpec.layout.controls,
                     top: o.layoutShowSequence ? undefined : 'none',
                     bottom: o.layoutShowLog ? undefined : 'none',
-                    // left: 'none',
+                    left: 'none',
                     right: ControlsWrapper,
                 }
             },
@@ -147,6 +154,7 @@ export class Viewer {
             modelLoader: new ModelLoader(this.plugin),
             collapsed: new BehaviorSubject<CollapsedState>({
                 selection: true,
+                strucmotifSubmit: true,
                 measurements: true,
                 superposition: true,
                 component: false,
@@ -165,13 +173,33 @@ export class Viewer {
         this.plugin.representation.structure.themes.colorThemeRegistry.add(SuperposeColorThemeProvider);
         // this.plugin.builders.structure.representation.registerPreset(RcsbSuperpositionRepresentationPreset);
 
+        ReactDOM.render(React.createElement(Plugin, { plugin: this.plugin }), target);
+        // 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<void> = new Promise<void>((resolve, reject)=>{
+            const recursive: () => void = () => {
+                if(this.plugin.canvas3d != null){
+                    resolve();
+                }else{
+                    setTimeout(()=>{
+                        recursive();
+                    }, 100);
+                }
+            };
+            recursive();
+        });
+        wait.then(result=>{
+            const renderer = this.plugin.canvas3d!.props.renderer;
+            PluginCommands.Canvas3D.SetSettings(this.plugin, { settings: { renderer: { ...renderer, backgroundColor: o.backgroundColor } } });
+        });
         if (o.showWelcomeToast) {
             PluginCommands.Toast.Show(this.plugin, {
                 title: 'Welcome',
                 message: `RCSB PDB Mol* Viewer ${RCSB_MOLSTAR_VERSION} [${BUILD_DATE.toLocaleString()}]`,
                 key: 'toast-welcome',
                 timeoutMs: 5000
-            })
+            });
         }
         this.prevExpanded = this.plugin.layout.state.isExpanded;
         this.plugin.layout.events.updated.subscribe(() => this.toggleControls());
@@ -201,17 +229,17 @@ export class Viewer {
 
     clear() {
         const state = this.plugin.state.data;
-        return PluginCommands.State.RemoveObject(this.plugin, { state, ref: state.tree.root.ref })
+        return PluginCommands.State.RemoveObject(this.plugin, { state, ref: state.tree.root.ref });
     }
 
     async loadPdbId(pdbId: string, props?: PresetProps, matrix?: Mat4) {
         for (const provider of this.modelUrlProviders) {
             try {
-                const p = provider(pdbId)
-                await this.customState.modelLoader.load({ fileOrUrl: p.url, format: p.format, isBinary: p.isBinary }, props, matrix)
-                break
+                const p = provider(pdbId);
+                await this.customState.modelLoader.load({ fileOrUrl: p.url, format: p.format, isBinary: p.isBinary }, props, matrix);
+                break;
             } catch (e) {
-                console.warn(`loading '${pdbId}' failed with '${e}', trying next model-loader-provider`)
+                console.warn(`loading '${pdbId}' failed with '${e}', trying next model-loader-provider`);
             }
         }
     }
@@ -224,7 +252,7 @@ export class Viewer {
     }
 
     loadStructureFromUrl(url: string, format: BuiltInTrajectoryFormat, isBinary: boolean, props?: PresetProps, matrix?: Mat4) {
-        return this.customState.modelLoader.load({ fileOrUrl: url, format, isBinary }, props, matrix)
+        return this.customState.modelLoader.load({ fileOrUrl: url, format, isBinary }, props, matrix);
     }
 
     loadSnapshotFromUrl(url: string, type: PluginState.SnapshotType) {
@@ -243,4 +271,158 @@ export class Viewer {
         const content = encodeStructureData(this.plugin);
         downloadAsZipFile(content);
     }
+
+    pluginCall(f: (plugin: PluginContext) => void){
+        f(this.plugin);
+    }
+
+    public getPlugin(): PluginContext {
+        return this.plugin;
+    }
+
+    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;
+    public select(...args: any[]){
+        if(args.length === 3){
+            if(args[2] === 'set')
+                this.clearSelection('select');
+            (args[0] as Array<{modelId: string; asymId: string; position: number;}>).forEach(r=>{
+                this.selectSegment(r.modelId, r.asymId, r.position, r.position, args[1], 'add');
+            });
+        }else if(args.length === 5){
+            this.selectSegment(args[0], args[1], args[2], args[2], args[3], args[4]);
+        }else if(args.length === 6){
+            this.selectSegment(args[0], args[1], args[2], args[3], args[4], args[5]);
+        }
+    }
+    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')
+            this.plugin.managers.structure.selection.fromLoci(modifier, 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') {
+            if(options == null){
+                this.plugin.managers.interactivity.lociSelects.deselectAll();
+            }else{
+                const data: Structure | undefined = getStructureWithModelId(this.plugin.managers.structure.hierarchy.current.structures, options.modelId);
+                if (data == null) return;
+                const sel: StructureSelection = Script.getStructureSelection(Q => Q.struct.generator.atomGroups({
+                    'chain-test': Q.core.rel.eq([options.labelAsymId, MolScriptBuilder.ammp('label_asym_id')])
+                }), data);
+                const loci: Loci = StructureSelection.toLociWithSourceUnits(sel);
+                this.plugin.managers.interactivity.lociSelects.deselect({loci});
+            }
+        }else if(mode === 'hover') {
+            this.plugin.managers.interactivity.lociHighlights.clearHighlights();
+        }
+    }
+
+    public createComponent(componentId: string, modelId: string, asymId: string, representationType: StructureRepresentationRegistry.BuiltIn): Promise<void>;
+    public createComponent(componentId: string, modelId: string, residues: Array<{asymId: string, position: number}>, representationType: StructureRepresentationRegistry.BuiltIn): Promise<void>;
+    public createComponent(componentId: string, modelId: string, asymId: string, begin: number, end: number, representationType: StructureRepresentationRegistry.BuiltIn): Promise<void>;
+    public createComponent(...args: any[]): Promise<void>{
+        if(args.length === 4 && typeof args[2] === 'string'){
+            return this.createComponentFromChain(args[0], args[1], args[2], args[3]);
+        }else if(args.length === 4 && args[2] instanceof Array){
+            return this.createComponentFromSet(args[0], args[1], args[2], args[3]);
+        }else if(args.length === 6 ){
+            return this.createComponentFromRange(args[0], args[1], args[2], args[3], args[4], args[5]);
+        }
+        throw 'createComponent error: wrong arguments';
+    }
+    private async createComponentFromChain(componentId: string, modelId: string, asymId: string, representationType: StructureRepresentationRegistry.BuiltIn): Promise<void>{
+        const structureRef: StructureRef | undefined = getStructureRefWithModelId(this.plugin.managers.structure.hierarchy.current.structures, modelId);
+        if(structureRef == null)
+            return;
+        await this.plugin.managers.structure.component.add({
+            selection: StructureSelectionQuery(
+                'innerQuery_' + Math.random().toString(36).substr(2),
+                MolScriptBuilder.struct.generator.atomGroups({
+                    'chain-test': MolScriptBuilder.core.rel.eq([asymId, MolScriptBuilder.ammp('label_asym_id')])
+                })
+            ),
+            options: { checkExisting: false, label: componentId },
+            representation: representationType,
+        }, [structureRef]);
+    }
+    private async createComponentFromSet(componentId: string, modelId: string, residues: Array<{asymId: string, position: number}>, representationType: StructureRepresentationRegistry.BuiltIn): Promise<void>{
+        const structureRef: StructureRef | undefined = getStructureRefWithModelId(this.plugin.managers.structure.hierarchy.current.structures, modelId);
+        if(structureRef == null)
+            return;
+        await this.plugin.managers.structure.component.add({
+            selection: StructureSelectionQuery(
+                'innerQuery_' + Math.random().toString(36).substr(2),
+                MolScriptBuilder.struct.combinator.merge(
+                    residues.map(r=>MolScriptBuilder.struct.generator.atomGroups({
+                        'chain-test': MolScriptBuilder.core.rel.eq([r.asymId, MolScriptBuilder.ammp('label_asym_id')]),
+                        'residue-test': MolScriptBuilder.core.rel.eq([r.position, MolScriptBuilder.ammp('label_seq_id')])
+                    }))
+                )
+            ),
+            options: { checkExisting: false, label: componentId },
+            representation: representationType,
+        }, [structureRef]);
+    }
+    private async createComponentFromRange(componentId: string, modelId: string, asymId: string, begin: number, end: number, representationType: StructureRepresentationRegistry.BuiltIn): Promise<void>{
+        const structureRef: StructureRef | undefined = getStructureRefWithModelId(this.plugin.managers.structure.hierarchy.current.structures, modelId);
+        if(structureRef == null)
+            return;
+        const seq_id: Array<number> = new Array<number>();
+        for(let n = begin; n <= end; n++){
+            seq_id.push(n);
+        }
+        await this.plugin.managers.structure.component.add({
+            selection: StructureSelectionQuery(
+                'innerQuery_' + Math.random().toString(36).substr(2),
+                MolScriptBuilder.struct.generator.atomGroups({
+                    'chain-test': MolScriptBuilder.core.rel.eq([asymId, MolScriptBuilder.ammp('label_asym_id')]),
+                    'residue-test': MolScriptBuilder.core.set.has([MolScriptBuilder.set(...SetUtils.toArray(new Set(seq_id))), MolScriptBuilder.ammp('label_seq_id')])
+                })
+            ),
+            options: { checkExisting: false, label: componentId },
+            representation: representationType,
+        }, [structureRef]);
+    }
+
+    public removeComponent(componentId: string): void{
+        this.plugin.managers.structure.hierarchy.currentComponentGroups.forEach(c=>{
+            for(const comp of c){
+                if(comp.cell.obj?.label === componentId) {
+                    this.plugin.managers.structure.hierarchy.remove(c);
+                    break;
+                }
+            }
+        });
+    }
+}
+
+function getStructureRefWithModelId(structures: StructureRef[], modelId: string): StructureRef|undefined{
+    for(const structure of structures){
+        if(!structure.cell?.obj?.data?.units)
+            continue;
+        const unit =  structure.cell.obj.data.units[0];
+        const id: string = unit.model.id;
+        if(id === modelId)
+            return structure;
+    }
+}
+
+function getStructureWithModelId(structures: StructureRef[], modelId: string): Structure|undefined{
+    const structureRef: StructureRef | undefined = getStructureRefWithModelId(structures, modelId);
+    if(structureRef != null)
+        return structureRef.cell?.obj?.data;
 }

+ 2 - 1
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
@@ -57,5 +58,5 @@ export interface ViewerState {
     visibility: BehaviorSubject<VisibilityState>
 }
 export function ViewerState(plugin: PluginContext) {
-    return plugin.customState as ViewerState
+    return plugin.customState as ViewerState;
 }

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

@@ -16,10 +16,11 @@ 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() {
-        return ViewerState(this.plugin)
+        return ViewerState(this.plugin);
     }
 
     componentDidMount() {
@@ -36,8 +37,8 @@ export class StructureTools extends PluginUIComponent {
             {visibility.superposition && <StructureSuperpositionControls initiallyCollapsed={collapsed.superposition} />}
             {visibility.component && <StructureComponentControls initiallyCollapsed={collapsed.component} />}
             {visibility.volume && <VolumeStreamingControls header='Density' initiallyCollapsed={collapsed.volume} />}
-
             {visibility.custom && <CustomStructureControls initiallyCollapsed={collapsed.custom} />}
+            <StrucmotifSubmitControls initiallyCollapsed={collapsed.strucmotifSubmit} />
         </>;
     }
 }

+ 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>
+        </>;
+    }
+}

+ 2 - 2
src/viewer/ui/import.tsx

@@ -18,13 +18,13 @@ export class ImportControls extends CollapsableControls {
             header: 'Import',
             isCollapsed: false,
             brand: { accent:  'gray' as const, svg: FileOutlineSvg }
-        }
+        };
     }
 
     renderControls() {
         return <div className={'msp-control-offset'} style={{ paddingTop: '1px' }}>
             <ApplyActionControl key={`${OpenFiles.id}`} state={this.plugin.state.data} action={OpenFiles} nodeRef={StateTransform.RootRef} initiallyCollapsed={true} />
             <ApplyActionControl key={`${DownloadStructure.id}`} state={this.plugin.state.data} action={DownloadStructure} nodeRef={StateTransform.RootRef} initiallyCollapsed={true} />
-        </div>
+        </div>;
     }
 }

+ 6 - 6
src/viewer/ui/session.tsx

@@ -14,14 +14,14 @@ class LocalStateControls extends CollapsableControls {
         return {
             header: 'Views',
             isCollapsed: true,
-        }
+        };
     }
 
     renderControls() {
         return <div>
             <LocalStateSnapshots />
             <LocalStateSnapshotList />
-        </div>
+        </div>;
     }
 }
 
@@ -30,13 +30,13 @@ class StateControls extends CollapsableControls {
         return {
             header: 'Download / Open',
             isCollapsed: true,
-        }
+        };
     }
 
     renderControls() {
         return <div>
             <StateExportImportControls />
-        </div>
+        </div>;
     }
 }
 
@@ -46,13 +46,13 @@ export class SessionControls extends CollapsableControls {
             header: 'Session',
             isCollapsed: true,
             brand: { accent:  'gray' as const, svg: SaveOutlinedSvg }
-        }
+        };
     }
 
     renderControls() {
         return <div className={'msp-control-offset'} style={{ paddingTop: '1px' }}>
             <LocalStateControls />
             <StateControls />
-        </div>
+        </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);
+    }
+}

+ 3 - 2
tsconfig.json

@@ -14,7 +14,8 @@
         "jsx": "react",
         "lib": [ "es6", "dom", "esnext.asynciterable", "es2016" ],
         "outDir": "build/src",
-        "rootDir": "src"
+        "rootDir": "src",
+        "declaration": true
     },
-    "include": [ "src/**/*" ],
+    "include": [ "src/**/*" ]
 }

+ 7 - 1
webpack.config.js

@@ -60,5 +60,11 @@ module.exports = [
             path: path.resolve(__dirname, `build/dist/viewer`)
         },
         ...sharedConfig
-    },
+    },{
+        entry: path.resolve(__dirname, `build/src/viewer/assets.js`),
+        output: {
+            path: path.resolve(__dirname, `build/dist/viewer`)
+        },
+        ...sharedConfig
+    }
 ]

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