Browse Source

simple (entity) sequence view

Alexander Rose 5 years ago
parent
commit
40cf348d40

+ 1 - 4
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 {

+ 23 - 0
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;
+    }
+}

+ 6 - 5
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';

+ 15 - 12
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;

+ 2 - 1
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)}

+ 248 - 0
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}&nbsp;</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>;
+    }
+}