Browse Source

Merge branch 'master' of https://github.com/molstar/molstar-proto

Alexander Rose 6 years ago
parent
commit
db6b2ed7ca

+ 162 - 0
src/apps/viewer/extensions/jolecule.ts

@@ -0,0 +1,162 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { StateTree, StateBuilder, StateAction } from 'mol-state';
+import { StateTransforms } from 'mol-plugin/state/transforms';
+import { createModelTree, complexRepresentation } from 'mol-plugin/state/actions/structure';
+import { PluginContext } from 'mol-plugin/context';
+import { PluginStateObject } from 'mol-plugin/state/objects';
+import { ParamDefinition } from 'mol-util/param-definition';
+import { PluginCommands } from 'mol-plugin/command';
+import { Vec3 } from 'mol-math/linear-algebra';
+import { PluginStateSnapshotManager } from 'mol-plugin/state/snapshots';
+import { MolScriptBuilder as MS } from 'mol-script/language/builder';
+import { Text } from 'mol-geo/geometry/text/text';
+import { UUID } from 'mol-util';
+import { ColorNames } from 'mol-util/color/tables';
+import { Camera } from 'mol-canvas3d/camera';
+import { StructureRepresentation3DHelpers } from 'mol-plugin/state/transforms/representation';
+
+export const CreateJoleculeState = StateAction.build({
+    display: { name: 'Jolecule State' },
+    params: { id: ParamDefinition.Text('1mbo') },
+    from: PluginStateObject.Root
+})(async ({ ref, state, params }, plugin: PluginContext) => {
+    try {
+        const id = params.id.trim().toLowerCase();
+        const data = await plugin.runTask(plugin.fetch({ url: `https://jolecule.appspot.com/pdb/${id}.views.json`, type: 'json' })) as JoleculeSnapshot[];
+
+        data.sort((a, b) => a.order - b.order);
+
+        await PluginCommands.State.RemoveObject.dispatch(plugin, { state, ref });
+        plugin.state.snapshots.clear();
+
+        const template = createTemplate(plugin, state.tree, id);
+        const snapshots = data.map((e, idx) => buildSnapshot(plugin, template, { e, idx, len: data.length }));
+        for (const s of snapshots) {
+            plugin.state.snapshots.add(s);
+        }
+
+        PluginCommands.State.Snapshots.Apply.dispatch(plugin, { id: snapshots[0].snapshot.id });
+    } catch (e) {
+        plugin.log.error(`Jolecule Failed: ${e}`);
+    }
+});
+
+interface JoleculeSnapshot {
+    order: number,
+    distances: { i_atom1: number, i_atom2: number }[],
+    labels: { i_atom: number, text: string }[],
+    camera: { up: Vec3, pos: Vec3, in: Vec3, slab: { z_front: number, z_back: number, zoom: number } },
+    selected: number[],
+    text: string
+}
+
+function createTemplate(plugin: PluginContext, tree: StateTree, id: string) {
+    const b = new StateBuilder.Root(tree);
+    const data = b.toRoot().apply(StateTransforms.Data.Download, { url: `https://www.ebi.ac.uk/pdbe/static/entry/${id}_updated.cif` }, { props: { isGhost: true }});
+    const model = createModelTree(data, 'cif');
+    const structure = model.apply(StateTransforms.Model.StructureFromModel, {}, { ref: 'structure' });
+    complexRepresentation(plugin, structure, { hideWater: true });
+    return b.getTree();
+}
+
+const labelOptions = {
+    ...ParamDefinition.getDefaultValues(Text.Params),
+    sizeFactor: 1.5,
+    offsetX: 1,
+    offsetY: 1,
+    offsetZ: 10,
+    background: true,
+    backgroundMargin: 0.2,
+    backgroundColor: ColorNames.snow,
+    backgroundOpacity: 0.9
+}
+
+// const distanceLabelOptions = {
+//     ...ParamDefinition.getDefaultValues(Text.Params),
+//     sizeFactor: 1,
+//     offsetX: 0,
+//     offsetY: 0,
+//     offsetZ: 10,
+//     background: true,
+//     backgroundMargin: 0.2,
+//     backgroundColor: ColorNames.snow,
+//     backgroundOpacity: 0.9
+// }
+
+function buildSnapshot(plugin: PluginContext, template: StateTree, params: { e: JoleculeSnapshot, idx: number, len: number }): PluginStateSnapshotManager.Entry {
+    const b = new StateBuilder.Root(template);
+
+    let i = 0;
+    for (const l of params.e.labels) {
+        b.to('structure')
+            .apply(StateTransforms.Model.StructureSelection, { query: createQuery([l.i_atom]), label: `Label ${++i}` })
+            .apply(StateTransforms.Representation.StructureLabels3D, {
+                target: { name: 'static-text', params: { value: l.text || '' } },
+                options: labelOptions
+            });
+    }
+    if (params.e.selected && params.e.selected.length > 0) {
+        b.to('structure')
+            .apply(StateTransforms.Model.StructureSelection, { query: createQuery(params.e.selected), label: `Selected` })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsStatic(plugin, 'ball-and-stick'));
+    }
+    // TODO
+    // for (const l of params.e.distances) {
+    //     b.to('structure')
+    //         .apply(StateTransforms.Model.StructureSelection, { query: createQuery([l.i_atom1, l.i_atom2]), label: `Distance ${++i}` })
+    //         .apply(StateTransforms.Representation.StructureLabels3D, {
+    //             target: { name: 'static-text', params: { value: l. || '' } },
+    //             options: labelOptions
+    //         });
+    // }
+    return PluginStateSnapshotManager.Entry({
+        id: UUID.create22(),
+        data: { tree: StateTree.toJSON(b.getTree()) },
+        camera: {
+            current: getCameraSnapshot(params.e.camera),
+            transitionStyle: 'animate',
+            transitionDurationInMs: 350
+        }
+    }, {
+        name:  params.e.text
+    });
+}
+
+function getCameraSnapshot(e: JoleculeSnapshot['camera']): Camera.Snapshot {
+    const direction = Vec3.sub(Vec3.zero(), e.pos, e.in);
+    Vec3.normalize(direction, direction);
+    const up = Vec3.sub(Vec3.zero(), e.pos, e.up);
+    Vec3.normalize(up, up);
+
+    const s: Camera.Snapshot = {
+        mode: 'perspective',
+        position: Vec3.scaleAndAdd(Vec3.zero(), e.pos, direction, e.slab.zoom),
+        target: e.pos,
+        direction,
+        up,
+        near: e.slab.zoom + e.slab.z_front,
+        far: e.slab.zoom + e.slab.z_back,
+        fogNear: e.slab.zoom + e.slab.z_front,
+        fogFar: e.slab.zoom + e.slab.z_back,
+        fov: Math.PI / 4,
+        zoom: 1
+    };
+    return s;
+}
+
+function createQuery(atomIndices: number[]) {
+    if (atomIndices.length === 0) return MS.struct.generator.empty();
+
+    return MS.struct.generator.atomGroups({
+        'atom-test': atomIndices.length === 1
+            ? MS.core.rel.eq([MS.struct.atomProperty.core.sourceIndex(), atomIndices[0]])
+            : MS.core.set.has([MS.set.apply(null, atomIndices), MS.struct.atomProperty.core.sourceIndex()]),
+        'group-by': 0
+    });
+}

