Browse Source

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

Alexander Rose 5 years ago
parent
commit
61f738ce3d
39 changed files with 437 additions and 166 deletions
  1. 3 1
      src/apps/basic-wrapper/index.ts
  2. 3 3
      src/apps/demos/lighting/index.ts
  3. 3 1
      src/examples/proteopedia-wrapper/index.ts
  4. 10 0
      src/mol-data/int/_spec/ordered-set.spec.ts
  5. 41 0
      src/mol-data/int/impl/ordered-set.ts
  6. 1 0
      src/mol-data/int/ordered-set.ts
  7. 1 1
      src/mol-model-formats/structure/mmcif/assembly.ts
  8. 2 25
      src/mol-model/structure/structure/element/loci.ts
  9. 1 1
      src/mol-plugin/behavior/dynamic/labels.ts
  10. 25 23
      src/mol-plugin/behavior/dynamic/representation.ts
  11. 1 1
      src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts
  12. 3 3
      src/mol-plugin/behavior/static/camera.ts
  13. 1 1
      src/mol-plugin/behavior/static/misc.ts
  14. 15 15
      src/mol-plugin/behavior/static/representation.ts
  15. 5 4
      src/mol-plugin/context.ts
  16. 3 3
      src/mol-plugin/skin/base/components/controls-base.scss
  17. 21 0
      src/mol-plugin/skin/base/components/misc.scss
  18. 39 6
      src/mol-plugin/skin/base/components/sequence.scss
  19. 5 0
      src/mol-plugin/skin/base/components/temp.scss
  20. 8 0
      src/mol-plugin/skin/base/icons.scss
  21. 1 2
      src/mol-plugin/skin/base/variables.scss
  22. 2 2
      src/mol-plugin/state.ts
  23. 5 5
      src/mol-plugin/state/actions/structure.ts
  24. 2 2
      src/mol-plugin/state/transforms/representation.ts
  25. 29 5
      src/mol-plugin/ui/controls/parameters.tsx
  26. 5 3
      src/mol-plugin/ui/image.tsx
  27. 14 3
      src/mol-plugin/ui/plugin.tsx
  28. 17 2
      src/mol-plugin/ui/sequence.tsx
  29. 3 2
      src/mol-plugin/ui/sequence/sequence.tsx
  30. 78 4
      src/mol-plugin/ui/state/actions.tsx
  31. 2 1
      src/mol-plugin/ui/state/apply-action.tsx
  32. 5 5
      src/mol-plugin/ui/state/common.tsx
  33. 1 1
      src/mol-plugin/ui/state/update-transform.tsx
  34. 2 2
      src/mol-plugin/ui/structure/selection.tsx
  35. 7 6
      src/mol-plugin/ui/viewport.tsx
  36. 4 4
      src/mol-plugin/util/viewport-screenshot.ts
  37. 47 26
      src/mol-util/data-source.ts
  38. 10 0
      src/mol-util/input/input-observer.ts
  39. 12 3
      src/mol-util/param-definition.ts

+ 3 - 1
src/apps/basic-wrapper/index.ts

@@ -108,11 +108,13 @@ class BasicWrapper {
     }
 
     setBackground(color: number) {
-        const renderer = this.plugin.canvas3d.props.renderer;
+        const renderer = this.plugin.canvas3d!.props.renderer;
         PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { renderer: { ...renderer,  backgroundColor: Color(color) } } });
     }
 
     toggleSpin() {
+        if (!this.plugin.canvas3d) return;
+
         const trackball = this.plugin.canvas3d.props.trackball;
         const spinning = trackball.spin;
         PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { trackball: { ...trackball, spin: !trackball.spin } } });

+ 3 - 3
src/apps/demos/lighting/index.ts

@@ -97,15 +97,15 @@ class LightingDemo {
         PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: {
             ...props,
             multiSample: {
-                ...this.plugin.canvas3d.props.multiSample,
+                ...this.plugin.canvas3d!.props.multiSample,
                 ...props.multiSample
             },
             renderer: {
-                ...this.plugin.canvas3d.props.renderer,
+                ...this.plugin.canvas3d!.props.renderer,
                 ...props.renderer
             },
             postprocessing: {
-                ...this.plugin.canvas3d.props.postprocessing,
+                ...this.plugin.canvas3d!.props.postprocessing,
                 ...props.postprocessing
             },
         }});

+ 3 - 1
src/examples/proteopedia-wrapper/index.ts

@@ -230,11 +230,13 @@ class MolStarProteopediaWrapper {
     }
 
     setBackground(color: number) {
+        if (!this.plugin.canvas3d) return;
         const renderer = this.plugin.canvas3d.props.renderer;
         PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { renderer: { ...renderer,  backgroundColor: Color(color) } } });
     }
 
     toggleSpin() {
+        if (!this.plugin.canvas3d) return;
         const trackball = this.plugin.canvas3d.props.trackball;
         const spinning = trackball.spin;
         PluginCommands.Canvas3D.SetSettings.dispatch(this.plugin, { settings: { trackball: { ...trackball, spin: !trackball.spin } } });
@@ -383,7 +385,7 @@ class MolStarProteopediaWrapper {
             // const position = Vec3.sub(Vec3.zero(), sphere.center, asmCenter);
             // Vec3.normalize(position, position);
             // Vec3.scaleAndAdd(position, sphere.center, position, sphere.radius);
-            const snapshot = this.plugin.canvas3d.camera.getFocus(sphere.center, Math.max(sphere.radius, 5));
+            const snapshot = this.plugin.canvas3d!.camera.getFocus(sphere.center, Math.max(sphere.radius, 5));
             PluginCommands.Camera.SetSnapshot.dispatch(this.plugin, { snapshot, durationMs: 250 });
         }
     }

+ 10 - 0
src/mol-data/int/_spec/ordered-set.spec.ts

@@ -6,6 +6,7 @@
 
 import OrderedSet from '../ordered-set'
 import Interval from '../interval'
