@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
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// @ts-self-types="./table.d.mts"
|
|
2
|
+
// Table output (cdp-kernel#475, ganymede#747): a value with rows and
|
|
3
|
+
// columns as one MIME bundle every reader can show. The design is
|
|
4
|
+
// Ganymede's docs/data-output.md:
|
|
5
|
+
// - application/vnd.dataresource+json: Table Schema fields and up to
|
|
6
|
+
// `limit` rows as JSON, plus `ganymede: { rows, shown }`, which the
|
|
7
|
+
// Ganymede apps render with sorting and paging;
|
|
8
|
+
// - text/html: a plain table, head and tail rows, for JupyterLab, VS Code
|
|
9
|
+
// and GitHub;
|
|
10
|
+
// - text/plain: shape, column types and ranges, and numbered head and
|
|
11
|
+
// tail rows, for agents and consoles.
|
|
12
|
+
// Sources: arrays of row objects or arrays, Arrow tables (apache-arrow,
|
|
13
|
+
// duckdb-wasm), Polars DataFrames and @duckdb/node-api results.
|
|
14
|
+
//
|
|
15
|
+
// No imports: this file must load in every target.
|
|
16
|
+
|
|
17
|
+
export const TABLE_MIME = "application/vnd.dataresource+json";
|
|
18
|
+
export const DEFAULT_LIMIT = 1000;
|
|
19
|
+
export const MAX_LIMIT = 10_000;
|
|
20
|
+
/** Embedded rows stop at this much JSON, whatever the limit. */
|
|
21
|
+
export const MAX_BYTES = 1_000_000;
|
|
22
|
+
/** A longer string is cut, with "…". */
|
|
23
|
+
export const MAX_CELL = 1000;
|
|
24
|
+
const HTML_ALL = 20;
|
|
25
|
+
const HTML_HEAD = 10;
|
|
26
|
+
const HTML_TAIL = 5;
|
|
27
|
+
const TEXT_HEAD = 3;
|
|
28
|
+
const TEXT_TAIL = 2;
|
|
29
|
+
const TEXT_COLUMNS = 20;
|
|
30
|
+
const TEXT_MAX = 4096;
|
|
31
|
+
|
|
32
|
+
const rawToEntity = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
33
|
+
const escapeHTML = (s) => s.replace(/[&<>"']/g, (c) => rawToEntity[c]);
|
|
34
|
+
|
|
35
|
+
function isObject(value) {
|
|
36
|
+
return value !== null && typeof value === "object";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isArrowTable(value) {
|
|
40
|
+
return isObject(value) && isObject(value.schema) && Array.isArray(value.schema.fields) &&
|
|
41
|
+
typeof value.numRows === "number" && typeof value.getChild === "function";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isPolarsFrame(value) {
|
|
45
|
+
return isObject(value) && isObject(value.schema) && typeof value.head === "function" &&
|
|
46
|
+
typeof value.toRecords === "function";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isDuckDBShaped(value) {
|
|
50
|
+
return isObject(value) && typeof value.columnNames === "function" && typeof value.columnTypes === "function" &&
|
|
51
|
+
typeof value.getRowObjectsJS === "function";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A `@duckdb/node-api` result reader (from `runAndReadAll` or
|
|
56
|
+
* `streamAndReadAll`) that has read all its rows. A `run()` result
|
|
57
|
+
* reads its rows asynchronously, so it isn't one.
|
|
58
|
+
*/
|
|
59
|
+
export function isDuckDBResult(value) {
|
|
60
|
+
return isDuckDBShaped(value) && typeof value.currentRowCount === "number" && typeof value.getRowsJS === "function" &&
|
|
61
|
+
value.done === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether `value` shows as a table when it is a cell's value. */
|
|
65
|
+
export function isTableLike(value) {
|
|
66
|
+
return isArrowTable(value) || isPolarsFrame(value) || isDuckDBResult(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Types ------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** A declared column type (Arrow, Polars or DuckDB) as a Table Schema type. */
|
|
72
|
+
export function schemaType(declared) {
|
|
73
|
+
const t = String(declared ?? "");
|
|
74
|
+
if (t === "") return undefined;
|
|
75
|
+
if (/^(list|large_?list|fixed_?size_?list|array)\b|\[\d*\]$/i.test(t)) return "array";
|
|
76
|
+
if (/^(struct|map|union|json)\b/i.test(t)) return "object";
|
|
77
|
+
if (/^(u?int\d*|u?(tiny|small|big|huge)?int(eger)?)\b/i.test(t)) return "integer";
|
|
78
|
+
if (/^(float\d*|double|real|decimal|numeric)\b/i.test(t)) return "number";
|
|
79
|
+
if (/^bool/i.test(t)) return "boolean";
|
|
80
|
+
if (/^date(32|64)?(<|$|\b(?!time))/i.test(t) && !/^datetime/i.test(t)) return "date";
|
|
81
|
+
if (/^(timestamp|datetime)/i.test(t)) return "datetime";
|
|
82
|
+
if (/^time/i.test(t)) return "time";
|
|
83
|
+
if (/^(interval|duration)/i.test(t)) return "duration";
|
|
84
|
+
if (/^(utf8|large_?utf8|string|varchar|char|text|categorical|enum|uuid|null)\b/i.test(t)) return "string";
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function polarsDeclared(dtype) {
|
|
89
|
+
// Polars DataTypes only expose their name through toJSON().
|
|
90
|
+
if (typeof dtype?.toJSON === "function") {
|
|
91
|
+
const json = dtype.toJSON();
|
|
92
|
+
const name = isObject(json) ? json.DataType ?? Object.keys(json)[0] : json;
|
|
93
|
+
if (typeof name === "string") return name;
|
|
94
|
+
if (isObject(name)) return Object.keys(name)[0];
|
|
95
|
+
}
|
|
96
|
+
return typeof dtype === "string" ? dtype : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function inferType(values) {
|
|
100
|
+
let type;
|
|
101
|
+
for (const v of values) {
|
|
102
|
+
if (v === null || v === undefined) continue;
|
|
103
|
+
let t;
|
|
104
|
+
if (typeof v === "bigint") t = "integer";
|
|
105
|
+
else if (typeof v === "number") t = Number.isInteger(v) ? "integer" : "number";
|
|
106
|
+
else if (typeof v === "boolean") t = "boolean";
|
|
107
|
+
else if (typeof v === "string") t = "string";
|
|
108
|
+
// A Date at midnight UTC, as new Date("1610-01-08") makes, is a date.
|
|
109
|
+
else if (v instanceof Date) t = v.getTime() % 86_400_000 === 0 ? "date" : "datetime";
|
|
110
|
+
else if (Array.isArray(v) || ArrayBuffer.isView(v)) t = ArrayBuffer.isView(v) && !(v instanceof DataView) && v.BYTES_PER_ELEMENT === 1 ? "string" : "array";
|
|
111
|
+
else t = "object";
|
|
112
|
+
if (type === undefined) type = t;
|
|
113
|
+
else if (type !== t) {
|
|
114
|
+
if ((type === "integer" && t === "number") || (type === "number" && t === "integer")) type = "number";
|
|
115
|
+
else if ((type === "date" && t === "datetime") || (type === "datetime" && t === "date")) type = "datetime";
|
|
116
|
+
else return "string";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return type ?? "string";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Values -----------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function clipString(s, max = MAX_CELL) {
|
|
125
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toDate(value) {
|
|
129
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
130
|
+
if (typeof value === "number" || typeof value === "bigint") {
|
|
131
|
+
const d = new Date(Number(value));
|
|
132
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A nested value as JSON-safe data: bigints as numbers or strings. */
|
|
138
|
+
function plain(value, depth = 0) {
|
|
139
|
+
if (value === null || value === undefined) return null;
|
|
140
|
+
if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
|
|
141
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
|
|
142
|
+
if (typeof value === "string") return clipString(value);
|
|
143
|
+
if (typeof value !== "object") return value;
|
|
144
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
145
|
+
if (depth > 8) return "…";
|
|
146
|
+
if (ArrayBuffer.isView(value) && !(value instanceof DataView) && value.BYTES_PER_ELEMENT === 1) {
|
|
147
|
+
return `<${value.byteLength} bytes>`;
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(value) || ArrayBuffer.isView(value)) return Array.from(value, (v) => plain(v, depth + 1));
|
|
150
|
+
// Arrow vectors and struct rows.
|
|
151
|
+
if (typeof value.toJSON === "function") return plain(value.toJSON(), depth + 1);
|
|
152
|
+
if (typeof value.toArray === "function") return plain(value.toArray(), depth + 1);
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [k, v] of Object.entries(value)) out[k] = plain(v, depth + 1);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One cell as Table Schema JSON for a column of `type`. */
|
|
159
|
+
export function encodeValue(value, type, declared) {
|
|
160
|
+
if (value === null || value === undefined) return null;
|
|
161
|
+
if (type === "time" && (typeof value === "bigint" || typeof value === "number")) return timeText(value, declared);
|
|
162
|
+
if (type === "date" || type === "datetime") {
|
|
163
|
+
const d = toDate(value);
|
|
164
|
+
if (d) return type === "date" ? d.toISOString().slice(0, 10) : d.toISOString();
|
|
165
|
+
}
|
|
166
|
+
if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
|
|
167
|
+
if (typeof value === "number") {
|
|
168
|
+
if (Number.isNaN(value)) return "NaN";
|
|
169
|
+
if (!Number.isFinite(value)) return value > 0 ? "Infinity" : "-Infinity";
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
if (typeof value === "string") return clipString(value);
|
|
173
|
+
if (typeof value === "boolean") return value;
|
|
174
|
+
return plain(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A time of day as `HH:MM:SS[.fraction]`, from a count in the declared
|
|
179
|
+
* type's unit: Arrow's `Time64<NANOSECOND>` and the like, Polars' `Time`
|
|
180
|
+
* (nanoseconds), or microseconds, as DuckDB's `TIME` is.
|
|
181
|
+
*/
|
|
182
|
+
export function timeText(value, declared = "") {
|
|
183
|
+
const d = String(declared);
|
|
184
|
+
const unit = /NANOSECOND|^Time$/.test(d) || /_NS$/i.test(d) ? 1_000_000_000n : /MILLISECOND/i.test(d) ? 1000n : /<SECOND/i.test(d) ? 1n : 1_000_000n;
|
|
185
|
+
const total = typeof value === "bigint" ? value : BigInt(Math.trunc(value));
|
|
186
|
+
const seconds = total / unit;
|
|
187
|
+
const fraction = total % unit;
|
|
188
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
189
|
+
const text = `${pad(seconds / 3600n)}:${pad((seconds / 60n) % 60n)}:${pad(seconds % 60n)}`;
|
|
190
|
+
if (fraction === 0n) return text;
|
|
191
|
+
return `${text}.${String(fraction).padStart(String(unit).length - 1, "0").replace(/0+$/, "")}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Unique column names: a result may have two columns called `id` (a
|
|
196
|
+
* join), and rows are keyed by name, so the second becomes `id_1`.
|
|
197
|
+
*/
|
|
198
|
+
export function uniqueNames(names) {
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
return names.map((name) => {
|
|
201
|
+
let unique = name;
|
|
202
|
+
for (let i = 1; seen.has(unique); i++) unique = `${name}_${i}`;
|
|
203
|
+
seen.add(unique);
|
|
204
|
+
return unique;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Sources ----------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @typedef {{ name: string, declared?: string }} Column
|
|
212
|
+
* @typedef {{ columns: Column[], total: number, read: (start: number, end: number) => unknown[][] }} Source
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
function fromObjects(rows, names) {
|
|
216
|
+
let columns = names;
|
|
217
|
+
if (!columns) {
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
for (const row of rows.slice(0, DEFAULT_LIMIT)) {
|
|
220
|
+
if (isObject(row)) for (const key of Object.keys(row)) seen.add(key);
|
|
221
|
+
}
|
|
222
|
+
columns = [...seen];
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
columns: columns.map((name) => ({ name: String(name) })),
|
|
226
|
+
total: rows.length,
|
|
227
|
+
read: (start, end) => rows.slice(start, end).map((row) => columns.map((c) => (isObject(row) ? row[c] : undefined))),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function fromArrays(rows, names) {
|
|
232
|
+
const width = names?.length ?? Math.max(0, ...rows.slice(0, DEFAULT_LIMIT).map((r) => (Array.isArray(r) ? r.length : 0)));
|
|
233
|
+
const columns = names ?? Array.from({ length: width }, (_, i) => String(i));
|
|
234
|
+
return {
|
|
235
|
+
columns: columns.map((name) => ({ name: String(name) })),
|
|
236
|
+
total: rows.length,
|
|
237
|
+
read: (start, end) => rows.slice(start, end).map((row) => columns.map((_, i) => (Array.isArray(row) ? row[i] : undefined))),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function fromArrow(table, names) {
|
|
242
|
+
const fields = table.schema.fields.filter((f) => !names || names.includes(f.name));
|
|
243
|
+
const vectors = fields.map((f) => table.getChild(f.name));
|
|
244
|
+
return {
|
|
245
|
+
columns: fields.map((f) => ({ name: String(f.name), declared: String(f.type) })),
|
|
246
|
+
total: table.numRows,
|
|
247
|
+
read(start, end) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (let i = start; i < Math.min(end, table.numRows); i++) out.push(vectors.map((v) => v?.get(i)));
|
|
250
|
+
return out;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function fromPolars(df, names) {
|
|
256
|
+
const columns = Object.entries(df.schema)
|
|
257
|
+
.filter(([name]) => !names || names.includes(name))
|
|
258
|
+
.map(([name, dtype]) => ({ name, declared: polarsDeclared(dtype) }));
|
|
259
|
+
const total = Number.isInteger(df.height) ? df.height : df.toRecords().length;
|
|
260
|
+
const records = (start, end) =>
|
|
261
|
+
typeof df.slice === "function" ? df.slice(start, end - start).toRecords() : df.toRecords().slice(start, end);
|
|
262
|
+
return {
|
|
263
|
+
columns,
|
|
264
|
+
total,
|
|
265
|
+
read: (start, end) => records(start, Math.min(end, total)).map((row) => columns.map((c) => row[c.name])),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function fromDuckDB(result, names) {
|
|
270
|
+
const types = result.columnTypes();
|
|
271
|
+
const all = result.columnNames().map((name, i) => ({ name, declared: String(types[i]) }));
|
|
272
|
+
const columns = names ? all.filter((c) => names.includes(c.name)) : all;
|
|
273
|
+
const at = columns.map((c) => all.indexOf(c));
|
|
274
|
+
let rows;
|
|
275
|
+
const get = () => (rows ??= result.getRowsJS());
|
|
276
|
+
return {
|
|
277
|
+
columns,
|
|
278
|
+
total: result.currentRowCount,
|
|
279
|
+
read: (start, end) => get().slice(start, end).map((row) => at.map((i) => row[i])),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A table's columns and rows, from any source `jupyter.table` takes. */
|
|
284
|
+
export function tableSource(value, options = {}) {
|
|
285
|
+
const names = Array.isArray(options.columns) ? options.columns.map(String) : undefined;
|
|
286
|
+
if (isObject(value) && typeof value.toTableSource === "function") return value.toTableSource(names);
|
|
287
|
+
if (isArrowTable(value)) return fromArrow(value, names);
|
|
288
|
+
if (isPolarsFrame(value)) return fromPolars(value, names);
|
|
289
|
+
if (isDuckDBResult(value)) return fromDuckDB(value, names);
|
|
290
|
+
if (isDuckDBShaped(value)) {
|
|
291
|
+
throw new TypeError(typeof value.currentRowCount === "number"
|
|
292
|
+
? "jupyter.table: this DuckDB reader hasn't read all its rows; call readAll() first, or use runAndReadAll()"
|
|
293
|
+
: "jupyter.table: a DuckDB run() result reads its rows asynchronously; use runAndReadAll(), or jupyter.sql");
|
|
294
|
+
}
|
|
295
|
+
if (Array.isArray(value)) {
|
|
296
|
+
const first = value.find((row) => row !== null && row !== undefined);
|
|
297
|
+
return Array.isArray(first) ? fromArrays(value, names) : fromObjects(value, names);
|
|
298
|
+
}
|
|
299
|
+
throw new TypeError(
|
|
300
|
+
"jupyter.table takes an array of rows, an Arrow table, a Polars DataFrame or a DuckDB result",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Each column's name and Table Schema type: declared, or inferred from `rows`. */
|
|
305
|
+
export function fieldsOf(source, rows) {
|
|
306
|
+
const names = uniqueNames(source.columns.map((c) => c.name));
|
|
307
|
+
return source.columns.map((c, i) => ({
|
|
308
|
+
name: names[i],
|
|
309
|
+
type: schemaType(c.declared) ?? inferType(rows.map((r) => r[i])),
|
|
310
|
+
}));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- The bundle -------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function count(n, noun) {
|
|
316
|
+
return `${n.toLocaleString("en-US")} ${n === 1 ? noun : `${noun}s`}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function shortValue(v) {
|
|
320
|
+
if (v === null || v === undefined) return "";
|
|
321
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
322
|
+
return clipString(s.replace(/\s+/g, " "), 30);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function columnStats(rows, i, type) {
|
|
326
|
+
let nulls = 0;
|
|
327
|
+
const values = [];
|
|
328
|
+
for (const row of rows) {
|
|
329
|
+
const v = row[i];
|
|
330
|
+
if (v === null) nulls++;
|
|
331
|
+
else values.push(v);
|
|
332
|
+
}
|
|
333
|
+
let range = "";
|
|
334
|
+
if (values.length) {
|
|
335
|
+
if (type === "integer" || type === "number") {
|
|
336
|
+
const nums = values.map(Number).filter(Number.isFinite);
|
|
337
|
+
if (nums.length) {
|
|
338
|
+
const min = Math.min(...nums);
|
|
339
|
+
const max = Math.max(...nums);
|
|
340
|
+
// Grouping only from 10,000, so years read as years.
|
|
341
|
+
const fmt = (n) => (Number.isInteger(n) && Math.abs(n) >= 10_000 ? n.toLocaleString("en-US") : String(Number(n.toPrecision(6))));
|
|
342
|
+
range = min === max ? fmt(min) : `${fmt(min)} to ${fmt(max)}`;
|
|
343
|
+
}
|
|
344
|
+
} else if (type === "date" || type === "datetime" || type === "time") {
|
|
345
|
+
const sorted = values.map(String).sort();
|
|
346
|
+
range = sorted[0] === sorted.at(-1) ? sorted[0] : `${sorted[0]} to ${sorted.at(-1)}`;
|
|
347
|
+
} else if (type === "boolean") {
|
|
348
|
+
const t = values.filter((v) => v === true).length;
|
|
349
|
+
range = `${t} true, ${values.length - t} false`;
|
|
350
|
+
} else {
|
|
351
|
+
const distinct = new Set(values.map((v) => (typeof v === "string" ? v : JSON.stringify(v)))).size;
|
|
352
|
+
range = count(distinct, "value");
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return [range, nulls ? count(nulls, "null") : ""].filter(Boolean).join(", ");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function htmlTable(fields, head, tail, total, title) {
|
|
359
|
+
let html = "<table>";
|
|
360
|
+
if (title) html += `<caption>${escapeHTML(title)}</caption>`;
|
|
361
|
+
html += "<thead><tr>";
|
|
362
|
+
for (const f of fields) {
|
|
363
|
+
html += `<th>${escapeHTML(f.name)}<br><small>${escapeHTML(f.type)}</small></th>`;
|
|
364
|
+
}
|
|
365
|
+
html += "</tr></thead><tbody>";
|
|
366
|
+
const row = (r) => {
|
|
367
|
+
let s = "<tr>";
|
|
368
|
+
fields.forEach((f, i) => {
|
|
369
|
+
const v = r[i];
|
|
370
|
+
const text = v === null ? "" : typeof v === "string" ? v : JSON.stringify(v);
|
|
371
|
+
const align = f.type === "integer" || f.type === "number" ? ' style="text-align:right"' : "";
|
|
372
|
+
s += `<td${align}>${escapeHTML(text)}</td>`;
|
|
373
|
+
});
|
|
374
|
+
return `${s}</tr>`;
|
|
375
|
+
};
|
|
376
|
+
for (const r of head) html += row(r);
|
|
377
|
+
const hidden = total - head.length - tail.length;
|
|
378
|
+
if (hidden > 0) html += `<tr><td colspan="${Math.max(1, fields.length)}">… ${count(hidden, "more row")}</td></tr>`;
|
|
379
|
+
for (const r of tail) html += row(r);
|
|
380
|
+
return `${html}</tbody></table>`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function textTable(fields, numbered) {
|
|
384
|
+
const cells = numbered.map(([n, r]) => [String(n), ...r.map(shortValue)]);
|
|
385
|
+
const widths = [0, ...fields.map(() => 0)];
|
|
386
|
+
for (const row of cells) row.forEach((c, i) => (widths[i] = Math.max(widths[i], c.length)));
|
|
387
|
+
return cells.map((row) => ` ${row.map((c, i) => c.padEnd(widths[i])).join(" | ").trimEnd()}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The MIME bundle for a table: Table Schema JSON with up to `limit` rows,
|
|
392
|
+
* an HTML table and a text summary.
|
|
393
|
+
*/
|
|
394
|
+
export function tableBundle(source, options = {}) {
|
|
395
|
+
const requested = options.limit ?? DEFAULT_LIMIT;
|
|
396
|
+
if (!Number.isInteger(requested) || requested < 0) throw new TypeError("jupyter.table: limit must be a whole number");
|
|
397
|
+
const limit = Math.min(requested, MAX_LIMIT);
|
|
398
|
+
const title = typeof options.title === "string" && options.title !== "" ? options.title : undefined;
|
|
399
|
+
const total = source.total;
|
|
400
|
+
|
|
401
|
+
const raw = source.read(0, Math.min(limit, total));
|
|
402
|
+
const fields = fieldsOf(source, raw);
|
|
403
|
+
const encode = (r) => fields.map((f, i) => encodeValue(r[i], f.type, source.columns[i].declared));
|
|
404
|
+
const utf8 = new TextEncoder();
|
|
405
|
+
|
|
406
|
+
// Rows as JSON, stopping at MAX_BYTES.
|
|
407
|
+
const encoded = [];
|
|
408
|
+
const data = [];
|
|
409
|
+
let bytes = 0;
|
|
410
|
+
for (const r of raw) {
|
|
411
|
+
const values = encode(r);
|
|
412
|
+
// defineProperty, so a column called __proto__ is an ordinary key.
|
|
413
|
+
const obj = {};
|
|
414
|
+
fields.forEach((f, i) => Object.defineProperty(obj, f.name, { value: values[i], enumerable: true, writable: true, configurable: true }));
|
|
415
|
+
bytes += utf8.encode(JSON.stringify(obj)).length + 1;
|
|
416
|
+
if (bytes > MAX_BYTES && data.length > 0) break;
|
|
417
|
+
encoded.push(values);
|
|
418
|
+
data.push(obj);
|
|
419
|
+
}
|
|
420
|
+
const shown = data.length;
|
|
421
|
+
|
|
422
|
+
// Head and tail rows, the tail from the source itself.
|
|
423
|
+
const split = total > HTML_ALL;
|
|
424
|
+
const htmlHead = split ? encoded.slice(0, HTML_HEAD) : encoded.slice(0, total);
|
|
425
|
+
const htmlTail = split ? source.read(total - HTML_TAIL, total).map(encode) : [];
|
|
426
|
+
const textSplit = total > TEXT_HEAD + TEXT_TAIL;
|
|
427
|
+
const textHeadRows = encoded.slice(0, textSplit ? TEXT_HEAD : total);
|
|
428
|
+
const textTail = !textSplit ? [] : htmlTail.length ? htmlTail.slice(-TEXT_TAIL) : source.read(total - TEXT_TAIL, total).map(encode);
|
|
429
|
+
|
|
430
|
+
// Text summary.
|
|
431
|
+
const lines = [];
|
|
432
|
+
const name = title ? `Table "${clipString(title, 80)}"` : "Table";
|
|
433
|
+
const shape = `${count(total, "row")}, ${count(fields.length, "column")}`;
|
|
434
|
+
lines.push(`${name} (${shape}${shown < total ? `; ${shown.toLocaleString("en-US")} shown` : ""})`);
|
|
435
|
+
if (fields.length) {
|
|
436
|
+
lines.push(shown < total ? `Columns (ranges over the first ${shown.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} rows):` : "Columns:");
|
|
437
|
+
const nameWidth = Math.min(30, Math.max(...fields.slice(0, TEXT_COLUMNS).map((f) => f.name.length)));
|
|
438
|
+
const typeWidth = Math.max(...fields.slice(0, TEXT_COLUMNS).map((f) => f.type.length));
|
|
439
|
+
fields.slice(0, TEXT_COLUMNS).forEach((f, i) => {
|
|
440
|
+
const stats = columnStats(encoded, i, f.type);
|
|
441
|
+
lines.push(` ${clipString(f.name, 30).padEnd(nameWidth)} ${f.type.padEnd(typeWidth)} ${stats}`.trimEnd());
|
|
442
|
+
});
|
|
443
|
+
if (fields.length > TEXT_COLUMNS) lines.push(` … ${count(fields.length - TEXT_COLUMNS, "more column")}`);
|
|
444
|
+
const numbered = [
|
|
445
|
+
...textHeadRows.map((r, i) => [i, r]),
|
|
446
|
+
...textTail.map((r, i) => [total - textTail.length + i, r]),
|
|
447
|
+
];
|
|
448
|
+
if (numbered.length) {
|
|
449
|
+
lines.push("Rows:");
|
|
450
|
+
const table = textTable(fields.slice(0, TEXT_COLUMNS), numbered.map(([n, r]) => [n, r.slice(0, TEXT_COLUMNS)]));
|
|
451
|
+
if (textTail.length) table.splice(textHeadRows.length, 0, " …");
|
|
452
|
+
lines.push(...table);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let text = lines.join("\n");
|
|
456
|
+
if (text.length > TEXT_MAX) text = `${text.slice(0, TEXT_MAX - 1)}…`;
|
|
457
|
+
|
|
458
|
+
/** @type {Record<string, unknown>} */
|
|
459
|
+
const resource = { schema: { fields }, data, ganymede: { rows: total, shown } };
|
|
460
|
+
if (title) resource.ganymede.title = title;
|
|
461
|
+
return {
|
|
462
|
+
[TABLE_MIME]: resource,
|
|
463
|
+
"text/html": htmlTable(fields, htmlHead, htmlTail, total, title),
|
|
464
|
+
"text/plain": text,
|
|
465
|
+
};
|
|
466
|
+
}
|