diff --git a/src/mol-plugin/behavior.ts b/src/mol-plugin/behavior.ts index 86339477be343557bbb6f51a1d3229cd14462db7..682fe1b1ccaf0fa07e7b818bca10ded4ecd815ff 100644 --- a/src/mol-plugin/behavior.ts +++ b/src/mol-plugin/behavior.ts @@ -15,6 +15,7 @@ import * as DynamicRepresentation from './behavior/dynamic/representation' import * as DynamicCamera from './behavior/dynamic/camera' import * as DynamicCustomProps from './behavior/dynamic/custom-props' import * as DynamicAnimation from './behavior/dynamic/animation' +import * as DynamicLabels from './behavior/dynamic/labels' export const BuiltInPluginBehaviors = { State: StaticState, @@ -27,5 +28,6 @@ export const PluginBehaviors = { Representation: DynamicRepresentation, Camera: DynamicCamera, CustomProps: DynamicCustomProps, - Animation: DynamicAnimation + Animation: DynamicAnimation, + Labels: DynamicLabels } \ No newline at end of file diff --git a/src/mol-plugin/behavior/behavior.ts b/src/mol-plugin/behavior/behavior.ts index c463184fa10e9fe14740ae056decb8282b0c9a9e..be542ec6132a939d98e9e56769fe8ec7114de197 100644 --- a/src/mol-plugin/behavior/behavior.ts +++ b/src/mol-plugin/behavior/behavior.ts @@ -95,7 +95,7 @@ namespace PluginBehavior { for (const s of this.subs) s.unsubscribe(); this.subs = []; } - update(params: P): boolean { + update(params: P): boolean | Promise<boolean> { if (shallowEqual(params, this.params)) return false; this.params = params; return true; diff --git a/src/mol-plugin/behavior/dynamic/labels.ts b/src/mol-plugin/behavior/dynamic/labels.ts new file mode 100644 index 0000000000000000000000000000000000000000..655a72016638378dbac99b0ff58bc68851c6831c --- /dev/null +++ b/src/mol-plugin/behavior/dynamic/labels.ts @@ -0,0 +1,233 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + */ + +import { PluginContext } from 'mol-plugin/context'; +import { PluginBehavior } from '../behavior'; +import { ParamDefinition as PD } from 'mol-util/param-definition' +import { Mat4, Vec3 } from 'mol-math/linear-algebra'; +import { PluginStateObject as SO, PluginStateObject } from '../../state/objects'; +import { StateSelection } from 'mol-state/state/selection'; +import { StateObjectCell, State } from 'mol-state'; +import { RuntimeContext } from 'mol-task'; +import { Shape } from 'mol-model/shape'; +import { Text } from 'mol-geo/geometry/text/text'; +import { ShapeRepresentation } from 'mol-repr/shape/representation'; +import { ColorNames } from 'mol-util/color/tables'; +import { TextBuilder } from 'mol-geo/geometry/text/text-builder'; +import { Unit, StructureElement, StructureProperties } from 'mol-model/structure'; +import { SetUtils } from 'mol-util/set'; +import { arrayEqual } from 'mol-util'; +import { MoleculeType } from 'mol-model/structure/model/types'; + +// TODO +// - support more object types than structures +// - tether label to the element nearest to the bounding sphere center +// - [Started] multiple levels of labels: structure, polymer, ligand +// - show structure/unit label only when there is a representation with sufficient overlap +// - support highlighting +// - better support saccharides (use data available after re-mediation) +// - size based on min bbox dimension (to avoid huge labels for very long but narrow polymers) +// - fixed size labels (invariant to zoom) [needs feature in text geo] +// - ??? max label length +// - ??? multi line labels [needs feature in text geo] +// - ??? use prevalent (how to define) color of representations of a structure to color the label +// - completely different approach (render not as 3d objects): overlay free layout in screenspace with occlusion info from bboxes + +export type SceneLabelsLevels = 'structure' | 'polymer' | 'ligand' + +export const SceneLabelsParams = { + ...Text.Params, + + background: PD.Boolean(true), + backgroundMargin: PD.Numeric(0.2, { min: 0, max: 1, step: 0.01 }), + backgroundColor: PD.Color(ColorNames.snow), + backgroundOpacity: PD.Numeric(0.9, { min: 0, max: 1, step: 0.01 }), + + levels: PD.MultiSelect([] as SceneLabelsLevels[], [ + ['structure', 'structure'], ['polymer', 'polymer'], ['ligand', 'ligand'] + ] as [SceneLabelsLevels, string][]), +} +export type SceneLabelsParams = typeof SceneLabelsParams +export type SceneLabelsProps = PD.Values<typeof SceneLabelsParams> + +interface LabelsData { + transforms: Mat4[] + texts: string[] + positions: Vec3[] + sizes: number[] + depths: number[] +} + +function getLabelsText(data: LabelsData, props: PD.Values<Text.Params>, text?: Text) { + const { texts, positions, depths } = data + const textBuilder = TextBuilder.create(props, texts.length * 10, texts.length * 10 / 2, text) + for (let i = 0, il = texts.length; i < il; ++i) { + const p = positions[i] + textBuilder.add(texts[i], p[0], p[1], p[2], depths[i], i) + } + return textBuilder.getText() +} + +export const SceneLabels = PluginBehavior.create<SceneLabelsProps>({ + name: 'scene-labels', + display: { name: 'Scene Labels', group: 'Labels' }, + ctor: class extends PluginBehavior.Handler<SceneLabelsProps> { + private data: LabelsData = { + transforms: [Mat4.identity()], + texts: [], + positions: [], + sizes: [], + depths: [] + } + private repr: ShapeRepresentation<LabelsData, Text, SceneLabelsParams> + private geo = Text.createEmpty() + private structures = new Set<SO.Molecule.Structure>() + + constructor(protected ctx: PluginContext, protected params: SceneLabelsProps) { + super(ctx, params) + this.repr = ShapeRepresentation(this.getLabelsShape, Text.Utils) + ctx.events.state.object.created.subscribe(this.triggerUpdate) + ctx.events.state.object.removed.subscribe(this.triggerUpdate) + ctx.events.state.object.updated.subscribe(this.triggerUpdate) + ctx.events.state.cell.stateUpdated.subscribe(this.triggerUpdate) + } + + private triggerUpdate = async () => { + await this.update(this.params) + } + + private getColor = () => ColorNames.dimgrey + private getSize = (groupId: number) => this.data.sizes[groupId] + private getLabel = () => '' + + private getLabelsShape = (ctx: RuntimeContext, data: LabelsData, props: SceneLabelsProps, shape?: Shape<Text>) => { + this.geo = getLabelsText(data, props, this.geo) + return Shape.create('Scene Labels', this.geo, this.getColor, this.getSize, this.getLabel, data.transforms) + } + + /** Update structures to be labeled, returns true if changed */ + private updateStructures(p: SceneLabelsProps) { + const state = this.ctx.state.dataState + const structures = state.select(q => q.rootsOfType(PluginStateObject.Molecule.Structure)); + const rootStructures = new Set<SO.Molecule.Structure>() + for (const s of structures) { + const rootStructure = getRootStructure(s, state) + if (!rootStructure || !SO.Molecule.Structure.is(rootStructure.obj)) continue + if (!state.cellStates.get(s.transform.ref).isHidden) { + rootStructures.add(rootStructure.obj) + } + } + if (!SetUtils.areEqual(rootStructures, this.structures)) { + this.structures = rootStructures + return true + } else { + return false + } + } + + private updateLabels(p: SceneLabelsProps) { + const l = StructureElement.create() + + const { texts, positions, sizes, depths } = this.data + texts.length = 0 + positions.length = 0 + sizes.length = 0 + depths.length = 0 + + this.structures.forEach(structure => { + if (p.levels.includes('structure')) { + texts.push(`${structure.data.model.label}`) + positions.push(structure.data.boundary.sphere.center) + sizes.push(structure.data.boundary.sphere.radius / 10) + depths.push(structure.data.boundary.sphere.radius) + } + + for (let i = 0, il = structure.data.units.length; i < il; ++i) { + let label = '' + const u = structure.data.units[i] + l.unit = u + l.element = u.elements[0] + + if (p.levels.includes('polymer') && u.polymerElements.length) { + label = `${StructureProperties.entity.pdbx_description(l)} (${getAsymId(u)(l)})` + } + + if (p.levels.includes('ligand') && !u.polymerElements.length) { + const compId = StructureProperties.residue.label_comp_id(l) + const chemComp = u.model.properties.chemicalComponentMap.get(compId) + const moleculeType = chemComp ? chemComp.moleculeType : MoleculeType.unknown + if (moleculeType === MoleculeType.other || moleculeType === MoleculeType.saccharide) { + label = `${StructureProperties.entity.pdbx_description(l)} (${getAsymId(u)(l)})` + } + } + + if (label) { + texts.push(label) + const { center, radius } = u.lookup3d.boundary.sphere + const transformedCenter = Vec3.transformMat4(Vec3.zero(), center, u.conformation.operator.matrix) + positions.push(transformedCenter) + sizes.push(Math.max(2, radius / 10)) + depths.push(radius) + } + } + }) + } + + register(): void { } + + async update(p: SceneLabelsProps) { + // console.log('update') + let updated = false + if (this.updateStructures(p) || !arrayEqual(this.params.levels, p.levels)) { + // console.log('update with data') + this.updateLabels(p) + await this.repr.createOrUpdate(p, this.data).run() + updated = true + } else if (!PD.areEqual(SceneLabelsParams, this.params, p)) { + // console.log('update props only') + await this.repr.createOrUpdate(p).run() + updated = true + } + if (updated) { + Object.assign(this.params, p) + this.ctx.canvas3d.add(this.repr) + } + return updated; + } + + unregister() { + + } + }, + params: () => SceneLabelsParams +}); + +// + +function getRootStructure(root: StateObjectCell, state: State) { + let parent: StateObjectCell | undefined + while (true) { + const _parent = StateSelection.findAncestorOfType(state.tree, state.cells, root.transform.ref, [PluginStateObject.Molecule.Structure]) + if (_parent) { + parent = _parent + root = _parent + } else { + break + } + } + return parent ? parent : + SO.Molecule.Structure.is(root.obj) ? root : undefined +} + +function getAsymId(unit: Unit): StructureElement.Property<string> { + switch (unit.kind) { + case Unit.Kind.Atomic: + return StructureProperties.chain.auth_asym_id + case Unit.Kind.Spheres: + case Unit.Kind.Gaussians: + return StructureProperties.coarse.asym_id + } +} \ No newline at end of file diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts index ad6c9ebc6c105583ba435c90d6ac77d13a6fa063..55eed51e3f48ae9a6945a6f2e1c9a3110aa17b02 100644 --- a/src/mol-plugin/index.ts +++ b/src/mol-plugin/index.ts @@ -14,6 +14,8 @@ import { PluginSpec } from './spec'; import { DownloadStructure, CreateComplexRepresentation, OpenStructure } from './state/actions/basic'; import { StateTransforms } from './state/transforms'; import { PluginBehaviors } from './behavior'; +import { ParamDefinition as PD } from 'mol-util/param-definition' +import { SceneLabelsParams } from './behavior/dynamic/labels'; function getParam(name: string, regex: string): string { let r = new RegExp(`${name}=(${regex})[&]?`, 'i'); @@ -38,6 +40,7 @@ const DefaultSpec: PluginSpec = { PluginSpec.Behavior(PluginBehaviors.Representation.DefaultLociLabelProvider), PluginSpec.Behavior(PluginBehaviors.Camera.FocusLociOnSelect, { minRadius: 20, extraRadius: 4 }), PluginSpec.Behavior(PluginBehaviors.Animation.StructureAnimation, { rotate: false, rotateValue: 0, explode: false, explodeValue: 0 }), + PluginSpec.Behavior(PluginBehaviors.Labels.SceneLabels, { ...PD.getDefaultValues(SceneLabelsParams) }), // TODO how to properly call PluginBehaviors.Labels.SceneLabels.definition.params() PluginSpec.Behavior(PluginBehaviors.CustomProps.PDBeStructureQualityReport, { autoAttach: true }), PluginSpec.Behavior(PluginBehaviors.CustomProps.RCSBAssemblySymmetry, { autoAttach: true }), ]