Panel for Chemistry 3D - candidate for Gallery post

I was wondering if this tutorial could be converted into something using pure Panel solution.
py3Dmol in Jupyter | Life is Worth Living (birdlet.github.io)

I think this would be a great addition to the Panel’s gallery and given some recent advances in bio-industry (thanks goes to COVID :frowning: ) illustrate few famous molecules and animate how protein folds would be a good marketing idea.

I’ve tried to use panel.pane.VTK as placeholder - so that I could populate a given molecule into it - using something like .object but that didn’t work.

I wonder if anyone have any ideas or objections.

Thanks in Advance!

1 Like

Hi @thoth291

As a starting point you can use

import panel as pn

pn.extension(sizing_mode="stretch_width")
html = """<script src="https://3Dmol.org/build/3Dmol-min.js" async></script>
         <div style="height: 100%; width: 100%; position: relative;" class='viewer_3Dmoljs' data-pdb='2POR' data-backgroundcolor='0xffffff' data-style='stick'></div>
         """
pn.pane.HTML(html, height=400).servable()

When you want more you can start building your own component :slight_smile:

import panel as pn
import param

pn.extension(sizing_mode="stretch_width")


class ThreeDMolViewer(param.Parameterized):
    pdb = param.String(
        doc="""
        The value describes a PDB ID to be loaded into the viewer."""
    )
    href = param.String(doc="""The value is a URL to a molecular data file.""")
    file_format = param.ObjectSelector(
        default="pdb",
        objects=["pdb", "sdf", "xyz", "mol2", "cube"],
        doc="""The value is the file format (default pdb; can be pdb, sdf, xyz, mol2, or cube)""",
    )
    background_color = param.Color(default="#ffffff")
    background_alpha = param.Number(
        default=1.0,
        bounds=(0.0, 1.0),
        step=0.01,
        doc="""
        The background alpha (default opaque: 1.0).""",
    )
    select = param.String(doc="""The value is an AtomSpec selection specification.""")
    style = param.ObjectSelector(
        default="stick",
        objects=["line", "cross", "stick", "sphere", "cartoon"],
        doc="""The value is a style specification. One of 'line', 'cross', 'stick', 'sphere' or 'cartoon'. Default is 'stick'""",
    )
    surface = param.String(doc="""A surface style specification""")
    labelres = param.String(doc="""A residue label style specification.""")
    zoomto = param.String(doc="""An AtomSpec selection specification to zoom to""")

    def __init__(self, height=400, **params):
        super().__init__(**params)

        self.view = pn.pane.HTML(self._repr_html_(), height=height)

    def _repr_html_(self):
        html = """<script src="https://3Dmol.org/build/3Dmol-min.js" async></script>
    <div style="height: 100%; width: 100%; position: relative;" class='viewer_3Dmoljs'"""
        if self.pdb:
            html += f" data-pdb='{self.pdb}' "
        if self.file_format and self.file_format != "pdb":
            html += f" data-type='{self.file_format}' "
        if self.background_color:
            html += f" data-backgroundcolor='{self.background_color}' "
        if self.background_alpha:
            html += f" data-backgroundalpha='{self.background_alpha}' "
        if self.select:
            html += f" data-select='{self.select}' "
        if self.style:
            html += f" data-style='{self.style}' "
        if self.surface:
            html += f" data-surface='{self.surface}' "
        if self.labelres:
            html += f" data-labelres='{self.labelres}' "
        if self.zoomto:
            html += f" data-zoomto='{self.zoomto}' "
        html += "></div> "
        return html

    @param.depends(
        "pdb",
        "href",
        "file_format",
        "background_color",
        "background_alpha",
        "style",
        "surface",
        "labelres",
        "zoomto",
        watch=True,
    )
    def _update_view(self, *events):
        self.view.object = self._repr_html_()


jsmol = ThreeDMolViewer(pdb="2POR", height=600)

pn.template.FastListTemplate(
    title="3DMol Viewer",
    sidebar=[pn.Param(jsmol)],
    main=[jsmol.view],
).servable()

