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
5 changes: 4 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,10 @@ jobs:
- name: Build extension
run: pnpm run build
env:
PACKAGE: "jupyterlab"
# `anywidget` builds the widget bundle into the python static
# dir; `jupyterlab` builds the renderers labextension. Order
# matters: anywidget first so the static bundle exists.
PACKAGE: "anywidget,jupyterlab"

- run: node tools/scripts/repack_wheel.mjs

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ rust/perspective-js/src/ts/ts-rs
rust/perspective-server/build
rust/perspective-python/LICENSE_THIRDPARTY_cargo.yml
rust/perspective-python/LICENSE.md
rust/perspective-python/perspective/widget/static/*.js.map
rust/perspective-server/docs/lib_gen.md
rust/perspective-viewer/src/ts/ts-rs
rust/perspective/src/ts/ts-rs
Expand All @@ -69,3 +70,4 @@ docs/static/react
rust/perspective-server/build
target/
dist-gh-pages
rust/perspective-python/perspective/widget/static
79 changes: 79 additions & 0 deletions packages/anywidget/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

import { WasmPlugin } from "@perspective-dev/esbuild-plugin/wasm.js";
import { WorkerPlugin } from "@perspective-dev/esbuild-plugin/worker.js";
import { build } from "@perspective-dev/esbuild-plugin/build.js";
import * as path from "node:path";
import { bundleAsync as bundleCss } from "lightningcss";
import * as fs from "node:fs";
import * as url from "node:url";
import {
resolveNPM,
inlineUrlVisitor,
} from "@perspective-dev/viewer/tools.mjs";

const __dirname = url.fileURLToPath(new URL(".", import.meta.url)).slice(0, -1);

// The anywidget bundle is `PerspectiveWidget._esm`/`._css`, loaded by anywidget
// from the python package at import time. Emit it (wasm inlined) directly into
// the python package's static dir. The filename stays `perspective-jupyter.*`
// to match `perspective/widget/__init__.py` + the maturin `include`.
const STATIC = path.resolve(
__dirname,
"..",
"..",
"rust",
"perspective-python",
"perspective",
"widget",
"static",
);

const ANYWIDGET_BUILD = {
entryPoints: ["src/js/index.js"],
plugins: [WasmPlugin(true), WorkerPlugin({ inline: true })],
outfile: path.join(STATIC, "perspective-anywidget.js"),
format: "esm",
define: {
global: "window",
},
loader: {
".css": "text",
".html": "text",
".ttf": "file",
},
};

async function build_css(filename, outfile) {
const { code } = await bundleCss({
filename,
minify: true,
visitor: inlineUrlVisitor(filename),
resolver: resolveNPM(import.meta.url),
});

fs.mkdirSync(path.dirname(outfile), { recursive: true });
fs.writeFileSync(outfile, code);
}

async function build_all() {
fs.mkdirSync(STATIC, { recursive: true });
await build_css(
path.resolve(__dirname, "src/css/index.css"),
path.join(STATIC, "perspective-anywidget.css"),
);

await build(ANYWIDGET_BUILD).catch(() => process.exit(1));
}

build_all();
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

export const MIME_TYPE = "application/psp+json";
export const PSP_CLASS = "PSPViewer";
export const PSP_CONTAINER_CLASS = "PSPContainer";
import * as fs from "node:fs";

// The build output is the anywidget bundle in the python package's static dir.
fs.rmSync("../../rust/perspective-python/perspective/widget/static", {
recursive: true,
force: true,
});
29 changes: 29 additions & 0 deletions packages/anywidget/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@perspective-dev/anywidget",
"version": "4.5.1",
"description": "The `PerspectiveWidget` anywidget bundle for `perspective-python` (shipped in the wheel and loaded by anywidget). Not a JupyterLab labextension.",
"type": "module",
"private": true,
"exports": {
".": "./src/js/index.js"
},
"files": [
"src/**/*"
],
"license": "Apache-2.0",
"scripts": {
"build": "node ./build.mjs",
"clean": "node ./clean.mjs"
},
"dependencies": {
"@perspective-dev/client": "workspace:",
"@perspective-dev/server": "workspace:",
"@perspective-dev/viewer": "workspace:",
"@perspective-dev/viewer-charts": "workspace:",
"@perspective-dev/viewer-datagrid": "workspace:"
},
"devDependencies": {
"@perspective-dev/esbuild-plugin": "workspace:",
"lightningcss": "catalog:"
}
}
43 changes: 43 additions & 0 deletions packages/anywidget/src/css/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
* ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
* ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
* ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
* ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
* ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
* ┃ Copyright (c) 2017, the Perspective Authors. ┃
* ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
* ┃ This file is part of the Perspective library, distributed under the terms ┃
* ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
* ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
*/

@import "@perspective-dev/viewer/dist/css/themes.css";

.PSPContainer {
overflow: auto;
padding-right: 5px;
padding-bottom: 5px;
height: 520px;
width: 100%;
flex: 1;
display: block;
}

