Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ cgm = dega.viz.Clustergram(matrix=mat)
dega.viz.landscape_clustergram(landscape_ist, cgm)
```

### Embedding Visualizations

Use the :py:meth:`embed` method on any Celldega widget to generate a
standalone HTML snippet that loads the Celldega JavaScript library from a
CDN. The snippet stays interactive but no longer syncs with Python,
making it handy on platforms like Kaggle or Colab that have limited
widget support.

```python
landscape = dega.viz.Landscape(base_url=base_url)

# Display in the notebook
display(landscape.embed())

# Or write to a file
landscape.embed("landscape.html")
```

![Celldega Demo](public/assets/celldega-demo.png)

## 📖 Documentation & Examples
Expand Down
95 changes: 95 additions & 0 deletions src/celldega/viz/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
Widget module for interactive visualization components.
"""

from base64 import b64encode
import colorsys
from contextlib import suppress
from copy import deepcopy
import json
from pathlib import Path
import re
import urllib.error
import uuid
import warnings

import anywidget
Expand All @@ -24,6 +27,83 @@
_enrich_registry = {} # maps names to widget instances


def _npm_version() -> str:
"""Return the NPM package version matching the Python version."""
from celldega import __version__

return re.sub(r"a(\d+)$", r"-alpha.\1", __version__)


def _serialize_state(widget) -> dict:
"""Return a JSON-serializable state dictionary for *widget*."""

state = widget._get_embed_state()["state"].copy()

ignored = {
"layout",
"style",
"tooltip",
"tabbable",
"_anywidget_id",
"_dom_classes",
"_view_count",
"_model_module",
"_model_module_version",
"_model_name",
"_view_module",
"_view_module_version",
"_view_name",
}

for name in list(state):
if name in ignored or name.startswith("_"):
state.pop(name, None)

for name, val in state.items():
if isinstance(val, (bytes | bytearray)):
state[name] = b64encode(val).decode("ascii")

return state


def _embed_html(widget, fp: str | Path | None = None):
"""Create an embeddable HTML snippet for ``widget``."""

from IPython.display import HTML

state = _serialize_state(widget)
npm_version = _npm_version()
js_url = (
f"https://cdn.jsdelivr.net/npm/celldega@{npm_version}/src/celldega/static/widget.js"
)
div_id = f"celldega-{uuid.uuid4().hex}"
width = state.get("width", 0)
height = state.get("height", 800)
width_css = f"{width}px" if isinstance(width, (int | float)) and width else "100%"
height_css = (
f"{height}px" if isinstance(height, (int | float)) and height else "800px"
)

html = f"""
<div id='{div_id}' style='width: {width_css}; height: {height_css};'></div>
<script type='module'>
import celldega from '{js_url}';
const state = {json.dumps(state)};
const model = {{
get: (name) => state[name],
on: () => {{}}
}};
celldega.render({{ model, el: document.getElementById('{div_id}') }});
</script>
"""

if fp is not None:
Path(fp).write_text(html, encoding="utf-8")
return Path(fp)

return HTML(html)


def _hsv_to_hex(h: float) -> str:
"""Convert HSV color to hex string."""
r, g, b = colorsys.hsv_to_rgb(h, 0.65, 0.9)
Expand Down Expand Up @@ -269,6 +349,11 @@ def close(self): # pragma: no cover - cleanup depends on JS
self.send({"event": "finalize"})
super().close()

def embed(self, fp: str | Path | None = None, **_kwargs):
"""Return an embeddable HTML snippet of the widget."""

return _embed_html(self, fp)


class Enrich(anywidget.AnyWidget):
"""
Expand Down Expand Up @@ -341,6 +426,11 @@ def close(self): # pragma: no cover - cleanup depends on JS
self.send({"event": "finalize"})
super().close()

def embed(self, fp: str | Path | None = None, **_kwargs):
"""Return an embeddable HTML snippet of the widget."""

return _embed_html(self, fp)


class Clustergram(anywidget.AnyWidget):
"""
Expand Down Expand Up @@ -425,3 +515,8 @@ def close(self): # pragma: no cover - cleanup depends on JS
with suppress(Exception):
self.send({"event": "finalize"})
super().close()

def embed(self, fp: str | Path | None = None, **_kwargs):
"""Return an embeddable HTML snippet of the widget."""

return _embed_html(self, fp)
Loading