1 Like

sagemath.org has been using jmol as a 3D function viewer like forever…
It can do much more than just display molecules!

1 Like

Do you have any specific links for inspiration @ea42gh ?

3d plots use jmol
https://doc.sagemath.org/html/en/tutorial/tour_plotting.html?highlight=jmol

1 Like

I’m sure I did everything wrong: I don’t know JS good enough to dig into it, I don’t know Parameterized (and prefer pure widget solution if possible), I’m still learning Panel (by doing, instead of reading docs).

So, @Marc , thank you for your post! Inspired by it I put together this

import panel as pn
pn.extension()
import param


class SMILESViewer(param.Parameterized):
    smiles = param.String(
        doc="""
        The value describes a molecule to be loaded into the viewer in SMILES format."""
    )
    opacity = param.Number(
        default=0.5,
        bounds=(0.0, 1.0),
        step=0.01,
        doc="""
        Mol alpha (default: 0.5).""",
    )
    height = param.Number(
        default=400,
        bounds=None,
        step=1,
        doc="""
        Height.""",
    )
    width = param.Number(
        default=400,
        bounds=None,
        step=1,
        doc="""
        Width.""",
    )
    select = param.String(doc="""The value is an AtomSpec selection specification.""")
    style = param.ObjectSelector(
        default="stick",
        objects=["line", "cross", "stick", "sphere", "cartoon"],
        doc="""The value is a style specification. One of 'line', 'cross', 'stick', 'sphere' or 'cartoon'. Default is 'stick'""",
    )
    surface = param.Boolean(default=False, doc="""A surface style specification""")
    zoomto = param.Boolean(default=True, doc="""An AtomSpec selection specification to zoom to""")

    def __init__(self, smiles=None, surface=False, style="stick", height=400, width=400, zoomto=True, opacity=0.5, **params):
        super().__init__(**params)
        self.view = pn.pane.HTML(height=height, width=width)
        self.height = height
        self.width = width
        self.smiles = smiles
        self.style = style
        self.surface = surface
        self.opacity = opacity
        self.zoomto = zoomto

    def _get_html_(self):
        from rdkit import Chem
        import py3Dmol
        assert self.style in ('line', 'stick', 'sphere', 'carton')
        #smi = 'COc3nc(OCc2ccc(C#N)c(c1ccc(C(=O)O)cc1)c2P(=O)(O)O)ccc3C[NH2+]CC(I)NC(=O)C(F)(Cl)Br'
        smi = self.smiles
        mol = self._smi2conf(smi)
        mblock = Chem.MolToMolBlock(mol)
        viewer = py3Dmol.view(width=self.width, height=self.height)
        viewer.addModel(mblock, 'mol')
        viewer.setStyle({self.style:{}})
        if self.surface:
            viewer.addSurface(py3Dmol.SAS, {'opacity': self.opacity})
        if self.zoomto:
            viewer.zoomTo()
        return viewer

    @param.depends(
        "smiles",
        "opacity",
        "height",
        "width",
        "style",
        "surface",
        "zoomto",
        watch=True,
    )
    def _update_view(self, *events):
        self.view.object = self._get_html_()
    
    def _repr_html(self):
        return self.view.object

    def _smi2conf(self,smiles):
        '''Convert SMILES to rdkit.Mol with 3D coordinates'''
        from rdkit import Chem
        from rdkit.Chem import AllChem
        mol = Chem.MolFromSmiles(self.smiles)
        if mol is not None:
            mol = Chem.AddHs(mol)
            AllChem.EmbedMolecule(mol)
            AllChem.MMFFOptimizeMolecule(mol, maxIters=200)
            return mol
        else:
            return None


jsmol = SMILESViewer(smiles='COc3nc(OCc2ccc(C#N)c(c1ccc(C(=O)O)cc1)c2P(=O)(O)O)ccc3C[NH2+]CC(I)NC(=O)C(F)(Cl)Br')

jsmol.view.object

