NGL viewer ReactiveHTML

Hi everyone, I just made my first ReactiveHTML which I’m very excited about and I wanted to share.

Its the NGL molecular viewer, which I was using before but as a custom bokeh widget.
(this can be found in the panel-chemistry repo)

As I was struggling to get custom bokeh extensions to built correctly for distribution in PyPi, the ReactiveHTML is far easier for me to use, as well as its easier to extend/update as I can just modify the python code.

I’ve added some additional shenanigans on top here just to play around with some nice colors:
(only works if you upload a .pdb file and requires BioPython/PyHDX 0.4.0b4)

Code:

from itertools import groupby, count

import param
from panel.reactive import ReactiveHTML
import panel as pn
import pandas as pd
import numpy as np

import proplot as pplt
from pyhdx.support import apply_cmap
from Bio.PDB import PDBParser

# monkey patch PBDParser
def get_structure(self, id, lines):
    """Return the structure.

    Arguments:
     - id - string, the id that will be used for the structure
     - lines - lines of the PDB file

    """

    self.header = None
    self.trailer = None
    # Make a StructureBuilder instance (pass id of structure as parameter)
    self.structure_builder.init_structure(id)

    self._parse(lines)

    self.structure_builder.set_header(self.header)
    # Return the Structure instance
    structure = self.structure_builder.get_structure()

    return structure

PDBParser.get_structure = get_structure
parser = PDBParser()

REPRESENTATIONS = [
    # "base",
    # "distance",
    "axes",
    "backbone",
    "ball+stick",
    "cartoon",
    "helixorient",
    "hyperball",
    "label",
    "licorice",
    "line",
    "point",
    "ribbon",
    "rocket",
    "rope",
    "spacefill",
    "surface",
    "trace",
    "unitcell",
    # "validation",
]
COLOR_SCHEMES = [
    "atomindex",
    "bfactor",
    "chainid",
    "chainindex",
    "chainname",
    "custom",
    "densityfit",
    "electrostatic",
    "element",
    "entityindex",
    "entitytype",
    "geoquality",
    "hydrophobicity",
    "modelindex",
    "moleculetype",
    "occupancy",
    "random",
    "residueindex",
    "resname",
    "sstruc",
    "uniform",
    "value",
    "volume",
]
EXTENSIONS = [
    "",  # no extension is allowed?
    "pdb",
    "cif",
    "csv",
    "ent",
    "gro",
    "json",
    "mcif",
    "mmcif",
    "mmtf",
    "mol2",
    "msgpack",
    "netcdf",
    "parm7",
    "pqr",
    "prmtop",
    "psf",
    "sd",
    "sdf",
    "top",
    "txt",
    "xml",
]


class NGL(ReactiveHTML):

    object = param.String()

    extension = param.Selector(
        default="pdb",
        objects=EXTENSIONS,
    )

    representation = param.Selector(
        default="ball+stick",
        objects=REPRESENTATIONS,
        doc="""
         A display representation. Default is 'ball+stick'. See
         http://nglviewer.org/ngl/api/manual/coloring.html#representations
         """,
    )

    selection = param.String(
        default="not ( water or ion )",
        doc="""
        Selection string to apply representations to. Default is to exclude water and ions. See
        http://nglviewer.org/ngl/api/manual/selection-language.html
        """
    )

    color_scheme = param.String('chainid')

    custom_color_scheme = param.List(
        default=[["white", "*"]],
        doc="""
        A custom color scheme. See
        http://nglviewer.org/ngl/api/manual/coloring.html#custom-coloring.""",
    )

    spin = param.Boolean(False)

    _template = """
    <div id="ngl_stage" style="width:100%; height:800px;"></div>
    """
    _scripts = {
        'render': """
            var stage = new NGL.Stage(ngl_stage)
            state._stage = stage
            stage.handleResize();
        """,
        'object': """
            self.updateStage()
            """,
        'color_scheme': """
            self.setParameters()
            """,
        'custom_color_scheme': """
            self.setParameters()
        """,
        'setParameters': """
            if (state._stage.compList.length !== 0) {
                const parameters = self.getParameters();
                state._stage.compList[0].reprList[0].setParameters( parameters );
            }
            self.after_layout
            """,
        'getParameters': """
            if (data.color_scheme==="custom"){
                var scheme = NGL.ColormakerRegistry.addSelectionScheme( data.custom_color_scheme, "new scheme")
                var parameters = {color: scheme}
            }
            else {
                var parameters = {colorScheme: data.color_scheme}
            }
            parameters["sele"] = data.selection
            
            return parameters
        """,
        'representation': """
            const parameters = self.getParameters();
            const component = state._stage.compList[0];
            component.removeAllRepresentations();
            component.addRepresentation(data.representation, parameters);
            """,
        'spin': """
            state._stage.setSpin(data.spin);
            """,
        'updateStage': """
            parameters = self.getParameters();
            state._stage.removeAllComponents()
            state._stage.loadFile(new Blob([data.object], {type: 'text/plain'}), { ext: data.extension}).then(function (component) {
              component.addRepresentation(data.representation, parameters);
              component.autoView();
            });
            """,
        'after_layout':"""
            state._stage.handleResize();
            """


    }

    __javascript__ = [
        "https://unpkg.com/ngl@2.0.0-dev.38/dist/ngl.js",
    ]


