diff --git a/src/mol-plugin/component.ts b/src/mol-plugin/component.ts index 56577179e12cfb870169c6cee6b0088762182276..fab609d81a056aa2604fc2252da0f604c34fe72e 100644 --- a/src/mol-plugin/component.ts +++ b/src/mol-plugin/component.ts @@ -12,12 +12,14 @@ export class PluginComponent<State> { private _state: BehaviorSubject<State>; private _updated = new Subject(); - updateState(...states: Partial<State>[]) { + updateState(...states: Partial<State>[]): boolean { const latest = this.latestState; const s = shallowMergeArray(latest, states); if (s !== latest) { this._state.next(s); + return true; } + return false; } get states() { diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 13f4d4480d7ee680983295cdb2b06d562c25d4ec..c766545aaded8c723164c400fd16ff57ebc6487d 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -173,6 +173,13 @@ export class PluginContext { } } + private initAnimations() { + if (!this.spec.animations) return; + for (const anim of this.spec.animations) { + this.state.animation.register(anim); + } + } + private initCustomParamEditors() { if (!this.spec.customParamEditors) return; @@ -188,6 +195,7 @@ export class PluginContext { this.initBehaviors(); this.initDataActions(); + this.initAnimations(); this.initCustomParamEditors(); this.lociLabels = new LociLabelManager(this); diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts index 54e7e1590e56e0970754f3e5083f11a53a6f8bb6..0a3a8d719e6d17fdc6595fb70bda19603f84db8e 100644 --- a/src/mol-plugin/index.ts +++ b/src/mol-plugin/index.ts @@ -14,6 +14,7 @@ import { PluginSpec } from './spec'; import { DownloadStructure, CreateComplexRepresentation, OpenStructure, OpenVolume, DownloadDensity } from './state/actions/basic'; import { StateTransforms } from './state/transforms'; import { PluginBehaviors } from './behavior'; +import { AnimateModelIndex } from './state/animation/built-in'; function getParam(name: string, regex: string): string { let r = new RegExp(`${name}=(${regex})[&]?`, 'i'); @@ -48,6 +49,9 @@ export const DefaultPluginSpec: PluginSpec = { PluginSpec.Behavior(PluginBehaviors.Labels.SceneLabels), PluginSpec.Behavior(PluginBehaviors.CustomProps.PDBeStructureQualityReport, { autoAttach: true }), PluginSpec.Behavior(PluginBehaviors.CustomProps.RCSBAssemblySymmetry, { autoAttach: true }), + ], + animations: [ + AnimateModelIndex ] } diff --git a/src/mol-plugin/spec.ts b/src/mol-plugin/spec.ts index 7475b6ebd0446cd623ad7de994031eb321530218..4c13c42c677060a31224063e7e088687bc791683 100644 --- a/src/mol-plugin/spec.ts +++ b/src/mol-plugin/spec.ts @@ -8,13 +8,15 @@ import { StateAction } from 'mol-state/action'; import { Transformer } from 'mol-state'; import { StateTransformParameters } from './ui/state/common'; import { PluginLayoutStateProps } from './layout'; +import { PluginStateAnimation } from './state/animation/model'; export { PluginSpec } interface PluginSpec { actions: PluginSpec.Action[], behaviors: PluginSpec.Behavior[], - customParamEditors?: [StateAction | Transformer, StateTransformParameters.Class][] + animations?: PluginStateAnimation[], + customParamEditors?: [StateAction | Transformer, StateTransformParameters.Class][], initialLayout?: PluginLayoutStateProps } diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts index f94af6388e24c853c4f9ce7e2a39e0a45bd10d90..7e6e6f172d5cace17575f9169e6ad0ef8a1ea612 100644 --- a/src/mol-plugin/state.ts +++ b/src/mol-plugin/state.ts @@ -13,6 +13,7 @@ import { PluginStateSnapshotManager } from './state/snapshots'; import { RxEventHelper } from 'mol-util/rx-event-helper'; import { Canvas3DProps } from 'mol-canvas3d/canvas3d'; import { PluginCommands } from './command'; +import { PluginAnimationManager } from './state/animation/manager'; export { PluginState } class PluginState { @@ -20,6 +21,7 @@ class PluginState { readonly dataState: State; readonly behaviorState: State; + readonly animation: PluginAnimationManager; readonly cameraSnapshots = new CameraSnapshotManager(); readonly snapshots = new PluginStateSnapshotManager(); @@ -81,6 +83,8 @@ class PluginState { }); this.behavior.currentObject.next(this.dataState.behaviors.currentObject.value); + + this.animation = new PluginAnimationManager(plugin); } } diff --git a/src/mol-plugin/state/animation/built-in.ts b/src/mol-plugin/state/animation/built-in.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc6ef04c6a3a4e4eb089e625cce42e65e5fcd807 --- /dev/null +++ b/src/mol-plugin/state/animation/built-in.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { PluginStateAnimation } from './model'; +import { PluginStateObject } from '../objects'; +import { StateTransforms } from '../transforms'; +import { StateSelection } from 'mol-state/state/selection'; +import { PluginCommands } from 'mol-plugin/command'; +import { ParamDefinition } from 'mol-util/param-definition'; + +export const AnimateModelIndex = PluginStateAnimation.create({ + name: 'built-in.animate-model-index', + display: { name: 'Animate Model Index' }, + params: () => ({ maxFPS: ParamDefinition.Numeric(3, { min: 0.5, max: 30, step: 0.5 }) }), + initialState: () => ({ frame: 1 }), + async apply(animState, t, ctx) { + // limit fps + if (t.current > 0 && t.current - t.lastApplied < 1000 / ctx.params.maxFPS) { + return { kind: 'skip' }; + } + + const state = ctx.plugin.state.dataState; + const models = state.selectQ(q => q.rootsOfType(PluginStateObject.Molecule.Model) + .filter(c => c.transform.transformer === StateTransforms.Model.ModelFromTrajectory)); + + const update = state.build(); + + for (const m of models) { + const parent = StateSelection.findAncestorOfType(state.tree, state.cells, m.transform.ref, [PluginStateObject.Molecule.Trajectory]); + if (!parent || !parent.obj) continue; + const traj = parent.obj as PluginStateObject.Molecule.Trajectory; + update.to(m.transform.ref).update(StateTransforms.Model.ModelFromTrajectory, + old => { + let modelIndex = animState.frame % traj.data.length; + if (modelIndex < 0) modelIndex += traj.data.length; + return { modelIndex }; + }); + } + + await PluginCommands.State.Update.dispatch(ctx.plugin, { state, tree: update }); + return { kind: 'next', state: { frame: animState.frame + 1 } }; + } +}) \ No newline at end of file diff --git a/src/mol-plugin/state/animation/manager.ts b/src/mol-plugin/state/animation/manager.ts index a1c5ab5b563a9978dc3ad6f9f3e7d325ab3bfa4b..68fd66029cdfdc97c953b6a5d27b8addce9979dc 100644 --- a/src/mol-plugin/state/animation/manager.ts +++ b/src/mol-plugin/state/animation/manager.ts @@ -4,4 +4,119 @@ * @author David Sehnal <david.sehnal@gmail.com> */ -// TODO \ No newline at end of file +import { PluginComponent } from 'mol-plugin/component'; +import { PluginContext } from 'mol-plugin/context'; +import { PluginStateAnimation } from './model'; +import { ParamDefinition as PD } from 'mol-util/param-definition'; + +export { PluginAnimationManager } + +class PluginAnimationManager extends PluginComponent<PluginAnimationManager.State> { + private map = new Map<string, PluginStateAnimation>(); + private animations: PluginStateAnimation[] = []; + private _current: PluginAnimationManager.Current; + private _params?: PD.For<PluginAnimationManager.State['params']> = void 0; + + get isEmpty() { return this.animations.length === 0; } + get current() { return this._current!; } + + getParams(): PD.Params { + if (!this._params) { + this._params = { + current: PD.Select(this.animations[0] && this.animations[0].name, + this.animations.map(a => [a.name, a.display.name] as [string, string]), + { label: 'Animation' }) + }; + } + return this._params as any as PD.Params; + } + + updateParams(newParams: Partial<PluginAnimationManager.State['params']>) { + this.updateState({ params: { ...this.latestState.params, ...newParams } }); + const anim = this.map.get(this.latestState.params.current)!; + const params = anim.params(this.context); + this._current = { + anim, + params, + paramValues: PD.getDefaultValues(params), + state: {}, + startedTime: -1, + lastTime: 0 + } + this.triggerUpdate(); + } + + updateCurrentParams(values: any) { + this._current.paramValues = { ...this._current.paramValues, ...values }; + this.triggerUpdate(); + } + + register(animation: PluginStateAnimation) { + if (this.map.has(animation.name)) { + this.context.log.error(`Animation '${animation.name}' is already registered.`); + return; + } + this._params = void 0; + this.map.set(animation.name, animation); + this.animations.push(animation); + if (this.animations.length === 1) { + this.updateParams({ current: animation.name }); + } else { + this.triggerUpdate(); + } + } + + start() { + this.updateState({ animationState: 'playing' }); + this.triggerUpdate(); + + this._current.lastTime = 0; + this._current.startedTime = -1; + this._current.state = this._current.anim.initialState(this._current.paramValues, this.context); + + requestAnimationFrame(this.animate); + } + + stop() { + this.updateState({ animationState: 'stopped' }); + this.triggerUpdate(); + } + + animate = async (t: number) => { + if (this._current.startedTime < 0) this._current.startedTime = t; + const newState = await this._current.anim.apply( + this._current.state, + { lastApplied: this._current.lastTime, current: t - this._current.startedTime }, + { params: this._current.paramValues, plugin: this.context }); + + if (newState.kind === 'finished') { + this.stop(); + } else if (newState.kind === 'next') { + this._current.state = newState.state; + this._current.lastTime = t - this._current.startedTime; + if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate); + } else if (newState.kind === 'skip') { + if (this.latestState.animationState === 'playing') requestAnimationFrame(this.animate); + } + } + + constructor(ctx: PluginContext) { + super(ctx, { params: { current: '' }, animationState: 'stopped' }); + } +} + +namespace PluginAnimationManager { + export interface Current { + anim: PluginStateAnimation + params: PD.Params, + paramValues: any, + state: any, + startedTime: number, + lastTime: number + } + + export interface State { + params: { current: string }, + animationState: 'stopped' | 'playing' + } +} \ No newline at end of file diff --git a/src/mol-plugin/state/animation/model.ts b/src/mol-plugin/state/animation/model.ts index 132663887a6c7287810562bbf82e0ac270286754..9cc06e7705625f7c7b21ba82aa293c305eb0d454 100644 --- a/src/mol-plugin/state/animation/model.ts +++ b/src/mol-plugin/state/animation/model.ts @@ -9,8 +9,9 @@ import { PluginContext } from 'mol-plugin/context'; export { PluginStateAnimation } -interface PluginStateAnimation<P extends PD.Params, S> { - id: string, +interface PluginStateAnimation<P extends PD.Params = any, S = any> { + name: string, + readonly display: { readonly name: string, readonly description?: string }, params: (ctx: PluginContext) => P, initialState(params: PD.Values<P>, ctx: PluginContext): S, @@ -18,20 +19,28 @@ interface PluginStateAnimation<P extends PD.Params, S> { * Apply the current frame and modify the state. * @param t Current absolute time since the animation started. */ - apply(state: S, t: number, ctx: PluginStateAnimation.Context<P>): Promise<PluginStateAnimation.ApplyResult<S>>, + apply(state: S, t: PluginStateAnimation.Time, ctx: PluginStateAnimation.Context<P>): Promise<PluginStateAnimation.ApplyResult<S>>, /** * The state must be serializable to JSON. If JSON.stringify is not enough, * custom serializer can be provided. */ - stateSerialization?: { toJson?(state: S): any, fromJson?(data: any): S } + stateSerialization?: { toJSON?(state: S): any, fromJSON?(data: any): S } } namespace PluginStateAnimation { - export type ApplyResult<S> = { kind: 'finished' } | { kind: 'next', state: S } + export interface Time { + lastApplied: number, + current: number + } + + export type ApplyResult<S> = { kind: 'finished' } | { kind: 'skip' } | { kind: 'next', state: S } export interface Context<P extends PD.Params> { params: PD.Values<P>, plugin: PluginContext } -} + export function create<P extends PD.Params, S>(params: PluginStateAnimation<P, S>) { + return params; + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx index 3118cc358799d9a90efd78eae81f6d9c2c492ec1..ca7140edbb5f20566c558925ec73aa8b733e2831 100644 --- a/src/mol-plugin/ui/plugin.tsx +++ b/src/mol-plugin/ui/plugin.tsx @@ -20,6 +20,7 @@ import { ApplyActionContol } from './state/apply-action'; import { PluginState } from 'mol-plugin/state'; import { UpdateTransformContol } from './state/update-transform'; import { StateObjectCell } from 'mol-state'; +import { AnimationControls } from './state/animation'; export class Plugin extends React.Component<{ plugin: PluginContext }, {}> { @@ -62,6 +63,7 @@ class Layout extends PluginComponent { <CurrentObject /> <Controls /> <CameraSnapshots /> + <AnimationControls /> <StateSnapshots /> </div>)} {layout.showControls && this.region('bottom', <Log />)} diff --git a/src/mol-plugin/ui/state/animation.tsx b/src/mol-plugin/ui/state/animation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b43f57c998d3a7d4cb8c0554c971943b10c3e82a --- /dev/null +++ b/src/mol-plugin/ui/state/animation.tsx @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react'; +import { PluginComponent } from '../base'; +import { ParameterControls, ParamOnChange } from '../controls/parameters'; + +export class AnimationControls extends PluginComponent<{ }> { + componentDidMount() { + this.subscribe(this.plugin.state.animation.updated, () => this.forceUpdate()); + } + + updateParams: ParamOnChange = p => { + this.plugin.state.animation.updateParams({ [p.name]: p.value }); + } + + updateCurrentParams: ParamOnChange = p => { + this.plugin.state.animation.updateCurrentParams({ [p.name]: p.value }); + } + + startOrStop = () => { + const anim = this.plugin.state.animation; + if (anim.latestState.animationState === 'playing') anim.stop(); + else anim.start(); + } + + render() { + const anim = this.plugin.state.animation; + if (anim.isEmpty) return null; + + const isDisabled = anim.latestState.animationState === 'playing'; + + // TODO: give it its own style + return <div style={{ marginBottom: '10px' }}> + <div className='msp-section-header'>Animations</div> + + <ParameterControls params={anim.getParams()} values={anim.latestState.params} onChange={this.updateParams} isDisabled={isDisabled} /> + <ParameterControls params={anim.current.params} values={anim.current.paramValues} onChange={this.updateCurrentParams} isDisabled={isDisabled} /> + + <div className='msp-btn-row-group'> + <button className='msp-btn msp-btn-block msp-form-control' onClick={this.startOrStop}> + {anim.latestState.animationState === 'playing' ? 'Stop' : 'Start'} + </button> + </div> + </div> + } +} \ No newline at end of file