+ 8 - 3
src/apps/viewer/index.ts

@@ -8,6 +8,8 @@ import { createPlugin, DefaultPluginSpec } from 'mol-plugin';
 import './index.html'
 import { PluginContext } from 'mol-plugin/context';
 import { PluginCommands } from 'mol-plugin/command';
+import { PluginSpec } from 'mol-plugin/spec';
+import { CreateJoleculeState } from './extensions/jolecule';
 require('mol-plugin/skin/light.scss')
 
 function getParam(name: string, regex: string): string {
@@ -18,15 +20,18 @@ function getParam(name: string, regex: string): string {
 const hideControls = getParam('hide-controls', `[^&]+`) === '1';
 
 function init() {
-    const plugin = createPlugin(document.getElementById('app')!, {
-        ...DefaultPluginSpec,
+    const spec: PluginSpec = {
+        actions: [...DefaultPluginSpec.actions, PluginSpec.Action(CreateJoleculeState)],
+        behaviors: [...DefaultPluginSpec.behaviors],
+        animations: [...DefaultPluginSpec.animations || []],
         layout: {
             initial: {
                 isExpanded: true,
                 showControls: !hideControls
             }
         }
-    });
+    };
+    const plugin = createPlugin(document.getElementById('app')!, spec);
     trySetSnapshot(plugin);
 }
 

+ 1 - 1
src/mol-plugin/behavior/static/state.ts

@@ -76,7 +76,7 @@ export function RemoveObject(ctx: PluginContext) {
                 curr = parent;
             }
         } else {
-            remove(state, ref);
+            return remove(state, ref);
         }
     });
 }

