Browse Source

Merge branch 'master' into dev-sb-v2

# Conflicts:
#	CHANGELOG.md
#	package-lock.json
#	package.json
#	src/viewer/helpers/selection.ts
#	src/viewer/index.html
#	src/viewer/index.ts
Sebastian Bittrich 3 years ago
parent
commit
56ae3144ea

+ 50 - 0
CHANGELOG.md

@@ -17,6 +17,56 @@
   - Added optional configuration parameter `config?: {props?: PresetProps; matrix?: Mat4; reprProvider?: TrajectoryHierarchyPresetProvider, params?: P}`
   - The loading configuration includes an optional trajectory preset provider `TrajectoryHierarchyPresetProvider` 
 
+## [1.9.0] - 2021-09-29
+### Added
+- Integrate PDBe/AlphaFold confidence coloring theme
+
+## [1.8.8] - 2021-09-28
+### General
+- Mol* 2.3.1
+
+## [1.8.7] - 2021-08-25
+### General
+- Mol* 2.2.3
+
+## [1.8.6] - 2021-08-23
+### General
+- Display warning if membrane preset calculation fails
+- Add fallback if membrane preset calculation fails
+
+## [1.8.5] - 2021-08-11
+### General
+- Update strucmotif integration with rcsb.org
+
+## [1.8.4] - 2021-08-03
+### General
+- Mol* 2.2.1
+- Reset camera for membrane preset
+
+## [1.8.3] - 2021-07-23
+### General
+- Rename 'Structural Motif Search' to 'Structure Motif Search'
+
+## [1.8.2] - 2021-07-20
+### Bug fixes
+- Post-pare for pecos API changes
+
+## [1.8.1] - 2021-07-16
+### Added
+- Prepare for pecos API changes
+
+## [1.8.0] - 2021-07-13
+### Added
+- Moved code for motif alignment (i.e., talking to pecos) here
+
+## [1.7.4] - 2021-07-12
+### Bug fixes
+- structure selection: handle selection of full chains
+
+## [1.7.3] - 2021-07-08
+### Bug fixes
+- Strucmotif: relative URLs when running inside of sierra
+
 ## [1.7.2] - 2021-07-05
 ### Bug fixes
 - Code that determines assemblyId is now aware of label_asym_id

+ 9 - 0
README.md