Which works.
But what I really want is for the last line to be jsmol.view.
When I do it - I get this error:

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/opt/lib/python3.8/site-packages/IPython/core/formatters.py in __call__(self, obj, include, exclude)
    968 
    969             if method is not None:
--> 970                 return method(include=include, exclude=exclude)
    971             return None
    972         else:

/opt/lib/python3.8/site-packages/panel/viewable.py in _repr_mimebundle_(self, include, exclude)
    586         doc = _Document()
    587         comm = state._comm_manager.get_server_comm()
--> 588         model = self._render_model(doc, comm)
    589         ref = model.ref['id']
    590         manager = CommManager(comm_id=comm.id, plot_id=ref)

/opt/lib/python3.8/site-packages/panel/viewable.py in _render_model(self, doc, comm)
    425         if comm is None:
    426             comm = state._comm_manager.get_server_comm()
--> 427         model = self.get_root(doc, comm)
    428 
    429         if config.embed:

/opt/lib/python3.8/site-packages/panel/pane/base.py in get_root(self, doc, comm, preprocess)
    252         doc = init_doc(doc)
    253         if self._updates:
--> 254             root = self._get_model(doc, comm=comm)
    255         else:
    256             root = self.layout._get_model(doc, comm=comm)

/opt/lib/python3.8/site-packages/panel/pane/markup.py in _get_model(self, doc, root, parent, comm)
     39 
     40     def _get_model(self, doc, root=None, parent=None, comm=None):
---> 41         model = self._bokeh_model(**self._get_properties())
     42         if root is None:
     43             root = model

/opt/lib/python3.8/site-packages/panel/pane/markup.py in _get_properties(self)
     77         if hasattr(text, '_repr_html_'):
     78             text = text._repr_html_()
---> 79         return dict(properties, text=escape(text))
     80 
     81 

