Skip to content

Commit 6a7537f

Browse files
committed
Add @perspective-dev/anywidget for vscode/jupyter
Signed-off-by: Andrew Stein <steinlink@gmail.com> # Conflicts: # packages/jupyterlab/package.json
1 parent 5a0aa7c commit 6a7537f

38 files changed

Lines changed: 638 additions & 1475 deletions

File tree

.github/workflows/build.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,10 @@ jobs:
559559
- name: Build extension
560560
run: pnpm run build
561561
env:
562-
PACKAGE: "jupyterlab"
562+
# `anywidget` builds the widget bundle into the python static
563+
# dir; `jupyterlab` builds the renderers labextension. Order
564+
# matters: anywidget first so the static bundle exists.
565+
PACKAGE: "anywidget,jupyterlab"
563566

564567
- run: node tools/scripts/repack_wheel.mjs
565568

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ rust/perspective-js/src/ts/ts-rs
5252
rust/perspective-server/build
5353
rust/perspective-python/LICENSE_THIRDPARTY_cargo.yml
5454
rust/perspective-python/LICENSE.md
55+
rust/perspective-python/perspective/widget/static/*.js.map
5556
rust/perspective-server/docs/lib_gen.md
5657
rust/perspective-viewer/src/ts/ts-rs
5758
rust/perspective/src/ts/ts-rs
@@ -69,3 +70,4 @@ docs/static/react
6970
rust/perspective-server/build
7071
target/
7172
dist-gh-pages
73+
rust/perspective-python/perspective/widget/static

packages/anywidget/build.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3+
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4+
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5+
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6+
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7+
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8+
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9+
// ┃ This file is part of the Perspective library, distributed under the terms ┃
10+
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11+
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12+
13+
import { WasmPlugin } from "@perspective-dev/esbuild-plugin/wasm.js";
14+
import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js";
15+
import { build } from "@perspective-dev/esbuild-plugin/build.js";
16+
import * as path from "node:path";
17+
import { bundleAsync as bundleCss } from "lightningcss";
18+
import * as fs from "node:fs";
19+
import * as url from "node:url";
20+
import {
21+
resolveNPM,
22+
inlineUrlVisitor,
23+
} from "@perspective-dev/viewer/tools.mjs";
24+
25+
const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);
26+
27+
// The anywidget bundle is `PerspectiveWidget._esm`/`._css`, loaded by anywidget
28+
// from the python package at import time. Emit it (wasm inlined) directly into
29+
// the python package's static dir. The filename stays `perspective-jupyter.*`
30+
// to match `perspective/widget/__init__.py` + the maturin `include`.
31+
const STATIC = path.resolve(
32+
__dirname,
33+
"..",
34+
"..",
35+
"rust",
36+
"perspective-python",
37+
"perspective",
38+
"widget",
39+
"static",
40+
);
41+
42+
const ANYWIDGET_BUILD = {
43+
entryPoints: ["src/js/index.js"],
44+
plugins: [WasmPlugin(true), WorkerPlugin({ inline: true })],
45+
outfile: path.join(STATIC, "perspective-anywidget.js"),
46+
format: "esm",
47+
define: {
48+
global: "window",
49+
},
50+
loader: {
51+
".css": "text",
52+
".html": "text",
53+
".ttf": "file",
54+
},
55+
};
56+
57+
async function build_css(filename, outfile) {
58+
const { code } = await bundleCss({
59+
filename,
60+
minify: true,
61+
visitor: inlineUrlVisitor(filename),
62+
resolver: resolveNPM(import.meta.url),
63+
});
64+
65+
fs.mkdirSync(path.dirname(outfile), { recursive: true });
66+
fs.writeFileSync(outfile, code);
67+
}
68+
69+
async function build_all() {
70+
fs.mkdirSync(STATIC, { recursive: true });
71+
await build_css(
72+
path.resolve(__dirname, "src/css/index.css"),
73+
path.join(STATIC, "perspective-anywidget.css"),
74+
);
75+
76+
await build(ANYWIDGET_BUILD).catch(() => process.exit(1));
77+
}
78+
79+
build_all();
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
1111
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
1212

13-
export const MIME_TYPE = "application/psp+json";
14-
export const PSP_CLASS = "PSPViewer";
15-
export const PSP_CONTAINER_CLASS = "PSPContainer";
13+
import * as fs from "node:fs";
14+
15+
// The build output is the anywidget bundle in the python package's static dir.
16+
fs.rmSync("../../rust/perspective-python/perspective/widget/static", {
17+
recursive: true,
18+
force: true,
19+
});

packages/anywidget/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "@perspective-dev/anywidget",
3+
"version": "4.5.1",
4+
"description": "The `PerspectiveWidget` anywidget bundle for `perspective-python` (shipped in the wheel and loaded by anywidget). Not a JupyterLab labextension.",
5+
"type": "module",
6+
"private": true,
7+
"exports": {
8+
".": "./src/js/index.js"
9+
},
10+
"files": [
11+
"src/**/*"
12+
],
13+
"license": "Apache-2.0",
14+
"scripts": {
15+
"build": "node ./build.mjs",
16+
"clean": "node ./clean.mjs"
17+
},
18+
"dependencies": {
19+
"@perspective-dev/client": "workspace:",
20+
"@perspective-dev/server": "workspace:",
21+
"@perspective-dev/viewer": "workspace:",
22+
"@perspective-dev/viewer-charts": "workspace:",
23+
"@perspective-dev/viewer-datagrid": "workspace:"
24+
},
25+
"devDependencies": {
26+
"@perspective-dev/esbuild-plugin": "workspace:",
27+
"lightningcss": "catalog:"
28+
}
29+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3+
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4+
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5+
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6+
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7+
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
8+
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9+
* ┃ This file is part of the Perspective library, distributed under the terms ┃
10+
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11+
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12+
*/
13+
14+
@import "@perspective-dev/viewer/dist/css/themes.css";
15+
16+
.PSPContainer {
17+
overflow: auto;
18+
padding-right: 5px;
19+
padding-bottom: 5px;
20+
height: 520px;
21+
width: 100%;
22+
flex: 1;
23+
display: block;
24+
}
25+
26+
.jp-Notebook .PSPContainer {
27+
resize: vertical;
28+
}
29+
30+
body[data-voila="voila"] .jp-OutputArea-output .PSPContainer,
31+
body[data-vscode-theme-id] .cell-output-ipywidget-background .PSPContainer {
32+
min-height: 520px;
33+
height: 520px;
34+
}
35+
36+
.PSPContainer perspective-viewer[theme="Pro Light"] {
37+
--psp-plugin--border: 1px solid #e0e0e0;
38+
}
39+
40+
.PSPContainer perspective-viewer {
41+
display: block;
42+
height: 100%;
43+
}