.jp-Notebook .PSPContainer {
resize: vertical;
}

body[data-voila="voila"] .jp-OutputArea-output .PSPContainer,
body[data-vscode-theme-id] .cell-output-ipywidget-background .PSPContainer {
min-height: 520px;
height: 520px;
}

.PSPContainer perspective-viewer[theme="Pro Light"] {
--psp-plugin--border: 1px solid #e0e0e0;
}

.PSPContainer perspective-viewer {
display: block;
height: 100%;
}
192 changes: 192 additions & 0 deletions packages/anywidget/src/js/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors. ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

import perspective from "@perspective-dev/client";
import perspective_viewer from "@perspective-dev/viewer";

import server_wasm from "@perspective-dev/server/dist/wasm/perspective-server.wasm";
import client_wasm from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm";

import "@perspective-dev/viewer-datagrid";
import "@perspective-dev/viewer-charts";

const ready = Promise.all([
perspective_viewer.init_client(client_wasm),
perspective.init_server(server_wasm),
]);

export { ready };

export async function worker() {
await ready;
return await perspective.worker();
}

const PERSISTENT_ATTRIBUTES = [
"plugin",
"columns",
"group_by",
"split_by",
"aggregates",
"sort",
"filter",
"expressions",
"plugin_config",
"settings",
"theme",
"title",
"version",
];

function isEqual(a, b) {
if (a === b) return true;
if (typeof a != "object" || typeof b != "object" || a == null || b == null)
return false;

const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length != keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (typeof a[key] === "function" || typeof b[key] === "function") {
if (a[key].toString() != b[key].toString()) return false;
} else {
if (!isEqual(a[key], b[key])) return false;
}
}
return true;
}

async function get_psp_wasm_module() {
let elem = customElements.get("perspective-viewer");
if (!elem) {
await customElements.whenDefined("perspective-viewer");
elem = customElements.get("perspective-viewer");
}
return elem.__wasm_module__;
}

async function render({ model, el }) {
await ready;

el.classList.add("PSPContainer");
const viewer = document.createElement("perspective-viewer");
viewer.classList.add("PSPViewer");
viewer.setAttribute("type", "application/psp+json");
viewer.addEventListener(
"contextmenu",
(event) => event.stopPropagation(),
false,
);
el.appendChild(viewer);

const client_id = `${Math.random()}`;
const wasm_module = await get_psp_wasm_module();
const psp_client = new wasm_module.Client(
async (binary_msg) => {
const buffer = binary_msg.slice().buffer;
model.send({ type: "binary_msg", client_id }, null, [buffer]);
},
() => {
model.send({ type: "hangup", client_id }, null);
},
);

const on_custom_msg = (msg, buffers) => {
if (msg.type === "binary_msg" && msg.client_id === client_id) {
const [dataview] = buffers;
psp_client.handle_response(dataview.buffer);
}
};
model.on("msg:custom", on_custom_msg);
model.send({ type: "connect", client_id }, null);

const binding_mode = model.get("binding_mode");
const table_name = model.get("table_name");
if (!table_name) {
throw new Error("table_name not set in model");
}

let load_complete = false;
const table_promise = psp_client.open_table(table_name).then(async (t) => {
if (binding_mode === "client-server") {
const local_client = await perspective.worker();
const remote_view = await t.view();
return await local_client.table(remote_view);
} else if (binding_mode === "server") {
return t;
} else {
throw new Error(`unknown binding mode: ${binding_mode}`);
}
});

await viewer.load(table_promise);
await viewer.restore(
Object.fromEntries(PERSISTENT_ATTRIBUTES.map((k) => [k, model.get(k)])),
);
load_complete = true;

const sync_to_python = async () => {
if (!load_complete) {
return;
}
const config = await viewer.save();
for (const name of Object.keys(config)) {
let new_value = config[name];
const current_value = model.get(name);
if (typeof new_value === "undefined") {
continue;
}
if (
new_value &&
typeof new_value === "string" &&
name !== "plugin" &&
name !== "theme" &&
name !== "title" &&
name !== "version"
) {
new_value = JSON.parse(new_value);
}
if (new_value === null && name === "plugin_config") {
new_value = {};
}
if (!isEqual(new_value, current_value)) {
model.set(name, new_value);
}
}
model.save_changes();
};
viewer.addEventListener("perspective-config-update", sync_to_python);

const trait_listeners = PERSISTENT_ATTRIBUTES.map((name) => {
const cb = () => {
viewer.restore({ [name]: model.get(name) });
};
model.on(`change:${name}`, cb);
return [name, cb];
});

return () => {
for (const [name, cb] of trait_listeners) {
model.off(`change:${name}`, cb);
}
model.off("msg:custom", on_custom_msg);
viewer.removeEventListener("perspective-config-update", sync_to_python);
psp_client.terminate();
viewer.delete();
if (viewer.parentNode === el) {
el.removeChild(viewer);
}
};
}

export default { render };
Loading
Loading