/opt/lib/python3.8/html/__init__.py in escape(s, quote)
     17     translated.
     18     """
---> 19     s = s.replace("&", "&amp;") # Must be done first!
     20     s = s.replace("<", "&lt;")
     21     s = s.replace(">", "&gt;")

AttributeError: 'NoneType' object has no attribute 'replace'

With this in the output cell:

HTML(view, height=400, sizing_mode='fixed', width=400)

One more annoying thing is that when I do use jsmol.view.object I get this <py3Dmol.view at 0x2b8c63aa1ac0> following the image. I wonder if that can be removed.

P.S. As you noticed I’m interested in visualizing by SMILES notation and not from file. There is a reason for that which originates from the researcher I’m working with.

Would be really appreciated for any advise!

Thanks in Advance!

Hi @thoth291,

I just playing around with py3Dmol with Panel and dropby on your thread. Thank you very much for your hard work for the code. Here, I added some lines to your code about the layout and how to use jsmol.view with panel.pane.HTML. Instead of passing the py3Dmol’s viewer as the object, the viewer.startjs + viewer.endjs could be use as the HTML object.

Here is the code:

import panel as pn
pn.extension()
import param

class SMILESViewer(pn.viewable.Viewer):
    smiles = param.String(
        doc="""
        The value describes a molecule to be loaded into the viewer in SMILES format."""
    )
    opacity = param.Number(
        default=0.5,
        bounds=(0.0, 1.0),
        step=0.01,
        doc="""
        Mol alpha (default: 0.5).""",
    )
    height = param.Number(
        default=400,
        bounds=None,
        step=1,
        doc="""
        Height.""",
    )
    width = param.Number(
        default=400,
        bounds=None,
        step=1,
        doc="""
        Width.""",
    )
    select = param.String(doc="""The value is an AtomSpec selection specification.""")
    style = param.ObjectSelector(
        default="stick",
        objects=["line", "cross", "stick", "sphere", "cartoon"],
        doc="""The value is a style specification. One of 'line', 'cross', 'stick', 'sphere' or 'cartoon'. Default is 'stick'""",
    )
    surface = param.Boolean(default=False, doc="""A surface style specification""")
    zoomto = param.Boolean(default=True, doc="""An AtomSpec selection specification to zoom to""")

    def __init__(self, smiles=None, surface=False, style="stick", height=400, width=400, zoomto=True, opacity=0.5, **params):
        super().__init__(**params)
        self.view = pn.pane.HTML(height=height, width=width)
        self.height = height
        self.width = width
        self.smiles = smiles
        self.style = style
        self.surface = surface
        self.opacity = opacity
        self.zoomto = zoomto
        self._layout = pn.Row(
            pn.Column(self.param.smiles,
                      self.param.height,
                      self.param.width,
                      self.param.style,
                      self.param.surface,
                      self.param.opacity,
                      self.param.zoomto,
                     ),
            self.view,
        )
    
    def __panel__(self):
        return self._layout

    def _get_html_(self):
        from rdkit import Chem
        import py3Dmol
        assert self.style in ("line", "cross", "stick", "sphere", "cartoon")
        #smi = 'COc3nc(OCc2ccc(C#N)c(c1ccc(C(=O)O)cc1)c2P(=O)(O)O)ccc3C[NH2+]CC(I)NC(=O)C(F)(Cl)Br'
        smi = self.smiles
        mol = self._smi2conf(smi)
        mblock = Chem.MolToMolBlock(mol)
        viewer = py3Dmol.view(width=self.width, height=self.height)
        viewer.addModel(mblock, 'mol')
        viewer.setStyle({self.style:{}})
        if self.surface:
            viewer.addSurface(py3Dmol.SAS, {'opacity': self.opacity})
        if self.zoomto:
            viewer.zoomTo()
        
        viewer_html = viewer.startjs + viewer.endjs
        return viewer_html

    @param.depends(
        "smiles",
        "opacity",
        "height",
        "width",
        "style",
        "surface",
        "zoomto",
        watch=True,
    )
    def _update_view(self, *events):
        self.view.object = self._get_html_()

    def _smi2conf(self,smiles):
        '''Convert SMILES to rdkit.Mol with 3D coordinates'''
        from rdkit import Chem
        from rdkit.Chem import AllChem
        mol = Chem.MolFromSmiles(self.smiles)
        if mol is not None:
            mol = Chem.AddHs(mol)
            AllChem.EmbedMolecule(mol)
            AllChem.MMFFOptimizeMolecule(mol, maxIters=200)
            return mol
        else:
            return None


jsmol = SMILESViewer(smiles='COc3nc(OCc2ccc(C#N)c(c1ccc(C(=O)O)cc1)c2P(=O)(O)O)ccc3C[NH2+]CC(I)NC(=O)C(F)(Cl)Br')

jsmol.servable()

The image is below.

Hope this is helpful.

1 Like

Thank you, @Arifin .
I think that this can be considered as solution to the problem.
This is really smart that you found a way to bypass issues by using some properties of Py3Dmol!
I wonder if this widget will land into Panel widget gallery.
Thank you!

2 Likes

Thanks for you comment @thoth291.
You may have interest to the https://github.com/MarcSkovMadsen/panel-chemistry repository.
Here, I am combining panel-chemistry’s JSMEEditor with your SMILESViewer. It could show the 3D structures when we edit structure on the JSME.

import panel as pn
pn.extension()
import param
from panel_chemistry.widgets import JSMEEditor

class SMILESViewer(pn.viewable.Viewer):
    smiles = param.String(
        doc="""
        The value describes a molecule to be loaded into the viewer in SMILES format."""
    )
    opacity = param.Number(
        default=0.5,
        bounds=(0.0, 1.0),
        step=0.01,
        doc="""
        Mol alpha (default: 0.5).""",
    )
    height = param.Number(
        default=400,
        bounds=None,
        step=1,
        doc="""
        Height.""",
    )
    width = param.Number(
        default=400,
        bounds=None,
        step=1,
        doc="""
        Width.""",
    )
    select = param.String(doc="""The value is an AtomSpec selection specification.""")
    style = param.ObjectSelector(
        default="stick",
        objects=["line", "cross", "stick", "sphere", "cartoon"],
        doc="""The value is a style specification. One of 'line', 'cross', 'stick', 'sphere' or 'cartoon'. Default is 'stick'""",
    )
    surface = param.Boolean(default=False, doc="""A surface style specification""")
    zoomto = param.Boolean(default=True, doc="""An AtomSpec selection specification to zoom to""")

    def __init__(self, smiles=None, surface=False, style="stick", height=400, width=400, zoomto=True, opacity=0.5, **params):
        super().__init__(**params)
        self.view = pn.pane.HTML(height=height, width=width)
        self.height = height
        self.width = width
        self.smiles = smiles
        self.style = style
        self.surface = surface
        self.opacity = opacity
        self.zoomto = zoomto
        self._layout = pn.Row(
            self.view,
            # pn.Column(self.param.smiles,
            #           self.param.height,
            #           self.param.width,
            #           self.param.style,
            #           self.param.surface,
            #           self.param.opacity,
            #           self.param.zoomto,
            #          ),
            
        )
    
    def __panel__(self):
        return self._layout

    def _get_html_(self):
        from rdkit import Chem
        import py3Dmol
        assert self.style in ("line", "cross", "stick", "sphere", "cartoon")
        #smi = 'COc3nc(OCc2ccc(C#N)c(c1ccc(C(=O)O)cc1)c2P(=O)(O)O)ccc3C[NH2+]CC(I)NC(=O)C(F)(Cl)Br'
        smi = self.smiles
        mol = self._smi2conf(smi)
        if mol is not None:
            mblock = Chem.MolToMolBlock(mol)
            viewer = py3Dmol.view(width=self.width, height=self.height)
            viewer.addModel(mblock, 'mol')
            viewer.setStyle({self.style:{}})
            if self.surface:
                viewer.addSurface(py3Dmol.SAS, {'opacity': self.opacity})
            if self.zoomto:
                viewer.zoomTo()

            viewer_html = viewer.startjs + viewer.endjs
            return viewer_html

    @param.depends(
        "smiles",
        "opacity",
        "height",
        "width",
        "style",
        "surface",
        "zoomto",
        watch=True,
    )
    def _update_view(self, *events):
        self.view.object = self._get_html_()

    def _smi2conf(self,smiles):
        '''Convert SMILES to rdkit.Mol with 3D coordinates'''
        from rdkit import Chem
        from rdkit.Chem import AllChem
        mol=None
        if self.smiles !="":
            mol = Chem.MolFromSmiles(self.smiles)
        if mol is not None:
            mol = Chem.AddHs(mol)
            AllChem.EmbedMolecule(mol)
            AllChem.MMFFOptimizeMolecule(mol, maxIters=200)
        return mol

class MolecularViewer(param.Parameterized):
    jsme = param.ClassSelector(class_=JSMEEditor)
    jsmol = param.ClassSelector(class_=SMILESViewer)
    smiles = param.String("")
    
    def __init__(self,jsme, jsmol, **params):
        params["jsme"] = jsme
        params["jsmol"] = jsmol
        super().__init__(**params)
        
    @param.depends("jsme.value", watch=True)
    def _update_smiles(self):
        self.smiles = self.jsme.value
        self.jsmol.smiles = self.jsme.value
        return self.jsmol.servable()
    
    def show(self):
        layout = pn.Row(self.jsme, self.jsmol.servable())
        return layout

pn.extension()
smiles=""
jsme=JSMEEditor(width=400, height=400, value=smiles, format="smiles")
jsmol=SMILESViewer(smiles=smiles)
molview=MolecularViewer(jsme,jsmol)
molview.show()

The image is below.
image

4 Likes

Hi @Arifin, @thoth291

I’ve included a Py3DMol pane in the panel-chemistry package.

I was thinking that maybe it would be an idea to ad an example of combining the JSMEEditory and the Py3DMol pane like you example above. What do you think?

If you make such an example before I do please share or even make a PR :slight_smile: Thanks.

You can checkout the reference Py3DMol notebook here Binder

1 Like