+import SortedArray from '../sorted-array';
 
 describe('ordered set', () => {
     function ordSetToArray(set: OrderedSet) {
@@ -163,6 +164,15 @@ describe('ordered set', () => {
     testEq('intersect AA', OrderedSet.intersect(arr136, OrderedSet.ofSortedArray([2, 3, 4, 6, 7])), [3, 6]);
     it('intersect AA1', () => expect(OrderedSet.union(arr136, OrderedSet.ofSortedArray([1, 3, 6]))).toBe(arr136));
 
+    testEq('idxIntersect 1', OrderedSet.indexedIntersect(
+        OrderedSet.ofSortedArray([1, 2, 4]),
+        SortedArray.ofSortedArray([1, 2, 3, 4, 5, 6]),
+        SortedArray.ofSortedArray([2, 4, 5, 8])), [0, 2]);
+    testEq('idxIntersect 2', OrderedSet.indexedIntersect(
+        OrderedSet.ofSortedArray([0, 1]),
+        SortedArray.ofSortedArray([1, 2]),
+        SortedArray.ofSortedArray([1, 2])), [0, 1]);
+
     testEq('subtract ES', OrderedSet.subtract(empty, singleton10), []);
     testEq('subtract ER', OrderedSet.subtract(empty, range1_4), []);
     testEq('subtract EA', OrderedSet.subtract(empty, arr136), []);

+ 41 - 0
src/mol-data/int/impl/ordered-set.ts

@@ -286,4 +286,45 @@ export function forEach(set: OrderedSetImpl, f: (value: number, i: number, ctx:
         }
     }
     return ctx;
+}
+
+
+export function indexedIntersect(idxA: OrderedSetImpl, a: S, b: S): OrderedSetImpl {
+    if (a === b) return idxA;
+    const lenI = size(idxA), lenA = a.length, lenB = b.length;
+    if (lenI === 0 || lenA === 0 || lenB === 0) return Empty;
+
+    const startJ = S.findPredecessorIndex(b, a[min(idxA)]);
+    const endJ = S.findPredecessorIndex(b, a[max(idxA)] + 1);
+
+    let commonCount = 0;
+
+    let offset = 0;
+    let O = 0;
+    let j = startJ;
+    while (O < lenI && j < endJ) {
+        const x = a[getAt(idxA, O)], y = b[j];
+        if (x < y) { O++; }
+        else if (x > y) { j++; }
+        else { commonCount++; O++; j++; }
+    }
+
+    // no common elements
+    if (commonCount === 0) return Empty;
+    // A === B
+    if (commonCount === lenA && commonCount === lenB) return idxA;
+
+    const indices = new Int32Array(commonCount);
+
+    offset = 0;
+    O = 0;
+    j = startJ;
+    while (O < lenI && j < endJ) {
+        const x = a[getAt(idxA, O)], y = b[j];
+        if (x < y) { O++; }
+        else if (x > y) { j++; }
+        else { indices[offset++] = j; O++; j++; }
+    }
+
+    return ofSortedArray(indices);
 }

+ 1 - 0
src/mol-data/int/ordered-set.ts

@@ -39,6 +39,7 @@ namespace OrderedSet {
 
     export const union: <T extends number = number>(a: OrderedSet<T>, b: OrderedSet<T>) => OrderedSet<T> = Base.union as any;
     export const intersect: <T extends number = number>(a: OrderedSet<T>, b: OrderedSet<T>) => OrderedSet<T> = Base.intersect as any;
+    export const indexedIntersect: <T extends number = number, S extends number = number>(idxA: OrderedSet<T>, a: SortedArray<S>, b: SortedArray<S>) => OrderedSet<T> = Base.indexedIntersect as any;
     /** Returns elements of `a` that are not in `b`, i.e `a` - `b` */
     export const subtract: <T extends number = number>(a: OrderedSet<T>, b: OrderedSet<T>) => OrderedSet<T> = Base.subtract as any;
 

+ 1 - 1
src/mol-model-formats/structure/mmcif/assembly.ts

@@ -117,7 +117,7 @@ function getAssemblyOperators(matrices: Matrices, operatorNames: string[][], sta
             Mat4.mul(m, m, matrices.get(op[i])!);
         }
         index++
-        operators[operators.length] = SymmetryOperator.create(`A-${index}`, m, { id: assemblyId, operList: op });
+        operators[operators.length] = SymmetryOperator.create(`ASM_${index}`, m, { id: assemblyId, operList: op });
     }
 
     return operators;

+ 2 - 25
src/mol-model/structure/structure/element/loci.ts

@@ -114,31 +114,8 @@ export namespace Loci {
             if (!structure.unitMap.has(e.unit.id)) return
             const unit = structure.unitMap.get(e.unit.id)
 
-            if (SortedArray.areEqual(e.unit.elements, unit.elements)) {
-                elements.push({ unit, indices: e.indices })
-            } else {
-                const _indices: UnitIndex[] = []
-                const end = unit.elements.length
-                let start = 0
-                for (let i = 0; i < OrderedSet.size(e.indices); ++i) {
-                    const v = OrderedSet.getAt(e.indices, i)
-                    const eI = e.unit.elements[v]
-                    const uI = SortedArray.indexOfInRange(unit.elements, eI, start, end) as UnitIndex | -1
-                    if (uI !== -1) {
-                        _indices.push(uI)
-                        start = uI
-                    }
-                }
-
-                let indices: OrderedSet<UnitIndex>
-                if (_indices.length > 12 && _indices[_indices.length - 1] - _indices[0] === _indices.length - 1) {
-                    indices = Interval.ofRange(_indices[0], _indices[_indices.length - 1])
-                } else {
-                    indices = SortedArray.ofSortedArray(_indices)
-                }
-
-                if (OrderedSet.size(indices) > 0) elements.push({ unit, indices })
-            }
+            const indices = OrderedSet.indexedIntersect(e.indices, e.unit.elements, unit.elements);
+            if (OrderedSet.size(indices) > 0) elements.push({ unit, indices });
         });
 
         return Loci(structure, elements);

+ 1 - 1
src/mol-plugin/behavior/dynamic/labels.ts

@@ -193,7 +193,7 @@ export const SceneLabels = PluginBehavior.create<SceneLabelsProps>({
             }
             if (updated) {
                 Object.assign(this.params, p)
-                this.ctx.canvas3d.add(this.repr)
+                this.ctx.canvas3d?.add(this.repr)
             }
             return updated;
         }

+ 25 - 23
src/mol-plugin/behavior/dynamic/representation.ts

@@ -18,6 +18,7 @@ import { Binding } from '../../../mol-util/binding';
 import { ParamDefinition as PD } from '../../../mol-util/param-definition';
 import { EmptyLoci, Loci } from '../../../mol-model/loci';
 import { Structure } from '../../../mol-model/structure';
+import { arrayMax } from '../../../mol-util/array';
 
 const B = ButtonsType
 const M = ModifiersKeys
@@ -110,31 +111,32 @@ export const SelectLoci = PluginBehavior.create({
             }
         }
         register() {
-            this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, button, modifiers }) => {
+            const actions: [keyof typeof DefaultSelectLociBindings, (current: Interactivity.Loci) => void, ((current: Interactivity.Loci) => boolean) | undefined][] = [
+                ['clickSelect', current => this.ctx.interactivity.lociSelects.select(current), void 0],
+                ['clickToggle', current => this.ctx.interactivity.lociSelects.toggle(current), void 0],
+                ['clickToggleExtend', current => this.ctx.interactivity.lociSelects.toggleExtend(current), void 0],
+                ['clickSelectOnly', current => this.ctx.interactivity.lociSelects.selectOnly(current), void 0],
+                ['clickDeselect', current => this.ctx.interactivity.lociSelects.deselect(current), void 0],
+                ['clickDeselectAllOnEmpty', () => this.ctx.interactivity.lociSelects.deselectAll(), current => Loci.isEmpty(current.loci)],
+            ];
+
+            // sort the action so that the ones with more modifiers trigger sooner.
+            actions.sort((a, b) => {
+                const x = this.params.bindings[a[0]], y = this.params.bindings[b[0]];
+                const k = x.triggers.length === 0 ? 0 : arrayMax(x.triggers.map(t => M.size(t.modifiers)));
+                const l = y.triggers.length === 0 ? 0 : arrayMax(y.triggers.map(t => M.size(t.modifiers)));
+                return l - k;
+            })
+
+            this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, buttons, modifiers }) => {
                 if (!this.ctx.canvas3d) return
 
-                if (Binding.match(this.params.bindings.clickSelect, button, modifiers)) {
-                    this.ctx.interactivity.lociSelects.select(current)
-                }
-
-                if (Binding.match(this.params.bindings.clickToggleExtend, button, modifiers)) {
-                    this.ctx.interactivity.lociSelects.toggleExtend(current)
-                }
-
-                if (Binding.match(this.params.bindings.clickSelectOnly, button, modifiers)) {
-                    this.ctx.interactivity.lociSelects.selectOnly(current)
-                }
-
-                if (Binding.match(this.params.bindings.clickToggle, button, modifiers)) {
-                    this.ctx.interactivity.lociSelects.toggle(current)
-                }
-
-                if (Binding.match(this.params.bindings.clickDeselect, button, modifiers)) {
-                    this.ctx.interactivity.lociSelects.deselect(current)
-                }
-
-                if (Binding.match(this.params.bindings.clickDeselectAllOnEmpty, button, modifiers)) {
-                    if (Loci.isEmpty(current.loci)) this.ctx.interactivity.lociSelects.deselectAll()
+                // only trigger the 1st action that matches
+                for (const [binding, action, condition] of actions) {
+                    if (Binding.match(this.params.bindings[binding], buttons, modifiers) && (!condition || condition(current))) {
+                        action(current);
+                        break;
+                    }
                 }
             });
             this.ctx.interactivity.lociSelects.addProvider(this.lociMarkProvider)

+ 1 - 1
src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts

@@ -248,7 +248,7 @@ const VolumeStreamingVisual = PluginStateTransform.BuiltIn({
 
         const provider = BuiltInVolumeRepresentations.isosurface;
         const props = params.type.params || {}
-        const repr = provider.factory({ webgl: plugin.canvas3d.webgl, ...plugin.volumeRepresentation.themeCtx }, provider.getParams)
+        const repr = provider.factory({ webgl: plugin.canvas3d?.webgl, ...plugin.volumeRepresentation.themeCtx }, provider.getParams)
         repr.setTheme(createTheme(plugin.volumeRepresentation.themeCtx, { volume: channel.data }, params))
         await repr.createOrUpdate(props, channel.data).runInContext(ctx);
         return new SO.Volume.Representation3D({ repr, source: a }, { label: `${Math.round(channel.isoValue.relativeValue * 100) / 100} σ [${srcParams.channel}]` });

+ 3 - 3
src/mol-plugin/behavior/static/camera.ts

@@ -16,13 +16,13 @@ export function registerDefault(ctx: PluginContext) {
 
 export function Reset(ctx: PluginContext) {
     PluginCommands.Camera.Reset.subscribe(ctx, () => {
-        ctx.canvas3d.resetCamera();
+        ctx.canvas3d?.resetCamera();
     })
 }
 
 export function SetSnapshot(ctx: PluginContext) {
     PluginCommands.Camera.SetSnapshot.subscribe(ctx, ({ snapshot, durationMs }) => {
-        ctx.canvas3d.camera.transition.apply(snapshot, durationMs);
+        ctx.canvas3d?.camera.transition.apply(snapshot, durationMs);
     })
 }
 
@@ -36,7 +36,7 @@ export function Snapshots(ctx: PluginContext) {
     });
 
     PluginCommands.Camera.Snapshots.Add.subscribe(ctx, ({ name, description }) => {
-        const entry = CameraSnapshotManager.Entry(ctx.canvas3d.camera.getSnapshot(), name, description);
+        const entry = CameraSnapshotManager.Entry(ctx.canvas3d!.camera.getSnapshot(), name, description);
         ctx.state.cameraSnapshots.add(entry);
     });
 

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

@@ -15,7 +15,7 @@ export function registerDefault(ctx: PluginContext) {
 
 export function Canvas3DSetSettings(ctx: PluginContext) {
     PluginCommands.Canvas3D.SetSettings.subscribe(ctx, e => {
-        ctx.canvas3d.setProps(e.settings);
+        ctx.canvas3d?.setProps(e.settings);
         ctx.events.canvas3d.settingsUpdated.next();
     })
 }

+ 15 - 15
src/mol-plugin/behavior/static/representation.ts

@@ -19,8 +19,8 @@ export function SyncRepresentationToCanvas(ctx: PluginContext) {
     let reprCount = 0;
 
     ctx.events.canvas3d.initialized.subscribe(() => {
-        ctx.canvas3d.reprCount.subscribe(v => {
-            if (reprCount === 0) ctx.canvas3d.resetCamera();
+        ctx.canvas3d?.reprCount.subscribe(v => {
+            if (reprCount === 0) ctx.canvas3d?.resetCamera();
             reprCount = v;
         });
     })
@@ -30,12 +30,12 @@ export function SyncRepresentationToCanvas(ctx: PluginContext) {
         if (!SO.isRepresentation3D(e.obj)) return;
         updateVisibility(e.state.cells.get(e.ref)!, e.obj.data.repr);
         e.obj.data.repr.setState({ syncManually: true });
-        ctx.canvas3d.add(e.obj.data.repr);
+        ctx.canvas3d?.add(e.obj.data.repr);
     });
     events.object.updated.subscribe(e => {
         if (e.oldObj && SO.isRepresentation3D(e.oldObj)) {
-            ctx.canvas3d.remove(e.oldObj.data.repr);
-            ctx.canvas3d.requestDraw(true);
+            ctx.canvas3d?.remove(e.oldObj.data.repr);
+            ctx.canvas3d?.requestDraw(true);
             e.oldObj.data.repr.destroy();
         }
 
@@ -47,12 +47,12 @@ export function SyncRepresentationToCanvas(ctx: PluginContext) {
         if (e.action === 'recreate') {
             e.obj.data.repr.setState({ syncManually: true });
         }
-        ctx.canvas3d.add(e.obj.data.repr);
+        ctx.canvas3d?.add(e.obj.data.repr);
     });
     events.object.removed.subscribe(e => {
         if (!SO.isRepresentation3D(e.obj)) return;
-        ctx.canvas3d.remove(e.obj.data.repr);
-        ctx.canvas3d.requestDraw(true);
+        ctx.canvas3d?.remove(e.obj.data.repr);
+        ctx.canvas3d?.requestDraw(true);
         e.obj.data.repr.destroy();
     });
 }
@@ -65,22 +65,22 @@ export function SyncStructureRepresentation3DState(ctx: PluginContext) {
         if (!SO.Molecule.Structure.Representation3DState.is(e.obj)) return;
         const data = e.obj.data as SO.Molecule.Structure.Representation3DStateData;
         data.source.data.repr.setState(data.state);
-        ctx.canvas3d.update(data.source.data.repr);
-        ctx.canvas3d.requestDraw(true);
+        ctx.canvas3d?.update(data.source.data.repr);
+        ctx.canvas3d?.requestDraw(true);
     });
     events.object.updated.subscribe(e => {
         if (!SO.Molecule.Structure.Representation3DState.is(e.obj)) return;
         const data = e.obj.data as SO.Molecule.Structure.Representation3DStateData;
         data.source.data.repr.setState(data.state);
-        ctx.canvas3d.update(data.source.data.repr);
-        ctx.canvas3d.requestDraw(true);
+        ctx.canvas3d?.update(data.source.data.repr);
+        ctx.canvas3d?.requestDraw(true);
     });
     events.object.removed.subscribe(e => {
         if (!SO.Molecule.Structure.Representation3DState.is(e.obj)) return;
         const data = e.obj.data as SO.Molecule.Structure.Representation3DStateData;
         data.source.data.repr.setState(data.initialState);
-        ctx.canvas3d.update(data.source.data.repr);
-        ctx.canvas3d.requestDraw(true);
+        ctx.canvas3d?.update(data.source.data.repr);
+        ctx.canvas3d?.requestDraw(true);
     });
 }
 
@@ -90,7 +90,7 @@ export function UpdateRepresentationVisibility(ctx: PluginContext) {
         const cell = e.state.cells.get(e.ref)!;
         if (!SO.isRepresentation3D(cell.obj)) return;
         updateVisibility(cell, cell.obj.data.repr);
-        ctx.canvas3d.requestDraw(true);
+        ctx.canvas3d?.requestDraw(true);
     })
 }
 

+ 5 - 4
src/mol-plugin/context.ts

@@ -102,7 +102,7 @@ export class PluginContext {
         }
     } as const
 
-    readonly canvas3d: Canvas3D;
+    readonly canvas3d: Canvas3D | undefined;
     readonly layout = new PluginLayout(this);
     readonly toasts = new PluginToastManager(this);
     readonly interactivity: Interactivity;
@@ -147,12 +147,13 @@ export class PluginContext {
         try {
             this.layout.setRoot(container);
             if (this.spec.layout && this.spec.layout.initial) this.layout.setProps(this.spec.layout.initial);
+
             (this.canvas3d as Canvas3D) = Canvas3D.fromCanvas(canvas, {}, t => this.runTask(t));
             this.events.canvas3d.initialized.next()
             this.events.canvas3d.initialized.isStopped = true // TODO is this a good way?
-            const renderer = this.canvas3d.props.renderer;
+            const renderer = this.canvas3d!.props.renderer;
             PluginCommands.Canvas3D.SetSettings.dispatch(this, { settings: { renderer: { ...renderer, backgroundColor: Color(0xFCFBF9) } } });
-            this.canvas3d.animate();
+            this.canvas3d!.animate();
             (this.helpers.viewportScreenshot as ViewportScreenshotWrapper) = new ViewportScreenshotWrapper(this);
             return true;
         } catch (e) {
@@ -188,7 +189,7 @@ export class PluginContext {
     dispose() {
         if (this.disposed) return;
         this.commands.dispose();
-        this.canvas3d.dispose();
+        this.canvas3d?.dispose();
         this.ev.dispose();
         this.state.dispose();
         this.tasks.dispose();

+ 3 - 3
src/mol-plugin/skin/base/components/controls-base.scss

@@ -125,8 +125,8 @@
         color: $hover-font-color;
         background-color: color-increase-contrast($msp-form-control-background, 5%);
         border: none;
-        outline-offset: -1px;
-        outline: 1px solid color-increase-contrast($msp-form-control-background, 20%);
+        outline-offset: -1px !important;
+        outline: 1px solid color-increase-contrast($msp-form-control-background, 20%) !important;
     }
 
     &:active, &:focus {
@@ -167,7 +167,7 @@ select.msp-form-control {
     background-size: 8px 12px;
     background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAUCAMAAACzvE1FAAAADFBMVEUzMzMzMzMzMzMzMzMKAG/3AAAAA3RSTlMAf4C/aSLHAAAAPElEQVR42q3NMQ4AIAgEQTn//2cLdRKppSGzBYwzVXvznNWs8C58CiussPJj8h6NwgorrKRdTvuV9v16Afn0AYFOB7aYAAAAAElFTkSuQmCC);
     background-repeat: no-repeat;
-    background-position: right $control-spacing top (($row-height - 12px) / 2);
+    background-position: right $control-spacing center;
     padding-right: ($row-height - 8px);
 }
 

+ 21 - 0
src/mol-plugin/skin/base/components/misc.scss

@@ -34,6 +34,7 @@
     top: 0;
     display: table;
     text-align: center;
+    background: $default-background;
 
     > div {
         b {
@@ -99,4 +100,24 @@
             outline: 1px solid color-increase-contrast($msp-form-control-background, 20%) !important;  
         }
     }
+}
+
+.msp-action-select {
+    position: relative;
+
+    select {
+        padding-left: $control-spacing + $row-height;
+    }
+
+    option:first-child {
+        color: color-lower-contrast($font-color, 15%);
+    }
+
+    > .msp-icon {
+        display: block;
+        top: 0;
+        left: $control-spacing;
+        position: absolute;
+        line-height: $row-height;
+    }
 }

+ 39 - 6
src/mol-plugin/skin/base/components/sequence.scss

@@ -7,9 +7,35 @@
     background: $sequence-background;
 }
 
+$sequence-select-height: 22px;
 .msp-sequence-select {
-    float: left;
-    width: $sequence-select-width;
+    position: relative;
+    height: $sequence-select-height;
+    width: 100%;
+    margin-bottom: 1px;
+    background: $control-background;
+    text-align: left;
+
+    > span {
+        display: inline-block;
+        line-height: $sequence-select-height;
+        padding: 0 $control-spacing;
+        font-size: 85%;
+        font-weight: bold;
+        cursor: default;
+    }
+
+    > select {
+        display: inline-block;
+        max-width: 120px;
+        width: auto;
+        text-overflow: ellipsis;
+        font-size: 85%;
+        height: $sequence-select-height;
+        line-height: $sequence-select-height;
+        background-size: 6px 8px;
+        background-color: $control-background;
+    }
 }
 
 .msp-sequence-wrapper {
@@ -17,15 +43,22 @@
     // use $control-spacing for top to have space for sequence numebrs
     padding: $control-spacing $control-spacing $info-vertical-padding $control-spacing;
     user-select: none;
-    height: 100%;
+    width: 100%;
     overflow-y: auto;
     overflow-x: hidden;
-    font-size: 90%;
-    line-height: 180%;
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 0;
 }
 
-.msp-sequence-wrapper-non-empty {
+.msp-sequence-wrapper-non-empty {    
+    font-size: 85%;
+    line-height: 180%;
     font-family: "Courier New", monospace;
+    background: $msp-form-control-background;
+    top: $sequence-select-height + 1px;
 }
 
 .msp-sequence-wrapper {

+ 5 - 0
src/mol-plugin/skin/base/components/temp.scss

@@ -14,11 +14,16 @@
     // border-right: $control-spacing solid $entity-color-Group; // TODO separate color
     border-bottom: 1px solid $entity-color-Group; // TODO separate color
     // border-bottom: 1px solid $entity-color-Group; // TODO separate color
+    cursor: default;
 
     > .msp-icon {
         display: block;
         float: left;
     }
+
+    > small {
+        font-weight: normal;
+    }
 }
 
 .msp-current-header {

+ 8 - 0
src/mol-plugin/skin/base/icons.scss

@@ -219,4 +219,12 @@
 .msp-icon-help-circle-collapse:before {
 	width: 2.5em !important;
 	content: "\e81d\0020\e883";
+}
+
+.msp-icon-flow-cascade:before {
+	content: "\e8d8";
+}
+
+.msp-icon-flow-tree:before {
+	content: "\e8da";
 }

+ 1 - 2
src/mol-plugin/skin/base/variables.scss

@@ -80,5 +80,4 @@ $entity-tag-color: color-lower-contrast($font-color, 20%);
 
 // sequence
 $sequence-background: $default-background;
-$sequence-number-color: $hover-font-color;
-$sequence-select-width: 300px;
+$sequence-number-color: $hover-font-color;

+ 2 - 2
src/mol-plugin/state.ts

@@ -52,12 +52,12 @@ class PluginState {
             animation: p.animation ? this.animation.getSnapshot() : void 0,
             startAnimation: p.startAnimation ? !!p.startAnimation : void 0,
             camera: p.camera ? {
-                current: this.plugin.canvas3d.camera.getSnapshot(),
+                current: this.plugin.canvas3d!.camera.getSnapshot(),
                 transitionStyle: p.cameraTranstion.name,
                 transitionDurationInMs: (params && params.cameraTranstion && params.cameraTranstion.name === 'animate') ? params.cameraTranstion.params.durationInMs : undefined
             } : void 0,
             cameraSnapshots: p.cameraSnapshots ? this.cameraSnapshots.getStateSnapshot() : void 0,
-            canvas3d: p.canvas3d ? { props: this.plugin.canvas3d.props } : void 0,
+            canvas3d: p.canvas3d ? { props: this.plugin.canvas3d?.props } : void 0,
             interactivity: p.interactivity ? { props: this.plugin.interactivity.props } : void 0,
             durationInMs: params && params.durationInMs
         };

+ 5 - 5
src/mol-plugin/state/actions/structure.ts

@@ -103,23 +103,23 @@ const DownloadStructure = StateAction.build({
     params: {
         source: PD.MappedStatic('bcif-static', {
             'pdbe-updated': PD.Group({
-                id: PD.Text('1cbs', { label: 'Id' }),
+                id: PD.Text('1cbs', { label: 'PDB Id(s)', description: 'One or more comma separated PDB ids.' }),
                 options: DownloadStructurePdbIdSourceOptions
             }, { isFlat: true }),
             'rcsb': PD.Group({
-                id: PD.Text('1tqn', { label: 'Id' }),
+                id: PD.Text('1tqn', { label: 'PDB Id(s)', description: 'One or more comma separated PDB ids.' }),
                 options: DownloadStructurePdbIdSourceOptions
             }, { isFlat: true }),
             'pdb-dev': PD.Group({
-                id: PD.Text('PDBDEV_00000001', { label: 'Id' }),
+                id: PD.Text('PDBDEV_00000001', { label: 'PDBDev Id(s)', description: 'One or more comma separated ids.' }),
                 options: DownloadStructurePdbIdSourceOptions
             }, { isFlat: true }),
             'bcif-static': PD.Group({
-                id: PD.Text('1tqn', { label: 'Id' }),
+                id: PD.Text('1tqn', { label: 'PDB Id(s)', description: 'One or more comma separated PDB ids.' }),
                 options: DownloadStructurePdbIdSourceOptions
             }, { isFlat: true }),
             'swissmodel': PD.Group({
-                id: PD.Text('Q9Y2I8', { label: 'UniProtKB AC' }),
+                id: PD.Text('Q9Y2I8', { label: 'UniProtKB AC(s)', description: 'One or more comma separated ACs.' }),
                 options: DownloadStructurePdbIdSourceOptions
             }, { isFlat: true, description: 'Loads the best homology model or experimental structure' }),
             'url': PD.Group({

+ 2 - 2
src/mol-plugin/state/transforms/representation.ts

@@ -204,7 +204,7 @@ const StructureRepresentation3D = PluginStateTransform.BuiltIn({
         return Task.create('Structure Representation', async ctx => {
             const provider = plugin.structureRepresentation.registry.get(params.type.name)
             const props = params.type.params || {}
-            const repr = provider.factory({ webgl: plugin.canvas3d.webgl, ...plugin.structureRepresentation.themeCtx }, provider.getParams)
+            const repr = provider.factory({ webgl: plugin.canvas3d?.webgl, ...plugin.structureRepresentation.themeCtx }, provider.getParams)
             repr.setTheme(createTheme(plugin.structureRepresentation.themeCtx, { structure: a.data }, params))
             // TODO set initial state, repr.setState({})
             await repr.createOrUpdate(props, a.data).runInContext(ctx);
@@ -620,7 +620,7 @@ const VolumeRepresentation3D = PluginStateTransform.BuiltIn({
         return Task.create('Volume Representation', async ctx => {
             const provider = plugin.volumeRepresentation.registry.get(params.type.name)
             const props = params.type.params || {}
-            const repr = provider.factory({ webgl: plugin.canvas3d.webgl, ...plugin.volumeRepresentation.themeCtx }, provider.getParams)
+            const repr = provider.factory({ webgl: plugin.canvas3d?.webgl, ...plugin.volumeRepresentation.themeCtx }, provider.getParams)
             repr.setTheme(createTheme(plugin.volumeRepresentation.themeCtx, { volume: a.data }, params))
             // TODO set initial state, repr.setState({})
             await repr.createOrUpdate(props, a.data).runInContext(ctx);

+ 29 - 5
src/mol-plugin/ui/controls/parameters.tsx

@@ -83,7 +83,7 @@ export class ParamHelp<L extends LegendData> extends React.PureComponent<{ legen
         return <div className='msp-control-row msp-help-text'>
             <div>
                 <div className='msp-help-description'><span className={`msp-icon msp-icon-help-circle`} />{description}</div>
-                <div className='msp-help-legend'>{Legend && <Legend legend={legend} />}</div>
+                {Legend && <div className='msp-help-legend'><Legend legend={legend} /></div>}
             </div>
         </div>
     }
@@ -123,15 +123,17 @@ export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<
         const help = this.props.param.help
             ? this.props.param.help(this.props.value)
             : { description: this.props.param.description, legend: this.props.param.legend }
+        const desc = this.props.param.description;
         const hasHelp = help.description || help.legend
         return <>
             <div className={this.className}>
-                <span title={this.props.param.description}>
+                <span title={desc}>
                     {label}
                     {hasHelp &&
-                        <button className='msp-help msp-btn-link msp-btn-icon msp-control-group-expander' onClick={this.toggleExpanded} title={`${this.state.isExpanded ? 'Hide' : 'Show'} help`}
-                                style={{ background: 'transparent', textAlign: 'left', padding: '0' }}>
-                                <span className={`msp-icon msp-icon-help-circle-${this.state.isExpanded ? 'collapse' : 'expand'}`} />
+                        <button className='msp-help msp-btn-link msp-btn-icon msp-control-group-expander' onClick={this.toggleExpanded}
+                            title={desc || `${this.state.isExpanded ? 'Hide' : 'Show'} help`}
+                            style={{ background: 'transparent', textAlign: 'left', padding: '0' }}>
+                            <span className={`msp-icon msp-icon-help-circle-${this.state.isExpanded ? 'collapse' : 'expand'}`} />
                         </button>
                     }
                 </span>
@@ -264,6 +266,28 @@ export class TextControl extends SimpleParam<PD.Text> {
     }
 }
 
+export class PureSelectControl extends  React.PureComponent<ParamProps<PD.Select<string | number>> & { title?: string }> {
+    protected update(value: string | number) {
+        this.props.onChange({ param: this.props.param, name: this.props.name, value });
+    }
+
+    onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+        if (typeof this.props.param.defaultValue === 'number') {
+            this.update(parseInt(e.target.value, 10));
+        } else {
+            this.update(e.target.value);
+        }
+    }
+
+    render() {
+        const isInvalid = this.props.value !== void 0 && !this.props.param.options.some(e => e[0] === this.props.value);
+        return <select className='msp-form-control' title={this.props.title} value={this.props.value !== void 0 ? this.props.value : this.props.param.defaultValue} onChange={this.onChange} disabled={this.props.isDisabled}>
+            {isInvalid && <option key={this.props.value} value={this.props.value}>{`[Invalid] ${this.props.value}`}</option>}
+            {this.props.param.options.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
+        </select>;
+    }
+}
+
 export class SelectControl extends SimpleParam<PD.Select<string | number>> {
     onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
         if (typeof this.props.param.defaultValue === 'number') {

+ 5 - 3
src/mol-plugin/ui/image.tsx

@@ -42,7 +42,7 @@ export class ImageControls<P, S extends ImageControlsState> extends CollapsableC
     }
 
     private getSize() {
-        return this.state.size === 'canvas' ? {
+        return this.state.size === 'canvas' && this.plugin.canvas3d ? {
             width: this.plugin.canvas3d.webgl.gl.drawingBufferWidth,
             height: this.plugin.canvas3d.webgl.gl.drawingBufferHeight
         } : {
@@ -66,7 +66,7 @@ export class ImageControls<P, S extends ImageControlsState> extends CollapsableC
             h = Math.round(height * (w / width))
         }
         setCanvasSize(this.canvas, w, h)
-        const { pixelRatio } = this.plugin.canvas3d.webgl
+        const pixelRatio = this.plugin.canvas3d?.webgl.pixelRatio || 1
         const pw = Math.round(w * pixelRatio)
         const ph = Math.round(h * pixelRatio)
         const imageData = this.imagePass.getImageData(pw, ph)
@@ -100,12 +100,14 @@ export class ImageControls<P, S extends ImageControlsState> extends CollapsableC
     }
 
     componentDidMount() {
+        if (!this.plugin.canvas3d) return;
+
         this.handlePreview()
 
         this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => {
             this.imagePass.setProps({
                 multiSample: { mode: 'on', sampleLevel: 2 },
-                postprocessing: this.plugin.canvas3d.props.postprocessing
+                postprocessing: this.plugin.canvas3d?.props.postprocessing
             })
             this.handlePreview()
         })

+ 14 - 3
src/mol-plugin/ui/plugin.tsx

@@ -14,7 +14,7 @@ import { PluginContext } from '../context';
 import { PluginReactContext, PluginUIComponent } from './base';
 import { LociLabels, TrajectoryViewportControls, StateSnapshotViewportControls, AnimationViewportControls, StructureToolsWrapper } from './controls';
 import { StateSnapshots } from './state';
-import { StateObjectActions } from './state/actions';
+import { StateObjectActionSelect } from './state/actions';
 import { StateTree } from './state/tree';
 import { BackgroundTaskProgress } from './task';
 import { Viewport, ViewportControls } from './viewport';
@@ -23,6 +23,7 @@ import { UpdateTransformControl } from './state/update-transform';
 import { SequenceView } from './sequence';
 import { Toasts } from './toast';
 import { ImageControls } from './image';
+import { Icon } from './controls/common';
 
 export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
     region(kind: 'left' | 'right' | 'bottom' | 'main', element: JSX.Element) {
@@ -246,8 +247,18 @@ export class CurrentObject extends PluginUIComponent {
         if (!showActions) return null;
 
         return <>
-            {(cell.status === 'ok' || cell.status === 'error') && <UpdateTransformControl state={current.state} transform={transform} /> }
-            {cell.status === 'ok' && <StateObjectActions state={current.state} nodeRef={ref} initiallyCollapsed />}
+            {(cell.status === 'ok' || cell.status === 'error') && <>
+                <div className='msp-section-header' style={{ margin: '0 0 -1px 0' }}>
+                    <Icon name='flow-cascade' />
+                    {`${cell.obj?.label || transform.transformer.definition.display.name}`} <small>{transform.transformer.definition.display.name}</small>
+                </div>
+                <UpdateTransformControl state={current.state} transform={transform} customHeader='none' />
+            </> }
+            {cell.status === 'ok' &&
+                <StateObjectActionSelect state={current.state} nodeRef={ref} plugin={this.plugin} />
+            }
+
+            {/* <StateObjectActions state={current.state} nodeRef={ref} initiallyCollapsed />} */}
         </>;
     }
 }

+ 17 - 2
src/mol-plugin/ui/sequence.tsx

@@ -14,7 +14,7 @@ import { SequenceWrapper } from './sequence/wrapper';
 import { PolymerSequenceWrapper } from './sequence/polymer';
 import { StructureElementSelectionManager } from '../util/structure-element-selection';
 import { MarkerAction } from '../../mol-util/marker-action';
-import { ParameterControls } from './controls/parameters';
+import { PureSelectControl } from './controls/parameters';
 import { ParamDefinition as PD } from '../../mol-util/param-definition';
 import { HeteroSequenceWrapper } from './sequence/hetero';
 import { State, StateSelection } from '../../mol-state';
@@ -295,9 +295,24 @@ export class SequenceView extends PluginUIComponent<{ }, SequenceViewState> {
 
         const sequenceWrapper = this.getSequenceWrapper()
 
+        const params = this.params;
+        const values = this.values;
+
         return <div className='msp-sequence'>
             <div className='msp-sequence-select'>
-                <ParameterControls params={this.params} values={this.values} onChange={this.setParamProps} />
+                <span className={`msp-icon msp-icon-help-circle`} style={{ cursor: 'help', position: 'absolute', right: 0, top: 0 }}
+                    title='This shows a single sequence. Use the controls to show a different sequence.' />
+
+                <span>Sequence of</span>
+                <PureSelectControl param={params.structure} name='structure' value={values.structure} onChange={this.setParamProps} />
+                {/* <span>Entity</span> */}
+                <PureSelectControl title='Entity' param={params.entity} name='entity' value={values.entity} onChange={this.setParamProps} />
+                {/* <span>Chain</span> */}
+                <PureSelectControl title='Chain' param={params.chain} name='chain' value={values.chain} onChange={this.setParamProps} />
+                {params.operator.options.length > 1 && <>
+                    {/* <span>Instance</span> */}
+                    <PureSelectControl title='Instance' param={params.operator} name='operator' value={values.operator} onChange={this.setParamProps} />
+                </>}
             </div>
 
             {typeof sequenceWrapper === 'string'

+ 3 - 2
src/mol-plugin/ui/sequence/sequence.tsx

@@ -214,7 +214,7 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
         for (let i = 0, il = markerArray.length; i < il; i++) {
             if (hasNumbers && i % period === 0 && i < il) o++;
             // o + 1 to account for help icon
-            const span = xs[o + 1] as HTMLSpanElement;
+            const span = xs[o] as HTMLSpanElement;
             if (!span) return;
             o++;
 
@@ -290,6 +290,8 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
         // residue spans are updated as react won't update them
         this.updateMarker()
 
+        // <span className={`msp-icon msp-icon-help`} style={{ cursor: 'help' }} title='This shows a single sequence. Use the menu on the left to show a different sequence.' />
+
         return <div
             className='msp-sequence-wrapper msp-sequence-wrapper-non-empty'
             onContextMenu={this.contextMenu}
@@ -299,7 +301,6 @@ export class Sequence<P extends SequenceProps> extends PluginUIComponent<P> {
             onMouseLeave={this.mouseLeave}
             ref={this.parentDiv}
         >
-            <span className={`msp-icon msp-icon-help`} style={{ cursor: 'help' }} title='This shows a single sequence. Use the menu on the left to show a different sequence.' />
             {elems}
         </div>;
     }

+ 78 - 4
src/mol-plugin/ui/state/actions.tsx

@@ -7,8 +7,9 @@
 import * as React from 'react';
 import { PluginUIComponent } from '../base';
 import { ApplyActionControl } from './apply-action';
-import { State } from '../../../mol-state';
+import { State, StateAction } from '../../../mol-state';
 import { Icon } from '../controls/common';
+import { PluginContext } from '../../context';
 
 export class StateObjectActions extends PluginUIComponent<{ state: State, nodeRef: string, hideHeader?: boolean, initiallyCollapsed?: boolean }> {
     get current() {
@@ -16,9 +17,9 @@ export class StateObjectActions extends PluginUIComponent<{ state: State, nodeRe
     }
 
     componentDidMount() {
-        this.subscribe(this.plugin.state.behavior.currentObject, o => {
-            this.forceUpdate();
-        });
+        // this.subscribe(this.plugin.state.behavior.currentObject, o => {
+        //     this.forceUpdate();
+        // });
 
         this.subscribe(this.plugin.events.state.object.updated, ({ ref, state }) => {
             const current = this.current;
@@ -41,4 +42,77 @@ export class StateObjectActions extends PluginUIComponent<{ state: State, nodeRe
             {actions.map((act, i) => <ApplyActionControl plugin={this.plugin} key={`${act.id}`} state={state} action={act} nodeRef={ref} initiallyCollapsed={this.props.initiallyCollapsed} />)}
         </div>;
     }
+}
+
+interface StateObjectActionSelectProps {
+    plugin: PluginContext,
+    state: State,
+    nodeRef: string
+}
+
+interface StateObjectActionSelectState {
+    state: State,
+    nodeRef: string,
+    version: string,
+    actions: readonly StateAction[],
+    currentActionIndex: number
+}
+
+function createStateObjectActionSelectState(props: StateObjectActionSelectProps): StateObjectActionSelectState {
+    const cell = props.state.cells.get(props.nodeRef)!;
+    const actions = props.state.actions.fromCell(cell, props.plugin);
+    (actions as StateAction[]).sort((a, b) => a.definition.display.name < b.definition.display.name ? -1 : a.definition.display.name === b.definition.display.name ? 0 : 1);
+    return {
+        state: props.state,
+        nodeRef: props.nodeRef,
+        version: cell.transform.version,
+        actions,
+        currentActionIndex: -1
+    }
+}
+
+export class StateObjectActionSelect extends PluginUIComponent<StateObjectActionSelectProps, StateObjectActionSelectState> {
+    state = createStateObjectActionSelectState(this.props);
+
+    get current() {
+        return this.plugin.state.behavior.currentObject.value;
+    }
+
+    static getDerivedStateFromProps(props: StateObjectActionSelectProps, state: StateObjectActionSelectState) {
+        if (state.state !== props.state || state.nodeRef !== props.nodeRef) return createStateObjectActionSelectState(props);
+        const cell = props.state.cells.get(props.nodeRef)!;
+        if (cell.transform.version !== state.version) return createStateObjectActionSelectState(props);
+        return null;
+    }
+
+    componentDidMount() {
+        this.subscribe(this.plugin.events.state.object.updated, ({ ref, state }) => {
+            const current = this.current;
+            if (current.ref !== ref || current.state !== state) return;
+            this.setState(createStateObjectActionSelectState(this.props));
+        });
+    }
+
+    onChange =  (e: React.ChangeEvent<HTMLSelectElement>) => {
+        this.setState({ currentActionIndex: parseInt(e.target.value, 10) });
+    }
+
+    render() {
+        const actions = this.state.actions;
+        if (actions.length === 0) return null;
+
+        const current = this.state.currentActionIndex >= 0 && actions[this.state.currentActionIndex];
+        const title = current ? current.definition.display.description : 'Select Action';
+
+        return <>
+            <div className='msp-contol-row msp-action-select'>
+                <select className='msp-form-control' title={title} value={this.state.currentActionIndex} onChange={this.onChange} style={{ fontWeight: 'bold' }}>
+                    <option key={-1} value={-1} style={{ color: '#999' }}>[ Select Action ]</option>
+                    {actions.map((a, i) => <option key={i} value={i}>{a.definition.display.name}</option>)}
+                </select>
+                <Icon name='flow-tree' />
+            </div>
+            {current && <ApplyActionControl key={current.id} plugin={this.plugin} state={this.props.state} action={current} nodeRef={this.props.nodeRef} hideHeader />}
+        </>;
+    }
 }

+ 2 - 1
src/mol-plugin/ui/state/apply-action.tsx

@@ -19,6 +19,7 @@ namespace ApplyActionControl {
         nodeRef: StateTransform.Ref,
         state: State,
         action: StateAction,
+        hideHeader?: boolean,
         initiallyCollapsed?: boolean
     }
 
@@ -42,7 +43,7 @@ class ApplyActionControl extends TransformControlBase<ApplyActionControl.Props,
     }
     getInfo() { return this._getInfo(this.props.nodeRef, this.props.state.transforms.get(this.props.nodeRef).version); }
     getTransformerId() { return this.props.state.transforms.get(this.props.nodeRef).transformer.id; }
-    getHeader() { return this.props.action.definition.display; }
+    getHeader() { return this.props.hideHeader ? 'none' : this.props.action.definition.display; }
     canApply() { return !this.state.error && !this.state.busy; }
     canAutoApply() { return false; }
     applyText() { return 'Apply'; }

+ 5 - 5
src/mol-plugin/ui/state/common.tsx

@@ -102,7 +102,7 @@ namespace TransformControlBase {
 abstract class TransformControlBase<P, S extends TransformControlBase.ComponentState> extends PurePluginUIComponent<P, S> {
     abstract applyAction(): Promise<void>;
     abstract getInfo(): StateTransformParameters.Props['info'];
-    abstract getHeader(): StateTransformer.Definition['display'];
+    abstract getHeader(): StateTransformer.Definition['display'] | 'none';
     abstract canApply(): boolean;
     abstract getTransformerId(): string;
     abstract canAutoApply(newParams: any): boolean;
@@ -187,12 +187,12 @@ abstract class TransformControlBase<P, S extends TransformControlBase.ComponentS
 
         const { a, b } = this.getSourceAndTarget();
         return <div className={wrapClass}>
-            <div className='msp-transform-header'>
-                <button className='msp-btn msp-btn-block msp-btn-collapse' onClick={this.toggleExpanded} title={display.description}>
-                    <span className={`msp-icon msp-icon-${this.state.isCollapsed ? 'expand' : 'collapse'}`} />
+            {display !== 'none' && <div className='msp-transform-header'>
+                <button className={`msp-btn msp-btn-block${isEmpty ? '' : ' msp-btn-collapse'}`} onClick={this.toggleExpanded} title={display.description}>
+                    {!isEmpty && <span className={`msp-icon msp-icon-${this.state.isCollapsed ? 'expand' : 'collapse'}`} />}
                     {display.name}
                 </button>
-            </div>
+            </div>}
             {!isEmpty && !this.state.isCollapsed && <>
                 <ParamEditor info={info} a={a} b={b} events={this.events} params={this.state.params} isDisabled={this.state.busy} />
 

+ 1 - 1
src/mol-plugin/ui/state/update-transform.tsx

@@ -19,7 +19,7 @@ namespace UpdateTransformControl {
         state: State,
         toggleCollapsed?: Observable<any>,
         initiallyCollapsed?: boolean,
-        customHeader?: StateTransformer.Definition['display']
+        customHeader?: StateTransformer.Definition['display'] | 'none'
     }
 
     export interface ComponentState extends TransformControlBase.ComponentState {

+ 2 - 2
src/mol-plugin/ui/structure/selection.tsx

@@ -63,7 +63,7 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
         const principalAxes = this.plugin.helpers.structureSelectionManager.getPrincipalAxes();
         const { origin, dirA, dirC } = principalAxes.boxAxes
         const radius = Math.max(Vec3.magnitude(dirA) + extraRadius, minRadius);
-        this.plugin.canvas3d.camera.focus(origin, radius, durationMs, dirA, dirC);
+        this.plugin.canvas3d?.camera.focus(origin, radius, durationMs, dirA, dirC);
     }
 
     focusLoci(loci: StructureElement.Loci) {
@@ -72,7 +72,7 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS
             if (this.plugin.helpers.structureSelectionManager.stats.elementCount === 0) return
             const sphere = Sphere3D.fromAxes3D(Sphere3D(), StructureElement.Loci.getPrincipalAxes(loci).boxAxes)
             const radius = Math.max(sphere.radius + extraRadius, minRadius);
-            this.plugin.canvas3d.camera.focus(sphere.center, radius, durationMs);
+            this.plugin.canvas3d?.camera.focus(sphere.center, radius, durationMs);
         }
     }
 

+ 7 - 6
src/mol-plugin/ui/viewport.tsx

@@ -94,9 +94,9 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
                 <ControlGroup header='Interactivity' initialExpanded={true}>
                     <ParameterControls params={Interactivity.Params} values={this.plugin.interactivity.props} onChange={this.setInteractivityProps} />
                 </ControlGroup>
-                <ControlGroup header='Viewport' initialExpanded={true}>
+                {this.plugin.canvas3d && <ControlGroup header='Viewport' initialExpanded={true}>
                     <ParameterControls params={Canvas3DParams} values={this.plugin.canvas3d.props} onChange={this.setSettings} />
-                </ControlGroup>
+                </ControlGroup>}
             </div>}
         </div>
     }
@@ -127,7 +127,7 @@ export class Viewport extends PluginUIComponent<{ }, ViewportState> {
     };
 
     private handleLogo = () => {
-        this.setState({ showLogo: this.plugin.canvas3d.reprCount.value === 0 })
+        this.setState({ showLogo: !this.plugin.canvas3d?.reprCount.value })
     }
 
     private handleResize = () => {
@@ -135,18 +135,19 @@ export class Viewport extends PluginUIComponent<{ }, ViewportState> {
         const canvas = this.canvas.current;
         if (container && canvas) {
             resizeCanvas(canvas, container);
-            this.plugin.canvas3d.handleResize();
+            this.plugin.canvas3d!.handleResize();
         }
     }
 
     componentDidMount() {
         if (!this.canvas.current || !this.container.current || !this.plugin.initViewer(this.canvas.current!, this.container.current!)) {
             this.setState({ noWebGl: true });
+            return;
         }
         this.handleLogo();
         this.handleResize();
 
-        const canvas3d = this.plugin.canvas3d;
+        const canvas3d = this.plugin.canvas3d!;
         this.subscribe(canvas3d.reprCount, this.handleLogo);
         this.subscribe(canvas3d.input.resize, this.handleResize);
 
@@ -163,7 +164,7 @@ export class Viewport extends PluginUIComponent<{ }, ViewportState> {
     }
 
     renderMissing() {
-        return <div>
+        return <div className='msp-no-webgl'>
             <div>
                 <p><b>WebGL does not seem to be available.</b></p>
                 <p>This can be caused by an outdated browser, graphics card driver issue, or bad weather. Sometimes, just restarting the browser helps.</p>

+ 4 - 4
src/mol-plugin/util/viewport-screenshot.ts

@@ -16,8 +16,8 @@ import { download } from '../../mol-util/download';
 export class ViewportScreenshotWrapper {
     private getCanvasSize() {
         return {
-            width: this.plugin.canvas3d.webgl.gl.drawingBufferWidth,
-            height: this.plugin.canvas3d.webgl.gl.drawingBufferHeight
+            width: this.plugin.canvas3d?.webgl.gl.drawingBufferWidth || 0,
+            height: this.plugin.canvas3d?.webgl.gl.drawingBufferHeight || 0
         };
     }
 
@@ -28,10 +28,10 @@ export class ViewportScreenshotWrapper {
     get imagePass() {
         if (this._imagePass) return this._imagePass;
 
-        this._imagePass = this.plugin.canvas3d.getImagePass()
+        this._imagePass = this.plugin.canvas3d!.getImagePass()
         this._imagePass.setProps({
             multiSample: { mode: 'on', sampleLevel: 2 },
-            postprocessing: this.plugin.canvas3d.props.postprocessing
+            postprocessing: this.plugin.canvas3d!.props.postprocessing
         });
         return this._imagePass;
     }

+ 47 - 26
src/mol-util/data-source.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>
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
@@ -67,8 +67,8 @@ function decompress(buffer: Uint8Array): Uint8Array {
     // return gzip.decompress();
 }
 
-async function processFile(ctx: RuntimeContext, asUint8Array: boolean, compressed: boolean, e: any) {
-    const data = (e.target as FileReader).result;
+async function processFile(ctx: RuntimeContext, asUint8Array: boolean, compressed: boolean, fileReader: FileReader) {
+    const data = fileReader.result;
 
     if (compressed) {
         await ctx.update('Decompressing...');
@@ -84,11 +84,31 @@ async function processFile(ctx: RuntimeContext, asUint8Array: boolean, compresse
     }
 }
 
-function readData(ctx: RuntimeContext, action: string, data: XMLHttpRequest | FileReader, asUint8Array: boolean): Promise<any> {
-    return new Promise<any>((resolve, reject) => {
-        data.onerror = (e: any) => {
+function isDone(data: XMLHttpRequest | FileReader) {
+    if (data instanceof FileReader) {
+        return data.readyState === FileReader.DONE
+    } else if (data instanceof XMLHttpRequest) {
+        return data.readyState === XMLHttpRequest.DONE
+    }
+    throw new Error('unknown data type')
+}
+
+function readData<T extends XMLHttpRequest | FileReader>(ctx: RuntimeContext, action: string, data: T, asUint8Array: boolean): Promise<T> {
+    return new Promise<T>((resolve, reject) => {
+        // first check if data reading is already done
+        if (isDone(data)) {
+            const error = (<FileReader>data).error;
+            if (error) {
+                reject((<FileReader>data).error || 'Failed.');
+            } else {
+                resolve(data);
+            }
+            return
+        }
+
+        data.onerror = (e: ProgressEvent) => {
             const error = (<FileReader>e.target).error;
-            reject(error ? error : 'Failed.');
+            reject(error || 'Failed.');
         };
 
         let hasError = false;
@@ -106,7 +126,10 @@ function readData(ctx: RuntimeContext, action: string, data: XMLHttpRequest | Fi
                 reject(e);
             }
         }
-        data.onload = (e: any) => resolve(e);
+
+        data.onload = (e: ProgressEvent) => {
+            resolve(data);
+        }
     });
 }
 
@@ -121,8 +144,8 @@ function readFromFileInternal(file: File, asUint8Array: boolean): Task<string |
             else reader.readAsBinaryString(file);
 
             ctx.update({ message: 'Opening file...', canAbort: true });
-            const e = await readData(ctx, 'Reading...', reader, asUint8Array);
-            const result = processFile(ctx, asUint8Array, isCompressed, e);
+            const fileReader = await readData(ctx, 'Reading...', reader, asUint8Array);
+            const result = processFile(ctx, asUint8Array, isCompressed, fileReader);
             return result;
         } finally {
             reader = void 0;
@@ -156,27 +179,25 @@ class RequestPool {
     }
 }
 
-async function processAjax(ctx: RuntimeContext, asUint8Array: boolean, decompressGzip: boolean, e: any) {
-    const req = (e.target as XMLHttpRequest);
+async function processAjax(ctx: RuntimeContext, asUint8Array: boolean, decompressGzip: boolean, req: XMLHttpRequest) {
     if (req.status >= 200 && req.status < 400) {
-        if (asUint8Array) {
-            const buff = new Uint8Array(e.target.response);
-            RequestPool.deposit(e.target);
+        if (asUint8Array === true) {
+            const buff = new Uint8Array(req.response);
+            RequestPool.deposit(req);
 
             if (decompressGzip) {
                 return decompress(buff);
             } else {
                 return buff;
             }
-        }
-        else {
-            const text = e.target.responseText;
-            RequestPool.deposit(e.target);
+        } else {
+            const text = req.responseText;
+            RequestPool.deposit(req);
             return text;
         }
     } else {
         const status = req.statusText;
-        RequestPool.deposit(e.target);
+        RequestPool.deposit(req);
         throw status;
     }
 }
@@ -196,23 +217,23 @@ function ajaxGetInternal(title: string | undefined, url: string, type: 'json' |
         xhttp.send(body);
 
         await ctx.update({ message: 'Waiting for server...', canAbort: true });
-        const e = await readData(ctx, 'Downloading...', xhttp, asUint8Array);
-        xhttp = void 0;
-        const result = await processAjax(ctx, asUint8Array, decompressGzip, e)
+        const req = await readData(ctx, 'Downloading...', xhttp, asUint8Array);
+        xhttp = void 0; // guard against reuse, help garbage collector
+        const result = await processAjax(ctx, asUint8Array, decompressGzip, req)
 
         if (type === 'json') {
             await ctx.update({ message: 'Parsing JSON...', canAbort: false });
-            return JSON.parse(result);
+            return JSON.parse(result as string);
         } else if (type === 'xml') {
             await ctx.update({ message: 'Parsing XML...', canAbort: false });
-            return parseXml(result);
+            return parseXml(result as string);
         }
 
         return result;
     }, () => {
         if (xhttp) {
             xhttp.abort();
-            xhttp = void 0;
+            xhttp = void 0; // guard against reuse, help garbage collector
         }
     });
 }

+ 10 - 0
src/mol-util/input/input-observer.ts

@@ -74,6 +74,16 @@ export namespace ModifiersKeys {
         return a.shift === b.shift && a.alt === b.alt && a.control === b.control && a.meta === b.meta;
     }
 
+    export function size(a?: ModifiersKeys) {
+        if (!a) return 0;
+        let ret = 0;
+        if (!!a.shift) ret++;
+        if (!!a.alt) ret++;
+        if (!!a.control) ret++;
+        if (!!a.meta) ret++;
+        return ret;
+    }
+
     export function create(modifierKeys: Partial<ModifiersKeys> = {}): ModifiersKeys {
         return {
             shift: !!modifierKeys.shift,

+ 12 - 3
src/mol-util/param-definition.ts

@@ -67,7 +67,7 @@ export namespace ParamDefinition {
         options: [T, string][]
     }
     export function Select<T extends string | number>(defaultValue: T, options: [T, string][], info?: Info): Select<T> {
-        return setInfo<Select<T>>({ type: 'select', defaultValue, options }, info)
+        return setInfo<Select<T>>({ type: 'select', defaultValue: checkDefaultKey(defaultValue, options), options }, info)
     }
 
     export interface ColorList<T extends string> extends Base<T> {
@@ -85,6 +85,7 @@ export namespace ParamDefinition {
         options: [E, string][]
     }
     export function MultiSelect<E extends string, T = E[]>(defaultValue: T, options: [E, string][], info?: Info): MultiSelect<E, T> {
+        // TODO: check if default value is a subset of options?
         return setInfo<MultiSelect<E, T>>({ type: 'multi-select', defaultValue, options }, info)
     }
 
@@ -187,10 +188,11 @@ export namespace ParamDefinition {
         map(name: string): Any
     }
     export function Mapped<T>(defaultKey: string, names: [string, string][], map: (name: string) => Any, info?: Info): Mapped<NamedParams<T>> {
+        const name = checkDefaultKey(defaultKey, names);
         return setInfo<Mapped<NamedParams<T>>>({
             type: 'mapped',
-            defaultValue: { name: defaultKey, params: map(defaultKey).defaultValue as any },
-            select: Select<string>(defaultKey, names, info),
+            defaultValue: { name, params: map(name).defaultValue as any },
+            select: Select<string>(name, names, info),
             map
         }, info);
     }
@@ -354,4 +356,11 @@ export namespace ParamDefinition {
         // a === b was checked at the top.
         return false;
     }
+
+    function checkDefaultKey<T>(k: T, options: [T, string][]) {
+        for (const o of options) {
+            if (o[0] === k) return k;
+        }
+        return options.length > 0 ? options[0][0] : void 0 as any as T;
+    }
 }