Skip to content

Commit a88c413

Browse files
committedOct 20, 2023
feat(experimental): Add Jupyter Widget (#176)
1 parent c5a1091 commit a88c413

File tree

7 files changed

+255
-0
lines changed

7 files changed

+255
-0
lines changed
 

‎.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ example/*.zarr
2929
example/.ipynb_checkpoints/*
3030
example/data/**
3131
__pycache__
32+
33+
.venv
34+
.ipynb_checkpoints
35+
dist/

‎python/README.md

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# vizarr
2+
3+
```sh
4+
pip install vizarr
5+
```
6+
7+
```python
8+
import vizarr
9+
import zarr
10+
11+
viewer = vizarr.Viewer()
12+
viewer.add_image(source=zarr.open("path/to/ome.zarr"))
13+
viewer
14+
```

‎python/deno.json

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"lock": false,
3+
"compilerOptions": {
4+
"checkJs": true,
5+
"allowJs": true,
6+
"lib": [
7+
"ES2022",
8+
"DOM",
9+
"DOM.Iterable"
10+
]
11+
},
12+
"fmt": {
13+
"useTabs": true
14+
},
15+
"lint": {
16+
"rules": {
17+
"exclude": [
18+
"prefer-const"
19+
]
20+
}
21+
}
22+
}

‎python/pyproject.toml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "vizarr"
7+
version = "0.0.1"
8+
dependencies = ["anywidget", "zarr"]
9+
10+
[project.optional-dependencies]
11+
dev = ["watchfiles", "jupyterlab"]
12+
13+
# automatically add the dev feature to the default env (e.g., hatch shell)
14+
[tool.hatch.envs.default]
15+
features = ["dev"]

‎python/src/vizarr/__init__.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import importlib.metadata
2+
3+
try:
4+
__version__ = importlib.metadata.version("vizarr")
5+
except importlib.metadata.PackageNotFoundError:
6+
__version__ = "unknown"
7+
8+
del importlib
9+
10+
from ._widget import Viewer

‎python/src/vizarr/_widget.js

+123
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import * as vizarr from "https://hms-dbmi.github.io/vizarr/index.js";
2+
import debounce from "https://esm.sh/just-debounce-it@3";
3+
4+
/**
5+
* @template T
6+
* @param {import("npm:@anywidget/types").AnyModel} model
7+
* @param {any} payload
8+
* @param {{ timeout?: number }} [options]
9+
* @returns {Promise<{ data: T, buffers: DataView[] }>}
10+
*/
11+
function send(model, payload, { timeout = 3000 } = {}) {
12+
let uuid = globalThis.crypto.randomUUID();
13+
return new Promise((resolve, reject) => {
14+
let timer = setTimeout(() => {
15+
reject(new Error(`Promise timed out after ${timeout} ms`));
16+
model.off("msg:custom", handler);
17+
}, timeout);
18+
/**
19+
* @param {{ uuid: string, payload: T }} msg
20+
* @param {DataView[]} buffers
21+
*/
22+
function handler(msg, buffers) {
23+
if (!(msg.uuid === uuid)) return;
24+
clearTimeout(timer);
25+
resolve({ data: msg.payload, buffers });
26+
model.off("msg:custom", handler);
27+
}
28+
model.on("msg:custom", handler);
29+
model.send({ payload, uuid });
30+
});
31+
}
32+
33+
/**
34+
* @param {import("npm:@anywidget/types").AnyModel} model
35+
* @param {string | { id: string }} source
36+
*/
37+
function get_source(model, source) {
38+
if (typeof source === "string") {
39+
return source;
40+
}
41+
// create a python
42+
return {
43+
/**
44+
* @param {string} key
45+
* @return {Promise<ArrayBuffer>}
46+
*/
47+
async getItem(key) {
48+
const { data, buffers } = await send(model, {
49+
type: "get",
50+
source_id: source.id,
51+
key,
52+
});
53+
if (!data.success) {
54+
throw { __zarr__: "KeyError" };
55+
}
56+
return buffers[0].buffer;
57+
},
58+
/**
59+
* @param {string} key
60+
* @return {Promise<boolean>}
61+
*/
62+
async containsItem(key) {
63+
const { data } = await send(model, {
64+
type: "has",
65+
source_id: source.id,
66+
key,
67+
});
68+
return data;
69+
},
70+
};
71+
}
72+
73+
/**
74+
* @typedef Model
75+
* @property {string} height
76+
* @property {ViewState=} view_state
77+
* @property {{ source: string | { id: string }}[]} _configs
78+
*/
79+
80+
/**
81+
* @typedef ViewState
82+
* @property {number} zoom
83+
* @property {[x: number, y: number]} target
84+
*/
85+
86+
/** @type {import("npm:@anywidget/types").Render<Model>} */
87+
export function render({ model, el }) {
88+
let div = document.createElement("div");
89+
{
90+
div.style.height = model.get("height");
91+
div.style.backgroundColor = "black";
92+
model.on("change:height", () => {
93+
div.style.height = model.get("height");
94+
});
95+
}
96+
let viewer = vizarr.createViewer(div);
97+
{
98+
model.on("change:view_state", () => {
99+
viewer.setViewState(model.get("view_state"));
100+
});
101+
viewer.on(
102+
"viewStateChange",
103+
debounce((/** @type {ViewState} */ update) => {
104+
model.set("view_state", update);
105+
model.save_changes();
106+
}, 200),
107+
);
108+
}
109+
{
110+
// sources are append-only now
111+
for (const config of model.get("_configs")) {
112+
const source = get_source(model, config.source);
113+
viewer.addImage({ ...config, source });
114+
}
115+
model.on("change:_configs", () => {
116+
const last = model.get("_configs").at(-1);
117+
if (!last) return;
118+
const source = get_source(model, last.source);
119+
viewer.addImage({ ...last, source });
120+
});
121+
}
122+
el.appendChild(div);
123+
}

‎python/src/vizarr/_widget.py

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import anywidget
2+
import traitlets
3+
import pathlib
4+
5+
import zarr
6+
import numpy as np
7+
8+
__all__ = ["Viewer"]
9+
10+
11+
def _store_keyprefix(obj):
12+
# Just grab the store and key_prefix from zarr.Array and zarr.Group objects
13+
if isinstance(obj, (zarr.Array, zarr.Group)):
14+
return obj.store, obj._key_prefix
15+
16+
if isinstance(obj, np.ndarray):
17+
# Create an in-memory store, and write array as as single chunk
18+
store = {}
19+
arr = zarr.create(
20+
store=store, shape=obj.shape, chunks=obj.shape, dtype=obj.dtype
21+
)
22+
arr[:] = obj
23+
return store, ""
24+
25+
if hasattr(obj, "__getitem__") and hasattr(obj, "__contains__"):
26+
return obj, ""
27+
28+
raise TypeError("Cannot normalize store path")
29+
30+
31+
class Viewer(anywidget.AnyWidget):
32+
_esm = pathlib.Path(__file__).parent / "_widget.js"
33+
_configs = traitlets.List().tag(sync=True)
34+
view_state = traitlets.Dict().tag(sync=True)
35+
height = traitlets.Unicode("500px").tag(sync=True)
36+
37+
def __init__(self, **kwargs):
38+
super().__init__(**kwargs)
39+
self._store_paths = []
40+
self.on_msg(self._handle_custom_msg)
41+
42+
def _handle_custom_msg(self, msg, buffers):
43+
store, key_prefix = self._store_paths[msg["payload"]["source_id"]]
44+
key = key_prefix + msg["payload"]["key"].lstrip("/")
45+
46+
if msg["payload"]["type"] == "has":
47+
self.send({"uuid": msg["uuid"], "payload": key in store})
48+
return
49+
50+
if msg["payload"]["type"] == "get":
51+
try:
52+
buffers = [store[key]]
53+
except KeyError:
54+
buffers = []
55+
self.send(
56+
{"uuid": msg["uuid"], "payload": {"success": len(buffers) == 1}},
57+
buffers,
58+
)
59+
return
60+
61+
def add_image(self, source, **config):
62+
if not isinstance(source, str):
63+
store, key_prefix = _store_keyprefix(source)
64+
source = {"id": len(self._store_paths)}
65+
self._store_paths.append((store, key_prefix))
66+
config["source"] = source
67+
self._configs = self._configs + [config]

0 commit comments

Comments
 (0)