diff --git a/src/apps/rednatco/idents.ts b/src/apps/rednatco/idents.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7ae6c06d071a022a2664d02342c88a9ea72ec25 --- /dev/null +++ b/src/apps/rednatco/idents.ts @@ -0,0 +1,5 @@ +export type ID ='data'|'structure'|'visual'|'pyramids'; + +export function ID(id: ID, ref: string) { + return `${id}_${ref}`; +} diff --git a/src/apps/rednatco/index.html b/src/apps/rednatco/index.html new file mode 100644 index 0000000000000000000000000000000000000000..777ac354b294e1f203afef1232bd73c8b62437ec --- /dev/null +++ b/src/apps/rednatco/index.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="stylesheet" type="text/css" href="molstar.css" /> + </head> + <body> + <div id="app"></div> + <script type="text/javascript" src="./molstar.js"></script> + </body> +</html> diff --git a/src/apps/rednatco/index.tsx b/src/apps/rednatco/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9cd142aa682759e7393f5f0697a28add4809b56 --- /dev/null +++ b/src/apps/rednatco/index.tsx @@ -0,0 +1,328 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import * as IDs from './idents'; +import { DnatcoConfalPyramids } from '../../extensions/dnatco'; +import { ConfalPyramidsParams } from '../../extensions/dnatco/confal-pyramids/representation'; +import { ConfalPyramidsColorThemeParams } from '../../extensions/dnatco/confal-pyramids/color'; +import { Loci } from '../../mol-model/loci'; +import { Structure } from '../../mol-model/structure'; +import { PluginBehavior, PluginBehaviors } from '../../mol-plugin/behavior'; +import { PluginCommands } from '../../mol-plugin/commands'; +import { PluginContext } from '../../mol-plugin/context'; +import { PluginSpec } from '../../mol-plugin/spec'; +import { LociLabel } from '../../mol-plugin-state/manager/loci-label'; +import { PluginStateObject as PSO } from '../../mol-plugin-state/objects'; +import { StateTransforms } from '../../mol-plugin-state/transforms'; +import { RawData } from '../../mol-plugin-state/transforms/data'; +import { createPluginUI } from '../../mol-plugin-ui'; +import { PluginUIContext } from '../../mol-plugin-ui/context'; +import { DefaultPluginUISpec, PluginUISpec } from '../../mol-plugin-ui/spec'; +import { Representation } from '../../mol-repr/representation'; +import { StateSelection } from '../../mol-state'; +import { StateTreeSpine } from '../../mol-state/tree/spine'; +import { lociLabel } from '../../mol-theme/label'; +import { Color } from '../../mol-util/color'; +import { arrayMax } from '../../mol-util/array'; +import { Binding } from '../../mol-util/binding'; +import { ButtonsType, ModifiersKeys } from '../../mol-util/input/input-observer'; +import { MarkerAction } from '../../mol-util/marker-action'; +import { ParamDefinition as PD } from '../../mol-util/param-definition'; +import { ObjectKeys } from '../../mol-util/type-helpers'; +import './index.html'; + +const Extensions = { + 'ntc-balls-pyramids-prop': PluginSpec.Behavior(DnatcoConfalPyramids), +}; + +const BaseRef = 'rdo'; +const AnimationDurationMsec = 150; + +const ReDNATCOLociLabelProvider = PluginBehavior.create({ + name: 'watlas-loci-label-provider', + category: 'interaction', + ctor: class implements PluginBehavior<undefined> { + private f = { + label: (loci: Loci) => { + switch (loci.kind) { + case 'structure-loci': + case 'element-loci': + return lociLabel(loci); + default: + return ''; + } + }, + group: (label: LociLabel) => label.toString().replace(/Model [0-9]+/g, 'Models'), + priority: 100 + }; + register() { this.ctx.managers.lociLabels.addProvider(this.f); } + unregister() { this.ctx.managers.lociLabels.removeProvider(this.f); } + constructor(protected ctx: PluginContext) { } + }, + display: { name: 'ReDNATCO labeling' } +}); + +const ReDNATCOLociSelectionBindings = { + clickFocus: Binding([Binding.Trigger(ButtonsType.Flag.Secondary)], 'Focus camera on selected loci using ${triggers}'), + clickToggle: Binding([Binding.Trigger(ButtonsType.Flag.Primary)], 'Set selection to clicked element using ${triggers}.'), + clickDeselectAllOnEmpty: Binding([Binding.Trigger(ButtonsType.Flag.Primary)], 'Deselect all when clicking on nothing using ${triggers}.'), +}; +const ReDNATCOLociSelectionParams = { + bindings: PD.Value(ReDNATCOLociSelectionBindings, { isHidden: true }), +}; +type WatlasLociSelectionProps = PD.Values<typeof ReDNATCOLociSelectionParams>; + +const ReDNATCOLociSelectionProvider = PluginBehavior.create({ + name: 'watlas-loci-selection-provider', + category: 'interaction', + display: { name: 'Interactive loci selection' }, + params: () => ReDNATCOLociSelectionParams, + ctor: class extends PluginBehavior.Handler<WatlasLociSelectionProps> { + private spine: StateTreeSpine.Impl; + private lociMarkProvider = (reprLoci: Representation.Loci, action: MarkerAction) => { + if (!this.ctx.canvas3d) return; + this.ctx.canvas3d.mark({ loci: reprLoci.loci }, action); + }; + private applySelectMark(ref: string, clear?: boolean) { + const cell = this.ctx.state.data.cells.get(ref); + if (cell && PSO.isRepresentation3D(cell.obj)) { + this.spine.current = cell; + const so = this.spine.getRootOfType(PSO.Molecule.Structure); + if (so) { + if (clear) { + this.lociMarkProvider({ loci: Structure.Loci(so.data) }, MarkerAction.Deselect); + } + const loci = this.ctx.managers.structure.selection.getLoci(so.data); + this.lociMarkProvider({ loci }, MarkerAction.Select); + } + } + } + private focusOnLoci(loci: Representation.Loci) { + if (!this.ctx.canvas3d) + return; + + const sphere = Loci.getBoundingSphere(loci.loci); + if (!sphere) + return; + const snapshot = this.ctx.canvas3d.camera.getSnapshot(); + snapshot.target = sphere.center; + + PluginCommands.Camera.SetSnapshot(this.ctx, { snapshot, durationMs: AnimationDurationMsec }); + } + register() { + const lociIsEmpty = (current: Representation.Loci) => Loci.isEmpty(current.loci); + const lociIsNotEmpty = (current: Representation.Loci) => !Loci.isEmpty(current.loci); + + const actions: [keyof typeof ReDNATCOLociSelectionBindings, (current: Representation.Loci) => void, ((current: Representation.Loci) => boolean) | undefined][] = [ + ['clickFocus', current => this.focusOnLoci(current), lociIsNotEmpty], + ['clickDeselectAllOnEmpty', () => this.ctx.managers.interactivity.lociSelects.deselectAll(), lociIsEmpty], + ['clickToggle', current => { + if (current.loci.kind === 'element-loci') + this.ctx.managers.interactivity.lociSelects.toggle(current, true); + }, + lociIsNotEmpty], + ]; + + // sort the action so that the ones with more modifiers trigger sooner. + actions.sort((a, b) => { + const x = this.params.bindings[a[0]], y = this.params.bindings[b[0]]; + const k = x.triggers.length === 0 ? 0 : arrayMax(x.triggers.map(t => ModifiersKeys.size(t.modifiers))); + const l = y.triggers.length === 0 ? 0 : arrayMax(y.triggers.map(t => ModifiersKeys.size(t.modifiers))); + return l - k; + }); + + this.subscribeObservable(this.ctx.behaviors.interaction.click, ({ current, button, modifiers }) => { + if (!this.ctx.canvas3d) return; + + // only trigger the 1st action that matches + for (const [binding, action, condition] of actions) { + if (Binding.match(this.params.bindings[binding], button, modifiers) && (!condition || condition(current))) { + action(current); + break; + } + } + }); + + this.ctx.managers.interactivity.lociSelects.addProvider(this.lociMarkProvider); + + this.subscribeObservable(this.ctx.state.events.object.created, ({ ref }) => this.applySelectMark(ref)); + + // re-apply select-mark to all representation of an updated structure + this.subscribeObservable(this.ctx.state.events.object.updated, ({ ref, obj, oldObj, oldData, action }) => { + const cell = this.ctx.state.data.cells.get(ref); + if (cell && PSO.Molecule.Structure.is(cell.obj)) { + const structure: Structure = obj.data; + const oldStructure: Structure | undefined = action === 'recreate' ? oldObj?.data : + action === 'in-place' ? oldData : undefined; + if (oldStructure && + Structure.areEquivalent(structure, oldStructure) && + Structure.areHierarchiesEqual(structure, oldStructure)) return; + + const reprs = this.ctx.state.data.select(StateSelection.Generators.ofType(PSO.Molecule.Structure.Representation3D, ref)); + for (const repr of reprs) this.applySelectMark(repr.transform.ref, true); + } + }); + + } + unregister() { + } + constructor(ctx: PluginContext, params: WatlasLociSelectionProps) { + super(ctx, params); + this.spine = new StateTreeSpine.Impl(ctx.state.data.cells); + } + }, +}); + +class ReDNATCOMspViewer { + constructor(public plugin: PluginUIContext) { + } + + private pyramidsParams(colors: Map<string, Color>, visible: Map<string, boolean>, transparent: boolean) { + const typeParams = {} as PD.Values<ConfalPyramidsParams>; + for (const k of Reflect.ownKeys(ConfalPyramidsParams) as (keyof ConfalPyramidsParams)[]) { + if (ConfalPyramidsParams[k].type === 'boolean') + (typeParams[k] as any) = visible.get(k) ?? ConfalPyramidsParams[k]['defaultValue']; + } + + const colorParams = {} as Record<string, Color>; // HAKZ until we implement changeable pyramid colors in Molstar !!! + for (const k of Reflect.ownKeys(ConfalPyramidsColorThemeParams) as (keyof ConfalPyramidsColorThemeParams)[]) + colorParams[k] = colors.get(k) ?? ConfalPyramidsColorThemeParams[k]['defaultValue']; + + return { + type: { name: 'confal-pyramids', params: { ...typeParams, alpha: transparent ? 0.5 : 1.0 } }, + colorTheme: { name: 'confal-pyramids', params: colorParams } + }; + } + + static async create(target: HTMLElement) { + const defaultSpec = DefaultPluginUISpec(); + const spec: PluginUISpec = { + ...defaultSpec, + behaviors: [ + PluginSpec.Behavior(ReDNATCOLociLabelProvider), + PluginSpec.Behavior(PluginBehaviors.Representation.HighlightLoci), + PluginSpec.Behavior(ReDNATCOLociSelectionProvider), + ...ObjectKeys(Extensions).map(k => Extensions[k]), + ], + components: { + ...defaultSpec.components, + controls: { + ...defaultSpec.components?.controls, + top: 'none', + right: 'none', + bottom: 'none', + left: 'none' + }, + }, + layout: { + initial: { + isExpanded: false, + showControls: false, + }, + }, + }; + + const plugin = await createPluginUI(target, spec); + + plugin.managers.interactivity.setProps({ granularity: 'two-residues' }); + plugin.selectionMode = true; + + return new ReDNATCOMspViewer(plugin); + } + + async loadStructure(data: string, type: 'pdb'|'cif') { + await this.plugin.state.data.build().toRoot().commit(); + + const b = (t => type === 'pdb' + ? t.apply(StateTransforms.Model.TrajectoryFromPDB) + : t.apply(StateTransforms.Data.ParseCif).apply(StateTransforms.Model.TrajectoryFromMmCif) + )(this.plugin.state.data.build().toRoot().apply(RawData, { data }, { ref: IDs.ID('data', BaseRef) })) + .apply(StateTransforms.Model.ModelFromTrajectory, { modelIndex: 0 }) + .apply(StateTransforms.Model.StructureFromModel, {}, { ref: IDs.ID('structure', BaseRef) }) + .apply( + StateTransforms.Representation.StructureRepresentation3D, + { + type: { name: 'cartoon', params: { sizeFactor: 0.2, sizeAspectRatio: 0.35, aromaticBonds: false } }, + }, + { ref: IDs.ID('visual', BaseRef) } + ) + .to(IDs.ID('structure', BaseRef)) + .apply( + StateTransforms.Representation.StructureRepresentation3D, + this.pyramidsParams(new Map(), new Map(), false), + { ref: IDs.ID('pyramids', BaseRef) } + ); + + b.commit(); + } +} + +class ReDNATCOMsp extends React.Component<ReDNATCOMsp.Props> { + private viewer: ReDNATCOMspViewer|null = null; + + loadStructure(data: string, type: 'pdb'|'cif') { + if (this.viewer) + this.viewer.loadStructure(data, type); + } + + componentDidMount() { + if (!this.viewer) { + const elem = document.getElementById(this.props.elemId + '-viewer'); + ReDNATCOMspViewer.create(elem!).then(viewer => { + this.viewer = viewer; + ReDNATCOMspApi.bind(this); + + if (this.props.onInited) + this.props.onInited(); + }); + } + } + + render() { + return ( + <div className='rmsp-app'> + <div id={this.props.elemId + '-viewer'}></div> + <div>Controls</div> + </div> + ); + } +} + +namespace ReDNATCOMsp { + export interface Props { + elemId: string; + onInited?: () => void; + } + + export function init(elemId: string, onInited?: () => void) { + const elem = document.getElementById(elemId); + if (!elem) + throw new Error(`Element ${elemId} does not exist`); + + ReactDOM.render(<ReDNATCOMsp elemId={elemId} onInited={onInited} />, elem); + } +} + +class _ReDNATCOMspApi { + private target: ReDNATCOMsp|null = null; + + private check() { + if (!this.target) + throw new Error('ReDNATCOMsp object not bound'); + } + + bind(target: ReDNATCOMsp) { + this.target = target; + } + + init(elemId: string, onInited?: () => void) { + ReDNATCOMsp.init(elemId, onInited); + } + + loadStructure(data: string) { + this.check(); + this.target!.loadStructure(data, 'cif'); + } +} + +export const ReDNATCOMspApi = new _ReDNATCOMspApi(); + diff --git a/webpack.config.js b/webpack.config.js index 2610aab44cd972f6cafafd5e27e2d74c1296a2f6..69b56fc54cf396e8ffbbf20cfc3d1f8cb6c62a0c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -9,8 +9,7 @@ const tests = [ ]; module.exports = [ - createApp('viewer', 'molstar'), - createApp('docking-viewer', 'molstar'), + createApp('rednatco', 'molstar'), ...examples.map(createExample), ...tests.map(createBrowserTest) -]; \ No newline at end of file +]; diff --git a/webpack.config.production.js b/webpack.config.production.js index c7f1119b03b2dba19a31ceec1aa4d775683f1e0b..2a4e56a42d6ce82b4ad751b841a06d5ae58679b4 100644 --- a/webpack.config.production.js +++ b/webpack.config.production.js @@ -3,7 +3,6 @@ const { createApp, createExample } = require('./webpack.config.common.js'); const examples = ['proteopedia-wrapper', 'basic-wrapper', 'lighting', 'alpha-orbitals']; module.exports = [ - createApp('viewer', 'molstar'), - createApp('docking-viewer', 'molstar'), + createApp('rednatco', 'molstar'), ...examples.map(createExample) -]; \ No newline at end of file +];