@tlockney/cdp-kernel 0.9.0 → 0.11.0
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.
- package/dist/browser/kernel.js +584 -425
- package/dist/browser/kernel.js.map +3 -3
- package/dist/browser/target.d.ts +7 -0
- package/dist/browser/target.js +2 -1
- package/dist/browser/worker.js +1191 -76
- package/dist/browser/worker.js.map +4 -4
- package/dist/celld-target.d.ts +2 -1
- package/dist/celld-target.js +5 -3
- package/dist/kernel-core.js +8 -0
- package/dist/package.d.ts +3 -3
- package/dist/package.js +6 -2
- package/dist/workerd-target.js +6 -2
- package/dist/workers-project.js +1 -1
- package/package.json +3 -3
- package/runtime/bootstrap.mjs +2 -0
- package/runtime/browser-worker.mjs +5 -1
- package/runtime/chart.d.mts +23 -0
- package/runtime/chart.mjs +359 -0
- package/runtime/display.d.mts +199 -1
- package/runtime/display.mjs +101 -65
- package/runtime/host.d.mts +248 -0
- package/runtime/host.mjs +215 -0
- package/runtime/sql.d.mts +147 -0
- package/runtime/sql.mjs +426 -0
- package/runtime/table.d.mts +39 -0
- package/runtime/table.mjs +466 -0
- package/runtime/workerd-worker.mjs +5 -1
package/dist/celld-target.d.ts
CHANGED
|
@@ -8,13 +8,14 @@ export declare const PROJECT_KEYS: string[];
|
|
|
8
8
|
* pre-built driver as its one module, and the kernel's token and import.meta
|
|
9
9
|
* as vars, which the driver hides from cells' `env`.
|
|
10
10
|
*/
|
|
11
|
-
export declare function celldConfig(project: CdpParams | null, { token, meta }: {
|
|
11
|
+
export declare function celldConfig(project: CdpParams | null, { token, meta, params }: {
|
|
12
12
|
token: string;
|
|
13
13
|
meta: {
|
|
14
14
|
url: string;
|
|
15
15
|
dirname: string;
|
|
16
16
|
filename: string;
|
|
17
17
|
};
|
|
18
|
+
params?: string;
|
|
18
19
|
}): CdpParams;
|
|
19
20
|
/**
|
|
20
21
|
* celld's installer puts it in ~/.local/bin, which a Jupyter server's PATH
|
package/dist/celld-target.js
CHANGED
|
@@ -48,13 +48,15 @@ export const PROJECT_KEYS = [
|
|
|
48
48
|
* pre-built driver as its one module, and the kernel's token and import.meta
|
|
49
49
|
* as vars, which the driver hides from cells' `env`.
|
|
50
50
|
*/
|
|
51
|
-
export function celldConfig(project, { token, meta }) {
|
|
51
|
+
export function celldConfig(project, { token, meta, params = "" }) {
|
|
52
52
|
const config = { name: "cdpk", main: "driver.mjs", no_bundle: true, compatibility_date: "2026-09-01" };
|
|
53
53
|
for (const key of PROJECT_KEYS)
|
|
54
54
|
if (project && project[key] !== undefined)
|
|
55
55
|
config[key] = project[key];
|
|
56
56
|
const vars = project && typeof project.vars === "object" && project.vars !== null ? project.vars : {};
|
|
57
|
-
|
|
57
|
+
// CDPK_PARAMS: jupyter.params (#456), the JSON text of GANYMEDE_PARAMS;
|
|
58
|
+
// prefixed so a project's own PARAMS is kept.
|
|
59
|
+
config.vars = { ...vars, TOKEN: token, META: JSON.stringify(meta), CDPK_PARAMS: params };
|
|
58
60
|
return config;
|
|
59
61
|
}
|
|
60
62
|
/**
|
|
@@ -199,7 +201,7 @@ class CelldHost {
|
|
|
199
201
|
const token = randomBytes(32).toString("hex");
|
|
200
202
|
// 0600: the config carries this launch's token.
|
|
201
203
|
writeFileSync(join(dir, "driver.mjs"), await bundleDriver(source, main, define));
|
|
202
|
-
writeFileSync(join(dir, "wrangler.jsonc"), JSON.stringify(celldConfig(project, { token, meta: cellMeta(cwd) }), null, 2), {
|
|
204
|
+
writeFileSync(join(dir, "wrangler.jsonc"), JSON.stringify(celldConfig(project, { token, meta: cellMeta(cwd), params: process.env.GANYMEDE_PARAMS }), null, 2), {
|
|
203
205
|
mode: 0o600,
|
|
204
206
|
});
|
|
205
207
|
const port = await freePort();
|
package/dist/kernel-core.js
CHANGED
|
@@ -325,6 +325,14 @@ export class KernelCore {
|
|
|
325
325
|
// Its prompt, if any, died with it; a late reply is dropped.
|
|
326
326
|
this.#input = null;
|
|
327
327
|
void this.#debugger?.attachTarget(null);
|
|
328
|
+
// Its comms died with it: tell the frontends, so a widget or a
|
|
329
|
+
// Ganymede host comm isn't left waiting on a runtime that is gone,
|
|
330
|
+
// and the next runtime can open its own (#461).
|
|
331
|
+
if (!this.#shuttingDown) {
|
|
332
|
+
for (const commId of this.#comms.keys())
|
|
333
|
+
this.#publish("comm_close", { comm_id: commId, data: {} });
|
|
334
|
+
}
|
|
335
|
+
this.#comms.clear();
|
|
328
336
|
if (!this.#shuttingDown) {
|
|
329
337
|
const text = note ?? `[cdp-kernel] ${this.#runtime} exited (${code}); restarting on next cell`;
|
|
330
338
|
this.#publish("stream", { name: "stderr", text: `\n${text}\n` });
|
package/dist/package.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.11.0";
|
|
3
3
|
/**
|
|
4
4
|
* The UTC day this version was released, set with VERSION. The publish
|
|
5
5
|
* workflow refuses a tag unless it's today or yesterday, so it can't go
|
|
6
6
|
* stale: see dependencyCutoff.
|
|
7
7
|
*/
|
|
8
|
-
export declare const RELEASE_DATE = "2026-11-
|
|
8
|
+
export declare const RELEASE_DATE = "2026-11-13";
|
|
9
9
|
/**
|
|
10
10
|
* Deno blocks packages less than 24 hours old (minimum dependency age), so
|
|
11
11
|
* a kernelspec relaunching a release from JSR would fail on release day. It
|
|
@@ -14,7 +14,7 @@ export declare const RELEASE_DATE = "2026-11-09";
|
|
|
14
14
|
* late in the day its tag was pushed), and nothing newer ever is.
|
|
15
15
|
*/
|
|
16
16
|
export declare function dependencyCutoff(releaseDate?: string): string;
|
|
17
|
-
export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
|
|
17
|
+
export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "sql.mjs", "chart.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
|
|
18
18
|
export type HostRuntime = "node" | "deno";
|
|
19
19
|
export declare function hostRuntime(): HostRuntime;
|
|
20
20
|
/** Where the CLI module was loaded from. */
|
package/dist/package.js
CHANGED
|
@@ -14,13 +14,13 @@ import { join, sep } from "node:path";
|
|
|
14
14
|
import process from "node:process";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
/** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
|
|
17
|
-
export const VERSION = "0.
|
|
17
|
+
export const VERSION = "0.11.0";
|
|
18
18
|
/**
|
|
19
19
|
* The UTC day this version was released, set with VERSION. The publish
|
|
20
20
|
* workflow refuses a tag unless it's today or yesterday, so it can't go
|
|
21
21
|
* stale: see dependencyCutoff.
|
|
22
22
|
*/
|
|
23
|
-
export const RELEASE_DATE = "2026-11-
|
|
23
|
+
export const RELEASE_DATE = "2026-11-13";
|
|
24
24
|
/**
|
|
25
25
|
* Deno blocks packages less than 24 hours old (minimum dependency age), so
|
|
26
26
|
* a kernelspec relaunching a release from JSR would fail on release day. It
|
|
@@ -43,7 +43,11 @@ export const RUNTIME_FILES = [
|
|
|
43
43
|
"workerd-worker.mjs",
|
|
44
44
|
"display.mjs",
|
|
45
45
|
"comm.mjs",
|
|
46
|
+
"host.mjs",
|
|
46
47
|
"widget.mjs",
|
|
48
|
+
"table.mjs",
|
|
49
|
+
"sql.mjs",
|
|
50
|
+
"chart.mjs",
|
|
47
51
|
"node-hooks.mjs",
|
|
48
52
|
"deno-compat.mjs",
|
|
49
53
|
"node-modules-resolve.mjs",
|
package/dist/workerd-target.js
CHANGED
|
@@ -35,7 +35,7 @@ import { connectUnixWebSocket } from "./ws-client.js";
|
|
|
35
35
|
import { findWranglerConfig, startMiniflare } from "./workers-project.js";
|
|
36
36
|
import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
|
|
37
37
|
/** The driver Worker's modules; the first is its main module. */
|
|
38
|
-
export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "widget.mjs", "evaluator.mjs", "introspect.mjs"];
|
|
38
|
+
export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "sql.mjs", "chart.mjs", "evaluator.mjs", "introspect.mjs"];
|
|
39
39
|
/** See BunTarget: how long an interrupted cell has to show it isn't busy. */
|
|
40
40
|
export const INTERRUPT_GRACE_MS = 500;
|
|
41
41
|
export function restartNote(runtime) {
|
|
@@ -71,6 +71,7 @@ const driver :Workerd.Worker = (
|
|
|
71
71
|
(name = "UNSAFE", unsafeEval = void),
|
|
72
72
|
(name = "TOKEN", fromEnvironment = "CDPK_TOKEN"),
|
|
73
73
|
(name = "META", fromEnvironment = "CDPK_META"),
|
|
74
|
+
(name = "CDPK_PARAMS", fromEnvironment = "CDPK_PARAMS"),
|
|
74
75
|
],
|
|
75
76
|
);
|
|
76
77
|
`;
|
|
@@ -332,7 +333,10 @@ export class RawWorkerd {
|
|
|
332
333
|
copyFileSync(join(source, name), join(dir, name));
|
|
333
334
|
writeFileSync(join(dir, "config.capnp"), workerdConfig({ socket, compatibilityDate }));
|
|
334
335
|
const token = randomBytes(32).toString("hex");
|
|
335
|
-
const env = { ...process.env, CDPK_TOKEN: token, CDPK_META: JSON.stringify(cellMeta(cwd))
|
|
336
|
+
const env = { ...process.env, CDPK_TOKEN: token, CDPK_META: JSON.stringify(cellMeta(cwd)),
|
|
337
|
+
// jupyter.params (#456); always set, so the binding exists.
|
|
338
|
+
CDPK_PARAMS: process.env.GANYMEDE_PARAMS ?? "",
|
|
339
|
+
};
|
|
336
340
|
// --experimental: the UnsafeEval binding.
|
|
337
341
|
const child = spawn(workerd, ["serve", "config.capnp", "--experimental"], { cwd: dir, stdio: ["ignore", "pipe", "pipe"], env });
|
|
338
342
|
child.once("exit", () => rmSync(dir, { recursive: true, force: true }));
|
package/dist/workers-project.js
CHANGED
|
@@ -185,7 +185,7 @@ export async function startMiniflare(configPath, cwd, startTimeoutMs) {
|
|
|
185
185
|
unsafeEvalBinding: "UNSAFE",
|
|
186
186
|
compatibilityDate: date,
|
|
187
187
|
compatibilityFlags: compatibilityFlagsFor(workerOptions.compatibilityFlags ?? []),
|
|
188
|
-
bindings: { ...workerOptions.bindings, TOKEN: token, META: JSON.stringify(cellMeta(cwd)) },
|
|
188
|
+
bindings: { ...workerOptions.bindings, TOKEN: token, META: JSON.stringify(cellMeta(cwd)), CDPK_PARAMS: process.env.GANYMEDE_PARAMS ?? "" },
|
|
189
189
|
// Miniflare 5 renamed Miniflare 4's defaultPersistRoot.
|
|
190
190
|
...(v5 ? { resourcePersistencePath: state } : { defaultPersistRoot: state }),
|
|
191
191
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tlockney/cdp-kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Thomas Lockney <thomas@lockney.net>",
|
|
@@ -77,11 +77,11 @@
|
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"acorn": "^8.15.0",
|
|
79
79
|
"acorn-walk": "^8.3.4",
|
|
80
|
-
"esbuild": "^0.
|
|
80
|
+
"esbuild": "^0.28.2",
|
|
81
81
|
"typescript": "^6.0.3"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@types/node": "^
|
|
84
|
+
"@types/node": "^26.6.6",
|
|
85
85
|
"medium-zoom": "^1.1.0",
|
|
86
86
|
"vitepress": "^1.6.4",
|
|
87
87
|
"workerd": "1.20261015.1",
|
package/runtime/bootstrap.mjs
CHANGED
|
@@ -83,6 +83,8 @@ function send(payload) {
|
|
|
83
83
|
|
|
84
84
|
const jupyter = createJupyter({
|
|
85
85
|
inspect: (value) => inspect(value),
|
|
86
|
+
// jupyter.params (#456): the kernel's environment reaches the target.
|
|
87
|
+
params: () => (isDeno ? Deno.env.get("GANYMEDE_PARAMS") : process.env.GANYMEDE_PARAMS),
|
|
86
88
|
base64,
|
|
87
89
|
readFile,
|
|
88
90
|
send(msgType, content, metadata, buffers) {
|
|
@@ -65,7 +65,10 @@ const jupyter = createJupyter({
|
|
|
65
65
|
// A synchronous prompt would need SharedArrayBuffer and Atomics.wait, so
|
|
66
66
|
// a cross-origin isolated page; until then, as with no stdin.
|
|
67
67
|
input: () => null,
|
|
68
|
+
// jupyter.params (#456): the target's `params` option, sent with info.
|
|
69
|
+
params: () => paramsText,
|
|
68
70
|
});
|
|
71
|
+
let paramsText;
|
|
69
72
|
|
|
70
73
|
// --- Modules ----------------------------------------------------------------
|
|
71
74
|
let cdn = DEFAULT_CDN;
|
|
@@ -150,7 +153,8 @@ async function preload(urls, base) {
|
|
|
150
153
|
// --- The shim cells and the evaluator use -----------------------------------
|
|
151
154
|
let handle = null;
|
|
152
155
|
|
|
153
|
-
function install({ base, cdn: cdnBase }) {
|
|
156
|
+
function install({ base, cdn: cdnBase, params }) {
|
|
157
|
+
if (typeof params === "string") paramsText = params;
|
|
154
158
|
if (typeof cdnBase === "string") cdn = cdnBase.endsWith("/") ? cdnBase : cdnBase + "/";
|
|
155
159
|
const url = new URL("$cdpk$repl.mjs", base).href;
|
|
156
160
|
globalThis.__cdpk = {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const VEGA: string;
|
|
2
|
+
export const VEGA_LITE: string;
|
|
3
|
+
export const LINKEDOM: string;
|
|
4
|
+
export const WARN_BYTES: number;
|
|
5
|
+
export const VEGA_CONFIG: Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
export function isPlotModule(value: unknown): boolean;
|
|
8
|
+
export function isSvgElement(value: unknown): boolean;
|
|
9
|
+
export function isVegaSpec(spec: Record<string, unknown>): boolean;
|
|
10
|
+
export function specOf(value: unknown): Record<string, unknown>;
|
|
11
|
+
export function svgSize(svg: string): { width: number; height: number };
|
|
12
|
+
export function withNamespace(svg: string): string;
|
|
13
|
+
export function legendSVG(scale: unknown, width: number, form?: "ramp" | "swatches"): { svg: string; height: number };
|
|
14
|
+
export function composeSVG(
|
|
15
|
+
inner: string,
|
|
16
|
+
parts: { title?: string; subtitle?: string; caption?: string; legend?: { svg: string; height: number } },
|
|
17
|
+
): string;
|
|
18
|
+
export function summarizePlot(element: unknown, options: Record<string, unknown>): string;
|
|
19
|
+
export function createChart(options: {
|
|
20
|
+
importModule: (specifier: string) => Promise<any>;
|
|
21
|
+
summarizeSpec?: (spec: Record<string, unknown>) => string | undefined;
|
|
22
|
+
warn?: (message: string) => void;
|
|
23
|
+
}): (chart: unknown, options?: unknown) => import("./display.d.mts").Displayable;
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// @ts-self-types="./chart.d.mts"
|
|
2
|
+
// jupyter.chart (cdp-kernel#479, ganymede#747): a Vega-Lite (or Vega) spec,
|
|
3
|
+
// or Observable Plot options, drawn in the kernel as one SVG with its
|
|
4
|
+
// legend and title inside, plus a text summary. An SVG shows the same in
|
|
5
|
+
// every reader: the Ganymede apps, JupyterLab, VS Code, a published page,
|
|
6
|
+
// and an agent that gets it as a PNG. Plot draws a categorical legend as
|
|
7
|
+
// HTML beside its SVG, so the legend is drawn here instead, from the
|
|
8
|
+
// plot's color scale.
|
|
9
|
+
//
|
|
10
|
+
// vega, vega-lite and linkedom (Plot's DOM where there is none) load
|
|
11
|
+
// through the cell's own import on first use, as jupyter.sql's DuckDB
|
|
12
|
+
// does, so nothing loads for a notebook that draws no chart.
|
|
13
|
+
//
|
|
14
|
+
// No static imports: this file must load in every target.
|
|
15
|
+
|
|
16
|
+
export const VEGA = "npm:vega@6";
|
|
17
|
+
export const VEGA_LITE = "npm:vega-lite@6";
|
|
18
|
+
export const LINKEDOM = "npm:linkedom@0.18";
|
|
19
|
+
/** Past this much SVG, jupyter.chart warns: aggregate or sample first. */
|
|
20
|
+
export const WARN_BYTES = 2_000_000;
|
|
21
|
+
|
|
22
|
+
const $display = Symbol.for("Jupyter.display");
|
|
23
|
+
/** Plot scales with a legend option, besides color. */
|
|
24
|
+
const OTHER_LEGENDS = ["opacity", "r", "symbol"];
|
|
25
|
+
const FONT = "system-ui, sans-serif";
|
|
26
|
+
|
|
27
|
+
const rawToEntity = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
28
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => rawToEntity[c]);
|
|
29
|
+
|
|
30
|
+
function isObject(value) {
|
|
31
|
+
return value !== null && typeof value === "object";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Plot's module: it has plot() and mark factories. */
|
|
35
|
+
export function isPlotModule(value) {
|
|
36
|
+
return (isObject(value) || typeof value === "function") && typeof value.plot === "function" &&
|
|
37
|
+
typeof value.barY === "function";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Keys one of which every Vega-Lite or Vega spec has. */
|
|
41
|
+
const SPEC_KEYS = ["$schema", "mark", "marks", "layer", "spec", "concat", "hconcat", "vconcat", "facet", "repeat"];
|
|
42
|
+
|
|
43
|
+
/** A spec object, or anything with toSpec() (vega-lite-api). */
|
|
44
|
+
export function specOf(value) {
|
|
45
|
+
const spec = isObject(value) && typeof value.toSpec === "function" ? value.toSpec() : value;
|
|
46
|
+
if (!isObject(spec) || Array.isArray(spec) || !SPEC_KEYS.some((key) => key in spec)) {
|
|
47
|
+
throw new TypeError(
|
|
48
|
+
"jupyter.chart takes a Vega-Lite or Vega spec, the Plot module and Plot options, or an SVG element",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return spec;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An SVG element already drawn, such as Plot's output without a legend. */
|
|
55
|
+
export function isSvgElement(value) {
|
|
56
|
+
return isObject(value) && typeof value.outerHTML === "string" && /^<svg\b/i.test(value.outerHTML);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isVegaSpec(spec) {
|
|
60
|
+
return typeof spec.$schema === "string" && /\/schema\/vega\/v\d/.test(spec.$schema);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- SVG helpers --------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** An SVG's width and height from its attributes or viewBox. */
|
|
66
|
+
export function svgSize(svg) {
|
|
67
|
+
const open = /<svg\b[^>]*>/i.exec(svg)?.[0] ?? "";
|
|
68
|
+
const attr = (name) => {
|
|
69
|
+
const m = new RegExp(`\\s${name}="([^"]*)"`, "i").exec(open);
|
|
70
|
+
return m ? m[1] : undefined;
|
|
71
|
+
};
|
|
72
|
+
let width = parseFloat(attr("width") ?? "");
|
|
73
|
+
let height = parseFloat(attr("height") ?? "");
|
|
74
|
+
const box = attr("viewBox")?.trim().split(/[\s,]+/).map(Number);
|
|
75
|
+
if (!Number.isFinite(width) && box?.length === 4) width = box[2];
|
|
76
|
+
if (!Number.isFinite(height) && box?.length === 4) height = box[3];
|
|
77
|
+
return { width: Number.isFinite(width) ? width : 640, height: Number.isFinite(height) ? height : 400 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The SVG with the namespace an <img> needs (an HTML DOM leaves it out). */
|
|
81
|
+
export function withNamespace(svg) {
|
|
82
|
+
return /<svg\b[^>]*\sxmlns=/i.test(svg) ? svg : svg.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A short FNV-1a hash of `text`, in base 36. */
|
|
86
|
+
function hash(text) {
|
|
87
|
+
let h = 0x811c9dc5;
|
|
88
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
89
|
+
return (h >>> 0).toString(36);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const textWidth = (text, size) => String(text).length * size * 0.6;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A legend for a color scale, as SVG: swatches for a categorical scale,
|
|
96
|
+
* a ramp for a continuous one, or the form `form` names ("swatches" or
|
|
97
|
+
* "ramp", as Plot's `legend` option does). `scale` is Plot's scale
|
|
98
|
+
* description.
|
|
99
|
+
*/
|
|
100
|
+
export function legendSVG(scale, width, form) {
|
|
101
|
+
if (!isObject(scale) || typeof scale.apply !== "function" || !Array.isArray(scale.domain)) return { svg: "", height: 0 };
|
|
102
|
+
const label = typeof scale.label === "string" && scale.label ? scale.label : "";
|
|
103
|
+
const parts = [];
|
|
104
|
+
let y = 0;
|
|
105
|
+
if (label) {
|
|
106
|
+
parts.push(`<text x="0" y="10" font-weight="bold">${esc(label)}</text>`);
|
|
107
|
+
y = 16;
|
|
108
|
+
}
|
|
109
|
+
const continuous = form === "ramp" ||
|
|
110
|
+
(form !== "swatches" &&
|
|
111
|
+
/^(linear|log|pow|sqrt|symlog|utc|time|sequential|diverging|quantize|threshold|quantile)/.test(String(scale.type)));
|
|
112
|
+
if (!continuous) {
|
|
113
|
+
let x = 0;
|
|
114
|
+
let row = y;
|
|
115
|
+
for (const value of scale.domain) {
|
|
116
|
+
const text = String(value);
|
|
117
|
+
const w = 14 + textWidth(text, 10) + 12;
|
|
118
|
+
if (x > 0 && x + w > width) {
|
|
119
|
+
x = 0;
|
|
120
|
+
row += 16;
|
|
121
|
+
}
|
|
122
|
+
parts.push(
|
|
123
|
+
`<rect x="${x}" y="${row}" width="10" height="10" fill="${esc(scale.apply(value))}"/>` +
|
|
124
|
+
`<text x="${x + 14}" y="${row + 9}">${esc(text)}</text>`,
|
|
125
|
+
);
|
|
126
|
+
x += w;
|
|
127
|
+
}
|
|
128
|
+
return { svg: parts.join(""), height: row + 14 };
|
|
129
|
+
}
|
|
130
|
+
const [lo, hi] = [scale.domain[0], scale.domain.at(-1)];
|
|
131
|
+
const n = 16;
|
|
132
|
+
const stops = [];
|
|
133
|
+
// Plot's interpolator runs over the scale's output (its range), so
|
|
134
|
+
// sampling it spaces a log or sqrt scale's colors as the chart does;
|
|
135
|
+
// without one, step through the domain, Dates as their times.
|
|
136
|
+
const range = Array.isArray(scale.range) && scale.range.length >= 2 && scale.range.every((r) => typeof r === "number")
|
|
137
|
+
? [scale.range[0], scale.range.at(-1)]
|
|
138
|
+
: undefined;
|
|
139
|
+
const numeric = (v) => (v instanceof Date ? v.getTime() : typeof v === "number" ? v : NaN);
|
|
140
|
+
const [a, b] = [numeric(lo), numeric(hi)];
|
|
141
|
+
for (let i = 0; i <= n; i++) {
|
|
142
|
+
const t = i / n;
|
|
143
|
+
let color;
|
|
144
|
+
if (typeof scale.interpolate === "function" && range) color = scale.interpolate(range[0] + (range[1] - range[0]) * t);
|
|
145
|
+
else if (Number.isFinite(a) && Number.isFinite(b)) {
|
|
146
|
+
const v = a + (b - a) * t;
|
|
147
|
+
color = scale.apply(lo instanceof Date ? new Date(v) : v);
|
|
148
|
+
} else color = scale.apply(i === 0 ? lo : hi);
|
|
149
|
+
stops.push(`<stop offset="${t * 100}%" stop-color="${esc(color)}"/>`);
|
|
150
|
+
}
|
|
151
|
+
// From the stops, so the same chart gives the same SVG on every run.
|
|
152
|
+
const id = `ramp-${hash(stops.join(""))}`;
|
|
153
|
+
const rampWidth = Math.min(240, width);
|
|
154
|
+
const fmt = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === "number" ? String(Number(v.toPrecision(4))) : String(v));
|
|
155
|
+
parts.push(
|
|
156
|
+
`<defs><linearGradient id="${id}">${stops.join("")}</linearGradient></defs>` +
|
|
157
|
+
`<rect x="0" y="${y}" width="${rampWidth}" height="10" fill="url(#${id})"/>` +
|
|
158
|
+
`<text x="0" y="${y + 22}">${esc(fmt(lo))}</text>` +
|
|
159
|
+
`<text x="${rampWidth}" y="${y + 22}" text-anchor="end">${esc(fmt(hi))}</text>`,
|
|
160
|
+
);
|
|
161
|
+
return { svg: parts.join(""), height: y + 26 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Plot's SVG with a title, subtitle, legend and caption around it, all in
|
|
166
|
+
* one SVG.
|
|
167
|
+
*/
|
|
168
|
+
export function composeSVG(inner, { title, subtitle, caption, legend }) {
|
|
169
|
+
const { width, height } = svgSize(inner);
|
|
170
|
+
const head = [];
|
|
171
|
+
let y = 0;
|
|
172
|
+
if (title) {
|
|
173
|
+
head.push(`<text x="0" y="${y + 16}" font-size="16" font-weight="bold">${esc(title)}</text>`);
|
|
174
|
+
y += 24;
|
|
175
|
+
}
|
|
176
|
+
if (subtitle) {
|
|
177
|
+
head.push(`<text x="0" y="${y + 13}" font-size="13">${esc(subtitle)}</text>`);
|
|
178
|
+
y += 20;
|
|
179
|
+
}
|
|
180
|
+
if (legend?.svg) {
|
|
181
|
+
head.push(`<g transform="translate(0,${y})" font-size="10">${legend.svg}</g>`);
|
|
182
|
+
y += legend.height + 8;
|
|
183
|
+
}
|
|
184
|
+
const body = withNamespace(inner).replace(/<svg\b/i, `<svg x="0" y="${y}"`);
|
|
185
|
+
let total = y + height;
|
|
186
|
+
let foot = "";
|
|
187
|
+
if (caption) {
|
|
188
|
+
foot = `<text x="0" y="${total + 16}" font-size="12">${esc(caption)}</text>`;
|
|
189
|
+
total += 24;
|
|
190
|
+
}
|
|
191
|
+
return (
|
|
192
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${total}" viewBox="0 0 ${width} ${total}" ` +
|
|
193
|
+
`font-family="${FONT}" fill="currentColor">${head.join("")}${body}${foot}</svg>`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Summaries ----------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
function markName(mark) {
|
|
200
|
+
if (typeof mark?.ariaLabel === "string" && mark.ariaLabel) return mark.ariaLabel;
|
|
201
|
+
return mark?.constructor?.name?.toLowerCase() ?? "mark";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function describeScale(name, scale) {
|
|
205
|
+
if (!isObject(scale)) return null;
|
|
206
|
+
const label = typeof scale.label === "string" && scale.label ? scale.label : "";
|
|
207
|
+
const domain = Array.isArray(scale.domain) ? scale.domain : [];
|
|
208
|
+
let range = "";
|
|
209
|
+
if (domain.length && domain.every((v) => typeof v === "number" || v instanceof Date) && !/ordinal|band|point/.test(scale.type)) {
|
|
210
|
+
const fmt = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(Number(v.toPrecision(4))));
|
|
211
|
+
range = `${fmt(domain[0])} to ${fmt(domain.at(-1))}`;
|
|
212
|
+
} else if (domain.length) {
|
|
213
|
+
range = `${domain.length} ${domain.length === 1 ? "value" : "values"}`;
|
|
214
|
+
}
|
|
215
|
+
const details = [scale.type, range].filter(Boolean).join(", ");
|
|
216
|
+
return `${name}${label ? `=${label}` : ""}${details ? ` (${details})` : ""}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A few lines about a Plot chart, for readers that can't see it. */
|
|
220
|
+
export function summarizePlot(element, options) {
|
|
221
|
+
const marks = (Array.isArray(options.marks) ? options.marks.flat(Infinity) : []).filter(isObject);
|
|
222
|
+
const names = [...new Set(marks.map(markName))];
|
|
223
|
+
const scales = ["x", "y", "fx", "fy", "color", "r", "opacity", "symbol"]
|
|
224
|
+
.map((name) => (typeof element?.scale === "function" ? describeScale(name, element.scale(name)) : null))
|
|
225
|
+
.filter(Boolean);
|
|
226
|
+
const rows = marks.map((m) => (Array.isArray(m.data) ? m.data.length : typeof m.data?.length === "number" ? m.data.length : 0));
|
|
227
|
+
const most = rows.length ? Math.max(...rows) : 0;
|
|
228
|
+
const title = typeof options.title === "string" && options.title ? ` "${options.title}"` : "";
|
|
229
|
+
const parts = [...scales];
|
|
230
|
+
if (most) parts.push(`${most} ${most === 1 ? "row" : "rows"}`);
|
|
231
|
+
return `Plot ${names.length ? `${names.join(", ")} ` : ""}chart${title}${parts.length ? `: ${parts.join(", ")}` : ""}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Vega's defaults, for a chart on any background ----------------------------
|
|
235
|
+
|
|
236
|
+
const INK = "currentColor";
|
|
237
|
+
/**
|
|
238
|
+
* Text and lines in `currentColor`, on no background: the page's text
|
|
239
|
+
* color when the SVG is inline, and in the Ganymede apps, which give an
|
|
240
|
+
* SVG image the page's color. In a plain <img> it is black.
|
|
241
|
+
*/
|
|
242
|
+
export const VEGA_CONFIG = {
|
|
243
|
+
background: "transparent",
|
|
244
|
+
axis: { labelColor: INK, titleColor: INK, domainColor: INK, tickColor: INK, gridColor: INK, gridOpacity: 0.15 },
|
|
245
|
+
legend: { labelColor: INK, titleColor: INK },
|
|
246
|
+
title: { color: INK, subtitleColor: INK },
|
|
247
|
+
header: { labelColor: INK, titleColor: INK },
|
|
248
|
+
view: { stroke: null },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
function mergeConfig(base, own) {
|
|
252
|
+
if (!isObject(own)) return base;
|
|
253
|
+
const out = { ...base };
|
|
254
|
+
for (const [key, value] of Object.entries(own)) {
|
|
255
|
+
out[key] = isObject(value) && isObject(base[key]) && !Array.isArray(value) ? mergeConfig(base[key], value) : value;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- The helper -----------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* @param {{
|
|
264
|
+
* importModule: (specifier: string) => Promise<any>,
|
|
265
|
+
* summarizeSpec?: (spec: object) => string | undefined,
|
|
266
|
+
* warn?: (message: string) => void,
|
|
267
|
+
* }} options
|
|
268
|
+
*/
|
|
269
|
+
export function createChart({ importModule, summarizeSpec, warn = (m) => console.warn(m) }) {
|
|
270
|
+
let dom;
|
|
271
|
+
async function document() {
|
|
272
|
+
if (globalThis.document && typeof globalThis.document.createElementNS === "function") return globalThis.document;
|
|
273
|
+
// A failed load isn't kept, so the next chart tries again.
|
|
274
|
+
dom ??= importModule(LINKEDOM)
|
|
275
|
+
.then(({ parseHTML }) => parseHTML("<!doctype html><html><body></body></html>").document)
|
|
276
|
+
.catch((err) => {
|
|
277
|
+
dom = undefined;
|
|
278
|
+
throw err;
|
|
279
|
+
});
|
|
280
|
+
return dom;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function renderVega(spec, options) {
|
|
284
|
+
const keepSpec = isObject(options) && options.spec === true;
|
|
285
|
+
const [vega, vl] = await Promise.all([importModule(VEGA), isVegaSpec(spec) ? null : importModule(VEGA_LITE)]);
|
|
286
|
+
const config = mergeConfig(VEGA_CONFIG, spec.config);
|
|
287
|
+
const vgSpec = vl ? vl.compile({ ...spec, config }).spec : { ...spec, config };
|
|
288
|
+
const view = new vega.View(vega.parse(vgSpec), { renderer: "none" });
|
|
289
|
+
let svg;
|
|
290
|
+
try {
|
|
291
|
+
svg = await view.toSVG();
|
|
292
|
+
} finally {
|
|
293
|
+
view.finalize();
|
|
294
|
+
}
|
|
295
|
+
const bundle = { "image/svg+xml": withNamespace(svg) };
|
|
296
|
+
const summary = summarizeSpec?.({ $schema: "https://vega.github.io/schema/vega-lite/v5.json", ...spec });
|
|
297
|
+
if (summary) bundle["text/plain"] = summary;
|
|
298
|
+
if (keepSpec) {
|
|
299
|
+
const v4 = /\/schema\/vega-lite\/v4/.test(String(spec.$schema));
|
|
300
|
+
bundle[vl ? (v4 ? "application/vnd.vegalite.v4+json" : "application/vnd.vegalite.v5+json") : "application/vnd.vega.v5+json"] = spec;
|
|
301
|
+
}
|
|
302
|
+
return bundle;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function renderPlot(Plot, options) {
|
|
306
|
+
if (!isObject(options)) throw new TypeError("jupyter.chart(Plot, options): options must be Plot's options object");
|
|
307
|
+
const { title, subtitle, caption, ...rest } = options;
|
|
308
|
+
const color = isObject(rest.color) ? rest.color : undefined;
|
|
309
|
+
const wantLegend = Boolean(color?.legend);
|
|
310
|
+
const plotOptions = { ...rest, document: await document(), figure: false };
|
|
311
|
+
if (color) plotOptions.color = { ...color, legend: false };
|
|
312
|
+
// Only the color legend is drawn; Plot would put the others in an
|
|
313
|
+
// HTML figure, which a reader that shows only images drops.
|
|
314
|
+
const others = OTHER_LEGENDS.filter((name) => isObject(rest[name]) && rest[name].legend);
|
|
315
|
+
if (others.length) {
|
|
316
|
+
warn(`jupyter.chart draws only a color legend; the ${others.join(", ")} legend is left out`);
|
|
317
|
+
}
|
|
318
|
+
const element = Plot.plot(plotOptions);
|
|
319
|
+
const inner = element.outerHTML;
|
|
320
|
+
const { width } = svgSize(inner);
|
|
321
|
+
const scale = wantLegend && typeof element.scale === "function" ? element.scale("color") : undefined;
|
|
322
|
+
const form = color?.legend === "ramp" || color?.legend === "swatches" ? color.legend : undefined;
|
|
323
|
+
const legend = scale ? legendSVG({ ...scale, label: scale.label ?? color.label }, width, form) : undefined;
|
|
324
|
+
const svg = composeSVG(inner, { title, subtitle, caption, legend });
|
|
325
|
+
return { "image/svg+xml": withNamespace(svg), "text/plain": summarizePlot(element, options) };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function chart(first, second) {
|
|
329
|
+
// Checked now, so a wrong argument fails the call.
|
|
330
|
+
const spec = isPlotModule(first) || isSvgElement(first) ? undefined : specOf(first);
|
|
331
|
+
if (isPlotModule(first) && !isObject(second)) {
|
|
332
|
+
throw new TypeError("jupyter.chart(Plot, options): options must be Plot's options object");
|
|
333
|
+
}
|
|
334
|
+
if (isSvgElement(first)) {
|
|
335
|
+
const bundle = { "image/svg+xml": withNamespace(first.outerHTML) };
|
|
336
|
+
return { [$display]: () => bundle };
|
|
337
|
+
}
|
|
338
|
+
let pending;
|
|
339
|
+
const render = () =>
|
|
340
|
+
(pending ??= (async () => {
|
|
341
|
+
const bundle = spec ? await renderVega(spec, second) : await renderPlot(first, second);
|
|
342
|
+
const size = bundle["image/svg+xml"].length;
|
|
343
|
+
if (size > WARN_BYTES) {
|
|
344
|
+
warn(
|
|
345
|
+
`jupyter.chart: the SVG is ${(size / 1e6).toFixed(1)} MB, which makes the notebook large; ` +
|
|
346
|
+
"aggregate or sample the data first",
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return bundle;
|
|
350
|
+
})().catch((err) => {
|
|
351
|
+
// Not kept, so displaying the chart again retries.
|
|
352
|
+
pending = undefined;
|
|
353
|
+
throw err;
|
|
354
|
+
}));
|
|
355
|
+
return { [$display]: render };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return chart;
|
|
359
|
+
}
|