+ 23 - 23
src/mol-plugin/command.ts

@@ -22,46 +22,46 @@ export const PluginCommands = {
 
         RemoveObject: PluginCommand<{ state: State, ref: StateTransform.Ref, removeParentGhosts?: boolean }>(),
 
-        ToggleExpanded: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }),
-        ToggleVisibility: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }),
-        Highlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }),
-        ClearHighlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }),
+        ToggleExpanded: PluginCommand<{ state: State, ref: StateTransform.Ref }>(),
+        ToggleVisibility: PluginCommand<{ state: State, ref: StateTransform.Ref }>(),
+        Highlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>(),
+        ClearHighlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>(),
 
         Snapshots: {
-            Add: PluginCommand<{ name?: string, description?: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }),
-            Replace: PluginCommand<{ id: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }),
-            Move: PluginCommand<{ id: string, dir: -1 | 1 }>({ isImmediate: true }),
-            Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
-            Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
-            Clear: PluginCommand<{}>({ isImmediate: true }),
+            Add: PluginCommand<{ name?: string, description?: string, params?: PluginState.GetSnapshotParams }>(),
+            Replace: PluginCommand<{ id: string, params?: PluginState.GetSnapshotParams }>(),
+            Move: PluginCommand<{ id: string, dir: -1 | 1 }>(),
+            Remove: PluginCommand<{ id: string }>(),
+            Apply: PluginCommand<{ id: string }>(),
+            Clear: PluginCommand<{}>(),
 
-            Upload: PluginCommand<{ name?: string, description?: string, serverUrl: string }>({ isImmediate: true }),
+            Upload: PluginCommand<{ name?: string, description?: string, serverUrl: string }>(),
             Fetch: PluginCommand<{ url: string }>(),
 
-            DownloadToFile: PluginCommand<{ name?: string }>({ isImmediate: true }),
-            OpenFile: PluginCommand<{ file: File }>({ isImmediate: true }),
+            DownloadToFile: PluginCommand<{ name?: string }>(),
+            OpenFile: PluginCommand<{ file: File }>(),
         }
     },
     Interactivity: {
         Structure: {
-            Highlight: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>({ isImmediate: true }),
-            Select: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>({ isImmediate: true })
+            Highlight: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>(),
+            Select: PluginCommand<{ loci: StructureElement.Loci, isOff?: boolean }>()
         }
     },
     Layout: {
-        Update: PluginCommand<{ state: Partial<PluginLayoutStateProps> }>({ isImmediate: true })
+        Update: PluginCommand<{ state: Partial<PluginLayoutStateProps> }>()
     },
     Camera: {
-        Reset: PluginCommand<{}>({ isImmediate: true }),
-        SetSnapshot: PluginCommand<{ snapshot: Camera.Snapshot, durationMs?: number }>({ isImmediate: true }),
+        Reset: PluginCommand<{}>(),
+        SetSnapshot: PluginCommand<{ snapshot: Camera.Snapshot, durationMs?: number }>(),
         Snapshots: {
-            Add: PluginCommand<{ name?: string, description?: string }>({ isImmediate: true }),
-            Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
-            Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
-            Clear: PluginCommand<{}>({ isImmediate: true }),
+            Add: PluginCommand<{ name?: string, description?: string }>(),
+            Remove: PluginCommand<{ id: string }>(),
+            Apply: PluginCommand<{ id: string }>(),
+            Clear: PluginCommand<{}>(),
         }
     },
     Canvas3D: {
-        SetSettings: PluginCommand<{ settings: Partial<Canvas3DProps> }>({ isImmediate: true })
+        SetSettings: PluginCommand<{ settings: Partial<Canvas3DProps> }>()
     }
 }

