@tlockney/cdp-kernel 0.10.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 +1 -1
- package/dist/browser/kernel.js.map +1 -1
- package/dist/browser/worker.js +968 -71
- package/dist/browser/worker.js.map +4 -4
- package/dist/package.d.ts +3 -3
- package/dist/package.js +5 -2
- package/dist/workerd-target.js +1 -1
- package/package.json +1 -1
- package/runtime/chart.d.mts +23 -0
- package/runtime/chart.mjs +359 -0
- package/runtime/display.d.mts +124 -0
- package/runtime/display.mjs +38 -65
- package/runtime/host.d.mts +7 -5
- 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/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-11";
|
|
|
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", "host.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
|
|
@@ -45,6 +45,9 @@ export const RUNTIME_FILES = [
|
|
|
45
45
|
"comm.mjs",
|
|
46
46
|
"host.mjs",
|
|
47
47
|
"widget.mjs",
|
|
48
|
+
"table.mjs",
|
|
49
|
+
"sql.mjs",
|
|
50
|
+
"chart.mjs",
|
|
48
51
|
"node-hooks.mjs",
|
|
49
52
|
"deno-compat.mjs",
|
|
50
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", "host.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) {
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/runtime/display.d.mts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { CommApi, Comms } from "./comm.d.mts";
|
|
2
2
|
import type { Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch } from "./host.d.mts";
|
|
3
3
|
import type { Widget, WidgetOptions } from "./widget.d.mts";
|
|
4
|
+
import type { Sql, SqlColumn, SqlFragment, SqlResult } from "./sql.d.mts";
|
|
4
5
|
|
|
6
|
+
export type { Sql, SqlColumn, SqlFragment, SqlResult };
|
|
5
7
|
export type { CommApi, Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch, Widget, WidgetOptions };
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -34,6 +36,25 @@ export const RESULT_MIME: "application/vnd.ganymede.result+json";
|
|
|
34
36
|
/** A JSON value, as `JSON.parse` returns it. */
|
|
35
37
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
36
38
|
|
|
39
|
+
/** Options for `jupyter.table`. */
|
|
40
|
+
export interface TableOptions {
|
|
41
|
+
/** Rows to embed: default 1,000, at most 10,000, and never past 1 MB of JSON. */
|
|
42
|
+
limit?: number;
|
|
43
|
+
/** The columns to show, in order. Names an array of arrays' columns. */
|
|
44
|
+
columns?: string[];
|
|
45
|
+
/** A caption for the table. */
|
|
46
|
+
title?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options for `jupyter.chart` with a Vega-Lite or Vega spec. */
|
|
50
|
+
export interface ChartOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Also keep the spec under its Vega MIME type, for a frontend that
|
|
53
|
+
* draws Vega itself. Default `false`: the SVG and a text summary only.
|
|
54
|
+
*/
|
|
55
|
+
spec?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
/** Options for `jupyter.display`. */
|
|
38
59
|
export interface DisplayOptions {
|
|
39
60
|
/**
|
|
@@ -332,6 +353,109 @@ export interface JupyterNamespace {
|
|
|
332
353
|
* @see jupyter-api.md#parameters-and-named-results
|
|
333
354
|
*/
|
|
334
355
|
result(name: string, value: unknown): void;
|
|
356
|
+
/**
|
|
357
|
+
* Show rows and columns as a table. The output has Ganymede's table
|
|
358
|
+
* type, `application/vnd.dataresource+json` (Table Schema fields, up to
|
|
359
|
+
* `limit` rows as JSON, and the total row count), which the Ganymede
|
|
360
|
+
* apps show with sorting and paging; a plain HTML table of the first
|
|
361
|
+
* and last rows for JupyterLab and VS Code; and a text summary of each
|
|
362
|
+
* column's type and range for agents and consoles.
|
|
363
|
+
*
|
|
364
|
+
* An Arrow table, a Polars DataFrame or a DuckDB result also shows this
|
|
365
|
+
* way when it is a cell's value; pass one here to set `limit`,
|
|
366
|
+
* `columns` or `title`.
|
|
367
|
+
*
|
|
368
|
+
* @param value An array of row objects, an array of arrays (name them
|
|
369
|
+
* with `columns`), an Arrow table (apache-arrow, duckdb-wasm), a
|
|
370
|
+
* Polars DataFrame, or a `@duckdb/node-api` result.
|
|
371
|
+
* @param options `limit`: rows to embed, default 1,000, at most 10,000,
|
|
372
|
+
* and never more than 1 MB of JSON. `columns`: which columns, in order.
|
|
373
|
+
* `title`: a caption.
|
|
374
|
+
* @throws {TypeError} `value` isn't one of those, or `limit` isn't a
|
|
375
|
+
* whole number.
|
|
376
|
+
* @example
|
|
377
|
+
* ```ts
|
|
378
|
+
* const moons = [
|
|
379
|
+
* { moon: "Io", radius_km: 1821.6, discovered: new Date("1610-01-08") },
|
|
380
|
+
* { moon: "Europa", radius_km: 1560.8, discovered: new Date("1610-01-08") },
|
|
381
|
+
* { moon: "Ganymede", radius_km: 2634.1, discovered: new Date("1610-01-07") },
|
|
382
|
+
* ];
|
|
383
|
+
* jupyter.table(moons, { title: "Galilean moons" });
|
|
384
|
+
* ```
|
|
385
|
+
* @see jupyter-api.md#tables
|
|
386
|
+
*/
|
|
387
|
+
table(value: unknown, options?: TableOptions): Displayable;
|
|
388
|
+
/**
|
|
389
|
+
* Run SQL on an in-memory [DuckDB](https://duckdb.org) database, as a
|
|
390
|
+
* tagged template: each `${value}` becomes a bound parameter, never
|
|
391
|
+
* query text, so values can't change the query. Resolves to a
|
|
392
|
+
* `SqlResult`, which shows as a table and has `columns` and `rows`.
|
|
393
|
+
*
|
|
394
|
+
* The database opens on the first query and lasts until the kernel
|
|
395
|
+
* restarts or `jupyter.sql.close()`. DuckDB reads CSV, Parquet and JSON
|
|
396
|
+
* files by path or URL; `jupyter.sql.load` makes a table from rows the
|
|
397
|
+
* notebook already has. An interrupt stops a running query.
|
|
398
|
+
*
|
|
399
|
+
* @throws {TypeError} Called as a function rather than a tag.
|
|
400
|
+
* @availability Node, Deno and Bun use `@duckdb/node-api`; the browser
|
|
401
|
+
* target (the Ganymede apps) uses duckdb-wasm from a CDN. Not on
|
|
402
|
+
* workerd or celld.
|
|
403
|
+
* @example
|
|
404
|
+
* ```ts
|
|
405
|
+
* const min = 2000;
|
|
406
|
+
* await jupyter.sql`
|
|
407
|
+
* select moon, radius_km
|
|
408
|
+
* from (values ('Io', 1821.6), ('Ganymede', 2634.1), ('Callisto', 2410.3)) as m(moon, radius_km)
|
|
409
|
+
* where radius_km > ${min}
|
|
410
|
+
* order by radius_km desc`;
|
|
411
|
+
* ```
|
|
412
|
+
* @see jupyter-api.md#sql-with-duckdb
|
|
413
|
+
*/
|
|
414
|
+
readonly sql: Sql;
|
|
415
|
+
/**
|
|
416
|
+
* Draw a chart in the kernel as one SVG, with its title and legend
|
|
417
|
+
* inside, plus a text summary for readers that can't see it. The SVG
|
|
418
|
+
* shows the same in the Ganymede apps, JupyterLab, VS Code and a
|
|
419
|
+
* published page. Text and axes are drawn in `currentColor`: the
|
|
420
|
+
* notebook's text color in the Ganymede apps, black in JupyterLab.
|
|
421
|
+
*
|
|
422
|
+
* Pass a [Vega-Lite](https://vega.github.io/vega-lite/) or Vega spec
|
|
423
|
+
* (or a vega-lite-api chart), or the
|
|
424
|
+
* [Observable Plot](https://observablehq.com/plot/) module and the
|
|
425
|
+
* options you would give `Plot.plot`. Plot's `title`, `subtitle`,
|
|
426
|
+
* `caption` and the color legend (`legend: true`, `"swatches"` or
|
|
427
|
+
* `"ramp"`) are drawn into the SVG; other scales' legends are left
|
|
428
|
+
* out. An SVG element shows as it is.
|
|
429
|
+
*
|
|
430
|
+
* vega and vega-lite (version 6), and linkedom for Plot where there is
|
|
431
|
+
* no DOM, load through the cell's import on first use. A chart past
|
|
432
|
+
* 2 MB of SVG logs a warning: aggregate or sample first.
|
|
433
|
+
*
|
|
434
|
+
* @param chart A Vega-Lite or Vega spec, the Plot module, or an SVG
|
|
435
|
+
* element.
|
|
436
|
+
* @param options With a spec, `ChartOptions`; with Plot, Plot's options.
|
|
437
|
+
* @throws {TypeError} `chart` is none of those, or Plot comes without
|
|
438
|
+
* options.
|
|
439
|
+
* @example
|
|
440
|
+
* ```ts
|
|
441
|
+
* jupyter.chart({
|
|
442
|
+
* title: "Galilean moons",
|
|
443
|
+
* data: { values: [
|
|
444
|
+
* { moon: "Io", radius_km: 1821.6 },
|
|
445
|
+
* { moon: "Europa", radius_km: 1560.8 },
|
|
446
|
+
* { moon: "Ganymede", radius_km: 2634.1 },
|
|
447
|
+
* { moon: "Callisto", radius_km: 2410.3 },
|
|
448
|
+
* ] },
|
|
449
|
+
* mark: "bar",
|
|
450
|
+
* encoding: {
|
|
451
|
+
* x: { field: "moon", sort: "-y" },
|
|
452
|
+
* y: { field: "radius_km", type: "quantitative" },
|
|
453
|
+
* },
|
|
454
|
+
* });
|
|
455
|
+
* ```
|
|
456
|
+
* @see jupyter-api.md#charts
|
|
457
|
+
*/
|
|
458
|
+
chart(chart: object, options?: ChartOptions | Record<string, unknown>): Displayable;
|
|
335
459
|
/**
|
|
336
460
|
* The Ganymede server running this kernel, through its host API: read,
|
|
337
461
|
* search and (when granted) run other notebooks under its root. The
|