218 lines
5.8 KiB
TypeScript
218 lines
5.8 KiB
TypeScript
/// <reference lib="dom" />
|
|
|
|
import type {
|
|
Config,
|
|
Data,
|
|
Layout,
|
|
// deno-lint-ignore no-unused-vars
|
|
newPlot,
|
|
// deno-lint-ignore no-unused-vars
|
|
Plots,
|
|
} from "npm:@types/plotly.js-dist-min";
|
|
|
|
export { Config, Data, Layout };
|
|
|
|
import webview from "npm:webview";
|
|
import { open } from "jsr:@opensrc/deno-open";
|
|
|
|
export type PlotOptions = {
|
|
width: number;
|
|
height: number;
|
|
title: string;
|
|
engine: "webview" | "browser";
|
|
};
|
|
|
|
export class Plot {
|
|
static #port: number;
|
|
static #server = Deno.serve({
|
|
port: 0,
|
|
onListen: ({ port }) => this.#port = port,
|
|
}, (req: Request) => {
|
|
const { pathname, searchParams } = new URL(req.url);
|
|
|
|
if (pathname === "/") {
|
|
return new Response(html(), {
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
},
|
|
});
|
|
}
|
|
if (pathname === "/favicon.ico") {
|
|
return new Response(null);
|
|
}
|
|
if (pathname === "/ws") {
|
|
const { socket, response } = Deno.upgradeWebSocket(req);
|
|
socket.addEventListener(
|
|
"open",
|
|
() => {
|
|
const uuid = searchParams.get("uuid") ?? "";
|
|
this.#wsList.set(uuid, socket);
|
|
this.#connectionPool.get(uuid)?.();
|
|
this.#connectionPool.delete(uuid);
|
|
},
|
|
);
|
|
return response;
|
|
}
|
|
|
|
return new Response(null, { status: 400 });
|
|
});
|
|
|
|
static #wsList = new Map<string, WebSocket>();
|
|
static #windows: Deno.ChildProcess[] = [];
|
|
static #connectionPool = new Map<string, (value?: undefined) => void>();
|
|
|
|
#window: Deno.ChildProcess | undefined;
|
|
#size: [number, number];
|
|
#title: string | undefined;
|
|
#uuid = crypto.randomUUID();
|
|
#ws = () => Plot.#wsList.get(this.#uuid);
|
|
#unbined = false;
|
|
#engine: "webview" | "browser";
|
|
|
|
constructor(options: Partial<PlotOptions> = {}) {
|
|
this.#title = options.title;
|
|
this.#size = [options.width ?? 400, options.height ?? 400];
|
|
this.#engine = options.engine ?? "webview";
|
|
}
|
|
|
|
update(
|
|
data: Partial<Data>,
|
|
layout: Partial<Layout> | null = null,
|
|
) {
|
|
this.#ws()?.send(
|
|
JSON.stringify({ kind: "plot.update", value: { data, layout } }),
|
|
);
|
|
}
|
|
|
|
async plot(
|
|
data: Partial<Data[]>,
|
|
layout: Partial<Layout> = {},
|
|
config: Partial<Config> = {},
|
|
) {
|
|
const cmd = new Deno.Command(webview.binaryPath, {
|
|
args: [
|
|
`--url=http://localhost:${Plot.#port}?uuid=${this.#uuid}`,
|
|
`--title="${this.#title ?? `Plot ${Plot.#port}`}"`,
|
|
`--width=${this.#size[0]}`,
|
|
`--height=${this.#size[1]}`,
|
|
],
|
|
});
|
|
|
|
if (this.#engine === "browser") {
|
|
open(`http://localhost:${Plot.#port}?uuid=${this.#uuid}`);
|
|
} else {
|
|
this.#window = cmd.spawn();
|
|
}
|
|
|
|
Plot.#windows.push(this.#window);
|
|
|
|
const { promise, resolve } = Promise.withResolvers();
|
|
Plot.#connectionPool.set(this.#uuid, resolve);
|
|
await promise;
|
|
|
|
this.#ws()?.send(
|
|
JSON.stringify({ kind: "plot.plot", value: { data, layout, config } }),
|
|
);
|
|
}
|
|
|
|
#moveWindow(x = 0, y = 0) {
|
|
Plot.#wsList.get(this.#uuid)?.send(
|
|
JSON.stringify({ kind: "window.move", value: { x, y } }),
|
|
);
|
|
}
|
|
#resizeWindow(width: number, height: number) {
|
|
this.#ws()?.send(
|
|
JSON.stringify({ kind: "window.resize", value: { width, height } }),
|
|
);
|
|
}
|
|
|
|
get window(): {
|
|
move: (x: number, y: number) => void;
|
|
resize: (width: number, height: number) => void;
|
|
} {
|
|
return { move: this.#moveWindow, resize: this.#resizeWindow };
|
|
}
|
|
|
|
unbind() {
|
|
this.#unbined = true;
|
|
}
|
|
|
|
[Symbol.dispose]() {
|
|
if (this.#unbined) return;
|
|
this.#ws()?.close(0, "instance dispose");
|
|
if (this.#engine === "webview") {
|
|
this.#window?.kill();
|
|
}
|
|
}
|
|
|
|
static [Symbol.dispose]() {
|
|
this.#wsList.forEach((socket) => socket.close(0, "instance dispose"));
|
|
this.#wsList.clear();
|
|
this.#server.shutdown();
|
|
}
|
|
}
|
|
|
|
function html() {
|
|
return `
|
|
<html>
|
|
<head>
|
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📊</text></svg>">
|
|
<script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
|
|
<style>
|
|
*,::before,::after { box-sizing: border-box; }
|
|
|
|
body { display: grid; place-items: center; height: 100dvh; width: 100dvw; margin: 0; padding: 0; }
|
|
|
|
#root { width: 95%; height: 95%; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module">(${client.toString()})()</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function client() {
|
|
if ("Deno" in globalThis) throw new Error("client only function");
|
|
|
|
const url = new URL(location.href);
|
|
const uuid = url.searchParams.get("uuid") ?? crypto.randomUUID();
|
|
const ws = new WebSocket(`${location.origin}/ws?uuid=${uuid}`);
|
|
const root = document.querySelector<HTMLElement>("#root")!;
|
|
|
|
// ws.addEventListener("open", () => {});
|
|
ws.addEventListener("close", () => {
|
|
close();
|
|
});
|
|
|
|
ws.addEventListener("message", ({ data }) => {
|
|
const { kind, value } = JSON.parse(data);
|
|
if (kind === "plot.plot") {
|
|
// use webgl as default type
|
|
(value.data as Record<string, unknown>[]).forEach((data) =>
|
|
data.type = data?.type ?? "scattergl"
|
|
);
|
|
|
|
//@ts-expect-error client side load from script tag
|
|
Plotly.newPlot(
|
|
root,
|
|
value.data,
|
|
value.layout,
|
|
{ responsive: true, ...(value.config ?? {}) },
|
|
);
|
|
} else if (kind === "plot.update") {
|
|
//@ts-expect-error client side load from script tag
|
|
Plotly.react(root, value.data, value.layout);
|
|
} else if (kind === "window.move") {
|
|
globalThis.moveTo(value.x, value.y);
|
|
} else if (kind === "window.resize") {
|
|
globalThis.resizeTo(value.width, value.height);
|
|
}
|
|
});
|
|
|
|
addEventListener("beforeunload", () => {
|
|
ws.close(1000, "window closed");
|
|
});
|
|
}
|