@@ -5,6 +5,15 @@
 RCSB PDB implementation of [Mol* (/'mol-star/)](https://github.com/molstar/molstar).
 Try it [here](https://rcsb.org/3d-view/).
 
+PDBe also maintains a flavor of Mol* called [PDBe Molstar](https://github.com/PDBeurope/pdbe-molstar).
+
+## Functionality
+Provides custom features used in the Mol* viewer on [rcsb.org](https://www.rcsb.org/3d-view):
+- visualization of structure alignment
+- visualization of structure motifs & UI to launch structure motif queries
+- interactivity functionality to highlight and add representations for selections of a structure, used in the [3D Protein Feature View](https://www.rcsb.org/3d-sequence/4hhb)
+- linkable focus representation on ligands or chains
+
 ## Install
     npm install @rcsb/rcsb-molstar
 

+ 26 - 22
package.json

@@ -20,7 +20,7 @@
         "watch": "concurrently -c \"green,gray,gray\" --names \"tsc,ext,wpc\" --kill-others \"npm:watch-tsc\" \"npm:watch-extra\" \"npm:watch-webpack\"",
         "watch-tsc": "tsc --watch --incremental",
         "watch-extra": "cpx \"src/**/*.{scss,html,ico}\" build/src/ --watch",
-        "watch-webpack": "webpack -w --mode development --display minimal",
+        "watch-webpack": "webpack -w --mode development --stats minimal",
         "serve": "http-server -p 1335",
         "preversion": "npm run test",
         "version": "npm run build",
@@ -37,29 +37,33 @@
     "author": "RCSB PDB and Mol* Contributors",
     "license": "MIT",
     "devDependencies": {
-        "@types/react": "^17.0.11",
-        "@types/react-dom": "^17.0.0",
-        "@typescript-eslint/eslint-plugin": "^4.9.1",
-        "@typescript-eslint/parser": "^4.9.1",
-        "concurrently": "^5.3.0",
-        "cpx2": "^3.0.0",
-        "css-loader": "^5.0.1",
-        "eslint": "^7.15.0",
+        "@types/react": "^17.0.20",
+        "@types/react-dom": "^17.0.9",
+        "@typescript-eslint/eslint-plugin": "^4.31.0",
+        "@typescript-eslint/parser": "^4.31.0",
+        "buffer": "^6.0.3",
+        "concurrently": "^6.2.1",
+        "cpx2": "^3.0.2",
+        "crypto-browserify": "^3.12.0",
+        "css-loader": "^6.2.0",
+        "eslint": "^7.32.0",
         "extra-watch-webpack-plugin": "^1.0.3",
         "file-loader": "^6.2.0",
-        "fs-extra": "^9.0.1",
-        "mini-css-extract-plugin": "^1.3.2",
-        "molstar": "^2.0.7",
-        "node-sass": "^5.0.0",
+        "fs-extra": "^10.0.0",
+        "mini-css-extract-plugin": "^2.3.0",
+        "molstar": "^2.3.1",
+        "node-sass": "^6.0.1",
+        "path-browserify": "^1.0.1",
         "raw-loader": "^4.0.2",
-        "react": "^17.0.1",
-        "react-dom": "^17.0.1",
-        "rxjs": "^6.6.6",
-        "sass-loader": "^10.1.0",
-        "style-loader": "^2.0.0",
-        "tslib": "^2.1.0",
-        "typescript": "^4.2.3",
-        "webpack": "^4.44.1",
-        "webpack-cli": "^3.3.12"
+        "react": "^17.0.2",
+        "react-dom": "^17.0.2",
+        "rxjs": "^7.3.0",
+        "sass-loader": "^12.1.0",
+        "stream-browserify": "^3.0.0",
+        "style-loader": "^3.2.1",
+        "tslib": "^2.3.1",
+        "typescript": "^4.4.3",
+        "webpack": "^5.52.1",
+        "webpack-cli": "^4.8.0"
     }
 }

+ 70 - 0
src/viewer/helpers/af-confidence/behavior.ts

@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Mandar Deshpande <mandar@ebi.ac.uk>
+ */
+
+import { OrderedSet } from 'molstar/lib/mol-data/int';
+import { AlphaFoldConfidence, AlphaFoldConfidenceProvider } from './prop';
+import { AlphaFoldConfidenceColorThemeProvider } from './color';
+import { Loci } from 'molstar/lib/mol-model/loci';
+import { StructureElement } from 'molstar/lib/mol-model/structure';
+import { ParamDefinition as PD } from 'molstar/lib/mol-util/param-definition';
+import { PluginBehavior } from 'molstar/lib/mol-plugin/behavior/behavior';
+
+export const AlphaFoldConfidenceScore = PluginBehavior.create<{ autoAttach: boolean, showTooltip: boolean }>({
+    name: 'af-confidence-prop',
+    category: 'custom-props',
+    display: {
+        name: 'AlphaFold Confidence Score',
+        description: 'AlphaFold Confidence Score.'
+    },
+    ctor: class extends PluginBehavior.Handler<{ autoAttach: boolean, showTooltip: boolean }> {
+        private provider = AlphaFoldConfidenceProvider
+
+        private labelProvider = {
+            label: (loci: Loci): string | undefined => {
+                if (!this.params.showTooltip) return;
+
+                switch (loci.kind) {
+                    case 'element-loci':
+                        if (loci.elements.length === 0) return;
+                        const e = loci.elements[0];
+                        const u = e.unit;
+                        if (!u.model.customProperties.hasReference(AlphaFoldConfidenceProvider.descriptor)) return;
+
+                        const se = StructureElement.Location.create(loci.structure, u, u.elements[OrderedSet.getAt(e.indices, 0)]);
+                        const confidenceScore = AlphaFoldConfidence.getConfidenceScore(se);
+                        return confidenceScore ? `Confidence score: ${confidenceScore[0]} <small>( ${confidenceScore[1]} )</small>` : `No confidence score`;
+
+                    default: return;
+                }
+            }
+        }
+
+        register(): void {
+            this.ctx.customModelProperties.register(this.provider, this.params.autoAttach);
+            this.ctx.managers.lociLabels.addProvider(this.labelProvider);
+
+            this.ctx.representation.structure.themes.colorThemeRegistry.add(AlphaFoldConfidenceColorThemeProvider);
+        }
+
+        update(p: { autoAttach: boolean, showTooltip: boolean }) {
+            let updated = this.params.autoAttach !== p.autoAttach;
+            this.params.autoAttach = p.autoAttach;
+            this.params.showTooltip = p.showTooltip;
+            this.ctx.customModelProperties.setDefaultAutoAttach(this.provider.descriptor.name, this.params.autoAttach);
+            return updated;
+        }
+
+        unregister() {
+            this.ctx.customModelProperties.unregister(AlphaFoldConfidenceProvider.descriptor.name);
+            this.ctx.managers.lociLabels.removeProvider(this.labelProvider);
+            this.ctx.representation.structure.themes.colorThemeRegistry.remove(AlphaFoldConfidenceColorThemeProvider);
+        }
+    },
+    params: () => ({
+        autoAttach: PD.Boolean(false),
+        showTooltip: PD.Boolean(true)
+    })
+});

+ 95 - 0
src/viewer/helpers/af-confidence/color.ts

@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Mandar Deshpande <mandar@ebi.ac.uk>
+ * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
+ */
+
+import { AlphaFoldConfidence, AlphaFoldConfidenceProvider } from './prop';
+import { Location } from 'molstar/lib/mol-model/location';
+import { StructureElement } from 'molstar/lib/mol-model/structure';
+import { ColorTheme, LocationColor } from 'molstar/lib/mol-theme/color';
+import { ThemeDataContext } from 'molstar/lib/mol-theme/theme';
+import { Color } from 'molstar/lib/mol-util/color';
+import { ParamDefinition as PD } from 'molstar/lib/mol-util/param-definition';
+import { CustomProperty } from 'molstar/lib/mol-model-props/common/custom-property';
+import { TableLegend } from 'molstar/lib/mol-util/legend';
+
+const DefaultColor = Color(0xaaaaaa);
+const ConfidenceColors: { [k: string]: Color } = {
+    'No Score': DefaultColor,
+    'Very low': Color(0xff7d45),
+    'Low': Color(0xffdb13),
+    'Confident': Color(0x65cbf3),
+    'Very high': Color(0x0053d6)
+};
+
+const ConfidenceColorLegend = TableLegend(Object.entries(ConfidenceColors));
+
+export function getAlphaFoldConfidenceColorThemeParams(ctx: ThemeDataContext) {
+    const categories = AlphaFoldConfidence.getCategories(ctx.structure);
+    if (categories.length === 0) {
+        return {
+            type: PD.MappedStatic('score', {
+                'score': PD.Group({})
+            })
+        };
+    }
+
+    return {
+        type: PD.MappedStatic('score', {
+            'score': PD.Group({}),
+            'category': PD.Group({
+                kind: PD.Select(categories[0], PD.arrayToOptions(categories))
+            }, { isFlat: true })
+        })
+    };
+}
+export type AlphaFoldConfidenceColorThemeParams = ReturnType<typeof getAlphaFoldConfidenceColorThemeParams>
+
+export function AlphaFoldConfidenceColorTheme(ctx: ThemeDataContext, props: PD.Values<AlphaFoldConfidenceColorThemeParams>): ColorTheme<AlphaFoldConfidenceColorThemeParams> {
+    let color: LocationColor = () => DefaultColor;
+
+    if (ctx.structure && ctx.structure.models.length > 0 && ctx.structure.models[0].customProperties.has(AlphaFoldConfidenceProvider.descriptor)) {
+        const getColor = (location: StructureElement.Location): Color => {
+            const score: string = AlphaFoldConfidence.getConfidenceScore(location)[1];
+
+            if (props.type.name !== 'score') {
+                const categoryProp = props.type.params.kind;
+                if (score === categoryProp) return ConfidenceColors[score];
+            }
+
+            return ConfidenceColors[score];
+        };
+
+        color = (location: Location) => {
+            if (StructureElement.Location.is(location)) {
+                return getColor(location);
+            }
+            return DefaultColor;
+        };
+    }
+
+    return {
+        factory: AlphaFoldConfidenceColorTheme,
+        granularity: 'group',
+        color,
+        props,
+        description: 'Assigns residue colors according to the AlphaFold Confidence score.',
+        legend: ConfidenceColorLegend
+    };
+}
+
+export const AlphaFoldConfidenceColorThemeProvider: ColorTheme.Provider<AlphaFoldConfidenceColorThemeParams, 'af-confidence'> =  {
+    name: 'af-confidence',
+    label: 'AlphaFold Confidence',
+    category: ColorTheme.Category.Validation,
+    factory: AlphaFoldConfidenceColorTheme,
+    getParams: getAlphaFoldConfidenceColorThemeParams,
+    defaultValues: PD.getDefaultValues(getAlphaFoldConfidenceColorThemeParams({})),
+    isApplicable: (ctx: ThemeDataContext) => AlphaFoldConfidence.isApplicable(ctx.structure?.models[0]),
+    ensureCustomProperties: {
+        attach: (ctx: CustomProperty.Context, data: ThemeDataContext) => data.structure ? AlphaFoldConfidenceProvider.attach(ctx, data.structure.models[0], void 0, true) : Promise.resolve(),
+        detach: (data) => data.structure && AlphaFoldConfidenceProvider.ref(data.structure.models[0], false)
+    }
+};

+ 145 - 0
src/viewer/helpers/af-confidence/prop.ts

@@ -0,0 +1,145 @@
+/**
+ * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Mandar Deshpande <mandar@ebi.ac.uk>
+ * @author David Sehnal <david.sehnal@gmail.com>
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
+ */
+
+import { Column, Table } from 'molstar/lib/mol-data/db';
+import { toTable } from 'molstar/lib/mol-io/reader/cif/schema';
+import { IndexedCustomProperty, Model, ResidueIndex, Unit } from 'molstar/lib/mol-model/structure';
+import { Structure, StructureElement } from 'molstar/lib/mol-model/structure/structure';
+import { ParamDefinition as PD } from 'molstar/lib/mol-util/param-definition';
+import { MmcifFormat } from 'molstar/lib/mol-model-formats/structure/mmcif';
+import { PropertyWrapper } from 'molstar/lib/mol-model-props/common/wrapper';
+import { CustomProperty } from 'molstar/lib/mol-model-props/common/custom-property';
+import { CustomModelProperty } from 'molstar/lib/mol-model-props/common/custom-model-property';
+import { CustomPropertyDescriptor } from 'molstar/lib/mol-model/custom-property';
+import { arraySetAdd } from 'molstar/lib/mol-util/array';
+import { dateToUtcString } from 'molstar/lib/mol-util/date';
+
+export type AlphaFoldConfidence = PropertyWrapper<{
+    score: IndexedCustomProperty.Residue<[number, string]>,
+    category: string[]
+}>
+
+export namespace AlphaFoldConfidence {
+    export function isApplicable(model?: Model): boolean {
+        if (!model || !MmcifFormat.is(model.sourceData)) return false;
+        return model.sourceData.data.frame.categoryNames.includes('ma_qa_metric_local');
+    }
+
+    export interface Info {
+        timestamp_utc: string
+    }
+
+    export const Schema = {
+        local_metric_values: {
+            label_asym_id: Column.Schema.str,
+            label_comp_id: Column.Schema.str,
+            label_seq_id: Column.Schema.int,
+            metric_id: Column.Schema.int,
+            metric_value: Column.Schema.float,
+            model_id: Column.Schema.int,
+            ordinal_id: Column.Schema.int
+        }
+    };
+    export type Schema = typeof Schema;
+
+    function tryGetInfoFromCif(categoryName: string, model: Model): undefined | Info {
+        if (!MmcifFormat.is(model.sourceData) || !model.sourceData.data.frame.categoryNames.includes(categoryName)) {
+            return;
+        }
+
+        const timestampField = model.sourceData.data.frame.categories[categoryName].getField('metric_value');
+        if (!timestampField || timestampField.rowCount === 0) return;
+
+        return { timestamp_utc: timestampField.str(0) || dateToUtcString(new Date()) };
+    }
+
+
+    export function fromCif(ctx: CustomProperty.Context, model: Model): AlphaFoldConfidence | undefined {
+        const info = tryGetInfoFromCif('ma_qa_metric_local', model);
+        if (!info) return;
+        const data = getCifData(model);
+        const metricMap = createScoreMapFromCif(model, data.residues);
+        return { info, data: metricMap };
+    }
+
+    export async function obtain(ctx: CustomProperty.Context, model: Model, _props: AlphaFoldConfidenceProps): Promise<CustomProperty.Data<any>> {
+        const cif = fromCif(ctx, model);
+        return { value: cif };
+    }
+
+    export function getConfidenceScore(e: StructureElement.Location): [number, string] {
+        if (!Unit.isAtomic(e.unit)) return [-1, 'No Score'];
+        const prop = AlphaFoldConfidenceProvider.get(e.unit.model).value;
+        if (!prop || !prop.data) return [-1, 'No Score'];
+        const rI = e.unit.residueIndex[e.element];
+        return prop.data.score.has(rI) ? prop.data.score.get(rI)! : [-1, 'No Score'];
+    }
+
+    const _emptyArray: string[] = [];
+    export function getCategories(structure?: Structure) {
+        if (!structure) return _emptyArray;
+        const prop = AlphaFoldConfidenceProvider.get(structure.models[0]).value;
+        if (!prop || !prop.data) return _emptyArray;
+        return prop.data.category;
+    }
+
+    function getCifData(model: Model) {
+        if (!MmcifFormat.is(model.sourceData)) throw new Error('Data format must be mmCIF.');
+        return {
+            residues: toTable(Schema.local_metric_values, model.sourceData.data.frame.categories.ma_qa_metric_local),
+        };
+    }
+}
+
+export const AlphaFoldConfidenceParams = {};
+export type AlphaFoldConfidenceParams = typeof AlphaFoldConfidenceParams
+export type AlphaFoldConfidenceProps = PD.Values<AlphaFoldConfidenceParams>
+
+export const AlphaFoldConfidenceProvider: CustomModelProperty.Provider<AlphaFoldConfidenceParams, AlphaFoldConfidence> = CustomModelProperty.createProvider({
+    label: 'AlphaFold Confidence Score',
+    descriptor: CustomPropertyDescriptor({
+        name: 'af_confidence_score'
+    }),
+    type: 'static',
+    defaultParams: AlphaFoldConfidenceParams,
+    getParams: () => AlphaFoldConfidenceParams,
+    isApplicable: (data: Model) => AlphaFoldConfidence.isApplicable(data),
+    obtain: async (ctx: CustomProperty.Context, data: Model, props: Partial<AlphaFoldConfidenceProps>) => {
+        const p = { ...PD.getDefaultValues(AlphaFoldConfidenceParams), ...props };
+        return await AlphaFoldConfidence.obtain(ctx, data, p);
+    }
+});
+
+function createScoreMapFromCif(modelData: Model, residueData: Table<typeof AlphaFoldConfidence.Schema.local_metric_values>): AlphaFoldConfidence['data'] {
+    const { label_asym_id, label_seq_id, metric_value, _rowCount } = residueData;
+
+    const ret = new Map<ResidueIndex, [number, string]>();
+    const categories: string[] = [];
+
+    const toCategory = (v: number): 'Very low' | 'Low' | 'Confident' | 'Very high' => {
+        if (v > 50 && v <= 70)  return 'Low';
+        if (v > 70 && v <= 90) return 'Confident';
+        if (v > 90) return 'Very high';
+        return 'Very low';
+    };
+
+    for (let i = 0; i < _rowCount; i++) {
+        const confidenceScore = metric_value.value(i);
+        const idx = modelData.atomicHierarchy.index.findResidue('1', label_asym_id.value(i), label_seq_id.value(i));
+        const confidenceCategory = toCategory(confidenceScore);
+
+        ret.set(idx, [confidenceScore, confidenceCategory]);
+        arraySetAdd(categories, confidenceCategory);
+    }
+
+    return {
+        score: IndexedCustomProperty.fromResidueMap(ret),
+        category: categories
+    };
+}

+ 27 - 8
src/viewer/helpers/preset.ts

@@ -238,7 +238,20 @@ export const RcsbPreset = TrajectoryHierarchyPresetProvider({
         } else if (p.kind === 'empty') {
             console.warn('Using empty representation');
         } else if (p.kind === 'membrane') {
-            representation = await plugin.builders.structure.representation.applyPreset(structureProperties!, MembraneOrientationPreset);
+            try {
+                representation = await plugin.builders.structure.representation.applyPreset(structureProperties!, MembraneOrientationPreset);
+
+                // reset the camera because the membranes render 1st and the structure might not be fully visible
+                requestAnimationFrame(() => plugin.canvas3d?.requestCameraReset());
+            } catch (error) {
+                const msg = 'Membrane calculation failed! - This can happen for tiny structures with only a dozen of residues.';
+                plugin.log.error(msg);
+                console.error(msg);
+                console.error(error);
+
+                // fall back to default representation to show something
+                representation = await plugin.builders.structure.representation.applyPreset(structureProperties!, 'auto');
+            }
         } else {
             representation = await plugin.builders.structure.representation.applyPreset(structureProperties!, 'auto');
         }
@@ -340,20 +353,26 @@ function determineAssemblyId(traj: any, p: MotifProps) {
     try {
         // find first assembly that contains all requested structOperIds - if multiple, the first will be returned
         const pdbx_struct_assembly_gen = traj.obj.data.representative.sourceData.data.frame.categories.pdbx_struct_assembly_gen;
-        const assembly_id = pdbx_struct_assembly_gen.getField('assembly_id');
-        const oper_expression = pdbx_struct_assembly_gen.getField('oper_expression');
-        const asym_id_list = pdbx_struct_assembly_gen.getField('asym_id_list');
+        if (pdbx_struct_assembly_gen) {
+            const assembly_id = pdbx_struct_assembly_gen.getField('assembly_id');
+            const oper_expression = pdbx_struct_assembly_gen.getField('oper_expression');
+            const asym_id_list = pdbx_struct_assembly_gen.getField('asym_id_list');
 
-        for (let i = 0, il = pdbx_struct_assembly_gen.rowCount; i < il; i++) {
-            if (ids.some(val => !equals(oper_expression.str(i), val[0]) || asym_id_list.str(i).indexOf(val[1]) === -1)) continue;
+            for (let i = 0, il = pdbx_struct_assembly_gen.rowCount; i < il; i++) {
+                if (ids.some(val => !equals(oper_expression.str(i), val[0]) || asym_id_list.str(i).indexOf(val[1]) === -1)) continue;
 
-            Object.assign(p, { assemblyId: assembly_id.str(i) });
-            return;
+                Object.assign(p, {assemblyId: assembly_id.str(i)});
+                return;
+            }
+        } else {
+            // this happens e.g. for AlphaFold structures
+            console.warn(`Source file is missing 'pdbx_struct_assembly_gen' category`);
         }
     } catch (error) {
         console.warn(error);
     }
     // default to '1' if error or legitimately not found
+    console.warn(`Could not auto-detect assembly-of-interest. Falling back to '1'`);
     Object.assign(p, { assemblyId: '1' });
 }
 

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

@@ -62,9 +62,9 @@ export type SelectionExpression = {
  * override pre-existing 'operatorName' values.
  * @param targets collection to process
  * @param structure parent structure
- * @param operatorName optional value to which missing operators are set, will default to 'ASM_1' if not specified
+ * @param operatorName optional value to which missing operators are set
  */
-export function normalizeTargets(targets: Target[], structure: Structure, operatorName: string = 'ASM_1'): Target[] {
+export function normalizeTargets(targets: Target[], structure: Structure, operatorName = undefined): Target[] {
     return targets.map(t => {
         if (t.structOperId) {
             const { structOperId, ...others } = t;
@@ -103,7 +103,7 @@ function toOperatorName(structure: Structure, expression: string): string {
  */
 export function createSelectionExpressions(labelBase: string, selection?: Target | Target[]): SelectionExpression[] {
     if (selection) {
-        if ('labelAsymId' in selection && 'labelSeqRange' in selection) {
+        if ('labelAsymId' in selection) {
             const target = selection as Target;
             const residues: number[] = (target.labelSeqRange) ? toRange(target.labelSeqRange!.beg, target.labelSeqRange!.end) : [];
             const test = rangeToTest(target.labelAsymId!, residues);

+ 110 - 0
src/viewer/helpers/superpose/pecos-integration.ts

@@ -0,0 +1,110 @@
+import { Mat4 } from 'molstar/lib/mol-math/linear-algebra';
+
+const ALIGNMENT_URL = 'https://alignment.rcsb.org/api/v1-beta/';
+
+// TODO probably move to selection.ts
+export type Residue = { label_asym_id: string, struct_oper_id?: string, label_seq_id: number };
+export type MotifSelection = { entry_id: string, residue_ids: Residue[] }
+export type MotifAlignmentRequest = {
+    query: {
+        entry_id: string,
+        residue_ids: Residue[]
+    },
+    hits: ({
+        id: string,
+        assembly_id: string,
+    } & MotifSelection)[]
+}
+
+export async function alignMotifs(query: MotifSelection, hit: MotifSelection): Promise<{ rmsd: number, matrix: Mat4 }> {
+    const q = {
+        options: {
+            return_sequence_data: false
+        },
+        context: {
+            mode: 'pairwise',
+            method: {
+                name: 'qcp',
+                parameters: {
+                    atom_pairing_strategy: 'all'
+                }
+            },
+            structures: [
+                {
+                    entry_id: query.entry_id,
+                    residue_ids: convertToPecosIdentifiers(query.residue_ids)
+                },
+                {
+                    entry_id: hit.entry_id,
+                    residue_ids: convertToPecosIdentifiers(hit.residue_ids)
+                }
+            ]
+        }
+    };
+
+    const formData = new FormData();
+    formData.append('query', JSON.stringify(q));
+
+    const r = await fetch(ALIGNMENT_URL + 'structures/submit', { method: 'POST', body: formData });
+
+    if (r.status !== 200) {
+        throw new Error('Failed to submit the job');
+    }
+
+    const uuid = await r.text();
+    const url = ALIGNMENT_URL + 'structures/results?uuid=' + uuid;
+    // polls every 25ms for up to 10 seconds
+    const result = await pollUntilDone(url, 25, 10 * 1000);
+
+    const { alignment_summary, blocks } = result.results[0];
+    return { rmsd: alignment_summary.scores[0].value, matrix: blocks[0].transformations[0] };
+}
+
+// convert strucmotif/arches residue identifiers to the pecos/sierra flavor
+function convertToPecosIdentifiers(identifiers: Residue[]) {
+    return identifiers.map(i => {
+        const o = Object.create(null);
+        Object.assign(o, {
+            asym_id: i.label_asym_id,
+            seq_id: i.label_seq_id
+        });
+        if (i.struct_oper_id) Object.assign(o, { struct_oper_id: i.struct_oper_id });
+        return o;
+    });
+}
+
+// create a promise that resolves after a short delay
+function delay(t: number) {
+    return new Promise(function(resolve) {
+        setTimeout(resolve, t);
+    });
+}
+
+/**
+ * Poll until results are available.
+ * @param url is the URL to request
+ * @param interval is how often to poll
+ * @param timeout is how long to poll waiting for a result (0 means try forever)
+ */
+async function pollUntilDone(url: string, interval: number, timeout: number) {
+    const start = Date.now();
+    async function run(): Promise<any> {
+        const r = await fetch(url);
+        const data = await r.json();
+        if (data.info.status === 'COMPLETE') {
+            // we know we're done here, return from here whatever you
+            // want the final resolved value of the promise to be
+            return data;
+        } else if (data.info.status === 'ERROR') {
+            throw new Error('Failed to complete the job. Error: ' + data.info.message);
+        } else {
+            if (timeout !== 0 && Date.now() - start > timeout) {
+                throw new Error('timeout error on pollUntilDone');
+            } else {
+                // run again with a short delay
+                return delay(interval).then(run);
+            }
+        }
+    }
+    return run();
+}

+ 104 - 57
src/viewer/index.html

@@ -40,28 +40,29 @@
         <div id="viewer"></div>
         <script>
             function getQueryParam(id) {
-                var a = new RegExp(id + '=([^&#=]*)', 'i')
-                var m = a.exec(window.location.search)
+                const a = new RegExp(id + '=([^&#=]*)', 'i')
+                const m = a.exec(window.location.search)
                 return m ? decodeURIComponent(m[1]) : undefined
             }
 
-            var isEmbedded = getQueryParam('embedded') === '1';
+            const isEmbedded = getQueryParam('embedded') === '1';
 
-            var pdbId = getQueryParam('pdbId')
-            var url = getQueryParam('url')
-            var _config = getQueryParam('config')
-            var config = _config && JSON.parse(_config)
-            var _loadPdbIds = getQueryParam('loadPdbIds')
-            var loadPdbIds = _loadPdbIds && JSON.parse(_loadPdbIds)
+            const pdbId = getQueryParam('pdbId')
+            const url = getQueryParam('url')
+            const _config = getQueryParam('config')
+            const config = _config && JSON.parse(_config)
+            const _loadPdbIds = getQueryParam('loadPdbIds')
+            const loadPdbIds = _loadPdbIds && JSON.parse(_loadPdbIds)
 
             // create an instance of the plugin
-            var viewer = new rcsbMolstar.Viewer('viewer', {
+            const viewer = new rcsbMolstar.Viewer('viewer', {
                 showImportControls: !pdbId,
                 showExportControls: true,
                 showSessionControls: !pdbId,
                 layoutShowLog: !pdbId,
                 layoutShowControls: !isEmbedded,
-                showMembraneOrientationPreset: true
+                showMembraneOrientationPreset: true,
+                detachedFromSierra: true // needed when running without sierra
             })
 
             // load pdbId or url
@@ -71,7 +72,7 @@
         </script>
         <div id="menu">
             <h2> RCSB PDB Mol* Viewer - Test Page</h2>
-            Examples
+            <label for="examples">Examples</label>
             <select id="examples" onchange="loadExample(parseInt(this.value))">
                 <option value=''></option>
             </select>
@@ -93,16 +94,21 @@
             <button style="padding: 3px;" onclick="motifs2()">Motifs 2</button>
             &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
 
+            <button style="padding: 3px;" onclick="motifsAlphaFold()">Motifs AF</button>
+            &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+
             <button style="padding: 3px" onclick="propset()">Propset</button>
         </div>
         <script>
 
             function loadExample(index) {
-                var e = examples[index]
-                viewer.loadPdbId(e.id, e.config)
+                const e = examples[index];
+                // let URL, if specified, take precedence over ID
+                if (e.url) return viewer.loadStructureFromUrl(e.url, e.format ?? 'mmcif', e.isBinary ?? false, e.config)
+                if (e.id) return viewer.loadPdbId(e.id, e.config)
             }
 
-            var examples = [
+            const examples = [
                 {
                     id: '1CRN',
                     info: 'small: only polymer',
@@ -333,6 +339,17 @@
                         }
                     }
                 },
+                {
+                    id: '5XES',
+                    // this will fail membrane calculation due to tiny size but should still show default representation
+                    info: 'failing membrane: TK9 NMR structure in SDS micelle',
+                    config: {
+                        props: {
+                            kind: 'membrane',
+                            assemblyId: '1'
+                        }
+                    }
+                },
                 {
                     id: '6WJC',
                     info: 'ligand validation: Muscarinic acetylcholine receptor 1 - muscarinic toxin 7 complex: Focus + Density',
@@ -370,8 +387,8 @@
                             kind: 'motif',
                             assemblyId: '1',
                             targets: [
-                                { labelAsymId: 'A', labelSeqId: 61 },
-                                { labelAsymId: 'A', labelSeqId: 69 },
+                                { labelAsymId: 'A', labelSeqId: 61, operatorName: 'ASM_1' },
+                                { labelAsymId: 'A', labelSeqId: 69, operatorName: 'ASM_1' },
                                 { labelAsymId: 'A', labelSeqId: 87, operatorName: 'ASM_4' }
                             ],
                         }
@@ -385,8 +402,8 @@
                             kind: 'motif',
                             assemblyId: '1',
                             targets: [
-                                { labelAsymId: 'A', labelSeqId: 61 },
-                                { labelAsymId: 'A', labelSeqId: 69 },
+                                { labelAsymId: 'A', labelSeqId: 61, structOperId: '1' },
+                                { labelAsymId: 'A', labelSeqId: 69, structOperId: '1' },
                                 { labelAsymId: 'A', labelSeqId: 87, structOperId: '4' }
                             ],
                         }
@@ -420,13 +437,18 @@
                             ],
                         }
                     }
+                },
+                {
+                    id: 'AF-Q8W3K0-F1',
+                    url: 'https://alphafold.ebi.ac.uk/files/AF-Q8W3K0-F1-model_v1.cif',
+                    info: 'confidence coloring: Probable disease resistance protein At1g58602'
                 }
             ];
 
-            var examplesSelect = document.getElementById('examples');
-            for (var i = 0, il = examples.length; i < il; ++i) {
-                var e = examples[i]
-                var option = document.createElement('option')
+            const examplesSelect = document.getElementById('examples');
+            for (let i = 0, il = examples.length; i < il; ++i) {
+                const e = examples[i]
+                const option = document.createElement('option')
                 Object.assign(option, { text: '[' + e.id + '] ' + e.info, value: i })
                 examplesSelect.appendChild(option)
             }
@@ -454,46 +476,35 @@
             function motifs1() {
                 viewer.clear()
                     .then(function() {
-                        return viewer.loadPdbIds([{
-                            pdbId: '5CBG',
-                            config: {
-                                props: {
-                                    label: '5CBG',
-                                    kind: 'motif',
-                                    // assemblyId: '2', // library should be able to infer assemblyId of the query
-                                    targets: [
-                                        { labelAsymId: 'C', labelSeqId: 30, structOperId: '3' },
-                                        { labelAsymId: 'C', labelSeqId: 32, structOperId: '3' },
-                                        { labelAsymId: 'C', labelSeqId: 34, structOperId: '3' }
-                                    ],
-                                    // color: 13203230
+                        return viewer.loadPdbIds([
+                            {
+                                pdbId: '4CHA',
+                                config: {
+                                    props: {
+                                        kind: 'motif',
+                                        label: '4CHA',
+                                        targets: [
+                                            { labelAsymId: 'B', structOperId: '1', labelSeqId: 42 },
+                                            { labelAsymId: 'B', structOperId: '1', labelSeqId: 87 },
+                                            { labelAsymId: 'C', structOperId: '1', labelSeqId: 47 }
+                                        ]
+                                    }
                                 }
-                            }
-                        }, {
-                            pdbId: '2W02',
-                            config: {
+                            }, {
+                                pdbId: '3RU4',
                                 props: {
-                                    label: '2W02 #1',
                                     kind: 'motif',
-                                    assemblyId: '2',
+                                    assemblyId: '1',
+                                    label: '3RU4 #2: 0.26 Å',
                                     targets: [
-                                        { labelAsymId: 'B', labelSeqId: 519 },
-                                        { labelAsymId: 'B', labelSeqId: 517 },
-                                        { labelAsymId: 'B', labelSeqId: 515 }
-                                    ],
-                                    // color: 4947916
+                                        { labelAsymId: 'D', structOperId: '2', labelSeqId: 42 },
+                                        { labelAsymId: 'D', structOperId: '2', labelSeqId: 87 },
+                                        { labelAsymId: 'E', structOperId: '2', labelSeqId: 46 }
+                                    ]
                                 },
-                                matrix: [
-                                    0.7953831708253857, -0.6048923987758781, 0.03835097744411625, 0,
-                                    -0.23732979915044658, -0.2525976533016715, 0.9380133218572628, 0,
-                                    -0.5577097614377604, -0.7551818399893184, -0.344470913935246, 0,
-                                    154.77888998938096, 207.0215940587305, 25.17873980937774, 1
-                                ]
+                                matrix: [-0.049396686, -0.99700946, -0.059431925, 0.0, -0.7568329, 0.076193266, -0.6491522, 0.0, 0.6517392, 0.012914069, -0.7583332, 0.0, 20.371853, 11.498471, 45.705563, 1.0]
                             }
-                        }]);
-                    })
-                    .then(function() {
-                        viewer.resetCamera(0)
+                        ]);
                     });
             }
 
@@ -543,6 +554,42 @@
                     });
             }
 
+            function motifsAlphaFold() {
+                viewer.clear()
+                    .then(function() {
+                        return viewer.loadPdbId('1LAP', {
+                            props: {
+                                label: '1LAP',
+                                kind: 'motif',
+                                targets: [
+                                    {label_asym_id: 'A', label_seq_id: 332, struct_oper_id: '1'},
+                                    {label_asym_id: 'A', label_seq_id: 334, struct_oper_id: '1'},
+                                    {label_asym_id: 'A', label_seq_id: 255, struct_oper_id: '1'},
+                                    {label_asym_id: 'A', label_seq_id: 273, struct_oper_id: '1'},
+                                    {label_asym_id: 'A', label_seq_id: 250, struct_oper_id: '1'}
+                                ],
+                            }
+                        });
+                    })
+                    .then(function() {
+                        const url = 'https://alphafold.ebi.ac.uk/files/AF-F1Q6S1-F1-model_v1.cif';
+                        const label = 'AF-F1Q6S1-F1 @ 0.23 RMSD';
+                        const targets = [
+                            // AF target must be devoid of struct_oper_id
+                            { label_asym_id: 'A', label_seq_id: 260 },
+                            { label_asym_id: 'A', label_seq_id: 265 },
+                            { label_asym_id: 'A', label_seq_id: 283 },
+                            { label_asym_id: 'A', label_seq_id: 342 },
+                            { label_asym_id: 'A', label_seq_id: 344 }
+                        ];
+                        const mat = [-0.471, 0.856, 0.215, 0, 0.405, -0.007, 0.914, 0, 0.784, 0.518, -0.343, 0, 54.981, 65.575, 12.287, 1];
+                        return viewer.loadStructureFromUrl(url, 'mmcif', false, { props: { kind: 'motif', label, targets } }, mat);
+                    })
+                    .then(function() {
+                        viewer.resetCamera(0)
+                    });
+            }
+
             function propset() {
                 viewer.clear()
                     .then(function () {

+ 44 - 5
src/viewer/index.ts

@@ -3,6 +3,8 @@
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  * @author Joan Segura <joan.segura@rcsb.org>
+ * @author Yana Rose <yana.rose@rcsb.org>
+ * @author Sebastian Bittrich <sebastian.bittrich@rcsb.org>
  */
 
 import { BehaviorSubject } from 'rxjs';
@@ -36,8 +38,10 @@ import { DefaultPluginUISpec, PluginUISpec } from 'molstar/lib/mol-plugin-ui/spe
 import { PluginUIContext } from 'molstar/lib/mol-plugin-ui/context';
 import { ANVILMembraneOrientation, MembraneOrientationPreset } from 'molstar/lib/extensions/anvil/behavior';
 import { MembraneOrientationRepresentationProvider } from 'molstar/lib/extensions/anvil/representation';
-import {PluginContext} from 'molstar/lib/mol-plugin/context';
-import {TrajectoryHierarchyPresetProvider} from 'molstar/lib/mol-plugin-state/builder/structure/hierarchy-preset';
+import { MotifAlignmentRequest, alignMotifs } from './helpers/superpose/pecos-integration';
+import { AlphaFoldConfidenceScore } from './helpers/af-confidence/behavior';
+import { PluginContext } from 'molstar/lib/mol-plugin/context';
+import { TrajectoryHierarchyPresetProvider } from 'molstar/lib/mol-plugin-state/builder/structure/hierarchy-preset';
 
 /** package version, filled in at bundle build time */
 declare const __RCSB_MOLSTAR_VERSION__: string;
@@ -52,7 +56,8 @@ const Extensions = {
     'rcsb-assembly-symmetry': PluginSpec.Behavior(RCSBAssemblySymmetry),
     'rcsb-validation-report': PluginSpec.Behavior(RCSBValidationReport),
     'mp4-export': PluginSpec.Behavior(Mp4Export),
-    'anvil-membrane-orientation': PluginSpec.Behavior(ANVILMembraneOrientation)
+    'anvil-membrane-orientation': PluginSpec.Behavior(ANVILMembraneOrientation),
+    'af-confidence': PluginSpec.Behavior(AlphaFoldConfidenceScore)
 };
 
 const DefaultViewerProps = {
@@ -62,6 +67,11 @@ const DefaultViewerProps = {
     showStructureSourceControls: true,
     showSuperpositionControls: true,
     showMembraneOrientationPreset: false,
+    /**
+     * Needed when running outside of sierra. If set to true, the strucmotif UI will use an absolute URL to sierra-prod.
+     * Otherwise, the link will be relative on the current host.
+     */
+    detachedFromSierra: false,
     modelUrlProviders: [
         (pdbId: string) => ({
             url: `https://models.rcsb.org/${pdbId.toLowerCase()}.bcif`,
@@ -158,12 +168,14 @@ export class Viewer {
                 component: false,
                 volume: true,
                 custom: true
-            })
+            }),
+            detachedFromSierra: o.detachedFromSierra
         };
 
         this._plugin.init()
             .then(async () => {
                 // hide 'Membrane Orientation' preset from UI - has to happen 'before' react render, apparently
+                // the corresponding behavior must be registered either way, because the 3d-view uses it (even without appearing in the UI)
                 if (!o.showMembraneOrientationPreset) {
                     this._plugin.builders.structure.representation.unregisterPreset(MembraneOrientationPreset);
                     this._plugin.representation.structure.registry.remove(MembraneOrientationRepresentationProvider);
@@ -242,6 +254,33 @@ export class Viewer {
         this.resetCamera(0);
     }
 
+    async alignMotifs(request: MotifAlignmentRequest) {
+        const { query, hits } = request;
+
+        await this.loadPdbId(query.entry_id,
+            {
+                props: {
+                    kind: 'motif',
+                    label: query.entry_id,
+                    targets: query.residue_ids
+                }
+            });
+
+        for (const hit of hits) {
+            const { rmsd, matrix } = await alignMotifs(query, hit);
+            await this.loadPdbId(hit.entry_id, {
+                props: {
+                    kind: 'motif',
+                    assemblyId: hit.assembly_id,
+                    label: `${hit.entry_id} #${hit.id}: ${rmsd.toFixed(2)} RMSD`,
+                    targets: hit.residue_ids
+                },
+                matrix
+            });
+            this.resetCamera(0);
+        }
+    }
+
     loadStructureFromUrl<P>(url: string, format: BuiltInTrajectoryFormat, isBinary: boolean, config?: {props?: PresetProps; matrix?: Mat4; reprProvider?: TrajectoryHierarchyPresetProvider, params?: P}) {
         return this.customState.modelLoader.load({ fileOrUrl: url, format, isBinary }, config?.props, config?.matrix, config?.reprProvider, config?.params);
     }
@@ -255,7 +294,7 @@ export class Viewer {
     }
 
     handleResize() {
-        this._plugin.layout.events.updated.next();
+        this._plugin.layout.events.updated.next(void 0);
     }
 
     exportLoadedStructures() {

+ 1 - 0
src/viewer/types.ts

@@ -50,6 +50,7 @@ export interface ViewerState {
     showSuperpositionControls: boolean
     modelLoader: ModelLoader
     collapsed: BehaviorSubject<CollapsedState>
+    detachedFromSierra: boolean
 }
 export function ViewerState(plugin: PluginContext) {
     return plugin.customState as ViewerState;

+ 10 - 6
src/viewer/ui/strucmotif.tsx

@@ -24,8 +24,10 @@ import { Vec3 } from 'molstar/lib/mol-math/linear-algebra/3d/vec3';
 import { Structure } from 'molstar/lib/mol-model/structure/structure/structure';
 import { Unit } from 'molstar/lib/mol-model/structure/structure/unit';
 import { UnitIndex } from 'molstar/lib/mol-model/structure/structure/element/element';
+import { ViewerState } from '../types';
 
-const ADVANCED_SEARCH_URL = 'https://rcsb.org/search?query=';
+const ABSOLUTE_ADVANCED_SEARCH_URL = 'https://rcsb.org/search?query=';
+const RELATIVE_ADVANCED_SEARCH_URL = '/search?query=';
 const RETURN_TYPE = '&return_type=assembly';
 const MIN_MOTIF_SIZE = 3;
 const MAX_MOTIF_SIZE = 10;
@@ -39,7 +41,7 @@ const MAX_MOTIF_EXTENT_SQUARED = MAX_MOTIF_EXTENT * MAX_MOTIF_EXTENT;
 export class StrucmotifSubmitControls extends CollapsableControls {
     protected defaultState() {
         return {
-            header: 'Structural Motif Search',
+            header: 'Structure Motif Search',
             isCollapsed: false,
             brand: { accent:  'gray' as const, svg: SearchIconSvg }
         };
@@ -207,15 +209,17 @@ class SubmitControls extends PurePluginUIComponent<{}, { isBusy: boolean, residu
             service: 'strucmotif',
             parameters: {
                 value: {
-                    data: pdbId.values().next().value as string,
+                    entry_id: pdbId.values().next().value as string,
                     residue_ids: residueIds.sort((a, b) => this.sortResidueIds(a, b))
                 },
-                score_cutoff: 0,
+                rmsd_cutoff: 2,
+                atom_pairing_scheme: 'ALL',
                 exchanges: exchanges
             }
         };
         // console.log(query);
-        const url = ADVANCED_SEARCH_URL + encodeURIComponent(JSON.stringify(query)) + RETURN_TYPE;
+        const sierraUrl = (this.plugin.customState as ViewerState).detachedFromSierra ? ABSOLUTE_ADVANCED_SEARCH_URL : RELATIVE_ADVANCED_SEARCH_URL;
+        const url = sierraUrl + encodeURIComponent(JSON.stringify(query)) + RETURN_TYPE;
         // console.log(url);
         window.open(url, '_blank');
     }
@@ -281,7 +285,7 @@ class SubmitControls extends PurePluginUIComponent<{}, { isBusy: boolean, residu
         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}>
+                <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 }} />

+ 8 - 2
webpack.config.js

@@ -42,16 +42,22 @@ const sharedConfig = {
             'node_modules',
             path.resolve(__dirname, 'build/src/')
         ],
+        fallback: {
+            fs: false,
+            buffer: require.resolve('buffer'),
+            crypto: require.resolve('crypto-browserify'),
+            path: require.resolve('path-browserify'),
+            stream: require.resolve('stream-browserify')
+        }
     },
     watchOptions: {
         aggregateTimeout: 750
     },
-    devtool: ''
+    devtool: false
 };
 
 module.exports = [
     {
-        node: { fs: 'empty' },
         entry: path.resolve(__dirname, `build/src/viewer/index.js`),
         output: {
             library: 'rcsbMolstar',