packages/anywidget/src/js/index.js

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2+
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3+
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4+
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5+
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6+
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7+
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
8+
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9+
// ┃ This file is part of the Perspective library, distributed under the terms ┃
10+
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11+
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12+
13+
import perspective from "@perspective-dev/client";
14+
import perspective_viewer from "@perspective-dev/viewer";
15+
16+
import server_wasm from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
17+
import client_wasm from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm";
18+
19+
import "@perspective-dev/viewer-datagrid";
20+
import "@perspective-dev/viewer-charts";
21+
22+
const ready = Promise.all([
23+
perspective_viewer.init_client(client_wasm),
24+
perspective.init_server(server_wasm),
25+
]);
26+
27+
export { ready };
28+
29+
export async function worker() {
30+
await ready;
31+
return await perspective.worker();
32+
}
33+
34+
const PERSISTENT_ATTRIBUTES = [
35+
"plugin",
36+
"columns",
37+
"group_by",
38+
"split_by",
39+
"aggregates",
40+
"sort",
41+
"filter",
42+
"expressions",
43+
"plugin_config",
44+
"settings",
45+
"theme",
46+
"title",
47+
"version",
48+
];
49+
50+
function isEqual(a, b) {
51+
if (a === b) return true;
52+
if (typeof a != "object" || typeof b != "object" || a == null || b == null)
53+
return false;
54+
55+
const keysA = Object.keys(a);
56+
const keysB = Object.keys(b);
57+
if (keysA.length != keysB.length) return false;
58+
for (const key of keysA) {
59+
if (!keysB.includes(key)) return false;
60+
if (typeof a[key] === "function" || typeof b[key] === "function") {
61+
if (a[key].toString() != b[key].toString()) return false;
62+
} else {
63+
if (!isEqual(a[key], b[key])) return false;
64+
}
65+
}
66+
return true;
67+
}
68+
69+
async function get_psp_wasm_module() {
70+
let elem = customElements.get("perspective-viewer");
71+
if (!elem) {
72+
await customElements.whenDefined("perspective-viewer");
73+
elem = customElements.get("perspective-viewer");
74+
}
75+
return elem.__wasm_module__;
76+
}
77+
78+
async function render({ model, el }) {
79+
await ready;
80+
81+
el.classList.add("PSPContainer");
82+
const viewer = document.createElement("perspective-viewer");
83+
viewer.classList.add("PSPViewer");
84+
viewer.setAttribute("type", "application/psp+json");
85+
viewer.addEventListener(
86+
"contextmenu",
87+
(event) => event.stopPropagation(),
88+
false,
89+
);
90+
el.appendChild(viewer);
91+
92+
const client_id = `${Math.random()}`;
93+
const wasm_module = await get_psp_wasm_module();
94+
const psp_client = new wasm_module.Client(
95+
async (binary_msg) => {
96+
const buffer = binary_msg.slice().buffer;
97+
model.send({ type: "binary_msg", client_id }, null, [buffer]);
98+
},
99+
() => {
100+
model.send({ type: "hangup", client_id }, null);
101+
},
102+
);
103+
104+
const on_custom_msg = (msg, buffers) => {
105+
if (msg.type === "binary_msg" && msg.client_id === client_id) {
106+
const [dataview] = buffers;
107+
psp_client.handle_response(dataview.buffer);
108+
}
109+
};
110+
model.on("msg:custom", on_custom_msg);
111+
model.send({ type: "connect", client_id }, null);
112+
113+
const binding_mode = model.get("binding_mode");
114+
const table_name = model.get("table_name");
115+
if (!table_name) {
116+
throw new Error("table_name not set in model");
117+
}
118+
119+
let load_complete = false;
120+
const table_promise = psp_client.open_table(table_name).then(async (t) => {
121+
if (binding_mode === "client-server") {
122+
const local_client = await perspective.worker();
123+
const remote_view = await t.view();
124+
return await local_client.table(remote_view);
125+
} else if (binding_mode === "server") {
126+
return t;
127+
} else {
128+
throw new Error(`unknown binding mode: ${binding_mode}`);
129+
}
130+
});
131+
132+
await viewer.load(table_promise);
133+
await viewer.restore(
134+
Object.fromEntries(PERSISTENT_ATTRIBUTES.map((k) => [k, model.get(k)])),
135+
);
136+
load_complete = true;
137+
138+
const sync_to_python = async () => {
139+
if (!load_complete) {
140+
return;
141+
}
142+
const config = await viewer.save();
143+
for (const name of Object.keys(config)) {
144+
let new_value = config[name];
145+
const current_value = model.get(name);
146+
if (typeof new_value === "undefined") {
147+
continue;
148+
}
149+
if (
150+
new_value &&
151+
typeof new_value === "string" &&
152+
name !== "plugin" &&
153+
name !== "theme" &&
154+
name !== "title" &&
155+
name !== "version"
156+
) {
157+
new_value = JSON.parse(new_value);
158+
}
159+
if (new_value === null && name === "plugin_config") {
160+
new_value = {};
161+
}
162+
if (!isEqual(new_value, current_value)) {
163+
model.set(name, new_value);
164+
}
165+
}
166+
model.save_changes();
167+
};
168+
viewer.addEventListener("perspective-config-update", sync_to_python);
169+
170+
const trait_listeners = PERSISTENT_ATTRIBUTES.map((name) => {
171+
const cb = () => {
172+
viewer.restore({ [name]: model.get(name) });
173+
};
174+
model.on(`change:${name}`, cb);
175+
return [name, cb];
176+
});
177+
178+
return () => {
179+
for (const [name, cb] of trait_listeners) {
180+
model.off(`change:${name}`, cb);
181+
}
182+
model.off("msg:custom", on_custom_msg);
183+
viewer.removeEventListener("perspective-config-update", sync_to_python);
184+
psp_client.terminate();
185+
viewer.delete();
186+
if (viewer.parentNode === el) {
187+
el.removeChild(viewer);
188+
}
189+
};
190+
}
191+
192+
export default { render };

0 commit comments

Comments
 (0)