+ 7 - 48
src/mol-plugin/command/base.ts

@@ -5,8 +5,6 @@
  */
 
 import { PluginContext } from '../context';
-import { LinkedList } from 'mol-data/generic';
-import { RxEventHelper } from 'mol-util/rx-event-helper';
 import { UUID } from 'mol-util';
 
 export { PluginCommand }
@@ -14,24 +12,23 @@ export { PluginCommand }
 interface PluginCommand<T = unknown> {
     readonly id: UUID,
     dispatch(ctx: PluginContext, params: T): Promise<void>,
-    subscribe(ctx: PluginContext, action: PluginCommand.Action<T>): PluginCommand.Subscription,
-    params: { isImmediate: boolean }
+    subscribe(ctx: PluginContext, action: PluginCommand.Action<T>): PluginCommand.Subscription
 }
 
 /** namespace.id must a globally unique identifier */
-function PluginCommand<T>(params?: Partial<PluginCommand<T>['params']>): PluginCommand<T> {
-    return new Impl({ isImmediate: false, ...params });
+function PluginCommand<T>(): PluginCommand<T> {
+    return new Impl();
 }
 
 class Impl<T> implements PluginCommand<T> {
     dispatch(ctx: PluginContext, params: T): Promise<void> {
-        return ctx.commands.dispatch(this, params)
+        return ctx.commands.dispatch(this, params);
     }
     subscribe(ctx: PluginContext, action: PluginCommand.Action<T>): PluginCommand.Subscription {
         return ctx.commands.subscribe(this, action);
     }
     id = UUID.create22();
-    constructor(public params: PluginCommand<T>['params']) {
+    constructor() {
     }
 }
 
@@ -47,19 +44,8 @@ namespace PluginCommand {
 
     export class Manager {
         private subs = new Map<string, Action<any>[]>();
-        private queue = LinkedList<Instance>();
         private disposing = false;
 
-        private ev = RxEventHelper.create();
-
-        readonly behaviour = {
-            locked: this.ev.behavior<boolean>(false)
-        };
-
-        lock(locked: boolean = true) {
-            this.behaviour.locked.next(locked);
-        }
-
         subscribe<T>(cmd: PluginCommand<T>, action: Action<T>): Subscription {
             let actions = this.subs.get(cmd.id);
             if (!actions) {
@@ -97,37 +83,22 @@ namespace PluginCommand {
                     return;
                 }
 
-                const instance: Instance = { cmd, params, resolve, reject };
-
-                if (cmd.params.isImmediate) {
-                    this.resolve(instance);
-                } else {
-                    this.queue.addLast({ cmd, params, resolve, reject });
-                    this.next();
-                }
+                this.resolve({ cmd, params, resolve, reject });
             });
         }
 
         dispose() {
             this.subs.clear();
-            while (this.queue.count > 0) {
-                this.queue.removeFirst();
-            }
         }
 
         private async resolve(instance: Instance) {
             const actions = this.subs.get(instance.cmd.id);
             if (!actions) {
-                try {
-                    instance.resolve();
-                } finally {
-                    if (!instance.cmd.params.isImmediate && !this.disposing) this.next();
-                }
+                instance.resolve();
                 return;
             }
 
             try {
-                if (!instance.cmd.params.isImmediate) this.executing = true;
                 // TODO: should actions be called "asynchronously" ("setImmediate") instead?
                 for (const a of actions) {
                     await a(instance.params);
@@ -135,19 +106,7 @@ namespace PluginCommand {
                 instance.resolve();
             } catch (e) {
                 instance.reject(e);
-            } finally {
-                if (!instance.cmd.params.isImmediate) {
-                    this.executing = false;
-                    if (!this.disposing) this.next();
-                }
             }
         }
-
-        private executing = false;
-        private async next() {
-            if (this.queue.count === 0 || this.executing) return;
-            const instance = this.queue.removeFirst()!;
-            this.resolve(instance);
-        }
     }
 }

+ 1 - 3
src/mol-plugin/context.ts

@@ -76,9 +76,7 @@ export class PluginContext {
         },
         labels: {
             highlight: this.ev.behavior<{ entries: ReadonlyArray<LociLabelEntry> }>({ entries: [] })
-        },
-
-        command: this.commands.behaviour
+        }
     };
 
     readonly canvas3d: Canvas3D;

+ 38 - 27
src/mol-plugin/state/actions/structure.ts

@@ -101,13 +101,13 @@ const DownloadStructure = StateAction.build({
                 })
             }, { isFlat: true })
         }, {
-            options: [
-                ['pdbe-updated', 'PDBe Updated'],
-                ['rcsb', 'RCSB'],
-                ['bcif-static', 'BinaryCIF (static PDBe Updated)'],
-                ['url', 'URL']
-            ]
-        })
+                options: [
+                    ['pdbe-updated', 'PDBe Updated'],
+                    ['rcsb', 'RCSB'],
+                    ['bcif-static', 'BinaryCIF (static PDBe Updated)'],
+                    ['url', 'URL']
+                ]
+            })
     }
 })(({ params, state }, ctx: PluginContext) => {
     const b = state.build();
@@ -143,7 +143,7 @@ const DownloadStructure = StateAction.build({
         createStructureTree(ctx, traj, supportProps);
     } else {
         for (const download of downloadParams) {
-            const data = b.toRoot().apply(StateTransforms.Data.Download, download, { props: { isGhost: true }});
+            const data = b.toRoot().apply(StateTransforms.Data.Download, download, { props: { isGhost: true } });
             const traj = createModelTree(data, src.name === 'url' ? src.params.format : 'cif');
             createStructureTree(ctx, traj, supportProps)
         }
@@ -172,18 +172,18 @@ function createSingleTrajectoryModel(sources: StateTransformer.Params<Download>[
         .apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 });
 }
 
-function createModelTree(b: StateBuilder.To<PluginStateObject.Data.Binary | PluginStateObject.Data.String>, format: 'pdb' | 'cif' | 'gro' = 'cif') {
+export function createModelTree(b: StateBuilder.To<PluginStateObject.Data.Binary | PluginStateObject.Data.String>, format: 'pdb' | 'cif' | 'gro' = 'cif') {
     let parsed: StateBuilder.To<PluginStateObject.Molecule.Trajectory>
     switch (format) {
         case 'cif':
-            parsed = b.apply(StateTransforms.Data.ParseCif, void 0, { props: { isGhost: true }})
-                .apply(StateTransforms.Model.TrajectoryFromMmCif, void 0, { props: { isGhost: true }})
+            parsed = b.apply(StateTransforms.Data.ParseCif, void 0, { props: { isGhost: true } })
+                .apply(StateTransforms.Model.TrajectoryFromMmCif, void 0, { props: { isGhost: true } })
             break
         case 'pdb':
-            parsed = b.apply(StateTransforms.Model.TrajectoryFromPDB, void 0, { props: { isGhost: true }});
+            parsed = b.apply(StateTransforms.Model.TrajectoryFromPDB, void 0, { props: { isGhost: true } });
             break
         case 'gro':
-            parsed = b.apply(StateTransforms.Model.TrajectoryFromGRO, void 0, { props: { isGhost: true }});
+            parsed = b.apply(StateTransforms.Model.TrajectoryFromGRO, void 0, { props: { isGhost: true } });
             break
         default:
             throw new Error('unsupported format')
@@ -203,19 +203,30 @@ function createStructureTree(ctx: PluginContext, b: StateBuilder.To<PluginStateO
     return root;
 }
 
-function complexRepresentation(ctx: PluginContext, root: StateBuilder.To<PluginStateObject.Molecule.Structure>) {
-    root.apply(StateTransforms.Model.StructureComplexElement, { type: 'atomic-sequence' })
-        .apply(StateTransforms.Representation.StructureRepresentation3D,
-            StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'cartoon'));
-    root.apply(StateTransforms.Model.StructureComplexElement, { type: 'atomic-het' })
-        .apply(StateTransforms.Representation.StructureRepresentation3D,
-            StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'ball-and-stick'));
-    root.apply(StateTransforms.Model.StructureComplexElement, { type: 'water' })
-        .apply(StateTransforms.Representation.StructureRepresentation3D,
-            StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'ball-and-stick', { alpha: 0.51 }));
-    root.apply(StateTransforms.Model.StructureComplexElement, { type: 'spheres' })
-        .apply(StateTransforms.Representation.StructureRepresentation3D,
-            StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'spacefill'));
+export function complexRepresentation(
+    ctx: PluginContext, root: StateBuilder.To<PluginStateObject.Molecule.Structure>,
+    params?: { hideSequence?: boolean, hideHET?: boolean, hideWater?: boolean, hideCoarse?: boolean; }
+) {
+    if (!params || !params.hideSequence) {
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'atomic-sequence' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'cartoon'));
+    }
+    if (!params || !params.hideHET) {
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'atomic-het' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'ball-and-stick'));
+    }
+    if (!params || !params.hideWater) {
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'water' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'ball-and-stick', { alpha: 0.51 }));
+    }
+    if (!params || !params.hideCoarse) {
+        root.apply(StateTransforms.Model.StructureComplexElement, { type: 'spheres' })
+            .apply(StateTransforms.Representation.StructureRepresentation3D,
+                StructureRepresentation3DHelpers.getDefaultParamsStatic(ctx, 'spacefill'));
+    }
 }
 
 export const CreateComplexRepresentation = StateAction.build({
@@ -296,7 +307,7 @@ export const StructureFromSelection = StateAction.build({
 
 
 export const TestBlob = StateAction.build({
-    display: { name: 'Test Blob'},
+    display: { name: 'Test Blob' },
     from: PluginStateObject.Root
 })(({ ref, state }, ctx: PluginContext) => {
 

+ 12 - 0
src/mol-plugin/state/transforms/model.ts

@@ -264,6 +264,7 @@ const StructureSelection = PluginStateTransform.BuiltIn({
     apply({ a, params, cache }) {
         const compiled = compile<Sel>(params.query);
         (cache as { compiled: QueryFn<Sel> }).compiled = compiled;
+        (cache as { source: Structure }).source = a.data;
 
         const result = compiled(new QueryContext(a.data));
         const s = Sel.unionStructure(result);
@@ -274,6 +275,11 @@ const StructureSelection = PluginStateTransform.BuiltIn({
     update: ({ a, b, oldParams, newParams, cache }) => {
         if (oldParams.query !== newParams.query) return StateTransformer.UpdateResult.Recreate;
 
+        if ((cache as { source: Structure }).source === a.data) {
+            return StateTransformer.UpdateResult.Unchanged;
+        }
+        (cache as { source: Structure }).source = a.data;
+
         if (updateStructureFromQuery((cache as { compiled: QueryFn<Sel> }).compiled, a.data, b, newParams.label)) {
             return StateTransformer.UpdateResult.Updated;
         }
@@ -298,6 +304,7 @@ const UserStructureSelection = PluginStateTransform.BuiltIn({
         const query = transpileMolScript(parsed[0]);
         const compiled = compile<Sel>(query);
         (cache as { compiled: QueryFn<Sel> }).compiled = compiled;
+        (cache as { source: Structure }).source = a.data;
         const result = compiled(new QueryContext(a.data));
         const s = Sel.unionStructure(result);
         const props = { label: `${params.label || 'Selection'}`, description: structureDesc(s) };
@@ -308,6 +315,11 @@ const UserStructureSelection = PluginStateTransform.BuiltIn({
             return StateTransformer.UpdateResult.Recreate;
         }
 
+        if ((cache as { source: Structure }).source === a.data) {
+            return StateTransformer.UpdateResult.Unchanged;
+        }
+        (cache as { source: Structure }).source = a.data;
+
         updateStructureFromQuery((cache as { compiled: QueryFn<Sel> }).compiled, a.data, b, newParams.label);
         return StateTransformer.UpdateResult.Updated;
     }

+ 4 - 10
src/mol-plugin/ui/viewport.tsx

@@ -12,7 +12,7 @@ import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { ParameterControls } from './controls/parameters';
 import { Canvas3DParams } from 'mol-canvas3d/canvas3d';
 import { PluginLayoutStateParams } from 'mol-plugin/layout';
-import { ControlGroup } from './controls/common';
+import { ControlGroup, IconButton } from './controls/common';
 
 interface ViewportState {
     noWebGl: boolean
@@ -59,22 +59,16 @@ export class ViewportControls extends PluginUIComponent {
     }
 
     icon(name: string, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title: string, isOn = true) {
-        return <button
-            className={`msp-btn msp-btn-link msp-btn-link-toggle-${isOn ? 'on' : 'off'}`}
-            onClick={onClick}
-            title={title}>
-                <span className={`msp-icon msp-icon-${name}`}/>
-            </button>
+        return <IconButton icon={name} toggleState={isOn} onClick={onClick} title={title} />;
     }
 
     render() {
-        // TODO: show some icons dimmed etc..
         return <div className={'msp-viewport-controls'}>
             <div className='msp-viewport-controls-buttons'>
                 {this.icon('reset-scene', this.resetCamera, 'Reset Camera')}<br/>
-                {this.icon('settings', this.toggleSettingsExpanded, 'Settings', this.state.isSettingsExpanded)}<br/>
                 {this.icon('tools', this.toggleControls, 'Toggle Controls', this.plugin.layout.state.showControls)}<br/>
-                {this.icon('expand-layout', this.toggleExpanded, 'Toggle Expanded', this.plugin.layout.state.isExpanded)}
+                {this.icon('expand-layout', this.toggleExpanded, 'Toggle Expanded', this.plugin.layout.state.isExpanded)}<br />
+                {this.icon('settings', this.toggleSettingsExpanded, 'Settings', this.state.isSettingsExpanded)}
             </div>
             {this.state.isSettingsExpanded &&
             <div className='msp-viewport-controls-scene-options'>

+ 37 - 17
src/mol-state/state.ts

@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  */
@@ -19,6 +19,7 @@ import { LogEntry } from 'mol-util/log-entry';
 import { now, formatTimespan } from 'mol-util/now';
 import { ParamDefinition } from 'mol-util/param-definition';
 import { StateTreeSpine } from './tree/spine';
+import { AsyncQueue } from 'mol-util/async-queue';
 
 export { State }
 
@@ -122,7 +123,7 @@ class State {
     }
 
     /**
-     * Reconcialites the existing state tree with the new version.
+     * Queues up a reconciliation of the existing state tree.
      *
      * If the tree is StateBuilder.To<T>, the corresponding StateObject is returned by the task.
      * @param tree Tree instance or a tree builder instance
@@ -131,27 +132,44 @@ class State {
     updateTree<T extends StateObject>(tree: StateBuilder.To<T>, options?: Partial<State.UpdateOptions>): Task<T>
     updateTree(tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions>): Task<void>
     updateTree(tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions>): Task<any> {
+        const params: UpdateParams = { tree, options };
         return Task.create('Update Tree', async taskCtx => {
-            this.events.isUpdating.next(true);
-            let updated = false;
-            const ctx = this.updateTreeAndCreateCtx(tree, taskCtx, options);
+            const ok = await this.updateQueue.enqueue(params);
+            if (!ok) return;
+
             try {
-                updated = await update(ctx);
-                if (StateBuilder.isTo(tree)) {
-                    const cell = this.select(tree.ref)[0];
-                    return cell && cell.obj;
-                }
+                const ret = await this._updateTree(taskCtx, params);
+                return ret;
             } finally {
-                this.spine.setSurrent();
+                this.updateQueue.handled(params);
+            }
+        }, () => {
+            this.updateQueue.remove(params);
+        });
+    }
 
-                if (updated) this.events.changed.next();
-                this.events.isUpdating.next(false);
+    private updateQueue = new AsyncQueue<UpdateParams>();
 
-                for (const ref of ctx.stateChanges) {
-                    this.events.cell.stateUpdated.next({ state: this, ref, cellState: this.tree.cellStates.get(ref) });
-                }
+    private async _updateTree(taskCtx: RuntimeContext, params: UpdateParams) {
+        this.events.isUpdating.next(true);
+        let updated = false;
+        const ctx = this.updateTreeAndCreateCtx(params.tree, taskCtx, params.options);
+        try {
+            updated = await update(ctx);
+            if (StateBuilder.isTo(params.tree)) {
+                const cell = this.select(params.tree.ref)[0];
+                return cell && cell.obj;
             }
-        });
+        } finally {
+            this.spine.setSurrent();
+
+            if (updated) this.events.changed.next();
+            this.events.isUpdating.next(false);
+
+            for (const ref of ctx.stateChanges) {
+                this.events.cell.stateUpdated.next({ state: this, ref, cellState: this.tree.cellStates.get(ref) });
+            }
+        }
     }
 
     private updateTreeAndCreateCtx(tree: StateTree | StateBuilder, taskCtx: RuntimeContext, options: Partial<State.UpdateOptions> | undefined) {
@@ -239,6 +257,8 @@ const StateUpdateDefaultOptions: State.UpdateOptions = {
 
 type Ref = StateTransform.Ref
 
+type UpdateParams = { tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions> }
+
 interface UpdateContext {
     parent: State,
     editInfo: StateBuilder.EditInfo | undefined

+ 19 - 1
src/mol-util/array.ts

@@ -56,4 +56,22 @@ export function arrayRms(array: NumberArray) {
 export function fillSerial<T extends NumberArray> (array: T, n?: number) {
     for (let i = 0, il = n ? Math.min(n, array.length) : array.length; i < il; ++i) array[ i ] = i
     return array
-}
+}
+
+export function arrayRemoveInPlace<T>(xs: T[], x: T) {
+    let i = 0, l = xs.length, found = false;
+    for (; i < l; i++) {
+        if (xs[i] === x) {
+            found = true;
+            break;
+        }
+    }
+    if (!found) return false;
+    i++;
+    for (; i < l; i++) {
+        xs[i] = xs[i - 1];
+    }
+    xs.pop();
+    return true;
+}
+(window as any).arrayRem = arrayRemoveInPlace

+ 42 - 0
src/mol-util/async-queue.ts

@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { arrayRemoveInPlace } from './array';
+import { Subject } from 'rxjs';
+
+export class AsyncQueue<T> {
+    private queue: T[] = [];
+    private signal = new Subject<{ v: T, removed: boolean }>();
+
+    enqueue(v: T) {
+        this.queue.push(v);
+        if (this.queue.length === 1) return true;
+        return this.waitFor(v);
+    }
+
+    handled(v: T) {
+        arrayRemoveInPlace(this.queue, v);
+        if (this.queue.length > 0) this.signal.next({ v: this.queue[0], removed: false });
+    }
+
+    remove(v: T) {
+        const rem = arrayRemoveInPlace(this.queue, v);
+        if (rem)
+        this.signal.next({ v, removed: true })
+        return rem;
+    }
+
+    private waitFor(t: T): Promise<boolean> {
+        return new Promise(res => {
+            const sub = this.signal.subscribe(({ v, removed }) => {
+                if (v === t) {
+                    sub.unsubscribe();
+                    res(removed);
+                }
+            });
+        })
+    }
+}