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.
@@ -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"]