diff --git a/src/mol-plugin/index.ts b/src/mol-plugin/index.ts index 419577f39d8e52b1e66515fc59dd14cca773ecc9..732ec22067933fdfd5b642677df4c8a2a0c6ff13 100644 --- a/src/mol-plugin/index.ts +++ b/src/mol-plugin/index.ts @@ -77,10 +77,7 @@ export const DefaultPluginSpec: PluginSpec = { AnimateAssemblyUnwind, AnimateUnitsExplode, AnimateStateInterpolation - ], - layout: { - controls: { top: 'none' } - } + ] } export function createPlugin(target: HTMLElement, spec?: PluginSpec): PluginContext { diff --git a/src/mol-plugin/skin/base/components/sequence.scss b/src/mol-plugin/skin/base/components/sequence.scss new file mode 100644 index 0000000000000000000000000000000000000000..c4e59e97d38eb3e4b89c99984adae93f86a22854 --- /dev/null +++ b/src/mol-plugin/skin/base/components/sequence.scss @@ -0,0 +1,23 @@ +.msp-sequence { + position: absolute; + right: 0; + top: 0; + left: 0; + bottom: 0; + overflow-y: scroll; + overflow-x: hidden; + font-size: 90%; + background: $sequence-background; +} + +.msp-sequence-entity { + word-break: break-word; + padding: $info-vertical-padding $control-spacing $info-vertical-padding $control-spacing; + user-select: none; +} + +.msp-sequence-entity { + span { + cursor: pointer; + } +} \ No newline at end of file diff --git a/src/mol-plugin/skin/base/ui.scss b/src/mol-plugin/skin/base/ui.scss index 7610eb8782137e39a2d45cdf5767724eddfcc5d7..7f9ab4ab194096c1d51ed026a0dd206038884b1b 100644 --- a/src/mol-plugin/skin/base/ui.scss +++ b/src/mol-plugin/skin/base/ui.scss @@ -1,12 +1,12 @@ @mixin non-selectable { - -webkit-user-select: none; /* Chrome/Safari */ + -webkit-user-select: none; /* Chrome/Safari */ -moz-user-select: none; /* Firefox */ -ms-user-select: none; /* IE10+ */ /* Rules below not implemented in browsers yet */ -o-user-select: none; user-select: none; - + cursor: default; } @@ -16,18 +16,18 @@ } ::-webkit-scrollbar-track { - //-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.8); + //-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.8); border-radius: 0; background-color: color-lower-contrast($control-background, 4%); } ::-webkit-scrollbar-thumb { border-radius: 0; - //-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.9); + //-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.9); background-color: color-lower-contrast($control-background, 8%); } -@import 'components/controls-base'; +@import 'components/controls-base'; @import 'components/controls'; @import 'components/slider'; @import 'components/panel'; @@ -37,6 +37,7 @@ @import 'components/tasks'; @import 'components/viewport'; @import 'components/log'; +@import 'components/sequence'; @import 'components/transformer'; @import 'components/toast'; @import 'components/help'; diff --git a/src/mol-plugin/skin/base/variables.scss b/src/mol-plugin/skin/base/variables.scss index 6b440af0449f577c28c950d04456fa49fc7f74a5..ba76987f7cc5072132a773838102fc2a66550de9 100644 --- a/src/mol-plugin/skin/base/variables.scss +++ b/src/mol-plugin/skin/base/variables.scss @@ -1,5 +1,5 @@ -// measures +// measures $control-label-width: 110px; $row-height: 32px; @@ -28,14 +28,14 @@ $standard-top-height: 2 * $row-height + 1; // TypeClass = 'Root' | 'Group' | 'Data' | 'Object' | 'Visual' | 'Selection' | 'Action' | 'Behaviour' // DO NOT CHANGE THESE!! -$entity-color-Root: $default-background; -$entity-color-Data: color-lower-contrast(#95a5a6, 15%); -$entity-color-Selection: color-lower-contrast(#e74c3c, 15%); -$entity-color-Action: color-lower-contrast(#34495e, 10%); -$entity-color-Object: color-lower-contrast(#2ecc71, 10%); -$entity-color-Behaviour: color-lower-contrast(#9b59b6, 10%); -$entity-color-Visual: color-lower-contrast(#3498db, 5%); -$entity-color-Group: color-lower-contrast(#e67e22, 5%); +$entity-color-Root: $default-background; +$entity-color-Data: color-lower-contrast(#95a5a6, 15%); +$entity-color-Selection: color-lower-contrast(#e74c3c, 15%); +$entity-color-Action: color-lower-contrast(#34495e, 10%); +$entity-color-Object: color-lower-contrast(#2ecc71, 10%); +$entity-color-Behaviour: color-lower-contrast(#9b59b6, 10%); +$entity-color-Visual: color-lower-contrast(#3498db, 5%); +$entity-color-Group: color-lower-contrast(#e67e22, 5%); ////////////////////////////////////////////////// // COLORS and COMPUTED COLORS @@ -43,8 +43,8 @@ $entity-color-Group: color-lower-contrast(#e67e22, 5%); $slider-disabledColor: #ccc; $control-background: color-increase-contrast($default-background, 6.5%); -$border-color: color-increase-contrast($default-background, 15%); -$msp-form-control-background: color-lower-contrast($default-background, 2.5%); +$border-color: color-increase-contrast($default-background, 15%); +$msp-form-control-background: color-lower-contrast($default-background, 2.5%); // buttons $msp-btn-link-font-color: $font-color; @@ -72,7 +72,10 @@ $highlight-info-font-color: $hover-font-color; $highlight-info-additional-font-color: color-lower-contrast($hover-font-color, 20%); // entity state -$entity-color-fully-visible: $font-color; +$entity-color-fully-visible: $font-color; $entity-color-not-visible: color-lower-contrast($font-color, 66%); $entity-color-partialy-visible: color-lower-contrast($font-color, 33%); $entity-tag-color: color-lower-contrast($font-color, 20%); + +// sequence +$sequence-background: $default-background; \ No newline at end of file diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx index f1c36001acb3bd6b8e96edd92c6a076edef761f9..615a35103d95d108f0119246232854868d70d5d2 100644 --- a/src/mol-plugin/ui/plugin.tsx +++ b/src/mol-plugin/ui/plugin.tsx @@ -20,6 +20,7 @@ import { BackgroundTaskProgress } from './task'; import { Viewport, ViewportControls } from './viewport'; import { StateTransform } from '../../mol-state'; import { UpdateTransformControl } from './state/update-transform'; +import { SequenceView } from './sequence'; export class Plugin extends React.Component<{ plugin: PluginContext }, {}> { @@ -92,7 +93,7 @@ class Layout extends PluginUIComponent { <div className={`msp-plugin-content ${layout.isExpanded ? 'msp-layout-expanded' : 'msp-layout-standard msp-layout-standard-outside'}`}> <div className={this.layoutVisibilityClassName}> {this.region('main', ViewportWrapper)} - {layout.showControls && controls.top !== 'none' && this.region('top', controls.top)} + {layout.showControls && controls.top !== 'none' && this.region('top', controls.top || SequenceView)} {layout.showControls && controls.left !== 'none' && this.region('left', controls.left || State)} {layout.showControls && controls.right !== 'none' && this.region('right', controls.right || ControlsWrapper)} {layout.showControls && controls.bottom !== 'none' && this.region('bottom', controls.bottom || Log)} diff --git a/src/mol-plugin/ui/sequence.tsx b/src/mol-plugin/ui/sequence.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a4375eaad886e55bea25aa968deb09f1ceedaf80 --- /dev/null +++ b/src/mol-plugin/ui/sequence.tsx @@ -0,0 +1,248 @@ +/** + * Copyright (c) 2018-2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react' +import { Structure, StructureSequence, Queries, StructureSelection, StructureProperties as SP, StructureQuery, StructureElement, Unit } from '../../mol-model/structure'; +import { PluginUIComponent } from './base'; +import { StateTreeSpine } from '../../mol-state/tree/spine'; +import { PluginStateObject as SO } from '../state/objects'; +import { Interaction } from '../util/interaction'; +import { OrderedSet, Interval } from '../../mol-data/int'; +import { Loci } from '../../mol-model/loci'; +import { applyMarkerAction, MarkerAction } from '../../mol-geo/geometry/marker-data'; +import { ButtonsType, ModifiersKeys, getButtons, getModifiers } from '../../mol-util/input/input-observer'; + + +export class SequenceView extends PluginUIComponent<{ }, { }> { + private spine: StateTreeSpine.Impl + + componentDidMount() { + this.spine = new StateTreeSpine.Impl(this.plugin.state.dataState.cells); + + this.subscribe(this.plugin.state.behavior.currentObject, o => { + const current = this.plugin.state.dataState.cells.get(o.ref)!; + this.spine.current = current + this.forceUpdate(); + }); + + this.subscribe(this.plugin.events.state.object.updated, ({ ref, state }) => { + const current = this.spine.current; + if (!current || current.sourceRef !== ref || current.state !== state) return; + this.forceUpdate(); + }); + } + + private getStructure() { + const so = this.spine && this.spine.getRootOfType(SO.Molecule.Structure) + return so && so.data + } + + render() { + const s = this.getStructure(); + if (!s) return <div className='msp-sequence'> + <div className='msp-sequence-entity'>No structure available</div> + </div>; + + const seqs = s.models[0].sequence.sequences; + return <div className='msp-sequence'> + {seqs.map((seq, i) => <EntitySequence key={i} seq={seq} structure={s} /> )} + </div>; + } +} + +function createQuery(entityId: string, label_seq_id: number) { + return Queries.generators.atoms({ + entityTest: ctx => { + return SP.entity.id(ctx.element) === entityId + }, + residueTest: ctx => { + if (ctx.element.unit.kind === Unit.Kind.Atomic) { + return SP.residue.label_seq_id(ctx.element) === label_seq_id + } else { + return ( + SP.coarse.seq_id_begin(ctx.element) <= label_seq_id && + SP.coarse.seq_id_end(ctx.element) >= label_seq_id + ) + } + } + }); +} + +function getSeqIdInterval(location: StructureElement): Interval { + const { unit, element } = location + const { model } = unit + switch (unit.kind) { + case Unit.Kind.Atomic: + const residueIndex = model.atomicHierarchy.residueAtomSegments.index[element] + const seqId = model.atomicHierarchy.residues.label_seq_id.value(residueIndex) + return Interval.ofSingleton(seqId) + case Unit.Kind.Spheres: + return Interval.ofRange( + model.coarseHierarchy.spheres.seq_id_begin.value(element), + model.coarseHierarchy.spheres.seq_id_end.value(element) + ) + case Unit.Kind.Gaussians: + return Interval.ofRange( + model.coarseHierarchy.gaussians.seq_id_begin.value(element), + model.coarseHierarchy.gaussians.seq_id_end.value(element) + ) + } +} + +type StructureSeq = { structure: Structure, seq: StructureSequence.Entity } + +function eachResidue(loci: Loci, structureSeq: StructureSeq, apply: (interval: Interval) => boolean) { + let changed = false + const { structure, seq } = structureSeq + if (!StructureElement.isLoci(loci)) return false + if (!Structure.areParentsEquivalent(loci.structure, structure)) return false + const l = StructureElement.create() + for (const e of loci.elements) { + l.unit = e.unit + OrderedSet.forEach(e.indices, v => { + l.element = e.unit.elements[v] + const entityId = SP.entity.id(l) + if (entityId === seq.entityId) { + if (apply(getSeqIdInterval(l))) changed = true + } + }) + } + return changed +} + +function markResidue(loci: Loci, structureSeq: StructureSeq, array: Uint8Array, action: MarkerAction) { + const { structure, seq } = structureSeq + return eachResidue(loci, { structure , seq }, (i: Interval) => { + let changed = false + OrderedSet.forEach(i, (v: number) => { + const start = Interval.start(i) - 1 + const end = Interval.end(i) - 1 + if (applyMarkerAction(array, start, end, action)) changed = true + }) + return changed + }) +} + +// TODO: this is really inefficient and should be done using a canvas. +class EntitySequence extends PluginUIComponent<{ seq: StructureSequence.Entity, structure: Structure }, { markerData: { array: Uint8Array } }> { + state = { + markerData: { array: new Uint8Array(this.props.seq.sequence.sequence.length) } + } + + private lociHighlightProvider = (loci: Interaction.Loci, action: MarkerAction) => { + const { array } = this.state.markerData; + const { structure, seq } = this.props + const changed = markResidue(loci.loci, { structure , seq }, array, action) + if (changed) this.setState({ markerData: { array } }) + } + + private lociSelectionProvider = (loci: Interaction.Loci, action: MarkerAction) => { + const { array } = this.state.markerData; + const { structure, seq } = this.props + const changed = markResidue(loci.loci, { structure , seq }, array, action) + if (changed) this.setState({ markerData: { array } }) + } + + componentDidMount() { + this.plugin.lociHighlights.addProvider(this.lociHighlightProvider) + this.plugin.lociSelections.addProvider(this.lociSelectionProvider) + } + + componentWillUnmount() { + this.plugin.lociHighlights.removeProvider(this.lociHighlightProvider) + this.plugin.lociSelections.removeProvider(this.lociSelectionProvider) + } + + getLoci(seqId: number) { + const query = createQuery(this.props.seq.entityId, seqId); + return StructureSelection.toLoci2(StructureQuery.run(query, this.props.structure)); + } + + highlight(seqId?: number, modifiers?: ModifiersKeys) { + const ev = { current: Interaction.Loci.Empty, modifiers } + if (seqId !== undefined) { + const loci = this.getLoci(seqId); + if (loci.elements.length > 0) ev.current = { loci }; + } + this.plugin.behaviors.interaction.highlight.next(ev) + } + + click(seqId: number | undefined, buttons: ButtonsType, modifiers: ModifiersKeys) { + const ev = { current: Interaction.Loci.Empty, buttons, modifiers } + if (seqId !== undefined) { + const loci = this.getLoci(seqId); + if (loci.elements.length > 0) ev.current = { loci }; + } + this.plugin.behaviors.interaction.click.next(ev) + } + + contextMenu = (e: React.MouseEvent) => { + e.preventDefault() + } + + mouseDown = (e: React.MouseEvent) => { + const buttons = getButtons(e.nativeEvent) + const modifiers = getModifiers(e.nativeEvent) + this.click(undefined, buttons, modifiers); + } + + render() { + const { markerData } = this.state; + const { seq } = this.props; + const { offset, sequence } = seq.sequence; + + const elems: JSX.Element[] = []; + for (let i = 0, _i = sequence.length; i < _i; i++) { + elems[elems.length] = <Residue seqId={offset + i + 1} letter={sequence[i]} parent={this} marker={markerData.array[i]} key={i} />; + } + + return <div + className='msp-sequence-entity' + onContextMenu={this.contextMenu} + onMouseDown={this.mouseDown} + > + <span style={{ fontWeight: 'bold' }}>{this.props.seq.entityId}:{offset} </span> + {elems} + </div>; + } +} + +class Residue extends PluginUIComponent<{ seqId: number, letter: string, parent: EntitySequence, marker: number }> { + + mouseEnter = (e: React.MouseEvent) => { + const modifiers = getModifiers(e.nativeEvent) + this.props.parent.highlight(this.props.seqId, modifiers); + } + + mouseLeave = () => { + this.props.parent.highlight(); + } + + mouseDown = (e: React.MouseEvent) => { + const buttons = getButtons(e.nativeEvent) + const modifiers = getModifiers(e.nativeEvent) + this.props.parent.click(this.props.seqId, buttons, modifiers); + e.stopPropagation() // so that `parent.mouseDown` is not called + } + + getBackgroundColor() { + // TODO make marker color configurable + if (this.props.marker === 0) return '' + if (this.props.marker % 2 === 0) return 'rgb(51, 255, 25)' // selected + return 'rgb(255, 102, 153)' // highlighted + } + + render() { + return <span + onMouseEnter={this.mouseEnter} + onMouseLeave={this.mouseLeave} + onMouseDown={this.mouseDown} + style={{ backgroundColor: this.getBackgroundColor() }}> + {this.props.letter} + </span>; + } +} \ No newline at end of file