Browse Source

Merge branch 'master' into volume

Alexander Rose 6 years ago
parent
commit
97168acc58

+ 0 - 5
.vscode/tasks.json

@@ -9,11 +9,6 @@
             "problemMatcher": [
                 "$tsc"
             ]
-        },
-        {
-            "type": "npm",
-            "script": "app-render-test",
-            "problemMatcher": []
         }
     ]
 }

+ 6 - 0
docs/state/readme.md

@@ -8,6 +8,8 @@ interface Snapshot {
     data?: State.Snapshot,
     // Snapshot of behavior state tree
     behaviour?: State.Snapshot,
+    // Snapshot for current animation,
+    animation?: PluginAnimationManager.Snapshot,
     // Saved camera positions
     cameraSnapshots?: CameraSnapshotManager.StateSnapshot,
     canvas3d?: {
@@ -69,6 +71,10 @@ interface Transform.Props {
 
 "Built-in" data state transforms and description of their parameters are defined in ``mol-plugin/state/transforms``. Behavior transforms are defined in ``mol-plugin/behavior``. Auto-generated documentation for the transforms is also [available](transforms.md).
 
+# Animation State
+
+Defined by ``CameraSnapshotManager.StateSnapshot`` in ``mol-plugin/state/animation/manager.ts``.
+
 # Canvas3D State
 
 Defined by ``Canvas3DParams`` in ``mol-canvas3d/canvas3d.ts``.

+ 33 - 5
docs/state/transforms.md

@@ -7,9 +7,11 @@
 * [ms-plugin.parse-ccp4](#ms-plugin-parse-ccp4)
 * [ms-plugin.parse-dsn6](#ms-plugin-parse-dsn6)
 * [ms-plugin.trajectory-from-mmcif](#ms-plugin-trajectory-from-mmcif)
+* [ms-plugin.trajectory-from-pdb](#ms-plugin-trajectory-from-pdb)
 * [ms-plugin.model-from-trajectory](#ms-plugin-model-from-trajectory)
 * [ms-plugin.structure-from-model](#ms-plugin-structure-from-model)
 * [ms-plugin.structure-assembly-from-model](#ms-plugin-structure-assembly-from-model)
+* [ms-plugin.structure-symmetry-from-model](#ms-plugin-structure-symmetry-from-model)
 * [ms-plugin.structure-selection](#ms-plugin-structure-selection)
 * [ms-plugin.structure-complex-element](#ms-plugin-structure-complex-element)
 * [ms-plugin.custom-model-properties](#ms-plugin-custom-model-properties)
@@ -65,7 +67,7 @@
 
 ----------------------------
 ## <a name="ms-plugin-parse-ccp4"></a>ms-plugin.parse-ccp4 :: Binary -> Ccp4
-*Parse CCP4/MRC from Binary data*
+*Parse CCP4/MRC/MAP from Binary data*
 
 ----------------------------
 ## <a name="ms-plugin-parse-dsn6"></a>ms-plugin.parse-dsn6 :: Binary -> Dsn6
@@ -82,6 +84,9 @@
 ```js
 {}
 ```
+----------------------------
+## <a name="ms-plugin-trajectory-from-pdb"></a>ms-plugin.trajectory-from-pdb :: String -> Trajectory
+
 ----------------------------
 ## <a name="ms-plugin-model-from-trajectory"></a>ms-plugin.model-from-trajectory :: Trajectory -> Model
 *Create a molecular structure from the specified model.*
@@ -104,13 +109,36 @@
 *Create a molecular structure assembly.*
 
 ### Parameters
-- **id**?: String *(Assembly Id. If none specified (undefined or empty string), the asymmetric unit is used.)*
+- **id**?: String *(Assembly Id. Value 'deposited' can be used to specify deposited asymmetric unit.)*
 
 ### Default Parameters
 ```js
 {}
 ```
 ----------------------------
+## <a name="ms-plugin-structure-symmetry-from-model"></a>ms-plugin.structure-symmetry-from-model :: Model -> Structure
+*Create a molecular structure symmetry.*
+
+### Parameters
+- **ijkMin**: 3D vector [x, y, z]
+- **ijkMax**: 3D vector [x, y, z]
+
+### Default Parameters
+```js
+{
+  "ijkMin": [
+    -1,
+    -1,
+    -1
+  ],
+  "ijkMax": [
+    1,
+    1,
+    1
+  ]
+}
+```
+----------------------------
 ## <a name="ms-plugin-structure-selection"></a>ms-plugin.structure-selection :: Structure -> Structure
 *Create a molecular structure from the specified query expression.*
 
@@ -149,7 +177,7 @@
 ```
 ----------------------------
 ## <a name="ms-plugin-volume-from-ccp4"></a>ms-plugin.volume-from-ccp4 :: Ccp4 -> Data
-*Create Volume from CCP4/MRC data*
+*Create Volume from CCP4/MRC/MAP data*
 
 ### Parameters
 - **voxelSize**: 3D vector [x, y, z]
@@ -294,7 +322,7 @@ Object with:
       - **highlightColor**: Color as 0xrrggbb
       - **selectColor**: Color as 0xrrggbb
       - **quality**: One of 'custom', 'auto', 'highest', 'higher', 'high', 'medium', 'low', 'lower', 'lowest'
-      - **isoValue**: Numeric value
+      - **isoValueNorm**: Numeric value *(Normalized Isolevel Value)*
       - **renderMode**: One of 'isosurface', 'volume'
       - **controlPoints**: A list of 2d vectors [xi, yi][]
       - **list**: One of 'OrangeRed', 'PurpleBlue', 'BluePurple', 'Oranges', 'BlueGreen', 'YellowOrangeBrown', 'YellowGreen', 'Reds', 'RedPurple', 'Greens', 'YellowGreenBlue', 'Purples', 'GreenBlue', 'Greys', 'YellowOrangeRed', 'PurpleRed', 'Blues', 'PurpleBlueGreen', 'Spectral', 'RedYellowGreen', 'RedBlue', 'PinkYellowGreen', 'PurpleGreen', 'RedYellowBlue', 'BrownWhiteGreen', 'RedGrey', 'PurpleOrange', 'Set2', 'Accent', 'Set1', 'Set3', 'Dark2', 'Paired', 'Pastel2', 'Pastel1', 'Magma', 'Inferno', 'Plasma', 'Viridis', 'Cividis', 'Twilight', 'Rainbow', 'RedWhiteBlue'
@@ -480,7 +508,7 @@ Object with:
       - **highlightColor**: Color as 0xrrggbb
       - **selectColor**: Color as 0xrrggbb
       - **quality**: One of 'custom', 'auto', 'highest', 'higher', 'high', 'medium', 'low', 'lower', 'lowest'
-      - **isoValue**: Numeric value
+      - **isoValueNorm**: Numeric value *(Normalized Isolevel Value)*
       - **renderMode**: One of 'isosurface', 'volume'
       - **controlPoints**: A list of 2d vectors [xi, yi][]
       - **list**: One of 'OrangeRed', 'PurpleBlue', 'BluePurple', 'Oranges', 'BlueGreen', 'YellowOrangeBrown', 'YellowGreen', 'Reds', 'RedPurple', 'Greens', 'YellowGreenBlue', 'Purples', 'GreenBlue', 'Greys', 'YellowOrangeRed', 'PurpleRed', 'Blues', 'PurpleBlueGreen', 'Spectral', 'RedYellowGreen', 'RedBlue', 'PinkYellowGreen', 'PurpleGreen', 'RedYellowBlue', 'BrownWhiteGreen', 'RedGrey', 'PurpleOrange', 'Set2', 'Accent', 'Set1', 'Set3', 'Dark2', 'Paired', 'Pastel2', 'Pastel1', 'Magma', 'Inferno', 'Plasma', 'Viridis', 'Cividis', 'Twilight', 'Rainbow', 'RedWhiteBlue'

+ 2 - 3
src/mol-canvas3d/controls/trackball.ts

@@ -209,6 +209,8 @@ namespace TrackballControls {
 
         /** Update the object's position, direction and up vectors */
         function update() {
+            if (p.spin) spin();
+
             Vec3.sub(_eye, object.position, target)
 
             rotateCamera()
@@ -300,7 +302,6 @@ namespace TrackballControls {
         function spin() {
             _spinSpeed[0] = (p.spinSpeed || 0) / 1000;
             if (!_isInteracting) Vec2.add(_moveCurr, _movePrev, _spinSpeed);
-            if (p.spin) requestAnimationFrame(spin);
         }
 
         // force an update at start
@@ -313,9 +314,7 @@ namespace TrackballControls {
 
             get props() { return p as Readonly<TrackballControlsProps> },
             setProps: (props: Partial<TrackballControlsProps>) => {
-                const wasSpinning = p.spin
                 Object.assign(p, props)
-                if (p.spin && !wasSpinning) requestAnimationFrame(spin)
             },
 
             update,

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

@@ -12,12 +12,14 @@ export class PluginComponent<State> {
     private _state: BehaviorSubject<State>;
     private _updated = new Subject();
 
-    updateState(...states: Partial<State>[]) {
+    updateState(...states: Partial<State>[]): boolean {
         const latest = this.latestState;
         const s = shallowMergeArray(latest, states);
         if (s !== latest) {
             this._state.next(s);
+            return true;
         }
+        return false;
     }
 
     get states() {

+ 8 - 0
src/mol-plugin/context.ts

@@ -178,6 +178,13 @@ export class PluginContext {
         }
     }
 
+    private initAnimations() {
+        if (!this.spec.animations) return;
+        for (const anim of this.spec.animations) {
+            this.state.animation.register(anim);
+        }
+    }
+
     private initCustomParamEditors() {
         if (!this.spec.customParamEditors) return;
 
@@ -193,6 +200,7 @@ export class PluginContext {
 
         this.initBehaviors();
         this.initDataActions();
+        this.initAnimations();
         this.initCustomParamEditors();
 
         this.lociLabels = new LociLabelManager(this);

+ 4 - 0
src/mol-plugin/index.ts

@@ -14,6 +14,7 @@ import { PluginSpec } from './spec';
 import { DownloadStructure, CreateComplexRepresentation, OpenStructure, OpenVolume, DownloadDensity } from './state/actions/basic';
 import { StateTransforms } from './state/transforms';
 import { PluginBehaviors } from './behavior';
+import { AnimateModelIndex } from './state/animation/built-in';
 
 function getParam(name: string, regex: string): string {
     let r = new RegExp(`${name}=(${regex})[&]?`, 'i');
@@ -48,6 +49,9 @@ export const DefaultPluginSpec: PluginSpec = {
         PluginSpec.Behavior(PluginBehaviors.Labels.SceneLabels),
         PluginSpec.Behavior(PluginBehaviors.CustomProps.PDBeStructureQualityReport, { autoAttach: true }),
         PluginSpec.Behavior(PluginBehaviors.CustomProps.RCSBAssemblySymmetry, { autoAttach: true }),
+    ],
+    animations: [
+        AnimateModelIndex
     ]
 }
 

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

@@ -76,19 +76,10 @@
     > div:first-child {
         position: absolute;
         top: 0;
-        left: 0;
+        left: 18px;
         bottom: 0;
-        right: 50px;
-        width: 100%;
-        padding-right: 50px;
-        display: table;
-        
-        > div {
-            height: $row-height;
-            display: table-cell;
-            vertical-align: middle;
-            padding: 0 ($control-spacing + 4px);
-        }
+        right: 62px;
+        display: grid;
     }
     > div:last-child {
         position: absolute;
@@ -101,9 +92,12 @@
         bottom: 0;
     }
     
-    // input[type=text] {
-    //     text-align: right;
-    // }
+    input[type=text] {
+        padding-right: 6px;
+        padding-left: 4px;
+        font-size: 80%;
+        text-align: right;
+    }
     
     // input[type=range] {
     //     width: 100%;
@@ -125,20 +119,10 @@
     > div:nth-child(2) {
         position: absolute;
         top: 0;
-        left: 0;
+        left: 35px;
         bottom: 0;
-        right: 25px;
-        width: 100%;
-        padding-left: 20px;
-        padding-right: 25px;
-        display: table;
-        
-        > div {
-            height: $row-height;
-            display: table-cell;
-            vertical-align: middle;
-            padding: 0 ($control-spacing + 4px);
-        }
+        right: 37px;
+        display: grid;
     }
     > div:last-child {
         position: absolute;
@@ -152,9 +136,12 @@
         font-size: 80%;
     }
     
-    // input[type=text] {
-    //     text-align: right;
-    // }
+    input[type=text] {
+        padding-right: 4px;
+        padding-left: 4px;
+        font-size: 80%;
+        text-align: center;
+    }
     
     // input[type=range] {
     //     width: 100%;

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

@@ -66,4 +66,8 @@
     background: white;
     cursor: inherit;
     display: block;
+}
+
+.msp-animation-section {
+    margin-bottom: $control-spacing;
 }

+ 1 - 0
src/mol-plugin/skin/base/components/slider.scss

@@ -14,6 +14,7 @@
   padding: 5px 0;
   width: 100%;
   border-radius: $slider-border-radius-base;
+  align-self: center;
   @include borderBox;
 
   &-rail {

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

@@ -8,13 +8,15 @@ import { StateAction } from 'mol-state/action';
 import { Transformer } from 'mol-state';
 import { StateTransformParameters } from './ui/state/common';
 import { PluginLayoutStateProps } from './layout';
+import { PluginStateAnimation } from './state/animation/model';
 
 export { PluginSpec }
 
 interface PluginSpec {
     actions: PluginSpec.Action[],
     behaviors: PluginSpec.Behavior[],
-    customParamEditors?: [StateAction | Transformer, StateTransformParameters.Class][]
+    animations?: PluginStateAnimation[],
+    customParamEditors?: [StateAction | Transformer, StateTransformParameters.Class][],
     initialLayout?: PluginLayoutStateProps
 }
 

+ 9 - 0
src/mol-plugin/state.ts

@@ -13,6 +13,7 @@ import { PluginStateSnapshotManager } from './state/snapshots';
 import { RxEventHelper } from 'mol-util/rx-event-helper';
 import { Canvas3DProps } from 'mol-canvas3d/canvas3d';
 import { PluginCommands } from './command';
+import { PluginAnimationManager } from './state/animation/manager';
 export { PluginState }
 
 class PluginState {
@@ -20,6 +21,7 @@ class PluginState {
 
     readonly dataState: State;
     readonly behaviorState: State;
+    readonly animation: PluginAnimationManager;
     readonly cameraSnapshots = new CameraSnapshotManager();
 
     readonly snapshots = new PluginStateSnapshotManager();
@@ -43,6 +45,7 @@ class PluginState {
         return {
             data: this.dataState.getSnapshot(),
             behaviour: this.behaviorState.getSnapshot(),
+            animation: this.animation.getSnapshot(),
             cameraSnapshots: this.cameraSnapshots.getStateSnapshot(),
             canvas3d: {
                 camera: this.plugin.canvas3d.camera.getSnapshot(),
@@ -60,6 +63,9 @@ class PluginState {
             if (snapshot.canvas3d.camera) this.plugin.canvas3d.camera.setState(snapshot.canvas3d.camera);
         }
         this.plugin.canvas3d.requestDraw(true);
+        if (snapshot.animation) {
+            this.animation.setSnapshot(snapshot.animation);
+        }
     }
 
     dispose() {
@@ -81,6 +87,8 @@ class PluginState {
         });
 
         this.behavior.currentObject.next(this.dataState.behaviors.currentObject.value);
+
+        this.animation = new PluginAnimationManager(plugin);
     }
 }
 
@@ -90,6 +98,7 @@ namespace PluginState {
     export interface Snapshot {
         data?: State.Snapshot,
         behaviour?: State.Snapshot,
+        animation?: PluginAnimationManager.Snapshot,
         cameraSnapshots?: CameraSnapshotManager.StateSnapshot,
         canvas3d?: {
             camera?: Camera.Snapshot,

+ 46 - 0
src/mol-plugin/state/animation/built-in.ts

@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginStateAnimation } from './model';
+import { PluginStateObject } from '../objects';
+import { StateTransforms } from '../transforms';
+import { StateSelection } from 'mol-state/state/selection';
+import { PluginCommands } from 'mol-plugin/command';
+import { ParamDefinition } from 'mol-util/param-definition';
+
+export const AnimateModelIndex = PluginStateAnimation.create({
+    name: 'built-in.animate-model-index',
+    display: { name: 'Animate Model Index' },
+    params: () => ({ maxFPS: ParamDefinition.Numeric(3, { min: 0.5, max: 30, step: 0.5 }) }),
+    initialState: () => ({ frame: 1 }),
+    async apply(animState, t, ctx) {
+        // limit fps
+        if (t.current > 0 && t.current - t.lastApplied < 1000 / ctx.params.maxFPS) {
+            return { kind: 'skip' };
+        }
+
+        const state = ctx.plugin.state.dataState;
+        const models = state.selectQ(q => q.rootsOfType(PluginStateObject.Molecule.Model)
+            .filter(c => c.transform.transformer === StateTransforms.Model.ModelFromTrajectory));
+
+        const update = state.build();
+
+        for (const m of models) {
+            const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]);
+            if (!parent || !parent.obj) continue;
+            const traj = parent.obj as PluginStateObject.Molecule.Trajectory;
+            update.to(m.transform.ref).update(StateTransforms.Model.ModelFromTrajectory,
+                old => {
+                    let modelIndex = animState.frame % traj.data.length;
+                    if (modelIndex < 0) modelIndex += traj.data.length;
+                    return { modelIndex };
+                });
+        }
+
+        await PluginCommands.State.Update.dispatch(ctx.plugin, { state, tree: update });
+        return { kind: 'next', state: { frame: animState.frame + 1 } };
+    }
+})

+ 165 - 0
src/mol-plugin/state/animation/manager.ts

@@ -0,0 +1,165 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginComponent } from 'mol-plugin/component';
+import { PluginContext } from 'mol-plugin/context';
+import { PluginStateAnimation } from './model';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+
+export { PluginAnimationManager }
+
+// TODO: pause functionality (this needs to reset if the state tree changes)
+// TODO: handle unregistered animations on state restore
+
+class PluginAnimationManager extends PluginComponent<PluginAnimationManager.State> {
+    private map = new Map<string, PluginStateAnimation>();
+    private animations: PluginStateAnimation[] = [];
+    private _current: PluginAnimationManager.Current;
+    private _params?: PD.For<PluginAnimationManager.State['params']> = void 0;
+
+    get isEmpty() { return this.animations.length === 0; }
+    get current() { return this._current!; }
+
+    getParams(): PD.Params {
+        if (!this._params) {
+            this._params = {
+                current: PD.Select(this.animations[0] && this.animations[0].name,
+                    this.animations.map(a => [a.name, a.display.name] as [string, string]),
+                    { label: 'Animation' })
+            };
+        }
+        return this._params as any as PD.Params;
+    }
+
+    updateParams(newParams: Partial<PluginAnimationManager.State['params']>) {
+        this.updateState({ params: { ...this.latestState.params, ...newParams } });
+        const anim = this.map.get(this.latestState.params.current)!;
+        const params = anim.params(this.context);
+        this._current = {
+            anim,
+            params,
+            paramValues: PD.getDefaultValues(params),
+            state: {},
+            startedTime: -1,
+            lastTime: 0
+        }
+        this.triggerUpdate();
+    }
+
+    updateCurrentParams(values: any) {
+        this._current.paramValues = { ...this._current.paramValues, ...values };
+        this.triggerUpdate();
+    }
+
+    register(animation: PluginStateAnimation) {
+        if (this.map.has(animation.name)) {
+            this.context.log.error(`Animation '${animation.name}' is already registered.`);
+            return;
+        }
+        this._params = void 0;
+        this.map.set(animation.name, animation);
+        this.animations.push(animation);
+        if (this.animations.length === 1) {
+            this.updateParams({ current: animation.name });
+        } else {
+            this.triggerUpdate();
+        }
+    }
+
+    start() {
+        this.updateState({ animationState: 'playing' });
+        this.triggerUpdate();
+
+        this._current.lastTime = 0;
+        this._current.startedTime = -1;
+        this._current.state = this._current.anim.initialState(this._current.paramValues, this.context);
+
+        requestAnimationFrame(this.animate);
+    }
+
+    stop() {
+        this.updateState({ animationState: 'stopped' });
+        this.triggerUpdate();
+    }
+
+    private animate = async (t: number) => {
+        if (this._current.startedTime < 0) this._current.startedTime = t;
+        const newState = await this._current.anim.apply(
+            this._current.state,
+            { lastApplied: this._current.lastTime, current: t - this._current.startedTime },
+            { params: this._current.paramValues, plugin: this.context });
+
+        if (newState.kind === 'finished') {
+            this.stop();
+        } else if (newState.kind === 'next') {
+            this._current.state = newState.state;
+            this._current.lastTime = t - this._current.startedTime;
+            if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate);
+        } else if (newState.kind === 'skip') {
+            if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate);
+        }
+    }
+
+    getSnapshot(): PluginAnimationManager.Snapshot {
+        if (!this.current) return { state: this.latestState };
+
+        return {
+            state: this.latestState,
+            current: {
+                paramValues: this._current.paramValues,
+                state: this._current.anim.stateSerialization ? this._current.anim.stateSerialization.toJSON(this._current.state) : this._current.state
+            }
+        };
+    }
+
+    setSnapshot(snapshot: PluginAnimationManager.Snapshot) {
+        this.updateState({ animationState: snapshot.state.animationState });
+        this.updateParams(snapshot.state.params);
+
+        if (snapshot.current) {
+            this.current.paramValues = snapshot.current.paramValues;
+            this.current.state = this._current.anim.stateSerialization
+                ? this._current.anim.stateSerialization.fromJSON(snapshot.current.state)
+                : snapshot.current.state;
+            this.triggerUpdate();
+            if (this.latestState.animationState === 'playing') this.resume();
+        }
+    }
+
+    private resume() {
+        this._current.lastTime = 0;
+        this._current.startedTime = -1;
+        requestAnimationFrame(this.animate);
+    }
+
+    constructor(ctx: PluginContext) {
+        super(ctx, { params: { current: '' }, animationState: 'stopped' });
+    }
+}
+
+namespace PluginAnimationManager {
+    export interface Current {
+        anim: PluginStateAnimation
+        params: PD.Params,
+        paramValues: any,
+        state: any,
+        startedTime: number,
+        lastTime: number
+    }
+
+    export interface State {
+        params: { current: string },
+        animationState: 'stopped' | 'playing'
+    }
+
+    export interface Snapshot {
+        state: State,
+        current?: {
+            paramValues: any,
+            state: any
+        }
+    }
+}

+ 49 - 0
src/mol-plugin/state/animation/model.ts

@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+import { PluginContext } from 'mol-plugin/context';
+
+export { PluginStateAnimation }
+
+// TODO: helpers for building animations (once more animations are added)
+//       for example "composite animation"
+
+interface PluginStateAnimation<P extends PD.Params = any, S = any> {
+    name: string,
+    readonly display: { readonly name: string, readonly description?: string },
+    params: (ctx: PluginContext) => P,
+    initialState(params: PD.Values<P>, ctx: PluginContext): S,
+
+    /**
+     * Apply the current frame and modify the state.
+     * @param t Current absolute time since the animation started.
+     */
+    apply(state: S, t: PluginStateAnimation.Time, ctx: PluginStateAnimation.Context<P>): Promise<PluginStateAnimation.ApplyResult<S>>,
+
+    /**
+     * The state must be serializable to JSON. If JSON.stringify is not enough,
+     * custom converted to an object that works with JSON.stringify can be provided.
+     */
+    stateSerialization?: { toJSON(state: S): any, fromJSON(data: any): S }
+}
+
+namespace PluginStateAnimation {
+    export interface Time {
+        lastApplied: number,
+        current: number
+    }
+
+    export type ApplyResult<S> = { kind: 'finished' } | { kind: 'skip' } | { kind: 'next', state: S }
+    export interface Context<P extends PD.Params> {
+        params: PD.Values<P>,
+        plugin: PluginContext
+    }
+
+    export function create<P extends PD.Params, S>(params: PluginStateAnimation<P, S>) {
+        return params;
+    }
+}

+ 53 - 0
src/mol-plugin/ui/controls/common.tsx

@@ -27,6 +27,59 @@ export class ControlGroup extends React.Component<{ header: string, initialExpan
     }
 }
 
+export class NumericInput extends React.PureComponent<{
+    value: number,
+    onChange: (v: number) => void,
+    onEnter?: () => void,
+    blurOnEnter?: boolean,
+    isDisabled?: boolean,
+    placeholder?: string
+}, { value: string }> {
+    state = { value: '0' };
+    input = React.createRef<HTMLInputElement>();
+
+    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        const value = +e.target.value;
+        this.setState({ value: e.target.value }, () => {
+            if (!Number.isNaN(value) && value !== this.props.value) {
+                this.props.onChange(value);
+            }
+        });
+    }
+
+    onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+        if ((e.keyCode === 13 || e.charCode === 13)) {
+            if (this.props.blurOnEnter && this.input.current) {
+                this.input.current.blur();
+            }
+            if (this.props.onEnter) this.props.onEnter();
+        }
+    }
+
+    onBlur = () => {
+        this.setState({ value: '' + this.props.value });
+    }
+
+    static getDerivedStateFromProps(props: { value: number }, state: { value: string }) {
+        const value = +state.value;
+        if (Number.isNaN(value) || value === props.value) return null;
+        return { value: '' + props.value };
+    }
+
+    render() {
+        return <input type='text'
+            ref={this.input}
+            onBlur={this.onBlur}
+            value={this.state.value}
+            placeholder={this.props.placeholder}
+            onChange={this.onChange}
+            onKeyPress={this.props.onEnter || this.props.blurOnEnter ? this.onKeyPress : void 0}
+            disabled={!!this.props.isDisabled}
+        />
+    }
+}
+
+
 // export const ToggleButton = (props: {
 //     onChange: (v: boolean) => void,
 //     value: boolean,

+ 8 - 38
src/mol-plugin/ui/controls/parameters.tsx

@@ -15,6 +15,7 @@ import { camelCaseToWords } from 'mol-util/string';
 import * as React from 'react';
 import LineGraphComponent from './line-graph/line-graph-component';
 import { Slider, Slider2 } from './slider';
+import { NumericInput } from './common';
 
 export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
     params: P,
@@ -152,53 +153,22 @@ export class LineGraphControl extends React.PureComponent<ParamProps<PD.LineGrap
     }
 }
 
-export class NumberInputControl extends React.PureComponent<ParamProps<PD.Numeric>, { value: string }> {
+export class NumberInputControl extends React.PureComponent<ParamProps<PD.Numeric>> {
     state = { value: '0' };
 
-    protected update(value: any) {
+    update = (value: number) => {
         this.props.onChange({ param: this.props.param, name: this.props.name, value });
     }
 
-    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-        const value = +e.target.value;
-        this.setState({ value: e.target.value }, () => {
-            if (!Number.isNaN(value) && value !== this.props.value) {
-                this.update(value);
-            }
-        });
-    }
-
-    onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
-        if (!this.props.onEnter) return;
-        if ((e.keyCode === 13 || e.charCode === 13)) {
-            this.props.onEnter();
-        }
-    }
-
-    onBlur = () => {
-        this.setState({ value: '' + this.props.value });
-    }
-
-    static getDerivedStateFromProps(props: { value: number }, state: { value: string }) {
-        const value = +state.value;
-        if (Number.isNaN(value) || value === props.value) return null;
-        return { value: '' + props.value };
-    }
-
     render() {
         const placeholder = this.props.param.label || camelCaseToWords(this.props.name);
         const label = this.props.param.label || camelCaseToWords(this.props.name);
         return <div className='msp-control-row'>
             <span title={this.props.param.description}>{label}</span>
             <div>
-                <input type='text'
-                    onBlur={this.onBlur}
-                    value={this.state.value}
-                    placeholder={placeholder}
-                    onChange={this.onChange}
-                    onKeyPress={this.props.onEnter ? this.onKeyPress : void 0}
-                    disabled={this.props.isDisabled}
-                />
+                <NumericInput
+                    value={this.props.value} onEnter={this.props.onEnter} placeholder={placeholder}
+                    isDisabled={this.props.isDisabled} onChange={this.update} />
             </div>
         </div>;
     }
@@ -208,7 +178,7 @@ export class NumberRangeControl extends SimpleParam<PD.Numeric> {
     onChange = (v: number) => { this.update(v); }
     renderControl() {
         return <Slider value={this.props.value} min={this.props.param.min!} max={this.props.param.max!}
-            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} />
+            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} onEnter={this.props.onEnter} />
     }
 }
 
@@ -267,7 +237,7 @@ export class BoundedIntervalControl extends SimpleParam<PD.Interval> {
     onChange = (v: [number, number]) => { this.update(v); }
     renderControl() {
         return <Slider2 value={this.props.value} min={this.props.param.min!} max={this.props.param.max!}
-            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} />;
+            step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} onEnter={this.props.onEnter} />;
     }
 }
 

+ 57 - 24
src/mol-plugin/ui/controls/slider.tsx

@@ -5,6 +5,7 @@
  */
 
 import * as React from 'react'
+import { NumericInput } from './common';
 
 export class Slider extends React.Component<{
     min: number,
@@ -12,7 +13,8 @@ export class Slider extends React.Component<{
     value: number,
     step?: number,
     onChange: (v: number) => void,
-    disabled?: boolean
+    disabled?: boolean,
+    onEnter?: () => void
 }, { isChanging: boolean, current: number }> {
 
     state = { isChanging: false, current: 0 }
@@ -35,18 +37,27 @@ export class Slider extends React.Component<{
         this.setState({ current });
     }
 
+    updateManually = (v: number) => {
+        let n = v;
+        if (this.props.step === 1) n = Math.round(n);
+        if (n < this.props.min) n = this.props.min;
+        if (n > this.props.max) n = this.props.max;
+        this.props.onChange(n);
+    }
+
     render() {
         let step = this.props.step;
         if (step === void 0) step = 1;
         return <div className='msp-slider'>
             <div>
-                <div>
-                    <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
-                        onBeforeChange={this.begin}
-                        onChange={this.updateCurrent as any} onAfterChange={this.end as any} />
-                </div></div>
+                <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
+                    onBeforeChange={this.begin}
+                    onChange={this.updateCurrent as any} onAfterChange={this.end as any} />
+            </div>
             <div>
-                {`${Math.round(100 * this.state.current) / 100}`}
+                <NumericInput
+                    value={this.state.current} onEnter={this.props.onEnter} blurOnEnter={true}
+                    isDisabled={this.props.disabled} onChange={this.updateManually} />
             </div>
         </div>;
     }
@@ -58,7 +69,8 @@ export class Slider2 extends React.Component<{
     value: [number, number],
     step?: number,
     onChange: (v: [number, number]) => void,
-    disabled?: boolean
+    disabled?: boolean,
+    onEnter?: () => void
 }, { isChanging: boolean, current: [number, number] }> {
 
     state = { isChanging: false, current: [0, 1] as [number, number] }
@@ -81,20 +93,41 @@ export class Slider2 extends React.Component<{
         this.setState({ current });
     }
 
+    updateMax = (v: number) => {
+        let n = v;
+        if (this.props.step === 1) n = Math.round(n);
+        if (n < this.state.current[0]) n = this.state.current[0]
+        else if (n < this.props.min) n = this.props.min;
+        if (n > this.props.max) n = this.props.max;
+        this.props.onChange([this.state.current[0], n]);
+    }
+
+    updateMin = (v: number) => {
+        let n = v;
+        if (this.props.step === 1) n = Math.round(n);
+        if (n < this.props.min) n = this.props.min;
+        if (n > this.state.current[1]) n = this.state.current[1];
+        else if (n > this.props.max) n = this.props.max;
+        this.props.onChange([n, this.state.current[1]]);
+    }
+
     render() {
         let step = this.props.step;
         if (step === void 0) step = 1;
         return <div className='msp-slider2'>
             <div>
-                {`${Math.round(100 * this.state.current[0]) / 100}`}
+                <NumericInput
+                    value={this.state.current[0]} onEnter={this.props.onEnter} blurOnEnter={true}
+                    isDisabled={this.props.disabled} onChange={this.updateMin} />
             </div>
             <div>
-                <div>
-                    <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
-                        onBeforeChange={this.begin} onChange={this.updateCurrent as any} onAfterChange={this.end as any} range={true} pushable={true} />
-                </div></div>
+                <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled}
+                    onBeforeChange={this.begin} onChange={this.updateCurrent as any} onAfterChange={this.end as any} range={true} pushable={true} />
+            </div>
             <div>
-                {`${Math.round(100 * this.state.current[1]) / 100}`}
+                <NumericInput
+                    value={this.state.current[1]} onEnter={this.props.onEnter} blurOnEnter={true}
+                    isDisabled={this.props.disabled} onChange={this.updateMax} />
             </div>
         </div>;
     }
@@ -102,10 +135,10 @@ export class Slider2 extends React.Component<{
 
 /**
  * The following code was adapted from react-components/slider library.
- * 
+ *
  * The MIT License (MIT)
  * Copyright (c) 2015-present Alipay.com, https://www.alipay.com/
- * 
+ *
  * Permission is hereby granted, free of charge, to any person obtaining a copy
  * of this software and associated documentation files (the "Software"), to deal
  * in the Software without restriction, including without limitation the rights
@@ -116,12 +149,12 @@ export class Slider2 extends React.Component<{
  * The above copyright notice and this permission notice shall be included in
  * all copies or substantial portions of the Software.
 
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 
- * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 
- * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 
- * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 
- * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 
- * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
@@ -540,7 +573,7 @@ export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState
         }
         return false;
 
-        // return this.state.bounds.some((x, i) => e.target 
+        // return this.state.bounds.some((x, i) => e.target
 
         // (
         //     //this.handleElements[i] && e.target === ReactDOM.findDOMNode(this.handleElements[i])
@@ -702,7 +735,7 @@ export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState
             dragging: handle === i,
             index: i,
             key: i,
-            ref: (h: any) => this.handleElements.push(h)  //`handle-${i}`,
+            ref: (h: any) => this.handleElements.push(h)  // `handle-${i}`,
         }));
         if (!range) { handles.shift(); }
 

+ 2 - 0
src/mol-plugin/ui/plugin.tsx

@@ -20,6 +20,7 @@ import { ApplyActionContol } from './state/apply-action';
 import { PluginState } from 'mol-plugin/state';
 import { UpdateTransformContol } from './state/update-transform';
 import { StateObjectCell } from 'mol-state';
+import { AnimationControls } from './state/animation';
 
 export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
 
@@ -61,6 +62,7 @@ class Layout extends PluginComponent {
                     {layout.showControls && this.region('right', <div className='msp-scrollable-container msp-right-controls'>
                         <CurrentObject />
                         <Controls />
+                        <AnimationControls />
                         <CameraSnapshots />
                         <StateSnapshots />
                     </div>)}

+ 3 - 5
src/mol-plugin/ui/state-tree.tsx

@@ -177,10 +177,6 @@ class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State
         const children = this.props.state.tree.children.get(this.props.nodeRef);
         const cellState = this.props.state.cellStates.get(this.props.nodeRef);
 
-        const remove = <button onClick={this.remove} className='msp-btn msp-btn-link msp-tree-remove-button'>
-            <span className='msp-icon msp-icon-remove' />
-        </button>;
-
         const visibility = <button onClick={this.toggleVisible} className={`msp-btn msp-btn-link msp-tree-visibility${cellState.isHidden ? ' msp-tree-visibility-hidden' : ''}`}>
             <span className='msp-icon msp-icon-visual-visibility' />
         </button>;
@@ -190,7 +186,9 @@ class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State
             {children.size > 0 &&  <button onClick={this.toggleExpanded} className='msp-btn msp-btn-link msp-tree-toggle-exp-button'>
                 <span className={`msp-icon msp-icon-${cellState.isCollapsed ? 'expand' : 'collapse'}`} />
             </button>}
-            {remove}{visibility}
+            {!cell.transform.props.isLocked && <button onClick={this.remove} className='msp-btn msp-btn-link msp-tree-remove-button'>
+                <span className='msp-icon msp-icon-remove' />
+            </button>}{visibility}
         </div>
     }
 }

+ 49 - 0
src/mol-plugin/ui/state/animation.tsx

@@ -0,0 +1,49 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { PluginComponent } from '../base';
+import { ParameterControls, ParamOnChange } from '../controls/parameters';
+
+export class AnimationControls extends PluginComponent<{ }> {
+    componentDidMount() {
+        this.subscribe(this.plugin.state.animation.updated, () => this.forceUpdate());
+    }
+
+    updateParams: ParamOnChange = p => {
+        this.plugin.state.animation.updateParams({ [p.name]: p.value });
+    }
+
+    updateCurrentParams: ParamOnChange = p => {
+        this.plugin.state.animation.updateCurrentParams({ [p.name]: p.value });
+    }
+
+    startOrStop = () => {
+        const anim = this.plugin.state.animation;
+        if (anim.latestState.animationState === 'playing') anim.stop();
+        else anim.start();
+    }
+
+    render() {
+        const anim = this.plugin.state.animation;
+        if (anim.isEmpty) return null;
+
+        const isDisabled = anim.latestState.animationState === 'playing';
+
+        return <div className='msp-animation-section'>
+            <div className='msp-section-header'>Animations</div>
+
+            <ParameterControls params={anim.getParams()} values={anim.latestState.params} onChange={this.updateParams} isDisabled={isDisabled} />
+            <ParameterControls params={anim.current.params} values={anim.current.paramValues} onChange={this.updateCurrentParams} isDisabled={isDisabled} />
+
+            <div className='msp-btn-row-group'>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.startOrStop}>
+                    {anim.latestState.animationState === 'playing' ? 'Stop' : 'Start'}
+                </button>
+            </div>
+        </div>
+    }
+}

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

@@ -53,7 +53,7 @@ namespace StateObject {
     };
 }
 
-interface StateObjectCell {
+interface StateObjectCell<T = StateObject> {
     transform: Transform,
 
     // Which object was used as a parent to create data in this cell
@@ -68,7 +68,7 @@ interface StateObjectCell {
     } | undefined;
 
     errorText?: string,
-    obj?: StateObject
+    obj?: T
 }
 
 namespace StateObjectCell {

+ 1 - 1
src/mol-state/state/selection.ts

@@ -29,7 +29,7 @@ namespace StateSelection {
     }
 
     function isObj(arg: any): arg is StateObjectCell {
-        return (arg as StateObjectCell).version !== void 0;
+        return (arg as StateObjectCell).version !== void 0 && (arg as StateObjectCell).transform !== void 0;
     }
 
     function isBuilder(arg: any): arg is Builder {

+ 3 - 1
src/mol-state/transform.ts

@@ -25,7 +25,9 @@ export namespace Transform {
     export interface Props {
         tag?: string
         isGhost?: boolean,
-        isBinding?: boolean
+        isBinding?: boolean,
+        // determine if the corresponding cell can be deleted by the user.
+        isLocked?: boolean
     }
 
     export interface Options {

+ 2 - 1
src/mol-state/transformer.ts

@@ -5,7 +5,7 @@
  */
 
 import { Task } from 'mol-task';
-import { StateObject } from './object';
+import { StateObject, StateObjectCell } from './object';
 import { Transform } from './transform';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { StateAction } from './action';
@@ -24,6 +24,7 @@ export namespace Transformer {
     export type Params<T extends Transformer<any, any, any>> = T extends Transformer<any, any, infer P> ? P : unknown;
     export type From<T extends Transformer<any, any, any>> = T extends Transformer<infer A, any, any> ? A : unknown;
     export type To<T extends Transformer<any, any, any>> = T extends Transformer<any, infer B, any> ? B : unknown;
+    export type Cell<T extends Transformer<any, any, any>> = T extends Transformer<any, infer B, any> ? StateObjectCell<B> : unknown;
 
     export function is(obj: any): obj is Transformer {
         return !!obj && typeof (obj as Transformer).toAction === 'function' && typeof (obj as Transformer).apply === 'function';