@tlockney/cdp-kernel 0.6.0 → 0.7.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/README.md +1 -1
- package/dist/browser/channels.d.ts +27 -0
- package/dist/browser/channels.js +90 -0
- package/dist/browser/kernel.js +29674 -0
- package/dist/browser/kernel.js.map +7 -0
- package/dist/browser/mod.d.ts +20 -0
- package/dist/browser/mod.js +38 -0
- package/dist/browser/target.d.ts +71 -0
- package/dist/browser/target.js +206 -0
- package/dist/browser/worker.js +1000 -0
- package/dist/browser/worker.js.map +7 -0
- package/dist/cdp.d.ts +9 -0
- package/dist/debugger.d.ts +2 -0
- package/dist/debugger.js +12 -6
- package/dist/kernel-core.d.ts +69 -0
- package/dist/kernel-core.js +754 -0
- package/dist/kernel.d.ts +6 -1
- package/dist/kernel.js +92 -723
- package/dist/message.d.ts +34 -0
- package/dist/message.js +14 -0
- package/dist/package.d.ts +2 -2
- package/dist/package.js +2 -2
- package/dist/target-api.d.ts +80 -0
- package/dist/target-api.js +22 -0
- package/dist/target.d.ts +4 -76
- package/dist/target.js +39 -22
- package/dist/traceback.d.ts +75 -0
- package/dist/traceback.js +200 -0
- package/dist/transform.d.ts +1 -0
- package/dist/transform.js +8 -1
- package/dist/wire.d.ts +4 -24
- package/dist/wire.js +3 -12
- package/dist/zmq-channels.d.ts +14 -0
- package/dist/zmq-channels.js +59 -0
- package/package.json +7 -2
- package/runtime/browser-worker.mjs +119 -0
- package/runtime/cdn.d.mts +2 -0
- package/runtime/cdn.mjs +17 -0
- package/runtime/display.mjs +202 -1
- package/runtime/format.d.mts +2 -0
- package/runtime/format.mjs +125 -0
package/dist/transform.js
CHANGED
|
@@ -23,7 +23,14 @@ export const SHIM = "__cdpk";
|
|
|
23
23
|
// which is what lets a Runtime.terminateExecution from the control session
|
|
24
24
|
// interrupt a busy loop. (Within that dispatch the inspector processes no
|
|
25
25
|
// other messages, so an interrupt would never be seen.)
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// An expression that throws before its first await rejects the replMode
|
|
28
|
+
// result promise at once, inside that dispatch, and a GC before the
|
|
29
|
+
// inspector's then-callback runs fails the evaluate with "Promise was
|
|
30
|
+
// collected" (#329). Yielding first moves the throw into a task, where
|
|
31
|
+
// SETTLE's reasoning applies. The debug console uses YIELD for this.
|
|
32
|
+
export const YIELD = `await ${SHIM}.tick();\n`;
|
|
33
|
+
const PRELUDE = `'use strict'; ${YIELD}`;
|
|
27
34
|
/**
|
|
28
35
|
* Each replMode cell also ends by yielding one macrotask, so the script
|
|
29
36
|
* settles with no other microtasks queued ahead of the inspector's
|
package/dist/wire.d.ts
CHANGED
|
@@ -1,35 +1,15 @@
|
|
|
1
|
+
import { type Header, type JsonObject, type KernelMessage } from "./message.ts";
|
|
2
|
+
export { makeHeader, PROTOCOL_VERSION } from "./message.ts";
|
|
3
|
+
export type { Header, JsonObject, ReceivedHeader } from "./message.ts";
|
|
1
4
|
export declare const DELIMITER: Uint8Array<ArrayBuffer>;
|
|
2
|
-
export
|
|
3
|
-
export type JsonObject = {
|
|
4
|
-
[key: string]: unknown;
|
|
5
|
-
};
|
|
6
|
-
export interface Header extends JsonObject {
|
|
7
|
-
msg_id: string;
|
|
8
|
-
session: string;
|
|
9
|
-
date: string;
|
|
10
|
-
username: string;
|
|
11
|
-
msg_type: string;
|
|
12
|
-
version: string;
|
|
13
|
-
}
|
|
14
|
-
/** A header as received: the fields the kernel relies on, plus whatever else came. */
|
|
15
|
-
export interface ReceivedHeader extends JsonObject {
|
|
16
|
-
msg_id: string;
|
|
17
|
-
msg_type: string;
|
|
18
|
-
}
|
|
19
|
-
export interface Message {
|
|
5
|
+
export interface Message extends KernelMessage {
|
|
20
6
|
identities: Uint8Array[];
|
|
21
7
|
sig: string;
|
|
22
8
|
/** Raw bytes of header, parent_header, metadata, content as received. */
|
|
23
9
|
signedParts: Uint8Array[];
|
|
24
|
-
header: ReceivedHeader;
|
|
25
|
-
parentHeader: JsonObject;
|
|
26
|
-
metadata: JsonObject;
|
|
27
|
-
content: JsonObject;
|
|
28
|
-
buffers: Uint8Array[];
|
|
29
10
|
}
|
|
30
11
|
export declare function sign(key: string, parts: Uint8Array[]): string;
|
|
31
12
|
export declare function verify(key: string, parts: Uint8Array[], sig: string): boolean;
|
|
32
|
-
export declare function makeHeader(session: string, msgType: string): Header;
|
|
33
13
|
export interface EncodeOptions {
|
|
34
14
|
/** Defaults to a fresh header; pass one to know its msg_id. */
|
|
35
15
|
header?: Header;
|
package/dist/wire.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// Copyright 2018-2026 the Deno authors, MIT license. The structure is kept;
|
|
5
5
|
// WebCrypto's async HMAC is replaced with node:crypto's synchronous one.
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
|
-
import { createHmac,
|
|
7
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { makeHeader } from "./message.js";
|
|
9
|
+
export { makeHeader, PROTOCOL_VERSION } from "./message.js";
|
|
8
10
|
const ENC = new TextEncoder();
|
|
9
11
|
const DEC = new TextDecoder();
|
|
10
12
|
export const DELIMITER = ENC.encode("<IDS|MSG>");
|
|
11
|
-
export const PROTOCOL_VERSION = "5.3";
|
|
12
13
|
export function sign(key, parts) {
|
|
13
14
|
if (key.length === 0)
|
|
14
15
|
return "";
|
|
@@ -29,16 +30,6 @@ export function verify(key, parts, sig) {
|
|
|
29
30
|
const actual = Buffer.from(sig, "hex");
|
|
30
31
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
31
32
|
}
|
|
32
|
-
export function makeHeader(session, msgType) {
|
|
33
|
-
return {
|
|
34
|
-
msg_id: randomUUID(),
|
|
35
|
-
session,
|
|
36
|
-
date: new Date().toISOString(),
|
|
37
|
-
username: "kernel",
|
|
38
|
-
msg_type: msgType,
|
|
39
|
-
version: PROTOCOL_VERSION,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
33
|
export function encodeMessage(session, key, msgType, content, options = {}) {
|
|
43
34
|
const parts = [
|
|
44
35
|
options.header ?? makeHeader(session, msgType),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ConnectionInfo } from "./kernel.ts";
|
|
2
|
+
import type { ChannelName, KernelChannels, Received } from "./kernel-core.ts";
|
|
3
|
+
import type { OutgoingMessage } from "./message.ts";
|
|
4
|
+
export declare class ZmqChannels implements KernelChannels {
|
|
5
|
+
#private;
|
|
6
|
+
readonly ready: Promise<unknown>;
|
|
7
|
+
constructor(conn: ConnectionInfo, log: (text: string) => void);
|
|
8
|
+
recv(channel: ChannelName): Promise<Received | null>;
|
|
9
|
+
shellQueued(): number;
|
|
10
|
+
reply(channel: ChannelName, peer: string, msg: OutgoingMessage): void;
|
|
11
|
+
publish(msg: OutgoingMessage): void;
|
|
12
|
+
stdinPeers(): number;
|
|
13
|
+
sendStdin(msg: OutgoingMessage): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// KernelChannels over ZeroMQ: the connection file's five ports, with
|
|
2
|
+
// messages framed and signed as src/wire.ts does it.
|
|
3
|
+
import { decodeMessage, encodeMessage, verify } from "./wire.js";
|
|
4
|
+
import { listening, PubSocket, RouterSocket, startHeartbeat } from "./zmtp.js";
|
|
5
|
+
export class ZmqChannels {
|
|
6
|
+
ready;
|
|
7
|
+
#key;
|
|
8
|
+
#log;
|
|
9
|
+
#routers;
|
|
10
|
+
#iopub;
|
|
11
|
+
constructor(conn, log) {
|
|
12
|
+
this.#key = conn.key;
|
|
13
|
+
this.#log = log;
|
|
14
|
+
const heartbeat = startHeartbeat(conn.hb_port, conn.ip);
|
|
15
|
+
this.#routers = {
|
|
16
|
+
shell: new RouterSocket(conn.shell_port, conn.ip),
|
|
17
|
+
control: new RouterSocket(conn.control_port, conn.ip),
|
|
18
|
+
stdin: new RouterSocket(conn.stdin_port, conn.ip),
|
|
19
|
+
};
|
|
20
|
+
this.#iopub = new PubSocket(conn.iopub_port, conn.ip);
|
|
21
|
+
// Settles once all five ports are bound (#257).
|
|
22
|
+
this.ready = Promise.all([heartbeat, ...Object.values(this.#routers).map((r) => r.server), this.#iopub.server].map(listening));
|
|
23
|
+
}
|
|
24
|
+
async recv(channel) {
|
|
25
|
+
const { peer, frames } = await this.#routers[channel].recv();
|
|
26
|
+
try {
|
|
27
|
+
const msg = decodeMessage(frames);
|
|
28
|
+
// Unsigned or forged messages are dropped, as in Deno.
|
|
29
|
+
return verify(this.#key, msg.signedParts, msg.sig) ? { peer, msg } : null;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
this.#log(`error handling message: ${err instanceof Error ? err.stack : err}`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
shellQueued() {
|
|
37
|
+
return this.#routers.shell.incoming.size;
|
|
38
|
+
}
|
|
39
|
+
#encode(msg) {
|
|
40
|
+
return encodeMessage(msg.header.session, this.#key, msg.header.msg_type, msg.content, {
|
|
41
|
+
header: msg.header,
|
|
42
|
+
parentHeader: msg.parentHeader,
|
|
43
|
+
metadata: msg.metadata,
|
|
44
|
+
buffers: msg.buffers,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
reply(channel, peer, msg) {
|
|
48
|
+
this.#routers[channel].send(peer, this.#encode(msg));
|
|
49
|
+
}
|
|
50
|
+
publish(msg) {
|
|
51
|
+
this.#iopub.send(this.#encode(msg));
|
|
52
|
+
}
|
|
53
|
+
stdinPeers() {
|
|
54
|
+
return this.#routers.stdin.peers.size;
|
|
55
|
+
}
|
|
56
|
+
sendStdin(msg) {
|
|
57
|
+
this.#routers.stdin.sendAll(this.#encode(msg));
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tlockney/cdp-kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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>",
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
"types": "./dist/globals.d.ts",
|
|
35
35
|
"default": "./dist/globals.js"
|
|
36
36
|
},
|
|
37
|
+
"./browser": {
|
|
38
|
+
"types": "./dist/browser/mod.d.ts",
|
|
39
|
+
"default": "./dist/browser/kernel.js"
|
|
40
|
+
},
|
|
41
|
+
"./browser/worker": "./dist/browser/worker.js",
|
|
37
42
|
"./globals/deno-compat": {
|
|
38
43
|
"types": "./dist/globals-deno-compat.d.ts",
|
|
39
44
|
"default": "./dist/globals-deno-compat.js"
|
|
@@ -49,7 +54,7 @@
|
|
|
49
54
|
"access": "public"
|
|
50
55
|
},
|
|
51
56
|
"scripts": {
|
|
52
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
57
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/build-browser.mjs dist/browser",
|
|
53
58
|
"check": "tsc -p tsconfig.json",
|
|
54
59
|
"docs:dev": "vitepress dev docs",
|
|
55
60
|
"docs:build": "vitepress build docs",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The driver for the browser target: a module Web Worker that runs cells.
|
|
2
|
+
//
|
|
3
|
+
// The page's kernel (src/browser/target.ts) sends each request as a
|
|
4
|
+
// message and gets a reply message back, as over the workerd driver's
|
|
5
|
+
// WebSocket (runtime/workerd-worker.mjs), whose shape this follows. Cells
|
|
6
|
+
// come from transformCellForEval and run as global code with indirect
|
|
7
|
+
// eval, so their declarations persist from cell to cell.
|
|
8
|
+
//
|
|
9
|
+
// A Worker has no module resolution beyond URLs, so `npm:` and `jsr:`
|
|
10
|
+
// specifiers, and bare package names, load from a CDN (esm.sh by default).
|
|
11
|
+
|
|
12
|
+
import { createJupyter } from "./display.mjs";
|
|
13
|
+
import { createHandler } from "./evaluator.mjs";
|
|
14
|
+
import { format, inspect } from "./format.mjs";
|
|
15
|
+
import { describeValue, inspectPath, resolvePath } from "./introspect.mjs";
|
|
16
|
+
import { DEFAULT_CDN, resolveSpecifier } from "./cdn.mjs";
|
|
17
|
+
|
|
18
|
+
function base64(bytes) {
|
|
19
|
+
let binary = "";
|
|
20
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
21
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
22
|
+
}
|
|
23
|
+
return btoa(binary);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const post = (message) => globalThis.postMessage(message);
|
|
27
|
+
|
|
28
|
+
// --- Output -----------------------------------------------------------------
|
|
29
|
+
const stream = (name) => (...args) => post({ kind: "stream", name, text: format(...args) + "\n" });
|
|
30
|
+
Object.assign(console, {
|
|
31
|
+
log: stream("stdout"),
|
|
32
|
+
info: stream("stdout"),
|
|
33
|
+
debug: stream("stdout"),
|
|
34
|
+
dir: (value) => post({ kind: "stream", name: "stdout", text: inspect(value, { depth: 4 }) + "\n" }),
|
|
35
|
+
warn: stream("stderr"),
|
|
36
|
+
error: stream("stderr"),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// An error thrown later by a cell's timer or promise goes to stderr, as on
|
|
40
|
+
// the other targets.
|
|
41
|
+
function reportUncaught(kind, err) {
|
|
42
|
+
post({ kind: "stream", name: "stderr", text: `${kind}: ${err instanceof Error ? (err.stack ?? err.message) : inspect(err)}\n` });
|
|
43
|
+
}
|
|
44
|
+
globalThis.addEventListener("unhandledrejection", (event) => {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
reportUncaught("Uncaught (in promise)", event.reason);
|
|
47
|
+
});
|
|
48
|
+
globalThis.addEventListener("error", (event) => {
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
reportUncaught("Uncaught", event.error ?? event.message);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const jupyter = createJupyter({
|
|
54
|
+
inspect: (value) => inspect(value, { depth: 4 }),
|
|
55
|
+
base64,
|
|
56
|
+
readFile() {
|
|
57
|
+
throw new Error("the browser target has no file system to read from; pass the image bytes instead");
|
|
58
|
+
},
|
|
59
|
+
send(msgType, content, metadata, buffers) {
|
|
60
|
+
post({ kind: "iopub", msg_type: msgType, content, metadata, buffers: buffers.map(base64), barrier: 0 });
|
|
61
|
+
},
|
|
62
|
+
// A synchronous prompt would need SharedArrayBuffer and Atomics.wait, so
|
|
63
|
+
// a cross-origin isolated page; until then, as with no stdin.
|
|
64
|
+
input: () => null,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// --- Modules ----------------------------------------------------------------
|
|
68
|
+
let cdn = DEFAULT_CDN;
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
// --- The shim cells and the evaluator use -----------------------------------
|
|
72
|
+
let handle = null;
|
|
73
|
+
|
|
74
|
+
function install({ base, cdn: cdnBase }) {
|
|
75
|
+
if (typeof cdnBase === "string") cdn = cdnBase.endsWith("/") ? cdnBase : cdnBase + "/";
|
|
76
|
+
const url = new URL("$cdpk$repl.mjs", base).href;
|
|
77
|
+
globalThis.__cdpk = {
|
|
78
|
+
import: (specifier, options) => import(resolveSpecifier(specifier, url, cdn), options),
|
|
79
|
+
meta: { url, resolve: (specifier) => resolveSpecifier(specifier, url, cdn) },
|
|
80
|
+
// inspect_request (#10): by descriptor, so nothing runs.
|
|
81
|
+
describeForInspect: (value, detail) => describeValue(value, { inspect: (v) => inspect(v, { depth: 2 }), detail }),
|
|
82
|
+
inspectPath: (path, detail) => inspectPath(path, { inspect: (v) => inspect(v, { depth: 2 }), detail }),
|
|
83
|
+
resolvePath,
|
|
84
|
+
barrier: () => 0,
|
|
85
|
+
broadcastResult: jupyter.broadcastResult,
|
|
86
|
+
async commDispatch(msgType, content, metadata, buffers64) {
|
|
87
|
+
const buffers = buffers64.map((b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0)));
|
|
88
|
+
return await jupyter.commDispatch(msgType, content, metadata, buffers);
|
|
89
|
+
},
|
|
90
|
+
describeError(value) {
|
|
91
|
+
if (value instanceof Error) {
|
|
92
|
+
return JSON.stringify({ name: value.name, message: value.message, stack: value.stack ?? "" });
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify({ name: "Error", message: inspect(value), stack: "" });
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
Object.defineProperty(globalThis, "__cdpk", { enumerable: false });
|
|
98
|
+
globalThis.jupyter = jupyter.namespace;
|
|
99
|
+
globalThis.prompt = jupyter.prompt;
|
|
100
|
+
globalThis.confirm = jupyter.confirm;
|
|
101
|
+
handle = createHandler(globalThis.__cdpk, (code) => (0, eval)(code));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function answer(req) {
|
|
105
|
+
if (req.op === "info") {
|
|
106
|
+
install(req);
|
|
107
|
+
return { userAgent: globalThis.navigator?.userAgent ?? "" };
|
|
108
|
+
}
|
|
109
|
+
if (handle === null) throw new Error("the driver wasn't sent info first");
|
|
110
|
+
return await handle(req);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
globalThis.addEventListener("message", (event) => {
|
|
114
|
+
const req = event.data;
|
|
115
|
+
answer(req).then(
|
|
116
|
+
(reply) => post({ kind: "reply", id: req.id, ...reply }),
|
|
117
|
+
(err) => post({ kind: "reply", id: req.id, failure: String(err?.stack ?? err) }),
|
|
118
|
+
);
|
|
119
|
+
});
|
package/runtime/cdn.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Where a browser cell's imports load from. A Worker resolves only URLs, so
|
|
2
|
+
// packages come from a CDN that serves npm and jsr as ES modules: esm.sh by
|
|
3
|
+
// default, with its jsr/ prefix for jsr packages.
|
|
4
|
+
//
|
|
5
|
+
// No imports: this file must load in every target.
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CDN = "https://esm.sh/";
|
|
8
|
+
|
|
9
|
+
/** The URL `specifier`, imported from `base`, loads from. */
|
|
10
|
+
export function resolveSpecifier(specifier, base, cdn = DEFAULT_CDN) {
|
|
11
|
+
if (/^(?:https?|data|blob):/.test(specifier)) return specifier;
|
|
12
|
+
if (/^\.{0,2}\//.test(specifier)) return new URL(specifier, base).href;
|
|
13
|
+
if (specifier.startsWith("npm:")) return cdn + specifier.slice(4);
|
|
14
|
+
if (specifier.startsWith("jsr:")) return `${cdn}jsr/${specifier.slice(4).replace(/^\//, "")}`;
|
|
15
|
+
// esm.sh serves polyfills for Node's builtins under node:.
|
|
16
|
+
return cdn + specifier;
|
|
17
|
+
}
|
package/runtime/display.mjs
CHANGED
|
@@ -45,7 +45,201 @@ function extractVega(obj) {
|
|
|
45
45
|
} else if (spec.$schema === "https://vega.github.io/schema/vega-lite/v5.json") {
|
|
46
46
|
mediaType = "application/vnd.vegalite.v5+json";
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
const summary = summarizeChart(spec);
|
|
49
|
+
return summary === undefined ? { [mediaType]: spec } : { [mediaType]: spec, "text/plain": summary };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A few lines of text/plain next to a chart, for frontends that can't draw
|
|
53
|
+
// it (jupyter console, nbconvert to text, agents reading the notebook):
|
|
54
|
+
// mark, encodings with value ranges, title and row count (#321). The
|
|
55
|
+
// approach follows nteract's repr-llm summarize_viz (BSD-3).
|
|
56
|
+
const VL_TYPES = { Q: "quantitative", N: "nominal", O: "ordinal", T: "temporal", G: "geojson" };
|
|
57
|
+
const MAX_CHILDREN = 6;
|
|
58
|
+
|
|
59
|
+
function clip(str, max = 60) {
|
|
60
|
+
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatNumber(n) {
|
|
64
|
+
return Number.isInteger(n) ? String(n) : String(Number(n.toPrecision(4)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function titleText(title) {
|
|
68
|
+
const text = typeof title === "object" && title !== null && !Array.isArray(title) ? title.text : title;
|
|
69
|
+
const str = Array.isArray(text) ? text.join(" ") : typeof text === "string" ? text : "";
|
|
70
|
+
return str ? ` "${clip(str, 80)}"` : "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getField(row, field) {
|
|
74
|
+
if (row === null || typeof row !== "object") return undefined;
|
|
75
|
+
if (field in row) return row[field];
|
|
76
|
+
// Vega-Lite reads "a.b" as a nested path and "a\.b" as a key with a dot.
|
|
77
|
+
if (field.includes("\\")) return row[field.replace(/\\(.)/g, "$1")];
|
|
78
|
+
return field.split(".").reduce((obj, key) => (obj === null || typeof obj !== "object" ? undefined : obj[key]), row);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fieldStats(rows, field, type) {
|
|
82
|
+
const values = [];
|
|
83
|
+
for (const row of rows) {
|
|
84
|
+
const value = getField(row, field);
|
|
85
|
+
if (value !== undefined && value !== null && value !== "") values.push(value);
|
|
86
|
+
}
|
|
87
|
+
if (values.length === 0) return "";
|
|
88
|
+
if (type === "quantitative") {
|
|
89
|
+
let min = Infinity;
|
|
90
|
+
let max = -Infinity;
|
|
91
|
+
for (const value of values) {
|
|
92
|
+
const n = Number(value);
|
|
93
|
+
if (!Number.isFinite(n)) continue;
|
|
94
|
+
if (n < min) min = n;
|
|
95
|
+
if (n > max) max = n;
|
|
96
|
+
}
|
|
97
|
+
if (min > max) return "";
|
|
98
|
+
return min === max ? formatNumber(min) : `${formatNumber(min)} to ${formatNumber(max)}`;
|
|
99
|
+
}
|
|
100
|
+
if (type === "temporal") {
|
|
101
|
+
let min;
|
|
102
|
+
let max;
|
|
103
|
+
for (const value of values) {
|
|
104
|
+
const t = value instanceof Date ? value.getTime() : typeof value === "number" ? value : Date.parse(String(value));
|
|
105
|
+
if (!Number.isFinite(t)) continue;
|
|
106
|
+
if (min === undefined || t < min.t) min = { t, value };
|
|
107
|
+
if (max === undefined || t > max.t) max = { t, value };
|
|
108
|
+
}
|
|
109
|
+
if (min === undefined) return "";
|
|
110
|
+
const show = ({ value }) =>
|
|
111
|
+
typeof value === "string" ? clip(value, 30) : new Date(value instanceof Date ? value.getTime() : value).toISOString();
|
|
112
|
+
return min.t === max.t ? show(min) : `${show(min)} to ${show(max)}`;
|
|
113
|
+
}
|
|
114
|
+
const distinct = new Set(values.map((value) => typeof value === "object" ? JSON.stringify(value) : String(value)));
|
|
115
|
+
return `${distinct.size} ${distinct.size === 1 ? "value" : "values"}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function encodingSummary(encoding, rows) {
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (const [channel, def] of Object.entries(encoding)) {
|
|
121
|
+
if (channel === "tooltip" || def === null || typeof def !== "object" || Array.isArray(def)) continue;
|
|
122
|
+
if (def.field === undefined && def.aggregate === undefined) continue;
|
|
123
|
+
let name = typeof def.field === "string" ? def.field : def.field?.repeat ? `repeat ${def.field.repeat}` : "";
|
|
124
|
+
if (def.aggregate !== undefined) name = `${def.aggregate}(${name})`;
|
|
125
|
+
else if (def.timeUnit !== undefined) name = `${typeof def.timeUnit === "string" ? def.timeUnit : "time"}(${name})`;
|
|
126
|
+
const type = VL_TYPES[def.type] ?? def.type;
|
|
127
|
+
const details = [];
|
|
128
|
+
if (typeof type === "string") details.push(type);
|
|
129
|
+
if (rows && typeof def.field === "string" && def.aggregate === undefined) {
|
|
130
|
+
const stats = fieldStats(rows, def.field, type);
|
|
131
|
+
if (stats) details.push(stats);
|
|
132
|
+
}
|
|
133
|
+
parts.push(`${channel}=${clip(name, 40)}${details.length ? ` (${details.join(", ")})` : ""}`);
|
|
134
|
+
}
|
|
135
|
+
return parts;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function vlRows(data, datasets) {
|
|
139
|
+
if (data === null || typeof data !== "object") return undefined;
|
|
140
|
+
if (Array.isArray(data.values)) return data.values;
|
|
141
|
+
if (typeof data.name === "string" && Array.isArray(datasets?.[data.name])) return datasets[data.name];
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function vlDataNote(data, rows) {
|
|
146
|
+
if (rows) return `${rows.length} ${rows.length === 1 ? "row" : "rows"}`;
|
|
147
|
+
if (typeof data?.url === "string") return `data from ${clip(data.url)}`;
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fieldList(def) {
|
|
152
|
+
if (Array.isArray(def)) return def.join(", ");
|
|
153
|
+
if (def === null || typeof def !== "object") return "";
|
|
154
|
+
if (typeof def.field === "string") return def.field;
|
|
155
|
+
return Object.values(def).map(fieldList).filter(Boolean).join(", ");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summarizeVegaLiteView(spec, label, inherited, datasets, depth, lines) {
|
|
159
|
+
const indent = " ".repeat(depth);
|
|
160
|
+
const data = spec.data ?? inherited.data;
|
|
161
|
+
const rows = vlRows(data, datasets);
|
|
162
|
+
const head = `${indent}${label}`;
|
|
163
|
+
const title = titleText(spec.title);
|
|
164
|
+
const children = spec.layer ?? spec.concat ?? spec.hconcat ?? spec.vconcat;
|
|
165
|
+
if (Array.isArray(children)) {
|
|
166
|
+
const noun = spec.layer ? "layer" : "view";
|
|
167
|
+
const note = [`${children.length} ${children.length === 1 ? noun : `${noun}s`}`, vlDataNote(data, rows)].filter(Boolean);
|
|
168
|
+
lines.push(`${head}${spec.layer ? "layered" : "concatenated"} chart${title}: ${note.join(", ")}`);
|
|
169
|
+
const encoding = spec.layer ? { ...inherited.encoding, ...spec.encoding } : {};
|
|
170
|
+
children.slice(0, MAX_CHILDREN).forEach((child, i) => {
|
|
171
|
+
if (child !== null && typeof child === "object") {
|
|
172
|
+
summarizeVegaLiteView(child, `${noun} ${i + 1}: `, { data, encoding }, datasets, depth + 1, lines);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
if (children.length > MAX_CHILDREN) lines.push(`${indent} … ${children.length - MAX_CHILDREN} more`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if ((spec.facet !== undefined || spec.repeat !== undefined) && spec.spec !== null && typeof spec.spec === "object") {
|
|
179
|
+
const what = spec.facet !== undefined ? `faceted by ${fieldList(spec.facet)}` : `repeated over ${fieldList(spec.repeat)}`;
|
|
180
|
+
const note = vlDataNote(data, rows);
|
|
181
|
+
lines.push(`${head}chart${title} ${what}${note ? `, ${note}` : ""}`);
|
|
182
|
+
summarizeVegaLiteView(spec.spec, "each panel: ", { data, encoding: {} }, datasets, depth + 1, lines);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const mark = typeof spec.mark === "string" ? spec.mark : spec.mark?.type;
|
|
186
|
+
const encoding = { ...inherited.encoding, ...spec.encoding };
|
|
187
|
+
const parts = encodingSummary(encoding, rows);
|
|
188
|
+
// A layer's rows were already counted on the layered chart's line.
|
|
189
|
+
if (spec.data !== undefined || depth === 0) {
|
|
190
|
+
const note = vlDataNote(data, rows);
|
|
191
|
+
if (note) parts.push(note);
|
|
192
|
+
}
|
|
193
|
+
lines.push(`${head}${typeof mark === "string" ? `${mark} ` : ""}chart${title}${parts.length ? `: ${parts.join(", ")}` : ""}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function vegaMarkTypes(marks, types) {
|
|
197
|
+
if (!Array.isArray(marks)) return types;
|
|
198
|
+
for (const mark of marks) {
|
|
199
|
+
if (typeof mark?.type === "string") types.add(mark.type);
|
|
200
|
+
vegaMarkTypes(mark?.marks, types);
|
|
201
|
+
}
|
|
202
|
+
return types;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function summarizeVega(spec) {
|
|
206
|
+
const sections = [];
|
|
207
|
+
const marks = [...vegaMarkTypes(spec.marks, new Set())];
|
|
208
|
+
if (marks.length) sections.push(`marks ${marks.join(", ")}`);
|
|
209
|
+
const list = (items, describe) => {
|
|
210
|
+
const shown = items.slice(0, MAX_CHILDREN).map(describe);
|
|
211
|
+
if (items.length > MAX_CHILDREN) shown.push(`… ${items.length - MAX_CHILDREN} more`);
|
|
212
|
+
return shown.join(", ");
|
|
213
|
+
};
|
|
214
|
+
const data = Array.isArray(spec.data) ? spec.data.filter((d) => d !== null && typeof d === "object") : [];
|
|
215
|
+
if (data.length) {
|
|
216
|
+
sections.push(`data ${list(data, (d) => {
|
|
217
|
+
const name = clip(String(d.name ?? "?"), 30);
|
|
218
|
+
if (Array.isArray(d.values)) return `${name} (${d.values.length} ${d.values.length === 1 ? "row" : "rows"})`;
|
|
219
|
+
if (typeof d.url === "string") return `${name} (from ${clip(d.url)})`;
|
|
220
|
+
if (d.source !== undefined) return `${name} (from ${clip(String(d.source), 30)})`;
|
|
221
|
+
return name;
|
|
222
|
+
})}`);
|
|
223
|
+
}
|
|
224
|
+
const scales = Array.isArray(spec.scales) ? spec.scales.filter((s) => s !== null && typeof s === "object") : [];
|
|
225
|
+
if (scales.length) {
|
|
226
|
+
sections.push(`scales ${list(scales, (s) => `${clip(String(s.name ?? "?"), 30)} (${s.type ?? "linear"})`)}`);
|
|
227
|
+
}
|
|
228
|
+
return `Vega chart${titleText(spec.title)}${sections.length ? `: ${sections.join("; ")}` : ""}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function summarizeChart(spec) {
|
|
232
|
+
try {
|
|
233
|
+
if (typeof spec.$schema === "string" && spec.$schema.includes("/vega-lite/")) {
|
|
234
|
+
const lines = [];
|
|
235
|
+
summarizeVegaLiteView(spec, "Vega-Lite ", { encoding: {} }, spec.datasets, 0, lines);
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
}
|
|
238
|
+
return summarizeVega(spec);
|
|
239
|
+
} catch {
|
|
240
|
+
// A summary is a fallback; a spec it doesn't understand still renders.
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
49
243
|
}
|
|
50
244
|
|
|
51
245
|
function isDataFrameLike(obj) {
|
|
@@ -95,9 +289,16 @@ function extractDataFrame(df) {
|
|
|
95
289
|
html += "</tr>";
|
|
96
290
|
}
|
|
97
291
|
html += "</tbody></table>";
|
|
292
|
+
// Polars has `height`; the row count is left out when there isn't one.
|
|
293
|
+
const size = [];
|
|
294
|
+
if (Number.isInteger(df.height)) size.push(`${df.height} ${df.height === 1 ? "row" : "rows"}`);
|
|
295
|
+
size.push(`${fields.length} ${fields.length === 1 ? "column" : "columns"}`);
|
|
296
|
+
const columns = fields.slice(0, 20).map((field) => `${clip(String(field.name), 40)} (${field.type})`);
|
|
297
|
+
if (fields.length > 20) columns.push(`… ${fields.length - 20} more`);
|
|
98
298
|
return {
|
|
99
299
|
"application/vnd.dataresource+json": { data, schema: { fields } },
|
|
100
300
|
"text/html": html,
|
|
301
|
+
"text/plain": `DataFrame (${size.join(", ")})${columns.length ? `: ${columns.join(", ")}` : ""}`,
|
|
101
302
|
};
|
|
102
303
|
}
|
|
103
304
|
|