Browse Source

Merge branch 'master' into gl-lines

# Conflicts:
#	src/mol-canvas3d/canvas3d.ts
#	src/mol-plugin/state/objects.ts
#	src/mol-plugin/state/transforms/visuals.ts
Alexander Rose 6 years ago
parent
commit
f42126af2c
100 changed files with 3994 additions and 1855 deletions
  1. 1 1
      src/apps/canvas/app.ts
  2. 1 1
      src/apps/canvas/component/representation.tsx
  3. 3 3
      src/apps/canvas/component/viewport.tsx
  4. 2 2
      src/apps/canvas/structure-view.ts
  5. 1 1
      src/apps/canvas/volume-view.ts
  6. 1 0
      src/apps/viewer/index.html
  7. 2 1
      src/examples/task.ts
  8. 1 1
      src/mol-app/component/parameters.tsx
  9. 207 0
      src/mol-canvas3d/camera.ts
  10. 0 100
      src/mol-canvas3d/camera/base.ts
  11. 0 49
      src/mol-canvas3d/camera/combined.ts
  12. 0 58
      src/mol-canvas3d/camera/orthographic.ts
  13. 0 43
      src/mol-canvas3d/camera/perspective.ts
  14. 47 52
      src/mol-canvas3d/canvas3d.ts
  15. 4 6
      src/mol-canvas3d/controls/trackball.ts
  16. 6 0
      src/mol-geo/geometry/picking.ts
  17. 2 2
      src/mol-gl/_spec/renderer.spec.ts
  18. 5 5
      src/mol-gl/renderer.ts
  19. 1 1
      src/mol-model/loci.ts
  20. 1 1
      src/mol-model/shape/shape.ts
  21. 3 3
      src/mol-model/structure/model/formats/mmcif.ts
  22. 1 1
      src/mol-model/structure/model/formats/mmcif/atomic.ts
  23. 1 1
      src/mol-model/structure/model/formats/mmcif/ihm.ts
  24. 1 1
      src/mol-model/structure/model/properties/custom/descriptor.ts
  25. 3 3
      src/mol-model/structure/model/properties/custom/indexed.ts
  26. 1 1
      src/mol-model/structure/query/context.ts
  27. 13 4
      src/mol-plugin/behavior.ts
  28. 15 8
      src/mol-plugin/behavior/behavior.ts
  29. 0 0
      src/mol-plugin/behavior/camera.ts
  30. 0 30
      src/mol-plugin/behavior/data.ts
  31. 42 0
      src/mol-plugin/behavior/dynamic/representation.ts
  32. 0 50
      src/mol-plugin/behavior/representation.ts
  33. 57 0
      src/mol-plugin/behavior/static/camera.ts
  34. 52 0
      src/mol-plugin/behavior/static/representation.ts
  35. 91 0
      src/mol-plugin/behavior/static/state.ts
  36. 5 2
      src/mol-plugin/command.ts
  37. 18 0
      src/mol-plugin/command/camera.ts
  38. 43 24
      src/mol-plugin/command/command.ts
  39. 0 13
      src/mol-plugin/command/data.ts
  40. 31 0
      src/mol-plugin/command/state.ts
  41. 83 83
      src/mol-plugin/context.ts
  42. 6 4
      src/mol-plugin/index.ts
  43. 56 31
      src/mol-plugin/state.ts
  44. 83 1
      src/mol-plugin/state/actions/basic.ts
  45. 0 21
      src/mol-plugin/state/base.ts
  46. 79 0
      src/mol-plugin/state/camera.ts
  47. 50 18
      src/mol-plugin/state/objects.ts
  48. 65 0
      src/mol-plugin/state/snapshots.ts
  49. 19 8
      src/mol-plugin/state/transforms/data.ts
  50. 54 31
      src/mol-plugin/state/transforms/model.ts
  51. 6 9
      src/mol-plugin/state/transforms/visuals.ts
  52. 61 0
      src/mol-plugin/ui/base.tsx
  53. 67 0
      src/mol-plugin/ui/camera.tsx
  54. 22 75
      src/mol-plugin/ui/controls.tsx
  55. 130 0
      src/mol-plugin/ui/controls/parameters.tsx
  56. 117 26
      src/mol-plugin/ui/plugin.tsx
  57. 126 27
      src/mol-plugin/ui/state-tree.tsx
  58. 129 0
      src/mol-plugin/ui/state.tsx
  59. 124 0
      src/mol-plugin/ui/state/apply-action.tsx
  60. 94 0
      src/mol-plugin/ui/state/parameters.tsx
  61. 98 0
      src/mol-plugin/ui/state/update-transform.tsx
  62. 54 0
      src/mol-plugin/ui/task.tsx
  63. 37 37
      src/mol-plugin/ui/viewport.tsx
  64. 87 0
      src/mol-plugin/util/canvas3d-identify.ts
  65. 0 0
      src/mol-plugin/util/logger.ts
  66. 73 0
      src/mol-plugin/util/task-manager.ts
  67. 76 0
      src/mol-state/action.ts
  68. 38 0
      src/mol-state/action/manager.ts
  69. 0 48
      src/mol-state/context.ts
  70. 1 3
      src/mol-state/index.ts
  71. 1 1
      src/mol-state/manager.ts
  72. 55 38
      src/mol-state/object.ts
  73. 0 178
      src/mol-state/selection.ts
  74. 422 221
      src/mol-state/state.ts
  75. 212 0
      src/mol-state/state/selection.ts
  76. 36 20
      src/mol-state/transform.ts
  77. 25 15
      src/mol-state/transformer.ts
  78. 3 78
      src/mol-state/tree.ts
  79. 93 0
      src/mol-state/tree/builder.ts
  80. 175 0
      src/mol-state/tree/immutable.ts
  81. 227 0
      src/mol-state/tree/transient.ts
  82. 0 262
      src/mol-state/util/immutable-tree.ts
  83. 1 1
      src/mol-task/execution/observable.ts
  84. 1 2
      src/mol-task/index.ts
  85. 1 1
      src/mol-task/util/chunked.ts
  86. 20 0
      src/mol-util/input/input-observer.ts
  87. 22 0
      src/mol-util/log-entry.ts
  88. 24 0
      src/mol-util/memoize.ts
  89. 23 2
      src/mol-util/now.ts
  90. 1 1
      src/mol-util/performance-monitor.ts
  91. 13 2
      src/mol-util/uuid.ts
  92. 133 135
      src/perf-tests/state.ts
  93. 1 1
      src/perf-tests/tasks.ts
  94. 1 1
      src/servers/model/preprocess/parallel.ts
  95. 1 1
      src/servers/model/properties/providers/pdbe.ts
  96. 1 1
      src/servers/model/server/api-local.ts
  97. 1 1
      src/servers/model/server/jobs.ts
  98. 2 1
      src/servers/model/server/query.ts
  99. 1 1
      src/servers/model/utils/fetch-props-pdbe.ts
  100. 1 1
      src/servers/volume/server/query/execute.ts

+ 1 - 1
src/apps/canvas/app.ts

@@ -4,7 +4,7 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import Canvas3D from 'mol-canvas3d/canvas3d';
+import { Canvas3D } from 'mol-canvas3d/canvas3d';
 import { getCifFromUrl, getModelsFromMmcif, getCifFromFile, getCcp4FromUrl, getVolumeFromCcp4, getCcp4FromFile, getVolumeFromVolcif } from './util';
 import { StructureView } from './structure-view';
 import { BehaviorSubject } from 'rxjs';

+ 1 - 1
src/apps/canvas/component/representation.tsx

@@ -5,7 +5,7 @@
  */
 
 import * as React from 'react'
-import Canvas3D from 'mol-canvas3d/canvas3d';
+import { Canvas3D } from 'mol-canvas3d/canvas3d';
 import { App } from '../app';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
 import { Representation } from 'mol-repr/representation';

+ 3 - 3
src/apps/canvas/component/viewport.tsx

@@ -11,7 +11,7 @@ import { EmptyLoci, Loci, areLociEqual } from 'mol-model/loci';
 import { labelFirst } from 'mol-theme/label';
 import { ButtonsType } from 'mol-util/input/input-observer';
 import { throttleTime } from 'rxjs/operators'
-import { CombinedCameraMode } from 'mol-canvas3d/camera/combined';
+import { Camera } from 'mol-canvas3d/camera';
 import { ColorParamComponent } from 'mol-app/component/parameter/color';
 import { Color } from 'mol-util/color';
 import { ParamDefinition as PD } from 'mol-util/param-definition'
@@ -24,7 +24,7 @@ interface ViewportState {
     noWebGl: boolean
     pickingInfo: string
     taskInfo: string
-    cameraMode: CombinedCameraMode
+    cameraMode: Camera.Mode
     backgroundColor: Color
 }
 
@@ -148,7 +148,7 @@ export class Viewport extends React.Component<ViewportProps, ViewportState> {
                         value={this.state.cameraMode}
                         style={{width: '150'}}
                         onChange={e => {
-                            const p = { cameraMode: e.target.value as CombinedCameraMode }
+                            const p = { cameraMode: e.target.value as Camera.Mode }
                             this.props.app.canvas3d.setProps(p)
                             this.setState(p)
                         }}

+ 2 - 2
src/apps/canvas/structure-view.ts

@@ -8,7 +8,7 @@ import { Model, Structure } from 'mol-model/structure';
 import { getStructureFromModel } from './util';
 import { AssemblySymmetry } from 'mol-model-props/rcsb/symmetry';
 import { getAxesShape } from './assembly-symmetry';
-import Canvas3D from 'mol-canvas3d/canvas3d';
+import { Canvas3D } from 'mol-canvas3d/canvas3d';
 // import { MeshBuilder } from 'mol-geo/mesh/mesh-builder';
 // import { addSphere } from 'mol-geo/mesh/builder/sphere';
 // import { Shape } from 'mol-model/shape';
@@ -213,7 +213,7 @@ export async function StructureView(app: App, canvas3d: Canvas3D, models: Readon
                 }
             }
 
-            canvas3d.center(structure.boundary.sphere.center)
+            canvas3d.camera.setState({ target: structure.boundary.sphere.center })
 
             // const mb = MeshBuilder.create()
             // mb.setGroup(0)

+ 1 - 1
src/apps/canvas/volume-view.ts

@@ -4,7 +4,7 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import Canvas3D from 'mol-canvas3d/canvas3d';
+import { Canvas3D } from 'mol-canvas3d/canvas3d';
 import { BehaviorSubject } from 'rxjs';
 import { App } from './app';
 import { VolumeData } from 'mol-model/volume';

+ 1 - 0
src/apps/viewer/index.html

