diff --git a/example.ipynb b/example.ipynb index f3d3b76..9cc8bb4 100644 --- a/example.ipynb +++ b/example.ipynb @@ -12,9 +12,7 @@ { "cell_type": "code", "execution_count": null, - "metadata": { - "scrolled": true - }, + "metadata": {}, "outputs": [], "source": [ "view = PDBeMolstar(\n", @@ -22,8 +20,17 @@ " theme=\"light\",\n", " hide_water=True,\n", " visual_style=\"cartoon\",\n", - " spin=True,\n", + " spin=False,\n", + " lighting='glossy',\n", ")\n", + "\n", + "view.tooltips = { 'data': [\n", + " { 'struct_asym_id': 'A', 'tooltip': 'Custom tooltip for chain A' }, \n", + " { 'struct_asym_id': 'B', 'tooltip': 'Custom tooltip for chain B' }, \n", + " { 'struct_asym_id': 'C', 'tooltip': 'Custom tooltip for chain C' }, \n", + " { 'struct_asym_id': 'D', 'tooltip': 'Custom tooltip for chain D' }, \n", + " ] }\n", + "\n", "view" ] }, @@ -33,20 +40,7 @@ "metadata": {}, "outputs": [], "source": [ - "# load local files via custom_data\n", - "from pathlib import Path \n", - "fpth = Path().resolve() / 'assets' / '6vsb.bcif'\n", - "custom_data = {\n", - " 'data': fpth.read_bytes(),\n", - " 'format': 'cif',\n", - " 'binary': True,\n", - " }\n", - "view = PDBeMolstar(\n", - " custom_data=custom_data, \n", - " hide_water=True,\n", - " hide_carbs=True,\n", - ")\n", - "view" + "view.clear_tooltips()" ] }, { @@ -55,7 +49,48 @@ "metadata": {}, "outputs": [], "source": [ - "view.molecule_id = \"1cbs\"" + "# color residues\n", + "data = [\n", + " {\n", + " \"start_residue_number\": 0,\n", + " \"end_residue_number\": 153,\n", + " \"struct_asym_id\": \"A\",\n", + " \"color\": {\"r\": 100, \"g\": 150, \"b\": 120},\n", + " \"focus\": False,\n", + " },\n", + " {\n", + " \"start_residue_number\": 30,\n", + " \"end_residue_number\": 153,\n", + " \"struct_asym_id\": \"B\",\n", + " \"color\": {\"r\": 213, \"g\": 52, \"b\": 235},\n", + " \"focus\": False,\n", + " },\n", + "]\n", + "\n", + "view.color(data, non_selected_color={\"r\": 0, \"g\": 87, \"b\": 0})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# color residues, keep previous coloring\n", + "data = [\n", + " {\n", + " \"struct_asym_id\": \"C\",\n", + " \"color\": {\"r\": 255, \"g\": 255, \"b\": 0},\n", + " \"focus\": False,\n", + " },\n", + "]\n", + "\n", + "color_data = {\n", + " 'data': data, \n", + " \"nonSelectedColor\": None,\n", + " \"keepColors\": True\n", + "}\n", + "view.color_data = color_data" ] }, { @@ -64,7 +99,12 @@ "metadata": {}, "outputs": [], "source": [ - "view.hide_water = True" + "# keep current colors, change representation of chain D\n", + "data = [\n", + " {\"struct_asym_id\": \"D\",'representation': 'gaussian-surface', 'representationColor': '#ff00ff'}\n", + "]\n", + "color_data = {'data': data, \"keepColors\": True, 'keepRepresentations': False}\n", + "view.color_data = color_data" ] }, { @@ -73,7 +113,20 @@ "metadata": {}, "outputs": [], "source": [ - "view.hide_water" + "# load local files via custom_data\n", + "from pathlib import Path \n", + "fpth = Path().resolve() / 'assets' / '6vsb.bcif'\n", + "custom_data = {\n", + " 'data': fpth.read_bytes(),\n", + " 'format': 'cif',\n", + " 'binary': True,\n", + " }\n", + "view = PDBeMolstar(\n", + " custom_data=custom_data, \n", + " hide_water=True,\n", + " hide_carbs=True,\n", + ")\n", + "view" ] }, { @@ -137,41 +190,6 @@ "view.clear_highlight()" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# color residues\n", - "data = [\n", - " {\n", - " \"start_residue_number\": 0,\n", - " \"end_residue_number\": 153,\n", - " \"struct_asym_id\": \"A\",\n", - " \"color\": {\"r\": 100, \"g\": 150, \"b\": 120},\n", - " \"focus\": False,\n", - " },\n", - " {\n", - " \"start_residue_number\": 30,\n", - " \"end_residue_number\": 153,\n", - " \"struct_asym_id\": \"B\",\n", - " \"color\": {\"r\": 213, \"g\": 52, \"b\": 235},\n", - " \"focus\": False,\n", - " },\n", - "]\n", - "view.color(data, non_selected_color={\"r\": 0, \"g\": 87, \"b\": 0})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "view.loaded" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/examples/rainbow_residues.py b/examples/rainbow_residues.py new file mode 100644 index 0000000..6bd2e99 --- /dev/null +++ b/examples/rainbow_residues.py @@ -0,0 +1,97 @@ +import statistics +from dataclasses import asdict, dataclass +from io import StringIO +from pathlib import Path + +import requests +import solara +import solara.lab +from Bio.PDB import PDBParser, Residue, Structure +from ipymolstar import THEMES, PDBeMolstar +from matplotlib import colormaps +from matplotlib.colors import Normalize +from solara.alias import rv + +AMINO_ACIDS = [ + "ALA", + "ARG", + "ASN", + "ASP", + "CYS", + "GLN", + "GLU", + "GLY", + "HIS", + "ILE", + "LEU", + "LYS", + "MET", + "PHE", + "PRO", + "PYL", + "SEC", + "SER", + "THR", + "TRP", + "TYR", + "VAL", +] + +# %% + +root = Path(__file__).parent.parent +pdb_path = root / "assets" / "1qyn.pdb" + +parser = PDBParser(QUIET=True) +structure = parser.get_structure("1qyn", pdb_path) +MAX_R = max(r.id[1] for r in structure.get_residues()) + +# %% + +custom_data = {"data": pdb_path.read_bytes(), "format": "pdb", "binary": False} + + +# %% +def color_residues( + structure: Structure.Structure, auth: bool = False, phase: int = 0 +) -> dict: + _, resn, _ = zip( + *[r.id for r in structure.get_residues() if r.get_resname() in AMINO_ACIDS] + ) + + rmin, rmax = min(resn), max(resn) + # todo check for off by one errors + norm = Normalize(vmin=rmin, vmax=rmax) + auth_str = "_auth" if auth else "" + + cmap = colormaps["hsv"] + data = [] + for i in range(rmin, rmax + 1): + range_size = rmax + 1 - rmin + j = rmin + ((i - rmin + phase) % range_size) + r, g, b, a = cmap(norm(i), bytes=True) + color = {"r": int(r), "g": int(g), "b": int(b)} + elem = { + f"start{auth_str}_residue_number": j, + f"end{auth_str}_residue_number": j, + "color": color, + "focus": False, + } + data.append(elem) + + color_data = {"data": data, "nonSelectedColor": None} + return color_data + + +@solara.component +def Page(): + phase = solara.use_reactive(0) + color_data = color_residues(structure, auth=True, phase=phase.value) + with solara.Card(): + PDBeMolstar.element( + custom_data=custom_data, hide_water=True, color_data=color_data + ) + solara.FloatSlider(label="Phase", min=0, max=MAX_R, value=phase, step=1) + + +Page() diff --git a/src/ipymolstar/pdbe-dark.css b/src/ipymolstar/pdbe-dark.css index d323bee..f702982 100644 --- a/src/ipymolstar/pdbe-dark.css +++ b/src/ipymolstar/pdbe-dark.css @@ -1 +1 @@ -@import url('https://www.ebi.ac.uk/pdbe/pdb-component-library/css/pdbe-molstar-3.1.3.css'); \ No newline at end of file +@import url('https://cdn.jsdelivr.net/npm/pdbe-molstar@3.2.0/build/pdbe-molstar.css'); \ No newline at end of file diff --git a/src/ipymolstar/pdbe-light.css b/src/ipymolstar/pdbe-light.css index c56ca50..1ba5981 100644 --- a/src/ipymolstar/pdbe-light.css +++ b/src/ipymolstar/pdbe-light.css @@ -1 +1 @@ -@import url('https://www.ebi.ac.uk/pdbe/pdb-component-library/css/pdbe-molstar-light-3.1.3.css'); \ No newline at end of file +@import url('https://cdn.jsdelivr.net/npm/pdbe-molstar@3.2.0/build/pdbe-molstar-light.css'); \ No newline at end of file diff --git a/src/ipymolstar/widget.js b/src/ipymolstar/widget.js index 862b461..fad1e47 100644 --- a/src/ipymolstar/widget.js +++ b/src/ipymolstar/widget.js @@ -1,5 +1,4 @@ -import * as myModule from "https://www.ebi.ac.uk/pdbe/pdb-component-library/js/pdbe-molstar-plugin-3.1.3.js"; -// import * as myModule from "https://cdn.jsdelivr.net/npm/pdbe-molstar@latest/build/pdbe-molstar-plugin.js" +import * as myModule from "https://cdn.jsdelivr.net/npm/pdbe-molstar@3.2.0/build/pdbe-molstar-plugin.js" function standardize_color(str) { var ctx = document.createElement("canvas").getContext("2d"); @@ -168,6 +167,12 @@ function render({ model, el }) { viewerInstance.visual.select(selectValue); } }, + "change:tooltips": () => { + const tooltipValue = model.get("tooltips"); + if (tooltipValue !== null) { + viewerInstance.visual.tooltips(tooltipValue); + } + }, }; let otherCallbacks = { @@ -177,6 +182,12 @@ function render({ model, el }) { "change:custom_data": () => { viewerInstance.visual.update(getOptions(model), true); }, + "change:visual_style": () => { + viewerInstance.visual.update(getOptions(model), true); + }, + // "change:lighting": () => { + // viewerInstance.visual.update(getOptions(model), true); + // }, "change:bg_color": () => { viewerInstance.canvas.setBgColor(toRgb(model.get("bg_color"))); }, @@ -186,6 +197,9 @@ function render({ model, el }) { viewerInstance.visual.reset(resetValue); } }, + "change:_clear_tooltips": () => { + viewerInstance.visual.clearTooltips(); + } }; let combinedCallbacks = Object.assign( @@ -210,50 +224,6 @@ function render({ model, el }) { model.save_changes(); }); - // TODO return unsubscribe - - // these require re-render - // model.on("change:visual_style", () => { - // viewerInstance.visual.update({visualStyle: model.get('visual_style')}); - // console.log(model.get('visual_style')); - // }); - - // model.on("change:lighting", () => { - // viewerInstance.visual.update({lighting: model.get('lighting')}); - // }); - - // model.on("change:_focus", () => { - // const focusValue = model.get("_focus"); - // if (focusValue !== null) { - // viewerInstance.visual.focus(focusValue); - // } - // }); - // model.on("change:_highlight", () => { - // const highlightValue = model.get("_highlight"); - // if (highlightValue !== null) { - // viewerInstance.visual.highlight(highlightValue); - // } - // }); - // model.on("change:_clear_highlight", () => { - // 1; - // viewerInstance.visual.clearHighlight(); - // }); - // model.on("change:_clear_selection", () => { - // viewerInstance.visual.clearSelection(model.get("_args")["number"]); - // }); - // }); - // model.on("change:_update", () => { - // const updateValue = model.get("_update"); - // if (updateValue !== null) { - // viewerInstance.visual.update(updateValue); - // } - - // }); - // model.on("change:hide_coarse", () => { - // viewerInstance.visual.visibility({ water: !model.get("hide_coarse") }); - // }); - - // this could be a loop? return () => { unsubscribes.forEach((unsubscribe) => unsubscribe()); }; diff --git a/src/ipymolstar/widget.py b/src/ipymolstar/widget.py index 6d173a4..57a0dac 100644 --- a/src/ipymolstar/widget.py +++ b/src/ipymolstar/widget.py @@ -2,6 +2,7 @@ import anywidget import traitlets +from typing import Optional, List, TypedDict, Any THEMES = { @@ -16,6 +17,52 @@ } +class Color(TypedDict): + r: int + g: int + b: int + + +# codeieum translation of QueryParam from +# https://github.com/molstar/pdbe-molstar/blob/master/src/app/helpers.ts#L180 +class QueryParam(TypedDict, total=False): + auth_seq_id: Optional[int] + entity_id: Optional[str] + auth_asym_id: Optional[str] + struct_asym_id: Optional[str] + residue_number: Optional[int] + start_residue_number: Optional[int] + end_residue_number: Optional[int] + auth_residue_number: Optional[int] + auth_ins_code_id: Optional[str] + start_auth_residue_number: Optional[int] + start_auth_ins_code_id: Optional[str] + end_auth_residue_number: Optional[int] + end_auth_ins_code_id: Optional[str] + atoms: Optional[List[str]] + label_comp_id: Optional[str] + color: Optional[Color] + sideChain: Optional[bool] + representation: Optional[str] + representationColor: Optional[Color] + focus: Optional[bool] + tooltip: Optional[str] + start: Optional[Any] + end: Optional[Any] + atom_id: Optional[List[int]] + uniprot_accession: Optional[str] + uniprot_residue_number: Optional[int] + start_uniprot_residue_number: Optional[int] + end_uniprot_residue_number: Optional[int] + + +class ResetParam(TypedDict, total=False): + camera: Optional[bool] + theme: Optional[bool] + highlightColor: Optional[bool] + selectColor: Optional[bool] + + class PDBeMolstar(anywidget.AnyWidget): _esm = pathlib.Path(__file__).parent / "widget.js" _css = pathlib.Path(__file__).parent / "pdbe-light.css" @@ -109,6 +156,8 @@ class PDBeMolstar(anywidget.AnyWidget): _clear_highlight = traitlets.Bool(default_value=False).tag(sync=True) color_data = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True) _clear_selection = traitlets.Bool(default_value=False).tag(sync=True) + tooltips = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True) + _clear_tooltips = traitlets.Bool(default_value=False).tag(sync=True) _set_color = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True) _reset = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True) _update = traitlets.Dict(allow_none=True, default_value=None).tag(sync=True) @@ -122,7 +171,13 @@ def __init__(self, theme="light", **kwargs): bg_color = kwargs.pop("bg_color", THEMES[theme]["bg_color"]) super().__init__(_css=_css, bg_color=bg_color, **kwargs) - def color(self, data, non_selected_color=None): + def color( + self, + data: list[QueryParam], + non_selected_color=None, + keep_colors=False, + keep_representations=False, + ) -> None: """ Alias for PDBE Molstar's `select` method. @@ -130,29 +185,47 @@ def color(self, data, non_selected_color=None): details """ - self.color_data = {"data": data, "nonSelectedColor": non_selected_color} + self.color_data = { + "data": data, + "nonSelectedColor": non_selected_color, + "keepColors": keep_colors, + "keepRepresentations": keep_representations, + } self.color_data = None - def focus(self, data): + def focus(self, data: list[QueryParam]): self._focus = data self._focus = None - def highlight(self, data): + def highlight(self, data: list[QueryParam]): self._highlight = data self._highlight = None def clear_highlight(self): self._clear_highlight = not self._clear_highlight + def clear_tooltips(self): + self._clear_tooltips = not self._clear_tooltips + def clear_selection(self, structure_number=None): + # move payload to the traitlet which triggers the callback self._args = {"number": structure_number} self._clear_selection = not self._clear_selection - def set_color(self, data): - self._set_color = data - self._set_color = None - - def reset(self, data): + # todo make two traits: select_color, hightlight_color + def set_color( + self, highlight: Optional[Color] = None, select: Optional[Color] = None + ): + data = {} + if highlight is not None: + data["highlight"] = highlight + if select is not None: + data["select"] = select + if data: + self._set_color = data + self._set_color = None + + def reset(self, data: ResetParam): self._reset = data self._reset = None