class NGLControl(param.Parameterized):
    ngl = param.ClassSelector(NGL, precedence=-1)

    color_scheme = param.Selector(
        default="chainid",
        objects=COLOR_SCHEMES,
        doc="""
    A predefined or 'custom' color scheme. If 'custom' is specified you need to specify the
    'custom_color_scheme' parameter. Default is 'element'. See
    http://nglviewer.org/ngl/api/manual/coloring.html""",
    )

    custom_color_scheme = param.List(precedence=-1)

    representation = param.Selector(
        default="ball+stick",
        objects=REPRESENTATIONS,
        doc="""
         A display representation. Default is 'ball+stick'. See
         http://nglviewer.org/ngl/api/manual/coloring.html#representations
         """,
    )

    phase = param.Number(0., bounds=(0., 2*np.pi))

    wavelength = param.Number(np.pi, bounds=(0.1*np.pi, 2.*np.pi))

    spin = param.Boolean(False)

    def __init__(self, **params):
        super().__init__(**params)

        params = self.param.params().keys() & self.ngl.param.params().keys() - {'name', 'phase', 'frequency'}
        self.param.watch(self._update_params, list(params))

        # file_input = param.Bytes() -> pn.Param(widgets={'file_input': pn.widgets.FileInput)
        self.file_input = pn.widgets.FileInput(accept=','.join('.' + s for s in EXTENSIONS[1:]))
        self.file_input.param.watch(self._file_input_updated, ['value', 'filename'])

        self.c_term = 2

    def _file_input_updated(self, *events):
        for event in events:  # Is this always length 1?
            name = self.file_input.filename.split('.')[0]
            ext = self.file_input.filename.split('.')[-1]
            # Set extension first as it does not trigger update of the ReactiveHTML
            self.extension = ext
            object = self.file_input.value.decode('utf-8')
            self.ngl.object = object

            if ext == 'pdb':
                lines = object.split('\n')
                structure = parser.get_structure(name, lines)

                self.c_term = max(r.get_id()[1] for r in structure.get_residues() if r.resname not in ['HOH'])
                self._sin_updated()

    def _update_params(self, *events):
        for event in events:
            setattr(self.ngl, event.name, event.new)

    @param.depends('phase', 'wavelength', watch=True)
    def _sin_updated(self):
        t = np.linspace(0, 2 * np.pi, num=self.c_term, endpoint=True)
        f = 1./self.wavelength
        s = pd.Series(np.sin(2*np.pi*f*t + self.phase))

        colors = apply_cmap(s, cmap, norm)
        colors.index += 1

        self.set_color_scheme(colors)

    def set_color_scheme(self, colors):
        """Set the colors of protein residues

        Parameters
        ----------

        colors : :class:`pd.Series`
            Panda series with hexadecimal colors. Index should match residue number

        """
        grp = colors.groupby(colors)

        color_list = []
        for c, pd_series in grp:
            result = [list(g) for _, g in groupby(pd_series.index, key=lambda n, c=count(): n - next(c))]

            resi = ' or '.join([f'{g[0]}-{g[-1]}' for g in result])
            color_list.append([c, resi])

        self.custom_color_scheme = color_list


# proplot / pyhdx functions
cmap = pplt.Colormap('turbo')
norm = pplt.Norm('linear', -1, 1.)

ngl = NGL(sizing_mode='stretch_both')
ngl_control = NGLControl(ngl=ngl)

controls = pn.Column(ngl_control.file_input, *pn.Param(ngl_control))
ngl_pane = pn.Pane(ngl, sizing_mode='stretch_both')
app = pn.Row(ngl_pane, controls)

app.servable()



6 Likes