react-bio-viz 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- react_bio_viz-0.1.0/.gitignore +11 -0
- react_bio_viz-0.1.0/PKG-INFO +141 -0
- react_bio_viz-0.1.0/README.md +119 -0
- react_bio_viz-0.1.0/build.mjs +35 -0
- react_bio_viz-0.1.0/hatch_build.py +46 -0
- react_bio_viz-0.1.0/js/_storeAdapter.ts +49 -0
- react_bio_viz-0.1.0/js/widget.tsx +163 -0
- react_bio_viz-0.1.0/package.json +24 -0
- react_bio_viz-0.1.0/pyproject.toml +56 -0
- react_bio_viz-0.1.0/src/react_bio_viz/__init__.py +44 -0
- react_bio_viz-0.1.0/src/react_bio_viz/_base.py +74 -0
- react_bio_viz-0.1.0/src/react_bio_viz/blasthitdistribution.py +44 -0
- react_bio_viz-0.1.0/src/react_bio_viz/distancematrix.py +40 -0
- react_bio_viz-0.1.0/src/react_bio_viz/genemodel.py +35 -0
- react_bio_viz-0.1.0/src/react_bio_viz/genomebrowser.py +31 -0
- react_bio_viz-0.1.0/src/react_bio_viz/io.py +133 -0
- react_bio_viz-0.1.0/src/react_bio_viz/msa.py +61 -0
- react_bio_viz-0.1.0/src/react_bio_viz/phylotree.py +80 -0
- react_bio_viz-0.1.0/src/react_bio_viz/static/widget.css +2 -0
- react_bio_viz-0.1.0/src/react_bio_viz/static/widget.js +100 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Built by `pnpm --filter @react-bio-viz/python-widgets build` (and by the hatchling build hook
|
|
2
|
+
# when packaging). Ships inside the wheel/sdist; not tracked in git.
|
|
3
|
+
src/react_bio_viz/static/widget.js
|
|
4
|
+
src/react_bio_viz/static/widget.css
|
|
5
|
+
|
|
6
|
+
node_modules/
|
|
7
|
+
.venv/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
__pycache__/
|
|
11
|
+
.pytest_cache/
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: react-bio-viz
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Jupyter widgets for biological data visualization: multiple sequence alignments, phylogenetic trees, gene models, genome browser tracks, and BLAST hit distributions
|
|
5
|
+
Project-URL: Homepage, https://github.com/holmrenser/react-bio-viz
|
|
6
|
+
Project-URL: Repository, https://github.com/holmrenser/react-bio-viz
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: bioinformatics,jupyter,msa,phylogenetics,visualization,widget
|
|
9
|
+
Classifier: Framework :: Jupyter
|
|
10
|
+
Classifier: Framework :: Jupyter :: JupyterLab :: 3
|
|
11
|
+
Classifier: Framework :: Jupyter :: JupyterLab :: 4
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: anywidget>=0.9
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: anywidget[dev]>=0.9; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# react-bio-viz
|
|
24
|
+
|
|
25
|
+
Jupyter widgets for biological data visualization: multiple sequence alignments, phylogenetic
|
|
26
|
+
trees, distance matrices, gene models, genome browser tracks, and BLAST hit distributions.
|
|
27
|
+
|
|
28
|
+
These are [anywidget](https://anywidget.dev) bindings around the `react-bio-viz` React components,
|
|
29
|
+
so the same visualizations run in a notebook and in a web app.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install react-bio-viz
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from react_bio_viz import MSA
|
|
41
|
+
|
|
42
|
+
widget = MSA(msa=[
|
|
43
|
+
{"header": "seq1", "sequence": "MKTAYIAKQRQISFVK"},
|
|
44
|
+
{"header": "seq2", "sequence": "MKTAYIAKQRQISFVR"},
|
|
45
|
+
])
|
|
46
|
+
widget
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Every piece of interactive state is an ordinary synced traitlet, so the kernel can both **drive**
|
|
50
|
+
and **observe** it — no bespoke callback API, just traitlets' own idiom:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
# Observe: fires whenever the user pans, zooms, or selects in the browser.
|
|
54
|
+
widget.observe(lambda change: print(change["new"]), names="viewport")
|
|
55
|
+
|
|
56
|
+
# Drive: writing the trait moves the view.
|
|
57
|
+
widget.viewport = {**widget.viewport, "x0": 0, "x1": 50}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Widgets
|
|
61
|
+
|
|
62
|
+
| Class | Data prop | Interactive traits |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| `MSA` | `msa` | `viewport`, `selection`, `row_order`, `panel_sizes` |
|
|
65
|
+
| `PhyloTree` | `tree` | `viewport`, `selection`; `leaf_order` (read-only) |
|
|
66
|
+
| `DistanceMatrix` | `labels`, `matrix` | `viewport`, `row_order`, `panel_sizes` |
|
|
67
|
+
| `GeneModel` | `gene` | `viewport` |
|
|
68
|
+
| `GenomeBrowser` | `tracks`, `reference_length` | `viewport` |
|
|
69
|
+
| `BlastHitDistribution` | `hits`, `query_length` | `viewport`, `selection`, `metric` |
|
|
70
|
+
|
|
71
|
+
`viewport` is a plain dict — `{"x0", "x1", "y0", "y1", "xMin", "xMax", "yMin", "yMax"}` — seeded on
|
|
72
|
+
construction to show the whole dataset, so it is readable and observable before any interaction.
|
|
73
|
+
|
|
74
|
+
`selection` is `{"rows", "columns"}` for `MSA`, `{"rerootedAt", "rerootPosition", "collapsed",
|
|
75
|
+
"order"}` for `PhyloTree` and `{"selectedHitIds"}` for `BlastHitDistribution`. `row_order` has the
|
|
76
|
+
same shape on `MSA` and `DistanceMatrix`, so one can follow the other with a plain `observe`.
|
|
77
|
+
|
|
78
|
+
User actions that are events rather than state arrive through callbacks. The widgets never edit
|
|
79
|
+
their data themselves — apply the change and assign the new data back:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
msa = MSA(msa=records)
|
|
83
|
+
msa.on_rename_row(lambda row_id, name: ...)
|
|
84
|
+
msa.on_remove_rows(lambda row_ids: ...)
|
|
85
|
+
msa.on_remove_columns(lambda columns: ...)
|
|
86
|
+
|
|
87
|
+
tree = PhyloTree(tree=tree_dict, interactive=True)
|
|
88
|
+
tree.on_node_click(lambda node: print(node["id"], node["leafNames"]))
|
|
89
|
+
tree.on_branch_click(lambda node: ...)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from react_bio_viz import BlastHitDistribution
|
|
94
|
+
|
|
95
|
+
hits = BlastHitDistribution(hits=blast_rows, query_length=2000)
|
|
96
|
+
hits.observe(lambda c: print("selected:", c["new"]["selectedHitIds"]), names="selection")
|
|
97
|
+
hits
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Naming
|
|
101
|
+
|
|
102
|
+
Traits are `snake_case` (idiomatic Python) and map to the React components' `camelCase` props;
|
|
103
|
+
values that are themselves data — viewport keys, `selection` keys, `options` keys — keep their
|
|
104
|
+
`camelCase` spelling, since they cross the wire as JSON and are documented by the JavaScript API.
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
An anywidget model is structurally already a `StoreController`, the external-store seam the React
|
|
109
|
+
components use for controlled state (`model.get` / `model.set` + `save_changes` / `on("change:…")`
|
|
110
|
+
↔ `getValue` / `setValue` / `subscribe`). So the Jupyter integration is one adapter,
|
|
111
|
+
`createAnywidgetStoreController`, and **no component contains any Jupyter-specific code** — a
|
|
112
|
+
notebook is just another external-store consumer, the same kind of thing as a web app's Zustand
|
|
113
|
+
store.
|
|
114
|
+
|
|
115
|
+
All six widgets share a single bundled ES module, dispatching on an internal `_component` trait,
|
|
116
|
+
so React ships once in the wheel rather than once per widget.
|
|
117
|
+
|
|
118
|
+
`PhyloTree`'s layout trait is `tree_layout` (ipywidgets reserves `layout` for a widget's CSS layout);
|
|
119
|
+
`PhyloTree(layout="radial")` is accepted as a shorthand.
|
|
120
|
+
|
|
121
|
+
## Example notebook
|
|
122
|
+
|
|
123
|
+
[`examples/tour.ipynb`](examples/tour.ipynb) loads an alignment, computes p-distances and a
|
|
124
|
+
neighbour-joining tree in plain Python, and links all three views. Documentation:
|
|
125
|
+
<https://holmrenser.github.io/react-bio-viz/python/>.
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pnpm --filter @react-bio-viz/python-widgets build # bundle the widget JavaScript
|
|
131
|
+
uv pip install -e ".[dev]"
|
|
132
|
+
pytest
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The wheel needs `src/react_bio_viz/static/widget.js`; the hatchling build hook builds it with
|
|
136
|
+
Node if it is missing, and skips when it is already there (so installing from an sdist or wheel
|
|
137
|
+
needs no Node toolchain).
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# react-bio-viz
|
|
2
|
+
|
|
3
|
+
Jupyter widgets for biological data visualization: multiple sequence alignments, phylogenetic
|
|
4
|
+
trees, distance matrices, gene models, genome browser tracks, and BLAST hit distributions.
|
|
5
|
+
|
|
6
|
+
These are [anywidget](https://anywidget.dev) bindings around the `react-bio-viz` React components,
|
|
7
|
+
so the same visualizations run in a notebook and in a web app.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install react-bio-viz
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from react_bio_viz import MSA
|
|
19
|
+
|
|
20
|
+
widget = MSA(msa=[
|
|
21
|
+
{"header": "seq1", "sequence": "MKTAYIAKQRQISFVK"},
|
|
22
|
+
{"header": "seq2", "sequence": "MKTAYIAKQRQISFVR"},
|
|
23
|
+
])
|
|
24
|
+
widget
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every piece of interactive state is an ordinary synced traitlet, so the kernel can both **drive**
|
|
28
|
+
and **observe** it — no bespoke callback API, just traitlets' own idiom:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# Observe: fires whenever the user pans, zooms, or selects in the browser.
|
|
32
|
+
widget.observe(lambda change: print(change["new"]), names="viewport")
|
|
33
|
+
|
|
34
|
+
# Drive: writing the trait moves the view.
|
|
35
|
+
widget.viewport = {**widget.viewport, "x0": 0, "x1": 50}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Widgets
|
|
39
|
+
|
|
40
|
+
| Class | Data prop | Interactive traits |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `MSA` | `msa` | `viewport`, `selection`, `row_order`, `panel_sizes` |
|
|
43
|
+
| `PhyloTree` | `tree` | `viewport`, `selection`; `leaf_order` (read-only) |
|
|
44
|
+
| `DistanceMatrix` | `labels`, `matrix` | `viewport`, `row_order`, `panel_sizes` |
|
|
45
|
+
| `GeneModel` | `gene` | `viewport` |
|
|
46
|
+
| `GenomeBrowser` | `tracks`, `reference_length` | `viewport` |
|
|
47
|
+
| `BlastHitDistribution` | `hits`, `query_length` | `viewport`, `selection`, `metric` |
|
|
48
|
+
|
|
49
|
+
`viewport` is a plain dict — `{"x0", "x1", "y0", "y1", "xMin", "xMax", "yMin", "yMax"}` — seeded on
|
|
50
|
+
construction to show the whole dataset, so it is readable and observable before any interaction.
|
|
51
|
+
|
|
52
|
+
`selection` is `{"rows", "columns"}` for `MSA`, `{"rerootedAt", "rerootPosition", "collapsed",
|
|
53
|
+
"order"}` for `PhyloTree` and `{"selectedHitIds"}` for `BlastHitDistribution`. `row_order` has the
|
|
54
|
+
same shape on `MSA` and `DistanceMatrix`, so one can follow the other with a plain `observe`.
|
|
55
|
+
|
|
56
|
+
User actions that are events rather than state arrive through callbacks. The widgets never edit
|
|
57
|
+
their data themselves — apply the change and assign the new data back:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
msa = MSA(msa=records)
|
|
61
|
+
msa.on_rename_row(lambda row_id, name: ...)
|
|
62
|
+
msa.on_remove_rows(lambda row_ids: ...)
|
|
63
|
+
msa.on_remove_columns(lambda columns: ...)
|
|
64
|
+
|
|
65
|
+
tree = PhyloTree(tree=tree_dict, interactive=True)
|
|
66
|
+
tree.on_node_click(lambda node: print(node["id"], node["leafNames"]))
|
|
67
|
+
tree.on_branch_click(lambda node: ...)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from react_bio_viz import BlastHitDistribution
|
|
72
|
+
|
|
73
|
+
hits = BlastHitDistribution(hits=blast_rows, query_length=2000)
|
|
74
|
+
hits.observe(lambda c: print("selected:", c["new"]["selectedHitIds"]), names="selection")
|
|
75
|
+
hits
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Naming
|
|
79
|
+
|
|
80
|
+
Traits are `snake_case` (idiomatic Python) and map to the React components' `camelCase` props;
|
|
81
|
+
values that are themselves data — viewport keys, `selection` keys, `options` keys — keep their
|
|
82
|
+
`camelCase` spelling, since they cross the wire as JSON and are documented by the JavaScript API.
|
|
83
|
+
|
|
84
|
+
## How it works
|
|
85
|
+
|
|
86
|
+
An anywidget model is structurally already a `StoreController`, the external-store seam the React
|
|
87
|
+
components use for controlled state (`model.get` / `model.set` + `save_changes` / `on("change:…")`
|
|
88
|
+
↔ `getValue` / `setValue` / `subscribe`). So the Jupyter integration is one adapter,
|
|
89
|
+
`createAnywidgetStoreController`, and **no component contains any Jupyter-specific code** — a
|
|
90
|
+
notebook is just another external-store consumer, the same kind of thing as a web app's Zustand
|
|
91
|
+
store.
|
|
92
|
+
|
|
93
|
+
All six widgets share a single bundled ES module, dispatching on an internal `_component` trait,
|
|
94
|
+
so React ships once in the wheel rather than once per widget.
|
|
95
|
+
|
|
96
|
+
`PhyloTree`'s layout trait is `tree_layout` (ipywidgets reserves `layout` for a widget's CSS layout);
|
|
97
|
+
`PhyloTree(layout="radial")` is accepted as a shorthand.
|
|
98
|
+
|
|
99
|
+
## Example notebook
|
|
100
|
+
|
|
101
|
+
[`examples/tour.ipynb`](examples/tour.ipynb) loads an alignment, computes p-distances and a
|
|
102
|
+
neighbour-joining tree in plain Python, and links all three views. Documentation:
|
|
103
|
+
<https://holmrenser.github.io/react-bio-viz/python/>.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pnpm --filter @react-bio-viz/python-widgets build # bundle the widget JavaScript
|
|
109
|
+
uv pip install -e ".[dev]"
|
|
110
|
+
pytest
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The wheel needs `src/react_bio_viz/static/widget.js`; the hatchling build hook builds it with
|
|
114
|
+
Node if it is missing, and skips when it is already there (so installing from an sdist or wheel
|
|
115
|
+
needs no Node toolchain).
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { build } from "esbuild";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const outdir = resolve(here, "src/react_bio_viz/static");
|
|
8
|
+
|
|
9
|
+
await mkdir(outdir, { recursive: true });
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Unlike the npm packages — which leave React external for the host app to provide — the widget
|
|
13
|
+
* bundle has to be self-contained: anywidget loads `_esm` as a standalone ES module in the
|
|
14
|
+
* notebook's page, with no bundler and no import map to resolve bare specifiers against.
|
|
15
|
+
*
|
|
16
|
+
* One bundle serves all five widgets, dispatching on the `_component` trait, so React ships once
|
|
17
|
+
* in the wheel instead of five times.
|
|
18
|
+
*/
|
|
19
|
+
await build({
|
|
20
|
+
entryPoints: [resolve(here, "js/widget.tsx")],
|
|
21
|
+
outfile: resolve(outdir, "widget.js"),
|
|
22
|
+
bundle: true,
|
|
23
|
+
format: "esm",
|
|
24
|
+
target: "es2020",
|
|
25
|
+
minify: true,
|
|
26
|
+
sourcemap: false,
|
|
27
|
+
jsx: "automatic",
|
|
28
|
+
// React libraries branch on this; without it esbuild keeps the dev-only paths (and the
|
|
29
|
+
// `process` reference they sit behind, which does not exist in a browser).
|
|
30
|
+
define: { "process.env.NODE_ENV": '"production"' },
|
|
31
|
+
loader: { ".css": "css" },
|
|
32
|
+
logLevel: "info",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
console.log(`built ${outdir}/widget.js (+ widget.css)`);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Bundles the widget JavaScript before hatchling packages the wheel or sdist.
|
|
2
|
+
|
|
3
|
+
anywidget loads ``_esm`` as a standalone ES module in the notebook page, so the bundle has to be
|
|
4
|
+
built and shipped inside the distribution — there is no bundler on the other side. This hook runs
|
|
5
|
+
``node build.mjs`` when the artifact is missing, and stays out of the way when it is already
|
|
6
|
+
there (an sdist install, or a checkout where ``pnpm --filter @react-bio-viz/python-widgets build``
|
|
7
|
+
has run), so packaging never hard-requires a Node toolchain.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pathlib
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
19
|
+
|
|
20
|
+
_ARTIFACTS = ("widget.js", "widget.css")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WidgetBuildHook(BuildHookInterface):
|
|
24
|
+
PLUGIN_NAME = "custom"
|
|
25
|
+
|
|
26
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
27
|
+
root = pathlib.Path(self.root)
|
|
28
|
+
static = root / "src" / "react_bio_viz" / "static"
|
|
29
|
+
|
|
30
|
+
if all((static / name).is_file() for name in _ARTIFACTS):
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
node = shutil.which("node")
|
|
34
|
+
if node is None:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"The widget bundle is missing and Node.js was not found on PATH.\n"
|
|
37
|
+
"Build it first with `pnpm --filter @react-bio-viz/python-widgets build`, "
|
|
38
|
+
"or install from an sdist/wheel, which ships it prebuilt."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
print("react-bio-viz: bundling widget JavaScript...", file=sys.stderr)
|
|
42
|
+
subprocess.run([node, "build.mjs"], cwd=root, check=True)
|
|
43
|
+
|
|
44
|
+
missing = [name for name in _ARTIFACTS if not (static / name).is_file()]
|
|
45
|
+
if missing:
|
|
46
|
+
raise RuntimeError(f"widget bundle did not produce: {', '.join(missing)}")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { StoreController } from "@react-bio-viz/core";
|
|
2
|
+
|
|
3
|
+
/** The part of anywidget's `AnyModel` the bridge uses — structural, so it needs no anywidget types. */
|
|
4
|
+
export interface AnyModel {
|
|
5
|
+
get(key: string): unknown;
|
|
6
|
+
set(key: string, value: unknown): void;
|
|
7
|
+
save_changes(): void;
|
|
8
|
+
on(event: string, callback: () => void): void;
|
|
9
|
+
off(event: string, callback: () => void): void;
|
|
10
|
+
/** Sends a custom message to the kernel (received by `widget.on_msg` in Python). */
|
|
11
|
+
send(content: Record<string, unknown>): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One synced trait as a {@link StoreController} — the seam a JavaScript app fills with a Zustand
|
|
16
|
+
* store, so no component has Jupyter-specific code. A trait written from Python re-renders the
|
|
17
|
+
* component; an interaction writes the trait back, firing `widget.observe` in the kernel.
|
|
18
|
+
*
|
|
19
|
+
* @param model - The anywidget model passed to `render({ model, el })`.
|
|
20
|
+
* @param key - The synced trait name to bind, e.g. `"viewport"` or `"selection"`.
|
|
21
|
+
*/
|
|
22
|
+
export function createAnywidgetStoreController<T>(model: AnyModel, key: string): StoreController<T> {
|
|
23
|
+
// The last non-null value, cached: `useSyncExternalStore` needs a stable snapshot between
|
|
24
|
+
// notifications, and a `null` one (before Python seeds the trait) would reach the component.
|
|
25
|
+
let snapshot = model.get(key) as T;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
getValue: () => {
|
|
29
|
+
const current = model.get(key) as T | null | undefined;
|
|
30
|
+
if (current !== null && current !== undefined) snapshot = current;
|
|
31
|
+
return snapshot;
|
|
32
|
+
},
|
|
33
|
+
setValue: (next) => {
|
|
34
|
+
const value = typeof next === "function" ? (next as (prev: T) => T)(snapshot) : next;
|
|
35
|
+
snapshot = value;
|
|
36
|
+
model.set(key, value);
|
|
37
|
+
model.save_changes();
|
|
38
|
+
},
|
|
39
|
+
subscribe: (listener) => {
|
|
40
|
+
const handler = () => {
|
|
41
|
+
const current = model.get(key) as T | null | undefined;
|
|
42
|
+
if (current !== null && current !== undefined) snapshot = current;
|
|
43
|
+
listener(snapshot);
|
|
44
|
+
};
|
|
45
|
+
model.on(`change:${key}`, handler);
|
|
46
|
+
return () => model.off(`change:${key}`, handler);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactElement } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import {
|
|
4
|
+
BlastHitDistribution,
|
|
5
|
+
DistanceMatrix,
|
|
6
|
+
GeneModel,
|
|
7
|
+
GenomeBrowser,
|
|
8
|
+
MultipleSequenceAlignment,
|
|
9
|
+
PhyloTree,
|
|
10
|
+
type TreeNodeInfo,
|
|
11
|
+
} from "react-bio-viz";
|
|
12
|
+
import "react-bio-viz/style.css";
|
|
13
|
+
|
|
14
|
+
import { createAnywidgetStoreController, type AnyModel } from "./_storeAdapter";
|
|
15
|
+
|
|
16
|
+
/** How one Python widget class maps onto a React component. */
|
|
17
|
+
interface WidgetSpec {
|
|
18
|
+
render: (props: Record<string, unknown>) => ReactElement;
|
|
19
|
+
/**
|
|
20
|
+
* Data and display traits, bound to the camelCased prop of the same name — or, as a
|
|
21
|
+
* `[trait, prop]` pair, to another prop (a trait can't take a name ipywidgets uses, like `layout`).
|
|
22
|
+
*/
|
|
23
|
+
props: (string | [string, string])[];
|
|
24
|
+
/** Interactive state: each trait binds, two-way, to the component's camelCased `<trait>Store` prop. */
|
|
25
|
+
stores: string[];
|
|
26
|
+
/** Callback props, each built from the model: a custom message to the kernel, or a trait write. */
|
|
27
|
+
events?: Record<string, (model: AnyModel) => (...args: never[]) => void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Node/branch click info without the screen coordinates, which mean nothing to the kernel. */
|
|
31
|
+
function nodePayload({ clientX: _x, clientY: _y, ...info }: TreeNodeInfo) {
|
|
32
|
+
return info;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Renders a component from props assembled out of traits, which TypeScript can't check: the Python
|
|
37
|
+
* class guarantees the required ones (and its tests assert the trait set).
|
|
38
|
+
*/
|
|
39
|
+
function bridge<P>(Component: ComponentType<P>): (props: Record<string, unknown>) => ReactElement {
|
|
40
|
+
const Dynamic = Component as unknown as ComponentType<Record<string, unknown>>;
|
|
41
|
+
return (props) => createElement(Dynamic, props);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** snake_case trait → camelCase prop. */
|
|
45
|
+
function toPropName(trait: string): string {
|
|
46
|
+
return trait.replace(/_([a-z])/g, (_match, char: string) => char.toUpperCase());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Store props are named for their state (`viewportStore`), so a trait binds to its prop by name alone. */
|
|
50
|
+
const SPECS: Record<string, WidgetSpec> = {
|
|
51
|
+
msa: {
|
|
52
|
+
render: bridge(MultipleSequenceAlignment),
|
|
53
|
+
props: ["msa", "width", "height", "options"],
|
|
54
|
+
stores: ["viewport", "selection", "row_order", "panel_sizes"],
|
|
55
|
+
events: {
|
|
56
|
+
onRenameRow: (model) => (rowId: string, name: string) => model.send({ event: "rename_row", row_id: rowId, name }),
|
|
57
|
+
onRemoveRows: (model) => (rowIds: string[]) => model.send({ event: "remove_rows", row_ids: rowIds }),
|
|
58
|
+
onRemoveColumns: (model) => (columns: number[]) => model.send({ event: "remove_columns", columns }),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
phylotree: {
|
|
62
|
+
render: bridge(PhyloTree),
|
|
63
|
+
props: [
|
|
64
|
+
"tree",
|
|
65
|
+
"width",
|
|
66
|
+
"height",
|
|
67
|
+
["tree_layout", "layout"],
|
|
68
|
+
"show_support_values",
|
|
69
|
+
"support_threshold",
|
|
70
|
+
"shade_branch_by_support",
|
|
71
|
+
"font_size",
|
|
72
|
+
"align_tips",
|
|
73
|
+
"interactive",
|
|
74
|
+
"drag_enabled",
|
|
75
|
+
"search_query",
|
|
76
|
+
"search_use_regex",
|
|
77
|
+
"show_scale_bar",
|
|
78
|
+
"show_branch_lengths",
|
|
79
|
+
"branch_width",
|
|
80
|
+
"node_radius",
|
|
81
|
+
"label_font_size",
|
|
82
|
+
"leaf_spacing",
|
|
83
|
+
"leaf_marker_color",
|
|
84
|
+
"node_styles",
|
|
85
|
+
"branch_styles",
|
|
86
|
+
"active_node_id",
|
|
87
|
+
],
|
|
88
|
+
stores: ["viewport", "selection"],
|
|
89
|
+
events: {
|
|
90
|
+
onNodeClick: (model) => (info: TreeNodeInfo) => model.send({ event: "node_click", node: nodePayload(info) }),
|
|
91
|
+
onBranchClick: (model) => (info: TreeNodeInfo) => model.send({ event: "branch_click", node: nodePayload(info) }),
|
|
92
|
+
onLeafOrderChange: (model) => (leafNames: string[]) => {
|
|
93
|
+
model.set("leaf_order", leafNames);
|
|
94
|
+
model.save_changes();
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
distancematrix: {
|
|
99
|
+
render: bridge(DistanceMatrix),
|
|
100
|
+
props: ["labels", "matrix", "label_names", "width", "height", "options"],
|
|
101
|
+
stores: ["viewport", "row_order", "panel_sizes"],
|
|
102
|
+
},
|
|
103
|
+
genemodel: {
|
|
104
|
+
render: bridge(GeneModel),
|
|
105
|
+
props: ["gene", "width", "color_seed", "show_scale"],
|
|
106
|
+
stores: ["viewport"],
|
|
107
|
+
},
|
|
108
|
+
genomebrowser: {
|
|
109
|
+
render: bridge(GenomeBrowser),
|
|
110
|
+
props: ["tracks", "reference_length", "reference_name", "width", "show_scale"],
|
|
111
|
+
stores: ["viewport"],
|
|
112
|
+
},
|
|
113
|
+
blasthitdistribution: {
|
|
114
|
+
render: bridge(BlastHitDistribution),
|
|
115
|
+
props: ["hits", "query_length", "query_name", "width", "show_scale"],
|
|
116
|
+
stores: ["viewport", "selection", "metric"],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
function buildProps(model: AnyModel, spec: WidgetSpec): Record<string, unknown> {
|
|
121
|
+
const props: Record<string, unknown> = {};
|
|
122
|
+
|
|
123
|
+
for (const entry of spec.props) {
|
|
124
|
+
const [trait, prop] = typeof entry === "string" ? [entry, toPropName(entry)] : entry;
|
|
125
|
+
const value = model.get(trait);
|
|
126
|
+
// `None` means "not set": leave the prop off, so the component's default applies.
|
|
127
|
+
if (value !== null && value !== undefined) props[prop] = value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const trait of spec.stores) {
|
|
131
|
+
// Python seeds these; an unset one leaves the state uncontrolled rather than null.
|
|
132
|
+
if (model.get(trait) === null || model.get(trait) === undefined) continue;
|
|
133
|
+
props[`${toPropName(trait)}Store`] = createAnywidgetStoreController(model, trait);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const [prop, handler] of Object.entries(spec.events ?? {})) props[prop] = handler(model);
|
|
137
|
+
|
|
138
|
+
return props;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function render({ model, el }: { model: AnyModel; el: HTMLElement }) {
|
|
142
|
+
const name = String(model.get("_component"));
|
|
143
|
+
const spec = SPECS[name];
|
|
144
|
+
if (!spec) {
|
|
145
|
+
el.textContent = `react-bio-viz: unknown component "${name}"`;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const root: Root = createRoot(el);
|
|
150
|
+
const draw = () => root.render(spec.render(buildProps(model, spec)));
|
|
151
|
+
draw();
|
|
152
|
+
|
|
153
|
+
// Store traits re-render through their store; only the plain traits need a redraw from here.
|
|
154
|
+
const watched = spec.props.map((entry) => `change:${typeof entry === "string" ? entry : entry[0]}`);
|
|
155
|
+
for (const event of watched) model.on(event, draw);
|
|
156
|
+
|
|
157
|
+
return () => {
|
|
158
|
+
for (const event of watched) model.off(event, draw);
|
|
159
|
+
root.unmount();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default { render };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@react-bio-viz/python-widgets",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "esbuild bundle of the react-bio-viz components for the anywidget/Jupyter package (not published to npm; the artifact ships inside the PyPI wheel)",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node build.mjs",
|
|
9
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
10
|
+
"lint": "eslint . --max-warnings 0"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@react-bio-viz/core": "workspace:*",
|
|
14
|
+
"react-bio-viz": "workspace:*",
|
|
15
|
+
"react": "^18.3.1",
|
|
16
|
+
"react-dom": "^18.3.1"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/react": "^18.3.3",
|
|
20
|
+
"@types/react-dom": "^18.3.0",
|
|
21
|
+
"esbuild": "^0.21.5",
|
|
22
|
+
"typescript": "^5.6.3"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "react-bio-viz"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Jupyter widgets for biological data visualization: multiple sequence alignments, phylogenetic trees, gene models, genome browser tracks, and BLAST hit distributions"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["jupyter", "widget", "bioinformatics", "visualization", "msa", "phylogenetics"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Framework :: Jupyter",
|
|
15
|
+
"Framework :: Jupyter :: JupyterLab :: 3",
|
|
16
|
+
"Framework :: Jupyter :: JupyterLab :: 4",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Bio-Informatics",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["anywidget>=0.9"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/holmrenser/react-bio-viz"
|
|
26
|
+
Repository = "https://github.com/holmrenser/react-bio-viz"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=7", "anywidget[dev]>=0.9"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build]
|
|
32
|
+
# The bundle is gitignored (it is generated), but it has to travel in both the wheel and the sdist:
|
|
33
|
+
# building a wheel from an sdist has no `node_modules` to rebuild it from, and requiring the npm
|
|
34
|
+
# workspace to install from source would make `pip install react-bio-viz` need a Node toolchain.
|
|
35
|
+
artifacts = [
|
|
36
|
+
"src/react_bio_viz/static/widget.js",
|
|
37
|
+
"src/react_bio_viz/static/widget.css",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/react_bio_viz"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.sdist]
|
|
44
|
+
# The JS sources travel too, so the bundle can be rebuilt/audited from the sdist alone.
|
|
45
|
+
include = ["src", "js", "build.mjs", "hatch_build.py", "package.json", "README.md", "pyproject.toml"]
|
|
46
|
+
# `include` still descends into the workspace symlinks pnpm leaves in `node_modules`.
|
|
47
|
+
exclude = ["node_modules", ".venv", "dist"]
|
|
48
|
+
|
|
49
|
+
# Bundles the widget JavaScript before packaging. Skipped when `static/widget.js` is already
|
|
50
|
+
# present, so building from an sdist (or from a checkout where `pnpm build` has run) needs no
|
|
51
|
+
# Node toolchain.
|
|
52
|
+
[tool.hatch.build.hooks.custom]
|
|
53
|
+
path = "hatch_build.py"
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|