diff --git a/src/apps/state-docs/pd-to-md.ts b/src/apps/state-docs/pd-to-md.ts index b285668073b8b80f070ff20d1b201f45fcda0943..310962f40f1f96d053db19466506e87595dfc8da 100644 --- a/src/apps/state-docs/pd-to-md.ts +++ b/src/apps/state-docs/pd-to-md.ts @@ -28,6 +28,8 @@ function paramInfo(param: PD.Any, offset: number): string { case 'group': return `Object with:\n${getParams(param.params, offset + 2)}`; case 'mapped': return `Object { name: string, params: object } where name+params are:\n${getMapped(param, offset + 2)}`; case 'line-graph': return `A list of 2d vectors [xi, yi][]`; + // TODO: support more languages + case 'script-expression': return `An expression in the specified language { language: 'mol-script', expressiong: string }`; default: const _: never = param; console.warn(`${_} has no associated UI component`); diff --git a/src/apps/viewer/index.ts b/src/apps/viewer/index.ts index 947f1be45eedcbdac2a0b5c8a131ba086ce57345..95ee7373824184c9efa64981504787cf8ab6cb6f 100644 --- a/src/apps/viewer/index.ts +++ b/src/apps/viewer/index.ts @@ -6,14 +6,39 @@ import { createPlugin, DefaultPluginSpec } from 'mol-plugin'; import './index.html' +import { PluginContext } from 'mol-plugin/context'; +import { PluginCommands } from 'mol-plugin/command'; require('mol-plugin/skin/light.scss') -createPlugin(document.getElementById('app')!, { - ...DefaultPluginSpec, - layout: { - initial: { - isExpanded: true, - showControls: true +function getParam(name: string, regex: string): string { + let r = new RegExp(`${name}=(${regex})[&]?`, 'i'); + return decodeURIComponent(((window.location.search || '').match(r) || [])[1] || ''); +} + +const hideControls = getParam('hide-controls', `[^&]+`) === '1'; + +function init() { + const plugin = createPlugin(document.getElementById('app')!, { + ...DefaultPluginSpec, + layout: { + initial: { + isExpanded: true, + showControls: !hideControls + } } + }); + trySetSnapshot(plugin); +} + +async function trySetSnapshot(ctx: PluginContext) { + try { + const snapshotUrl = getParam('snapshot-url', `[^&]+`); + if (!snapshotUrl) return; + await PluginCommands.State.Snapshots.Fetch.dispatch(ctx, { url: snapshotUrl }) + } catch (e) { + ctx.log.error('Failed to load snapshot.'); + console.warn('Failed to load snapshot', e); } -}); \ No newline at end of file +} + +init(); \ No newline at end of file diff --git a/src/mol-model/structure/structure/element.ts b/src/mol-model/structure/structure/element.ts index 7d182793309d9163aa212152827bb349c026cb98..3e0ec4ec82004ed7b7d30d7d4a56716a61a35acd 100644 --- a/src/mol-model/structure/structure/element.ts +++ b/src/mol-model/structure/structure/element.ts @@ -254,11 +254,19 @@ namespace StructureElement { } if (loci.elements.length === 0) return MS.struct.generator.empty(); - const sourceIndices = UniqueArray.create<number, number>(); + const sourceIndexMap = new Map<string, UniqueArray<number, number>>(); const el = StructureElement.create(), p = StructureProperties.atom.sourceIndex; for (const e of loci.elements) { const { indices } = e; const { elements } = e.unit; + const opName = e.unit.conformation.operator.name; + + let sourceIndices: UniqueArray<number, number>; + if (sourceIndexMap.has(opName)) sourceIndices = sourceIndexMap.get(opName)!; + else { + sourceIndices = UniqueArray.create<number, number>(); + sourceIndexMap.set(opName, sourceIndices); + } el.unit = e.unit; for (let i = 0, _i = OrderedSet.size(indices); i < _i; i++) { @@ -268,7 +276,20 @@ namespace StructureElement { } } - const xs = sourceIndices.array; + const byOpName: Expression[] = []; + const keys = sourceIndexMap.keys(); + while (true) { + const k = keys.next(); + if (k.done) break; + byOpName.push(getOpNameQuery(k.value, sourceIndexMap.get(k.value)!.array)); + } + + return MS.struct.modifier.union([ + byOpName.length === 1 ? byOpName[0] : MS.struct.combinator.merge.apply(null, byOpName) + ]); + } + + function getOpNameQuery(opName: string, xs: number[]) { sortArray(xs); const ranges: number[] = []; @@ -304,7 +325,7 @@ namespace StructureElement { return MS.struct.generator.atomGroups({ 'atom-test': tests.length > 1 ? MS.core.logic.or.apply(null, tests) : tests[0], - 'group-by': 0 + 'chain-test': MS.core.rel.eq([MS.struct.atomProperty.core.operatorName(), opName]) }); } } diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts index 2f5f155083f00ae7589affdecf5998523ad1defe..1e2f8331c1bd92eba141fe66224c696b0775b854 100644 --- a/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts +++ b/src/mol-plugin/behavior/dynamic/volume-streaming/behavior.ts @@ -19,7 +19,6 @@ import { LRUCache } from 'mol-util/lru-cache'; import { ParamDefinition as PD } from 'mol-util/param-definition'; import { urlCombine } from 'mol-util/url'; import { VolumeServerHeader, VolumeServerInfo } from './model'; -import { CreateVolumeStreamingBehavior } from './transformers'; import { ButtonsType } from 'mol-util/input/input-observer'; import { PluginCommands } from 'mol-plugin/command'; import { StateSelection } from 'mol-state'; @@ -93,7 +92,7 @@ export namespace VolumeStreaming { export class Behavior extends PluginBehavior.WithSubscribers<Params> { private cache = LRUCache.create<ChannelsData>(25); - private params: Params = {} as any; + public params: Params = {} as any; // private ref: string = ''; channels: Channels = {} @@ -151,19 +150,19 @@ export namespace VolumeStreaming { private updateDynamicBox(ref: string, box: Box3D) { if (this.params.view.name !== 'selection-box') return; - const eR = this.params.view.params.radius; const state = this.plugin.state.dataState; - const update = state.build().to(ref).update(CreateVolumeStreamingBehavior, old => ({ - ...old, + const newParams: Params = { + ...this.params, view: { name: 'selection-box' as 'selection-box', params: { - radius: eR, + radius: this.params.view.params.radius, bottomLeft: box.min, topRight: box.max } } - })); + }; + const update = state.build().to(ref).update(newParams); PluginCommands.State.Update.dispatch(this.plugin, { state, tree: update, options: { doNotUpdateCurrent: true } }); } @@ -251,6 +250,13 @@ export namespace VolumeStreaming { }; } + getDescription() { + if (this.params.view.name === 'selection-box') return 'Selection'; + if (this.params.view.name === 'box') return 'Static Box'; + if (this.params.view.name === 'cell') return 'Cell'; + return ''; + } + constructor(public plugin: PluginContext, public info: VolumeServerInfo.Data) { super(plugin); } diff --git a/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts b/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts index bca90d1535a3cf93682cfb4a89001b0822af2748..5ad5d37985b1770a7be5689e5961c1c84e95dcee 100644 --- a/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts +++ b/src/mol-plugin/behavior/dynamic/volume-streaming/transformers.ts @@ -18,6 +18,8 @@ import { VolumeStreaming } from './behavior'; import { VolumeRepresentation3DHelpers } from 'mol-plugin/state/transforms/representation'; import { BuiltInVolumeRepresentations } from 'mol-repr/volume/registry'; import { createTheme } from 'mol-theme/theme'; +import { Box3D } from 'mol-math/geometry'; +import { Vec3 } from 'mol-math/linear-algebra'; // import { PluginContext } from 'mol-plugin/context'; export const InitVolumeStreaming = StateAction.build({ @@ -65,6 +67,29 @@ export const InitVolumeStreaming = StateAction.build({ await state.updateTree(behTree).runInContext(taskCtx); })); +export const BoxifyVolumeStreaming = StateAction.build({ + display: { name: 'Boxify Volume Streaming', description: 'Make the current box permanent.' }, + from: VolumeStreaming, + isApplicable: (a) => a.data.params.view.name === 'selection-box' +})(({ a, ref, state }, plugin: PluginContext) => { + const params = a.data.params; + if (params.view.name !== 'selection-box') return; + const box = Box3D.create(Vec3.clone(params.view.params.bottomLeft), Vec3.clone(params.view.params.topRight)); + const r = params.view.params.radius; + Box3D.expand(box, box, Vec3.create(r, r, r)); + const newParams: VolumeStreaming.Params = { + ...params, + view: { + name: 'box' as 'box', + params: { + bottomLeft: box.min, + topRight: box.max + } + } + }; + return state.updateTree(state.build().to(ref).update(newParams)); +}); + export { CreateVolumeStreamingInfo } type CreateVolumeStreamingInfo = typeof CreateVolumeStreamingInfo const CreateVolumeStreamingInfo = PluginStateTransform.BuiltIn({ @@ -120,11 +145,13 @@ const CreateVolumeStreamingBehavior = PluginStateTransform.BuiltIn({ apply: ({ a, params }, plugin: PluginContext) => Task.create('Volume streaming', async _ => { const behavior = new VolumeStreaming.Behavior(plugin, a.data); await behavior.update(params); - return new VolumeStreaming(behavior, { label: 'Volume Streaming' }); + return new VolumeStreaming(behavior, { label: 'Volume Streaming', description: behavior.getDescription() }); }), update({ b, newParams }) { return Task.create('Update Volume Streaming', async _ => { - return await b.data.update(newParams) ? StateTransformer.UpdateResult.Updated : StateTransformer.UpdateResult.Unchanged; + const ret = await b.data.update(newParams) ? StateTransformer.UpdateResult.Updated : StateTransformer.UpdateResult.Unchanged; + b.description = b.data.getDescription(); + return ret; }); } }); diff --git a/src/mol-plugin/behavior/static/state.ts b/src/mol-plugin/behavior/static/state.ts index 6d28606edd90159610d37359df45cea1bf140af0..dd444dd5fe757e88a4ad7db9a7a24bc22cafd407 100644 --- a/src/mol-plugin/behavior/static/state.ts +++ b/src/mol-plugin/behavior/static/state.ts @@ -128,10 +128,18 @@ export function Snapshots(ctx: PluginContext) { }); PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description, params }) => { - const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), name, description); + const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), { name, description }); ctx.state.snapshots.add(entry); }); + PluginCommands.State.Snapshots.Replace.subscribe(ctx, ({ id, params }) => { + ctx.state.snapshots.replace(id, ctx.state.getSnapshot(params)); + }); + + PluginCommands.State.Snapshots.Move.subscribe(ctx, ({ id, dir }) => { + ctx.state.snapshots.move(id, dir); + }); + PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => { const snapshot = ctx.state.snapshots.setCurrent(id); if (!snapshot) return; @@ -150,9 +158,7 @@ export function Snapshots(ctx: PluginContext) { PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => { const json = await ctx.runTask(ctx.fetch({ url, type: 'json' })); // fetch(url, { referrer: 'no-referrer' }); - const current = ctx.state.snapshots.setRemoteSnapshot(json.data); - if (!current) return; - return ctx.state.setSnapshot(current); + await ctx.state.snapshots.setRemoteSnapshot(json.data); }); PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => { @@ -169,8 +175,8 @@ export function Snapshots(ctx: PluginContext) { PluginCommands.State.Snapshots.OpenFile.subscribe(ctx, async ({ file }) => { try { const data = await readFromFile(file, 'string').run(); - const json = JSON.parse(data as string); - await ctx.state.setSnapshot(json); + const snapshot = JSON.parse(data as string); + return ctx.state.setSnapshot(snapshot); } catch (e) { ctx.log.error(`Reading JSON state: ${e}`); } diff --git a/src/mol-plugin/command.ts b/src/mol-plugin/command.ts index 29e8929f1ac8d5840eb3520fa1a2bb72abc8ac8e..73accd0fc786cd73132a44538dadf92e8ac93b1f 100644 --- a/src/mol-plugin/command.ts +++ b/src/mol-plugin/command.ts @@ -29,6 +29,8 @@ export const PluginCommands = { Snapshots: { Add: PluginCommand<{ name?: string, description?: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }), + Replace: PluginCommand<{ id: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }), + Move: PluginCommand<{ id: string, dir: -1 | 1 }>({ isImmediate: true }), Remove: PluginCommand<{ id: string }>({ isImmediate: true }), Apply: PluginCommand<{ id: string }>({ isImmediate: true }), Clear: PluginCommand<{}>({ isImmediate: true }), diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 7fd0a9a0c3dfb71f633dc80cbbe4e630d9be3510..58a870c7682f37ee7f610b123dfdd1d146364785 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -66,6 +66,10 @@ export class PluginContext { }; readonly behaviors = { + state: { + isAnimating: this.ev.behavior<boolean>(false), + isUpdating: this.ev.behavior<boolean>(false) + }, canvas3d: { highlight: this.ev.behavior<Canvas3D.HighlightEvent>({ current: Representation.Loci.Empty, prev: Representation.Loci.Empty }), click: this.ev.behavior<Canvas3D.ClickEvent>({ current: Representation.Loci.Empty, modifiers: ModifiersKeys.None, buttons: 0 }) @@ -159,6 +163,12 @@ export class PluginContext { return PluginCommands.State.Update.dispatch(this, { state, tree }); } + private initBehaviorEvents() { + merge(this.state.dataState.events.isUpdating, this.state.behaviorState.events.isUpdating).subscribe(u => { + this.behaviors.state.isUpdating.next(u); + }); + } + private initBuiltInBehavior() { BuiltInPluginBehaviors.State.registerDefault(this); BuiltInPluginBehaviors.Representation.registerDefault(this); @@ -206,6 +216,7 @@ export class PluginContext { constructor(public spec: PluginSpec) { this.events.log.subscribe(e => this.log.entries = this.log.entries.push(e)); + this.initBehaviorEvents(); this.initBuiltInBehavior(); this.initBehaviors(); diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts index ffcf45afb01e8dc7f062bf432aec39cfea45b557..95d5b9673a3fc14fd36ab38b69714d0f9d6e708a 100644 --- a/src/mol-plugin/index.ts +++ b/src/mol-plugin/index.ts @@ -9,20 +9,14 @@ import { PluginContext } from './context'; import { Plugin } from './ui/plugin' import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { PluginCommands } from './command'; import { PluginSpec } from './spec'; import { StateTransforms } from './state/transforms'; import { PluginBehaviors } from './behavior'; import { AnimateModelIndex } from './state/animation/built-in'; import { StateActions } from './state/actions'; -import { InitVolumeStreaming } from './behavior/dynamic/volume-streaming/transformers'; +import { InitVolumeStreaming, BoxifyVolumeStreaming, CreateVolumeStreamingBehavior } from './behavior/dynamic/volume-streaming/transformers'; import { StructureRepresentationInteraction } from './behavior/dynamic/selection/structure-representation-interaction'; -function getParam(name: string, regex: string): string { - let r = new RegExp(`${name}=(${regex})[&]?`, 'i'); - return decodeURIComponent(((window.location.search || '').match(r) || [])[1] || ''); -} - export const DefaultPluginSpec: PluginSpec = { actions: [ PluginSpec.Action(StateActions.Structure.DownloadStructure), @@ -31,8 +25,10 @@ export const DefaultPluginSpec: PluginSpec = { PluginSpec.Action(StateActions.Structure.CreateComplexRepresentation), PluginSpec.Action(StateActions.Structure.EnableModelCustomProps), - // PluginSpec.Action(StateActions.Volume.InitVolumeStreaming), + // Volume streaming PluginSpec.Action(InitVolumeStreaming), + PluginSpec.Action(BoxifyVolumeStreaming), + PluginSpec.Action(CreateVolumeStreamingBehavior), PluginSpec.Action(StateTransforms.Data.Download), PluginSpec.Action(StateTransforms.Data.ParseCif), @@ -44,6 +40,7 @@ export const DefaultPluginSpec: PluginSpec = { PluginSpec.Action(StateTransforms.Model.StructureSymmetryFromModel), PluginSpec.Action(StateTransforms.Model.StructureFromModel), PluginSpec.Action(StateTransforms.Model.ModelFromTrajectory), + PluginSpec.Action(StateTransforms.Model.UserStructureSelection), PluginSpec.Action(StateTransforms.Volume.VolumeFromCcp4), PluginSpec.Action(StateTransforms.Representation.StructureRepresentation3D), PluginSpec.Action(StateTransforms.Representation.StructureLabels3D), @@ -71,19 +68,5 @@ export const DefaultPluginSpec: PluginSpec = { export function createPlugin(target: HTMLElement, spec?: PluginSpec): PluginContext { const ctx = new PluginContext(spec || DefaultPluginSpec); ReactDOM.render(React.createElement(Plugin, { plugin: ctx }), target); - - trySetSnapshot(ctx); - return ctx; -} - -async function trySetSnapshot(ctx: PluginContext) { - try { - const snapshotUrl = getParam('snapshot-url', `[^&]+`); - if (!snapshotUrl) return; - await PluginCommands.State.Snapshots.Fetch.dispatch(ctx, { url: snapshotUrl }) - } catch (e) { - ctx.log.error('Failed to load snapshot.'); - console.warn('Failed to load snapshot', e); - } } \ No newline at end of file diff --git a/src/mol-plugin/layout.ts b/src/mol-plugin/layout.ts index 715a84be5cd68db524c193d3add84564c831636b..3be770d7036fa5db618f7c72d8bbf9f088d616e5 100644 --- a/src/mol-plugin/layout.ts +++ b/src/mol-plugin/layout.ts @@ -58,7 +58,7 @@ export class PluginLayout extends PluginComponent<PluginLayoutStateProps> { private rootState: RootState | undefined = void 0; private expandedViewport: HTMLMetaElement; - setProps(props: PluginLayoutStateProps) { + setProps(props: Partial<PluginLayoutStateProps>) { this.updateState(props); } diff --git a/src/mol-plugin/skin/base/components/controls-base.scss b/src/mol-plugin/skin/base/components/controls-base.scss index 4bd3f6c21b1314a4d4daefe728338d306b86e74c..6cc81d94774a45bc48c23e499b0e0daea47537e7 100644 --- a/src/mol-plugin/skin/base/components/controls-base.scss +++ b/src/mol-plugin/skin/base/components/controls-base.scss @@ -18,6 +18,15 @@ text-align: center; } +.msp-btn-icon-small { + height: $row-height; + width: 20px; + font-size: 85%; + line-height: $row-height; + padding: 0; + text-align: center; +} + .msp-btn-link { .msp-icon { font-size: 100%; diff --git a/src/mol-plugin/skin/base/components/temp.scss b/src/mol-plugin/skin/base/components/temp.scss index b94dd4262f4e875bddb170d27d32f7ab6807fa9b..cad3a2eb699cc3cd7c521873a05381751852ddcc 100644 --- a/src/mol-plugin/skin/base/components/temp.scss +++ b/src/mol-plugin/skin/base/components/temp.scss @@ -52,6 +52,12 @@ > button:first-child { border-left: $control-spacing solid color-increase-contrast($default-background, 12%) !important; } + + > div { + position: absolute; + right: 0; + top: 0; + } } button { @@ -60,13 +66,6 @@ } } -.msp-state-list-remove-button { - position: absolute; - right: 0; - top: 0; - width: $row-height; -} - .msp-tree-row { position: relative; height: $row-height; diff --git a/src/mol-plugin/skin/base/icons.scss b/src/mol-plugin/skin/base/icons.scss index 64502c7276d2d22746ea32c5390f6e880b15eeb8..8f35f30ba64d1d1722b8920aefeb5590d244b406 100644 --- a/src/mol-plugin/skin/base/icons.scss +++ b/src/mol-plugin/skin/base/icons.scss @@ -144,4 +144,44 @@ .msp-icon-model-first:before { content: "\e89c"; +} + +.msp-icon-down-thin:before { + content: "\e88b"; +} + +.msp-icon-up-thin:before { + content: "\e88e"; +} + +.msp-icon-left-thin:before { + content: "\e88c"; +} + +.msp-icon-right-thin:before { + content: "\e88d"; +} + +.msp-icon-switch:before { + content: "\e896"; +} + +.msp-icon-play:before { + content: "\e897"; +} + +.msp-icon-stop:before { + content: "\e898"; +} + +.msp-icon-pause:before { + content: "\e899"; +} + +.msp-icon-left-open:before { + content: "\e87c"; +} + +.msp-icon-right-open:before { + content: "\e87d"; } \ No newline at end of file diff --git a/src/mol-plugin/spec.ts b/src/mol-plugin/spec.ts index 3a9611eb8d91f954dc718e7c177affaddbde2b48..6a8599df9b7870cd3b5d8b31de1b59ad89516ca5 100644 --- a/src/mol-plugin/spec.ts +++ b/src/mol-plugin/spec.ts @@ -17,7 +17,7 @@ interface PluginSpec { animations?: PluginStateAnimation[], customParamEditors?: [StateAction | StateTransformer, StateTransformParameters.Class][], layout?: { - initial?: PluginLayoutStateProps, + initial?: Partial<PluginLayoutStateProps>, controls?: { left?: React.ComponentClass | 'none', right?: React.ComponentClass | 'none', diff --git a/src/mol-plugin/state.ts b/src/mol-plugin/state.ts index 28ca1986e8724f8482c4de17802479dd2fdd67e8..ff27f0b9994a052f3de8e99731566a972289352f 100644 --- a/src/mol-plugin/state.ts +++ b/src/mol-plugin/state.ts @@ -25,7 +25,7 @@ class PluginState { readonly behaviorState: State; readonly animation: PluginAnimationManager; readonly cameraSnapshots = new CameraSnapshotManager(); - readonly snapshots = new PluginStateSnapshotManager(); + readonly snapshots: PluginStateSnapshotManager; readonly behavior = { kind: this.ev.behavior<PluginState.Kind>('data'), @@ -56,7 +56,8 @@ class PluginState { cameraSnapshots: p.cameraSnapshots ? this.cameraSnapshots.getStateSnapshot() : void 0, canvas3d: p.canvas3d ? { props: this.plugin.canvas3d.props - } : void 0 + } : void 0, + durationInMs: params && params.durationInMs }; } @@ -89,6 +90,7 @@ class PluginState { } constructor(private plugin: import('./context').PluginContext) { + this.snapshots = new PluginStateSnapshotManager(plugin); this.dataState = State.create(new SO.Root({ }), { globalContext: plugin }); this.behaviorState = State.create(new PluginBehavior.Root({ }), { globalContext: plugin, rootProps: { isLocked: true } }); @@ -110,6 +112,7 @@ namespace PluginState { export type CameraTransitionStyle = 'instant' | 'animate' export const GetSnapshotParams = { + durationInMs: PD.Numeric(1500, { min: 100, max: 15000, step: 100 }, { label: 'Duration in ms' }), data: PD.Boolean(true), behavior: PD.Boolean(false), animation: PD.Boolean(true), @@ -119,7 +122,7 @@ namespace PluginState { cameraSnapshots: PD.Boolean(false), cameraTranstionStyle: PD.Select<CameraTransitionStyle>('animate', [['animate', 'Animate'], ['instant', 'Instant']]) }; - export type GetSnapshotParams = Partial<PD.Value<typeof GetSnapshotParams>> + export type GetSnapshotParams = Partial<PD.Values<typeof GetSnapshotParams>> export const DefaultGetSnapshotParams = PD.getDefaultValues(GetSnapshotParams); export interface Snapshot { @@ -134,6 +137,7 @@ namespace PluginState { cameraSnapshots?: CameraSnapshotManager.StateSnapshot, canvas3d?: { props?: Canvas3DProps - } + }, + durationInMs?: number } } diff --git a/src/mol-plugin/state/animation/manager.ts b/src/mol-plugin/state/animation/manager.ts index 5e3cb09874eb55285ebbb7fd474f93f62357c5eb..4b9105afb18e8e22b57f41ad11a0c54dbe3416b4 100644 --- a/src/mol-plugin/state/animation/manager.ts +++ b/src/mol-plugin/state/animation/manager.ts @@ -91,6 +91,9 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat start() { this.context.canvas3d.setSceneAnimating(true); this.updateState({ animationState: 'playing' }); + if (!this.context.behaviors.state.isAnimating.value) { + this.context.behaviors.state.isAnimating.next(true); + } this.triggerUpdate(); this._current.lastTime = 0; @@ -103,6 +106,9 @@ class PluginAnimationManager extends PluginComponent<PluginAnimationManager.Stat stop() { this.context.canvas3d.setSceneAnimating(false); if (typeof this._frame !== 'undefined') cancelAnimationFrame(this._frame); + if (this.context.behaviors.state.isAnimating.value) { + this.context.behaviors.state.isAnimating.next(false); + } if (this.state.animationState !== 'stopped') { this.updateState({ animationState: 'stopped' }); diff --git a/src/mol-plugin/state/snapshots.ts b/src/mol-plugin/state/snapshots.ts index 1648eb7850e965b80410e0fb7c07c43eb8ec61b5..e30e142ef29bb4d054b770cd91a545167d68dcbb 100644 --- a/src/mol-plugin/state/snapshots.ts +++ b/src/mol-plugin/state/snapshots.ts @@ -4,39 +4,95 @@ * @author David Sehnal <david.sehnal@gmail.com> */ -import { OrderedMap } from 'immutable'; +import { List } from 'immutable'; import { UUID } from 'mol-util'; import { PluginState } from '../state'; import { PluginComponent } from 'mol-plugin/component'; +import { PluginContext } from 'mol-plugin/context'; export { PluginStateSnapshotManager } -class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | undefined, entries: OrderedMap<string, PluginStateSnapshotManager.Entry> }> { +class PluginStateSnapshotManager extends PluginComponent<{ + current?: UUID | undefined, + entries: List<PluginStateSnapshotManager.Entry>, + isPlaying: boolean, + nextSnapshotDelayInMs: number +}> { + static DefaultNextSnapshotDelayInMs = 1500; + + private entryMap = new Map<string, PluginStateSnapshotManager.Entry>(); + readonly events = { changed: this.ev() }; - getEntry(id: string) { - return this.state.entries.get(id); + currentGetSnapshotParams: PluginState.GetSnapshotParams = PluginState.DefaultGetSnapshotParams as any; + + getIndex(e: PluginStateSnapshotManager.Entry) { + return this.state.entries.indexOf(e); + } + + getEntry(id: string | undefined) { + if (!id) return; + return this.entryMap.get(id); } remove(id: string) { - if (!this.state.entries.has(id)) return; + const e = this.entryMap.get(id); + if (!e) return; + + this.entryMap.delete(id); this.updateState({ current: this.state.current === id ? void 0 : this.state.current, - entries: this.state.entries.delete(id) + entries: this.state.entries.delete(this.getIndex(e)) }); this.events.changed.next(); } add(e: PluginStateSnapshotManager.Entry) { - this.updateState({ current: e.snapshot.id, entries: this.state.entries.set(e.snapshot.id, e) }); + this.entryMap.set(e.snapshot.id, e); + this.updateState({ current: e.snapshot.id, entries: this.state.entries.push(e) }); + this.events.changed.next(); + } + + replace(id: string, snapshot: PluginState.Snapshot) { + const old = this.getEntry(id); + if (!old) return; + + const idx = this.getIndex(old); + // The id changes here! + const e = PluginStateSnapshotManager.Entry(snapshot, { + name: old.name, + description: old.description + }); + this.entryMap.set(snapshot.id, e); + this.updateState({ current: e.snapshot.id, entries: this.state.entries.set(idx, e) }); + this.events.changed.next(); + } + + move(id: string, dir: -1 | 1) { + const len = this.state.entries.size; + if (len < 2) return; + + const e = this.getEntry(id); + if (!e) return; + const from = this.getIndex(e); + let to = (from + dir) % len; + if (to < 0) to += len; + const f = this.state.entries.get(to); + + const entries = this.state.entries.asMutable(); + entries.set(to, e); + entries.set(from, f); + + this.updateState({ current: e.snapshot.id, entries: entries.asImmutable() }); this.events.changed.next(); } clear() { if (this.state.entries.size === 0) return; - this.updateState({ current: void 0, entries: OrderedMap<string, PluginStateSnapshotManager.Entry>() }); + this.entryMap.clear(); + this.updateState({ current: void 0, entries: List<PluginStateSnapshotManager.Entry>() }); this.events.changed.next(); } @@ -50,39 +106,50 @@ class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | unde } getNextId(id: string | undefined, dir: -1 | 1) { - const xs = this.state.entries; - const keys = xs.keys(); - let k = keys.next(); - let prev = k.value; - const fst = prev; - while (!k.done) { - k = keys.next(); - if (k.value === id && dir === -1) return prev; - if (!k.done && prev === id && dir === 1) return k.value; - if (!k.done) prev = k.value; - else break; + const len = this.state.entries.size; + if (!id) { + if (len === 0) return void 0; + const idx = dir === -1 ? len - 1 : 0; + return this.state.entries.get(idx).snapshot.id; } - if (dir === -1) return prev; - return fst; + + const e = this.getEntry(id); + if (!e) return; + let idx = this.getIndex(e); + if (idx < 0) return; + + idx = (idx + dir) % len; + if (idx < 0) idx += len; + + return this.state.entries.get(idx).snapshot.id; } - setRemoteSnapshot(snapshot: PluginStateSnapshotManager.RemoteSnapshot): PluginState.Snapshot | undefined { + async setRemoteSnapshot(snapshot: PluginStateSnapshotManager.RemoteSnapshot): Promise<PluginState.Snapshot | undefined> { this.clear(); - const entries = this.state.entries.withMutations(m => { - for (const e of snapshot.entries) { - m.set(e.snapshot.id, e); - } - }); + const entries = List<PluginStateSnapshotManager.Entry>().asMutable() + for (const e of snapshot.entries) { + this.entryMap.set(e.snapshot.id, e); + entries.push(e); + } const current = snapshot.current ? snapshot.current : snapshot.entries.length > 0 ? snapshot.entries[0].snapshot.id : void 0; - this.updateState({ current, entries }); + this.updateState({ + current, + entries: entries.asImmutable(), + isPlaying: false, + nextSnapshotDelayInMs: snapshot.playback ? snapshot.playback.nextSnapshotDelayInMs : PluginStateSnapshotManager.DefaultNextSnapshotDelayInMs + }); this.events.changed.next(); if (!current) return; - const ret = this.getEntry(current); - return ret && ret.snapshot; + const entry = this.getEntry(current); + const next = entry && entry.snapshot; + if (!next) return; + await this.plugin.state.setSnapshot(next); + if (snapshot.playback && snapshot.playback.isPlaying) this.play(); + return next; } getRemoteSnapshot(options?: { name?: string, description?: string }): PluginStateSnapshotManager.RemoteSnapshot { @@ -92,12 +159,53 @@ class PluginStateSnapshotManager extends PluginComponent<{ current?: UUID | unde name: options && options.name, description: options && options.description, current: this.state.current, + playback: { + isPlaying: this.state.isPlaying, + nextSnapshotDelayInMs: this.state.nextSnapshotDelayInMs + }, entries: this.state.entries.valueSeq().toArray() }; } - constructor() { - super({ current: void 0, entries: OrderedMap<string, PluginStateSnapshotManager.Entry>() }); + private timeoutHandle: any = void 0; + private next = async () => { + this.timeoutHandle = void 0; + const next = this.getNextId(this.state.current, 1); + if (!next || next === this.state.current) { + this.stop(); + return; + } + const snapshot = this.setCurrent(next)!; + await this.plugin.state.setSnapshot(snapshot); + const delay = typeof snapshot.durationInMs !== 'undefined' ? snapshot.durationInMs : this.state.nextSnapshotDelayInMs; + this.timeoutHandle = setTimeout(this.next, delay); + }; + + play() { + this.updateState({ isPlaying: true }); + this.next(); + } + + stop() { + this.updateState({ isPlaying: false }); + if (typeof this.timeoutHandle !== 'undefined') clearTimeout(this.timeoutHandle); + this.timeoutHandle = void 0; + this.events.changed.next(); + } + + togglePlay() { + if (this.state.isPlaying) this.stop(); + else this.play(); + } + + constructor(private plugin: PluginContext) { + super({ + current: void 0, + entries: List(), + isPlaying: false, + nextSnapshotDelayInMs: PluginStateSnapshotManager.DefaultNextSnapshotDelayInMs + }); + // TODO make nextSnapshotDelayInMs editable } } @@ -109,8 +217,8 @@ namespace PluginStateSnapshotManager { snapshot: PluginState.Snapshot } - export function Entry(snapshot: PluginState.Snapshot, name?: string, description?: string): Entry { - return { timestamp: +new Date(), name, snapshot, description }; + export function Entry(snapshot: PluginState.Snapshot, params: {name?: string, description?: string }): Entry { + return { timestamp: +new Date(), snapshot, ...params }; } export interface RemoteSnapshot { @@ -118,6 +226,10 @@ namespace PluginStateSnapshotManager { name?: string, description?: string, current: UUID | undefined, + playback: { + isPlaying: boolean, + nextSnapshotDelayInMs: number, + }, entries: Entry[] } } \ No newline at end of file diff --git a/src/mol-plugin/state/transforms/model.ts b/src/mol-plugin/state/transforms/model.ts index fd46f04fad369b6cb8eee92d9eb65d9b268f0069..dd52f03e21207603c8123c540b2ef209939be7f2 100644 --- a/src/mol-plugin/state/transforms/model.ts +++ b/src/mol-plugin/state/transforms/model.ts @@ -22,6 +22,8 @@ import { stringToWords } from 'mol-util/string'; import { PluginStateObject as SO, PluginStateTransform } from '../objects'; import { trajectoryFromGRO } from 'mol-model-formats/structure/gro'; import { parseGRO } from 'mol-io/reader/gro/parser'; +import { parseMolScript } from 'mol-script/language/parser'; +import { transpileMolScript } from 'mol-script/script/mol-script/symbols'; export { TrajectoryFromMmCif }; export { TrajectoryFromPDB }; @@ -31,8 +33,10 @@ export { StructureFromModel }; export { StructureAssemblyFromModel }; export { StructureSymmetryFromModel }; export { StructureSelection }; +export { UserStructureSelection }; export { StructureComplexElement }; export { CustomModelProperties }; + type TrajectoryFromMmCif = typeof TrajectoryFromMmCif const TrajectoryFromMmCif = PluginStateTransform.BuiltIn({ name: 'trajectory-from-mmcif', @@ -243,6 +247,31 @@ const StructureSelection = PluginStateTransform.BuiltIn({ } }); +type UserStructureSelection = typeof UserStructureSelection +const UserStructureSelection = PluginStateTransform.BuiltIn({ + name: 'user-structure-selection', + display: { name: 'Structure Selection', description: 'Create a molecular structure from the specified query expression.' }, + from: SO.Molecule.Structure, + to: SO.Molecule.Structure, + params: { + query: PD.ScriptExpression({ language: 'mol-script', expression: '(sel.atom.atom-groups :residue-test (= atom.resname ALA))' }), + label: PD.makeOptional(PD.Text('')) + } +})({ + apply({ a, params }) { + // TODO: use cache, add "update" + const parsed = parseMolScript(params.query.expression); + if (parsed.length === 0) throw new Error('No query'); + const query = transpileMolScript(parsed[0]); + const compiled = compile<Sel>(query); + const result = compiled(new QueryContext(a.data)); + const s = Sel.unionStructure(result); + if (s.elementCount === 0) return StateObject.Null; + const props = { label: `${params.label || 'Selection'}`, description: structureDesc(s) }; + return new SO.Molecule.Structure(s, props); + } +}); + namespace StructureComplexElement { export type Types = 'atomic-sequence' | 'water' | 'atomic-het' | 'spheres' } diff --git a/src/mol-plugin/state/transforms/representation.ts b/src/mol-plugin/state/transforms/representation.ts index 08b0c3275ad5fa487fdd5e818217edd6b3f99d47..6b8d31f51c850c5d7ead6ee5cdabfbf2b523c699 100644 --- a/src/mol-plugin/state/transforms/representation.ts +++ b/src/mol-plugin/state/transforms/representation.ts @@ -194,7 +194,12 @@ const StructureLabels3D = PluginStateTransform.BuiltIn({ to: SO.Molecule.Representation3D, params: { // TODO: other targets - target: PD.Select<'elements' | 'residues'>('residues', [['residues', 'Residues'], ['elements', 'Elements']]), + target: PD.MappedStatic('residues', { + 'elements': PD.Group({ }), + 'residues': PD.Group({ }), + 'static-text': PD.Group({ value: PD.Text('') }, { isFlat: true }) + }), + // PD.Select<'elements' | 'residues'>('residues', [['residues', 'Residues'], ['elements', 'Elements']]), options: PD.Group({ ...Text.Params, @@ -212,7 +217,7 @@ const StructureLabels3D = PluginStateTransform.BuiltIn({ apply({ a, params }) { return Task.create('Structure Labels', async ctx => { const repr = await getLabelRepresentation(ctx, a.data, params); - return new SO.Molecule.Representation3D(repr, { label: `Labels`, description: params.target }); + return new SO.Molecule.Representation3D(repr, { label: `Labels`, description: params.target.name }); }); }, update({ a, b, newParams }) { diff --git a/src/mol-plugin/ui/controls.tsx b/src/mol-plugin/ui/controls.tsx index 847b7b215264217b8cef2dd802fd5d5dce376e7a..fba79579b48fe8838daad958088053925973aee3 100644 --- a/src/mol-plugin/ui/controls.tsx +++ b/src/mol-plugin/ui/controls.tsx @@ -56,6 +56,7 @@ export class TrajectoryControls extends PluginUIComponent<{}, { show: boolean, l componentDidMount() { this.subscribe(this.plugin.state.dataState.events.changed, this.update); + this.subscribe(this.plugin.behaviors.state.isAnimating, this.update); } reset = () => PluginCommands.State.ApplyAction.dispatch(this.plugin, { @@ -76,21 +77,25 @@ export class TrajectoryControls extends PluginUIComponent<{}, { show: boolean, l render() { if (!this.state.show) return null; + const isAnimating = this.plugin.behaviors.state.isAnimating.value; + return <div className='msp-traj-controls'> - <IconButton icon='model-first' title='First Model' onClick={this.reset} /> - <IconButton icon='model-prev' title='Previous Model' onClick={this.prev} /> - <IconButton icon='model-next' title='Next Model' onClick={this.next} /> + <IconButton icon='model-first' title='First Model' onClick={this.reset} disabled={isAnimating} /> + <IconButton icon='model-prev' title='Previous Model' onClick={this.prev} disabled={isAnimating} /> + <IconButton icon='model-next' title='Next Model' onClick={this.next} disabled={isAnimating} /> { !!this.state.label && <span>{this.state.label}</span> } </div>; } } -export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBusy: boolean }> { - state = { isBusy: false } +export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBusy: boolean, show: boolean }> { + state = { isBusy: false, show: true } componentDidMount() { // TODO: this needs to be diabled when the state is updating! this.subscribe(this.plugin.state.snapshots.events.changed, () => this.forceUpdate()); + this.subscribe(this.plugin.behaviors.state.isUpdating, isBusy => this.setState({ isBusy })); + this.subscribe(this.plugin.behaviors.state.isAnimating, isAnimating => this.setState({ show: !isAnimating })); } async update(id: string) { @@ -104,35 +109,43 @@ export class StateSnapshotViewportControls extends PluginUIComponent<{}, { isBus this.update(e.target.value); } - prev = () => { + prev = () => { const s = this.plugin.state.snapshots; const id = s.getNextId(s.state.current, -1); if (id) this.update(id); } - next = () => { + next = () => { const s = this.plugin.state.snapshots; const id = s.getNextId(s.state.current, 1); if (id) this.update(id); } + togglePlay = () => { + this.plugin.state.snapshots.togglePlay(); + } + render() { const snapshots = this.plugin.state.snapshots; const count = snapshots.state.entries.size; - if (count < 2) { + if (count < 2 || !this.state.show) { return null; } const current = snapshots.state.current; + const isPlaying = snapshots.state.isPlaying; + + // TODO: better handle disabled state return <div className='msp-state-snapshot-viewport-controls'> - <select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy}> + <select className='msp-form-control' value={current || 'none'} onChange={this.change} disabled={this.state.isBusy || isPlaying}> {!current && <option key='none' value='none'></option>} {snapshots.state.entries.valueSeq().map((e, i) => <option key={e!.snapshot.id} value={e!.snapshot.id}>{`[${i! + 1}/${count}]`} {e!.name || new Date(e!.timestamp).toLocaleString()}</option>)} </select> - <IconButton icon='model-prev' title='Previous State' onClick={this.prev} disabled={this.state.isBusy} /> - <IconButton icon='model-next' title='Next State' onClick={this.next} disabled={this.state.isBusy} /> + <IconButton icon='left-open' title='Previous State' onClick={this.prev} disabled={this.state.isBusy || isPlaying} /> + <IconButton icon='right-open' title='Next State' onClick={this.next} disabled={this.state.isBusy || isPlaying} /> + <IconButton icon={isPlaying ? 'pause' : 'play'} title={isPlaying ? 'Pause' : 'Play'} onClick={this.togglePlay} /> </div>; } } diff --git a/src/mol-plugin/ui/controls/common.tsx b/src/mol-plugin/ui/controls/common.tsx index 423b0784c0abee51a9d521161e2ea4759d81dca4..7bbb92d9156ebbf3648cd000a5d4130c07b4b00b 100644 --- a/src/mol-plugin/ui/controls/common.tsx +++ b/src/mol-plugin/ui/controls/common.tsx @@ -81,10 +81,18 @@ export class NumericInput extends React.PureComponent<{ } } -export function IconButton(props: { icon: string, onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, title?: string, toggleState?: boolean, disabled?: boolean }) { - let className = `msp-btn msp-btn-link msp-btn-icon`; +export function IconButton(props: { + icon: string, + isSmall?: boolean, + onClick: (e: React.MouseEvent<HTMLButtonElement>) => void, + title?: string, + toggleState?: boolean, + disabled?: boolean, + 'data-id'?: string +}) { + let className = `msp-btn msp-btn-link msp-btn-icon${props.isSmall ? '-small' : ''}`; if (typeof props.toggleState !== 'undefined') className += ` msp-btn-link-toggle-${props.toggleState ? 'on' : 'off'}` - return <button className={className} onClick={props.onClick} title={props.title} disabled={props.disabled}> + return <button className={className} onClick={props.onClick} title={props.title} disabled={props.disabled} data-id={props['data-id']}> <span className={`msp-icon msp-icon-${props.icon}`}/> </button>; } diff --git a/src/mol-plugin/ui/controls/parameters.tsx b/src/mol-plugin/ui/controls/parameters.tsx index ed0baaa35f518ad4dcf46f6b0c8f4b02d84ea619..c09203ccdf7e27ca81bbe27594b0ac10518e95cb 100644 --- a/src/mol-plugin/ui/controls/parameters.tsx +++ b/src/mol-plugin/ui/controls/parameters.tsx @@ -63,6 +63,7 @@ function controlFor(param: PD.Any): ParamControl | undefined { case 'group': return GroupControl; case 'mapped': return MappedControl; case 'line-graph': return LineGraphControl; + case 'script-expression': return ScriptExpressionControl; default: const _: never = param; console.warn(`${_} has no associated UI component`); @@ -77,7 +78,7 @@ export interface ParamProps<P extends PD.Base<any> = PD.Base<any>> { name: strin export type ParamControl = React.ComponentClass<ParamProps<any>> export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<ParamProps<P>> { - protected update(value: any) { + protected update(value: P['defaultValue']) { this.props.onChange({ param: this.props.param, name: this.props.name, value }); } @@ -562,3 +563,32 @@ export class ConvertedControl extends React.PureComponent<ParamProps<PD.Converte return <Converted param={this.props.param.converted} value={value} name={this.props.name} onChange={this.onChange} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} /> } } + +export class ScriptExpressionControl extends SimpleParam<PD.ScriptExpression> { + onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const value = e.target.value; + if (value !== this.props.value.expression) { + this.update({ language: this.props.value.language, expression: value }); + } + } + + onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (!this.props.onEnter) return; + if ((e.keyCode === 13 || e.charCode === 13)) { + this.props.onEnter(); + } + } + + renderControl() { + // TODO: improve! + + const placeholder = this.props.param.label || camelCaseToWords(this.props.name); + return <input type='text' + value={this.props.value.expression || ''} + placeholder={placeholder} + onChange={this.onChange} + onKeyPress={this.props.onEnter ? this.onKeyPress : void 0} + disabled={this.props.isDisabled} + />; + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/state.tsx b/src/mol-plugin/ui/state.tsx index 132d5ea2c7c3343949f894cb25c22daca4ac67b5..d28f75d7ad9570f0a3ab740f08e57ccf616d4ca1 100644 --- a/src/mol-plugin/ui/state.tsx +++ b/src/mol-plugin/ui/state.tsx @@ -13,24 +13,26 @@ import { ParameterControls } from './controls/parameters'; import { ParamDefinition as PD} from 'mol-util/param-definition'; import { PluginState } from 'mol-plugin/state'; import { urlCombine } from 'mol-util/url'; +import { IconButton } from './controls/common'; +import { formatTimespan } from 'mol-util/now'; export class StateSnapshots extends PluginUIComponent<{ }> { render() { return <div> <div className='msp-section-header'>State</div> - <StateSnapshotControls /> + <LocalStateSnapshots /> <LocalStateSnapshotList /> <RemoteStateSnapshots /> </div>; } } -class StateSnapshotControls extends PluginUIComponent< +class LocalStateSnapshots extends PluginUIComponent< { }, - { params: PD.Values<typeof StateSnapshotControls.Params> }> { + { params: PD.Values<typeof LocalStateSnapshots.Params> }> { - state = { params: PD.getDefaultValues(StateSnapshotControls.Params) }; + state = { params: PD.getDefaultValues(LocalStateSnapshots.Params) }; static Params = { name: PD.Text(), @@ -41,7 +43,11 @@ class StateSnapshotControls extends PluginUIComponent< }; add = () => { - PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description }); + PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { + name: this.state.params.name, + description: this.state.params.options.description, + params: this.state.params.options + }); this.setState({ params: { name: '', @@ -81,8 +87,10 @@ class StateSnapshotControls extends PluginUIComponent< </div> </div> - <ParameterControls params={StateSnapshotControls.Params} values={this.state.params} onEnter={this.add} onChange={p => { - this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any); + <ParameterControls params={LocalStateSnapshots.Params} values={this.state.params} onEnter={this.add} onChange={p => { + const params = { ...this.state.params, [p.name]: p.value }; + this.setState({ params } as any); + this.plugin.state.snapshots.currentGetSnapshotParams = params.options; }}/> <div className='msp-btn-row-group'> @@ -99,26 +107,52 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> { this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate()); } - apply(id: string) { - return () => PluginCommands.State.Snapshots.Apply.dispatch(this.plugin, { id }); + apply = (e: React.MouseEvent<HTMLElement>) => { + const id = e.currentTarget.getAttribute('data-id'); + if (!id) return; + PluginCommands.State.Snapshots.Apply.dispatch(this.plugin, { id }); } - remove(id: string) { - return () => { - PluginCommands.State.Snapshots.Remove.dispatch(this.plugin, { id }); - } + remove = (e: React.MouseEvent<HTMLElement>) => { + const id = e.currentTarget.getAttribute('data-id'); + if (!id) return; + PluginCommands.State.Snapshots.Remove.dispatch(this.plugin, { id }); + } + + moveUp = (e: React.MouseEvent<HTMLElement>) => { + const id = e.currentTarget.getAttribute('data-id'); + if (!id) return; + PluginCommands.State.Snapshots.Move.dispatch(this.plugin, { id, dir: -1 }); + } + + moveDown = (e: React.MouseEvent<HTMLElement>) => { + const id = e.currentTarget.getAttribute('data-id'); + if (!id) return; + PluginCommands.State.Snapshots.Move.dispatch(this.plugin, { id, dir: 1 }); + } + + replace = (e: React.MouseEvent<HTMLElement>) => { + const id = e.currentTarget.getAttribute('data-id'); + if (!id) return; + PluginCommands.State.Snapshots.Replace.dispatch(this.plugin, { id, params: this.plugin.state.snapshots.currentGetSnapshotParams }); } render() { const current = this.plugin.state.snapshots.state.current; return <ul style={{ listStyle: 'none' }} className='msp-state-list'> - {this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.snapshot.id}> - <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.snapshot.id)}> - <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}>{e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>{e!.description}</small> - </button> - <button onClick={this.remove(e!.snapshot.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'> - <span className='msp-icon msp-icon-remove' /> + {this.plugin.state.snapshots.state.entries.map(e => <li key={e!.snapshot.id}> + <button data-id={e!.snapshot.id} className='msp-btn msp-btn-block msp-form-control' onClick={this.apply}> + <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}> + {e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small> + {`${e!.snapshot.durationInMs ? formatTimespan(e!.snapshot.durationInMs, false) + `${e!.description ? ', ' : ''}` : ''}${e!.description ? e!.description : ''}`} + </small> </button> + <div> + <IconButton data-id={e!.snapshot.id} icon='up-thin' title='Move Up' onClick={this.moveUp} isSmall={true} /> + <IconButton data-id={e!.snapshot.id} icon='down-thin' title='Move Down' onClick={this.moveDown} isSmall={true} /> + <IconButton data-id={e!.snapshot.id} icon='switch' title='Replace' onClick={this.replace} isSmall={true} /> + <IconButton data-id={e!.snapshot.id} icon='remove' title='Remove' onClick={this.remove} isSmall={true} /> + </div> </li>)} </ul>; } @@ -172,7 +206,11 @@ class RemoteStateSnapshots extends PluginUIComponent< upload = async () => { this.setState({ isBusy: true }); if (this.plugin.state.snapshots.state.entries.size === 0) { - await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description }); + await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { + name: this.state.params.name, + description: this.state.params.options.description, + params: this.plugin.state.snapshots.currentGetSnapshotParams + }); } await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, { @@ -254,9 +292,9 @@ class RemoteStateSnapshotList extends PurePluginUIComponent< disabled={this.props.isBusy} onContextMenu={this.open} title='Click to download, right-click to open in a new tab.'> {e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small> </button> - <button data-id={e!.id} onClick={this.props.remove} className='msp-btn msp-btn-link msp-state-list-remove-button' disabled={this.props.isBusy}> - <span className='msp-icon msp-icon-remove' /> - </button> + <div> + <IconButton data-id={e!.id} icon='remove' title='Remove' onClick={this.props.remove} disabled={this.props.isBusy} /> + </div> </li>)} </ul>; } diff --git a/src/mol-plugin/util/structure-labels.ts b/src/mol-plugin/util/structure-labels.ts index 7bdb7bce0af682291d332c55f15378cfc9e5e282..e2057a51e522d6a499f96492357f962847ac4bd2 100644 --- a/src/mol-plugin/util/structure-labels.ts +++ b/src/mol-plugin/util/structure-labels.ts @@ -36,7 +36,7 @@ function getLabelsText(data: LabelsData, props: PD.Values<Text.Params>, text?: T export async function getLabelRepresentation(ctx: RuntimeContext, structure: Structure, params: StateTransformer.Params<StructureLabels3D>, prev?: ShapeRepresentation<LabelsData, Text, Text.Params>) { const repr = prev || ShapeRepresentation(getLabelsShape, Text.Utils); - const data = getLabelData(structure, params.target); + const data = getLabelData(structure, params); await repr.createOrUpdate(params.options, data).runInContext(ctx); return repr; } @@ -47,7 +47,26 @@ function getLabelsShape(ctx: RuntimeContext, data: LabelsData, props: PD.Values< } const boundaryHelper = new BoundaryHelper(); -function getLabelData(structure: Structure, level: 'elements' | 'residues'): LabelsData { +function getLabelData(structure: Structure, params: StateTransformer.Params<StructureLabels3D>): LabelsData { + if (params.target.name === 'static-text') { + return getLabelDataStatic(structure, params.target.params.value); + } else { + return getLabelDataComputed(structure, params.target.name); + } + +} + +function getLabelDataStatic(structure: Structure, text: string): LabelsData { + const boundary = structure.boundary.sphere; + return { + texts: [text], + positions: [boundary.center], + sizes: [1], + depths: [boundary.radius] + }; +} + +function getLabelDataComputed(structure: Structure, level: 'elements' | 'residues'): LabelsData { const data: LabelsData = { texts: [], positions: [], sizes: [], depths: [] }; const l = StructureElement.create(); diff --git a/src/mol-script/language/symbol-table/structure-query.ts b/src/mol-script/language/symbol-table/structure-query.ts index 1d87ccf8a62a6b4abd7ee496664acb2210752bbc..4f9f7377efb220f0dbb65717677675540095a9d1 100644 --- a/src/mol-script/language/symbol-table/structure-query.ts +++ b/src/mol-script/language/symbol-table/structure-query.ts @@ -249,6 +249,7 @@ const atomProperty = { }), Type.Num, 'Number of bonds (by default only covalent bonds are counted).'), sourceIndex: atomProp(Type.Num, 'Index of the atom/element in the input file.'), + operatorName: atomProp(Type.Str, 'Name of the symmetry operator applied to this element.'), }, topology: { diff --git a/src/mol-script/runtime/query/table.ts b/src/mol-script/runtime/query/table.ts index 7e48327940d58dd3913730897b2a514eb9a566eb..ba1072a587beb9c0c8ed2bf3b83dac130894b7f8 100644 --- a/src/mol-script/runtime/query/table.ts +++ b/src/mol-script/runtime/query/table.ts @@ -206,9 +206,14 @@ const symbols = [ elementRadius: xs['atom-radius'] })(ctx)), D(MolScript.structureQuery.modifier.wholeResidues, (ctx, xs) => Queries.modifiers.wholeResidues(xs[0] as any)(ctx)), + D(MolScript.structureQuery.modifier.union, (ctx, xs) => Queries.modifiers.union(xs[0] as any)(ctx)), D(MolScript.structureQuery.modifier.expandProperty, (ctx, xs) => Queries.modifiers.expandProperty(xs[0] as any, xs['property'])(ctx)), D(MolScript.structureQuery.modifier.exceptBy, (ctx, xs) => Queries.modifiers.exceptBy(xs[0] as any, xs['by'] as any)(ctx)), + // ============= COMBINATORS ================ + + D(MolScript.structureQuery.combinator.merge, (ctx, xs) => Queries.combinators.merge(xs as any)(ctx)), + // ============= ATOM PROPERTIES ================ // ~~~ CORE ~~~ @@ -220,6 +225,7 @@ const symbols = [ D(MolScript.structureQuery.atomProperty.core.y, atomProp(StructureProperties.atom.y)), D(MolScript.structureQuery.atomProperty.core.z, atomProp(StructureProperties.atom.z)), D(MolScript.structureQuery.atomProperty.core.sourceIndex, atomProp(StructureProperties.atom.sourceIndex)), + D(MolScript.structureQuery.atomProperty.core.operatorName, atomProp(StructureProperties.unit.operator_name)), D(MolScript.structureQuery.atomProperty.core.atomKey, (ctx, _) => cantorPairing(ctx.element.unit.id, ctx.element.element)), // TODO: diff --git a/src/mol-script/script/mol-script/symbols.ts b/src/mol-script/script/mol-script/symbols.ts index 43aa954f111bcb83f046ecfb26eec59eaf2b88c6..0c4826f7ffc685e85a84b9617b83188def6a6e06 100644 --- a/src/mol-script/script/mol-script/symbols.ts +++ b/src/mol-script/script/mol-script/symbols.ts @@ -7,7 +7,6 @@ import { UniqueArray } from 'mol-data/generic'; import Expression from '../../language/expression'; import { Argument, MSymbol } from '../../language/symbol'; -//import * as M from './macro' import { MolScriptSymbolTable as MolScript } from '../../language/symbol-table'; import Type from '../../language/type'; @@ -197,6 +196,7 @@ export const SymbolTable = [ Alias(MolScript.structureQuery.atomProperty.core.y, 'atom.y'), Alias(MolScript.structureQuery.atomProperty.core.z, 'atom.z'), Alias(MolScript.structureQuery.atomProperty.core.sourceIndex, 'atom.src-index'), + Alias(MolScript.structureQuery.atomProperty.core.operatorName, 'atom.op-name'), Alias(MolScript.structureQuery.atomProperty.core.atomKey, 'atom.key'), Alias(MolScript.structureQuery.atomProperty.core.bondCount, 'atom.bond-count'), @@ -343,4 +343,4 @@ export function transpileMolScript(expr: Expression) { // if (a.length === b.length) return (a < b) as any; // return a.length - b.length; // }); -//export default [...sortedSymbols, ...NamedArgs.map(a => ':' + a), ...Constants]; \ No newline at end of file +// export default [...sortedSymbols, ...NamedArgs.map(a => ':' + a), ...Constants]; \ No newline at end of file diff --git a/src/mol-state/state.ts b/src/mol-state/state.ts index 129969ad06fc7bd42e7c587ce4204f8fb36073ef..4da90c9051973657063640f83dd9143e380d5924 100644 --- a/src/mol-state/state.ts +++ b/src/mol-state/state.ts @@ -42,7 +42,8 @@ class State { removed: this.ev<State.ObjectEvent & { obj?: StateObject }>() }, log: this.ev<LogEntry>(), - changed: this.ev<void>() + changed: this.ev<void>(), + isUpdating: this.ev<boolean>() }; readonly behaviors = { @@ -130,6 +131,7 @@ class State { updateTree(tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions>): Task<void> updateTree(tree: StateTree | StateBuilder, options?: Partial<State.UpdateOptions>): Task<any> { return Task.create('Update Tree', async taskCtx => { + this.events.isUpdating.next(true); let updated = false; const ctx = this.updateTreeAndCreateCtx(tree, taskCtx, options); try { @@ -140,6 +142,7 @@ class State { } } finally { if (updated) this.events.changed.next(); + this.events.isUpdating.next(false); for (const ref of ctx.stateChanges) { this.events.cell.stateUpdated.next({ state: this, ref, cellState: this.tree.cellStates.get(ref) }); diff --git a/src/mol-util/now.ts b/src/mol-util/now.ts index d2d1037af7648a5af6fd8afbe22e27c001640c13..7a88af574b4b70147b5d99127c19d46abbd07ffb 100644 --- a/src/mol-util/now.ts +++ b/src/mol-util/now.ts @@ -28,7 +28,7 @@ namespace now { } -function formatTimespan(t: number) { +function formatTimespan(t: number, includeMsZeroes = true) { if (isNaN(t)) return 'n/a'; let h = Math.floor(t / (60 * 60 * 1000)), @@ -37,6 +37,7 @@ function formatTimespan(t: number) { ms = Math.floor(t % 1000).toString(); while (ms.length < 3) ms = '0' + ms; + while (!includeMsZeroes && ms.length > 1 && ms[ms.length - 1] === '0') ms = ms.substr(0, ms.length - 1); if (h > 0) return `${h}h${m}m${s}.${ms}s`; if (m > 0) return `${m}m${s}.${ms}s`; diff --git a/src/mol-util/param-definition.ts b/src/mol-util/param-definition.ts index 46d8abbfeb457cfbb5ae83f320374cb77cd266e2..3bb4e3ed84140937872cf12da198e9c63ed3501f 100644 --- a/src/mol-util/param-definition.ts +++ b/src/mol-util/param-definition.ts @@ -213,7 +213,14 @@ export namespace ParamDefinition { return { type: 'conditioned', select: Select<string>(conditionForValue(defaultValue) as string, options), defaultValue, conditionParams, conditionForValue, conditionedValue }; } - export type Any = Value<any> | Select<any> | MultiSelect<any> | Boolean | Text | Color | Vec3 | Numeric | FileParam | Interval | LineGraph | ColorScale<any> | Group<any> | Mapped<any> | Converted<any, any> | Conditioned<any, any, any> + export interface ScriptExpression extends Base<{ language: 'mol-script', expression: string }> { + type: 'script-expression' + } + export function ScriptExpression(defaultValue: ScriptExpression['defaultValue'], info?: Info): ScriptExpression { + return setInfo<ScriptExpression>({ type: 'script-expression', defaultValue }, info) + } + + export type Any = Value<any> | Select<any> | MultiSelect<any> | Boolean | Text | Color | Vec3 | Numeric | FileParam | Interval | LineGraph | ColorScale<any> | Group<any> | Mapped<any> | Converted<any, any> | Conditioned<any, any, any> | ScriptExpression export type Params = { [k: string]: Any } export type Values<T extends Params> = { [k in keyof T]: T[k]['defaultValue'] } @@ -301,6 +308,9 @@ export namespace ParamDefinition { return true; } else if (p.type === 'vec3') { return Vec3Data.equals(a, b); + } else if (p.type === 'script-expression') { + const u = a as ScriptExpression['defaultValue'], v = b as ScriptExpression['defaultValue']; + return u.language === v.language && u.expression === v.expression; } else if (typeof a === 'object' && typeof b === 'object') { return shallowEqual(a, b); }