@@ -8,6 +8,7 @@
             * {
                 margin: 0;
                 padding: 0;
+                box-sizing: border-box;
             }
             html, body {
                 width: 100%;

+ 2 - 1
src/examples/task.ts

@@ -4,7 +4,8 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { Task, Progress, Scheduler, now, MultistepTask, chunkedSubtask } from 'mol-task'
+import { Task, Progress, Scheduler, MultistepTask, chunkedSubtask } from 'mol-task'
+import { now } from 'mol-util/now';
 
 export async function test1() {
     const t = Task.create('test', async () => 1);

+ 1 - 1
src/mol-app/component/parameters.tsx

@@ -48,7 +48,7 @@ export class ParametersComponent<P extends PD.Params> extends React.Component<Pa
     }
 
     render() {
-        return <div>
+        return <div style={{ width: '100%' }}>
             { Object.keys(this.props.params).map(k => {
                 const param = this.props.params[k]
                 const value = this.props.values[k]

+ 207 - 0
src/mol-canvas3d/camera.ts

@@ -0,0 +1,207 @@
+/**
+ * Copyright (c) 2018 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>
+ */
+
+import { Mat4, Vec3, Vec4, EPSILON } from 'mol-math/linear-algebra'
+import { Viewport, cameraLookAt, cameraProject, cameraUnproject } from './camera/util';
+import { Object3D } from 'mol-gl/object3d';
+import { BehaviorSubject } from 'rxjs';
+
+export { Camera }
+
+// TODO: slab controls that modify near/far planes?
+
+class Camera implements Object3D {
+    readonly updatedViewProjection = new BehaviorSubject<Camera>(this);
+
+    readonly view: Mat4 = Mat4.identity();
+    readonly projection: Mat4 = Mat4.identity();
+    readonly projectionView: Mat4 = Mat4.identity();
+    readonly inverseProjectionView: Mat4 = Mat4.identity();
+
+    readonly viewport: Viewport;
+    readonly state: Readonly<Camera.Snapshot> = Camera.createDefaultSnapshot();
+
+    get position() { return this.state.position; }
+    set position(v: Vec3) { Vec3.copy(this.state.position, v); }
+
+    get direction() { return this.state.direction; }
+    set direction(v: Vec3) { Vec3.copy(this.state.direction, v); }
+
+    get up() { return this.state.up; }
+    set up(v: Vec3) { Vec3.copy(this.state.up, v); }
+
+    get target() { return this.state.target; }
+    set target(v: Vec3) { Vec3.copy(this.state.target, v); }
+
+    private prevProjection = Mat4.identity();
+    private prevView = Mat4.identity();
+
+    updateMatrices() {
+        const snapshot = this.state as Camera.Snapshot;
+        const height = 2 * Math.tan(snapshot.fov / 2) * Vec3.distance(snapshot.position, snapshot.target);
+        snapshot.zoom = this.viewport.height / height;
+
+        switch (this.state.mode) {
+            case 'orthographic': updateOrtho(this); break;
+            case 'perspective': updatePers(this); break;
+            default: throw new Error('unknown camera mode');
+        }
+
+        const changed = !Mat4.areEqual(this.projection, this.prevProjection, EPSILON.Value) || !Mat4.areEqual(this.view, this.prevView, EPSILON.Value);
+
+        Mat4.mul(this.projectionView, this.projection, this.view)
+        Mat4.invert(this.inverseProjectionView, this.projectionView)
+
+
+        if (changed) {
+            Mat4.mul(this.projectionView, this.projection, this.view)
+            Mat4.invert(this.inverseProjectionView, this.projectionView)
+
+            Mat4.copy(this.prevView, this.view);
+            Mat4.copy(this.prevProjection, this.projection);
+            this.updatedViewProjection.next(this);
+        }
+
+        return changed;
+    }
+
+    setState(snapshot?: Partial<Camera.Snapshot>) {
+        Camera.copySnapshot(this.state, snapshot);
+    }
+
+    getSnapshot() {
+        const ret = Camera.createDefaultSnapshot();
+        Camera.copySnapshot(ret, this.state);
+        return ret;
+    }
+
+    lookAt(target: Vec3) {
+        cameraLookAt(this.direction, this.up, this.position, target);
+    }
+
+    translate(v: Vec3) {
+        Vec3.add(this.position, this.position, v)
+    }
+
+    project(out: Vec4, point: Vec3) {
+        return cameraProject(out, point, this.viewport, this.projectionView)
+    }
+
+    unproject(out: Vec3, point: Vec3) {
+        return cameraUnproject(out, point, this.viewport, this.inverseProjectionView)
+    }
+
+    dispose() {
+        this.updatedViewProjection.complete();
+    }
+
+    constructor(state?: Partial<Camera.Snapshot>, viewport = Viewport.create(-1, -1, 1, 1)) {
+        this.viewport = viewport;
+        Camera.copySnapshot(this.state, state);
+    }
+
+}
+
+namespace Camera {
+    export type Mode = 'perspective' | 'orthographic'
+
+    export function createDefaultSnapshot(): Snapshot {
+        return {
+            mode: 'perspective',
+
+            position: Vec3.zero(),
+            direction: Vec3.create(0, 0, -1),
+            up: Vec3.create(0, 1, 0),
+
+            target: Vec3.create(0, 0, 0),
+
+            near: 0.1,
+            far: 10000,
+            fogNear: 0.1,
+            fogFar: 10000,
+
+            fov: Math.PI / 4,
+            zoom: 1
+        };
+    }
+
+    export interface Snapshot {
+        mode: Mode,
+
+        position: Vec3,
+        direction: Vec3,
+        up: Vec3,
+        target: Vec3,
+
+        near: number,
+        far: number,
+        fogNear: number,
+        fogFar: number,
+
+        fov: number,
+        zoom: number
+    }
+
+    export function copySnapshot(out: Snapshot, source?: Partial<Snapshot>) {
+        if (!source) return;
+
+        if (typeof source.mode !== 'undefined') out.mode = source.mode;
+
+        if (typeof source.position !== 'undefined') Vec3.copy(out.position, source.position);
+        if (typeof source.direction !== 'undefined') Vec3.copy(out.direction, source.direction);
+        if (typeof source.up !== 'undefined') Vec3.copy(out.up, source.up);
+        if (typeof source.target !== 'undefined') Vec3.copy(out.target, source.target);
+
+        if (typeof source.near !== 'undefined') out.near = source.near;
+        if (typeof source.far !== 'undefined') out.far = source.far;
+        if (typeof source.fogNear !== 'undefined') out.fogNear = source.fogNear;
+        if (typeof source.fogFar !== 'undefined') out.fogFar = source.fogFar;
+
+        if (typeof source.fov !== 'undefined') out.fov = source.fov;
+        if (typeof source.zoom !== 'undefined') out.zoom = source.zoom;
+    }
+}
+
+const _center = Vec3.zero();
+function updateOrtho(camera: Camera) {
+    const { viewport, state: { zoom, near, far } } = camera;
+
+    const fullLeft = (viewport.width - viewport.x) / -2
+    const fullRight = (viewport.width - viewport.x) / 2
+    const fullTop = (viewport.height - viewport.y) / 2
+    const fullBottom = (viewport.height - viewport.y) / -2
+
+    const dx = (fullRight - fullLeft) / (2 * zoom)
+    const dy = (fullTop - fullBottom) / (2 * zoom)
+    const cx = (fullRight + fullLeft) / 2
+    const cy = (fullTop + fullBottom) / 2
+
+    const left = cx - dx
+    const right = cx + dx
+    const top = cy + dy
+    const bottom = cy - dy
+
+    // build projection matrix
+    Mat4.ortho(camera.projection, left, right, bottom, top, Math.abs(near), Math.abs(far))
+
+    // build view matrix
+    Vec3.add(_center, camera.position, camera.direction)
+    Mat4.lookAt(camera.view, camera.position, _center, camera.up)
+}
+
+function updatePers(camera: Camera) {
+    const aspect = camera.viewport.width / camera.viewport.height
+
+    const { fov, near, far } = camera.state;
+
+    // build projection matrix
+    Mat4.perspective(camera.projection, fov, aspect, Math.abs(near), Math.abs(far))
+
+    // build view matrix
+    Vec3.add(_center, camera.position, camera.direction)
+    Mat4.lookAt(camera.view, camera.position, _center, camera.up)
+}

+ 0 - 100
src/mol-canvas3d/camera/base.ts

@@ -1,100 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author Alexander Rose <alexander.rose@weirdbyte.de>
- */
-
-import { Mat4, Vec3, Vec4 } from 'mol-math/linear-algebra'
-import { cameraProject, cameraUnproject, cameraLookAt, Viewport } from './util';
-import { Object3D } from 'mol-gl/object3d';
-
-export interface Camera extends Object3D {
-    readonly projection: Mat4,
-    readonly projectionView: Mat4,
-    readonly inverseProjectionView: Mat4,
-    readonly viewport: Viewport,
-
-    near: number,
-    far: number,
-    fogNear: number,
-    fogFar: number,
-}
-
-export const DefaultCameraProps = {
-    position: Vec3.zero(),
-    direction: Vec3.create(0, 0, -1),
-    up: Vec3.create(0, 1, 0),
-    viewport: Viewport.create(-1, -1, 1, 1),
-    target: Vec3.create(0, 0, 0),
-
-    near: 0.1,
-    far: 10000,
-    fogNear: 0.1,
-    fogFar: 10000,
-}
-export type CameraProps = typeof DefaultCameraProps
-
-export namespace Camera {
-    export function create(props?: Partial<CameraProps>): Camera {
-        const p = { ...DefaultCameraProps, ...props };
-
-        const { view, position, direction, up } = Object3D.create()
-        Vec3.copy(position, p.position)
-        Vec3.copy(direction, p.direction)
-        Vec3.copy(up, p.up)
-
-        const projection = Mat4.identity()
-        const viewport = Viewport.clone(p.viewport)
-        const projectionView = Mat4.identity()
-        const inverseProjectionView = Mat4.identity()
-
-        return {
-            projection,
-            projectionView,
-            inverseProjectionView,
-            viewport,
-
-            view,
-            position,
-            direction,
-            up,
-
-            near: p.near,
-            far: p.far,
-            fogNear: p.fogNear,
-            fogFar: p.fogFar,
-        }
-    }
-
-    export function update (camera: Camera) {
-        Mat4.mul(camera.projectionView, camera.projection, camera.view)
-        Mat4.invert(camera.inverseProjectionView, camera.projectionView)
-        return camera
-    }
-
-    export function lookAt (camera: Camera, target: Vec3) {
-        cameraLookAt(camera.direction, camera.up, camera.position, target)
-    }
-
-    export function reset (camera: Camera, props: CameraProps) {
-        Vec3.copy(camera.position, props.position)
-        Vec3.copy(camera.direction, props.direction)
-        Vec3.copy(camera.up, props.up)
-        Mat4.setIdentity(camera.view)
-        Mat4.setIdentity(camera.projection)
-        Mat4.setIdentity(camera.projectionView)
-        Mat4.setIdentity(camera.inverseProjectionView)
-    }
-
-    export function translate (camera: Camera, v: Vec3) {
-        Vec3.add(camera.position, camera.position, v)
-    }
-
-    export function project (camera: Camera, out: Vec4, point: Vec3) {
-        return cameraProject(out, point, camera.viewport, camera.projectionView)
-    }
-
-    export function unproject (camera: Camera, out: Vec3, point: Vec3) {
-        return cameraUnproject(out, point, camera.viewport, camera.inverseProjectionView)
-    }
-}

+ 0 - 49
src/mol-canvas3d/camera/combined.ts

@@ -1,49 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author Alexander Rose <alexander.rose@weirdbyte.de>
- */
-
-import { PerspectiveCamera,  } from './perspective';
-import { OrthographicCamera } from './orthographic';
-import { Camera, DefaultCameraProps } from './base';
-import { Vec3 } from 'mol-math/linear-algebra';
-
-export type CombinedCameraMode = 'perspective' | 'orthographic'
-
-export interface CombinedCamera extends Camera {
-    target: Vec3
-    fov: number
-    zoom: number
-    mode: CombinedCameraMode
-}
-
-export const DefaultCombinedCameraProps = {
-    ...DefaultCameraProps,
-    target: Vec3.zero(),
-    fov: Math.PI / 4,
-    zoom: 1,
-    mode: 'perspective' as CombinedCameraMode
-}
-export type CombinedCameraProps = Partial<typeof DefaultCombinedCameraProps>
-
-export namespace CombinedCamera {
-    export function create(props: CombinedCameraProps = {}): CombinedCamera {
-        const { zoom, fov, mode, target: t } = { ...DefaultCombinedCameraProps, ...props };
-        const target = Vec3.create(t[0], t[1], t[2])
-        const camera = { ...Camera.create(props), zoom, fov, mode, target }
-        update(camera)
-
-        return camera
-    }
-
-    export function update(camera: CombinedCamera) {
-        const height = 2 * Math.tan(camera.fov / 2) * Vec3.distance(camera.position, camera.target)
-        camera.zoom = camera.viewport.height / height
-
-        switch (camera.mode) {
-            case 'orthographic': OrthographicCamera.update(camera); break
-            case 'perspective': PerspectiveCamera.update(camera); break
-        }
-    }
-}

+ 0 - 58
src/mol-canvas3d/camera/orthographic.ts

@@ -1,58 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author Alexander Rose <alexander.rose@weirdbyte.de>
- */
-
-import { Mat4, Vec3 } from 'mol-math/linear-algebra'
-import { DefaultCameraProps, Camera } from './base'
-
-export interface OrthographicCamera extends Camera {
-    zoom: number
-}
-
-export const DefaultOrthographicCameraProps = {
-    ...DefaultCameraProps,
-    zoom: 1
-}
-export type OrthographicCameraProps = Partial<typeof DefaultOrthographicCameraProps>
-
-export namespace OrthographicCamera {
-    export function create(props: OrthographicCameraProps = {}): OrthographicCamera {
-        const { zoom } = { ...DefaultOrthographicCameraProps, ...props };
-        const camera = { ...Camera.create(props), zoom }
-        update(camera)
-
-        return camera
-    }
-
-    const center = Vec3.zero()
-    export function update(camera: OrthographicCamera) {
-        const { viewport, zoom } = camera
-
-        const fullLeft = (viewport.width - viewport.x) / -2
-        const fullRight = (viewport.width - viewport.x) / 2
-        const fullTop = (viewport.height - viewport.y) / 2
-        const fullBottom = (viewport.height - viewport.y) / -2
-
-        const dx = (fullRight - fullLeft) / (2 * zoom)
-        const dy = (fullTop - fullBottom) / (2 * zoom)
-        const cx = (fullRight + fullLeft) / 2
-        const cy = (fullTop + fullBottom) / 2
-
-        const left = cx - dx
-        const right = cx + dx
-        const top = cy + dy
-        const bottom = cy - dy
-
-        // build projection matrix
-        Mat4.ortho(camera.projection, left, right, bottom, top, Math.abs(camera.near), Math.abs(camera.far))
-
-        // build view matrix
-        Vec3.add(center, camera.position, camera.direction)
-        Mat4.lookAt(camera.view, camera.position, center, camera.up)
-
-        // update projection * view and invert
-        Camera.update(camera)
-    }
-}

+ 0 - 43
src/mol-canvas3d/camera/perspective.ts

@@ -1,43 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author Alexander Rose <alexander.rose@weirdbyte.de>
- */
-
-import { Mat4, Vec3 } from 'mol-math/linear-algebra'
-import { DefaultCameraProps, Camera } from './base'
-
-export interface PerspectiveCamera extends Camera {
-    fov: number
-}
-
-export const DefaultPerspectiveCameraProps = {
-    ...DefaultCameraProps,
-    fov: Math.PI / 4,
-}
-export type PerspectiveCameraProps = Partial<typeof DefaultPerspectiveCameraProps>
-
-export namespace PerspectiveCamera {
-    export function create(props: PerspectiveCameraProps = {}): PerspectiveCamera {
-        const { fov } = { ...DefaultPerspectiveCameraProps, ...props }
-        const camera = { ...Camera.create(props), fov }
-        update(camera)
-
-        return camera
-    }
-
-    const center = Vec3.zero()
-    export function update(camera: PerspectiveCamera) {
-        const aspect = camera.viewport.width / camera.viewport.height
-
-        // build projection matrix
-        Mat4.perspective(camera.projection, camera.fov, aspect, Math.abs(camera.near), Math.abs(camera.far))
-
-        // build view matrix
-        Vec3.add(center, camera.position, camera.direction)
-        Mat4.lookAt(camera.view, camera.position, center, camera.up)
-
-        // update projection * view and invert
-        Camera.update(camera)
-    }
-}

+ 47 - 52
src/mol-canvas3d/canvas3d.ts

@@ -5,8 +5,9 @@
  */
 
 import { BehaviorSubject, Subscription } from 'rxjs';
+import { now } from 'mol-util/now';
 
-import { Vec3, Mat4, EPSILON } from 'mol-math/linear-algebra'
+import { Vec3 } from 'mol-math/linear-algebra'
 import InputObserver from 'mol-util/input/input-observer'
 import * as SetUtils from 'mol-util/set'
 import Renderer, { RendererStats } from 'mol-gl/renderer'
@@ -24,20 +25,22 @@ import { PickingId, decodeIdRGB } from 'mol-geo/geometry/picking';
 import { MarkerAction } from 'mol-geo/geometry/marker-data';
 import { Loci, EmptyLoci, isEmptyLoci } from 'mol-model/loci';
 import { Color } from 'mol-util/color';
-import { CombinedCamera, CombinedCameraMode } from './camera/combined';
+import { Camera } from './camera';
 
 export const DefaultCanvas3DProps = {
+    // TODO: FPS cap?
+    // maxFps: 30,
     cameraPosition: Vec3.create(0, 0, 50),
-    cameraMode: 'perspective' as CombinedCameraMode,
+    cameraMode: 'perspective' as Camera.Mode,
     backgroundColor: Color(0x000000),
 }
 export type Canvas3DProps = typeof DefaultCanvas3DProps
 
+export { Canvas3D }
+
 interface Canvas3D {
     readonly webgl: WebGLContext,
 
-    center: (p: Vec3) => void
-
     hide: (repr: Representation.Any) => void
     show: (repr: Representation.Any) => void
 
@@ -46,7 +49,7 @@ interface Canvas3D {
     update: () => void
     clear: () => void
 
-    draw: (force?: boolean) => void
+    // draw: (force?: boolean) => void
     requestDraw: (force?: boolean) => void
     animate: () => void
     pick: () => void
@@ -54,13 +57,11 @@ interface Canvas3D {
     mark: (loci: Loci, action: MarkerAction) => void
     getLoci: (pickingId: PickingId) => { loci: Loci, repr?: Representation.Any }
 
-    readonly reprCount: BehaviorSubject<number>
-    readonly identified: BehaviorSubject<string>
-    readonly didDraw: BehaviorSubject<number>
+    readonly didDraw: BehaviorSubject<now.Timestamp>
 
     handleResize: () => void
     resetCamera: () => void
-    readonly camera: CombinedCamera
+    readonly camera: Camera
     downloadScreenshot: () => void
     getImageData: (variant: RenderVariant) => ImageData
     setProps: (props: Partial<Canvas3DProps>) => void
@@ -79,13 +80,12 @@ namespace Canvas3D {
         const reprRenderObjects = new Map<Representation.Any, Set<RenderObject>>()
         const reprUpdatedSubscriptions = new Map<Representation.Any, Subscription>()
         const reprCount = new BehaviorSubject(0)
-        const identified = new BehaviorSubject('')
 
-        const startTime = performance.now()
-        const didDraw = new BehaviorSubject(0)
+        const startTime = now()
+        const didDraw = new BehaviorSubject<now.Timestamp>(0 as now.Timestamp)
         const input = InputObserver.create(canvas)
 
-        const camera = CombinedCamera.create({
+        const camera = new Camera({
             near: 0.1,
             far: 10000,
             position: Vec3.clone(p.cameraPosition),
@@ -118,8 +118,6 @@ namespace Canvas3D {
         let isPicking = false
         let drawPending = false
         let lastRenderTime = -1
-        const prevProjectionView = Mat4.zero()
-        const prevSceneView = Mat4.zero()
 
         function getLoci(pickingId: PickingId) {
             let loci: Loci = EmptyLoci
@@ -156,7 +154,7 @@ namespace Canvas3D {
         //     return 0
         // }
 
-        function render(variant: RenderVariant, force?: boolean) {
+        function render(variant: RenderVariant, force: boolean) {
             if (isPicking) return false
             // const p = scene.boundingSphere.center
             // console.log(p[0], p[1], p[2])
@@ -178,51 +176,56 @@ namespace Canvas3D {
 
             // console.log(camera.fogNear, camera.fogFar, targetDistance)
 
-            switch (variant) {
-                case 'pickObject': objectPickTarget.bind(); break;
-                case 'pickInstance': instancePickTarget.bind(); break;
-                case 'pickGroup': groupPickTarget.bind(); break;
-                case 'draw':
-                    webgl.unbindFramebuffer();
-                    renderer.setViewport(0, 0, canvas.width, canvas.height);
-                    break;
-            }
             let didRender = false
             controls.update()
-            CombinedCamera.update(camera)
-            if (force || !Mat4.areEqual(camera.projectionView, prevProjectionView, EPSILON.Value) || !Mat4.areEqual(scene.view, prevSceneView, EPSILON.Value)) {
-                // console.log('foo', force, prevSceneView, scene.view)
-                Mat4.copy(prevProjectionView, camera.projectionView)
-                Mat4.copy(prevSceneView, scene.view)
+            const cameraChanged = camera.updateMatrices();
+
+            if (force || cameraChanged) {
+                switch (variant) {
+                    case 'pickObject': objectPickTarget.bind(); break;
+                    case 'pickInstance': instancePickTarget.bind(); break;
+                    case 'pickGroup': groupPickTarget.bind(); break;
+                    case 'draw':
+                        webgl.unbindFramebuffer();
+                        renderer.setViewport(0, 0, canvas.width, canvas.height);
+                        break;
+                }
+
                 renderer.render(scene, variant)
                 if (variant === 'draw') {
-                    lastRenderTime = performance.now()
+                    lastRenderTime = now()
                     pickDirty = true
                 }
                 didRender = true
             }
-            return didRender
+
+            return didRender && cameraChanged;
         }
 
+        let forceNextDraw = false;
+
         function draw(force?: boolean) {
-            if (render('draw', force)) {
-                didDraw.next(performance.now() - startTime)
+            if (render('draw', !!force || forceNextDraw)) {
+                didDraw.next(now() - startTime as now.Timestamp)
             }
+            forceNextDraw = false;
             drawPending = false
         }
 
         function requestDraw(force?: boolean) {
             if (drawPending) return
             drawPending = true
-            window.requestAnimationFrame(() => draw(force))
+            forceNextDraw = !!force;
+            // The animation frame is being requested by animate already.
+            // window.requestAnimationFrame(() => draw(force))
         }
 
         function animate() {
             draw(false)
-            if (performance.now() - lastRenderTime > 200) {
+            if (now() - lastRenderTime > 200) {
                 if (pickDirty) pick()
             }
-            window.requestAnimationFrame(() => animate())
+            window.requestAnimationFrame(animate)
         }
 
         function pick() {
@@ -291,11 +294,6 @@ namespace Canvas3D {
         return {
             webgl,
 
-            center: (p: Vec3) => {
-                Vec3.set(controls.target, p[0], p[1], p[2])
-                Vec3.set(camera.target, p[0], p[1], p[2])
-            },
-
             hide: (repr: Representation.Any) => {
                 const renderObjectSet = reprRenderObjects.get(repr)
                 if (renderObjectSet) renderObjectSet.forEach(o => o.state.visible = false)
@@ -328,7 +326,7 @@ namespace Canvas3D {
                 scene.clear()
             },
 
-            draw,
+            // draw,
             requestDraw,
             animate,
             pick,
@@ -352,12 +350,10 @@ namespace Canvas3D {
                     case 'pickGroup': return groupPickTarget.getImageData()
                 }
             },
-            reprCount,
-            identified,
             didDraw,
             setProps: (props: Partial<Canvas3DProps>) => {
-                if (props.cameraMode !== undefined && props.cameraMode !== camera.mode) {
-                    camera.mode = props.cameraMode
+                if (props.cameraMode !== undefined && props.cameraMode !== camera.state.mode) {
+                    camera.setState({ mode: props.cameraMode })
                 }
                 if (props.backgroundColor !== undefined && props.backgroundColor !== renderer.props.clearColor) {
                     renderer.setClearColor(props.backgroundColor)
@@ -368,7 +364,7 @@ namespace Canvas3D {
             get props() {
                 return {
                     cameraPosition: Vec3.clone(camera.position),
-                    cameraMode: camera.mode,
+                    cameraMode: camera.state.mode,
                     backgroundColor: renderer.props.clearColor
                 }
             },
@@ -383,6 +379,7 @@ namespace Canvas3D {
                 input.dispose()
                 controls.dispose()
                 renderer.dispose()
+                camera.dispose()
             }
         }
 
@@ -399,6 +396,4 @@ namespace Canvas3D {
             groupPickTarget.setSize(pickWidth, pickHeight)
         }
     }
-}
-
-export default Canvas3D
+}

+ 4 - 6
src/mol-canvas3d/controls/trackball.ts

@@ -2,6 +2,7 @@
  * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author David Sehnal <david.sehnal@gmail.com>
  */
 
 /*
@@ -16,7 +17,6 @@ import { Object3D } from 'mol-gl/object3d';
 
 export const DefaultTrackballControlsProps = {
     noScroll: true,
-    target: [0, 0, 0] as Vec3,
 
     rotateSpeed: 3.0,
     zoomSpeed: 4.0,
@@ -25,14 +25,13 @@ export const DefaultTrackballControlsProps = {
     staticMoving: true,
     dynamicDampingFactor: 0.2,
 
-    minDistance: 0,
+    minDistance: 0.01,
     maxDistance: Infinity
 }
 export type TrackballControlsProps = Partial<typeof DefaultTrackballControlsProps>
 
 interface TrackballControls {
     viewport: Viewport
-    target: Vec3
 
     dynamicDampingFactor: number
     rotateSpeed: number
@@ -45,11 +44,11 @@ interface TrackballControls {
 }
 
 namespace TrackballControls {
-    export function create (input: InputObserver, object: Object3D, props: TrackballControlsProps = {}): TrackballControls {
+    export function create (input: InputObserver, object: Object3D & { target: Vec3 }, props: TrackballControlsProps = {}): TrackballControls {
         const p = { ...DefaultTrackballControlsProps, ...props }
 
         const viewport: Viewport = { x: 0, y: 0, width: 0, height: 0 }
-        const target: Vec3 = Vec3.clone(p.target)
+        const target: Vec3 = object.target
 
         let { rotateSpeed, zoomSpeed, panSpeed } = p
         let { staticMoving, dynamicDampingFactor } = p
@@ -294,7 +293,6 @@ namespace TrackballControls {
 
         return {
             viewport,
-            target,
 
             get dynamicDampingFactor() { return dynamicDampingFactor },
             set dynamicDampingFactor(value: number ) { dynamicDampingFactor = value },

+ 6 - 0
src/mol-geo/geometry/picking.ts

@@ -21,6 +21,12 @@ export interface PickingId {
     groupId: number
 }
 
+export namespace PickingId {
+    export function areSame(a: PickingId, b: PickingId) {
+        return a.objectId === b.objectId && a.instanceId === b.instanceId && a.groupId === b.groupId;
+    }
+}
+
 export interface PickingInfo {
     label: string
     data?: any

+ 2 - 2
src/mol-gl/_spec/renderer.spec.ts

@@ -6,7 +6,7 @@
 
 import { createGl } from './gl.shim';
 
-import { PerspectiveCamera } from 'mol-canvas3d/camera/perspective';
+import { Camera } from 'mol-canvas3d/camera';
 import { Vec3, Mat4 } from 'mol-math/linear-algebra';
 import { ValueCell } from 'mol-util';
 
@@ -36,7 +36,7 @@ import { Sphere3D } from 'mol-math/geometry';
 
 function createRenderer(gl: WebGLRenderingContext) {
     const ctx = createContext(gl)
-    const camera = PerspectiveCamera.create({
+    const camera = new Camera({
         near: 0.01,
         far: 10000,
         position: Vec3.create(0, 0, 50)

+ 5 - 5
src/mol-gl/renderer.ts

@@ -6,7 +6,7 @@
 
 // import { Vec3, Mat4 } from 'mol-math/linear-algebra'
 import { Viewport } from 'mol-canvas3d/camera/util';
-import { Camera } from 'mol-canvas3d/camera/base';
+import { Camera } from 'mol-canvas3d/camera';
 
 import Scene from './scene';
 import { WebGLContext, createImageData } from './webgl/context';
@@ -100,8 +100,8 @@ namespace Renderer {
             uHighlightColor: ValueCell.create(Vec3.clone(highlightColor)),
             uSelectColor: ValueCell.create(Vec3.clone(selectColor)),
 
-            uFogNear: ValueCell.create(camera.near),
-            uFogFar: ValueCell.create(camera.far / 50),
+            uFogNear: ValueCell.create(camera.state.near),
+            uFogFar: ValueCell.create(camera.state.far / 50),
             uFogColor: ValueCell.create(Vec3.clone(fogColor)),
         }
 
@@ -157,8 +157,8 @@ namespace Renderer {
             ValueCell.update(globalUniforms.uModelViewProjection, Mat4.mul(modelViewProjection, modelView, camera.projection))
             ValueCell.update(globalUniforms.uInvModelViewProjection, Mat4.invert(invModelViewProjection, modelViewProjection))
 
-            ValueCell.update(globalUniforms.uFogFar, camera.fogFar)
-            ValueCell.update(globalUniforms.uFogNear, camera.fogNear)
+            ValueCell.update(globalUniforms.uFogFar, camera.state.fogFar)
+            ValueCell.update(globalUniforms.uFogNear, camera.state.fogNear)
 
             currentProgramId = -1
 

+ 1 - 1
src/mol-model/loci.ts

@@ -37,4 +37,4 @@ export function areLociEqual(lociA: Loci, lociB: Loci) {
     return false
 }
 
-export type Loci =  StructureElement.Loci | Link.Loci | EveryLoci | EmptyLoci | Shape.Loci
+export type Loci = StructureElement.Loci | Link.Loci | EveryLoci | EmptyLoci | Shape.Loci

+ 1 - 1
src/mol-model/shape/shape.ts

@@ -25,7 +25,7 @@ export namespace Shape {
         let currentGroupCount = -1
 
         return {
-            id: UUID.create(),
+            id: UUID.create22(),
             name,
             mesh,
             get groupCount() {

+ 3 - 3
src/mol-model/structure/model/formats/mmcif.ts

@@ -169,7 +169,7 @@ function createStandardModel(format: mmCIF_Format, atom_site: AtomSite, entities
     if (previous && atomic.sameAsPrevious) {
         return {
             ...previous,
-            id: UUID.create(),
+            id: UUID.create22(),
             modelNum: atom_site.pdbx_PDB_model_num.value(0),
             atomicConformation: atomic.conformation,
             _dynamicPropertyData: Object.create(null)
@@ -182,7 +182,7 @@ function createStandardModel(format: mmCIF_Format, atom_site: AtomSite, entities
         : format.data._name;
 
     return {
-        id: UUID.create(),
+        id: UUID.create22(),
         label,
         sourceData: format,
         modelNum: atom_site.pdbx_PDB_model_num.value(0),
@@ -208,7 +208,7 @@ function createModelIHM(format: mmCIF_Format, data: IHMData, formatData: FormatD
     const coarse = getIHMCoarse(data, formatData);
 
     return {
-        id: UUID.create(),
+        id: UUID.create22(),
         label: data.model_name,
         sourceData: format,
         modelNum: data.model_id,

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

@@ -62,7 +62,7 @@ function createHierarchyData(atom_site: AtomSite, offsets: { residues: ArrayLike
 
 function getConformation(atom_site: AtomSite): AtomicConformation {
     return {
-        id: UUID.create(),
+        id: UUID.create22(),
         atomId: atom_site.id,
         occupancy: atom_site.occupancy,
         B_iso_or_equiv: atom_site.B_iso_or_equiv,

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

@@ -49,7 +49,7 @@ export function getIHMCoarse(data: IHMData, formatData: FormatData): { hierarchy
             gaussians: { ...gaussianData, ...gaussianKeys, ...gaussianRanges },
         },
         conformation: {
-            id: UUID.create(),
+            id: UUID.create22(),
             spheres: sphereConformation,
             gaussians: gaussianConformation
         }

+ 1 - 1
src/mol-model/structure/model/properties/custom/descriptor.ts

@@ -31,7 +31,7 @@ function ModelPropertyDescriptor<Ctx, Desc extends ModelPropertyDescriptor<Ctx>>
 namespace ModelPropertyDescriptor {
     export function getUUID(prop: ModelPropertyDescriptor): UUID {
         if (!(prop as any).__key) {
-            (prop as any).__key = UUID.create();
+            (prop as any).__key = UUID.create22();
         }
         return (prop as any).__key;
     }

+ 3 - 3
src/mol-model/structure/model/properties/custom/indexed.ts

@@ -83,7 +83,7 @@ function arrayToMap<Idx extends IndexedCustomProperty.Index, T>(array: ArrayLike
 }
 
 class SegmentedMappedIndexedCustomProperty<Idx extends IndexedCustomProperty.Index, T = any> implements IndexedCustomProperty<Idx, T> {
-    readonly id: UUID = UUID.create();
+    readonly id: UUID = UUID.create22();
     readonly kind: Unit.Kind;
     has(idx: Idx): boolean { return this.map.has(idx); }
     get(idx: Idx) { return this.map.get(idx); }
@@ -129,7 +129,7 @@ class SegmentedMappedIndexedCustomProperty<Idx extends IndexedCustomProperty.Ind
 }
 
 class ElementMappedCustomProperty<T = any> implements IndexedCustomProperty<ElementIndex, T> {
-    readonly id: UUID = UUID.create();
+    readonly id: UUID = UUID.create22();
     readonly kind: Unit.Kind;
     readonly level = 'atom';
     has(idx: ElementIndex): boolean { return this.map.has(idx); }
@@ -173,7 +173,7 @@ class ElementMappedCustomProperty<T = any> implements IndexedCustomProperty<Elem
 }
 
 class EntityMappedCustomProperty<T = any> implements IndexedCustomProperty<EntityIndex, T> {
-    readonly id: UUID = UUID.create();
+    readonly id: UUID = UUID.create22();
     readonly kind: Unit.Kind;
     readonly level = 'entity';
     has(idx: EntityIndex): boolean { return this.map.has(idx); }

+ 1 - 1
src/mol-model/structure/query/context.ts

@@ -5,7 +5,7 @@
  */
 
 import { Structure, StructureElement, Unit } from '../structure';
-import { now } from 'mol-task';
+import { now } from 'mol-util/now';
 import { ElementIndex } from '../model';
 import { Link } from '../structure/unit/links';
 

+ 13 - 4
src/mol-plugin/behavior.ts

@@ -5,10 +5,19 @@
  */
 
 export * from './behavior/behavior'
-import * as Data from './behavior/data'
-import * as Representation from './behavior/representation'
+
+import * as StaticState from './behavior/static/state'
+import * as StaticRepresentation from './behavior/static/representation'
+import * as StaticCamera from './behavior/static/camera'
+
+import * as DynamicRepresentation from './behavior/dynamic/representation'
+
+export const BuiltInPluginBehaviors = {
+    State: StaticState,
+    Representation: StaticRepresentation,
+    Camera: StaticCamera
+}
 
 export const PluginBehaviors = {
-    Data,
-    Representation
+    Representation: DynamicRepresentation
 }

+ 15 - 8
src/mol-plugin/behavior/behavior.ts

@@ -4,8 +4,7 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { PluginStateTransform } from '../state/base';
-import { PluginStateObjects as SO } from '../state/objects';
+import { PluginStateTransform, PluginStateObject } from '../state/objects';
 import { Transformer } from 'mol-state';
 import { Task } from 'mol-task';
 import { PluginContext } from 'mol-plugin/context';
@@ -23,26 +22,34 @@ interface PluginBehavior<P = unknown> {
 }
 
 namespace PluginBehavior {
+    export class Root extends PluginStateObject.Create({ name: 'Root', typeClass: 'Root' }) { }
+    export class Behavior extends PluginStateObject.CreateBehavior<PluginBehavior>({ name: 'Behavior' }) { }
+
     export interface Ctor<P = undefined> { new(ctx: PluginContext, params?: P): PluginBehavior<P> }
 
     export interface CreateParams<P> {
         name: string,
         ctor: Ctor<P>,
         label?: (params: P) => { label: string, description?: string },
-        display: { name: string, description?: string },
-        params?: Transformer.Definition<SO.BehaviorRoot, SO.Behavior, P>['params']
+        display: {
+            name: string,
+            group: string,
+            description?: string
+        },
+        params?: Transformer.Definition<Root, Behavior, P>['params'],
     }
 
     export function create<P>(params: CreateParams<P>) {
-        return PluginStateTransform.Create<SO.BehaviorRoot, SO.Behavior, P>({
+        // TODO: cache groups etc
+        return PluginStateTransform.Create<Root, Behavior, P>({
             name: params.name,
             display: params.display,
-            from: [SO.BehaviorRoot],
-            to: [SO.Behavior],
+            from: [Root],
+            to: [Behavior],
             params: params.params,
             apply({ params: p }, ctx: PluginContext) {
                 const label = params.label ? params.label(p) : { label: params.display.name, description: params.display.description };
-                return new SO.Behavior(label, new params.ctor(ctx, p));
+                return new Behavior(new params.ctor(ctx, p), label);
             },
             update({ b, newParams }) {
                 return Task.create('Update Behavior', async () => {

+ 0 - 0
src/mol-plugin/behavior/camera.ts


+ 0 - 30
src/mol-plugin/behavior/data.ts

@@ -1,30 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { PluginBehavior } from './behavior';
-import { PluginCommands } from 'mol-plugin/command';
-import { StateTree } from 'mol-state';
-
-export const SetCurrentObject = PluginBehavior.create({
-    name: 'set-current-data-object-behavior',
-    ctor: PluginBehavior.simpleCommandHandler(PluginCommands.Data.SetCurrentObject, ({ ref }, ctx) => ctx.state.data.setCurrent(ref)),
-    display: { name: 'Set Current Handler' }
-});
-
-export const Update = PluginBehavior.create({
-    name: 'update-data-behavior',
-    ctor: PluginBehavior.simpleCommandHandler(PluginCommands.Data.Update, ({ tree }, ctx) => ctx.state.updateData(tree)),
-    display: { name: 'Update Data Handler' }
-});
-
-export const RemoveObject = PluginBehavior.create({
-    name: 'remove-object-data-behavior',
-    ctor: PluginBehavior.simpleCommandHandler(PluginCommands.Data.RemoveObject, ({ ref }, ctx) => {
-        const tree = StateTree.build(ctx.state.data.tree).delete(ref).getTree();
-        ctx.state.updateData(tree);
-    }),
-    display: { name: 'Remove Object Handler' }
-});

+ 42 - 0
src/mol-plugin/behavior/dynamic/representation.ts

@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginBehavior } from '../behavior';
+import { EmptyLoci, Loci, areLociEqual } from 'mol-model/loci';
+import { MarkerAction } from 'mol-geo/geometry/marker-data';
+
+export const HighlightLoci = PluginBehavior.create({
+    name: 'representation-highlight-loci',
+    ctor: class extends PluginBehavior.Handler {
+        register(): void {
+            let prevLoci: Loci = EmptyLoci, prevRepr: any = void 0;
+            this.subscribeObservable(this.ctx.behaviors.canvas.highlightLoci, current => {
+                if (!this.ctx.canvas3d) return;
+
+                if (current.repr !== prevRepr || !areLociEqual(current.loci, prevLoci)) {
+                    this.ctx.canvas3d.mark(prevLoci, MarkerAction.RemoveHighlight);
+                    this.ctx.canvas3d.mark(current.loci, MarkerAction.Highlight);
+                    prevLoci = current.loci;
+                    prevRepr = current.repr;
+                }
+            });
+        }
+    },
+    display: { name: 'Highlight Loci on Canvas', group: 'Data' }
+});
+
+export const SelectLoci = PluginBehavior.create({
+    name: 'representation-select-loci',
+    ctor: class extends PluginBehavior.Handler {
+        register(): void {
+            this.subscribeObservable(this.ctx.behaviors.canvas.selectLoci, ({ loci }) => {
+                if (!this.ctx.canvas3d) return;
+                this.ctx.canvas3d.mark(loci, MarkerAction.Toggle);
+            });
+        }
+    },
+    display: { name: 'Select Loci on Canvas', group: 'Data' }
+});

+ 0 - 50
src/mol-plugin/behavior/representation.ts

@@ -1,50 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { PluginBehavior } from './behavior';
-import { PluginStateObjects as SO } from '../state/objects';
-
-class _AddRepresentationToCanvas extends PluginBehavior.Handler {
-    register(): void {
-        this.subscribeObservable(this.ctx.events.state.data.object.created, o => {
-            if (!SO.StructureRepresentation3D.is(o.obj)) return;
-            this.ctx.canvas3d.add(o.obj.data);
-            this.ctx.canvas3d.requestDraw(true);
-        });
-        this.subscribeObservable(this.ctx.events.state.data.object.updated, o => {
-            const oo = o.obj;
-            if (!SO.StructureRepresentation3D.is(oo)) return;
-            this.ctx.canvas3d.add(oo.data);
-            this.ctx.canvas3d.requestDraw(true);
-        });
-        this.subscribeObservable(this.ctx.events.state.data.object.removed, o => {
-            const oo = o.obj;
-            console.log('removed', o.ref, oo && oo.type);
-            if (!SO.StructureRepresentation3D.is(oo)) return;
-            this.ctx.canvas3d.remove(oo.data);
-            console.log('removed from canvas', o.ref);
-            this.ctx.canvas3d.requestDraw(true);
-            oo.data.destroy();
-        });
-        this.subscribeObservable(this.ctx.events.state.data.object.replaced, o => {
-            if (o.oldObj && SO.StructureRepresentation3D.is(o.oldObj)) {
-                this.ctx.canvas3d.remove(o.oldObj.data);
-                this.ctx.canvas3d.requestDraw(true);
-                o.oldObj.data.destroy();
-            }
-            if (o.newObj && SO.StructureRepresentation3D.is(o.newObj)) {
-                this.ctx.canvas3d.add(o.newObj.data);
-                this.ctx.canvas3d.requestDraw(true);
-            }
-        });
-    }
-}
-
-export const AddRepresentationToCanvas = PluginBehavior.create({
-    name: 'add-representation-to-canvas',
-    ctor: _AddRepresentationToCanvas,
-    display: { name: 'Add Representation To Canvas' }
-});

+ 57 - 0
src/mol-plugin/behavior/static/camera.ts

@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginContext } from 'mol-plugin/context';
+import { PluginCommands } from 'mol-plugin/command';
+import { PluginStateObject as SO } from '../../state/objects';
+import { CameraSnapshotManager } from 'mol-plugin/state/camera';
+
+export function registerDefault(ctx: PluginContext) {
+    Reset(ctx);
+    SetSnapshot(ctx);
+    Snapshots(ctx);
+}
+
+export function Reset(ctx: PluginContext) {
+    PluginCommands.Camera.Reset.subscribe(ctx, () => {
+        const sel = ctx.state.dataState.select(q => q.root.subtree().ofType(SO.Molecule.Structure));
+        if (!sel.length) return;
+
+        const center = (sel[0].obj! as SO.Molecule.Structure).data.boundary.sphere.center;
+        ctx.canvas3d.camera.setState({ target: center });
+        ctx.canvas3d.requestDraw(true);
+
+        // TODO
+        // ctx.canvas3d.resetCamera();
+    })
+}
+
+export function SetSnapshot(ctx: PluginContext) {
+    PluginCommands.Camera.SetSnapshot.subscribe(ctx, ({ snapshot }) => {
+        ctx.canvas3d.camera.setState(snapshot);
+        ctx.canvas3d.requestDraw();
+    })
+}
+
+export function Snapshots(ctx: PluginContext) {
+    PluginCommands.Camera.Snapshots.Clear.subscribe(ctx, () => {
+        ctx.state.cameraSnapshots.clear();
+    });
+
+    PluginCommands.Camera.Snapshots.Remove.subscribe(ctx, ({ id }) => {
+        ctx.state.cameraSnapshots.remove(id);
+    });
+
+    PluginCommands.Camera.Snapshots.Add.subscribe(ctx, ({ name, description }) => {
+        const entry = CameraSnapshotManager.Entry(name || new Date().toLocaleTimeString(), ctx.canvas3d.camera.getSnapshot(), description);
+        ctx.state.cameraSnapshots.add(entry);
+    });
+
+    PluginCommands.Camera.Snapshots.Apply.subscribe(ctx, ({ id }) => {
+        const e = ctx.state.cameraSnapshots.getEntry(id);
+        return PluginCommands.Camera.SetSnapshot.dispatch(ctx, { snapshot: e.snapshot });
+    });
+}

+ 52 - 0
src/mol-plugin/behavior/static/representation.ts

@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginStateObject as SO } from '../../state/objects';
+import { PluginContext } from 'mol-plugin/context';
+
+export function registerDefault(ctx: PluginContext) {
+    SyncRepresentationToCanvas(ctx);
+}
+
+export function SyncRepresentationToCanvas(ctx: PluginContext) {
+    const events = ctx.state.dataState.events;
+    events.object.created.subscribe(e => {
+        if (!SO.isRepresentation3D(e.obj)) return;
+        ctx.canvas3d.add(e.obj.data);
+        ctx.canvas3d.requestDraw(true);
+
+        // TODO: update visiblity
+    });
+    events.object.updated.subscribe(e => {
+        if (e.oldObj && SO.isRepresentation3D(e.oldObj)) {
+            ctx.canvas3d.remove(e.oldObj.data);
+            ctx.canvas3d.requestDraw(true);
+            e.oldObj.data.destroy();
+        }
+
+        if (!SO.isRepresentation3D(e.obj)) return;
+
+        // TODO: update visiblity
+        ctx.canvas3d.add(e.obj.data);
+        ctx.canvas3d.requestDraw(true);
+    });
+    events.object.removed.subscribe(e => {
+        const oo = e.obj;
+        if (!SO.isRepresentation3D(oo)) return;
+        ctx.canvas3d.remove(oo.data);
+        ctx.canvas3d.requestDraw(true);
+        oo.data.destroy();
+    });
+}
+
+export function UpdateRepresentationVisibility(ctx: PluginContext) {
+    ctx.state.dataState.events.cell.stateUpdated.subscribe(e => {
+        const cell = e.state.cells.get(e.ref)!;
+        if (!SO.isRepresentation3D(cell.obj)) return;
+
+        // TODO: update visiblity
+    })
+}

+ 91 - 0
src/mol-plugin/behavior/static/state.ts

@@ -0,0 +1,91 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommands } from '../../command';
+import { PluginContext } from '../../context';
+import { StateTree, Transform, State } from 'mol-state';
+import { PluginStateSnapshotManager } from 'mol-plugin/state/snapshots';
+
+export function registerDefault(ctx: PluginContext) {
+    SetCurrentObject(ctx);
+    Update(ctx);
+    ApplyAction(ctx);
+    RemoveObject(ctx);
+    ToggleExpanded(ctx);
+    ToggleVisibility(ctx);
+    Snapshots(ctx);
+}
+
+export function SetCurrentObject(ctx: PluginContext) {
+    PluginCommands.State.SetCurrentObject.subscribe(ctx, ({ state, ref }) => state.setCurrent(ref));
+}
+
+export function Update(ctx: PluginContext) {
+    PluginCommands.State.Update.subscribe(ctx, ({ state, tree }) => ctx.runTask(state.update(tree)));
+}
+
+export function ApplyAction(ctx: PluginContext) {
+    PluginCommands.State.ApplyAction.subscribe(ctx, ({ state, action, ref }) => ctx.runTask(state.apply(action.action, action.params, ref)));
+}
+
+export function RemoveObject(ctx: PluginContext) {
+    PluginCommands.State.RemoveObject.subscribe(ctx, ({ state, ref }) => {
+        const tree = state.tree.build().delete(ref).getTree();
+        return ctx.runTask(state.update(tree));
+    });
+}
+
+export function ToggleExpanded(ctx: PluginContext) {
+    PluginCommands.State.ToggleExpanded.subscribe(ctx, ({ state, ref }) => state.updateCellState(ref, ({ isCollapsed }) => ({ isCollapsed: !isCollapsed })));
+}
+
+export function ToggleVisibility(ctx: PluginContext) {
+    PluginCommands.State.ToggleVisibility.subscribe(ctx, ({ state, ref }) => setVisibility(state, ref, !state.tree.cellStates.get(ref).isHidden));
+}
+
+function setVisibility(state: State, root: Transform.Ref, value: boolean) {
+    StateTree.doPreOrder(state.tree, state.tree.transforms.get(root), { state, value }, setVisibilityVisitor);
+}
+
+function setVisibilityVisitor(t: Transform, tree: StateTree, ctx: { state: State, value: boolean }) {
+    ctx.state.updateCellState(t.ref, { isHidden: ctx.value });
+}
+
+export function Snapshots(ctx: PluginContext) {
+    PluginCommands.State.Snapshots.Clear.subscribe(ctx, () => {
+        ctx.state.snapshots.clear();
+    });
+
+    PluginCommands.State.Snapshots.Remove.subscribe(ctx, ({ id }) => {
+        ctx.state.snapshots.remove(id);
+    });
+
+    PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description }) => {
+        const entry = PluginStateSnapshotManager.Entry(name || new Date().toLocaleTimeString(), ctx.state.getSnapshot(), description);
+        ctx.state.snapshots.add(entry);
+    });
+
+    PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => {
+        const e = ctx.state.snapshots.getEntry(id);
+        return ctx.state.setSnapshot(e.snapshot);
+    });
+
+    PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, serverUrl }) => {
+        return fetch(`${serverUrl}/set?name=${encodeURIComponent(name || '')}&description=${encodeURIComponent(description || '')}`, {
+            method: 'POST',
+            mode: 'cors',
+            referrer: 'no-referrer',
+            headers: { 'Content-Type': 'application/json; charset=utf-8' },
+            body: JSON.stringify(ctx.state.getSnapshot())
+        }) as any as Promise<void>;
+    });
+
+    PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => {
+        const req = await fetch(url, { referrer: 'no-referrer' });
+        const json = await req.json();
+        return ctx.state.setSnapshot(json.data);
+    });
+}

+ 5 - 2
src/mol-plugin/command.ts

@@ -4,9 +4,12 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import * as Data from './command/data';
+import * as State from './command/state';
+import * as Camera from './command/camera';
 
 export * from './command/command';
+
 export const PluginCommands = {
-    Data
+    State,
+    Camera
 }

+ 18 - 0
src/mol-plugin/command/camera.ts

@@ -0,0 +1,18 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommand } from './command';
+import { Camera } from 'mol-canvas3d/camera';
+
+export const Reset = PluginCommand<{}>({ isImmediate: true });
+export const SetSnapshot = PluginCommand<{ snapshot: Camera.Snapshot }>({ isImmediate: true });
+
+export const Snapshots = {
+    Add: PluginCommand<{ name?: string, description?: string }>({ isImmediate: true }),
+    Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
+    Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
+    Clear: PluginCommand<{ }>({ isImmediate: true }),
+}

+ 43 - 24
src/mol-plugin/command/command.ts

@@ -7,22 +7,22 @@
 import { PluginContext } from '../context';
 import { LinkedList } from 'mol-data/generic';
 import { RxEventHelper } from 'mol-util/rx-event-helper';
+import { UUID } from 'mol-util';
 
 export { PluginCommand }
 
 interface PluginCommand<T = unknown> {
-    readonly id: PluginCommand.Id,
+    readonly id: UUID,
     dispatch(ctx: PluginContext, params: T): Promise<void>,
     subscribe(ctx: PluginContext, action: PluginCommand.Action<T>): PluginCommand.Subscription,
-    params?: { toJSON(params: T): any, fromJSON(json: any): T }
+    params: { isImmediate: boolean }
 }
 
 /** namespace.id must a globally unique identifier */
-function PluginCommand<T>(namespace: string, id: string, params?: PluginCommand<T>['params']): PluginCommand<T> {
-    return new Impl(`${namespace}.${id}` as PluginCommand.Id, params);
+function PluginCommand<T>(params?: Partial<PluginCommand<T>['params']>): PluginCommand<T> {
+    return new Impl({ isImmediate: false, ...params });
 }
 
-const cmdRepo = new Map<string, PluginCommand<any>>();
 class Impl<T> implements PluginCommand<T> {
     dispatch(ctx: PluginContext, params: T): Promise<void> {
         return ctx.commands.dispatch(this, params)
@@ -30,9 +30,8 @@ class Impl<T> implements PluginCommand<T> {
     subscribe(ctx: PluginContext, action: PluginCommand.Action<T>): PluginCommand.Subscription {
         return ctx.commands.subscribe(this, action);
     }
-    constructor(public id: PluginCommand.Id, public params: PluginCommand<T>['params']) {
-        if (cmdRepo.has(id)) throw new Error(`Command id '${id}' already in use.`);
-        cmdRepo.set(id, this);
+    id = UUID.create22();
+    constructor(public params: PluginCommand<T>['params']) {
     }
 }
 
@@ -44,7 +43,7 @@ namespace PluginCommand {
     }
 
     export type Action<T> = (params: T) => void | Promise<void>
-    type Instance = { id: string, params: any, resolve: () => void, reject: (e: any) => void }
+    type Instance = { cmd: PluginCommand<any>, params: any, resolve: () => void, reject: (e: any) => void }
 
     export class Manager {
         private subs = new Map<string, Action<any>[]>();
@@ -85,22 +84,27 @@ namespace PluginCommand {
 
 
         /** Resolves after all actions have completed */
-        dispatch<T>(cmd: PluginCommand<T> | Id, params: T) {
+        dispatch<T>(cmd: PluginCommand<T>, params: T) {
             return new Promise<void>((resolve, reject) => {
                 if (this.disposing) {
                     reject('disposed');
                     return;
                 }
 
-                const id = typeof cmd === 'string' ? cmd : (cmd as PluginCommand<T>).id;
-                const actions = this.subs.get(id);
+                const actions = this.subs.get(cmd.id);
                 if (!actions) {
                     resolve();
                     return;
                 }
 
-                this.queue.addLast({ id, params, resolve, reject });
-                this.next();
+                const instance: Instance = { cmd, params, resolve, reject };
+
+                if (cmd.params.isImmediate) {
+                    this.resolve(instance);
+                } else {
+                    this.queue.addLast({ cmd, params, resolve, reject });
+                    this.next();
+                }
             });
         }
 
@@ -111,24 +115,39 @@ namespace PluginCommand {
             }
         }
 
-        private async next() {
-            if (this.queue.count === 0) return;
-            const cmd = this.queue.removeFirst()!;
-
-            const actions = this.subs.get(cmd.id);
-            if (!actions) return;
+        private async resolve(instance: Instance) {
+            const actions = this.subs.get(instance.cmd.id);
+            if (!actions) {
+                try {
+                    instance.resolve();
+                } finally {
+                    if (!instance.cmd.params.isImmediate && !this.disposing) this.next();
+                }
+                return;
+            }
 
             try {
+                if (!instance.cmd.params.isImmediate) this.executing = true;
                 // TODO: should actions be called "asynchronously" ("setImmediate") instead?
                 for (const a of actions) {
-                    await a(cmd.params);
+                    await a(instance.params);
                 }
-                cmd.resolve();
+                instance.resolve();
             } catch (e) {
-                cmd.reject(e);
+                instance.reject(e);
             } finally {
-                if (!this.disposing) this.next();
+                if (!instance.cmd.params.isImmediate) {
+                    this.executing = false;
+                    if (!this.disposing) this.next();
+                }
             }
         }
+
+        private executing = false;
+        private async next() {
+            if (this.queue.count === 0 || this.executing) return;
+            const instance = this.queue.removeFirst()!;
+            this.resolve(instance);
+        }
     }
 }

+ 0 - 13
src/mol-plugin/command/data.ts

@@ -1,13 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { PluginCommand } from './command';
-import { Transform, StateTree } from 'mol-state';
-
-export const SetCurrentObject = PluginCommand<{ ref: Transform.Ref }>('ms-data', 'set-current-object');
-export const Update = PluginCommand<{ tree: StateTree }>('ms-data', 'update');
-export const UpdateObject = PluginCommand<{ ref: Transform.Ref, params: any }>('ms-data', 'update-object');
-export const RemoveObject = PluginCommand<{ ref: Transform.Ref }>('ms-data', 'remove-object');

+ 31 - 0
src/mol-plugin/command/state.ts

@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommand } from './command';
+import { Transform, State } from 'mol-state';
+import { StateAction } from 'mol-state/action';
+
+export const SetCurrentObject = PluginCommand<{ state: State, ref: Transform.Ref }>();
+export const ApplyAction = PluginCommand<{ state: State, action: StateAction.Instance, ref?: Transform.Ref }>();
+export const Update = PluginCommand<{ state: State, tree: State.Tree | State.Builder }>();
+
+// export const UpdateObject = PluginCommand<{ ref: Transform.Ref, params: any }>('ms-data', 'update-object');
+
+export const RemoveObject = PluginCommand<{ state: State, ref: Transform.Ref }>();
+
+export const ToggleExpanded = PluginCommand<{ state: State, ref: Transform.Ref }>({ isImmediate: true });
+
+export const ToggleVisibility = PluginCommand<{ state: State, ref: Transform.Ref }>({ isImmediate: true });
+
+export const Snapshots = {
+    Add: PluginCommand<{ name?: string, description?: string }>({ isImmediate: true }),
+    Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
+    Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
+    Clear: PluginCommand<{ }>({ isImmediate: true }),
+
+    Upload: PluginCommand<{ name?: string, description?: string, serverUrl: string }>({ isImmediate: true }),
+    Fetch: PluginCommand<{ url: string }>()
+}

+ 83 - 83
src/mol-plugin/context.ts

@@ -4,36 +4,59 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { StateTree, StateSelection, Transformer, Transform } from 'mol-state';
-import Canvas3D from 'mol-canvas3d/canvas3d';
+import { Transformer, Transform, State } from 'mol-state';
+import { Canvas3D } from 'mol-canvas3d/canvas3d';
 import { StateTransforms } from './state/transforms';
-import { PluginStateObjects as SO } from './state/objects';
+import { PluginStateObject as SO } from './state/objects';
 import { RxEventHelper } from 'mol-util/rx-event-helper';
 import { PluginState } from './state';
-import { MolScriptBuilder } from 'mol-script/language/builder';
 import { PluginCommand, PluginCommands } from './command';
 import { Task } from 'mol-task';
 import { merge } from 'rxjs';
-import { PluginBehaviors } from './behavior';
+import { PluginBehaviors, BuiltInPluginBehaviors } from './behavior';
+import { Loci, EmptyLoci } from 'mol-model/loci';
+import { Representation } from 'mol-repr/representation';
+import { CreateStructureFromPDBe } from './state/actions/basic';
+import { LogEntry } from 'mol-util/log-entry';
+import { TaskManager } from './util/task-manager';
 
 export class PluginContext {
     private disposed = false;
     private ev = RxEventHelper.create();
+    private tasks = new TaskManager();
 
     readonly state = new PluginState(this);
     readonly commands = new PluginCommand.Manager();
 
     readonly events = {
         state: {
-            data: this.state.data.context.events,
-            behavior: this.state.behavior.context.events
-        }
+            cell: {
+                stateUpdated: merge(this.state.dataState.events.cell.stateUpdated, this.state.behaviorState.events.cell.stateUpdated),
+                created: merge(this.state.dataState.events.cell.created, this.state.behaviorState.events.cell.created),
+                removed: merge(this.state.dataState.events.cell.removed, this.state.behaviorState.events.cell.removed),
+            },
+            object: {
+                created: merge(this.state.dataState.events.object.created, this.state.behaviorState.events.object.created),
+                removed: merge(this.state.dataState.events.object.removed, this.state.behaviorState.events.object.removed),
+                updated: merge(this.state.dataState.events.object.updated, this.state.behaviorState.events.object.updated)
+            },
+            // data: this.state.dataState.events,
+            // behavior: this.state.behaviorState.events,
+            cameraSnapshots: this.state.cameraSnapshots.events,
+            snapshots: this.state.snapshots.events,
+        },
+        log: this.ev<LogEntry>(),
+        task: this.tasks.events
     };
 
     readonly behaviors = {
-        state: {
-            data: this.state.data.context.behaviors,
-            behavior: this.state.behavior.context.behaviors
+        // state: {
+        //     data: this.state.dataState.behaviors,
+        //     behavior: this.state.behaviorState.behaviors
+        // },
+        canvas: {
+            highlightLoci: this.ev.behavior<{ loci: Loci, repr?: Representation.Any }>({ loci: EmptyLoci }),
+            selectLoci: this.ev.behavior<{ loci: Loci, repr?: Representation.Any }>({ loci: EmptyLoci }),
         },
         command: this.commands.behaviour
     };
@@ -45,14 +68,18 @@ export class PluginContext {
         try {
             (this.canvas3d as Canvas3D) = Canvas3D.create(canvas, container);
             this.canvas3d.animate();
-            console.log('canvas3d created');
             return true;
         } catch (e) {
+            this.log(LogEntry.error('' + e));
             console.error(e);
             return false;
         }
     }
 
+    log(e: LogEntry) {
+        this.events.log.next(e);
+    }
+
     /**
      * This should be used in all transform related request so that it could be "spoofed" to allow
      * "static" access to resources.
@@ -62,8 +89,8 @@ export class PluginContext {
         return type === 'string' ? await req.text() : new Uint8Array(await req.arrayBuffer());
     }
 
-    async runTask<T>(task: Task<T>) {
-        return await task.run(p => console.log(p), 250);
+    runTask<T>(task: Task<T>) {
+        return this.tasks.run(task);
     }
 
     dispose() {
@@ -72,100 +99,73 @@ export class PluginContext {
         this.canvas3d.dispose();
         this.ev.dispose();
         this.state.dispose();
+        this.tasks.dispose();
         this.disposed = true;
     }
 
-    async _test_initBehaviours() {
-        const tree = StateTree.build(this.state.behavior.tree)
-            .toRoot().apply(PluginBehaviors.Data.SetCurrentObject)
-            .and().toRoot().apply(PluginBehaviors.Data.Update)
-            .and().toRoot().apply(PluginBehaviors.Data.RemoveObject)
-            .and().toRoot().apply(PluginBehaviors.Representation.AddRepresentationToCanvas)
-            .getTree();
+    private initBuiltInBehavior() {
+        BuiltInPluginBehaviors.State.registerDefault(this);
+        BuiltInPluginBehaviors.Representation.registerDefault(this);
+        BuiltInPluginBehaviors.Camera.registerDefault(this);
 
-        await this.state.updateBehaviour(tree);
+        merge(this.state.dataState.events.log, this.state.behaviorState.events.log).subscribe(e => this.events.log.next(e));
     }
 
-    _test_applyTransform(a: Transform.Ref, transformer: Transformer, params: any) {
-        const tree = StateTree.build(this.state.data.tree).to(a).apply(transformer, params).getTree();
-        PluginCommands.Data.Update.dispatch(this, { tree });
+    async _test_initBehaviors() {
+        const tree = this.state.behaviorState.tree.build()
+            .toRoot().apply(PluginBehaviors.Representation.HighlightLoci, { ref: PluginBehaviors.Representation.HighlightLoci.id })
+            .toRoot().apply(PluginBehaviors.Representation.SelectLoci, { ref: PluginBehaviors.Representation.SelectLoci.id })
+            .getTree();
+
+        await this.runTask(this.state.behaviorState.update(tree));
     }
 
-    _test_createState(url: string) {
-        const b = StateTree.build(this.state.data.tree);
-
-        const query = MolScriptBuilder.struct.generator.atomGroups({
-            // 'atom-test': MolScriptBuilder.core.rel.eq([
-            //     MolScriptBuilder.struct.atomProperty.macromolecular.label_comp_id(),
-            //     MolScriptBuilder.es('C')
-            // ]),
-            'residue-test': MolScriptBuilder.core.rel.eq([
-                MolScriptBuilder.struct.atomProperty.macromolecular.label_comp_id(),
-                'ALA'
-            ])
-        });
+    _test_initDataActions() {
+        this.state.dataState.actions
+            .add(CreateStructureFromPDBe)
+            .add(StateTransforms.Data.Download)
+            .add(StateTransforms.Data.ParseCif)
+            .add(StateTransforms.Model.CreateStructureAssembly)
+            .add(StateTransforms.Model.CreateStructure)
+            .add(StateTransforms.Model.CreateModelFromTrajectory)
+            .add(StateTransforms.Visuals.CreateStructureRepresentation);
+    }
 
-        const newTree = b.toRoot()
-            .apply(StateTransforms.Data.Download, { url })
-            .apply(StateTransforms.Data.ParseCif)
-            .apply(StateTransforms.Model.ParseModelsFromMmCif, {}, { ref: 'models' })
-            .apply(StateTransforms.Model.CreateStructureFromModel, { modelIndex: 0 }, { ref: 'structure' })
-            .apply(StateTransforms.Model.CreateStructureAssembly)
-            .apply(StateTransforms.Model.CreateStructureSelection, { query, label: 'ALA residues' })
-            .apply(StateTransforms.Visuals.CreateStructureRepresentation)
-            .getTree();
+    applyTransform(state: State, a: Transform.Ref, transformer: Transformer, params: any) {
+        const tree = state.tree.build().to(a).apply(transformer, params);
+        return PluginCommands.State.Update.dispatch(this, { state, tree });
+    }
 
-        this.state.updateData(newTree);
+    updateTransform(state: State, a: Transform.Ref, params: any) {
+        const tree = state.build().to(a).update(params);
+        return PluginCommands.State.Update.dispatch(this, { state, tree });
     }
 
     private initEvents() {
-        merge(this.events.state.data.object.created, this.events.state.behavior.object.created).subscribe(o => {
-            console.log('creating', o.obj.type);
-            if (!SO.Behavior.is(o.obj)) return;
+        this.events.state.object.created.subscribe(o => {
+            if (!SO.isBehavior(o.obj)) return;
             o.obj.data.register();
         });
 
-        merge(this.events.state.data.object.removed, this.events.state.behavior.object.removed).subscribe(o => {
-            if (!SO.Behavior.is(o.obj)) return;
+        this.events.state.object.removed.subscribe(o => {
+            if (!SO.isBehavior(o.obj)) return;
             o.obj.data.unregister();
         });
 
-        merge(this.events.state.data.object.replaced, this.events.state.behavior.object.replaced).subscribe(o => {
-            if (o.oldObj && SO.Behavior.is(o.oldObj)) o.oldObj.data.unregister();
-            if (o.newObj && SO.Behavior.is(o.newObj)) o.newObj.data.register();
+        this.events.state.object.updated.subscribe(o => {
+            if (o.action === 'recreate') {
+                if (o.oldObj && SO.isBehavior(o.oldObj)) o.oldObj.data.unregister();
+                if (o.obj && SO.isBehavior(o.obj)) o.obj.data.register();
+            }
         });
     }
 
-    _test_centerView() {
-        const sel = StateSelection.select(StateSelection.root().subtree().ofType(SO.Structure.type), this.state.data);
-        if (!sel.length) return;
-
-        const center = (sel[0].obj! as SO.Structure).data.boundary.sphere.center;
-        console.log({ sel, center, rc: this.canvas3d.reprCount });
-        this.canvas3d.center(center);
-        this.canvas3d.requestDraw(true);
-    }
-
-    _test_nextModel() {
-        const models = StateSelection.select('models', this.state.data)[0].obj as SO.Models;
-        const idx = (this.state.data.tree.getValue('structure')!.params as Transformer.Params<typeof StateTransforms.Model.CreateStructureFromModel>).modelIndex;
-        const newTree = StateTree.updateParams(this.state.data.tree, 'structure', { modelIndex: (idx + 1) % models.data.length });
-        return this.state.updateData(newTree);
-        // this.viewer.requestDraw(true);
-    }
-
-    _test_playModels() {
-        const update = async () => {
-            await this._test_nextModel();
-            setTimeout(update, 1000 / 15);
-        }
-        update();
-    }
-
     constructor() {
         this.initEvents();
+        this.initBuiltInBehavior();
 
-        this._test_initBehaviours();
+        this._test_initBehaviors();
+        this._test_initDataActions();
     }
 
     // logger = ;

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

@@ -8,6 +8,7 @@ import { PluginContext } from './context';
 import { Plugin } from './ui/plugin'
 import * as React from 'react';
 import * as ReactDOM from 'react-dom';
+import { PluginCommands } from './command';
 
 function getParam(name: string, regex: string): string {
     let r = new RegExp(`${name}=(${regex})[&]?`, 'i');
@@ -28,8 +29,9 @@ export function createPlugin(target: HTMLElement): PluginContext {
 }
 
 function trySetSnapshot(ctx: PluginContext) {
-    const snapshot = getParam('snapshot', `(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?`);
-    if (!snapshot) return;
-    const data = JSON.parse(atob(snapshot));
-    setTimeout(() => ctx.state.setSnapshot(data), 250);
+    const snapshotUrl = getParam('snapshot-url', `[^&]+`);
+    if (!snapshotUrl) return;
+    // const data = JSON.parse(atob(snapshot));
+    // setTimeout(() => ctx.state.setSnapshot(data), 250);
+    PluginCommands.State.Snapshots.Fetch.dispatch(ctx, { url: snapshotUrl })
 }

+ 56 - 31
src/mol-plugin/state.ts

@@ -4,64 +4,89 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { State, StateTree } from 'mol-state';
-import { PluginStateObjects as SO } from './state/objects';
-import { CombinedCamera } from 'mol-canvas3d/camera/combined';
-
+import { State } from 'mol-state';
+import { PluginStateObject as SO } from './state/objects';
+import { Camera } from 'mol-canvas3d/camera';
+import { PluginBehavior } from './behavior';
+import { CameraSnapshotManager } from './state/camera';
+import { PluginStateSnapshotManager } from './state/snapshots';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
 export { PluginState }
 
 class PluginState {
-    readonly data: State;
-    readonly behavior: State;
+    private ev = RxEventHelper.create();
+
+    readonly dataState: State;
+    readonly behaviorState: State;
+    readonly cameraSnapshots = new CameraSnapshotManager();
+
+    readonly snapshots = new PluginStateSnapshotManager();
+
+    readonly behavior = {
+        kind: this.ev.behavior<PluginState.Kind>('data'),
+        currentObject: this.ev.behavior<State.ObjectEvent>({} as any)
+    }
+
+    setKind(kind: PluginState.Kind) {
+        const current = this.behavior.kind.value;
+        if (kind !== current) {
+            this.behavior.kind.next(kind);
+            this.behavior.currentObject.next(kind === 'data'
+                ? this.dataState.behaviors.currentObject.value
+                : this.behaviorState.behaviors.currentObject.value)
+        }
+    }
 
     getSnapshot(): PluginState.Snapshot {
         return {
-            data: this.data.getSnapshot(),
-            behaviour: this.behavior.getSnapshot(),
+            data: this.dataState.getSnapshot(),
+            behaviour: this.behaviorState.getSnapshot(),
+            cameraSnapshots: this.cameraSnapshots.getStateSnapshot(),
             canvas3d: {
-                camera: { ...this.plugin.canvas3d.camera }
+                camera: this.plugin.canvas3d.camera.getSnapshot()
             }
         };
     }
 
     async setSnapshot(snapshot: PluginState.Snapshot) {
-        await this.behavior.setSnapshot(snapshot.behaviour);
-        await this.data.setSnapshot(snapshot.data);
-
-        // TODO: handle camera
-        // console.log({ old: { ...this.plugin.canvas3d.camera  }, new: snapshot.canvas3d.camera });
-        // CombinedCamera.copy(snapshot.canvas3d.camera, this.plugin.canvas3d.camera);
-        // CombinedCamera.update(this.plugin.canvas3d.camera);
-        // this.plugin.canvas3d.center
-        // console.log({ copied: { ...this.plugin.canvas3d.camera  } });
+        // await this.plugin.runTask(this.behaviorState.setSnapshot(snapshot.behaviour));
+        await this.plugin.runTask(this.dataState.setSnapshot(snapshot.data));
+        this.cameraSnapshots.setStateSnapshot(snapshot.cameraSnapshots);
+        this.plugin.canvas3d.camera.setState(snapshot.canvas3d.camera);
         this.plugin.canvas3d.requestDraw(true);
-        // console.log('updated camera');
-    }
-
-    updateData(tree: StateTree) {
-        return this.plugin.runTask(this.data.update(tree));
-    }
-
-    updateBehaviour(tree: StateTree) {
-        return this.plugin.runTask(this.behavior.update(tree));
     }
 
     dispose() {
-        this.data.dispose();
+        this.ev.dispose();
+        this.dataState.dispose();
+        this.behaviorState.dispose();
+        this.cameraSnapshots.dispose();
     }
 
     constructor(private plugin: import('./context').PluginContext) {
-        this.data = State.create(new SO.DataRoot({ label: 'Root' }, { }), { globalContext: plugin });
-        this.behavior = State.create(new SO.BehaviorRoot({ label: 'Root' }, { }), { globalContext: plugin });
+        this.dataState = State.create(new SO.Root({ }), { globalContext: plugin });
+        this.behaviorState = State.create(new PluginBehavior.Root({ }), { globalContext: plugin });
+
+        this.dataState.behaviors.currentObject.subscribe(o => {
+            if (this.behavior.kind.value === 'data') this.behavior.currentObject.next(o);
+        });
+        this.behaviorState.behaviors.currentObject.subscribe(o => {
+            if (this.behavior.kind.value === 'behavior') this.behavior.currentObject.next(o);
+        });
+
+        this.behavior.currentObject.next(this.dataState.behaviors.currentObject.value);
     }
 }
 
 namespace PluginState {
+    export type Kind = 'data' | 'behavior'
+
     export interface Snapshot {
         data: State.Snapshot,
         behaviour: State.Snapshot,
+        cameraSnapshots: CameraSnapshotManager.StateSnapshot,
         canvas3d: {
-            camera: CombinedCamera
+            camera: Camera.Snapshot
         }
     }
 }

+ 83 - 1
src/mol-plugin/state/actions/basic.ts

@@ -4,4 +4,86 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-// TODO: basic actions like "download and create default representation"
+import { StateAction } from 'mol-state/action';
+import { PluginStateObject } from '../objects';
+import { StateTransforms } from '../transforms';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+import { StateSelection } from 'mol-state/state/selection';
+
+export const CreateStructureFromPDBe = StateAction.create<PluginStateObject.Root, void, { id: string }>({
+    from: [PluginStateObject.Root],
+    display: {
+        name: 'Entry from PDBe',
+        description: 'Download a structure from PDBe and create its default Assembly and visual'
+    },
+    params: {
+        default: () => ({ id: '1grm' }),
+        controls: () => ({
+            id: PD.Text('PDB id', '', '1grm'),
+        }),
+        validate: p => !p.id || !p.id.trim() ? ['Enter id.'] : void 0
+    },
+    apply({ params, state }) {
+        const url = `http://www.ebi.ac.uk/pdbe/static/entry/${params.id.toLowerCase()}_updated.cif`;
+        const b = state.build();
+
+        // const query = MolScriptBuilder.struct.generator.atomGroups({
+        //     // 'atom-test': MolScriptBuilder.core.rel.eq([
+        //     //     MolScriptBuilder.struct.atomProperty.macromolecular.label_comp_id(),
+        //     //     MolScriptBuilder.es('C')
+        //     // ]),
+        //     'residue-test': MolScriptBuilder.core.rel.eq([
+        //         MolScriptBuilder.struct.atomProperty.macromolecular.label_comp_id(),
+        //         'ALA'
+        //     ])
+        // });
+
+        const newTree = b.toRoot()
+            .apply(StateTransforms.Data.Download, { url })
+            .apply(StateTransforms.Data.ParseCif)
+            .apply(StateTransforms.Model.ParseTrajectoryFromMmCif, {})
+            .apply(StateTransforms.Model.CreateModelFromTrajectory, { modelIndex: 0 })
+            .apply(StateTransforms.Model.CreateStructureAssembly)
+            // .apply(StateTransforms.Model.CreateStructureSelection, { query, label: 'ALA residues' })
+            .apply(StateTransforms.Visuals.CreateStructureRepresentation)
+            .getTree();
+
+        return state.update(newTree);
+    }
+});
+
+export const UpdateTrajectory = StateAction.create<PluginStateObject.Root, void, { action: 'advance' | 'reset', by?: number }>({
+    from: [],
+    display: {
+        name: 'Update Trajectory'
+    },
+    params: {
+        default: () => ({ action: 'reset', by: 1 })
+    },
+    apply({ params, state }) {
+        const models = state.select(q => q.rootsOfType(PluginStateObject.Molecule.Model).filter(c => c.transform.transformer === StateTransforms.Model.CreateModelFromTrajectory));
+
+        const update = state.build();
+
+        if (params.action === 'reset') {
+            for (const m of models) {
+                update.to(m.transform.ref).update(StateTransforms.Model.CreateModelFromTrajectory,
+                    () => ({ modelIndex: 0}));
+            }
+        } else {
+            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.CreateModelFromTrajectory,
+                    old => {
+                        let modelIndex = (old.modelIndex + params.by!) % traj.data.length;
+                        if (modelIndex < 0) modelIndex += traj.data.length;
+                        return { modelIndex };
+                    });
+            }
+        }
+
+        return state.update(update);
+    }
+});

+ 0 - 21
src/mol-plugin/state/base.ts

@@ -1,21 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { StateObject, Transformer } from 'mol-state';
-
-export type TypeClass = 'root' | 'data' | 'prop'
-
-export namespace PluginStateObject {
-    export type TypeClass = 'Root' | 'Group' | 'Data' | 'Object' | 'Representation' | 'Behavior'
-    export interface TypeInfo { name: string, shortName: string, description: string, typeClass: TypeClass }
-    export interface Props { label: string, description?: string }
-
-    export const Create = StateObject.factory<TypeInfo, Props>();
-}
-
-export namespace PluginStateTransform {
-    export const Create = Transformer.factory('ms-plugin');
-}

+ 79 - 0
src/mol-plugin/state/camera.ts

@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Camera } from 'mol-canvas3d/camera';
+import { OrderedMap } from 'immutable';
+import { UUID } from 'mol-util';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
+
+export { CameraSnapshotManager }
+
+class CameraSnapshotManager {
+    private ev = RxEventHelper.create();
+    private _entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
+
+    readonly events = {
+        changed: this.ev()
+    };
+
+    get entries() { return this._entries; }
+
+    getEntry(id: string) {
+        return this._entries.get(id);
+    }
+
+    remove(id: string) {
+        if (!this._entries.has(id)) return;
+        this._entries.delete(id);
+        this.events.changed.next();
+    }
+
+    add(e: CameraSnapshotManager.Entry) {
+        this._entries.set(e.id, e);
+        this.events.changed.next();
+    }
+
+    clear() {
+        if (this._entries.size === 0) return;
+        this._entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
+        this.events.changed.next();
+    }
+
+    getStateSnapshot(): CameraSnapshotManager.StateSnapshot {
+        const entries: CameraSnapshotManager.Entry[] = [];
+        this._entries.forEach(e => entries.push(e!));
+        return { entries };
+    }
+
+    setStateSnapshot(state: CameraSnapshotManager.StateSnapshot ) {
+        this._entries = OrderedMap<string, CameraSnapshotManager.Entry>().asMutable();
+        for (const e of state.entries) {
+            this._entries.set(e.id, e);
+        }
+        this.events.changed.next();
+    }
+
+    dispose() {
+        this.ev.dispose();
+    }
+}
+
+namespace CameraSnapshotManager {
+    export interface Entry {
+        id: UUID,
+        name: string,
+        description?: string,
+        snapshot: Camera.Snapshot
+    }
+
+    export function Entry(name: string, snapshot: Camera.Snapshot, description?: string): Entry {
+        return { id: UUID.create22(), name, snapshot, description };
+    }
+
+    export interface StateSnapshot {
+        entries: Entry[]
+    }
+}

+ 50 - 18
src/mol-plugin/state/objects.ts

@@ -4,38 +4,70 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { PluginStateObject } from './base';
 import { CifFile } from 'mol-io/reader/cif';
-import { Model as _Model, Structure as _Structure } from 'mol-model/structure'
+import { Model as _Model, Structure as _Structure } from 'mol-model/structure';
+import { VolumeData } from 'mol-model/volume';
+import { PluginBehavior } from 'mol-plugin/behavior/behavior';
+import { Representation } from 'mol-repr/representation';
 import { StructureRepresentation } from 'mol-repr/structure/representation';
+import { VolumeRepresentation } from 'mol-repr/volume/representation';
+import { StateObject, Transformer } from 'mol-state';
 
-const _create = PluginStateObject.Create
+export type TypeClass = 'root' | 'data' | 'prop'
 
-namespace PluginStateObjects {
-    export class DataRoot extends _create({ name: 'Root', shortName: 'R', typeClass: 'Root', description: 'Where everything begins.' }) { }
-    export class BehaviorRoot extends _create({ name: 'Root', shortName: 'R', typeClass: 'Root', description: 'Where everything begins.' }) { }
+export namespace PluginStateObject {
+    export type Any = StateObject<any, TypeInfo>
 
-    export class Group extends _create({ name: 'Group', shortName: 'G', typeClass: 'Group', description: 'A group on entities.' }) { }
+    export type TypeClass = 'Root' | 'Group' | 'Data' | 'Object' | 'Representation3D' | 'Behavior'
+    export interface TypeInfo { name: string, typeClass: TypeClass }
 
-    export class Behavior extends _create<import('../behavior').PluginBehavior>({ name: 'Behavior', shortName: 'B', typeClass: 'Behavior', description: 'Modifies plugin functionality.' }) { }
+    export const Create = StateObject.factory<TypeInfo>();
+
+    export function isRepresentation3D(o?: Any): o is StateObject<Representation.Any, TypeInfo> {
+        return !!o && o.type.typeClass === 'Representation3D';
+    }
+
+    export function isBehavior(o?: Any): o is StateObject<PluginBehavior, TypeInfo> {
+        return !!o && o.type.typeClass === 'Behavior';
+    }
+
+    export function CreateRepresentation3D<T extends Representation.Any>(type: { name: string }) {
+        return Create<T>({ ...type, typeClass: 'Representation3D' })
+    }
+
+    export function CreateBehavior<T extends PluginBehavior>(type: { name: string }) {
+        return Create<T>({ ...type, typeClass: 'Behavior' })
+    }
+
+    export class Root extends Create({ name: 'Root', typeClass: 'Root' }) { }
+
+    export class Group extends Create({ name: 'Group', typeClass: 'Group' }) { }
 
     export namespace Data {
-        export class String extends _create<string>({ name: 'String Data', typeClass: 'Data', shortName: 'S_D', description: 'A string.' }) { }
-        export class Binary extends _create<Uint8Array>({ name: 'Binary Data', typeClass: 'Data', shortName: 'B_D', description: 'A binary blob.' }) { }
-        export class Json extends _create<any>({ name: 'JSON Data', typeClass: 'Data', shortName: 'JS_D', description: 'Represents JSON data.' }) { }
-        export class Cif extends _create<CifFile>({ name: 'Cif File', typeClass: 'Data', shortName: 'CF', description: 'Represents parsed CIF data.' }) { }
+        export class String extends Create<string>({ name: 'String Data', typeClass: 'Data', }) { }
+        export class Binary extends Create<Uint8Array>({ name: 'Binary Data', typeClass: 'Data' }) { }
+        export class Json extends Create<any>({ name: 'JSON Data', typeClass: 'Data' }) { }
+        export class Cif extends Create<CifFile>({ name: 'CIF File', typeClass: 'Data' }) { }
 
         // TODO
-        // export class MultipleRaw extends _create<{
+        // export class MultipleRaw extends Create<{
         //     [key: string]: { type: 'String' | 'Binary', data: string | Uint8Array }
         // }>({ name: 'Data', typeClass: 'Data', shortName: 'MD', description: 'Multiple Keyed Data.' }) { }
     }
 
-    export class Models extends _create<ReadonlyArray<_Model>>({ name: 'Molecule Model', typeClass: 'Object', shortName: 'M_M', description: 'A model of a molecule.' }) { }
-    export class Structure extends _create<_Structure>({ name: 'Molecule Structure', typeClass: 'Object', shortName: 'M_S', description: 'A structure of a molecule.' }) { }
-
+    export namespace Molecule {
+        export class Trajectory extends Create<ReadonlyArray<_Model>>({ name: 'Trajectory', typeClass: 'Object' }) { }
+        export class Model extends Create<_Model>({ name: 'Model', typeClass: 'Object' }) { }
+        export class Structure extends Create<_Structure>({ name: 'Structure', typeClass: 'Object' }) { }
+        export class Representation3D extends CreateRepresentation3D<StructureRepresentation<any>>({ name: 'Structure 3D' }) { }
+    }
 
-    export class StructureRepresentation3D extends _create<StructureRepresentation<any>>({ name: 'Molecule Structure Representation', typeClass: 'Representation', shortName: 'S_R', description: 'A representation of a molecular structure.' }) { }
+    export namespace Volume {
+        export class Data extends Create<VolumeData>({ name: 'Volume Data', typeClass: 'Object' }) { }
+        export class Representation3D extends CreateRepresentation3D<VolumeRepresentation<any>>({ name: 'Volume 3D' }) { }
+    }
 }
 
-export { PluginStateObjects }
+export namespace PluginStateTransform {
+    export const Create = Transformer.factory('ms-plugin');
+}

+ 65 - 0
src/mol-plugin/state/snapshots.ts

@@ -0,0 +1,65 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { OrderedMap } from 'immutable';
+import { UUID } from 'mol-util';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
+import { PluginState } from '../state';
+
+export { PluginStateSnapshotManager }
+
+class PluginStateSnapshotManager {
+    private ev = RxEventHelper.create();
+    private _entries = OrderedMap<string, PluginStateSnapshotManager.Entry>().asMutable();
+
+    readonly events = {
+        changed: this.ev()
+    };
+
+    get entries() { return this._entries; }
+
+    getEntry(id: string) {
+        return this._entries.get(id);
+    }
+
+    remove(id: string) {
+        if (!this._entries.has(id)) return;
+        this._entries.delete(id);
+        this.events.changed.next();
+    }
+
+    add(e: PluginStateSnapshotManager.Entry) {
+        this._entries.set(e.id, e);
+        this.events.changed.next();
+    }
+
+    clear() {
+        if (this._entries.size === 0) return;
+        this._entries = OrderedMap<string, PluginStateSnapshotManager.Entry>().asMutable();
+        this.events.changed.next();
+    }
+
+    dispose() {
+        this.ev.dispose();
+    }
+}
+
+namespace PluginStateSnapshotManager {
+    export interface Entry {
+        id: UUID,
+        name: string,
+        description?: string,
+        snapshot: PluginState.Snapshot
+    }
+
+    export function Entry(name: string, snapshot: PluginState.Snapshot, description?: string): Entry {
+        return { id: UUID.create22(), name, snapshot, description };
+    }
+
+    export interface StateSnapshot {
+        entries: Entry[]
+    }
+}

+ 19 - 8
src/mol-plugin/state/transforms/data.ts

@@ -4,22 +4,23 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { PluginStateTransform } from '../base';
-import { PluginStateObjects as SO } from '../objects';
+import { PluginStateTransform } from '../objects';
+import { PluginStateObject as SO } from '../objects';
 import { Task } from 'mol-task';
 import CIF from 'mol-io/reader/cif'
 import { PluginContext } from 'mol-plugin/context';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
+import { Transformer } from 'mol-state';
 
 export { Download }
 namespace Download { export interface Params { url: string, isBinary?: boolean, label?: string } }
-const Download = PluginStateTransform.Create<SO.DataRoot, SO.Data.String | SO.Data.Binary, Download.Params>({
+const Download = PluginStateTransform.Create<SO.Root, SO.Data.String | SO.Data.Binary, Download.Params>({
     name: 'download',
     display: {
         name: 'Download',
         description: 'Download string or binary data from the specified URL'
     },
-    from: [SO.DataRoot],
+    from: [SO.Root],
     to: [SO.Data.String, SO.Data.Binary],
     params: {
         default: () => ({
@@ -27,17 +28,27 @@ const Download = PluginStateTransform.Create<SO.DataRoot, SO.Data.String | SO.Da
         }),
         controls: () => ({
             url: PD.Text('URL', 'Resource URL. Must be the same domain or support CORS.', ''),
+            label: PD.Text('Label', '', ''),
             isBinary: PD.Boolean('Binary', 'If true, download data as binary (string otherwise)', false)
-        })
+        }),
+        validate: p => !p.url || !p.url.trim() ? ['Enter url.'] : void 0
     },
     apply({ params: p }, globalCtx: PluginContext) {
         return Task.create('Download', async ctx => {
             // TODO: track progress
             const data = await globalCtx.fetch(p.url, p.isBinary ? 'binary' : 'string');
             return p.isBinary
-                ? new SO.Data.Binary({ label: p.label ? p.label : p.url }, data as Uint8Array)
-                : new SO.Data.String({ label: p.label ? p.label : p.url }, data as string);
+                ? new SO.Data.Binary(data as Uint8Array, { label: p.label ? p.label : p.url })
+                : new SO.Data.String(data as string, { label: p.label ? p.label : p.url });
         });
+    },
+    update({ oldParams, newParams, b }) {
+        if (oldParams.url !== newParams.url || oldParams.isBinary !== newParams.isBinary) return Transformer.UpdateResult.Recreate;
+        if (oldParams.label !== newParams.label) {
+            (b.label as string) = newParams.label || newParams.url;
+            return Transformer.UpdateResult.Updated;
+        }
+        return Transformer.UpdateResult.Unchanged;
     }
 });
 
@@ -55,7 +66,7 @@ const ParseCif = PluginStateTransform.Create<SO.Data.String | SO.Data.Binary, SO
         return Task.create('Parse CIF', async ctx => {
             const parsed = await (SO.Data.String.is(a) ? CIF.parse(a.data) : CIF.parseBinary(a.data)).runInContext(ctx);
             if (parsed.isError) throw new Error(parsed.message);
-            return new SO.Data.Cif({ label: 'CIF File' }, parsed.result);
+            return new SO.Data.Cif(parsed.result);
         });
     }
 });

+ 54 - 31
src/mol-plugin/state/transforms/model.ts

@@ -4,8 +4,8 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { PluginStateTransform } from '../base';
-import { PluginStateObjects as SO } from '../objects';
+import { PluginStateTransform } from '../objects';
+import { PluginStateObject as SO } from '../objects';
 import { Task } from 'mol-task';
 import { Model, Format, Structure, ModelSymmetry, StructureSymmetry, QueryContext, StructureSelection } from 'mol-model/structure';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
@@ -13,16 +13,16 @@ import Expression from 'mol-script/language/expression';
 import { compile } from 'mol-script/runtime/query/compiler';
 import { Mat4 } from 'mol-math/linear-algebra';
 
-export { ParseModelsFromMmCif }
-namespace ParseModelsFromMmCif { export interface Params { blockHeader?: string } }
-const ParseModelsFromMmCif = PluginStateTransform.Create<SO.Data.Cif, SO.Models, ParseModelsFromMmCif.Params>({
-    name: 'parse-models-from-mmcif',
+export { ParseTrajectoryFromMmCif }
+namespace ParseTrajectoryFromMmCif { export interface Params { blockHeader?: string } }
+const ParseTrajectoryFromMmCif = PluginStateTransform.Create<SO.Data.Cif, SO.Molecule.Trajectory, ParseTrajectoryFromMmCif.Params>({
+    name: 'parse-trajectory-from-mmcif',
     display: {
         name: 'Models from mmCIF',
         description: 'Identify and create all separate models in the specified CIF data block'
     },
     from: [SO.Data.Cif],
-    to: [SO.Models],
+    to: [SO.Molecule.Trajectory],
     params: {
         default: a => ({ blockHeader: a.data.blocks[0].header }),
         controls(a) {
@@ -40,22 +40,22 @@ const ParseModelsFromMmCif = PluginStateTransform.Create<SO.Data.Cif, SO.Models,
             if (!block) throw new Error(`Data block '${[header]}' not found.`);
             const models = await Model.create(Format.mmCIF(block)).runInContext(ctx);
             if (models.length === 0) throw new Error('No models found.');
-            const label = models.length === 1 ? `${models[0].label}` : `${models[0].label} (${models.length} models)`;
-            return new SO.Models({ label }, models);
+            const label = { label: models[0].label, description: `${models.length} model${models.length === 1 ? '' : 's'}` };
+            return new SO.Molecule.Trajectory(models, label);
         });
     }
 });
 
-export { CreateStructureFromModel }
-namespace CreateStructureFromModel { export interface Params { modelIndex: number, transform3d?: Mat4 } }
-const CreateStructureFromModel = PluginStateTransform.Create<SO.Models, SO.Structure, CreateStructureFromModel.Params>({
-    name: 'create-structure-from-model',
+export { CreateModelFromTrajectory }
+namespace CreateModelFromTrajectory { export interface Params { modelIndex: number } }
+const CreateModelFromTrajectory = PluginStateTransform.Create<SO.Molecule.Trajectory, SO.Molecule.Model, CreateModelFromTrajectory.Params>({
+    name: 'create-model-from-trajectory',
     display: {
-        name: 'Structure from Model',
+        name: 'Model from Trajectory',
         description: 'Create a molecular structure from the specified model.'
     },
-    from: [SO.Models],
-    to: [SO.Structure],
+    from: [SO.Molecule.Trajectory],
+    to: [SO.Molecule.Model],
     params: {
         default: () => ({ modelIndex: 0 }),
         controls: a => ({ modelIndex: PD.Range('Model Index', 'Model Index', 0, 0, Math.max(0, a.data.length - 1), 1) })
@@ -63,61 +63,84 @@ const CreateStructureFromModel = PluginStateTransform.Create<SO.Models, SO.Struc
     isApplicable: a => a.data.length > 0,
     apply({ a, params }) {
         if (params.modelIndex < 0 || params.modelIndex >= a.data.length) throw new Error(`Invalid modelIndex ${params.modelIndex}`);
-        let s = Structure.ofModel(a.data[params.modelIndex]);
+        const model = a.data[params.modelIndex];
+        const label = { label: `Model ${model.modelNum}` };
+        return new SO.Molecule.Model(model, label);
+    }
+});
+
+export { CreateStructure }
+namespace CreateStructure { export interface Params { transform3d?: Mat4 } }
+const CreateStructure = PluginStateTransform.Create<SO.Molecule.Model, SO.Molecule.Structure, CreateStructure.Params>({
+    name: 'create-structure-from-model',
+    display: {
+        name: 'Structure from Model',
+        description: 'Create a molecular structure from the specified model.'
+    },
+    from: [SO.Molecule.Model],
+    to: [SO.Molecule.Structure],
+    apply({ a, params }) {
+        let s = Structure.ofModel(a.data);
         if (params.transform3d) s = Structure.transform(s, params.transform3d);
-        return new SO.Structure({ label: `Model ${s.models[0].modelNum}`, description: s.elementCount === 1 ? '1 element' : `${s.elementCount} elements` }, s);
+        const label = { label: a.data.label, description: s.elementCount === 1 ? '1 element' : `${s.elementCount} elements` };
+        return new SO.Molecule.Structure(s, label);
     }
 });
 
+function structureDesc(s: Structure) {
+    return s.elementCount === 1 ? '1 element' : `${s.elementCount} elements`;
+}
 
 export { CreateStructureAssembly }
 namespace CreateStructureAssembly { export interface Params { /** if not specified, use the 1st */ id?: string } }
-const CreateStructureAssembly = PluginStateTransform.Create<SO.Structure, SO.Structure, CreateStructureAssembly.Params>({
+const CreateStructureAssembly = PluginStateTransform.Create<SO.Molecule.Model, SO.Molecule.Structure, CreateStructureAssembly.Params>({
     name: 'create-structure-assembly',
     display: {
         name: 'Structure Assembly',
         description: 'Create a molecular structure assembly.'
     },
-    from: [SO.Structure],
-    to: [SO.Structure],
+    from: [SO.Molecule.Model],
+    to: [SO.Molecule.Structure],
     params: {
         default: () => ({ id: void 0 }),
         controls(a) {
-            const { model } = a.data;
+            const model = a.data;
             const ids = model.symmetry.assemblies.map(a => [a.id, a.id] as [string, string]);
             return { id: PD.Select('Asm Id', 'Assembly Id', ids.length ? ids[0][0] : '', ids) };
         }
     },
-    isApplicable: a => a.data.models.length === 1 && a.data.model.symmetry.assemblies.length > 0,
     apply({ a, params }) {
         return Task.create('Build Assembly', async ctx => {
             let id = params.id;
-            const model = a.data.model;
+            const model = a.data;
             if (!id && model.symmetry.assemblies.length) id = model.symmetry.assemblies[0].id;
-            const asm = ModelSymmetry.findAssembly(a.data.model, id || '');
+            const asm = ModelSymmetry.findAssembly(model, id || '');
             if (!asm) throw new Error(`Assembly '${id}' not found`);
 
-            const s = await StructureSymmetry.buildAssembly(a.data, id!).runInContext(ctx);
-            return new SO.Structure({ label: `Assembly ${id}`, description: s.elementCount === 1 ? '1 element' : `${s.elementCount} elements` }, s);
+            const base = Structure.ofModel(model);
+            const s = await StructureSymmetry.buildAssembly(base, id!).runInContext(ctx);
+            const label = { label: `Assembly ${id}`, description: structureDesc(s) };
+            return new SO.Molecule.Structure(s, label);
         })
     }
 });
 
 export { CreateStructureSelection }
 namespace CreateStructureSelection { export interface Params { query: Expression, label?: string } }
-const CreateStructureSelection = PluginStateTransform.Create<SO.Structure, SO.Structure, CreateStructureSelection.Params>({
+const CreateStructureSelection = PluginStateTransform.Create<SO.Molecule.Structure, SO.Molecule.Structure, CreateStructureSelection.Params>({
     name: 'create-structure-selection',
     display: {
         name: 'Structure Selection',
         description: 'Create a molecular structure from the specified model.'
     },
-    from: [SO.Structure],
-    to: [SO.Structure],
+    from: [SO.Molecule.Structure],
+    to: [SO.Molecule.Structure],
     apply({ a, params }) {
         // TODO: use cache, add "update"
         const compiled = compile<StructureSelection>(params.query);
         const result = compiled(new QueryContext(a.data));
         const s = StructureSelection.unionStructure(result);
-        return new SO.Structure({ label: `${params.label || 'Selection'}`, description: s.elementCount === 1 ? '1 element' : `${s.elementCount} elements` }, s);
+        const label = { label: `${params.label || 'Selection'}`, description: structureDesc(s) };
+        return new SO.Molecule.Structure(s, label);
     }
 });

+ 6 - 9
src/mol-plugin/state/transforms/visuals.ts

@@ -6,10 +6,8 @@
 
 import { Transformer } from 'mol-state';
 import { Task } from 'mol-task';
-import { PluginStateTransform } from '../base';
-import { PluginStateObjects as SO } from '../objects';
-// import { CartoonRepresentation, DefaultCartoonProps } from 'mol-repr/structure/representation/cartoon';
-// import { BallAndStickRepresentation } from 'mol-repr/structure/representation/ball-and-stick';
+import { PluginStateTransform } from '../objects';
+import { PluginStateObject as SO } from '../objects';
 import { PluginContext } from 'mol-plugin/context';
 import { ColorTheme } from 'mol-theme/color';
 import { SizeTheme } from 'mol-theme/size';
@@ -21,17 +19,16 @@ const representationRegistry = new RepresentationRegistry()
 
 export { CreateStructureRepresentation }
 namespace CreateStructureRepresentation { export interface Params { } }
-const CreateStructureRepresentation = PluginStateTransform.Create<SO.Structure, SO.StructureRepresentation3D, CreateStructureRepresentation.Params>({
+const CreateStructureRepresentation = PluginStateTransform.Create<SO.Molecule.Structure, SO.Molecule.Representation3D, CreateStructureRepresentation.Params>({
     name: 'create-structure-representation',
     display: { name: 'Create 3D Representation' },
-    from: [SO.Structure],
-    to: [SO.StructureRepresentation3D],
+    from: [SO.Molecule.Structure],
+    to: [SO.Molecule.Representation3D],
     apply({ a, params }, plugin: PluginContext) {
         return Task.create('Structure Representation', async ctx => {
             const repr = representationRegistry.create('cartoon', { colorThemeRegistry, sizeThemeRegistry }, a.data)
-            // const repr = BallAndStickRepresentation(); // CartoonRepresentation();
             await repr.createOrUpdate({ webgl: plugin.canvas3d.webgl, colorThemeRegistry, sizeThemeRegistry }, {}, {}, a.data).runInContext(ctx);
-            return new SO.StructureRepresentation3D({ label: 'Visual Repr.' }, repr);
+            return new SO.Molecule.Representation3D(repr);
         });
     },
     update({ a, b }, plugin: PluginContext) {

+ 61 - 0
src/mol-plugin/ui/base.tsx

@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { Observable, Subscription } from 'rxjs';
+import { PluginContext } from '../context';
+
+export const PluginReactContext = React.createContext(void 0 as any as PluginContext);
+
+export abstract class PluginComponent<P = {}, S = {}, SS = {}> extends React.Component<P, S, SS> {
+    static contextType = PluginReactContext;
+    readonly plugin: PluginContext;
+
+    private subs: Subscription[] | undefined = void 0;
+
+    protected subscribe<T>(obs: Observable<T>, action: (v: T) => void) {
+        if (typeof this.subs === 'undefined') this.subs = []
+        this.subs.push(obs.subscribe(action));
+    }
+
+    componentWillUnmount() {
+        if (!this.subs) return;
+        for (const s of this.subs) s.unsubscribe();
+    }
+
+    protected init?(): void;
+
+    constructor(props: P, context?: any) {
+        super(props, context);
+        this.plugin = context;
+        if (this.init) this.init();
+    }
+}
+
+export abstract class PurePluginComponent<P = {}, S = {}, SS = {}> extends React.PureComponent<P, S, SS> {
+    static contextType = PluginReactContext;
+    readonly plugin: PluginContext;
+
+    private subs: Subscription[] | undefined = void 0;
+
+    protected subscribe<T>(obs: Observable<T>, action: (v: T) => void) {
+        if (typeof this.subs === 'undefined') this.subs = []
+        this.subs.push(obs.subscribe(action));
+    }
+
+    componentWillUnmount() {
+        if (!this.subs) return;
+        for (const s of this.subs) s.unsubscribe();
+    }
+
+    protected init?(): void;
+
+    constructor(props: P, context?: any) {
+        super(props, context);
+        this.plugin = context;
+        if (this.init) this.init();
+    }
+}

+ 67 - 0
src/mol-plugin/ui/camera.tsx

@@ -0,0 +1,67 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommands } from 'mol-plugin/command';
+import * as React from 'react';
+import { PluginComponent } from './base';
+
+export class CameraSnapshots extends PluginComponent<{ }, { }> {
+    render() {
+        return <div>
+            <h3>Camera Snapshots</h3>
+            <CameraSnapshotControls />
+            <CameraSnapshotList />
+        </div>;
+    }
+}
+
+class CameraSnapshotControls extends PluginComponent<{ }, { name: string, description: string }> {
+    state = { name: '', description: '' };
+
+    add = () => {
+        PluginCommands.Camera.Snapshots.Add.dispatch(this.plugin, this.state);
+        this.setState({ name: '', description: '' })
+    }
+
+    clear = () => {
+        PluginCommands.Camera.Snapshots.Clear.dispatch(this.plugin, {});
+    }
+
+    render() {
+        return <div>
+            <input type='text' value={this.state.name} placeholder='Name...' style={{ width: '33%', display: 'block', float: 'left' }} onChange={e => this.setState({ name: e.target.value })} />
+            <input type='text' value={this.state.description} placeholder='Description...' style={{ width: '67%', display: 'block' }} onChange={e => this.setState({ description: e.target.value })} />
+            <button style={{ float: 'right' }} onClick={this.clear}>Clear</button>
+            <button onClick={this.add}>Add</button>
+        </div>;
+    }
+}
+
+class CameraSnapshotList extends PluginComponent<{ }, { }> {
+    componentDidMount() {
+        this.subscribe(this.plugin.events.state.cameraSnapshots.changed, () => this.forceUpdate());
+    }
+
+    apply(id: string) {
+        return () => PluginCommands.Camera.Snapshots.Apply.dispatch(this.plugin, { id });
+    }
+
+    remove(id: string) {
+        return () => {
+            PluginCommands.Camera.Snapshots.Remove.dispatch(this.plugin, { id });
+        }
+    }
+
+    render() {
+        return <ul style={{ listStyle: 'none' }}>
+            {this.plugin.state.cameraSnapshots.entries.valueSeq().map(e =><li key={e!.id}>
+                <button onClick={this.apply(e!.id)}>Set</button>
+                &nbsp;{e!.name} <small>{e!.description}</small>
+                <button onClick={this.remove(e!.id)} style={{ float: 'right' }}>X</button>
+            </li>)}
+        </ul>;
+    }
+}

+ 22 - 75
src/mol-plugin/ui/controls.tsx

@@ -5,87 +5,34 @@
  */
 
 import * as React from 'react';
-import { PluginContext } from '../context';
-import { Transform, Transformer, StateObject } from 'mol-state';
-import { ParametersComponent } from 'mol-app/component/parameters';
-
-export class Controls extends React.Component<{ plugin: PluginContext }, { id: string }> {
-    state = { id: '1grm' };
-
-    private createState = () => {
-        const url = `http://www.ebi.ac.uk/pdbe/static/entry/${this.state.id.toLowerCase()}_updated.cif`;
-        // const url = `https://webchem.ncbr.muni.cz/CoordinateServer/${this.state.id.toLowerCase()}/full`
-        this.props.plugin._test_createState(url);
-    }
-
-    private _snap: any = void 0;
-    private getSnapshot = () => {
-        this._snap = this.props.plugin.state.getSnapshot();
-        console.log(btoa(JSON.stringify(this._snap)));
-    }
-    private setSnapshot = () => {
-        if (!this._snap) return;
-        this.props.plugin.state.setSnapshot(this._snap);
-    }
+import { PluginCommands } from 'mol-plugin/command';
+import { UpdateTrajectory } from 'mol-plugin/state/actions/basic';
+import { PluginComponent } from './base';
 
+export class Controls extends PluginComponent<{ }, { }> {
     render() {
-        return <div>
-            <input type='text' defaultValue={this.state.id} onChange={e => this.setState({ id: e.currentTarget.value })} />
-            <button onClick={this.createState}>Create State</button><br/>
-            <button onClick={() => this.props.plugin._test_centerView()}>Center View</button><br/>
-            <button onClick={() => this.props.plugin._test_nextModel()}>Next Model</button><br/>
-            <button onClick={() => this.props.plugin._test_playModels()}>Play Models</button><br/>
-            <hr />
-            <button onClick={this.getSnapshot}>Get Snapshot</button>
-            <button onClick={this.setSnapshot}>Set Snapshot</button>
-        </div>;
-    }
-}
-
-export class _test_CreateTransform extends React.Component<{ plugin: PluginContext, nodeRef: Transform.Ref, transformer: Transformer }, { params: any }> {
-    private getObj() {
-        const obj = this.props.plugin.state.data.objects.get(this.props.nodeRef)!;
-        return obj;
-    }
-
-    private getDefaultParams() {
-        const p = this.props.transformer.definition.params;
-        if (!p || !p.default) return { };
-        const obj = this.getObj();
-        if (!obj.obj) return { };
-        return p.default(obj.obj, this.props.plugin);
-    }
-
-    private getParamDef() {
-        const p = this.props.transformer.definition.params;
-        if (!p || !p.controls) return { };
-        const obj = this.getObj();
-        if (!obj.obj) return { };
-        return p.controls(obj.obj, this.props.plugin);
-    }
+        return <>
 
-    private create() {
-        console.log(this.props.transformer.definition.name, this.state.params);
-        this.props.plugin._test_applyTransform(this.props.nodeRef, this.props.transformer, this.state.params);
+        </>;
     }
+}
 
-    state = { params: this.getDefaultParams() }
-
+export class TrajectoryControls extends PluginComponent {
     render() {
-        const obj = this.getObj();
-        if (obj.state !== StateObject.StateType.Ok) {
-            // TODO filter this elsewhere
-            return <div />;
-        }
-
-        const t = this.props.transformer;
-
-        return <div key={`${this.props.nodeRef} ${this.props.transformer.id}`}>
-            <div style={{ borderBottom: '1px solid #999'}}>{(t.definition.display && t.definition.display.name) || t.definition.name}</div>
-            <ParametersComponent params={this.getParamDef()} values={this.state.params as any} onChange={(k, v) => {
-                this.setState({ params: { ...this.state.params, [k]: v } });
-            }} />
-            <button onClick={() => this.create()} style={{ width: '100%' }}>Create</button>
+        return <div>
+            <b>Trajectory: </b>
+            <button onClick={() => PluginCommands.State.ApplyAction.dispatch(this.plugin, {
+                state: this.plugin.state.dataState,
+                action: UpdateTrajectory.create({ action: 'advance', by: -1 })
+            })}>&lt;&lt;</button>
+            <button onClick={() => PluginCommands.State.ApplyAction.dispatch(this.plugin, {
+                state: this.plugin.state.dataState,
+                action: UpdateTrajectory.create({ action: 'reset' })
+            })}>Reset</button>
+            <button onClick={() => PluginCommands.State.ApplyAction.dispatch(this.plugin, {
+                state: this.plugin.state.dataState,
+                action: UpdateTrajectory.create({ action: 'advance', by: +1 })
+            })}>&gt;&gt;</button><br />
         </div>
     }
 }

+ 130 - 0
src/mol-plugin/ui/controls/parameters.tsx

@@ -0,0 +1,130 @@
+/**
+ * Copyright (c) 2018 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>
+ */
+
+import * as React from 'react'
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+
+export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
+    params: P,
+    values: any,
+    onChange: ParamOnChange,
+    isEnabled?: boolean,
+    onEnter?: () => void
+}
+
+export class ParameterControls<P extends PD.Params> extends React.PureComponent<ParameterControlsProps<P>, {}> {
+    render() {
+        const common = {
+            changes: this.props.onChange,
+            isEnabled: this.props.isEnabled,
+            onEnter: this.props.onEnter,
+        }
+        const params = this.props.params;
+        const values = this.props.values;
+        return <div style={{ width: '100%' }}>
+            {Object.keys(params).map(key => <ParamWrapper control={controlFor(params[key])} param={params[key]} key={key} {...common} name={key} value={values[key]} />)}
+        </div>;
+    }
+}
+
+function controlFor(param: PD.Any): ValueControl {
+    switch (param.type) {
+        case 'boolean': return BoolControl;
+        case 'number': return NumberControl;
+        case 'range': return NumberControl;
+        case 'multi-select': throw new Error('nyi');
+        case 'color': throw new Error('nyi');
+        case 'select': return SelectControl;
+        case 'text': return TextControl;
+    }
+    throw new Error('not supporter');
+}
+
+type ParamWrapperProps = { name: string, value: any, param: PD.Base<any>, changes: ParamOnChange, control: ValueControl, onEnter?: () => void, isEnabled?: boolean }
+export type ParamOnChange = (params: { param: PD.Base<any>, name: string, value: any }) => void
+type ValueControlProps<P extends PD.Base<any> = PD.Base<any>> = { value: any, param: P, isEnabled?: boolean, onChange: (v: any) => void, onEnter?: () => void }
+type ValueControl = React.ComponentClass<ValueControlProps<any>>
+
+export class ParamWrapper extends React.PureComponent<ParamWrapperProps> {
+    onChange = (value: any) => {
+        this.props.changes({ param: this.props.param, name: this.props.name, value });
+    }
+
+    render() {
+        return <div>
+            <span title={this.props.param.description}>{this.props.param.label}</span>
+            <div>
+                <this.props.control value={this.props.value} param={this.props.param} onChange={this.onChange} onEnter={this.props.onEnter} isEnabled={this.props.isEnabled} />
+            </div>
+        </div>;
+    }
+}
+
+export class BoolControl extends React.PureComponent<ValueControlProps> {
+    onClick = () => {
+        this.props.onChange(!this.props.value);
+    }
+
+    render() {
+        return <button onClick={this.onClick} disabled={!this.props.isEnabled}>{this.props.value ? '✓ On' : '✗ Off'}</button>;
+    }
+}
+
+export class NumberControl extends React.PureComponent<ValueControlProps<PD.Numeric>, { value: string }> {
+    // state = { value: this.props.value }
+    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        this.props.onChange(+e.target.value);
+        // this.setState({ value: e.target.value });
+    }
+
+    render() {
+        return <input type='range'
+            value={'' + this.props.value}
+            min={this.props.param.min}
+            max={this.props.param.max}
+            step={this.props.param.step}
+            onChange={this.onChange}
+        />;
+    }
+}
+
+export class TextControl extends React.PureComponent<ValueControlProps<PD.Text>> {
+    onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        const value = e.target.value;
+        if (value !== this.props.value) {
+            this.props.onChange(value);
+        }
+    }
+
+    onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+        if (!this.props.onEnter) return;
+        if ((e.keyCode === 13 || e.charCode === 13)) {
+            this.props.onEnter();
+        }
+    }
+
+    render() {
+        return <input type='text'
+            value={this.props.value || ''}
+            onChange={this.onChange}
+            onKeyPress={this.props.onEnter ? this.onKeyPress : void 0}
+        />;
+    }
+}
+
+export class SelectControl extends React.PureComponent<ValueControlProps<PD.Select<any>>> {
+    onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+        this.setState({ value: e.target.value });
+        this.props.onChange(e.target.value);
+    }
+
+    render() {
+        return <select value={this.props.value || ''} onChange={this.onChange}>
+            {this.props.param.options.map(([value, label]) => <option key={label} value={value}>{label}</option>)}
+        </select>;
+    }
+}

+ 117 - 26
src/mol-plugin/ui/plugin.tsx

@@ -7,50 +7,141 @@
 import * as React from 'react';
 import { PluginContext } from '../context';
 import { StateTree } from './state-tree';
-import { Viewport } from './viewport';
-import { Controls, _test_CreateTransform } from './controls';
-import { Transformer } from 'mol-state';
+import { Viewport, ViewportControls } from './viewport';
+import { Controls, TrajectoryControls } from './controls';
+import { PluginComponent, PluginReactContext } from './base';
+import { CameraSnapshots } from './camera';
+import { StateSnapshots } from './state';
+import { List } from 'immutable';
+import { LogEntry } from 'mol-util/log-entry';
+import { formatTime } from 'mol-util';
+import { BackgroundTaskProgress } from './task';
+import { ApplyActionContol } from './state/apply-action';
+import { PluginState } from 'mol-plugin/state';
+import { UpdateTransformContol } from './state/update-transform';
 
-// TODO: base object with subscribe helpers
-
-export class Plugin extends React.Component<{ plugin: PluginContext }, { }> {
+export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
     render() {
-        return <div style={{ position: 'absolute', width: '100%', height: '100%', fontFamily: 'monospace' }}>
-            <div style={{ position: 'absolute', width: '350px', height: '100%', overflowY: 'scroll' }}>
-                <StateTree plugin={this.props.plugin} />
-                <hr />
-                <_test_CurrentObject plugin={this.props.plugin} />
-            </div>
-            <div style={{ position: 'absolute', left: '350px', right: '250px', height: '100%' }}>
-                <Viewport plugin={this.props.plugin} />
-            </div>
-            <div style={{ position: 'absolute', width: '250px', right: '0', height: '100%' }}>
-                <Controls plugin={this.props.plugin} />
+        return <PluginReactContext.Provider value={this.props.plugin}>
+            <div style={{ position: 'absolute', width: '100%', height: '100%', fontFamily: 'monospace' }}>
+                <div style={{ position: 'absolute', width: '350px', height: '100%', overflowY: 'scroll', padding: '10px' }}>
+                    <State />
+                </div>
+                <div style={{ position: 'absolute', left: '350px', right: '300px', top: '0', bottom: '100px' }}>
+                    <Viewport />
+                    <div style={{ position: 'absolute', left: '10px', top: '10px', height: '100%', color: 'white' }}>
+                        <TrajectoryControls />
+                    </div>
+                    <ViewportControls />
+                    <div style={{ position: 'absolute', left: '10px', bottom: '10px', color: 'white' }}>
+                        <BackgroundTaskProgress />
+                    </div>
+                </div>
+                <div style={{ position: 'absolute', width: '300px', right: '0', top: '0', bottom: '0', padding: '10px', overflowY: 'scroll' }}>
+                    <CurrentObject />
+                    <hr />
+                    <Controls />
+                    <hr />
+                    <CameraSnapshots />
+                    <hr />
+                    <StateSnapshots />
+                </div>
+                <div style={{ position: 'absolute', right: '300px', left: '350px', bottom: '0', height: '100px', overflow: 'hidden' }}>
+                    <Log />
+                </div>
             </div>
+        </PluginReactContext.Provider>;
+    }
+}
+
+export class State extends PluginComponent {
+    componentDidMount() {
+        this.subscribe(this.plugin.state.behavior.kind, () => this.forceUpdate());
+    }
+
+    set(kind: PluginState.Kind) {
+        // TODO: do command for this?
+        this.plugin.state.setKind(kind);
+    }
+
+    render() {
+        const kind = this.plugin.state.behavior.kind.value;
+        return <>
+            <button onClick={() => this.set('data')} style={{ fontWeight: kind === 'data' ? 'bold' : 'normal'}}>Data</button>
+            <button onClick={() => this.set('behavior')} style={{ fontWeight: kind === 'behavior' ? 'bold' : 'normal'}}>Behavior</button>
+            <StateTree state={kind === 'data' ? this.plugin.state.dataState : this.plugin.state.behaviorState} />
+        </>
+    }
+}
+
+export class Log extends PluginComponent<{}, { entries: List<LogEntry> }> {
+    private wrapper = React.createRef<HTMLDivElement>();
+
+    componentDidMount() {
+        this.subscribe(this.plugin.events.log, e => this.setState({ entries: this.state.entries.push(e) }));
+    }
+
+    componentDidUpdate() {
+        this.scrollToBottom();
+    }
+
+    state = { entries: List<LogEntry>() };
+
+    private scrollToBottom() {
+        const log = this.wrapper.current;
+        if (log) log.scrollTop = log.scrollHeight - log.clientHeight - 1;
+    }
+
+    render() {
+        return <div ref={this.wrapper} style={{ position: 'absolute', top: '0', right: '0', bottom: '0', left: '0', padding: '10px', overflowY: 'scroll' }}>
+            <ul style={{ listStyle: 'none' }}>
+                {this.state.entries.map((e, i) => <li key={i} style={{ borderBottom: '1px solid #999', padding: '3px' }}>
+                    [{e!.type}] [{formatTime(e!.timestamp)}] {e!.message}
+                </li>)}
+            </ul>
         </div>;
     }
 }
 
-export class _test_CurrentObject extends React.Component<{ plugin: PluginContext }, { }> {
+export class CurrentObject extends PluginComponent {
+    get current() {
+        return this.plugin.state.behavior.currentObject.value;
+    }
+
     componentDidMount() {
-        // TODO: move to constructor?
-        this.props.plugin.behaviors.state.data.currentObject.subscribe(() => 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;
+            if (current.ref !== ref || current.state !== state) return;
+            this.forceUpdate();
+        });
     }
+
     render() {
-        const ref = this.props.plugin.behaviors.state.data.currentObject.value.ref;
+        const current = this.current;
+
+        const ref = current.ref;
         // const n = this.props.plugin.state.data.tree.nodes.get(ref)!;
-        const obj = this.props.plugin.state.data.objects.get(ref)!;
+        const obj = current.state.cells.get(ref)!;
 
         const type = obj && obj.obj ? obj.obj.type : void 0;
 
-        const transforms = type
-            ? Transformer.fromType(type)
+        const transform = current.state.tree.transforms.get(ref);
+
+        const actions = type
+            ? current.state.actions.fromType(type)
             : []
         return <div>
-            Current Ref: {this.props.plugin.behaviors.state.data.currentObject.value.ref}
             <hr />
+            <h3>{obj.obj ? obj.obj.label : ref}</h3>
+            <UpdateTransformContol state={current.state} transform={transform} />
+            <hr />
+            <h3>Create</h3>
             {
-                transforms.map((t, i) => <_test_CreateTransform key={`${t.id} ${ref} ${i}`} plugin={this.props.plugin} transformer={t} nodeRef={ref} />)
+                actions.map((act, i) => <ApplyActionContol plugin={this.plugin} key={`${act.id}`} state={current.state} action={act} nodeRef={ref} />)
             }
         </div>;
     }

+ 126 - 27
src/mol-plugin/ui/state-tree.tsx

@@ -5,49 +5,148 @@
  */
 
 import * as React from 'react';
-import { PluginContext } from '../context';
-import { PluginStateObject } from 'mol-plugin/state/base';
-import { StateObject } from 'mol-state'
+import { PluginStateObject } from 'mol-plugin/state/objects';
+import { State } from 'mol-state'
 import { PluginCommands } from 'mol-plugin/command';
+import { PluginComponent } from './base';
 
-export class StateTree extends React.Component<{ plugin: PluginContext }, { }> {
+export class StateTree extends PluginComponent<{ state: State }, { }> {
     componentDidMount() {
-        // TODO: move to constructor?
-        this.props.plugin.events.state.data.updated.subscribe(() => this.forceUpdate());
+        // this.subscribe(this.props.state.events.changed, () => {
+        //     this.forceUpdate()
+        // });
     }
+
     render() {
         // const n = this.props.plugin.state.data.tree.nodes.get(this.props.plugin.state.data.tree.rootRef)!;
-        const n = this.props.plugin.state.data.tree.rootRef;
+        const n = this.props.state.tree.root.ref;
         return <div>
-            <StateTreeNode plugin={this.props.plugin} nodeRef={n} key={n} />
-            { /* n.children.map(c => <StateTreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />) */}
+            <StateTreeNode state={this.props.state} nodeRef={n} />
+            {/* n.children.map(c => <StateTreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />) */}
         </div>;
     }
 }
 
-export class StateTreeNode extends React.Component<{ plugin: PluginContext, nodeRef: string }, { }> {
+class StateTreeNode extends PluginComponent<{ nodeRef: string, state: State }, { }> {
+    is(e: State.ObjectEvent) {
+        return e.ref === this.props.nodeRef && e.state === this.props.state;
+    }
+
+    get cellState() {
+        return this.props.state.tree.cellStates.get(this.props.nodeRef);
+    }
+
+    componentDidMount() {
+        let isCollapsed = this.cellState.isCollapsed;
+        this.subscribe(this.plugin.events.state.cell.stateUpdated, e => {
+            if (this.is(e) && isCollapsed !== e.cellState.isCollapsed) {
+                isCollapsed = e.cellState.isCollapsed;
+                this.forceUpdate();
+            }
+        });
+
+        this.subscribe(this.plugin.events.state.cell.created, e => {
+            if (this.props.state === e.state && this.props.nodeRef === e.cell.transform.parent) {
+                this.forceUpdate();
+            }
+        });
+
+        this.subscribe(this.plugin.events.state.cell.removed, e => {
+            if (this.props.state === e.state && this.props.nodeRef === e.parent) {
+                this.forceUpdate();
+            }
+        });
+    }
+
     render() {
-        const n = this.props.plugin.state.data.tree.nodes.get(this.props.nodeRef)!;
-        const obj = this.props.plugin.state.data.objects.get(this.props.nodeRef)!;
-        if (!obj.obj) {
-            return <div style={{ borderLeft: '1px solid black', paddingLeft: '5px' }}>
-                {StateObject.StateType[obj.state]} {obj.errorText}
-            </div>;
-        }
-        const props = obj.obj!.props as PluginStateObject.Props;
-        const type = obj.obj!.type.info as PluginStateObject.TypeInfo;
-        return <div style={{ borderLeft: '1px solid #999', paddingLeft: '7px' }}>
+        const cellState = this.props.state.tree.cellStates.get(this.props.nodeRef);
+
+        const expander = <>
             [<a href='#' onClick={e => {
                 e.preventDefault();
-                PluginCommands.Data.RemoveObject.dispatch(this.props.plugin, { ref: this.props.nodeRef });
-            }}>X</a>][<span title={type.description}>{ type.shortName }</span>] <a href='#' onClick={e => {
-                e.preventDefault();
-                PluginCommands.Data.SetCurrentObject.dispatch(this.props.plugin, { ref: this.props.nodeRef });
-            }}>{props.label}</a> {props.description ? <small>{props.description}</small> : void 0}
-            {n.children.size === 0
+                PluginCommands.State.ToggleExpanded.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
+            }}>{cellState.isCollapsed ? '+' : '-'}</a>]
+        </>;
+
+        const children = this.props.state.tree.children.get(this.props.nodeRef);
+        return <div>
+            {children.size === 0 ? void 0 : expander} <StateTreeNodeLabel nodeRef={this.props.nodeRef} state={this.props.state} />
+            {cellState.isCollapsed || children.size === 0
                 ? void 0
-                : <div style={{ marginLeft: '3px' }}>{n.children.map(c => <StateTreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />)}</div>
+                : <div style={{ marginLeft: '7px', paddingLeft: '3px', borderLeft: '1px solid #999' }}>{children.map(c => <StateTreeNode state={this.props.state} nodeRef={c!} key={c} />)}</div>
             }
         </div>;
     }
+}
+
+class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State }> {
+    is(e: State.ObjectEvent) {
+        return e.ref === this.props.nodeRef && e.state === this.props.state;
+    }
+
+    componentDidMount() {
+        this.subscribe(this.plugin.events.state.cell.stateUpdated, e => {
+            if (this.is(e)) this.forceUpdate();
+        });
+
+        let isCurrent = this.is(this.props.state.behaviors.currentObject.value);
+
+        this.subscribe(this.plugin.state.behavior.currentObject, e => {
+            let update = false;
+            if (this.is(e)) {
+                if (!isCurrent) {
+                    isCurrent = true;
+                    update = true;
+                }
+            } else if (isCurrent) {
+                isCurrent = false;
+                update = true;
+            }
+            if (update && e.state.tree.transforms.has(this.props.nodeRef)) {
+                this.forceUpdate();
+            }
+        });
+    }
+
+    render() {
+        const n = this.props.state.tree.transforms.get(this.props.nodeRef)!;
+        const cell = this.props.state.cells.get(this.props.nodeRef)!;
+
+        const isCurrent = this.is(this.props.state.behaviors.currentObject.value);
+
+        const remove = <>[<a href='#' onClick={e => {
+            e.preventDefault();
+            PluginCommands.State.RemoveObject.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
+        }}>X</a>]</>
+
+        let label: any;
+        if (cell.status !== 'ok' || !cell.obj) {
+            const name = (n.transformer.definition.display && n.transformer.definition.display.name) || n.transformer.definition.name;
+            label = <><b>{cell.status}</b> <a href='#' onClick={e => {
+                e.preventDefault();
+                PluginCommands.State.SetCurrentObject.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
+            }}>{name}</a>: <i>{cell.errorText}</i></>;
+        } else {
+            const obj = cell.obj as PluginStateObject.Any;
+            label = <><a href='#' onClick={e => {
+                e.preventDefault();
+                PluginCommands.State.SetCurrentObject.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
+            }}>{obj.label}</a> {obj.description ? <small>{obj.description}</small> : void 0}</>;
+        }
+
+        const cellState = this.props.state.tree.cellStates.get(this.props.nodeRef);
+
+        if (!cellState) console.log('missing state', this.props.nodeRef, this.props.state.tree, this.props.state.tree.transforms.has(this.props.nodeRef));
+
+        const visibility = <>
+            [<a href='#' onClick={e => {
+                e.preventDefault();
+                PluginCommands.State.ToggleVisibility.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
+            }}>{cellState.isHidden ? 'H' : 'V'}</a>]
+        </>;
+
+        return <>
+            {remove}{visibility} {isCurrent ? <b>{label}</b> : label}
+        </>;
+    }
 }

+ 129 - 0
src/mol-plugin/ui/state.tsx

@@ -0,0 +1,129 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginCommands } from 'mol-plugin/command';
+import * as React from 'react';
+import { PluginComponent } from './base';
+import { shallowEqual } from 'mol-util';
+import { List } from 'immutable';
+import { LogEntry } from 'mol-util/log-entry';
+
+export class StateSnapshots extends PluginComponent<{ }, { serverUrl: string }> {
+    state = { serverUrl: 'http://webchem.ncbr.muni.cz/molstar-state' }
+
+    updateServerUrl = (serverUrl: string) => { this.setState({ serverUrl }) };
+
+    render() {
+        return <div>
+            <h3>State Snapshots</h3>
+            <StateSnapshotControls serverUrl={this.state.serverUrl} serverChanged={this.updateServerUrl} />
+            <b>Local</b>
+            <LocalStateSnapshotList />
+            <RemoteStateSnapshotList serverUrl={this.state.serverUrl} />
+        </div>;
+    }
+}
+
+class StateSnapshotControls extends PluginComponent<{ serverUrl: string, serverChanged: (url: string) => void }, { name: string, description: string, serverUrl: string, isUploading: boolean }> {
+    state = { name: '', description: '', serverUrl: this.props.serverUrl, isUploading: false };
+
+    add = () => {
+        PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.name, description: this.state.description });
+        this.setState({ name: '', description: '' })
+    }
+
+    clear = () => {
+        PluginCommands.State.Snapshots.Clear.dispatch(this.plugin, {});
+    }
+
+    shouldComponentUpdate(nextProps: { serverUrl: string, serverChanged: (url: string) => void }, nextState: { name: string, description: string, serverUrl: string, isUploading: boolean }) {
+        return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
+    }
+
+    upload = async () => {
+        this.setState({ isUploading: true });
+        await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, { name: this.state.name, description: this.state.description, serverUrl: this.state.serverUrl });
+        this.setState({ isUploading: false });
+    }
+
+    render() {
+        return <div>
+            <input type='text' value={this.state.name} placeholder='Name...' style={{ width: '33%', display: 'block', float: 'left' }} onChange={e => this.setState({ name: e.target.value })} />
+            <input type='text' value={this.state.description} placeholder='Description...' style={{ width: '67%', display: 'block' }} onChange={e => this.setState({ description: e.target.value })} />
+            <input type='text' value={this.state.serverUrl} placeholder='Server URL...' style={{ width: '100%', display: 'block' }} onChange={e => {
+                this.setState({ serverUrl: e.target.value });
+                this.props.serverChanged(e.target.value);
+            }} />
+            <button style={{ float: 'right' }} onClick={this.clear}>Clear</button>
+            <button onClick={this.add}>Add Local</button>
+            <button onClick={this.upload} disabled={this.state.isUploading}>Upload</button>
+        </div>;
+    }
+}
+
+class LocalStateSnapshotList extends PluginComponent<{ }, { }> {
+    componentDidMount() {
+        this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate());
+    }
+
+    apply(id: string) {
+        return () => PluginCommands.State.Snapshots.Apply.dispatch(this.plugin, { id });
+    }
+
+    remove(id: string) {
+        return () => {
+            PluginCommands.State.Snapshots.Remove.dispatch(this.plugin, { id });
+        }
+    }
+
+    render() {
+        return <ul style={{ listStyle: 'none' }}>
+            {this.plugin.state.snapshots.entries.valueSeq().map(e =><li key={e!.id}>
+                <button onClick={this.apply(e!.id)}>Set</button>
+                &nbsp;{e!.name} <small>{e!.description}</small>
+                <button onClick={this.remove(e!.id)} style={{ float: 'right' }}>X</button>
+            </li>)}
+        </ul>;
+    }
+}
+
+type RemoteEntry = { url: string, timestamp: number, id: string, name: string, description: string }
+class RemoteStateSnapshotList extends PluginComponent<{ serverUrl: string }, { entries: List<RemoteEntry>, isFetching: boolean }> {
+    state = { entries: List<RemoteEntry>(), isFetching: false };
+
+    componentDidMount() {
+        this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate());
+        this.refresh();
+    }
+
+    refresh = async () => {
+        try {
+            this.setState({ isFetching: true });
+            const req = await fetch(`${this.props.serverUrl}/list`);
+            const json: RemoteEntry[] = await req.json();
+            this.setState({ entries: List<RemoteEntry>(json.map((e: RemoteEntry) => ({ ...e, url: `${this.props.serverUrl}/get/${e.id}` }))), isFetching: false })
+        } catch (e) {
+            this.plugin.log(LogEntry.error('Fetching Remote Snapshots: ' + e));
+            this.setState({ entries: List<RemoteEntry>(), isFetching: false })
+        }
+    }
+
+    fetch(url: string) {
+        return () => PluginCommands.State.Snapshots.Fetch.dispatch(this.plugin, { url });
+    }
+
+    render() {
+        return <div>
+            <b>Remote</b> <button onClick={this.refresh} disabled={this.state.isFetching}>Refresh</button>
+            <ul style={{ listStyle: 'none' }}>
+                {this.state.entries.valueSeq().map(e =><li key={e!.id}>
+                    <button onClick={this.fetch(e!.url)} disabled={this.state.isFetching}>Fetch</button>
+                    &nbsp;{e!.name} <small>{e!.description}</small>
+                </li>)}
+            </ul>
+        </div>;
+    }
+}

+ 124 - 0
src/mol-plugin/ui/state/apply-action.tsx

@@ -0,0 +1,124 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { PluginCommands } from 'mol-plugin/command';
+import { State, Transform } from 'mol-state';
+import { StateAction } from 'mol-state/action';
+import { Subject } from 'rxjs';
+import { PurePluginComponent } from '../base';
+import { StateTransformParameters } from './parameters';
+import { memoizeOne } from 'mol-util/memoize';
+import { PluginContext } from 'mol-plugin/context';
+
+export { ApplyActionContol };
+
+namespace ApplyActionContol {
+    export interface Props {
+        plugin: PluginContext,
+        nodeRef: Transform.Ref,
+        state: State,
+        action: StateAction
+    }
+
+    export interface ComponentState {
+        nodeRef: Transform.Ref,
+        params: any,
+        error?: string,
+        busy: boolean,
+        isInitial: boolean
+    }
+}
+
+class ApplyActionContol extends PurePluginComponent<ApplyActionContol.Props, ApplyActionContol.ComponentState> {
+    private busy: Subject<boolean>;
+
+    onEnter = () => {
+        if (this.state.error) return;
+        this.apply();
+    }
+
+    source = this.props.state.cells.get(this.props.nodeRef)!.obj!;
+
+    getInfo = memoizeOne((t: Transform.Ref) => StateTransformParameters.infoFromAction(this.plugin, this.props.state, this.props.action, this.props.nodeRef));
+
+    events: StateTransformParameters.Props['events'] = {
+        onEnter: this.onEnter,
+        onChange: (params, isInitial, errors) => {
+            this.setState({ params, isInitial, error: errors && errors[0] })
+        }
+    }
+
+    // getInitialParams() {
+    //     const p = this.props.action.definition.params;
+    //     if (!p || !p.default) return {};
+    //     return p.default(this.source, this.plugin);
+    // }
+
+    // initialErrors() {
+    //     const p = this.props.action.definition.params;
+    //     if (!p || !p.validate) return void 0;
+    //     const errors = p.validate(this.info.initialValues, this.source, this.plugin);
+    //     return errors && errors[0];
+    // }
+
+    state = { nodeRef: this.props.nodeRef, error: void 0, isInitial: true, params: this.getInfo(this.props.nodeRef).initialValues, busy: false };
+
+    apply = async () => {
+        this.setState({ busy: true });
+
+        try {
+            await PluginCommands.State.ApplyAction.dispatch(this.plugin, {
+                state: this.props.state,
+                action: this.props.action.create(this.state.params),
+                ref: this.props.nodeRef
+            });
+        } finally {
+            this.busy.next(false);
+        }
+    }
+
+    init() {
+        this.busy = new Subject();
+        this.subscribe(this.busy, busy => this.setState({ busy }));
+    }
+
+    refresh = () => {
+        this.setState({ params: this.getInfo(this.props.nodeRef).initialValues, isInitial: true, error: void 0 });
+    }
+
+    static getDerivedStateFromProps(props: ApplyActionContol.Props, state: ApplyActionContol.ComponentState) {
+        if (props.nodeRef === state.nodeRef) return null;
+        const source = props.state.cells.get(props.nodeRef)!.obj!;
+        const definition = props.action.definition.params || { };
+        const initialValues = definition.default ? definition.default(source, props.plugin) : {};
+
+        const newState: Partial<ApplyActionContol.ComponentState> = {
+            nodeRef: props.nodeRef,
+            params: initialValues,
+            isInitial: true,
+            error: void 0
+        };
+        return newState;
+    }
+
+    render() {
+        const info = this.getInfo(this.props.nodeRef);
+        const action = this.props.action;
+
+        return <div>
+            <div style={{ borderBottom: '1px solid #999', marginBottom: '5px' }}><h3>{(action.definition.display && action.definition.display.name) || action.id}</h3></div>
+
+            <StateTransformParameters info={info} events={this.events} params={this.state.params} isEnabled={!this.state.busy} />
+
+            <div style={{ textAlign: 'right' }}>
+                <span style={{ color: 'red' }}>{this.state.error}</span>
+                {this.state.isInitial ? void 0 : <button title='Refresh Params' onClick={this.refresh} disabled={this.state.busy}>↻</button>}
+                <button onClick={this.apply} disabled={!!this.state.error || this.state.busy}>Create</button>
+            </div>
+        </div>
+    }
+}

+ 94 - 0
src/mol-plugin/ui/state/parameters.tsx

@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { StateObject, Transformer, State, Transform, StateObjectCell } from 'mol-state';
+import { shallowEqual } from 'mol-util/object';
+import * as React from 'react';
+import { PurePluginComponent } from '../base';
+import { ParameterControls, ParamOnChange } from '../controls/parameters';
+import { StateAction } from 'mol-state/action';
+import { PluginContext } from 'mol-plugin/context';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+
+export { StateTransformParameters };
+
+class StateTransformParameters extends PurePluginComponent<StateTransformParameters.Props> {
+    getDefinition() {
+        const controls = this.props.info.definition.controls;
+        if (!controls) return { };
+        return controls!(this.props.info.source, this.plugin)
+    }
+
+    validate(params: any) {
+        const validate = this.props.info.definition.validate;
+        if (!validate) return void 0;
+        return validate(params, this.props.info.source, this.plugin)
+    }
+
+    areInitial(params: any) {
+        const areEqual = this.props.info.definition.areEqual;
+        if (!areEqual) return shallowEqual(params, this.props.info.initialValues);
+        return areEqual(params, this.props.info.initialValues);
+    }
+
+    onChange: ParamOnChange = ({ name, value }) => {
+        const params = { ...this.props.params, [name]: value };
+        this.props.events.onChange(params, this.areInitial(params), this.validate(params));
+    };
+
+    render() {
+        return <ParameterControls params={this.props.info.params} values={this.props.params} onChange={this.onChange} onEnter={this.props.events.onEnter} isEnabled={this.props.isEnabled} />;
+    }
+}
+
+
+namespace StateTransformParameters {
+    export interface Props {
+        info: {
+            definition: Transformer.ParamsDefinition,
+            params: PD.Params,
+            initialValues: any,
+            source: StateObject,
+            isEmpty: boolean
+        },
+        events: {
+            onChange: (params: any, areInitial: boolean, errors?: string[]) => void,
+            onEnter: () => void,
+        }
+        params: any,
+        isEnabled?: boolean
+    }
+
+    export type Class = React.ComponentClass<Props>
+
+    export function infoFromAction(plugin: PluginContext, state: State, action: StateAction, nodeRef: Transform.Ref): Props['info'] {
+        const source = state.cells.get(nodeRef)!.obj!;
+        const definition = action.definition.params || { };
+        const initialValues = definition.default ? definition.default(source, plugin) : {};
+        const params = definition.controls ? definition.controls(source, plugin) : {};
+        return {
+            source,
+            definition: action.definition.params || { },
+            initialValues,
+            params,
+            isEmpty: Object.keys(params).length === 0
+        };
+    }
+
+    export function infoFromTransform(plugin: PluginContext, state: State, transform: Transform): Props['info'] {
+        const cell = state.cells.get(transform.ref)!;
+        const source: StateObjectCell | undefined = (cell.sourceRef && state.cells.get(cell.sourceRef)!) || void 0;
+        const definition = transform.transformer.definition.params || { };
+        const params = definition.controls ? definition.controls((source && source.obj) as any, plugin) : {};
+        return {
+            source: (source && source.obj) as any,
+            definition,
+            initialValues: transform.params,
+            params,
+            isEmpty: Object.keys(params).length === 0
+        }
+    }
+}

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

@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { State, Transform } from 'mol-state';
+import * as React from 'react';
+import { Subject } from 'rxjs';
+import { PurePluginComponent } from '../base';
+import { StateTransformParameters } from './parameters';
+import { memoizeOne } from 'mol-util/memoize';
+
+export { UpdateTransformContol };
+
+namespace UpdateTransformContol {
+    export interface Props {
+        transform: Transform,
+        state: State
+    }
+
+    export interface ComponentState {
+        transform: Transform,
+        params: any,
+        error?: string,
+        busy: boolean,
+        isInitial: boolean
+    }
+}
+
+class UpdateTransformContol extends PurePluginComponent<UpdateTransformContol.Props, UpdateTransformContol.ComponentState> {
+    private busy: Subject<boolean>;
+
+    onEnter = () => {
+        if (this.state.error) return;
+        this.apply();
+    }
+
+    getInfo = memoizeOne((t: Transform) => StateTransformParameters.infoFromTransform(this.plugin, this.props.state, this.props.transform));
+
+    events: StateTransformParameters.Props['events'] = {
+        onEnter: this.onEnter,
+        onChange: (params, isInitial, errors) => {
+            this.setState({ params, isInitial, error: errors && errors[0] })
+        }
+    }
+
+    state: UpdateTransformContol.ComponentState = { transform: this.props.transform, error: void 0, isInitial: true, params: this.getInfo(this.props.transform).initialValues, busy: false };
+
+    apply = async () => {
+        this.setState({ busy: true });
+
+        try {
+            await this.plugin.updateTransform(this.props.state, this.props.transform.ref, this.state.params);
+        } finally {
+            this.busy.next(false);
+        }
+    }
+
+    init() {
+        this.busy = new Subject();
+        this.subscribe(this.busy, busy => this.setState({ busy }));
+    }
+
+    refresh = () => {
+        this.setState({ params: this.props.transform.params, isInitial: true, error: void 0 });
+    }
+
+    static getDerivedStateFromProps(props: UpdateTransformContol.Props, state: UpdateTransformContol.ComponentState) {
+        if (props.transform === state.transform) return null;
+        const newState: Partial<UpdateTransformContol.ComponentState> = {
+            transform: props.transform,
+            params: props.transform.params,
+            isInitial: true,
+            error: void 0
+        };
+        return newState;
+    }
+
+    render() {
+        const info = this.getInfo(this.props.transform);
+        if (info.isEmpty) return <div>Nothing to update</div>;
+
+        const tr = this.props.transform.transformer;
+
+        return <div>
+            <div style={{ borderBottom: '1px solid #999', marginBottom: '5px' }}><h3>{(tr.definition.display && tr.definition.display.name) || tr.id}</h3></div>
+
+            <StateTransformParameters info={info} events={this.events} params={this.state.params} isEnabled={!this.state.busy} />
+
+            <div style={{ textAlign: 'right' }}>
+                <span style={{ color: 'red' }}>{this.state.error}</span>
+                {this.state.isInitial ? void 0 : <button title='Refresh Params' onClick={this.refresh} disabled={this.state.busy}>↻</button>}
+                <button onClick={this.apply} disabled={!!this.state.error || this.state.busy || this.state.isInitial}>Update</button>
+            </div>
+        </div>
+    }
+}

+ 54 - 0
src/mol-plugin/ui/task.tsx

@@ -0,0 +1,54 @@
+/**
+ * Copyright (c) 2018 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 { OrderedMap } from 'immutable';
+import { TaskManager } from 'mol-plugin/util/task-manager';
+import { filter } from 'rxjs/operators';
+import { Progress } from 'mol-task';
+
+export class BackgroundTaskProgress extends PluginComponent<{ }, { tracked: OrderedMap<number, TaskManager.ProgressEvent> }> {
+    componentDidMount() {
+        this.subscribe(this.plugin.events.task.progress.pipe(filter(e => e.level !== 'none')), e => {
+            this.setState({ tracked: this.state.tracked.set(e.id, e) })
+        });
+        this.subscribe(this.plugin.events.task.finished, ({ id }) => {
+            this.setState({ tracked: this.state.tracked.delete(id) })
+        })
+    }
+
+    state = { tracked: OrderedMap<number, TaskManager.ProgressEvent>() };
+
+    render() {
+        return <div>
+            {this.state.tracked.valueSeq().map(e => <ProgressEntry key={e!.id} event={e!} />)}
+        </div>;
+    }
+}
+
+class ProgressEntry extends PluginComponent<{ event: TaskManager.ProgressEvent }> {
+    render() {
+        const root = this.props.event.progress.root;
+        const subtaskCount = countSubtasks(this.props.event.progress.root) - 1;
+        const pr = root.progress.isIndeterminate
+            ? void 0
+            : <>[{root.progress.current}/{root.progress.max}]</>;
+        const subtasks = subtaskCount > 0
+            ? <>[{subtaskCount} subtask(s)]</>
+            : void 0
+        return <div>
+            {root.progress.message} {pr} {subtasks}
+        </div>;
+    }
+}
+
+function countSubtasks(progress: Progress.Node) {
+    if (progress.children.length === 0) return 1;
+    let sum = 0;
+    for (const c of progress.children) sum += countSubtasks(c);
+    return sum;
+}

+ 37 - 37
src/mol-plugin/ui/viewport.tsx

@@ -6,20 +6,28 @@
  */
 
 import * as React from 'react';
-import { PluginContext } from '../context';
-import { Loci, EmptyLoci, areLociEqual } from 'mol-model/loci';
-import { MarkerAction } from 'mol-geo/geometry/marker-data';
 import { ButtonsType } from 'mol-util/input/input-observer';
-
-interface ViewportProps {
-    plugin: PluginContext
-}
+import { Canvas3dIdentifyHelper } from 'mol-plugin/util/canvas3d-identify';
+import { PluginComponent } from './base';
+import { PluginCommands } from 'mol-plugin/command';
 
 interface ViewportState {
     noWebGl: boolean
 }
 
-export class Viewport extends React.Component<ViewportProps, ViewportState> {
+export class ViewportControls extends PluginComponent {
+    resetCamera = () => {
+        PluginCommands.Camera.Reset.dispatch(this.plugin, {});
+    }
+
+    render() {
+        return <div style={{ position: 'absolute', right: '10px', top: '10px', height: '100%', color: 'white' }}>
+            <button onClick={this.resetCamera}>Reset Camera</button>
+        </div>
+    }
+}
+
+export class Viewport extends PluginComponent<{ }, ViewportState> {
     private container: HTMLDivElement | null = null;
     private canvas: HTMLCanvasElement | null = null;
 
@@ -27,42 +35,34 @@ export class Viewport extends React.Component<ViewportProps, ViewportState> {
         noWebGl: false
     };
 
-    handleResize() {
-        this.props.plugin.canvas3d.handleResize();
+    private handleResize = () => {
+         this.plugin.canvas3d.handleResize();
     }
 
     componentDidMount() {
-        if (!this.canvas || !this.container || !this.props.plugin.initViewer(this.canvas, this.container)) {
+        if (!this.canvas || !this.container || !this.plugin.initViewer(this.canvas, this.container)) {
             this.setState({ noWebGl: true });
         }
         this.handleResize();
 
-        const canvas3d = this.props.plugin.canvas3d;
-        canvas3d.input.resize.subscribe(() => this.handleResize());
-
-        let prevLoci: Loci = EmptyLoci;
-        canvas3d.input.move.subscribe(async ({x, y, inside, buttons}) => {
-            if (!inside || buttons) return;
-            const p = await canvas3d.identify(x, y);
-            if (p) {
-                const { loci } = canvas3d.getLoci(p);
-
-                if (!areLociEqual(loci, prevLoci)) {
-                    canvas3d.mark(prevLoci, MarkerAction.RemoveHighlight);
-                    canvas3d.mark(loci, MarkerAction.Highlight);
-                    prevLoci = loci;
-                }
-            }
-        })
-
-        canvas3d.input.click.subscribe(async ({x, y, buttons}) => {
-            if (buttons !== ButtonsType.Flag.Primary) return
-            const p = await canvas3d.identify(x, y)
-            if (p) {
-                const { loci } = canvas3d.getLoci(p)
-                canvas3d.mark(loci, MarkerAction.Toggle)
-            }
-        })
+        const canvas3d = this.plugin.canvas3d;
+        this.subscribe(canvas3d.input.resize, this.handleResize);
+
+        const idHelper = new Canvas3dIdentifyHelper(this.plugin, 15);
+
+        this.subscribe(canvas3d.input.move, ({x, y, inside, buttons}) => {
+            if (!inside || buttons) { return; }
+            idHelper.move(x, y);
+        });
+
+        this.subscribe(canvas3d.input.leave, () => {
+            idHelper.leave();
+        });
+
+        this.subscribe(canvas3d.input.click, ({x, y, buttons}) => {
+            if (buttons !== ButtonsType.Flag.Primary) return;
+            idHelper.select(x, y);
+        });
     }
 
     componentWillUnmount() {

+ 87 - 0
src/mol-plugin/util/canvas3d-identify.ts

@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { PluginContext } from '../context';
+import { PickingId } from 'mol-geo/geometry/picking';
+import { EmptyLoci, Loci, areLociEqual } from 'mol-model/loci';
+import { Representation } from 'mol-repr/representation';
+
+export class Canvas3dIdentifyHelper {
+    private cX = -1;
+    private cY = -1;
+
+    private lastX = -1;
+    private lastY = -1;
+
+    private id: PickingId | undefined = void 0;
+
+    private currentIdentifyT = 0;
+
+    private prevLoci: { loci: Loci, repr?: Representation.Any } = { loci: EmptyLoci };
+    private prevT = 0;
+
+    private inside = false;
+
+    private async identify(select: boolean, t: number) {
+        if (this.lastX !== this.cX && this.lastY !== this.cY) {
+            this.id = await this.ctx.canvas3d.identify(this.cX, this.cY);
+            this.lastX = this.cX;
+            this.lastY = this.cY;
+        }
+
+        if (!this.id) return;
+
+        if (select) {
+            this.ctx.behaviors.canvas.selectLoci.next(this.ctx.canvas3d.getLoci(this.id));
+            return;
+        }
+
+        // only highlight the latest
+        if (!this.inside || this.currentIdentifyT !== t) {
+            return;
+        }
+
+        const loci = this.ctx.canvas3d.getLoci(this.id);
+        if (loci.repr !== this.prevLoci.repr || !areLociEqual(loci.loci, this.prevLoci.loci)) {
+            this.ctx.behaviors.canvas.highlightLoci.next(loci);
+            this.prevLoci = loci;
+        }
+    }
+
+    private animate: (t: number) => void = t => {
+        if (this.inside && t - this.prevT > 1000 / this.maxFps) {
+            this.prevT = t;
+            this.currentIdentifyT = t;
+            this.identify(false, t);
+        }
+        requestAnimationFrame(this.animate);
+    }
+
+    leave() {
+        this.inside = false;
+        if (this.prevLoci.loci !== EmptyLoci) {
+            this.prevLoci = { loci: EmptyLoci };
+            this.ctx.behaviors.canvas.highlightLoci.next(this.prevLoci);
+            this.ctx.canvas3d.requestDraw(true);
+        }
+    }
+
+    move(x: number, y: number) {
+        this.inside = true;
+        this.cX = x;
+        this.cY = y;
+    }
+
+    select(x: number, y: number) {
+        this.cX = x;
+        this.cY = y;
+        this.identify(true, 0);
+    }
+
+    constructor(private ctx: PluginContext, private maxFps: number = 15) {
+        this.animate(0);
+    }
+}

+ 0 - 0
src/mol-plugin/util/logger.ts


+ 73 - 0
src/mol-plugin/util/task-manager.ts

@@ -0,0 +1,73 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Task, Progress } from 'mol-task';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
+import { now } from 'mol-util/now';
+
+export { TaskManager }
+
+class TaskManager {
+    private ev = RxEventHelper.create();
+    private id = 0;
+
+    readonly events = {
+        progress: this.ev<TaskManager.ProgressEvent>(),
+        finished: this.ev<{ id: number }>()
+    };
+
+    private track(id: number) {
+        return (progress: Progress) => {
+            const elapsed = now() - progress.root.progress.startedTime;
+            progress.root.progress.startedTime
+            this.events.progress.next({
+                id,
+                level: elapsed < 250 ? 'none' : elapsed < 1500 ? 'background' : 'overlay',
+                progress
+            });
+        };
+    }
+
+    async run<T>(task: Task<T>): Promise<T> {
+        const id = this.id++;
+        try {
+            const ret = await task.run(this.track(id), 100);
+            return ret;
+        } finally {
+            this.events.finished.next({ id });
+        }
+    }
+
+    dispose() {
+        this.ev.dispose();
+    }
+}
+
+namespace TaskManager {
+    export type ReportLevel = 'none' | 'background' | 'overlay'
+
+    export interface ProgressEvent {
+        id: number,
+        level: ReportLevel,
+        progress: Progress
+    }
+
+    function delay(time: number): Promise<void> {
+        return new Promise(res => setTimeout(res, time));
+    }
+    export function testTask(N: number) {
+        return Task.create('Test', async ctx => {
+            let i = 0;
+            while (i < N) {
+                await delay(100 + Math.random() * 200);
+                if (ctx.shouldUpdate) {
+                    await ctx.update({ message: 'Step ' + i, current: i, max: N, isIndeterminate: false });
+                }
+                i++;
+            }
+        })
+    }
+}

+ 76 - 0
src/mol-state/action.ts

@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Task } from 'mol-task';
+import { UUID } from 'mol-util';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+import { StateObject, StateObjectCell } from './object';
+import { State } from './state';
+import { Transformer } from './transformer';
+
+export { StateAction };
+
+interface StateAction<A extends StateObject = StateObject, T = any, P = unknown> {
+    create(params: P): StateAction.Instance,
+    readonly id: UUID,
+    readonly definition: StateAction.Definition<A, T, P>
+}
+
+namespace StateAction {
+    export type Id = string & { '@type': 'transformer-id' }
+    export type Params<T extends StateAction<any, any, any>> = T extends StateAction<any, any, infer P> ? P : unknown;
+    export type ReType<T extends StateAction<any, any, any>> = T extends StateAction<any, infer T, any> ? T : unknown;
+    export type ControlsFor<Props> = { [P in keyof Props]?: PD.Any }
+
+    export interface Instance {
+        action: StateAction,
+        params: any
+    }
+
+    export interface ApplyParams<A extends StateObject = StateObject, P = unknown> {
+        cell: StateObjectCell,
+        a: A,
+        state: State,
+        params: P
+    }
+
+    export interface Definition<A extends StateObject = StateObject, T = any, P = unknown> {
+        readonly from: StateObject.Ctor[],
+        readonly display?: { readonly name: string, readonly description?: string },
+
+        /**
+         * Apply an action that modifies the State specified in Params.
+         */
+        apply(params: ApplyParams<A, P>, globalCtx: unknown): T | Task<T>,
+
+        readonly params?: Transformer<A, any, P>['definition']['params'],
+
+        /** Test if the transform can be applied to a given node */
+        isApplicable?(a: A, globalCtx: unknown): boolean
+    }
+
+    export function create<A extends StateObject, T, P>(definition: Definition<A, T, P>): StateAction<A, T, P> {
+        const action: StateAction<A, T, P> = {
+            create(params) { return { action, params }; },
+            id: UUID.create22(),
+            definition
+        };
+        return action;
+    }
+
+    export function fromTransformer<T extends Transformer>(transformer: T) {
+        const def = transformer.definition;
+        return create<Transformer.From<T>, void, Transformer.Params<T>>({
+            from: def.from,
+            display: def.display,
+            params: def.params as Transformer<Transformer.From<T>, any, Transformer.Params<T>>['definition']['params'],
+            apply({ cell, state, params }) {
+                const tree = state.build().to(cell.transform.ref).apply(transformer, params);
+                return state.update(tree);
+            }
+        })
+    }
+}

+ 38 - 0
src/mol-state/action/manager.ts

@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { StateAction } from 'mol-state/action';
+import { StateObject } from '../object';
+import { Transformer } from 'mol-state/transformer';
+
+export { StateActionManager }
+
+class StateActionManager {
+    private actions: Map<StateAction['id'], StateAction> = new Map();
+    private fromTypeIndex = new Map<StateObject.Type, StateAction[]>();
+
+    add(actionOrTransformer: StateAction | Transformer) {
+        const action = Transformer.is(actionOrTransformer) ? actionOrTransformer.toAction() : actionOrTransformer;
+
+        if (this.actions.has(action.id)) return this;
+
+        this.actions.set(action.id, action);
+
+        for (const t of action.definition.from) {
+            if (this.fromTypeIndex.has(t.type)) {
+                this.fromTypeIndex.get(t.type)!.push(action);
+            } else {
+                this.fromTypeIndex.set(t.type, [action]);
+            }
+        }
+
+        return this;
+    }
+
+    fromType(type: StateObject.Type): ReadonlyArray<StateAction> {
+        return this.fromTypeIndex.get(type) || [];
+    }
+}

+ 0 - 48
src/mol-state/context.ts

@@ -1,48 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { StateObject } from './object';
-import { Transform } from './transform';
-import { RxEventHelper } from 'mol-util/rx-event-helper';
-
-export { StateContext }
-
-class StateContext {
-    private ev = RxEventHelper.create();
-
-    readonly events = {
-        object: {
-            stateChanged: this.ev<{ ref: Transform.Ref }>(),
-            propsChanged: this.ev<{ ref: Transform.Ref, newProps: unknown }>(),
-
-            updated: this.ev<{ ref: Transform.Ref, obj?: StateObject }>(),
-            replaced: this.ev<{ ref: Transform.Ref, oldObj?: StateObject, newObj?: StateObject }>(),
-            created: this.ev<{ ref: Transform.Ref, obj: StateObject }>(),
-            removed: this.ev<{ ref: Transform.Ref, obj?: StateObject }>(),
-
-            currentChanged: this.ev<{ ref: Transform.Ref }>()
-        },
-        warn: this.ev<string>(),
-        updated: this.ev<void>()
-    };
-
-    readonly behaviors = {
-        currentObject: this.ev.behavior<{ ref: Transform.Ref }>(void 0 as any)
-    };
-
-    readonly globalContext: unknown;
-    readonly defaultObjectProps: unknown;
-
-    dispose() {
-        this.ev.dispose();
-    }
-
-    constructor(params: { globalContext: unknown, defaultObjectProps: unknown, rootRef: Transform.Ref }) {
-        this.globalContext = params.globalContext;
-        this.defaultObjectProps = params.defaultObjectProps;
-        this.behaviors.currentObject.next({ ref: params.rootRef });
-    }
-}

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

@@ -8,6 +8,4 @@ export * from './object'
 export * from './state'
 export * from './transformer'
 export * from './tree'
-export * from './context'
-export * from './transform'
-export * from './selection'
+export * from './transform'

+ 1 - 1
src/mol-plugin/state/action.ts → src/mol-state/manager.ts

@@ -4,4 +4,4 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-// TODO actions that modify state and can be "applied" to certain state objects.
+// TODO manage snapshots etc

+ 55 - 38
src/mol-state/object.ts

@@ -1,60 +1,77 @@
-
 /**
  * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { Transform } from './transform';
 import { UUID } from 'mol-util';
+import { Transform } from './transform';
+
+export { StateObject, StateObjectCell }
 
-/** A mutable state object */
-export interface StateObject<P = any, D = any> {
+interface StateObject<D = any, T extends StateObject.Type = { name: string, typeClass: any }> {
     readonly id: UUID,
-    readonly type: StateObject.Type,
-    readonly props: P,
-    readonly data: D
+    readonly type: T,
+    readonly data: D,
+    readonly label: string,
+    readonly description?: string,
 }
 
-export namespace StateObject {
-    export enum StateType {
-        // The object has been successfully created
-        Ok,
-        // An error occured during the creation of the object
-        Error,
-        // The object is queued to be created
-        Pending,
-        // The object is currently being created
-        Processing
+namespace StateObject {
+    export function factory<T extends Type>() {
+        return <D = { }>(type: T) => create<D, T>(type);
     }
 
-    export interface Type<Info = any> {
-        info: Info
+    export type Type<Cls extends string = string> = { name: string, typeClass: Cls }
+    export type Ctor = { new(...args: any[]): StateObject, type: any }
+
+    export function create<Data, T extends Type>(type: T) {
+        return class implements StateObject<Data, T> {
+            static type = type;
+            static is(obj?: StateObject): obj is StateObject<Data, T> { return !!obj && type === obj.type; }
+            id = UUID.create22();
+            type = type;
+            label: string;
+            description?: string;
+            constructor(public data: Data, props?: { label: string, description?: string }) {
+                this.label = props && props.label || type.name;
+                this.description = props && props.description;
+            }
+        }
     }
+}
 
-    export function factory<TypeInfo, CommonProps>() {
-        return <D = { }, P = {}>(typeInfo: TypeInfo) => create<P & CommonProps, D, TypeInfo>(typeInfo);
+interface StateObjectCell {
+    transform: Transform,
+
+    // Which object was used as a parent to create data in this cell
+    sourceRef: Transform.Ref | undefined,
+
+    version: string
+    status: StateObjectCell.Status,
+
+    errorText?: string,
+    obj?: StateObject
+}
+
+namespace StateObjectCell {
+    export type Status = 'ok' | 'error' | 'pending' | 'processing'
+
+    export interface State {
+        isHidden: boolean,
+        isCollapsed: boolean
     }
 
-    export type Ctor = { new(...args: any[]): StateObject, type: Type }
+    export const DefaultState: State = { isHidden: false, isCollapsed: false };
 
-    export function create<Props, Data, TypeInfo>(typeInfo: TypeInfo) {
-        const dataType: Type<TypeInfo> = { info: typeInfo };
-        return class implements StateObject<Props, Data> {
-            static type = dataType;
-            static is(obj?: StateObject): obj is StateObject<Props, Data> { return !!obj && dataType === obj.type; }
-            id = UUID.create();
-            type = dataType;
-            constructor(public props: Props, public data: Data) { }
-        }
+    export function areStatesEqual(a: State, b: State) {
+        return a.isHidden !== b.isHidden || a.isCollapsed !== b.isCollapsed;
     }
 
-    export interface Node {
-        ref: Transform.Ref,
-        state: StateType,
-        props: unknown,
-        errorText?: string,
-        obj?: StateObject,
-        version: string
+    export function isStateChange(a: State, b?: Partial<State>) {
+        if (!b) return false;
+        if (typeof b.isCollapsed !== 'undefined' && a.isCollapsed !== b.isCollapsed) return true;
+        if (typeof b.isHidden !== 'undefined' && a.isHidden !== b.isHidden) return true;
+        return false;
     }
 }

+ 0 - 178
src/mol-state/selection.ts

@@ -1,178 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { StateObject } from './object';
-import { State } from './state';
-import { ImmutableTree } from './util/immutable-tree';
-
-namespace StateSelection {
-    export type Selector = Query | Builder | string | StateObject.Node;
-    export type NodeSeq = StateObject.Node[]
-    export type Query = (state: State) => NodeSeq;
-
-    export function select(s: Selector, state: State) {
-        return compile(s)(state);
-    }
-
-    export function compile(s: Selector): Query {
-        const selector = s ? s : root();
-        let query: Query;
-        if (isBuilder(selector)) query = (selector as any).compile();
-        else if (isObj(selector)) query = (byValue(selector) as any).compile();
-        else if (isQuery(selector)) query = selector;
-        else query = (byRef(selector as string) as any).compile();
-        return query;
-    }
-
-    function isObj(arg: any): arg is StateObject.Node {
-        return (arg as StateObject.Node).version !== void 0;
-    }
-
-    function isBuilder(arg: any): arg is Builder {
-        return arg.compile !== void 0;
-    }
-
-    function isQuery(arg: any): arg is Query {
-        return typeof arg === 'function';
-    }
-
-    export interface Builder {
-        flatMap(f: (n: StateObject.Node) => StateObject.Node[]): Builder;
-        mapEntity(f: (n: StateObject.Node) => StateObject.Node): Builder;
-        unique(): Builder;
-
-        parent(): Builder;
-        first(): Builder;
-        filter(p: (n: StateObject.Node) => boolean): Builder;
-        subtree(): Builder;
-        children(): Builder;
-        ofType(t: StateObject.Type): Builder;
-        ancestorOfType(t: StateObject.Type): Builder;
-    }
-
-    const BuilderPrototype: any = {};
-
-    function registerModifier(name: string, f: Function) {
-        BuilderPrototype[name] = function (this: any, ...args: any[]) { return f.call(void 0, this, ...args) };
-    }
-
-    function build(compile: () => Query): Builder {
-        return Object.create(BuilderPrototype, { compile: { writable: false, configurable: false, value: compile } });
-    }
-
-    export function root() { return build(() => (state: State) => [state.objects.get(state.tree.rootRef)!]) }
-
-
-    export function byRef(...refs: string[]) {
-        return build(() => (state: State) => {
-            const ret: StateObject.Node[] = [];
-            for (const ref of refs) {
-                const n = state.objects.get(ref);
-                if (!n) continue;
-                ret.push(n);
-            }
-            return ret;
-        });
-    }
-
-    export function byValue(...objects: StateObject.Node[]) { return build(() => (state: State) => objects); }
-
-    registerModifier('flatMap', flatMap);
-    export function flatMap(b: Selector, f: (obj: StateObject.Node, state: State) => NodeSeq) {
-        const q = compile(b);
-        return build(() => (state: State) => {
-            const ret: StateObject.Node[] = [];
-            for (const n of q(state)) {
-                for (const m of f(n, state)) {
-                    ret.push(m);
-                }
-            }
-            return ret;
-        });
-    }
-
-    registerModifier('mapEntity', mapEntity);
-    export function mapEntity(b: Selector, f: (n: StateObject.Node, state: State) => StateObject.Node | undefined) {
-        const q = compile(b);
-        return build(() => (state: State) => {
-            const ret: StateObject.Node[] = [];
-            for (const n of q(state)) {
-                const x = f(n, state);
-                if (x) ret.push(x);
-            }
-            return ret;
-        });
-    }
-
-    registerModifier('unique', unique);
-    export function unique(b: Selector) {
-        const q = compile(b);
-        return build(() => (state: State) => {
-            const set = new Set<string>();
-            const ret: StateObject.Node[] = [];
-            for (const n of q(state)) {
-                if (!set.has(n.ref)) {
-                    set.add(n.ref);
-                    ret.push(n);
-                }
-            }
-            return ret;
-        })
-    }
-
-    registerModifier('first', first);
-    export function first(b: Selector) {
-        const q = compile(b);
-        return build(() => (state: State) => {
-            const r = q(state);
-            return r.length ? [r[0]] : [];
-        });
-    }
-
-    registerModifier('filter', filter);
-    export function filter(b: Selector, p: (n: StateObject.Node) => boolean) { return flatMap(b, n => p(n) ? [n] : []); }
-
-    registerModifier('subtree', subtree);
-    export function subtree(b: Selector) {
-        return flatMap(b, (n, s) => {
-            const nodes = [] as string[];
-            ImmutableTree.doPreOrder(s.tree, s.tree.nodes.get(n.ref), nodes, (x, _, ctx) => { ctx.push(x.ref) });
-            return nodes.map(x => s.objects.get(x)!);
-        });
-    }
-
-    registerModifier('children', children);
-    export function children(b: Selector) {
-        return flatMap(b, (n, s) => {
-            const nodes: StateObject.Node[] = [];
-            s.tree.nodes.get(n.ref)!.children.forEach(c => nodes.push(s.objects.get(c!)!));
-            return nodes;
-        });
-    }
-
-    registerModifier('ofType', ofType);
-    export function ofType(b: Selector, t: StateObject.Type) { return filter(b, n => n.obj ? n.obj.type === t : false); }
-
-    registerModifier('ancestorOfType', ancestorOfType);
-    export function ancestorOfType(b: Selector, t: StateObject.Type) { return unique(mapEntity(b, (n, s) => findAncestorOfType(s, n.ref, t))); }
-
-    registerModifier('parent', parent);
-    export function parent(b: Selector) { return unique(mapEntity(b, (n, s) => s.objects.get(s.tree.nodes.get(n.ref)!.parent))); }
-
-    function findAncestorOfType({ tree, objects }: State, root: string, type: StateObject.Type): StateObject.Node | undefined {
-        let current = tree.nodes.get(root)!;
-        while (true) {
-            current = tree.nodes.get(current.parent)!;
-            if (current.ref === tree.rootRef) {
-                return objects.get(tree.rootRef);
-            }
-            const obj = objects.get(current.ref)!.obj!;
-            if (obj.type === type) return objects.get(current.ref);
-        }
-    }
-}
-
-export { StateSelection }

+ 422 - 221
src/mol-state/state.ts

@@ -4,108 +4,171 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { StateObject } from './object';
+import { StateObject, StateObjectCell } from './object';
 import { StateTree } from './tree';
 import { Transform } from './transform';
-import { ImmutableTree } from './util/immutable-tree';
 import { Transformer } from './transformer';
-import { StateContext } from './context';
 import { UUID } from 'mol-util';
 import { RuntimeContext, Task } from 'mol-task';
+import { StateSelection } from './state/selection';
+import { RxEventHelper } from 'mol-util/rx-event-helper';
+import { StateTreeBuilder } from './tree/builder';
+import { StateAction } from './action';
+import { StateActionManager } from './action/manager';
+import { TransientTree } from './tree/transient';
+import { LogEntry } from 'mol-util/log-entry';
+import { now, formatTimespan } from 'mol-util/now';
 
 export { State }
 
 class State {
-    private _tree: StateTree = StateTree.create();
-    private _current: Transform.Ref = this._tree.rootRef;
+    private _tree: TransientTree = StateTree.createEmpty().asTransient();
+
+    protected errorFree = true;
     private transformCache = new Map<Transform.Ref, unknown>();
 
-    get tree() { return this._tree; }
-    get current() { return this._current; }
+    private ev = RxEventHelper.create();
+
+    readonly globalContext: unknown = void 0;
+    readonly events = {
+        cell: {
+            stateUpdated: this.ev<State.ObjectEvent & { cellState: StateObjectCell.State}>(),
+            created: this.ev<State.ObjectEvent & { cell: StateObjectCell }>(),
+            removed: this.ev<State.ObjectEvent & { parent: Transform.Ref }>(),
+        },
+        object: {
+            updated: this.ev<State.ObjectEvent & { action: 'in-place' | 'recreate', obj: StateObject, oldObj?: StateObject }>(),
+            created: this.ev<State.ObjectEvent & { obj: StateObject }>(),
+            removed: this.ev<State.ObjectEvent & { obj?: StateObject }>()
+        },
+        log: this.ev<LogEntry>(),
+        changed: this.ev<void>()
+    };
+
+    readonly behaviors = {
+        currentObject: this.ev.behavior<State.ObjectEvent>({ state: this, ref: Transform.RootRef })
+    };
+
+    readonly actions = new StateActionManager();
 
-    readonly objects: State.Objects = new Map();
-    readonly context: StateContext;
+    get tree(): StateTree { return this._tree; }
+    get current() { return this.behaviors.currentObject.value.ref; }
+
+    build() { return this._tree.build(); }
+
+    readonly cells: State.Cells = new Map();
 
     getSnapshot(): State.Snapshot {
-        const props = Object.create(null);
-        const keys = this.objects.keys();
-        while (true) {
-            const key = keys.next();
-            if (key.done) break;
-            const o = this.objects.get(key.value)!;
-            props[key.value] = { ...o.props };
-        }
-        return {
-            tree: StateTree.toJSON(this._tree),
-            props
-        };
+        return { tree: StateTree.toJSON(this._tree) };
     }
 
     setSnapshot(snapshot: State.Snapshot) {
         const tree = StateTree.fromJSON(snapshot.tree);
-        // TODO: support props and async
-        return this.update(tree).run();
+        return this.update(tree);
     }
 
     setCurrent(ref: Transform.Ref) {
-        this._current = ref;
-        this.context.behaviors.currentObject.next({ ref });
+        this.behaviors.currentObject.next({ state: this, ref });
+    }
+
+    updateCellState(ref: Transform.Ref, stateOrProvider: ((old: StateObjectCell.State) => Partial<StateObjectCell.State>) | Partial<StateObjectCell.State>) {
+        const update = typeof stateOrProvider === 'function'
+            ? stateOrProvider(this.tree.cellStates.get(ref))
+            : stateOrProvider;
+
+        if (this._tree.updateCellState(ref, update)) {
+            this.events.cell.stateUpdated.next({ state: this, ref, cellState: this.tree.cellStates.get(ref) });
+        }
     }
 
     dispose() {
-        this.context.dispose();
+        this.ev.dispose();
     }
 
-    update(tree: StateTree): Task<void> {
-        // TODO: support props
+    /**
+     * Select Cells by ref or a query generated on the fly.
+     * @example state.select('test')
+     * @example state.select(q => q.byRef('test').subtree())
+     */
+    select(selector: Transform.Ref | ((q: typeof StateSelection.Generators) => StateSelection.Selector)) {
+        if (typeof selector === 'string') return StateSelection.select(selector, this);
+        return StateSelection.select(selector(StateSelection.Generators), this)
+    }
+
+    /** If no ref is specified, apply to root */
+    apply<A extends StateAction>(action: A, params: StateAction.Params<A>, ref: Transform.Ref = Transform.RootRef): Task<void> {
+        return Task.create('Apply Action', ctx => {
+            const cell = this.cells.get(ref);
+            if (!cell) throw new Error(`'${ref}' does not exist.`);
+            if (cell.status !== 'ok') throw new Error(`Action cannot be applied to a cell with status '${cell.status}'`);
+
+            return runTask(action.definition.apply({ cell, a: cell.obj!, params, state: this }, this.globalContext), ctx);
+        });
+    }
+
+    update(tree: StateTree | StateTreeBuilder): Task<void> {
+        const _tree = (StateTreeBuilder.is(tree) ? tree.getTree() : tree).asTransient();
         return Task.create('Update Tree', async taskCtx => {
+            let updated = false;
             try {
                 const oldTree = this._tree;
-                this._tree = tree;
+                this._tree = _tree;
 
                 const ctx: UpdateContext = {
-                    stateCtx: this.context,
+                    parent: this,
+                    editInfo: StateTreeBuilder.is(tree) ? tree.editInfo : void 0,
+
+                    errorFree: this.errorFree,
                     taskCtx,
                     oldTree,
-                    tree: tree,
-                    objects: this.objects,
-                    transformCache: this.transformCache
+                    tree: _tree,
+                    cells: this.cells as Map<Transform.Ref, StateObjectCell>,
+                    transformCache: this.transformCache,
+
+                    changed: false,
+                    hadError: false,
+                    newCurrent: void 0
                 };
-                // TODO: have "cancelled" error? Or would this be handled automatically?
-                await update(ctx);
+
+                this.errorFree = true;
+                // TODO: handle "cancelled" error? Or would this be handled automatically?
+                updated = await update(ctx);
             } finally {
-                this.context.events.updated.next();
+                if (updated) this.events.changed.next();
             }
         });
     }
 
-    constructor(rootObject: StateObject, params?: { globalContext?: unknown, defaultObjectProps?: unknown }) {
+    constructor(rootObject: StateObject, params?: { globalContext?: unknown }) {
         const tree = this._tree;
-        const root = tree.getValue(tree.rootRef)!;
-        const defaultObjectProps = (params && params.defaultObjectProps) || { }
+        const root = tree.root;
 
-        this.objects.set(tree.rootRef, {
-            ref: tree.rootRef,
+        (this.cells as Map<Transform.Ref, StateObjectCell>).set(root.ref, {
+            transform: root,
+            sourceRef: void 0,
             obj: rootObject,
-            state: StateObject.StateType.Ok,
+            status: 'ok',
             version: root.version,
-            props: { ...defaultObjectProps }
+            errorText: void 0
         });
 
-        this.context = new StateContext({
-            globalContext: params && params.globalContext,
-            defaultObjectProps,
-            rootRef: tree.rootRef
-        });
+        this.globalContext = params && params.globalContext;
     }
 }
 
 namespace State {
-    export type Objects = Map<Transform.Ref, StateObject.Node>
+    export type Cells = ReadonlyMap<Transform.Ref, StateObjectCell>
+
+    export type Tree = StateTree
+    export type Builder = StateTreeBuilder
+
+    export interface ObjectEvent {
+        state: State,
+        ref: Ref
+    }
 
     export interface Snapshot {
-        readonly tree: StateTree.Serialized,
-        readonly props: { [key: string]: unknown }
+        readonly tree: StateTree.Serialized
     }
 
     export function create(rootObject: StateObject, params?: { globalContext?: unknown, defaultObjectProps?: unknown }) {
@@ -113,212 +176,350 @@ namespace State {
     }
 }
 
-    type Ref = Transform.Ref
+type Ref = Transform.Ref
 
-    interface UpdateContext {
-        stateCtx: StateContext,
-        taskCtx: RuntimeContext,
-        oldTree: StateTree,
-        tree: StateTree,
-        objects: State.Objects,
-        transformCache: Map<Ref, unknown>
-    }
+interface UpdateContext {
+    parent: State,
+    editInfo: StateTreeBuilder.EditInfo | undefined
+
+    errorFree: boolean,
+    taskCtx: RuntimeContext,
+    oldTree: StateTree,
+    tree: TransientTree,
+    cells: Map<Transform.Ref, StateObjectCell>,
+    transformCache: Map<Ref, unknown>,
 
-    async function update(ctx: UpdateContext) {
-        const roots = findUpdateRoots(ctx.objects, ctx.tree);
-        const deletes = findDeletes(ctx);
+    changed: boolean,
+    hadError: boolean,
+    newCurrent?: Ref
+}
+
+async function update(ctx: UpdateContext) {
+    // if only a single node was added/updated, we can skip potentially expensive diffing
+    const fastTrack = !!(ctx.errorFree && ctx.editInfo && ctx.editInfo.count === 1 && ctx.editInfo.lastUpdate && ctx.editInfo.sourceTree === ctx.oldTree);
+
+    let deletes: Transform.Ref[], deletedObjects: (StateObject | undefined)[] = [], roots: Transform.Ref[];
+
+    if (fastTrack) {
+        deletes = [];
+        roots = [ctx.editInfo!.lastUpdate!];
+    } else {
+        // find all nodes that will definitely be deleted.
+        // this is done in "post order", meaning that leaves will be deleted first.
+        deletes = findDeletes(ctx);
+
+        const current = ctx.parent.current;
+        let hasCurrent = false;
         for (const d of deletes) {
-            const obj = ctx.objects.has(d) ? ctx.objects.get(d)!.obj : void 0;
-            ctx.objects.delete(d);
-            ctx.transformCache.delete(d);
-            ctx.stateCtx.events.object.removed.next({ ref: d, obj });
-            // TODO: handle current object change
+            if (d === current) {
+                hasCurrent = true;
+                break;
+            }
         }
 
-        initObjectState(ctx, roots);
+        if (hasCurrent) {
+            const newCurrent = findNewCurrent(ctx, current, deletes);
+            ctx.parent.setCurrent(newCurrent);
+        }
 
-        for (const root of roots) {
-            await updateSubtree(ctx, root);
+        for (const d of deletes) {
+            const obj = ctx.cells.has(d) ? ctx.cells.get(d)!.obj : void 0;
+            ctx.cells.delete(d);
+            ctx.transformCache.delete(d);
+            deletedObjects.push(obj);
         }
+
+        // Find roots where transform version changed or where nodes will be added.
+        roots = findUpdateRoots(ctx.cells, ctx.tree);
     }
 
-    function findUpdateRoots(objects: State.Objects, tree: StateTree) {
-        const findState = {
-            roots: [] as Ref[],
-            objects
-        };
+    // Init empty cells where not present
+    // this is done in "pre order", meaning that "parents" will be created 1st.
+    const addedCells = initCells(ctx, roots);
 
-        ImmutableTree.doPreOrder(tree, tree.nodes.get(tree.rootRef)!, findState, (n, _, s) => {
-            if (!s.objects.has(n.ref)) {
-                s.roots.push(n.ref);
-                return false;
-            }
-            const o = s.objects.get(n.ref)!;
-            if (o.version !== n.value.version) {
-                s.roots.push(n.ref);
-                return false;
-            }
+    // Ensure cell states stay consistent
+    if (!ctx.editInfo) {
+        syncStates(ctx);
+    }
 
-            return true;
-        });
+    // Notify additions of new cells.
+    for (const cell of addedCells) {
+        ctx.parent.events.cell.created.next({ state: ctx.parent, ref: cell.transform.ref, cell });
+    }
 
-        return findState.roots;
+    for (let i = 0; i < deletes.length; i++) {
+        const d = deletes[i];
+        const parent = ctx.oldTree.transforms.get(d).parent;
+        ctx.parent.events.object.removed.next({ state: ctx.parent, ref: d, obj: deletedObjects[i] });
+        ctx.parent.events.cell.removed.next({ state: ctx.parent, ref: d, parent: parent });
     }
 
-    function findDeletes(ctx: UpdateContext): Ref[] {
-        // TODO: do this in some sort of "tree order"?
-        const deletes: Ref[] = [];
-        const keys = ctx.objects.keys();
-        while (true) {
-            const key = keys.next();
-            if (key.done) break;
-            if (!ctx.tree.nodes.has(key.value)) deletes.push(key.value);
-        }
-        return deletes;
+    if (deletedObjects.length) deletedObjects = [];
+
+    // Set status of cells that will be updated to 'pending'.
+    initCellStatus(ctx, roots);
+
+    // Sequentially update all the subtrees.
+    for (const root of roots) {
+        await updateSubtree(ctx, root);
     }
 
-    function setObjectState(ctx: UpdateContext, ref: Ref, state: StateObject.StateType, errorText?: string) {
-        let changed = false;
-        if (ctx.objects.has(ref)) {
-            const obj = ctx.objects.get(ref)!;
-            changed = obj.state !== state;
-            obj.state = state;
-            obj.errorText = errorText;
-        } else {
-            const obj: StateObject.Node = { ref, state, version: UUID.create(), errorText, props: { ...ctx.stateCtx.defaultObjectProps } };
-            ctx.objects.set(ref, obj);
-            changed = true;
-        }
-        if (changed) ctx.stateCtx.events.object.stateChanged.next({ ref });
+    if (ctx.newCurrent) ctx.parent.setCurrent(ctx.newCurrent);
+
+    return deletes.length > 0 || roots.length > 0 || ctx.changed;
+}
+
+function findUpdateRoots(cells: Map<Transform.Ref, StateObjectCell>, tree: StateTree) {
+    const findState = { roots: [] as Ref[], cells };
+    StateTree.doPreOrder(tree, tree.root, findState, findUpdateRootsVisitor);
+    return findState.roots;
+}
+
+function findUpdateRootsVisitor(n: Transform, _: any, s: { roots: Ref[], cells: Map<Ref, StateObjectCell> }) {
+    const cell = s.cells.get(n.ref);
+    if (!cell || cell.version !== n.version || cell.status === 'error') {
+        s.roots.push(n.ref);
+        return false;
     }
+    return true;
+}
 
-    function _initVisitor(t: ImmutableTree.Node<Transform>, _: any, ctx: UpdateContext) {
-        setObjectState(ctx, t.ref, StateObject.StateType.Pending);
+type FindDeletesCtx = { newTree: StateTree, cells: State.Cells, deletes: Ref[] }
+function checkDeleteVisitor(n: Transform, _: any, ctx: FindDeletesCtx) {
+    if (!ctx.newTree.transforms.has(n.ref) && ctx.cells.has(n.ref)) ctx.deletes.push(n.ref);
+}
+function findDeletes(ctx: UpdateContext): Ref[] {
+    const deleteCtx: FindDeletesCtx = { newTree: ctx.tree, cells: ctx.cells, deletes: [] };
+    StateTree.doPostOrder(ctx.oldTree, ctx.oldTree.root, deleteCtx, checkDeleteVisitor);
+    return deleteCtx.deletes;
+}
+
+function syncStatesVisitor(n: Transform, tree: StateTree, oldState: StateTree.CellStates) {
+    if (!oldState.has(n.ref)) return;
+    (tree as TransientTree).updateCellState(n.ref, oldState.get(n.ref));
+}
+function syncStates(ctx: UpdateContext) {
+    StateTree.doPreOrder(ctx.tree, ctx.tree.root, ctx.oldTree.cellStates, syncStatesVisitor);
+}
+
+function setCellStatus(ctx: UpdateContext, ref: Ref, status: StateObjectCell.Status, errorText?: string) {
+    const cell = ctx.cells.get(ref)!;
+    const changed = cell.status !== status;
+    cell.status = status;
+    cell.errorText = errorText;
+    if (changed) ctx.parent.events.cell.stateUpdated.next({ state: ctx.parent, ref, cellState: ctx.tree.cellStates.get(ref) });
+}
+
+function initCellStatusVisitor(t: Transform, _: any, ctx: UpdateContext) {
+    ctx.cells.get(t.ref)!.transform = t;
+    setCellStatus(ctx, t.ref, 'pending');
+}
+
+function initCellStatus(ctx: UpdateContext, roots: Ref[]) {
+    for (const root of roots) {
+        StateTree.doPreOrder(ctx.tree, ctx.tree.transforms.get(root), ctx, initCellStatusVisitor);
     }
-    /** Return "resolve set" */
-    function initObjectState(ctx: UpdateContext, roots: Ref[]) {
-        for (const root of roots) {
-            ImmutableTree.doPreOrder(ctx.tree, ctx.tree.nodes.get(root), ctx, _initVisitor);
-        }
+}
+
+type InitCellsCtx = { ctx: UpdateContext, added: StateObjectCell[] }
+function initCellsVisitor(transform: Transform, _: any, { ctx, added }: InitCellsCtx) {
+    if (ctx.cells.has(transform.ref)) {
+        return;
     }
 
-    function doError(ctx: UpdateContext, ref: Ref, errorText: string) {
-        setObjectState(ctx, ref, StateObject.StateType.Error, errorText);
-        const wrap = ctx.objects.get(ref)!;
-        if (wrap.obj) {
-            ctx.stateCtx.events.object.removed.next({ ref });
-            ctx.transformCache.delete(ref);
-            wrap.obj = void 0;
-        }
+    const cell: StateObjectCell = {
+        transform,
+        sourceRef: void 0,
+        status: 'pending',
+        version: UUID.create22(),
+        errorText: void 0
+    };
+    ctx.cells.set(transform.ref, cell);
+    added.push(cell);
+}
 
-        const children = ctx.tree.nodes.get(ref)!.children.values();
-        while (true) {
-            const next = children.next();
-            if (next.done) return;
-            doError(ctx, next.value, 'Parent node contains error.');
-        }
+function initCells(ctx: UpdateContext, roots: Ref[]) {
+    const initCtx: InitCellsCtx = { ctx, added: [] };
+    for (const root of roots) {
+        StateTree.doPreOrder(ctx.tree, ctx.tree.transforms.get(root), initCtx, initCellsVisitor);
     }
+    return initCtx.added;
+}
 
-    function findAncestor(tree: StateTree, objects: State.Objects, root: Ref, types: { type: StateObject.Type }[]): StateObject {
-        let current = tree.nodes.get(root)!;
-        while (true) {
-            current = tree.nodes.get(current.parent)!;
-            if (current.ref === tree.rootRef) {
-                return objects.get(tree.rootRef)!.obj!;
-            }
-            const obj = objects.get(current.ref)!.obj!;
-            for (const t of types) if (obj.type === t.type) return objects.get(current.ref)!.obj!;
+function findNewCurrent(ctx: UpdateContext, start: Ref, deletes: Ref[]) {
+    const deleteSet = new Set(deletes);
+    return _findNewCurrent(ctx.oldTree, start, deleteSet);
+}
+
+function _findNewCurrent(tree: StateTree, ref: Ref, deletes: Set<Ref>): Ref {
+    if (ref === Transform.RootRef) return ref;
+
+    const node = tree.transforms.get(ref)!;
+    const siblings = tree.children.get(node.parent)!.values();
+
+    let prevCandidate: Ref | undefined = void 0, seenRef = false;
+
+    while (true) {
+        const s = siblings.next();
+        if (s.done) break;
+
+        if (deletes.has(s.value)) continue;
+
+        const t = tree.transforms.get(s.value);
+        if (t.props && t.props.isGhost) continue;
+        if (s.value === ref) {
+            seenRef = true;
+            if (!deletes.has(ref)) prevCandidate = ref;
+            continue;
         }
+
+        if (seenRef) return t.ref;
+
+        prevCandidate = t.ref;
     }
 
-    async function updateSubtree(ctx: UpdateContext, root: Ref) {
-        setObjectState(ctx, root, StateObject.StateType.Processing);
-
-        try {
-            const update = await updateNode(ctx, root);
-            setObjectState(ctx, root, StateObject.StateType.Ok);
-            if (update.action === 'created') {
-                ctx.stateCtx.events.object.created.next({ ref: root, obj: update.obj! });
-            } else if (update.action === 'updated') {
-                ctx.stateCtx.events.object.updated.next({ ref: root, obj: update.obj });
-            } else if (update.action === 'replaced') {
-                ctx.stateCtx.events.object.replaced.next({ ref: root, oldObj: update.oldObj, newObj: update.newObj });
-            }
-        } catch (e) {
-            doError(ctx, root, '' + e);
-            return;
-        }
+    if (prevCandidate) return prevCandidate;
+    return _findNewCurrent(tree, node.parent, deletes);
+}
 
-        const children = ctx.tree.nodes.get(root)!.children.values();
-        while (true) {
-            const next = children.next();
-            if (next.done) return;
-            await updateSubtree(ctx, next.value);
-        }
+/** Set status and error text of the cell. Remove all existing objects in the subtree. */
+function doError(ctx: UpdateContext, ref: Ref, errorText: string | undefined) {
+    ctx.hadError = true;
+    (ctx.parent as any as { errorFree: boolean }).errorFree = false;
+
+    if (errorText) {
+        setCellStatus(ctx, ref, 'error', errorText);
+        ctx.parent.events.log.next({ type: 'error', timestamp: new Date(), message: errorText });
+    }
+
+    const cell = ctx.cells.get(ref)!;
+    if (cell.obj) {
+        const obj = cell.obj;
+        cell.obj = void 0;
+        ctx.parent.events.object.removed.next({ state: ctx.parent, ref, obj });
+        ctx.transformCache.delete(ref);
     }
 
-    async function updateNode(ctx: UpdateContext, currentRef: Ref) {
-        const { oldTree, tree, objects } = ctx;
-        const transform = tree.getValue(currentRef)!;
-        const parent = findAncestor(tree, objects, currentRef, transform.transformer.definition.from);
-        // console.log('parent', transform.transformer.id, transform.transformer.definition.from[0].type, parent ? parent.ref : 'undefined')
-        if (!oldTree.nodes.has(currentRef) || !objects.has(currentRef)) {
-            // console.log('creating...', transform.transformer.id, oldTree.nodes.has(currentRef), objects.has(currentRef));
-            const obj = await createObject(ctx, currentRef, transform.transformer, parent, transform.params);
-            objects.set(currentRef, {
-                ref: currentRef,
-                obj,
-                state: StateObject.StateType.Ok,
-                version: transform.version,
-                props: { ...ctx.stateCtx.defaultObjectProps, ...transform.defaultProps }
-            });
-            return { action: 'created', obj };
-        } else {
-            // console.log('updating...', transform.transformer.id);
-            const current = objects.get(currentRef)!;
-            const oldParams = oldTree.getValue(currentRef)!.params;
-            switch (await updateObject(ctx, currentRef, transform.transformer, parent, current.obj!, oldParams, transform.params)) {
-                case Transformer.UpdateResult.Recreate: {
-                    const obj = await createObject(ctx, currentRef, transform.transformer, parent, transform.params);
-                    objects.set(currentRef, {
-                        ref: currentRef,
-                        obj,
-                        state: StateObject.StateType.Ok,
-                        version: transform.version,
-                        props: { ...ctx.stateCtx.defaultObjectProps, ...current.props, ...transform.defaultProps }
-                    });
-                    return { action: 'replaced', oldObj: current.obj!, newObj: obj };
-                }
-                case Transformer.UpdateResult.Updated:
-                    current.version = transform.version;
-                    current.props = { ...ctx.stateCtx.defaultObjectProps, ...current.props, ...transform.defaultProps };
-                    return { action: 'updated', obj: current.obj };
-                default:
-                    // TODO check if props need to be updated
-                    return { action: 'none' };
+    // remove the objects in the child nodes if they exist
+    const children = ctx.tree.children.get(ref).values();
+    while (true) {
+        const next = children.next();
+        if (next.done) return;
+        doError(ctx, next.value, void 0);
+    }
+}
+
+type UpdateNodeResult =
+    | { action: 'created', obj: StateObject }
+    | { action: 'updated', obj: StateObject }
+    | { action: 'replaced', oldObj?: StateObject, obj: StateObject }
+    | { action: 'none' }
+
+async function updateSubtree(ctx: UpdateContext, root: Ref) {
+    setCellStatus(ctx, root, 'processing');
+
+    try {
+        const start = now();
+        const update = await updateNode(ctx, root);
+        const time = now() - start;
+
+        if (update.action !== 'none') ctx.changed = true;
+
+        setCellStatus(ctx, root, 'ok');
+        if (update.action === 'created') {
+            ctx.parent.events.object.created.next({ state: ctx.parent, ref: root, obj: update.obj! });
+            ctx.parent.events.log.next(LogEntry.info(`Created ${update.obj.label} in ${formatTimespan(time)}.`));
+            if (!ctx.hadError) {
+                const transform = ctx.tree.transforms.get(root);
+                if (!transform.props || !transform.props.isGhost) ctx.newCurrent = root;
             }
+        } else if (update.action === 'updated') {
+            ctx.parent.events.object.updated.next({ state: ctx.parent, ref: root, action: 'in-place', obj: update.obj });
+            ctx.parent.events.log.next(LogEntry.info(`Updated ${update.obj.label} in ${formatTimespan(time)}.`));
+        } else if (update.action === 'replaced') {
+            ctx.parent.events.object.updated.next({ state: ctx.parent, ref: root, action: 'recreate', obj: update.obj, oldObj: update.oldObj });
+            ctx.parent.events.log.next(LogEntry.info(`Updated ${update.obj.label} in ${formatTimespan(time)}.`));
         }
+    } catch (e) {
+        ctx.changed = true;
+        if (!ctx.hadError) ctx.newCurrent = root;
+        doError(ctx, root, '' + e);
+        return;
     }
 
-    function runTask<T>(t: T | Task<T>, ctx: RuntimeContext) {
-        if (typeof (t as any).run === 'function') return (t as Task<T>).runInContext(ctx);
-        return t as T;
+    const children = ctx.tree.children.get(root).values();
+    while (true) {
+        const next = children.next();
+        if (next.done) return;
+        await updateSubtree(ctx, next.value);
     }
+}
 
-    function createObject(ctx: UpdateContext, ref: Ref, transformer: Transformer, a: StateObject, params: any) {
-        const cache = { };
-        ctx.transformCache.set(ref, cache);
-        return runTask(transformer.definition.apply({ a, params, cache }, ctx.stateCtx.globalContext), ctx.taskCtx);
+async function updateNode(ctx: UpdateContext, currentRef: Ref): Promise<UpdateNodeResult> {
+    const { oldTree, tree } = ctx;
+    const current = ctx.cells.get(currentRef)!;
+    const transform = current.transform;
+
+    // special case for Root
+    if (current.transform.ref === Transform.RootRef) return { action: 'none' };
+
+    const parentCell = StateSelection.findAncestorOfType(tree, ctx.cells, currentRef, transform.transformer.definition.from);
+    if (!parentCell) {
+        throw new Error(`No suitable parent found for '${currentRef}'`);
     }
 
-    async function updateObject(ctx: UpdateContext, ref: Ref, transformer: Transformer, a: StateObject, b: StateObject, oldParams: any, newParams: any) {
-        if (!transformer.definition.update) {
-            return Transformer.UpdateResult.Recreate;
-        }
-        let cache = ctx.transformCache.get(ref);
-        if (!cache) {
-            cache = { };
-            ctx.transformCache.set(ref, cache);
+    const parent = parentCell.obj!;
+    current.sourceRef = parentCell.transform.ref;
+
+    if (!oldTree.transforms.has(currentRef)) {
+        const obj = await createObject(ctx, currentRef, transform.transformer, parent, transform.params);
+        current.obj = obj;
+        current.version = transform.version;
+
+        return { action: 'created', obj };
+    } else {
+        const oldParams = oldTree.transforms.get(currentRef)!.params;
+
+        const updateKind = !!current.obj
+            ? await updateObject(ctx, currentRef, transform.transformer, parent, current.obj!, oldParams, transform.params)
+            : Transformer.UpdateResult.Recreate;
+
+        switch (updateKind) {
+            case Transformer.UpdateResult.Recreate: {
+                const oldObj = current.obj;
+                const newObj = await createObject(ctx, currentRef, transform.transformer, parent, transform.params);
+                current.obj = newObj;
+                current.version = transform.version;
+                return { action: 'replaced', oldObj, obj: newObj };
+            }
+            case Transformer.UpdateResult.Updated:
+                current.version = transform.version;
+                return { action: 'updated', obj: current.obj! };
+            default:
+                return { action: 'none' };
         }
-        return runTask(transformer.definition.update({ a, oldParams, b, newParams, cache }, ctx.stateCtx.globalContext), ctx.taskCtx);
-    }
+    }
+}
+
+function runTask<T>(t: T | Task<T>, ctx: RuntimeContext) {
+    if (typeof (t as any).runInContext === 'function') return (t as Task<T>).runInContext(ctx);
+    return t as T;
+}
+
+function createObject(ctx: UpdateContext, ref: Ref, transformer: Transformer, a: StateObject, params: any) {
+    const cache = Object.create(null);
+    ctx.transformCache.set(ref, cache);
+    return runTask(transformer.definition.apply({ a, params, cache }, ctx.parent.globalContext), ctx.taskCtx);
+}
+
+async function updateObject(ctx: UpdateContext, ref: Ref, transformer: Transformer, a: StateObject, b: StateObject, oldParams: any, newParams: any) {
+    if (!transformer.definition.update) {
+        return Transformer.UpdateResult.Recreate;
+    }
+    let cache = ctx.transformCache.get(ref);
+    if (!cache) {
+        cache = Object.create(null);
+        ctx.transformCache.set(ref, cache);
+    }
+    return runTask(transformer.definition.update({ a, oldParams, b, newParams, cache }, ctx.parent.globalContext), ctx.taskCtx);
+}

+ 212 - 0
src/mol-state/state/selection.ts

@@ -0,0 +1,212 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { StateObject, StateObjectCell } from '../object';
+import { State } from '../state';
+import { StateTree } from '../tree';
+import { Transform } from '../transform';
+
+namespace StateSelection {
+    export type Selector = Query | Builder | string | StateObjectCell;
+    export type CellSeq = StateObjectCell[]
+    export type Query = (state: State) => CellSeq;
+
+    export function select(s: Selector, state: State) {
+        return compile(s)(state);
+    }
+
+    export function compile(s: Selector): Query {
+        const selector = s ? s : Generators.root;
+        let query: Query;
+        if (isBuilder(selector)) query = (selector as any).compile();
+        else if (isObj(selector)) query = (Generators.byValue(selector) as any).compile();
+        else if (isQuery(selector)) query = selector;
+        else query = (Generators.byRef(selector as string) as any).compile();
+        return query;
+    }
+
+    function isObj(arg: any): arg is StateObjectCell {
+        return (arg as StateObjectCell).version !== void 0;
+    }
+
+    function isBuilder(arg: any): arg is Builder {
+        return arg.compile !== void 0;
+    }
+
+    function isQuery(arg: any): arg is Query {
+        return typeof arg === 'function';
+    }
+
+    export interface Builder {
+        flatMap(f: (n: StateObjectCell) => StateObjectCell[]): Builder;
+        mapEntity(f: (n: StateObjectCell) => StateObjectCell): Builder;
+        unique(): Builder;
+
+        parent(): Builder;
+        first(): Builder;
+        filter(p: (n: StateObjectCell) => boolean): Builder;
+        withStatus(s: StateObjectCell.Status): Builder;
+        subtree(): Builder;
+        children(): Builder;
+        ofType(t: StateObject.Ctor): Builder;
+        ancestorOfType(t: StateObject.Ctor): Builder;
+
+        select(state: State): CellSeq
+    }
+
+    const BuilderPrototype: any = {
+        select(state?: State) {
+            return select(this, state || this.state);
+        }
+    };
+
+    function registerModifier(name: string, f: Function) {
+        BuilderPrototype[name] = function (this: any, ...args: any[]) { return f.call(void 0, this, ...args) };
+    }
+
+    function build(compile: () => Query): Builder {
+        return Object.create(BuilderPrototype, { compile: { writable: false, configurable: false, value: compile } });
+    }
+
+    export namespace Generators {
+        export const root = build(() => (state: State) => [state.cells.get(state.tree.root.ref)!]);
+
+        export function byRef(...refs: Transform.Ref[]) {
+            return build(() => (state: State) => {
+                const ret: StateObjectCell[] = [];
+                for (const ref of refs) {
+                    const n = state.cells.get(ref);
+                    if (!n) continue;
+                    ret.push(n);
+                }
+                return ret;
+            });
+        }
+
+        export function byValue(...objects: StateObjectCell[]) { return build(() => (state: State) => objects); }
+
+        export function rootsOfType(type: StateObject.Ctor) {
+            return build(() => state => {
+                const ctx = { roots: [] as StateObjectCell[], cells: state.cells, type: type.type };
+                StateTree.doPreOrder(state.tree, state.tree.root, ctx, _findRootsOfType);
+                return ctx.roots;
+            });
+        }
+
+        function _findRootsOfType(n: Transform, _: any, s: { type: StateObject.Type, roots: StateObjectCell[], cells: State.Cells }) {
+            const cell = s.cells.get(n.ref);
+            if (cell && cell.obj && cell.obj.type === s.type) {
+                s.roots.push(cell);
+                return false;
+            }
+            return true;
+        }
+    }
+
+    registerModifier('flatMap', flatMap);
+    export function flatMap(b: Selector, f: (obj: StateObjectCell, state: State) => CellSeq) {
+        const q = compile(b);
+        return build(() => (state: State) => {
+            const ret: StateObjectCell[] = [];
+            for (const n of q(state)) {
+                for (const m of f(n, state)) {
+                    ret.push(m);
+                }
+            }
+            return ret;
+        });
+    }
+
+    registerModifier('mapEntity', mapEntity);
+    export function mapEntity(b: Selector, f: (n: StateObjectCell, state: State) => StateObjectCell | undefined) {
+        const q = compile(b);
+        return build(() => (state: State) => {
+            const ret: StateObjectCell[] = [];
+            for (const n of q(state)) {
+                const x = f(n, state);
+                if (x) ret.push(x);
+            }
+            return ret;
+        });
+    }
+
+    registerModifier('unique', unique);
+    export function unique(b: Selector) {
+        const q = compile(b);
+        return build(() => (state: State) => {
+            const set = new Set<string>();
+            const ret: StateObjectCell[] = [];
+            for (const n of q(state)) {
+                if (!n) continue;
+                if (!set.has(n.transform.ref)) {
+                    set.add(n.transform.ref);
+                    ret.push(n);
+                }
+            }
+            return ret;
+        })
+    }
+
+    registerModifier('first', first);
+    export function first(b: Selector) {
+        const q = compile(b);
+        return build(() => (state: State) => {
+            const r = q(state);
+            return r.length ? [r[0]] : [];
+        });
+    }
+
+    registerModifier('filter', filter);
+    export function filter(b: Selector, p: (n: StateObjectCell) => boolean) { return flatMap(b, n => p(n) ? [n] : []); }
+
+    registerModifier('withStatus', withStatus);
+    export function withStatus(b: Selector, s: StateObjectCell.Status) { return filter(b, n => n.status === s); }
+
+    registerModifier('subtree', subtree);
+    export function subtree(b: Selector) {
+        return flatMap(b, (n, s) => {
+            const nodes = [] as string[];
+            StateTree.doPreOrder(s.tree, s.tree.transforms.get(n.transform.ref), nodes, (x, _, ctx) => { ctx.push(x.ref) });
+            return nodes.map(x => s.cells.get(x)!);
+        });
+    }
+
+    registerModifier('children', children);
+    export function children(b: Selector) {
+        return flatMap(b, (n, s) => {
+            const nodes: StateObjectCell[] = [];
+            s.tree.children.get(n.transform.ref).forEach(c => nodes.push(s.cells.get(c!)!));
+            return nodes;
+        });
+    }
+
+    registerModifier('ofType', ofType);
+    export function ofType(b: Selector, t: StateObject.Ctor) { return filter(b, n => n.obj ? n.obj.type === t.type : false); }
+
+    registerModifier('ancestorOfType', ancestorOfType);
+    export function ancestorOfType(b: Selector, types: StateObject.Ctor[]) { return unique(mapEntity(b, (n, s) => findAncestorOfType(s.tree, s.cells, n.transform.ref, types))); }
+
+    registerModifier('parent', parent);
+    export function parent(b: Selector) { return unique(mapEntity(b, (n, s) => s.cells.get(s.tree.transforms.get(n.transform.ref)!.parent))); }
+
+    export function findAncestorOfType(tree: StateTree, cells: State.Cells, root: Transform.Ref, types: StateObject.Ctor[]): StateObjectCell | undefined {
+        let current = tree.transforms.get(root)!, len = types.length;
+        while (true) {
+            current = tree.transforms.get(current.parent)!;
+            const cell = cells.get(current.ref)!;
+            if (!cell.obj) return void 0;
+            const obj = cell.obj;
+            for (let i = 0; i < len; i++) {
+                if (obj.type === types[i].type) return cells.get(current.ref);
+            }
+            if (current.ref === Transform.RootRef) {
+                return void 0;
+            }
+        }
+    }
+}
+
+export { StateSelection }

+ 36 - 20
src/mol-state/transform.ts

@@ -9,43 +9,57 @@ import { Transformer } from './transformer';
 import { UUID } from 'mol-util';
 
 export interface Transform<A extends StateObject = StateObject, B extends StateObject = StateObject, P = unknown> {
+    readonly parent: Transform.Ref,
     readonly transformer: Transformer<A, B, P>,
-    readonly params: P,
+    readonly props: Transform.Props,
     readonly ref: Transform.Ref,
-    readonly version: string,
-    readonly defaultProps?: unknown
+    readonly params: P,
+    readonly version: string
 }
 
 export namespace Transform {
     export type Ref = string
 
-    export interface Options { ref?: Ref, defaultProps?: unknown }
+    export const RootRef = '-=root=-' as Ref;
 
-    export function create<A extends StateObject, B extends StateObject, P>(transformer: Transformer<A, B, P>, params?: P, options?: Options): Transform<A, B, P> {
-        const ref = options && options.ref ? options.ref : UUID.create() as string as Ref;
+    export interface Props {
+        tag?: string
+        isGhost?: boolean,
+        isBinding?: boolean
+    }
+
+    export interface Options {
+        ref?: string,
+        props?: Props
+    }
+
+    export function create<A extends StateObject, B extends StateObject, P>(parent: Ref, transformer: Transformer<A, B, P>, params?: P, options?: Options): Transform<A, B, P> {
+        const ref = options && options.ref ? options.ref : UUID.create22() as string as Ref;
         return {
+            parent,
             transformer,
-            params: params || {} as any,
+            props: (options && options.props) || { },
             ref,
-            version: UUID.create(),
-            defaultProps: options && options.defaultProps
+            params: params || {} as any,
+            version: UUID.create22()
         }
     }
 
-    export function updateParams<T>(t: Transform, params: any): Transform {
-        return { ...t, params, version: UUID.create() };
+    export function withParams<T>(t: Transform, params: any): Transform {
+        return { ...t, params, version: UUID.create22() };
     }
 
-    export function createRoot(ref: Ref): Transform {
-        return create(Transformer.ROOT, {}, { ref });
+    export function createRoot(): Transform {
+        return create(RootRef, Transformer.ROOT, {}, { ref: RootRef });
     }
 
     export interface Serialized {
+        parent: string,
         transformer: string,
         params: any,
+        props: Props,
         ref: string,
-        version: string,
-        defaultProps?: unknown
+        version: string
     }
 
     function _id(x: any) { return x; }
@@ -54,11 +68,12 @@ export namespace Transform {
             ? t.transformer.definition.customSerialization.toJSON
             : _id;
         return {
+            parent: t.parent,
             transformer: t.transformer.id,
             params: pToJson(t.params),
+            props: t.props,
             ref: t.ref,
-            version: t.version,
-            defaultProps: t.defaultProps
+            version: t.version
         };
     }
 
@@ -68,11 +83,12 @@ export namespace Transform {
             ? transformer.definition.customSerialization.toJSON
             : _id;
         return {
+            parent: t.parent as Ref,
             transformer,
             params: pFromJson(t.params),
-            ref: t.ref,
-            version: t.version,
-            defaultProps: t.defaultProps
+            props: t.props,
+            ref: t.ref as Ref,
+            version: t.version
         };
     }
 }

+ 25 - 15
src/mol-state/transformer.ts

@@ -8,9 +8,11 @@ import { Task } from 'mol-task';
 import { StateObject } from './object';
 import { Transform } from './transform';
 import { ParamDefinition as PD } from 'mol-util/param-definition';
+import { StateAction } from './action';
 
 export interface Transformer<A extends StateObject = StateObject, B extends StateObject = StateObject, P = unknown> {
-    apply(params?: P, props?: Partial<Transform.Options>): Transform<A, B, P>,
+    apply(parent: Transform.Ref, params?: P, props?: Partial<Transform.Options>): Transform<A, B, P>,
+    toAction(): StateAction<A, void, P>,
     readonly namespace: string,
     readonly id: Transformer.Id,
     readonly definition: Transformer.Definition<A, B, P>
@@ -19,9 +21,14 @@ export interface Transformer<A extends StateObject = StateObject, B extends Stat
 export namespace Transformer {
     export type Id = string & { '@type': 'transformer-id' }
     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 ControlsFor<Props> = { [P in keyof Props]?: PD.Any }
 
+    export function is(obj: any): obj is Transformer {
+        return !!obj && typeof (obj as Transformer).toAction === 'function' && typeof (obj as Transformer).apply === 'function';
+    }
+
     export interface ApplyParams<A extends StateObject = StateObject, P = unknown> {
         a: A,
         params: P,
@@ -40,10 +47,21 @@ export namespace Transformer {
 
     export enum UpdateResult { Unchanged, Updated, Recreate }
 
+    export interface ParamsDefinition<A extends StateObject = StateObject, P = unknown> {
+        /** Check the parameters and return a list of errors if the are not valid. */
+        default?(a: A, globalCtx: unknown): P,
+        /** Specify default control descriptors for the parameters */
+        controls?(a: A, globalCtx: unknown): ControlsFor<P>,
+        /** Check the parameters and return a list of errors if the are not valid. */
+        validate?(params: P, a: A, globalCtx: unknown): string[] | undefined,
+        /** Optional custom parameter equality. Use deep structural equal by default. */
+        areEqual?(oldParams: P, newParams: P): boolean
+    }
+
     export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P = unknown> {
         readonly name: string,
-        readonly from: { type: StateObject.Type }[],
-        readonly to: { type: StateObject.Type }[],
+        readonly from: StateObject.Ctor[],
+        readonly to: StateObject.Ctor[],
         readonly display?: { readonly name: string, readonly description?: string },
 
         /**
@@ -59,16 +77,7 @@ export namespace Transformer {
          */
         update?(params: UpdateParams<A, B, P>, globalCtx: unknown): Task<UpdateResult> | UpdateResult,
 
-        params?: {
-            /** Check the parameters and return a list of errors if the are not valid. */
-            default?(a: A, globalCtx: unknown): P,
-            /** Specify default control descriptors for the parameters */
-            controls?(a: A, globalCtx: unknown): ControlsFor<P>,
-            /** Check the parameters and return a list of errors if the are not valid. */
-            validate?(a: A, params: P, globalCtx: unknown): string[] | undefined,
-            /** Optional custom parameter equality. Use deep structural equal by default. */
-            areEqual?(oldParams: P, newParams: P): boolean
-        }
+        readonly params?: ParamsDefinition<A, P>,
 
         /** Test if the transform can be applied to a given node */
         isApplicable?(a: A, globalCtx: unknown): boolean,
@@ -77,7 +86,7 @@ export namespace Transformer {
         isSerializable?(params: P): { isSerializable: true } | { isSerializable: false; reason: string },
 
         /** Custom conversion to and from JSON */
-        customSerialization?: { toJSON(params: P, obj?: B): any, fromJSON(data: any): P }
+        readonly customSerialization?: { toJSON(params: P, obj?: B): any, fromJSON(data: any): P }
     }
 
     const registry = new Map<Id, Transformer<any, any>>();
@@ -114,7 +123,8 @@ export namespace Transformer {
         }
 
         const t: Transformer<A, B, P> = {
-            apply(params, props) { return Transform.create<A, B, P>(t as any, params, props); },
+            apply(parent, params, props) { return Transform.create<A, B, P>(parent, t, params, props); },
+            toAction() { return StateAction.fromTransformer(t); },
             namespace,
             id,
             definition

+ 3 - 78
src/mol-state/tree.ts

@@ -4,82 +4,7 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { Transform } from './transform';
-import { ImmutableTree } from './util/immutable-tree';
-import { Transformer } from './transformer';
-import { StateObject } from './object';
+import { StateTree } from './tree/immutable';
+import { TransientTree } from './tree/transient';
 
-interface StateTree extends ImmutableTree<Transform> { }
-
-namespace StateTree {
-    export interface Transient extends ImmutableTree.Transient<Transform> { }
-    export interface Serialized extends ImmutableTree.Serialized { }
-
-    function _getRef(t: Transform) { return t.ref; }
-
-    export function create() {
-        return ImmutableTree.create<Transform>(Transform.createRoot('<:root:>'), _getRef);
-    }
-
-    export function updateParams<T extends Transformer = Transformer>(tree: StateTree, ref: Transform.Ref, params: Transformer.Params<T>): StateTree {
-        const t = tree.nodes.get(ref)!.value;
-        const newTransform = Transform.updateParams(t, params);
-        const newTree = ImmutableTree.asTransient(tree);
-        newTree.setValue(ref, newTransform);
-        return newTree.asImmutable();
-    }
-
-    export function toJSON(tree: StateTree) {
-        return ImmutableTree.toJSON(tree, Transform.toJSON) as Serialized;
-    }
-
-    export function fromJSON(data: Serialized): StateTree {
-        return ImmutableTree.fromJSON(data, _getRef, Transform.fromJSON);
-    }
-
-    export interface Builder {
-        getTree(): StateTree
-    }
-
-    export function build(tree: StateTree) {
-        return new Builder.Root(tree);
-    }
-
-    export namespace Builder {
-        interface State {
-            tree: StateTree.Transient
-        }
-
-        export class Root implements Builder {
-            private state: State;
-            to<A extends StateObject>(ref: Transform.Ref) { return new To<A>(this.state, ref, this); }
-            toRoot<A extends StateObject>() { return new To<A>(this.state, this.state.tree.rootRef as any, this); }
-            delete(ref: Transform.Ref) {
-                this.state.tree.remove(ref);
-                return this;
-            }
-            getTree(): StateTree { return this.state.tree.asImmutable(); }
-            constructor(tree: StateTree) { this.state = { tree: ImmutableTree.asTransient(tree) } }
-        }
-
-        export class To<A extends StateObject> implements Builder {
-            apply<T extends Transformer<A, any, any>>(tr: T, params?: Transformer.Params<T>, props?: Partial<Transform.Options>): To<Transformer.To<T>> {
-                const t = tr.apply(params, props);
-                this.state.tree.add(this.ref, t);
-                return new To(this.state, t.ref, this.root);
-            }
-
-            and() { return this.root; }
-
-            getTree(): StateTree { return this.state.tree.asImmutable(); }
-
-            constructor(private state: State, private ref: Transform.Ref, private root: Root) {
-                if (!this.state.tree.nodes.has(ref)) {
-                    throw new Error(`Could not find node '${ref}'.`);
-                }
-            }
-        }
-    }
-}
-
-export { StateTree }
+export { StateTree, TransientTree }

+ 93 - 0
src/mol-state/tree/builder.ts

@@ -0,0 +1,93 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { StateTree } from './immutable';
+import { TransientTree } from './transient';
+import { StateObject, StateObjectCell } from '../object';
+import { Transform } from '../transform';
+import { Transformer } from '../transformer';
+
+export { StateTreeBuilder }
+
+interface StateTreeBuilder {
+    readonly editInfo: StateTreeBuilder.EditInfo,
+    getTree(): StateTree
+}
+
+namespace StateTreeBuilder {
+    export interface EditInfo {
+        sourceTree: StateTree,
+        count: number,
+        lastUpdate?: Transform.Ref
+    }
+
+    interface State {
+        tree: TransientTree,
+        editInfo: EditInfo
+    }
+
+    export function is(obj: any): obj is StateTreeBuilder {
+        return !!obj && typeof (obj as StateTreeBuilder).getTree === 'function';
+    }
+
+    export class Root implements StateTreeBuilder {
+        private state: State;
+        get editInfo() { return this.state.editInfo; }
+
+        to<A extends StateObject>(ref: Transform.Ref) { return new To<A>(this.state, ref, this); }
+        toRoot<A extends StateObject>() { return new To<A>(this.state, this.state.tree.root.ref, this); }
+        delete(ref: Transform.Ref) {
+            this.editInfo.count++;
+            this.state.tree.remove(ref);
+            return this;
+        }
+        getTree(): StateTree { return this.state.tree.asImmutable(); }
+        constructor(tree: StateTree) { this.state = { tree: tree.asTransient(), editInfo: { sourceTree: tree, count: 0, lastUpdate: void 0 } } }
+    }
+
+    export class To<A extends StateObject> implements StateTreeBuilder {
+        get editInfo() { return this.state.editInfo; }
+
+        apply<T extends Transformer<A, any, any>>(tr: T, params?: Transformer.Params<T>, options?: Partial<Transform.Options>, initialCellState?: Partial<StateObjectCell.State>): To<Transformer.To<T>> {
+            const t = tr.apply(this.ref, params, options);
+            this.state.tree.add(t, initialCellState);
+            this.editInfo.count++;
+            this.editInfo.lastUpdate = t.ref;
+            return new To(this.state, t.ref, this.root);
+        }
+
+        update<T extends Transformer<A, any, any>>(transformer: T, params: (old: Transformer.Params<T>) => Transformer.Params<T>): Root
+        update(params: any): Root
+        update<T extends Transformer<A, any, any>>(paramsOrTransformer: T, provider?: (old: Transformer.Params<T>) => Transformer.Params<T>) {
+            let params: any;
+            if (provider) {
+                const old = this.state.tree.transforms.get(this.ref)!;
+                params = provider(old.params as any);
+            } else {
+                params = paramsOrTransformer;
+            }
+
+            if (this.state.tree.setParams(this.ref, params)) {
+                this.editInfo.count++;
+                this.editInfo.lastUpdate = this.ref;
+            }
+
+            return this.root;
+        }
+
+        to<A extends StateObject>(ref: Transform.Ref) { return this.root.to<A>(ref); }
+        toRoot<A extends StateObject>() { return this.root.toRoot<A>(); }
+        delete(ref: Transform.Ref) { return this.root.delete(ref); }
+
+        getTree(): StateTree { return this.state.tree.asImmutable(); }
+
+        constructor(private state: State, private ref: Transform.Ref, private root: Root) {
+            if (!this.state.tree.transforms.has(ref)) {
+                throw new Error(`Could not find node '${ref}'.`);
+            }
+        }
+    }
+}

+ 175 - 0
src/mol-state/tree/immutable.ts

@@ -0,0 +1,175 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Map as ImmutableMap, OrderedSet } from 'immutable';
+import { Transform } from '../transform';
+import { TransientTree } from './transient';
+import { StateTreeBuilder } from './builder';
+import { StateObjectCell } from 'mol-state/object';
+
+export { StateTree }
+
+/**
+ * An immutable tree where each node requires a unique reference.
+ * Represented as an immutable map.
+ */
+interface StateTree {
+    readonly root: Transform,
+    readonly transforms: StateTree.Transforms,
+    readonly children: StateTree.Children,
+    readonly cellStates: StateTree.CellStates,
+
+    asTransient(): TransientTree,
+    build(): StateTreeBuilder.Root
+}
+
+namespace StateTree {
+    type Ref = Transform.Ref
+
+    export interface ChildSet {
+        readonly size: number,
+        readonly values: OrderedSet<Ref>['values'],
+        has(ref: Ref): boolean,
+        readonly forEach: OrderedSet<Ref>['forEach'],
+        readonly map: OrderedSet<Ref>['map']
+    }
+
+    interface _Map<T> {
+        readonly size: number,
+        has(ref: Ref): boolean,
+        get(ref: Ref): T
+    }
+
+    export interface Transforms extends _Map<Transform> {}
+    export interface Children extends _Map<ChildSet> { }
+    export interface CellStates extends _Map<StateObjectCell.State> { }
+
+    class Impl implements StateTree {
+        get root() { return this.transforms.get(Transform.RootRef)! }
+
+        asTransient(): TransientTree {
+            return new TransientTree(this);
+        }
+
+        build(): StateTreeBuilder.Root {
+            return new StateTreeBuilder.Root(this);
+        }
+
+        constructor(public transforms: StateTree.Transforms, public children: Children, public cellStates: CellStates) {
+        }
+    }
+
+    /**
+     * Create an instance of an immutable tree.
+     */
+    export function createEmpty(): StateTree {
+        const root = Transform.createRoot();
+        return create(ImmutableMap([[root.ref, root]]), ImmutableMap([[root.ref, OrderedSet()]]), ImmutableMap([[root.ref, StateObjectCell.DefaultState]]));
+    }
+
+    export function create(nodes: Transforms, children: Children, cellStates: CellStates): StateTree {
+        return new Impl(nodes, children, cellStates);
+    }
+
+    type VisitorCtx = { tree: StateTree, state: any, f: (node: Transform, tree: StateTree, state: any) => boolean | undefined | void };
+
+    function _postOrderFunc(this: VisitorCtx, c: Ref | undefined) { _doPostOrder(this, this.tree.transforms.get(c!)!); }
+    function _doPostOrder(ctx: VisitorCtx, root: Transform) {
+        const children = ctx.tree.children.get(root.ref);
+        if (children && children.size) {
+            children.forEach(_postOrderFunc, ctx);
+        }
+        ctx.f(root, ctx.tree, ctx.state);
+    }
+
+    /**
+     * Visit all nodes in a subtree in "post order", meaning leafs get visited first.
+     */
+    export function doPostOrder<S>(tree: StateTree, root: Transform, state: S, f: (node: Transform, tree: StateTree, state: S) => boolean | undefined | void): S {
+        const ctx: VisitorCtx = { tree, state, f };
+        _doPostOrder(ctx, root);
+        return ctx.state;
+    }
+
+    function _preOrderFunc(this: VisitorCtx, c: Ref | undefined) { _doPreOrder(this, this.tree.transforms.get(c!)!); }
+    function _doPreOrder(ctx: VisitorCtx, root: Transform) {
+        const ret = ctx.f(root, ctx.tree, ctx.state);
+        if (typeof ret === 'boolean' && !ret) return;
+        const children = ctx.tree.children.get(root.ref);
+        if (children && children.size) {
+            children.forEach(_preOrderFunc, ctx);
+        }
+    }
+
+    /**
+     * Visit all nodes in a subtree in "pre order", meaning leafs get visited last.
+     * If the visitor function returns false, the visiting for that branch is interrupted.
+     */
+    export function doPreOrder<S>(tree: StateTree, root: Transform, state: S, f: (node: Transform, tree: StateTree, state: S) => boolean | undefined | void): S {
+        const ctx: VisitorCtx = { tree, state, f };
+        _doPreOrder(ctx, root);
+        return ctx.state;
+    }
+
+    function _subtree(n: Transform, _: any, subtree: Transform[]) { subtree.push(n); }
+    /**
+     * Get all nodes in a subtree, leafs come first.
+     */
+    export function subtreePostOrder<T>(tree: StateTree, root: Transform) {
+        return doPostOrder<Transform[]>(tree, root, [], _subtree);
+    }
+
+    function _visitNodeToJson(node: Transform, tree: StateTree, ctx: [Transform.Serialized, StateObjectCell.State][]) {
+        // const children: Ref[] = [];
+        // tree.children.get(node.ref).forEach(_visitChildToJson as any, children);
+        ctx.push([Transform.toJSON(node), tree.cellStates.get(node.ref)]);
+    }
+
+    export interface Serialized {
+        /** Transforms serialized in pre-order */
+        transforms: [Transform.Serialized, StateObjectCell.State][]
+    }
+
+    export function toJSON<T>(tree: StateTree): Serialized {
+        const transforms: [Transform.Serialized, StateObjectCell.State][] = [];
+        doPreOrder(tree, tree.root, transforms, _visitNodeToJson);
+        return { transforms };
+    }
+
+    export function fromJSON<T>(data: Serialized): StateTree {
+        const nodes = ImmutableMap<Ref, Transform>().asMutable();
+        const children = ImmutableMap<Ref, OrderedSet<Ref>>().asMutable();
+        const cellStates = ImmutableMap<Ref, StateObjectCell.State>().asMutable();
+
+        for (const t of data.transforms) {
+            const transform = Transform.fromJSON(t[0]);
+            nodes.set(transform.ref, transform);
+            cellStates.set(transform.ref, t[1]);
+
+            if (!children.has(transform.ref)) {
+                children.set(transform.ref, OrderedSet<Ref>().asMutable());
+            }
+
+            if (transform.ref !== transform.parent) children.get(transform.parent).add(transform.ref);
+        }
+
+        for (const t of data.transforms) {
+            const ref = t[0].ref;
+            children.set(ref, children.get(ref).asImmutable());
+        }
+
+        return create(nodes.asImmutable(), children.asImmutable(), cellStates.asImmutable());
+    }
+
+    export function dump(tree: StateTree) {
+        console.log({
+            tr: (tree.transforms as ImmutableMap<any, any>).keySeq().toArray(),
+            tr1: (tree.transforms as ImmutableMap<any, any>).valueSeq().toArray().map(t => t.ref),
+            ch: (tree.children as ImmutableMap<any, any>).keySeq().toArray(),
+            cs: (tree.cellStates as ImmutableMap<any, any>).keySeq().toArray()
+        });
+    }
+}

+ 227 - 0
src/mol-state/tree/transient.ts

@@ -0,0 +1,227 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { Map as ImmutableMap, OrderedSet } from 'immutable';
+import { Transform } from '../transform';
+import { StateTree } from './immutable';
+import { StateTreeBuilder } from './builder';
+import { StateObjectCell } from 'mol-state/object';
+import { shallowEqual } from 'mol-util/object';
+
+export { TransientTree }
+
+class TransientTree implements StateTree {
+    transforms = this.tree.transforms as ImmutableMap<Transform.Ref, Transform>;
+    children = this.tree.children as ImmutableMap<Transform.Ref, OrderedSet<Transform.Ref>>;
+    cellStates = this.tree.cellStates as ImmutableMap<Transform.Ref, StateObjectCell.State>;
+
+    private changedNodes = false;
+    private changedChildren = false;
+    private changedStates = false;
+
+    private _childMutations: Map<Transform.Ref, OrderedSet<Transform.Ref>> | undefined = void 0;
+
+    private get childMutations() {
+        if (this._childMutations) return this._childMutations;
+        this._childMutations = new Map();
+        return this._childMutations;
+    }
+
+    private changeStates() {
+        if (this.changedStates) return;
+        this.changedStates = true;
+        this.cellStates = this.cellStates.asMutable();
+    }
+
+    private changeNodes() {
+        if (this.changedNodes) return;
+        this.changedNodes = true;
+        this.transforms = this.transforms.asMutable();
+    }
+
+    private changeChildren() {
+        if (this.changedChildren) return;
+        this.changedChildren = true;
+        this.children = this.children.asMutable();
+    }
+
+    get root() { return this.transforms.get(Transform.RootRef)! }
+
+    build(): StateTreeBuilder.Root {
+        return new StateTreeBuilder.Root(this);
+    }
+
+    asTransient() {
+        return this.asImmutable().asTransient();
+    }
+
+    private addChild(parent: Transform.Ref, child: Transform.Ref) {
+        this.changeChildren();
+
+        if (this.childMutations.has(parent)) {
+            this.childMutations.get(parent)!.add(child);
+        } else {
+            const set = (this.children.get(parent) as OrderedSet<Transform.Ref>).asMutable();
+            set.add(child);
+            this.children.set(parent, set);
+            this.childMutations.set(parent, set);
+        }
+    }
+
+    private removeChild(parent: Transform.Ref, child: Transform.Ref) {
+        this.changeChildren();
+
+        if (this.childMutations.has(parent)) {
+            this.childMutations.get(parent)!.remove(child);
+        } else {
+            const set = (this.children.get(parent) as OrderedSet<Transform.Ref>).asMutable();
+            set.remove(child);
+            this.children.set(parent, set);
+            this.childMutations.set(parent, set);
+        }
+    }
+
+    private clearRoot() {
+        const parent = Transform.RootRef;
+        if (this.children.get(parent).size === 0) return;
+
+        this.changeChildren();
+
+        const set = OrderedSet<Transform.Ref>();
+        this.children.set(parent, set);
+        this.childMutations.set(parent, set);
+    }
+
+    add(transform: Transform, initialState?: Partial<StateObjectCell.State>) {
+        const ref = transform.ref;
+
+        if (this.transforms.has(transform.ref)) {
+            const node = this.transforms.get(transform.ref);
+            if (node.parent !== transform.parent) alreadyPresent(transform.ref);
+        }
+
+        const children = this.children.get(transform.parent);
+        if (!children) parentNotPresent(transform.parent);
+
+        if (!children.has(transform.ref)) {
+            this.addChild(transform.parent, transform.ref);
+        }
+
+        if (!this.children.has(transform.ref)) {
+            if (!this.changedChildren) {
+                this.changedChildren = true;
+                this.children = this.children.asMutable();
+            }
+            this.children.set(transform.ref, OrderedSet());
+        }
+
+        this.changeNodes();
+        this.transforms.set(ref, transform);
+
+        if (!this.cellStates.has(ref)) {
+            this.changeStates();
+            if (StateObjectCell.isStateChange(StateObjectCell.DefaultState, initialState)) {
+                this.cellStates.set(ref, { ...StateObjectCell.DefaultState, ...initialState });
+            } else {
+                this.cellStates.set(ref, StateObjectCell.DefaultState);
+            }
+        }
+
+        return this;
+    }
+
+    /** Calls Transform.definition.params.areEqual if available, otherwise uses shallowEqual to check if the params changed */
+    setParams(ref: Transform.Ref, params: unknown) {
+        ensurePresent(this.transforms, ref);
+
+        const transform = this.transforms.get(ref)!;
+        const def = transform.transformer.definition;
+        if (def.params && def.params.areEqual) {
+            if (def.params.areEqual(transform.params, params)) return false;
+        } else {
+            if (shallowEqual(transform.params, params)) {
+                return false;
+            }
+        }
+
+        if (!this.changedNodes) {
+            this.changedNodes = true;
+            this.transforms = this.transforms.asMutable();
+        }
+
+        this.transforms.set(transform.ref, Transform.withParams(transform, params));
+        return true;
+    }
+
+    updateCellState(ref: Transform.Ref, state: Partial<StateObjectCell.State>) {
+        ensurePresent(this.transforms, ref);
+
+        const old = this.cellStates.get(ref);
+        if (!StateObjectCell.isStateChange(old, state)) return false;
+
+        this.changeStates();
+        this.cellStates.set(ref, { ...old, ...state });
+
+        return true;
+    }
+
+    remove(ref: Transform.Ref): Transform[] {
+        const node = this.transforms.get(ref);
+        if (!node) return [];
+
+        const st = StateTree.subtreePostOrder(this, node);
+        if (ref === Transform.RootRef) {
+            st.pop();
+            if (st.length === 0) return st;
+            this.clearRoot();
+        } else {
+            if (st.length === 0) return st;
+            this.removeChild(node.parent, node.ref);
+        }
+
+        this.changeNodes();
+        this.changeChildren();
+        this.changeStates();
+
+        for (const n of st) {
+            this.transforms.delete(n.ref);
+            this.children.delete(n.ref);
+            this.cellStates.delete(n.ref);
+            if (this._childMutations) this._childMutations.delete(n.ref);
+        }
+
+        return st;
+    }
+
+    asImmutable() {
+        if (!this.changedNodes && !this.changedChildren && !this.changedStates && !this._childMutations) return this.tree;
+        if (this._childMutations) this._childMutations.forEach(fixChildMutations, this.children);
+        return StateTree.create(
+            this.changedNodes ? this.transforms.asImmutable() : this.transforms,
+            this.changedChildren ? this.children.asImmutable() : this.children,
+            this.changedStates ? this.cellStates.asImmutable() : this.cellStates);
+    }
+
+    constructor(private tree: StateTree) {
+
+    }
+}
+
+function fixChildMutations(this: ImmutableMap<Transform.Ref, OrderedSet<Transform.Ref>>, m: OrderedSet<Transform.Ref>, k: Transform.Ref) { this.set(k, m.asImmutable()); }
+
+function alreadyPresent(ref: Transform.Ref) {
+    throw new Error(`Transform '${ref}' is already present in the tree.`);
+}
+
+function parentNotPresent(ref: Transform.Ref) {
+    throw new Error(`Parent '${ref}' must be present in the tree.`);
+}
+
+function ensurePresent(nodes: StateTree.Transforms, ref: Transform.Ref) {
+    if (!nodes.has(ref)) {
+        throw new Error(`Node '${ref}' is not present in the tree.`);
+    }
+}

+ 0 - 262
src/mol-state/util/immutable-tree.ts

@@ -1,262 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import { Map as ImmutableMap, OrderedSet } from 'immutable';
-
-/**
- * An immutable tree where each node requires a unique reference.
- * Represented as an immutable map.
- */
-export interface ImmutableTree<T> {
-    readonly rootRef: ImmutableTree.Ref,
-    readonly version: number,
-    readonly nodes: ImmutableTree.Nodes<T>,
-    getRef(e: T): ImmutableTree.Ref,
-    getValue(ref: ImmutableTree.Ref): T | undefined
-}
-
-export namespace ImmutableTree {
-    export type Ref = string
-    export interface MutableNode<T> { ref: ImmutableTree.Ref, value: T, version: number, parent: ImmutableTree.Ref, children: OrderedSet<ImmutableTree.Ref> }
-    export interface Node<T> extends Readonly<MutableNode<T>> { }
-    export interface Nodes<T> extends ImmutableMap<ImmutableTree.Ref, Node<T>> { }
-
-    class Impl<T> implements ImmutableTree<T> {
-        readonly rootRef: ImmutableTree.Ref;
-        readonly version: number;
-        readonly nodes: ImmutableTree.Nodes<T>;
-        readonly getRef: (e: T) => ImmutableTree.Ref;
-
-        getValue(ref: Ref) {
-            const n = this.nodes.get(ref);
-            return n ? n.value : void 0;
-        }
-
-        constructor(rootRef: ImmutableTree.Ref, nodes: ImmutableTree.Nodes<T>, getRef: (e: T) => ImmutableTree.Ref, version: number) {
-            this.rootRef = rootRef;
-            this.nodes = nodes;
-            this.getRef = getRef;
-            this.version = version;
-        }
-    }
-
-    /**
-     * Create an instance of an immutable tree.
-     */
-    export function create<T>(root: T, getRef: (t: T) => ImmutableTree.Ref): ImmutableTree<T> {
-        const ref = getRef(root);
-        const r: Node<T> = { ref, value: root, version: 0, parent: ref, children: OrderedSet() };
-        return new Impl(ref, ImmutableMap([[ref, r]]), getRef, 0);
-    }
-
-    export function asTransient<T>(tree: ImmutableTree<T>) {
-        return new Transient(tree);
-    }
-
-    type N = Node<any>
-    type Ns = Nodes<any>
-
-    type VisitorCtx = { nodes: Ns, state: any, f: (node: N, nodes: Ns, state: any) => boolean | undefined | void };
-
-    function _postOrderFunc(this: VisitorCtx, c: ImmutableTree.Ref | undefined) { _doPostOrder(this, this.nodes.get(c!)!); }
-    function _doPostOrder(ctx: VisitorCtx, root: N) {
-        if (root.children.size) {
-            root.children.forEach(_postOrderFunc, ctx);
-        }
-        ctx.f(root, ctx.nodes, ctx.state);
-    }
-
-    /**
-     * Visit all nodes in a subtree in "post order", meaning leafs get visited first.
-     */
-    export function doPostOrder<T, S>(tree: ImmutableTree<T>, root: Node<T>, state: S, f: (node: Node<T>, nodes: Nodes<T>, state: S) => boolean | undefined | void) {
-        const ctx: VisitorCtx = { nodes: tree.nodes, state, f };
-        _doPostOrder(ctx, root);
-        return ctx.state;
-    }
-
-    function _preOrderFunc(this: VisitorCtx, c: ImmutableTree.Ref | undefined) { _doPreOrder(this, this.nodes.get(c!)!); }
-    function _doPreOrder(ctx: VisitorCtx, root: N) {
-        const ret = ctx.f(root, ctx.nodes, ctx.state);
-        if (typeof ret === 'boolean' && !ret) return;
-        if (root.children.size) {
-            root.children.forEach(_preOrderFunc, ctx);
-        }
-    }
-
-    /**
-     * Visit all nodes in a subtree in "pre order", meaning leafs get visited last.
-     * If the visitor function returns false, the visiting for that branch is interrupted.
-     */
-    export function doPreOrder<T, S>(tree: ImmutableTree<T>, root: Node<T>, state: S, f: (node: Node<T>, nodes: Nodes<T>, state: S) => boolean | undefined | void) {
-        const ctx: VisitorCtx = { nodes: tree.nodes, state, f };
-        _doPreOrder(ctx, root);
-        return ctx.state;
-    }
-
-    function _subtree(n: N, nodes: Ns, subtree: N[]) { subtree.push(n); }
-    /**
-     * Get all nodes in a subtree, leafs come first.
-     */
-    export function subtreePostOrder<T>(tree: ImmutableTree<T>, root: Node<T>) {
-        return doPostOrder<T, Node<T>[]>(tree, root, [], _subtree);
-    }
-
-
-    function _visitChildToJson(this: Ref[], ref: Ref) { this.push(ref); }
-    interface ToJsonCtx { nodes: Ref[], parent: any, children: any, values: any, valueToJSON: (v: any) => any }
-    function _visitNodeToJson(this: ToJsonCtx, node: Node<any>) {
-        this.nodes.push(node.ref);
-        const children: Ref[] = [];
-        node.children.forEach(_visitChildToJson as any, children);
-        this.parent[node.ref] = node.parent;
-        this.children[node.ref] = children;
-        this.values[node.ref] = this.valueToJSON(node.value);
-    }
-
-    export interface Serialized {
-        root: Ref,
-        nodes: Ref[],
-        parent: { [key: string]: string },
-        children: { [key: string]: any },
-        values: { [key: string]: any }
-    }
-
-    export function toJSON<T>(tree: ImmutableTree<T>, valueToJSON: (v: T) => any): Serialized {
-        const ctx: ToJsonCtx = { nodes: [], parent: { }, children: {}, values: {}, valueToJSON };
-        tree.nodes.forEach(_visitNodeToJson as any, ctx);
-        return {
-            root: tree.rootRef,
-            nodes: ctx.nodes,
-            parent: ctx.parent,
-            children: ctx.children,
-            values: ctx.values
-        };
-    }
-
-    export function fromJSON<T>(data: Serialized, getRef: (v: T) => Ref, valueFromJSON: (v: any) => T): ImmutableTree<T> {
-        const nodes = ImmutableMap<ImmutableTree.Ref, Node<T>>().asMutable();
-        for (const ref of data.nodes) {
-            nodes.set(ref, {
-                ref,
-                value: valueFromJSON(data.values[ref]),
-                version: 0,
-                parent: data.parent[ref],
-                children: OrderedSet(data.children[ref])
-            });
-        }
-        return new Impl(data.root, nodes.asImmutable(), getRef, 0);
-    }
-
-    function checkSetRef(oldRef: ImmutableTree.Ref, newRef: ImmutableTree.Ref) {
-        if (oldRef !== newRef) {
-            throw new Error(`Cannot setValue of node '${oldRef}' because the new value has a different ref '${newRef}'.`);
-        }
-    }
-
-    function ensureNotPresent(nodes: Ns, ref: ImmutableTree.Ref) {
-        if (nodes.has(ref)) {
-            throw new Error(`Cannot add node '${ref}' because a different node with this ref already present in the tree.`);
-        }
-    }
-
-    function ensurePresent(nodes: Ns, ref: ImmutableTree.Ref) {
-        if (!nodes.has(ref)) {
-            throw new Error(`Node '${ref}' is not present in the tree.`);
-        }
-    }
-
-    function mutateNode(nodes: Ns, mutations: Map<ImmutableTree.Ref, N>, ref: ImmutableTree.Ref): N {
-        ensurePresent(nodes, ref);
-        if (mutations.has(ref)) {
-            return mutations.get(ref)!;
-        }
-        const node = nodes.get(ref)!;
-        const newNode: N = { ref: node.ref, value: node.value, version: node.version + 1, parent: node.parent, children: node.children.asMutable() };
-        mutations.set(ref, newNode);
-        nodes.set(ref, newNode);
-        return newNode;
-    }
-
-    export class Transient<T> implements ImmutableTree<T> {
-        nodes = this.tree.nodes.asMutable();
-        version: number = this.tree.version + 1;
-        private mutations: Map<ImmutableTree.Ref, Node<T>> = new Map();
-
-        mutate(ref: ImmutableTree.Ref): MutableNode<T> {
-            return mutateNode(this.nodes, this.mutations, ref);
-        }
-
-        get rootRef() { return this.tree.rootRef; }
-        getRef(e: T) {
-            return this.tree.getRef(e);
-        }
-
-        getValue(ref: Ref) {
-            const n = this.nodes.get(ref);
-            return n ? n.value : void 0;
-        }
-
-        add(parentRef: ImmutableTree.Ref, value: T) {
-            const ref = this.getRef(value);
-            ensureNotPresent(this.nodes, ref);
-            const parent = this.mutate(parentRef);
-            const node: Node<T> = { ref, version: 0, value, parent: parent.ref, children: OrderedSet<string>().asMutable() };
-            this.mutations.set(ref, node);
-            parent.children.add(ref);
-            this.nodes.set(ref, node);
-            return node;
-        }
-
-        setValue(ref: ImmutableTree.Ref, value: T): Node<T> {
-            checkSetRef(ref, this.getRef(value));
-            const node = this.mutate(ref);
-            node.value = value;
-            return node;
-        }
-
-        remove<T>(ref: ImmutableTree.Ref): Node<T>[] {
-            const { nodes, mutations } = this;
-            const node = nodes.get(ref);
-            if (!node) return [];
-            const parent = nodes.get(node.parent)!;
-            const children = this.mutate(parent.ref).children;
-            const st = subtreePostOrder(this, node);
-            if (ref !== this.rootRef) children.delete(ref);
-            for (const n of st) {
-                nodes.delete(n.value.ref);
-                mutations.delete(n.value.ref);
-            }
-            return st;
-        }
-
-        removeChildren(ref: ImmutableTree.Ref): Node<T>[] {
-            const { nodes, mutations } = this;
-            let node = nodes.get(ref);
-            if (!node || !node.children.size) return [];
-            node = this.mutate(ref);
-            const st = subtreePostOrder(this, node);
-            node.children.clear();
-            for (const n of st) {
-                if (n === node) continue;
-                nodes.delete(n.value.ref);
-                mutations.delete(n.value.ref);
-            }
-            return st;
-        }
-
-        asImmutable() {
-            if (this.mutations.size === 0) return this.tree;
-
-            this.mutations.forEach(m => (m as MutableNode<T>).children = m.children.asImmutable());
-            return new Impl<T>(this.tree.rootRef, this.nodes.asImmutable(), this.tree.getRef, this.version);
-        }
-
-        constructor(private tree: ImmutableTree<T>) {
-
-        }
-    }
-}

+ 1 - 1
src/mol-task/execution/observable.ts

@@ -7,7 +7,7 @@
 import { Task } from '../task'
 import { RuntimeContext } from './runtime-context'
 import { Progress } from './progress'
-import { now } from '../util/now'
+import { now } from 'mol-util/now';
 import { Scheduler } from '../util/scheduler'
 import { UserTiming } from '../util/user-timing'
 

+ 1 - 2
src/mol-task/index.ts

@@ -7,9 +7,8 @@
 import { Task } from './task'
 import { RuntimeContext } from './execution/runtime-context'
 import { Progress } from './execution/progress'
-import { now } from './util/now'
 import { Scheduler } from './util/scheduler'
 import { MultistepTask } from './util/multistep'
 import { chunkedSubtask } from './util/chunked'
 
-export { Task, RuntimeContext, Progress, now, Scheduler, MultistepTask, chunkedSubtask }
+export { Task, RuntimeContext, Progress, Scheduler, MultistepTask, chunkedSubtask }

+ 1 - 1
src/mol-task/util/chunked.ts

@@ -4,7 +4,7 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { now } from './now'
+import { now } from 'mol-util/now';
 import { RuntimeContext } from '../execution/runtime-context'
 
 type UniformlyChunkedFn<S> = (chunkSize: number, state: S) => number

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

@@ -141,6 +141,8 @@ interface InputObserver {
     pinch: Subject<PinchInput>,
     click: Subject<ClickInput>,
     move: Subject<MoveInput>,
+    leave: Subject<undefined>,
+    enter: Subject<undefined>,
     resize: Subject<ResizeInput>,
 
     dispose: () => void
@@ -175,6 +177,8 @@ namespace InputObserver {
         const wheel = new Subject<WheelInput>()
         const pinch = new Subject<PinchInput>()
         const resize = new Subject<ResizeInput>()
+        const leave = new Subject<undefined>()
+        const enter = new Subject<undefined>()
 
         attach()
 
@@ -189,6 +193,8 @@ namespace InputObserver {
             pinch,
             click,
             move,
+            leave,
+            enter,
             resize,
 
             dispose
@@ -204,6 +210,9 @@ namespace InputObserver {
             window.addEventListener('mousemove', onMouseMove as any, false)
             window.addEventListener('mouseup', onMouseUp as any, false)
 
+            element.addEventListener('mouseenter', onMouseEnter as any, false)
+            element.addEventListener('mouseleave', onMouseLeave as any, false)
+
             element.addEventListener('touchstart', onTouchStart as any, false)
             element.addEventListener('touchmove', onTouchMove as any, false)
             element.addEventListener('touchend', onTouchEnd as any, false)
@@ -227,6 +236,9 @@ namespace InputObserver {
             window.removeEventListener('mousemove', onMouseMove as any, false)
             window.removeEventListener('mouseup', onMouseUp as any, false)
 
+            element.removeEventListener('mouseenter', onMouseEnter as any, false)
+            element.removeEventListener('mouseleave', onMouseLeave as any, false)
+
             element.removeEventListener('touchstart', onTouchStart as any, false)
             element.removeEventListener('touchmove', onTouchMove as any, false)
             element.removeEventListener('touchend', onTouchEnd as any, false)
@@ -386,6 +398,14 @@ namespace InputObserver {
             }
         }
 
+        function onMouseEnter (ev: Event) {
+            enter.next();
+        }
+
+        function onMouseLeave (ev: Event) {
+            leave.next();
+        }
+
         function onResize (ev: Event) {
             resize.next()
         }

+ 22 - 0
src/mol-util/log-entry.ts

@@ -0,0 +1,22 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+export { LogEntry }
+
+interface LogEntry {
+    type: LogEntry.Type,
+    timestamp: Date,
+    message: string
+}
+
+namespace LogEntry {
+    export type Type = 'message' | 'error' | 'warning' | 'info'
+
+    export function message(msg: string): LogEntry { return { type: 'message', timestamp: new Date(), message: msg }; }
+    export function error(msg: string): LogEntry { return { type: 'error', timestamp: new Date(), message: msg }; }
+    export function warning(msg: string): LogEntry { return { type: 'warning', timestamp: new Date(), message: msg }; }
+    export function info(msg: string): LogEntry { return { type: 'info', timestamp: new Date(), message: msg }; }
+}

+ 24 - 0
src/mol-util/memoize.ts

@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+export function memoizeOne<Args extends any[], T>(f: (...args: Args) => T): (...args: Args) => T {
+    let lastArgs: any[] | undefined = void 0, value: any = void 0;
+    return (...args) => {
+        if (!lastArgs || lastArgs.length !== args.length) {
+            lastArgs = args;
+            value = f.apply(void 0, args);
+            return value;
+        }
+        for (let i = 0, _i = args.length; i < _i; i++) {
+            if (args[i] !== lastArgs[i]) {
+                lastArgs = args;
+                value = f.apply(void 0, args);
+                return value;
+            }
+        }
+        return value;
+    }
+}

+ 23 - 2
src/mol-task/util/now.ts → src/mol-util/now.ts

@@ -7,7 +7,7 @@
 declare var process: any;
 declare var window: any;
 
-const now: () => number = (function () {
+const now: () => now.Timestamp = (function () {
     if (typeof window !== 'undefined' && window.performance) {
         const perf = window.performance;
         return () => perf.now();
@@ -23,4 +23,25 @@ const now: () => number = (function () {
     }
 }());
 
-export { now }
+namespace now {
+    export type Timestamp = number & { '@type': 'now-timestamp' }
+}
+
+
+function formatTimespan(t: number) {
+    if (isNaN(t)) return 'n/a';
+
+    let h = Math.floor(t / (60 * 60 * 1000)),
+        m = Math.floor(t / (60 * 1000) % 60),
+        s = Math.floor(t / 1000 % 60),
+        ms = Math.floor(t % 1000).toString();
+
+    while (ms.length < 3) ms = '0' + ms;
+
+    if (h > 0) return `${h}h${m}m${s}.${ms}s`;
+    if (m > 0) return `${m}m${s}.${ms}s`;
+    if (s > 0) return `${s}.${ms}s`;
+    return `${t.toFixed(0)}ms`;
+}
+
+export { now, formatTimespan }

+ 1 - 1
src/mol-util/performance-monitor.ts

@@ -5,7 +5,7 @@
  * Copyright (c) 2016 - now David Sehnal, licensed under Apache 2.0, See LICENSE file for more info.
  */
 
-import { now } from 'mol-task/util/now'
+import { now } from 'mol-util/now';
 
 export class PerformanceMonitor {
     private starts = new Map<string, number>();

+ 13 - 2
src/mol-util/uuid.ts

@@ -4,12 +4,23 @@
  * @author David Sehnal <david.sehnal@gmail.com>
  */
 
-import { now } from 'mol-task'
+import { now } from 'mol-util/now';
 
 type UUID = string & { '@type': 'uuid' }
 
 namespace UUID {
-    export function create(): UUID {
+    const chars: string[] = [];
+    /** Creates 22 characted "base64" UUID */
+    export function create22(): UUID {
+        let d = (+new Date()) + now();
+        for (let i = 0; i < 16; i++) {
+            chars[i] = String.fromCharCode((d + Math.random()*0xff)%0xff | 0);
+            d = Math.floor(d/0xff);
+        }
+        return btoa(chars.join('')).replace(/\+/g, '-').replace(/\//g, '_').substr(0, 22) as UUID;
+    }
+
+    export function createv4(): UUID {
         let d = (+new Date()) + now();
         const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
             const r = (d + Math.random()*16)%16 | 0;

+ 133 - 135
src/perf-tests/state.ts

@@ -1,135 +1,133 @@
-import { State, StateObject, StateTree, Transformer, StateSelection } from 'mol-state';
-import { Task } from 'mol-task';
-import * as util from 'util';
-
-export type TypeClass = 'root' | 'shape' | 'prop'
-export interface ObjProps { label: string }
-export interface TypeInfo { name: string, class: TypeClass }
-
-const _obj = StateObject.factory<TypeInfo, ObjProps>()
-const _transform = Transformer.factory('test');
-
-export class Root extends _obj({ name: 'Root', class: 'root' }) { }
-export class Square extends _obj<{ a: number }>({ name: 'Square', class: 'shape' }) { }
-export class Circle extends _obj<{ r: number }>({ name: 'Circle', class: 'shape' }) { }
-export class Area extends _obj<{ volume: number }>({ name: 'Area', class: 'prop' }) { }
-
-export const CreateSquare = _transform<Root, Square, { a: number }>({
-    name: 'create-square',
-    from: [Root],
-    to: [Square],
-    apply({ params: p }) {
-        return new Square({ label: `Square a=${p.a}` }, p);
-    },
-    update({ b, newParams: p }) {
-        b.props.label = `Square a=${p.a}`
-        b.data.a = p.a;
-        return Transformer.UpdateResult.Updated;
-    }
-});
-
-export const CreateCircle = _transform<Root, Circle, { r: number }>({
-    name: 'create-circle',
-    from: [Root],
-    to: [Square],
-    apply({ params: p }) {
-        return new Circle({ label: `Circle r=${p.r}` }, p);
-    },
-    update({ b, newParams: p }) {
-        b.props.label = `Circle r=${p.r}`
-        b.data.r = p.r;
-        return Transformer.UpdateResult.Updated;
-    }
-});
-
-export const CaclArea = _transform<Square | Circle, Area, {}>({
-    name: 'calc-area',
-    from: [Square, Circle],
-    to: [Area],
-    apply({ a }) {
-        if (a instanceof Square) return new Area({ label: 'Area' }, { volume: a.data.a * a.data.a });
-        else if (a instanceof Circle) return new Area({ label: 'Area' }, { volume: a.data.r * a.data.r * Math.PI });
-        throw new Error('Unknown object type.');
-    },
-    update({ a, b }) {
-        if (a instanceof Square) b.data.volume = a.data.a * a.data.a;
-        else if (a instanceof Circle) b.data.volume = a.data.r * a.data.r * Math.PI;
-        else throw new Error('Unknown object type.');
-        return Transformer.UpdateResult.Updated;
-    }
-});
-
-export async function runTask<A>(t: A | Task<A>): Promise<A> {
-    if ((t as any).run) return await (t as Task<A>).run();
-    return t as A;
-}
-
-function hookEvents(state: State) {
-    state.context.events.object.created.subscribe(e => console.log('created:', e.ref));
-    state.context.events.object.removed.subscribe(e => console.log('removed:', e.ref));
-    state.context.events.object.replaced.subscribe(e => console.log('replaced:', e.ref));
-    state.context.events.object.stateChanged.subscribe(e => console.log('stateChanged:', e.ref,
-        StateObject.StateType[state.objects.get(e.ref)!.state]));
-    state.context.events.object.updated.subscribe(e => console.log('updated:', e.ref));
-}
-
-export async function testState() {
-    const state = State.create(new Root({ label: 'Root' }, { }));
-    hookEvents(state);
-
-    const tree = state.tree;
-    const builder = StateTree.build(tree);
-    builder.toRoot<Root>()
-        .apply(CreateSquare, { a: 10 }, { ref: 'square' })
-        .apply(CaclArea);
-    const tree1 = builder.getTree();
-
-    printTTree(tree1);
-
-    const tree2 = StateTree.updateParams<typeof CreateSquare>(tree1, 'square', { a: 15 });
-    printTTree(tree1);
-    printTTree(tree2);
-
-    await state.update(tree1).run();
-    console.log('----------------');
-    console.log(util.inspect(state.objects, true, 3, true));
-
-    console.log('----------------');
-    const jsonString = JSON.stringify(StateTree.toJSON(tree2), null, 2);
-    const jsonData = JSON.parse(jsonString);
-    printTTree(tree2);
-    console.log(jsonString);
-    const treeFromJson = StateTree.fromJSON(jsonData);
-    printTTree(treeFromJson);
-
-    console.log('----------------');
-    await state.update(treeFromJson).run();
-    console.log(util.inspect(state.objects, true, 3, true));
-
-    console.log('----------------');
-
-    const q = StateSelection.byRef('square').parent();
-    const sel = StateSelection.select(q, state);
-    console.log(sel);
-}
-
-testState();
-
-
-//test();
-
-export function printTTree(tree: StateTree) {
-    let lines: string[] = [];
-    function print(offset: string, ref: any) {
-        const t = tree.nodes.get(ref)!;
-        const tr = t.value;
-
-        const name = tr.transformer.id;
-        lines.push(`${offset}|_ (${ref}) ${name} ${tr.params ? JSON.stringify(tr.params) : ''}, v${t.value.version}`);
-        offset += '   ';
-
-        t.children.forEach(c => print(offset, c!));
-    }
-    print('', tree.rootRef);
-    console.log(lines.join('\n'));
-}
+// import { State, StateObject, StateTree, Transformer } from 'mol-state';
+// import { Task } from 'mol-task';
+// import * as util from 'util';
+
+// export type TypeClass = 'root' | 'shape' | 'prop'
+// export interface ObjProps { label: string }
+// export interface TypeInfo { name: string, class: TypeClass }
+
+// const _obj = StateObject.factory<TypeInfo, ObjProps>()
+// const _transform = Transformer.factory('test');
+
+// export class Root extends _obj({ name: 'Root', class: 'root' }) { }
+// export class Square extends _obj<{ a: number }>({ name: 'Square', class: 'shape' }) { }
+// export class Circle extends _obj<{ r: number }>({ name: 'Circle', class: 'shape' }) { }
+// export class Area extends _obj<{ volume: number }>({ name: 'Area', class: 'prop' }) { }
+
+// export const CreateSquare = _transform<Root, Square, { a: number }>({
+//     name: 'create-square',
+//     from: [Root],
+//     to: [Square],
+//     apply({ params: p }) {
+//         return new Square({ label: `Square a=${p.a}` }, p);
+//     },
+//     update({ b, newParams: p }) {
+//         b.props.label = `Square a=${p.a}`
+//         b.data.a = p.a;
+//         return Transformer.UpdateResult.Updated;
+//     }
+// });
+
+// export const CreateCircle = _transform<Root, Circle, { r: number }>({
+//     name: 'create-circle',
+//     from: [Root],
+//     to: [Square],
+//     apply({ params: p }) {
+//         return new Circle({ label: `Circle r=${p.r}` }, p);
+//     },
+//     update({ b, newParams: p }) {
+//         b.props.label = `Circle r=${p.r}`
+//         b.data.r = p.r;
+//         return Transformer.UpdateResult.Updated;
+//     }
+// });
+
+// export const CaclArea = _transform<Square | Circle, Area, {}>({
+//     name: 'calc-area',
+//     from: [Square, Circle],
+//     to: [Area],
+//     apply({ a }) {
+//         if (a instanceof Square) return new Area({ label: 'Area' }, { volume: a.data.a * a.data.a });
+//         else if (a instanceof Circle) return new Area({ label: 'Area' }, { volume: a.data.r * a.data.r * Math.PI });
+//         throw new Error('Unknown object type.');
+//     },
+//     update({ a, b }) {
+//         if (a instanceof Square) b.data.volume = a.data.a * a.data.a;
+//         else if (a instanceof Circle) b.data.volume = a.data.r * a.data.r * Math.PI;
+//         else throw new Error('Unknown object type.');
+//         return Transformer.UpdateResult.Updated;
+//     }
+// });
+
+// export async function runTask<A>(t: A | Task<A>): Promise<A> {
+//     if ((t as any).run) return await (t as Task<A>).run();
+//     return t as A;
+// }
+
+// function hookEvents(state: State) {
+//     state.events.object.created.subscribe(e => console.log('created:', e.ref));
+//     state.events.object.removed.subscribe(e => console.log('removed:', e.ref));
+//     state.events.object.replaced.subscribe(e => console.log('replaced:', e.ref));
+//     state.events.object.cellState.subscribe(e => console.log('stateChanged:', e.ref, e.cell.status));
+//     state.events.object.updated.subscribe(e => console.log('updated:', e.ref));
+// }
+
+// export async function testState() {
+//     const state = State.create(new Root({ label: 'Root' }, { }));
+//     hookEvents(state);
+
+//     const tree = state.tree;
+//     const builder = tree.build();
+//     builder.toRoot<Root>()
+//         .apply(CreateSquare, { a: 10 }, { ref: 'square' })
+//         .apply(CaclArea);
+//     const tree1 = builder.getTree();
+
+//     printTTree(tree1);
+
+//     const tree2 = StateTree.updateParams<typeof CreateSquare>(tree1, 'square', { a: 15 });
+//     printTTree(tree1);
+//     printTTree(tree2);
+
+//     await state.update(tree1).run();
+//     console.log('----------------');
+//     console.log(util.inspect(state.cells, true, 3, true));
+
+//     console.log('----------------');
+//     const jsonString = JSON.stringify(StateTree.toJSON(tree2), null, 2);
+//     const jsonData = JSON.parse(jsonString);
+//     printTTree(tree2);
+//     console.log(jsonString);
+//     const treeFromJson = StateTree.fromJSON(jsonData);
+//     printTTree(treeFromJson);
+
+//     console.log('----------------');
+//     await state.update(treeFromJson).run();
+//     console.log(util.inspect(state.cells, true, 3, true));
+
+//     console.log('----------------');
+
+//     const sel = state.select('square');
+//     console.log(sel);
+// }
+
+// testState();
+
+
+// //test();
+
+// export function printTTree(tree: StateTree) {
+//     let lines: string[] = [];
+//     function print(offset: string, ref: any) {
+//         const t = tree.nodes.get(ref)!;
+//         const tr = t;
+
+//         const name = tr.transformer.id;
+//         lines.push(`${offset}|_ (${ref}) ${name} ${tr.params ? JSON.stringify(tr.params) : ''}, v${t.version}`);
+//         offset += '   ';
+
+//         tree.children.get(ref).forEach(c => print(offset, c!));
+//     }
+//     print('', tree.root.ref);
+//     console.log(lines.join('\n'));
+// }

+ 1 - 1
src/perf-tests/tasks.ts

@@ -1,5 +1,5 @@
 import * as B from 'benchmark'
-import { now } from 'mol-task/util/now'
+import { now } from 'mol-util/now';
 import { Scheduler } from 'mol-task/util/scheduler'
 
 export namespace Tasks {

+ 1 - 1
src/servers/model/preprocess/parallel.ts

@@ -6,7 +6,7 @@
 
 import * as path from 'path'
 import * as cluster from 'cluster'
-import { now } from 'mol-task';
+import { now } from 'mol-util/now';
 import { PerformanceMonitor } from 'mol-util/performance-monitor';
 import { preprocessFile } from './preprocess';
 import { createModelPropertiesProvider } from '../property-provider';

+ 1 - 1
src/servers/model/properties/providers/pdbe.ts

@@ -83,7 +83,7 @@ function getParam<T>(params: any, ...path: string[]): T | undefined {
 
 
 function apiQueryProvider(urlPrefix: string, cache: any) {
-    const cacheKey = UUID.create();
+    const cacheKey = UUID.create22();
     return async (model: Model) => {
         try {
             if (cache[cacheKey]) return cache[cacheKey];

+ 1 - 1
src/servers/model/server/api-local.ts

@@ -10,7 +10,7 @@ import { JobManager, Job } from './jobs';
 import { ConsoleLogger } from 'mol-util/console-logger';
 import { resolveJob } from './query';
 import { StructureCache } from './structure-wrapper';
-import { now } from 'mol-task';
+import { now } from 'mol-util/now';
 import { PerformanceMonitor } from 'mol-util/performance-monitor';
 import { QueryName } from './api';
 

+ 1 - 1
src/servers/model/server/jobs.ts

@@ -43,7 +43,7 @@ export function createJob<Name extends QueryName>(definition: JobDefinition<Name
     const normalizedParams = normalizeQueryParams(queryDefinition, definition.queryParams);
     const sourceId = definition.sourceId || '_local_';
     return {
-        id: UUID.create(),
+        id: UUID.create22(),
         datetime_utc: `${new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '')}`,
         key: `${sourceId}/${definition.entryId}`,
         sourceId,

+ 2 - 1
src/servers/model/server/query.ts

@@ -8,7 +8,8 @@ import { Column } from 'mol-data/db';
 import { CifWriter } from 'mol-io/writer/cif';
 import { StructureQuery, StructureSelection, Structure } from 'mol-model/structure';
 import { encode_mmCIF_categories } from 'mol-model/structure/export/mmcif';
-import { now, Progress } from 'mol-task';
+import { Progress } from 'mol-task';
+import { now } from 'mol-util/now';
 import { ConsoleLogger } from 'mol-util/console-logger';
 import { PerformanceMonitor } from 'mol-util/performance-monitor';
 import Config from '../config';

+ 1 - 1
src/servers/model/utils/fetch-props-pdbe.ts

@@ -9,7 +9,7 @@ import * as fs from 'fs'
 import * as path from 'path'
 import * as argparse from 'argparse'
 import { makeDir } from 'mol-util/make-dir';
-import { now } from 'mol-task';
+import { now } from 'mol-util/now';
 import { PerformanceMonitor } from 'mol-util/performance-monitor';
 
 const cmdParser = new argparse.ArgumentParser({

+ 1 - 1
src/servers/volume/server/query/execute.ts

@@ -26,7 +26,7 @@ export default async function execute(params: Data.QueryParams, outputProvider:
     const start = getTime();
     State.pendingQueries++;
 
-    const guid = UUID.create() as any as string;
+    const guid = UUID.create22() as any as string;
     params.detail = Math.min(Math.max(0, params.detail | 0), ServerConfig.limits.maxOutputSizeInVoxelCountByPrecisionLevel.length - 1);
     ConsoleLogger.logId(guid, 'Info', `id=${params.sourceId},encoding=${params.asBinary ? 'binary' : 'text'},detail=${params.detail},${queryBoxToString(params.box)}`);