dference 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1370 @@
1
+ // dference – frontend (anywidget ESM), modelled on marimo.ui.table.
2
+ //
3
+ // The table is paged on the Python side: every change to status, search,
4
+ // filters, sort or page sends a `query` message; Python answers with the rows
5
+ // of that page only. Static metadata (columns, counts, names) arrives as
6
+ // synced traits. The checked rows are synced back as `selected_ids`.
7
+
8
+ // Types are checked with `tsc --checkJs` (see tsconfig.json); nothing is compiled.
9
+
10
+ /** @typedef {"equal"|"mismatch"|"missing_left"|"missing_right"} Status */
11
+ /** @typedef {"key"|"compared"|"left_only"|"right_only"} ColumnKind */
12
+ /** @typedef {"number"|"boolean"|"datetime"|"string"} FilterType */
13
+ /**
14
+ * A cell value after JSON conversion in Python (`_query._json`).
15
+ * @typedef {string|number|boolean|null} Value
16
+ */
17
+ /**
18
+ * @typedef {object} Column
19
+ * @property {string} name
20
+ * @property {ColumnKind} kind
21
+ * @property {string} dtype
22
+ * @property {boolean} numeric
23
+ * @property {FilterType} ftype
24
+ * @property {number} mismatches
25
+ */
26
+ /**
27
+ * Counts and names synced from Python (`_widget._summary_payload`).
28
+ * @typedef {object} Summary
29
+ * @property {number} equal
30
+ * @property {number} mismatch
31
+ * @property {number} missing_left
32
+ * @property {number} missing_right
33
+ * @property {number} total
34
+ * @property {number} found
35
+ * @property {number} left_rows
36
+ * @property {number} right_rows
37
+ * @property {string[]} keys
38
+ * @property {string[]} compared
39
+ * @property {string[]} left_only_cols
40
+ * @property {string[]} right_only_cols
41
+ * @property {string[]} ignored
42
+ */
43
+ /**
44
+ * Traits synced with the Python `DataFrameDiff` widget.
45
+ * @typedef {object} Model
46
+ * @property {Column[]} columns
47
+ * @property {Summary} summary
48
+ * @property {string} left_name
49
+ * @property {string} right_name
50
+ * @property {string} left_short
51
+ * @property {string} right_short
52
+ * @property {number} page_size
53
+ * @property {number[]} selected_ids
54
+ */
55
+ /**
56
+ * One row as sent by Python. `l`/`r` are aligned with `columns` and `null`
57
+ * for a side that does not exist in this row; `d` lists differing columns.
58
+ * @typedef {{id: number, s: Status, d: number[], l: Value[]|null, r: Value[]|null}} Row
59
+ */
60
+ /** @typedef {{op: string, a: string, b: string}} Filter */
61
+ /** @typedef {{col: number|"status", dir: 1|-1}} Sort */
62
+
63
+ // left before right: "only in left" (missing_right) comes before "only in right"
64
+ const STATUS_ORDER = /** @type {const} */ (["equal", "mismatch", "missing_right", "missing_left"]);
65
+ const FUNNEL = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 3.5h11l-4.2 5v4l-2.6 1.2V8.5z"/></svg>`;
66
+ const PAGE_SIZES = [10, 25, 50, 100];
67
+ const LOCALE = "en-US";
68
+ const SEARCH_DEBOUNCE_MS = 200;
69
+ const INT_TEXT = /^-?\d+$/;
70
+
71
+ // ---------------------------------------------------------------- formatting
72
+
73
+ /** @type {Readonly<Record<string, string>>} */
74
+ const HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
75
+ /**
76
+ * Escape text for use in HTML content and attributes.
77
+ * @param {unknown} s
78
+ */
79
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] ?? c);
80
+ /** @param {Value|undefined} v */
81
+ const plain = (v) => (v === null || v === undefined ? "" : String(v));
82
+ /** @param {number} x */
83
+ const n = (x) => x.toLocaleString(LOCALE);
84
+
85
+ /**
86
+ * Percentage with one decimal; 0% / 100% only when exact.
87
+ * @param {number} part
88
+ * @param {number} whole
89
+ */
90
+ function pct(part, whole) {
91
+ if (!whole) return "–";
92
+ if (part === 0) return "0%";
93
+ if (part === whole) return "100%";
94
+ const p = (100 * part) / whole;
95
+ if (p < 0.1) return "<0.1%";
96
+ if (p > 99.9) return ">99.9%";
97
+ return `${p.toLocaleString(LOCALE, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
98
+ }
99
+
100
+ /**
101
+ * Signed difference right − left for two numbers, else null.
102
+ * @param {Value|undefined} a
103
+ * @param {Value|undefined} b
104
+ * @returns {string|null}
105
+ */
106
+ function delta(a, b) {
107
+ // integers beyond 2**53 arrive as text (see `_json`): subtract them exactly
108
+ if (INT_TEXT.test(String(a)) && INT_TEXT.test(String(b)) && (typeof a === "string" || typeof b === "string")) {
109
+ const d = BigInt(String(b)) - BigInt(String(a));
110
+ return (d > 0n ? "+" : "") + d.toLocaleString(LOCALE);
111
+ }
112
+ if (typeof a !== "number" || typeof b !== "number") return null;
113
+ const d = b - a;
114
+ const s =
115
+ Math.abs(d) >= 0.01 || d === 0 ? d.toLocaleString(LOCALE, { maximumFractionDigits: 2 }) : d.toExponential(2);
116
+ return (d > 0 ? "+" : "") + s;
117
+ }
118
+
119
+ // ------------------------------------------------------ invisible characters
120
+ // Characters that render as nothing (or like a normal space) but still make
121
+ // two strings differ. Regular spaces (␣) are only marked when leading,
122
+ // trailing or repeated; single inner spaces stay untouched.
123
+
124
+ /** @type {Readonly<Record<string, [symbol: string, name: string]>>} */
125
+ const INV_NAMES = {
126
+ "\t": ["→", "tab"],
127
+ "\n": ["↵", "line feed"],
128
+ "\r": ["␍", "carriage return"],
129
+ "\u00a0": ["⍽", "no-break space"],
130
+ "\u202f": ["⍽", "narrow no-break space"],
131
+ "\u2007": ["⍽", "figure space"],
132
+ "\u2009": ["⍽", "thin space"],
133
+ "\u3000": ["⍽", "ideographic space"],
134
+ "\u200b": ["ZWSP", "zero-width space"],
135
+ "\u200c": ["ZWNJ", "zero-width non-joiner"],
136
+ "\u200d": ["ZWJ", "zero-width joiner"],
137
+ "\u2060": ["WJ", "word joiner"],
138
+ "\ufeff": ["BOM", "byte order mark"],
139
+ "\u00ad": ["SHY", "soft hyphen"],
140
+ "\u200e": ["LRM", "left-to-right mark"],
141
+ "\u200f": ["RLM", "right-to-left mark"],
142
+ };
143
+ // Control characters are exactly what these patterns are meant to find.
144
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: detecting invisible characters is the point
145
+ const INV_RE = /[\u0000-\u001f\u007f-\u00a0\u00ad\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f-\u2064\u3000\ufeff]/;
146
+ const INV_SPACE_RE = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/g;
147
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: detecting invisible characters is the point
148
+ const INV_ZERO_RE = /[\u0000-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2064\ufeff]/g;
149
+
150
+ /**
151
+ * Code point of a single character as hex digits, e.g. `"200B"`.
152
+ * @param {string} c
153
+ */
154
+ const codePoint = (c) => (c.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, "0");
155
+ /** @param {string} c */
156
+ const hex = (c) => `U+${codePoint(c)}`;
157
+
158
+ /**
159
+ * Flags for regular spaces that should be made visible.
160
+ * @param {string[]} chars
161
+ * @returns {boolean[]}
162
+ */
163
+ function markedSpaces(chars) {
164
+ const mark = new Array(chars.length).fill(false);
165
+ for (let i = 0; i < chars.length; ) {
166
+ if (chars[i] !== " ") {
167
+ i++;
168
+ continue;
169
+ }
170
+ let j = i;
171
+ while (j < chars.length && chars[j] === " ") j++;
172
+ if (i === 0 || j === chars.length || j - i > 1) mark.fill(true, i, j);
173
+ i = j;
174
+ }
175
+ return mark;
176
+ }
177
+
178
+ /**
179
+ * Does the value contain anything worth highlighting?
180
+ * @param {Value|undefined} s
181
+ * @returns {s is string}
182
+ */
183
+ function hasInvisible(s) {
184
+ if (typeof s !== "string") return false;
185
+ return s === "" || INV_RE.test(s) || markedSpaces(Array.from(s)).some(Boolean);
186
+ }
187
+
188
+ /**
189
+ * HTML for a string with its invisible characters rendered as grey markers.
190
+ * @param {string} s
191
+ */
192
+ function visualize(s) {
193
+ if (s === "") return '<span class="dfd-inv" title="Empty string (not null)">""</span>';
194
+ const chars = Array.from(s);
195
+ const mark = markedSpaces(chars);
196
+ return chars
197
+ .map((c, i) => {
198
+ if (c === " ") return mark[i] ? '<span class="dfd-inv" title="U+0020 space">␣</span>' : " ";
199
+ if (!INV_RE.test(c)) return esc(c);
200
+ const [sym, name] = INV_NAMES[c] ?? [hex(c), "control / invisible character"];
201
+ const cls = sym.length > 1 ? "dfd-inv code" : "dfd-inv";
202
+ return `<span class="${cls}" title="${hex(c)} ${name}">${esc(sym)}</span>${c === "\n" ? "<br>" : ""}`;
203
+ })
204
+ .join("");
205
+ }
206
+
207
+ /**
208
+ * Text after removing invisible characters and normalising whitespace.
209
+ * @param {string} s
210
+ */
211
+ const normalise = (s) => s.replace(INV_SPACE_RE, " ").replace(INV_ZERO_RE, "").replace(/\s+/g, " ").trim();
212
+
213
+ /**
214
+ * Do two strings differ only in invisible characters?
215
+ * @param {Value|undefined} a
216
+ * @param {Value|undefined} b
217
+ */
218
+ const onlyInvisible = (a, b) =>
219
+ typeof a === "string" && typeof b === "string" && a !== b && normalise(a) === normalise(b);
220
+
221
+ /**
222
+ * Tooltip form of a value: strings quoted, invisible characters as `\u` escapes.
223
+ * @param {Value|undefined} v
224
+ */
225
+ const literal = (v) =>
226
+ typeof v === "string"
227
+ ? `"${Array.from(v)
228
+ .map((c) => (INV_RE.test(c) ? `\\u${codePoint(c).toLowerCase()}` : c))
229
+ .join("")}"`
230
+ : plain(v);
231
+
232
+ /**
233
+ * HTML for one cell value.
234
+ * @param {Value|undefined} v
235
+ * @param {boolean} showInvisible
236
+ */
237
+ function fmt(v, showInvisible) {
238
+ if (v === null || v === undefined) return '<span class="dfd-null">null</span>';
239
+ if (typeof v === "boolean") return String(v);
240
+ if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}T00:00:00$/.test(v)) return esc(v.slice(0, 10));
241
+ if (showInvisible && hasInvisible(v)) return visualize(v);
242
+ return esc(v);
243
+ }
244
+
245
+ // ------------------------------------------------------------ column filters
246
+
247
+ /**
248
+ * Operators per filter type – must match `OPERATORS` in `_query.py`.
249
+ * @type {Readonly<Record<FilterType, [op: string, label: string][]>>}
250
+ */
251
+ const OPS = {
252
+ string: [
253
+ ["contains", "contains"],
254
+ ["not_contains", "does not contain"],
255
+ ["equals", "equals"],
256
+ ["starts", "starts with"],
257
+ ["ends", "ends with"],
258
+ ["is_empty", "is empty"],
259
+ ["not_empty", "is not empty"],
260
+ ["is_null", "is null"],
261
+ ["not_null", "is not null"],
262
+ ],
263
+ number: [
264
+ ["between", "between"],
265
+ ["eq", "="],
266
+ ["ne", "≠"],
267
+ ["is_null", "is null"],
268
+ ["not_null", "is not null"],
269
+ ],
270
+ datetime: [
271
+ ["between", "between"],
272
+ ["is_null", "is null"],
273
+ ["not_null", "is not null"],
274
+ ],
275
+ boolean: [
276
+ ["true", "is true"],
277
+ ["false", "is false"],
278
+ ["is_null", "is null"],
279
+ ],
280
+ };
281
+ const NO_VALUE = new Set(["is_empty", "not_empty", "is_null", "not_null", "true", "false"]);
282
+
283
+ /**
284
+ * Short text for a filter pill, e.g. `contains “kiel”` or `10 – 20`.
285
+ * @param {FilterType} ftype
286
+ * @param {Filter} f
287
+ */
288
+ function describeFilter(ftype, f) {
289
+ const label = OPS[ftype].find(([k]) => k === f.op)?.[1] ?? f.op;
290
+ if (NO_VALUE.has(f.op)) return label;
291
+ if (f.op === "between") return f.a && f.b ? `${f.a} – ${f.b}` : f.a ? `≥ ${f.a}` : `≤ ${f.b}`;
292
+ return ftype === "string" ? `${label} “${f.a}”` : `${label} ${f.a}`;
293
+ }
294
+
295
+ /**
296
+ * Surrounding background colour, so sticky headers & popovers are opaque.
297
+ * Walks up through shadow roots, since widgets may live inside one.
298
+ * @param {Element} node
299
+ * @returns {string|null}
300
+ */
301
+ function detectBg(node) {
302
+ /** @type {Element|null} */
303
+ let el = node;
304
+ while (el) {
305
+ const bg = getComputedStyle(el).backgroundColor;
306
+ if (bg && bg !== "transparent" && !/rgba\([^)]*,\s*0\)$/.test(bg)) return bg;
307
+ const root = el.getRootNode();
308
+ el = el.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
309
+ }
310
+ return null;
311
+ }
312
+
313
+ // ==================================================================== render
314
+
315
+ /** @type {import("@anywidget/types").Render<Model>} */
316
+ function render({ model, el }) {
317
+ /** UI state; everything in `query()` is evaluated on the Python side. */
318
+ const state = {
319
+ statuses: /** @type {Set<Status>} */ (new Set(STATUS_ORDER)),
320
+ search: "",
321
+ sort: /** @type {Sort|null} */ (null),
322
+ page: 0,
323
+ pageSize: model.get("page_size") || 10,
324
+ onlyDiffCols: false,
325
+ showInvisible: true,
326
+ /** Quick filter from a column header: rows that differ (`≠`) or are equal (`=`) in it. */
327
+ diffColumn: /** @type {number|null} */ (null),
328
+ diffEqual: false,
329
+ filters: /** @type {Map<number, Filter>} */ (new Map()),
330
+ /** Open header popover: a column index or the status column. */
331
+ colMenu: /** @type {number|"status"|null} */ (null),
332
+ /** Row shown in the detail panel and its position in the view. */
333
+ detail: /** @type {{row: Row, pos: number}|null} */ (null),
334
+ };
335
+ /** Last page received from Python. */
336
+ let view = { rows: /** @type {Row[]} */ ([]), filtered: 0, page: 0, diffColumns: /** @type {number[]} */ ([]) };
337
+ let loading = true;
338
+ let error = "";
339
+ let reqSeq = 0;
340
+ /** After a page change triggered by detail navigation: which row to open. */
341
+ let pendingDetail = /** @type {"first"|"last"|null} */ (null);
342
+ /** @type {Set<number>} */
343
+ let selected = new Set(model.get("selected_ids") || []);
344
+ let M = meta();
345
+
346
+ /** Static metadata of the comparison, re-read when Python replaces it. */
347
+ function meta() {
348
+ return {
349
+ columns: model.get("columns"),
350
+ summary: model.get("summary"),
351
+ L: model.get("left_name"),
352
+ R: model.get("right_name"),
353
+ LS: model.get("left_short") || "L",
354
+ RS: model.get("right_short") || "R",
355
+ };
356
+ }
357
+
358
+ // Each side has one colour and one chip (its short name) everywhere. A row that
359
+ // exists on one side only is shown with the chip of the side it exists on.
360
+ /** @param {Status} s */
361
+ const label = (s) =>
362
+ ({ equal: "Equal", mismatch: "Mismatch", missing_left: `Only in ${M.R}`, missing_right: `Only in ${M.L}` })[s];
363
+ /** @param {Status} s */
364
+ const hint = (s) =>
365
+ ({
366
+ equal: "Key in both, all values equal",
367
+ mismatch: "Key in both, at least one value differs",
368
+ missing_left: `Key only in ${M.R}, missing in ${M.L}`,
369
+ missing_right: `Key only in ${M.L}, missing in ${M.R}`,
370
+ })[s];
371
+ /** @param {Status} s */
372
+ const symbol = (s) => ({ equal: "=", mismatch: "≠", missing_left: M.RS, missing_right: M.LS })[s];
373
+ /** @param {Status} s */
374
+ const badge = (s) => `<span class="dfd-badge st-${s}" aria-hidden="true">${esc(symbol(s))}</span>`;
375
+ /**
376
+ * Chip of a side: its short name on the side colour.
377
+ * @param {"l"|"r"} side
378
+ */
379
+ const chip = (side) =>
380
+ side === "l"
381
+ ? `<span class="dfd-side l" title="${esc(M.L)} (left)">${esc(M.LS)}</span>`
382
+ : `<span class="dfd-side r" title="${esc(M.R)} (right)">${esc(M.RS)}</span>`;
383
+ /** @param {Value|undefined} v */
384
+ const F = (v) => fmt(v, state.showInvisible);
385
+
386
+ /**
387
+ * Value of column `i` in a row (`undefined` if the column cannot exist there).
388
+ * @param {Row} row
389
+ * @param {number} i
390
+ * @returns {Value|undefined}
391
+ */
392
+ function cellValue(row, i) {
393
+ const kind = M.columns[i].kind;
394
+ if (kind === "left_only") return row.l ? row.l[i] : undefined;
395
+ if (kind === "right_only") return row.r ? row.r[i] : undefined;
396
+ return row.l ? row.l[i] : row.r?.[i];
397
+ }
398
+ /**
399
+ * Key columns of a row as `[name, value]` pairs.
400
+ * @param {Row} row
401
+ * @returns {[string, Value|undefined][]}
402
+ */
403
+ const keyOf = (row) =>
404
+ M.columns.flatMap((c, i) =>
405
+ c.kind === "key" ? [/** @type {[string, Value|undefined]} */ ([c.name, cellValue(row, i)])] : [],
406
+ );
407
+
408
+ // ------------------------------------------------------------ skeleton
409
+ el.innerHTML = `
410
+ <div class="dfd">
411
+ <div class="dfd-overview"></div>
412
+ <div class="dfd-toolbar">
413
+ <label class="dfd-search">
414
+ <svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/></svg>
415
+ <input type="search" placeholder="Search…" aria-label="Search all values">
416
+ </label>
417
+ <label class="dfd-toggle"><input type="checkbox" data-act="only-diff"> Differing columns only</label>
418
+ <label class="dfd-toggle" title="Mark spaces at the start/end or repeated, tabs, line breaks, no-break and zero-width characters">
419
+ <input type="checkbox" data-act="show-inv" checked> Show invisible characters</label>
420
+ <span class="dfd-toolbar-dyn"></span>
421
+ <span class="dfd-spacer"></span>
422
+ <button class="dfd-btn" data-act="export" title="Download all filtered rows as CSV">Export CSV</button>
423
+ </div>
424
+ <div class="dfd-error" role="alert" hidden></div>
425
+ <div class="dfd-scroll"></div>
426
+ <div class="dfd-footer"></div>
427
+ <div class="dfd-detail-wrap"></div>
428
+ <div class="dfd-colmenu-wrap"></div>
429
+ </div>`;
430
+ const root = /** @type {HTMLElement} */ (el.firstElementChild);
431
+
432
+ /**
433
+ * Element of the skeleton above; these always exist, so a miss is a bug.
434
+ * @template {Element} [T=HTMLElement]
435
+ * @param {string} selector
436
+ * @returns {T}
437
+ */
438
+ function $(selector) {
439
+ const found = root.querySelector(selector);
440
+ if (!found) throw new Error(`dference: missing element ${selector}`);
441
+ return /** @type {T} */ (found);
442
+ }
443
+ /**
444
+ * Element that may be absent (menus and panels that are rendered on demand).
445
+ * @template {Element} [T=HTMLElement]
446
+ * @param {string} selector
447
+ * @returns {T|null}
448
+ */
449
+ const $opt = (selector) => /** @type {T|null} */ (root.querySelector(selector));
450
+ const searchInput = /** @type {HTMLInputElement} */ ($(".dfd-search input"));
451
+
452
+ // ------------------------------------------------------------ messaging
453
+
454
+ /** Serialisable query – mirrors `Query.from_message` in `_query.py`. */
455
+ function queryPayload() {
456
+ return {
457
+ statuses: [...state.statuses],
458
+ search: state.search,
459
+ diff_column: state.diffColumn,
460
+ diff_equal: state.diffEqual,
461
+ filters: [...state.filters].map(([column, f]) => ({ column, ...f })),
462
+ sort: state.sort ? { column: state.sort.col, descending: state.sort.dir < 0 } : null,
463
+ };
464
+ }
465
+
466
+ /** Ask Python for the current page; stale answers are ignored via `req`. */
467
+ function query() {
468
+ loading = true;
469
+ root.classList.add("loading");
470
+ model.send({ type: "query", req: ++reqSeq, page: state.page, page_size: state.pageSize, ...queryPayload() });
471
+ }
472
+
473
+ /**
474
+ * Custom message from Python (`page`, `export` or `error`).
475
+ * @param {any} msg
476
+ * @param {DataView[]} buffers
477
+ */
478
+ function onMessage(msg, buffers) {
479
+ if (!msg || typeof msg !== "object") return;
480
+ if (msg.type === "error") {
481
+ error = msg.message || "Unknown error";
482
+ loading = false;
483
+ root.classList.remove("loading");
484
+ return renderError();
485
+ }
486
+ if (msg.type === "export" && buffers && buffers.length) return download(buffers[0], msg.filename);
487
+ if (msg.type !== "page" || msg.req !== reqSeq) return;
488
+ error = "";
489
+ loading = false;
490
+ root.classList.remove("loading");
491
+ view = { rows: msg.rows, filtered: msg.filtered, page: msg.page, diffColumns: msg.diff_columns };
492
+ state.page = msg.page;
493
+ if (pendingDetail && view.rows.length) {
494
+ const idx = pendingDetail === "first" ? 0 : view.rows.length - 1;
495
+ state.detail = { row: view.rows[idx], pos: state.page * state.pageSize + idx };
496
+ }
497
+ pendingDetail = null;
498
+ // keep the detail position in sync if the row is on this page
499
+ const detail = state.detail;
500
+ if (detail) {
501
+ const idx = view.rows.findIndex((r) => r.id === detail.row.id);
502
+ detail.pos = idx >= 0 ? state.page * state.pageSize + idx : -1;
503
+ if (idx >= 0) detail.row = view.rows[idx];
504
+ }
505
+ renderError();
506
+ renderTable();
507
+ renderDetail();
508
+ }
509
+
510
+ /**
511
+ * Save an exported CSV buffer as a file.
512
+ * @param {DataView} buffer
513
+ * @param {string} [filename]
514
+ */
515
+ function download(buffer, filename) {
516
+ // copy into a plain ArrayBuffer: a DataView may sit on a SharedArrayBuffer
517
+ const bytes = new Uint8Array(buffer.byteLength);
518
+ bytes.set(new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength));
519
+ const blob = new Blob([bytes], { type: "text/csv;charset=utf-8" });
520
+ const a = document.createElement("a");
521
+ a.href = URL.createObjectURL(blob);
522
+ a.download = filename || "dataframe-diff.csv";
523
+ document.body.appendChild(a);
524
+ a.click();
525
+ setTimeout(() => {
526
+ URL.revokeObjectURL(a.href);
527
+ a.remove();
528
+ }, 0);
529
+ const btn = /** @type {HTMLButtonElement} */ ($('[data-act="export"]'));
530
+ btn.disabled = false;
531
+ btn.textContent = "Export CSV";
532
+ }
533
+
534
+ // ------------------------------------------------------------ overview
535
+
536
+ /** Status bars, legends and metadata line (static until the filter changes). */
537
+ function renderOverview() {
538
+ const S = M.summary;
539
+ const total = Math.max(1, S.total);
540
+ /** @param {number} c */
541
+ const share = (c) => ((100 * c) / total).toLocaleString(LOCALE, { maximumFractionDigits: 1 });
542
+ /** @param {Status} s */
543
+ const off = (s) => (state.statuses.has(s) ? "" : "off");
544
+ // both bars share one scale: segments use flex-grow = count, no gaps
545
+ const seg = STATUS_ORDER.map((s) =>
546
+ S[s] ? `<span class="st-${s} ${off(s)}" style="flex:${S[s]}" title="${esc(label(s))}: ${n(S[s])}"></span>` : "",
547
+ ).join("");
548
+ /** @type {{key: string, title: string, tip: string, count: number, statuses: Status[]}[]} */
549
+ const groups = [
550
+ {
551
+ key: "found",
552
+ title: "Found",
553
+ tip: "Key on both sides (equal + mismatch)",
554
+ count: S.equal + S.mismatch,
555
+ statuses: ["equal", "mismatch"],
556
+ },
557
+ {
558
+ key: "notfound",
559
+ title: "Not found",
560
+ tip: `Key on one side only (only in ${M.L} + only in ${M.R})`,
561
+ count: S.missing_left + S.missing_right,
562
+ statuses: ["missing_left", "missing_right"],
563
+ },
564
+ ];
565
+ /** @param {Status[]} sts */
566
+ const gOff = (sts) => (sts.some((x) => state.statuses.has(x)) ? "" : "off");
567
+ const seg2 = groups
568
+ .map((g) =>
569
+ g.count
570
+ ? `<span class="g-${g.key} ${gOff(g.statuses)}" style="flex:${g.count}" title="${g.title}: ${n(g.count)}"></span>`
571
+ : "",
572
+ )
573
+ .join("");
574
+ const legend = STATUS_ORDER.map(
575
+ (s) => `
576
+ <div class="dfd-stat ${off(s)}" title="${esc(hint(s))}">
577
+ ${badge(s)}<span>${esc(label(s))}</span><b>${n(S[s])}</b><small>${share(S[s])}%</small></div>`,
578
+ ).join("");
579
+ const legend2 = groups
580
+ .map(
581
+ (g) => `
582
+ <div class="dfd-stat ${gOff(g.statuses)}" title="${esc(g.tip)}">
583
+ <span class="dfd-swatch g-${g.key}" aria-hidden="true"></span><span>${g.title}</span><b>${n(g.count)}</b><small>${share(g.count)}%</small></div>`,
584
+ )
585
+ .join("");
586
+ const extra = [];
587
+ if (S.left_only_cols.length) extra.push(`only in ${esc(M.L)}: ${S.left_only_cols.map(esc).join(", ")}`);
588
+ if (S.right_only_cols.length) extra.push(`only in ${esc(M.R)}: ${S.right_only_cols.map(esc).join(", ")}`);
589
+ if (S.ignored?.length) extra.push(`ignored: ${S.ignored.map(esc).join(", ")}`);
590
+ $(".dfd-overview").innerHTML = `
591
+ <div class="dfd-bars" aria-hidden="true">
592
+ <div class="dfd-dist">${seg}</div>
593
+ <div class="dfd-dist">${seg2}</div>
594
+ </div>
595
+ <div class="dfd-stats">${legend}</div>
596
+ <div class="dfd-stats">${legend2}</div>
597
+ <div class="dfd-meta">
598
+ <span class="dfd-sidename">${chip("l")}<b>${esc(M.L)}</b> (left) ${n(S.left_rows)} rows</span>
599
+ <span class="dfd-sidename">${chip("r")}<b>${esc(M.R)}</b> (right) ${n(S.right_rows)} rows</span>
600
+ <span>Key: ${S.keys.map((k) => `<code>${esc(k)}</code>`).join(" + ")}</span>
601
+ <span>${S.compared.length} columns compared</span>
602
+ ${extra.map((e) => `<span>${e}</span>`).join("")}
603
+ </div>`;
604
+ }
605
+
606
+ // -------------------------------------------------------- status filter
607
+
608
+ function statusSummaryText() {
609
+ if (state.statuses.size === STATUS_ORDER.length) return "All";
610
+ if (state.statuses.size === 0) return "None";
611
+ if (state.statuses.size === 3 && !state.statuses.has("equal")) return "All differences";
612
+ return STATUS_ORDER.filter((s) => state.statuses.has(s))
613
+ .map(label)
614
+ .join(", ");
615
+ }
616
+
617
+ /** Popover of the status column: sort, presets and one checkbox per status. */
618
+ function statusMenuHtml() {
619
+ const S = M.summary;
620
+ const presets = [
621
+ ["all", "All"],
622
+ ["diff", "All differences"],
623
+ ["missing", "One side only"],
624
+ ];
625
+ const sorted = state.sort && state.sort.col === "status" ? state.sort.dir : 0;
626
+ return `
627
+ <div class="dfd-colmenu status" role="dialog" aria-label="Sort and filter by status">
628
+ <div class="dfd-cm-title"><b>Status</b></div>
629
+ <div class="dfd-cm-sort">
630
+ <button data-cm="asc" class="${sorted === 1 ? "on" : ""}">↑ Sort ascending</button>
631
+ <button data-cm="desc" class="${sorted === -1 ? "on" : ""}">↓ Sort descending</button>
632
+ ${sorted ? '<button data-cm="unsort">Clear sort</button>' : ""}
633
+ </div>
634
+ <div role="group" aria-label="Filter by status">
635
+ <div class="dfd-presets">${presets.map(([k, t]) => `<button data-preset="${k}">${t}</button>`).join("")}</div>
636
+ ${STATUS_ORDER.map(
637
+ (s) => `
638
+ <label class="dfd-opt">
639
+ <input type="checkbox" data-status="${s}" ${state.statuses.has(s) ? "checked" : ""}>
640
+ ${badge(s)}
641
+ <span class="dfd-opt-text"><span>${esc(label(s))}</span><small>${esc(hint(s))}</small></span>
642
+ <b>${n(S[s])}</b>
643
+ <button class="dfd-only" data-only="${s}" title="Show only ${esc(label(s))}">only</button>
644
+ </label>`,
645
+ ).join("")}
646
+ </div>
647
+ </div>`;
648
+ }
649
+
650
+ /** Update the checkboxes of an open status menu in place (keeps the focus). */
651
+ function syncStatusMenu() {
652
+ for (const cb of root.querySelectorAll(".dfd-colmenu [data-status]")) {
653
+ if (cb instanceof HTMLInputElement)
654
+ cb.checked = isStatus(cb.dataset.status) && state.statuses.has(/** @type {Status} */ (cb.dataset.status));
655
+ }
656
+ }
657
+
658
+ function renderToolbarDyn() {
659
+ const parts = [];
660
+ if (state.statuses.size !== STATUS_ORDER.length)
661
+ parts.push(`<span class="dfd-pillfilter col">
662
+ <button class="dfd-pill-edit" data-colmenu="status" title="Edit status filter"><b>Status</b> ${esc(statusSummaryText())}</button>
663
+ <button data-act="clear-status" aria-label="Remove status filter">×</button></span>`);
664
+ for (const [i, f] of state.filters) {
665
+ const c = M.columns[i];
666
+ parts.push(`<span class="dfd-pillfilter col">
667
+ <button class="dfd-pill-edit" data-colmenu="${i}" title="Edit filter"><b>${esc(c.name)}</b> ${esc(describeFilter(c.ftype, f))}</button>
668
+ <button data-rmfilter="${i}" aria-label="Remove filter on ${esc(c.name)}">×</button></span>`);
669
+ }
670
+ if (state.filters.size > 1)
671
+ parts.push(`<button class="dfd-link" data-act="clear-filters">Clear all filters</button>`);
672
+ if (state.diffColumn !== null)
673
+ parts.push(`<span class="dfd-pillfilter">${state.diffEqual ? "Equal" : "Differs"} in <b>${esc(M.columns[state.diffColumn].name)}</b>
674
+ <button data-act="clear-colfilter" aria-label="Remove column filter">×</button></span>`);
675
+ if (selected.size)
676
+ parts.push(`<span class="dfd-pillfilter sel">${n(selected.size)} selected
677
+ <button data-act="clear-sel" aria-label="Clear selection">×</button></span>`);
678
+ $(".dfd-toolbar-dyn").innerHTML = parts.join("");
679
+ }
680
+
681
+ function renderError() {
682
+ const box = $(".dfd-error");
683
+ box.hidden = !error;
684
+ box.textContent = error ? `Could not load rows – ${error}` : "";
685
+ }
686
+
687
+ // ---------------------------------------------------------------- table
688
+
689
+ function visibleCols() {
690
+ const idx = M.columns.map((_, i) => i);
691
+ if (!state.onlyDiffCols) return idx;
692
+ // columns the view is filtered on stay: "=" on a column means no difference in it
693
+ const hit = new Set([...view.diffColumns, ...state.filters.keys()]);
694
+ if (state.diffColumn !== null) hit.add(state.diffColumn);
695
+ return idx.filter((i) => M.columns[i].kind !== "compared" || hit.has(i));
696
+ }
697
+
698
+ /**
699
+ * Header cell of column `i`: name, sort/filter button, dtype and match rate.
700
+ * @param {number} i
701
+ */
702
+ function th(i) {
703
+ const c = M.columns[i];
704
+ const arrow = state.sort && state.sort.col === i ? (state.sort.dir > 0 ? "↑" : "↓") : "";
705
+ let tag = "";
706
+ if (c.kind === "key") tag = `<span class="dfd-tag">key</span>`;
707
+ else if (c.kind === "left_only") tag = `<span class="dfd-tag dfd-sidename">only ${chip("l")}${esc(M.L)}</span>`;
708
+ else if (c.kind === "right_only") tag = `<span class="dfd-tag dfd-sidename">only ${chip("r")}${esc(M.R)}</span>`;
709
+ let stats = "";
710
+ if (c.kind === "compared") {
711
+ // shares relative to the rows whose key exists on both sides
712
+ const found = M.summary.found;
713
+ const mm = c.mismatches,
714
+ eq = found - mm;
715
+ const on = state.diffColumn === i;
716
+ const tip = `${n(eq)} of ${n(found)} matched rows equal (${pct(eq, found)}), ${n(mm)} differ (${pct(mm, found)})`;
717
+ stats = `<div class="dfd-colstats" title="${esc(tip)}">
718
+ <span class="dfd-bar2" aria-hidden="true"><span class="eq" style="flex:${eq}"></span><span class="mm" style="flex:${mm}"></span></span>
719
+ ${
720
+ eq
721
+ ? `<button class="dfd-delta eq ${on && state.diffEqual ? "on" : ""}" data-diffcol="${i}" data-eq
722
+ title="${esc(tip)}\nClick to show only rows that are equal in “${esc(c.name)}”">${pct(eq, found)} =</button>`
723
+ : `<span class="dfd-muted">0% =</span>`
724
+ }
725
+ ${
726
+ mm
727
+ ? `<button class="dfd-delta ${on && !state.diffEqual ? "on" : ""}" data-diffcol="${i}"
728
+ title="${esc(tip)}\nClick to show only rows that differ in “${esc(c.name)}”">${pct(mm, found)} ≠</button>`
729
+ : `<span class="dfd-muted">0% ≠</span>`
730
+ }
731
+ </div>`;
732
+ }
733
+ return `<th class="${c.numeric ? "num" : ""}">
734
+ <div class="dfd-th-top">
735
+ <button class="dfd-sort" data-sort="${i}" title="Sort">${esc(c.name)}<span class="dfd-arrow">${arrow}</span></button>
736
+ <button class="dfd-colbtn ${state.filters.has(i) ? "on" : ""} ${state.colMenu === i ? "open" : ""}" data-colmenu="${i}"
737
+ aria-label="Sort and filter ${esc(c.name)}" aria-haspopup="dialog" title="Sort and filter">${FUNNEL}</button>
738
+ </div>
739
+ <div class="dfd-colmeta"><span class="dfd-dtype">${esc(c.dtype)}</span>${tag}</div>${stats}</th>`;
740
+ }
741
+
742
+ /**
743
+ * Body cell of column `i`; differing cells show both values.
744
+ * @param {Row} row
745
+ * @param {number} i
746
+ */
747
+ function td(row, i) {
748
+ const c = M.columns[i];
749
+ const cls = [c.numeric ? "num" : "", c.kind === "key" ? "key" : ""].join(" ");
750
+ // a column can only differ in rows that exist on both sides
751
+ if (row.l && row.r && row.d.includes(i)) {
752
+ const l = row.l[i];
753
+ const r = row.r[i];
754
+ const d = delta(l, r);
755
+ const inv = onlyInvisible(l, r);
756
+ const title = `${inv ? "Differs only in invisible characters\n" : ""}${M.L}: ${literal(l)}\n${M.R}: ${literal(r)}${d ? `\nΔ ${d}` : ""}`;
757
+ return `<td class="${cls} diff ${inv ? "inv-only" : ""}" title="${esc(title)}">
758
+ <div class="dfd-val"><span class="dfd-v">${F(l)}</span>${chip("l")}</div>
759
+ <div class="dfd-val"><span class="dfd-v">${F(r)}</span>${chip("r")}</div></td>`;
760
+ }
761
+ const v = cellValue(row, i);
762
+ return v === undefined ? `<td class="${cls} absent">–</td>` : `<td class="${cls}">${F(v)}</td>`;
763
+ }
764
+
765
+ function renderTable() {
766
+ const cols = visibleCols();
767
+ const rows = view.rows;
768
+ const pages = Math.max(1, Math.ceil(view.filtered / state.pageSize));
769
+ const start = state.page * state.pageSize;
770
+ const allSel = rows.length > 0 && rows.every((r) => selected.has(r.id));
771
+ const stArrow = state.sort && state.sort.col === "status" ? (state.sort.dir > 0 ? "↑" : "↓") : "";
772
+ const activeId = state.detail ? state.detail.row.id : null;
773
+
774
+ let body;
775
+ if (rows.length) {
776
+ body = rows
777
+ .map(
778
+ (r) => `
779
+ <tr data-id="${r.id}" class="st-${r.s} ${selected.has(r.id) ? "sel" : ""} ${activeId === r.id ? "active" : ""}">
780
+ <td class="cb"><input type="checkbox" data-sel="${r.id}" ${selected.has(r.id) ? "checked" : ""} aria-label="Select row"></td>
781
+ <td class="status" title="${esc(`${label(r.s)}: ${hint(r.s)}`)}"><span class="dfd-sr">${esc(label(r.s))}</span>${badge(r.s)}</td>
782
+ ${cols.map((i) => td(r, i)).join("")}
783
+ </tr>`,
784
+ )
785
+ .join("");
786
+ } else if (loading) {
787
+ body = `<tr><td class="dfd-empty" colspan="${cols.length + 2}">Loading…</td></tr>`;
788
+ } else {
789
+ body = `<tr><td class="dfd-empty" colspan="${cols.length + 2}">No rows match these filters.
790
+ <button class="dfd-link" data-act="reset">Reset filters</button></td></tr>`;
791
+ }
792
+
793
+ $(".dfd-scroll").innerHTML = `
794
+ <table class="${rows.length === pageRows() ? "full" : ""}">
795
+ <thead><tr>
796
+ <th class="cb"><input type="checkbox" data-act="page-all" ${allSel ? "checked" : ""} aria-label="Select all rows on this page"></th>
797
+ <th class="status">
798
+ <div class="dfd-th-top">
799
+ <button class="dfd-sort" data-sort="status" title="Sort">Status<span class="dfd-arrow">${stArrow}</span></button>
800
+ <button class="dfd-colbtn ${state.statuses.size !== STATUS_ORDER.length ? "on" : ""} ${state.colMenu === "status" ? "open" : ""}" data-colmenu="status"
801
+ aria-label="Sort and filter by status" aria-haspopup="dialog" title="Sort and filter">${FUNNEL}</button>
802
+ </div>
803
+ </th>
804
+ ${cols.map(th).join("")}
805
+ </tr></thead>
806
+ <tbody>${body}</tbody>
807
+ </table>`;
808
+
809
+ const end = Math.min(view.filtered, start + rows.length);
810
+ const of = view.filtered !== M.summary.total ? ` (filtered from ${n(M.summary.total)})` : "";
811
+ $(".dfd-footer").innerHTML = `
812
+ <span>${view.filtered ? `${n(start + 1)}–${n(end)}` : 0} of ${n(view.filtered)}${of}</span>
813
+ <span class="dfd-spacer"></span>
814
+ <label>Rows per page
815
+ <select data-act="page-size">${PAGE_SIZES.map((x) => `<option ${x === state.pageSize ? "selected" : ""}>${x}</option>`).join("")}</select>
816
+ </label>
817
+ <span class="dfd-pager">
818
+ <button data-page="first" ${state.page === 0 ? "disabled" : ""} aria-label="First page">«</button>
819
+ <button data-page="prev" ${state.page === 0 ? "disabled" : ""} aria-label="Previous page">‹</button>
820
+ <span>Page ${n(state.page + 1)} of ${n(pages)}</span>
821
+ <button data-page="next" ${state.page >= pages - 1 ? "disabled" : ""} aria-label="Next page">›</button>
822
+ <button data-page="last" ${state.page >= pages - 1 ? "disabled" : ""} aria-label="Last page">»</button>
823
+ </span>`;
824
+ renderToolbarDyn();
825
+ fixTableHeight();
826
+ }
827
+
828
+ /**
829
+ * Rows the table has room for: a full page, or all rows of a smaller diff. The
830
+ * total never changes while filtering, so neither does the height.
831
+ */
832
+ const pageRows = () => Math.max(1, Math.min(state.pageSize, M.summary.total));
833
+
834
+ /**
835
+ * Height for `pageRows()` rows, whatever the view holds: header + rows × row
836
+ * height (`--dfd-row-h`) + borders and a horizontal scrollbar.
837
+ */
838
+ function fixTableHeight() {
839
+ const scroll = $(".dfd-scroll");
840
+ const head = scroll.querySelector("thead");
841
+ const rowH = Number.parseFloat(getComputedStyle(root).getPropertyValue("--dfd-row-h")) || 48;
842
+ const headH = head ? head.getBoundingClientRect().height : 0;
843
+ if (!headH) return; // not laid out yet (hidden or detached)
844
+ const chrome = scroll.offsetHeight - scroll.clientHeight;
845
+ // round up: the header can be a fractional height, and a scrollbar must never appear
846
+ const height = `${Math.ceil(headH + pageRows() * rowH + chrome)}px`;
847
+ if (scroll.style.height !== height) scroll.style.height = height;
848
+ }
849
+ // A resize can add or remove the horizontal scrollbar (or lay out a widget that
850
+ // was hidden): measure again in the next frame, which also keeps the observer
851
+ // from reporting a loop when the new height resizes the box once more.
852
+ const resizeObserver = new ResizeObserver(() => requestAnimationFrame(fixTableHeight));
853
+
854
+ // ---------------------------------------------------------- detail view
855
+
856
+ function renderDetail() {
857
+ const wrap = $(".dfd-detail-wrap");
858
+ if (!state.detail) {
859
+ wrap.innerHTML = "";
860
+ return;
861
+ }
862
+ const { row, pos } = state.detail;
863
+ const key = keyOf(row)
864
+ .map(([k, v]) => `<code>${esc(k)} = ${F(v)}</code>`)
865
+ .join(" ");
866
+ const lines = M.columns
867
+ .map((c, i) => {
868
+ const isDiff = row.d.includes(i);
869
+ const lv = c.kind === "right_only" || !row.l ? undefined : row.l[i];
870
+ const rv = c.kind === "left_only" || !row.r ? undefined : row.r[i];
871
+ /** @param {Value|undefined} v */
872
+ const show = (v) => (v === undefined ? '<span class="dfd-absent">–</span>' : F(v));
873
+ const d = isDiff ? delta(lv, rv) : null;
874
+ const mark =
875
+ c.kind === "key"
876
+ ? "key"
877
+ : isDiff
878
+ ? d
879
+ ? `Δ ${esc(d)}`
880
+ : onlyInvisible(lv, rv)
881
+ ? "≠ invisible characters only"
882
+ : "≠"
883
+ : c.kind === "compared" && row.l && row.r
884
+ ? "="
885
+ : "";
886
+ return `<tr class="${isDiff ? "diff" : ""}"><th>${esc(c.name)}</th>
887
+ <td class="${c.numeric ? "num" : ""}">${show(lv)}</td><td class="${c.numeric ? "num" : ""}">${show(rv)}</td>
888
+ <td class="mark">${mark}</td></tr>`;
889
+ })
890
+ .join("");
891
+ wrap.innerHTML = `
892
+ <section class="dfd-detail st-${row.s}" aria-label="Row details">
893
+ <header>
894
+ <span class="dfd-pill">${badge(row.s)}${esc(label(row.s))}</span>
895
+ <span class="dfd-detail-key">${key}</span>
896
+ <span class="dfd-spacer"></span>
897
+ <span class="dfd-pager">
898
+ <button data-detail="prev" ${pos <= 0 ? "disabled" : ""} aria-label="Previous row">‹</button>
899
+ <span>${pos >= 0 ? `${n(pos + 1)} / ${n(view.filtered)}` : "outside filter"}</span>
900
+ <button data-detail="next" ${pos < 0 || pos >= view.filtered - 1 ? "disabled" : ""} aria-label="Next row">›</button>
901
+ </span>
902
+ <button class="dfd-close" data-act="close-detail" aria-label="Close details">×</button>
903
+ </header>
904
+ <div class="dfd-detail-scroll">
905
+ <table><thead><tr><th>Column</th><th><span class="dfd-sidename">${chip("l")}${esc(M.L)} (left)</span></th><th><span class="dfd-sidename">${chip("r")}${esc(M.R)} (right)</span></th><th></th></tr></thead>
906
+ <tbody>${lines}</tbody></table>
907
+ </div>
908
+ </section>`;
909
+ }
910
+
911
+ /**
912
+ * Move the detail panel by one row, fetching the neighbouring page if needed.
913
+ * @param {1|-1} dirn
914
+ */
915
+ function stepDetail(dirn) {
916
+ if (!state.detail || state.detail.pos < 0) return;
917
+ const target = state.detail.pos + dirn;
918
+ if (target < 0 || target >= view.filtered) return;
919
+ const pageOfTarget = Math.floor(target / state.pageSize);
920
+ if (pageOfTarget === state.page) {
921
+ const row = view.rows[target - state.page * state.pageSize];
922
+ state.detail = { row, pos: target };
923
+ renderTable();
924
+ renderDetail();
925
+ } else {
926
+ state.page = pageOfTarget;
927
+ pendingDetail = dirn > 0 ? "first" : "last";
928
+ query();
929
+ }
930
+ }
931
+
932
+ // ---------------------------------------------------------- column menu
933
+
934
+ /**
935
+ * Render the sort/filter popover of the column in `state.colMenu`.
936
+ * @param {Element} [anchor] Element to position the popover under.
937
+ */
938
+ function renderColMenu(anchor) {
939
+ const wrap = $(".dfd-colmenu-wrap");
940
+ const i = state.colMenu;
941
+ if (i === null) {
942
+ wrap.innerHTML = "";
943
+ return;
944
+ }
945
+ if (i === "status") {
946
+ wrap.innerHTML = statusMenuHtml();
947
+ placeMenu(anchor);
948
+ $opt(".dfd-colmenu input")?.focus({ preventScroll: true });
949
+ return;
950
+ }
951
+ const c = M.columns[i];
952
+ const t = c.ftype;
953
+ const f = state.filters.get(i) ?? { op: OPS[t][0][0], a: "", b: "" };
954
+ const inType = t === "number" ? 'type="number" step="any"' : t === "datetime" ? 'type="date"' : 'type="text"';
955
+ const sorted = state.sort && state.sort.col === i ? state.sort.dir : 0;
956
+ wrap.innerHTML = `
957
+ <div class="dfd-colmenu" role="dialog" aria-label="Sort and filter ${esc(c.name)}">
958
+ <div class="dfd-cm-title"><b>${esc(c.name)}</b><span class="dfd-dtype">${esc(c.dtype)}</span></div>
959
+ <div class="dfd-cm-sort">
960
+ <button data-cm="asc" class="${sorted === 1 ? "on" : ""}">↑ Sort ascending</button>
961
+ <button data-cm="desc" class="${sorted === -1 ? "on" : ""}">↓ Sort descending</button>
962
+ ${sorted ? '<button data-cm="unsort">Clear sort</button>' : ""}
963
+ </div>
964
+ <div class="dfd-cm-filter ${NO_VALUE.has(f.op) ? "novalue" : ""} ${f.op === "between" ? "two" : ""}">
965
+ <label class="dfd-cm-label">Filter
966
+ <select data-f="op">${OPS[t].map(([k, lbl]) => `<option value="${k}" ${k === f.op ? "selected" : ""}>${esc(lbl)}</option>`).join("")}</select>
967
+ </label>
968
+ <div class="dfd-cm-values">
969
+ <input data-f="a" ${inType} value="${esc(f.a)}" placeholder="${t === "number" ? "min" : t === "datetime" ? "" : "value"}" aria-label="Value">
970
+ <span class="dfd-cm-and">and</span>
971
+ <input data-f="b" ${inType} value="${esc(f.b)}" placeholder="${t === "number" ? "max" : ""}" aria-label="Upper value">
972
+ </div>
973
+ ${c.kind === "compared" ? `<p class="dfd-cm-hint">A differing row matches if the ${esc(M.L)} <i>or</i> the ${esc(M.R)} value matches.</p>` : ""}
974
+ </div>
975
+ <div class="dfd-cm-actions">
976
+ <button data-cm="clear" ${state.filters.has(i) ? "" : "disabled"}>Clear filter</button>
977
+ <button data-cm="apply" class="primary">Apply</button>
978
+ </div>
979
+ </div>`;
980
+ const menu = placeMenu(anchor);
981
+ const first = menu?.querySelector(NO_VALUE.has(f.op) ? "select" : 'input[data-f="a"]');
982
+ if (first instanceof HTMLElement) first.focus({ preventScroll: true });
983
+ }
984
+
985
+ /**
986
+ * Position the open popover under `anchor` (default: its header button).
987
+ * @param {Element} [anchor]
988
+ * @returns {HTMLElement|null} The popover.
989
+ */
990
+ function placeMenu(anchor) {
991
+ const menu = $opt(".dfd-colmenu");
992
+ const btn = anchor ?? root.querySelector(`.dfd-scroll [data-colmenu="${state.colMenu}"]`);
993
+ if (menu && btn) {
994
+ const rr = root.getBoundingClientRect();
995
+ const br = btn.getBoundingClientRect();
996
+ const w = menu.offsetWidth || 280;
997
+ menu.style.top = `${br.bottom - rr.top + 4}px`;
998
+ menu.style.left = `${Math.max(0, Math.min(br.left - rr.left, rr.width - w))}px`;
999
+ }
1000
+ return menu;
1001
+ }
1002
+
1003
+ /** Mark the header button whose menu is open. */
1004
+ function syncColButtons() {
1005
+ for (const b of root.querySelectorAll(".dfd-colbtn")) {
1006
+ if (b instanceof HTMLElement) b.classList.toggle("open", b.dataset.colmenu === String(state.colMenu));
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Toggle the column menu of column `i` (or of the status column).
1012
+ * @param {number|"status"} i
1013
+ * @param {Element} anchor
1014
+ */
1015
+ function openColMenu(i, anchor) {
1016
+ state.colMenu = state.colMenu === i ? null : i;
1017
+ renderColMenu(anchor);
1018
+ syncColButtons();
1019
+ }
1020
+
1021
+ function closeColMenu() {
1022
+ if (state.colMenu === null) return;
1023
+ state.colMenu = null;
1024
+ renderColMenu();
1025
+ syncColButtons();
1026
+ }
1027
+
1028
+ /** Read the popover inputs into `state.filters` (an empty value removes the filter). */
1029
+ function applyColMenu() {
1030
+ const i = state.colMenu;
1031
+ const menu = $opt(".dfd-colmenu");
1032
+ if (typeof i !== "number" || !menu) return;
1033
+ /** @param {"op"|"a"|"b"} k */
1034
+ const get = (k) => {
1035
+ const input = menu.querySelector(`[data-f="${k}"]`);
1036
+ return input instanceof HTMLInputElement || input instanceof HTMLSelectElement ? input.value.trim() : "";
1037
+ };
1038
+ const f = { op: get("op"), a: get("a"), b: get("b") };
1039
+ const empty = !NO_VALUE.has(f.op) && (f.op === "between" ? !f.a && !f.b : !f.a);
1040
+ if (empty) state.filters.delete(i);
1041
+ else state.filters.set(i, f);
1042
+ closeColMenu();
1043
+ changed();
1044
+ }
1045
+
1046
+ // ------------------------------------------------------------ actions
1047
+
1048
+ /**
1049
+ * Any change to the query: back to page 1 and fetch.
1050
+ * @param {{overview?: boolean}} [options] Also re-render the status overview.
1051
+ */
1052
+ function changed({ overview = false } = {}) {
1053
+ state.page = 0;
1054
+ if (overview) {
1055
+ renderOverview();
1056
+ syncStatusMenu();
1057
+ }
1058
+ renderToolbarDyn();
1059
+ query();
1060
+ }
1061
+
1062
+ /** @param {Iterable<Status>} statuses */
1063
+ const setStatuses = (statuses) => {
1064
+ state.statuses = new Set(statuses);
1065
+ changed({ overview: true });
1066
+ };
1067
+
1068
+ /** Push the checked row ids to Python (the only reactive trait). */
1069
+ function syncSelection() {
1070
+ model.set(
1071
+ "selected_ids",
1072
+ [...selected].sort((a, b) => a - b),
1073
+ );
1074
+ model.save_changes();
1075
+ }
1076
+
1077
+ function exportCsv() {
1078
+ const btn = /** @type {HTMLButtonElement} */ ($('[data-act="export"]'));
1079
+ btn.disabled = true;
1080
+ btn.textContent = "Exporting…";
1081
+ // own request id space: an export must not invalidate a pending page request
1082
+ model.send({ type: "export", req: "export", ...queryPayload() });
1083
+ }
1084
+
1085
+ /**
1086
+ * Closest ancestor (or self) of `t` matching `selector`, as an HTMLElement.
1087
+ * @param {Element} t
1088
+ * @param {string} selector
1089
+ * @returns {HTMLElement|null}
1090
+ */
1091
+ const closest = (t, selector) => {
1092
+ const found = t.closest(selector);
1093
+ return found instanceof HTMLElement ? found : null;
1094
+ };
1095
+
1096
+ /** @param {string|undefined} value */
1097
+ const isStatus = (value) => /** @type {readonly string[]} */ (STATUS_ORDER).includes(value ?? "");
1098
+
1099
+ // --------------------------------------------------------------- events
1100
+
1101
+ /**
1102
+ * One delegated click handler for the whole widget.
1103
+ * @param {MouseEvent} e
1104
+ */
1105
+ function onClick(e) {
1106
+ if (!(e.target instanceof Element)) return;
1107
+ const t = e.target;
1108
+ const sel = closest(t, "[data-sel]");
1109
+ if (sel instanceof HTMLInputElement) {
1110
+ const id = Number(sel.dataset.sel);
1111
+ if (sel.checked) selected.add(id);
1112
+ else selected.delete(id);
1113
+ syncSelection();
1114
+ return renderTable();
1115
+ }
1116
+ const preset = closest(t, "[data-preset]");
1117
+ if (preset) {
1118
+ e.preventDefault();
1119
+ const p = preset.dataset.preset;
1120
+ return setStatuses(
1121
+ p === "all"
1122
+ ? STATUS_ORDER
1123
+ : p === "diff"
1124
+ ? ["mismatch", "missing_left", "missing_right"]
1125
+ : ["missing_left", "missing_right"],
1126
+ );
1127
+ }
1128
+ const only = closest(t, "[data-only]");
1129
+ if (only) {
1130
+ e.preventDefault();
1131
+ const s = only.dataset.only;
1132
+ return isStatus(s) ? setStatuses([/** @type {Status} */ (s)]) : undefined;
1133
+ }
1134
+ const cm = closest(t, "[data-cm]");
1135
+ if (cm && state.colMenu !== null) {
1136
+ const i = state.colMenu;
1137
+ const a = cm.dataset.cm;
1138
+ if (a === "apply") return applyColMenu();
1139
+ if (a === "clear" && i !== "status") {
1140
+ state.filters.delete(i);
1141
+ closeColMenu();
1142
+ return changed();
1143
+ }
1144
+ state.sort = a === "unsort" ? null : { col: i, dir: a === "asc" ? 1 : -1 };
1145
+ closeColMenu();
1146
+ return changed();
1147
+ }
1148
+ const cmb = closest(t, "[data-colmenu]");
1149
+ if (cmb) return openColMenu(cmb.dataset.colmenu === "status" ? "status" : Number(cmb.dataset.colmenu), cmb);
1150
+ const rm = closest(t, "[data-rmfilter]");
1151
+ if (rm) {
1152
+ state.filters.delete(Number(rm.dataset.rmfilter));
1153
+ return changed();
1154
+ }
1155
+ if (t.closest(".dfd-colmenu")) return;
1156
+
1157
+ const act = closest(t, "[data-act]")?.dataset.act;
1158
+ switch (act) {
1159
+ case "page-all": {
1160
+ const checked = t instanceof HTMLInputElement && t.checked;
1161
+ for (const r of view.rows) {
1162
+ if (checked) selected.add(r.id);
1163
+ else selected.delete(r.id);
1164
+ }
1165
+ syncSelection();
1166
+ return renderTable();
1167
+ }
1168
+ case "clear-status":
1169
+ return setStatuses(STATUS_ORDER);
1170
+ case "clear-filters":
1171
+ state.filters.clear();
1172
+ return changed();
1173
+ case "clear-colfilter":
1174
+ state.diffColumn = null;
1175
+ state.diffEqual = false;
1176
+ return changed();
1177
+ case "clear-sel":
1178
+ selected.clear();
1179
+ syncSelection();
1180
+ return renderTable();
1181
+ case "close-detail":
1182
+ state.detail = null;
1183
+ renderTable();
1184
+ return renderDetail();
1185
+ case "export":
1186
+ return exportCsv();
1187
+ case "reset":
1188
+ Object.assign(state, { statuses: new Set(STATUS_ORDER), search: "", diffColumn: null, diffEqual: false });
1189
+ state.filters.clear();
1190
+ searchInput.value = "";
1191
+ return changed({ overview: true });
1192
+ }
1193
+ if (act) return;
1194
+
1195
+ const dc = closest(t, "[data-diffcol]");
1196
+ if (dc) {
1197
+ const i = Number(dc.dataset.diffcol);
1198
+ const eq = dc.dataset.eq !== undefined;
1199
+ // clicking the active filter turns it off, the other symbol switches to it
1200
+ const same = state.diffColumn === i && state.diffEqual === eq;
1201
+ state.diffColumn = same ? null : i;
1202
+ state.diffEqual = !same && eq;
1203
+ return changed();
1204
+ }
1205
+ const so = closest(t, "[data-sort]");
1206
+ if (so) {
1207
+ /** @type {Sort["col"]} */
1208
+ const col = so.dataset.sort === "status" ? "status" : Number(so.dataset.sort);
1209
+ if (!state.sort || state.sort.col !== col) state.sort = { col, dir: 1 };
1210
+ else if (state.sort.dir === 1) state.sort.dir = -1;
1211
+ else state.sort = null;
1212
+ return changed();
1213
+ }
1214
+ const pg = closest(t, "[data-page]");
1215
+ if (pg) {
1216
+ const pages = Math.max(1, Math.ceil(view.filtered / state.pageSize));
1217
+ /** @type {Record<string, number>} */
1218
+ const targets = { first: 0, prev: state.page - 1, next: state.page + 1, last: pages - 1 };
1219
+ state.page = targets[pg.dataset.page ?? ""] ?? state.page;
1220
+ return query();
1221
+ }
1222
+ const dn = closest(t, "[data-detail]");
1223
+ if (dn) return stepDetail(dn.dataset.detail === "next" ? 1 : -1);
1224
+ const tr = closest(t, "tbody tr[data-id]");
1225
+ if (tr && $(".dfd-scroll").contains(tr)) {
1226
+ const id = Number(tr.dataset.id);
1227
+ const idx = view.rows.findIndex((r) => r.id === id);
1228
+ state.detail =
1229
+ state.detail?.row.id === id || idx < 0 ? null : { row: view.rows[idx], pos: state.page * state.pageSize + idx };
1230
+ renderTable();
1231
+ renderDetail();
1232
+ $opt(".dfd-detail")?.scrollIntoView({ block: "nearest", behavior: "smooth" });
1233
+ }
1234
+ }
1235
+
1236
+ /**
1237
+ * Checkboxes and selects (status filter, toggles, page size, filter operator).
1238
+ * @param {Event} e
1239
+ */
1240
+ function onChange(e) {
1241
+ const t = e.target;
1242
+ if (!(t instanceof HTMLInputElement || t instanceof HTMLSelectElement)) return;
1243
+ const status = t.dataset.status;
1244
+ if (t instanceof HTMLInputElement && isStatus(status)) {
1245
+ const s = new Set(state.statuses);
1246
+ if (t.checked) s.add(/** @type {Status} */ (status));
1247
+ else s.delete(/** @type {Status} */ (status));
1248
+ return setStatuses(s);
1249
+ }
1250
+ if (t.dataset.f === "op") {
1251
+ const box = t.closest(".dfd-cm-filter");
1252
+ box?.classList.toggle("novalue", NO_VALUE.has(t.value));
1253
+ box?.classList.toggle("two", t.value === "between");
1254
+ return;
1255
+ }
1256
+ const checked = t instanceof HTMLInputElement && t.checked;
1257
+ switch (t.dataset.act) {
1258
+ case "only-diff":
1259
+ state.onlyDiffCols = checked;
1260
+ return renderTable();
1261
+ case "show-inv":
1262
+ state.showInvisible = checked;
1263
+ renderTable();
1264
+ return renderDetail();
1265
+ case "page-size":
1266
+ state.pageSize = Number(t.value);
1267
+ return changed();
1268
+ }
1269
+ }
1270
+
1271
+ /**
1272
+ * Close popovers on clicks outside of them.
1273
+ * @param {MouseEvent} e
1274
+ */
1275
+ function onDocClick(e) {
1276
+ const path = e.composedPath();
1277
+ if (
1278
+ state.colMenu !== null &&
1279
+ !path.includes($(".dfd-colmenu-wrap")) &&
1280
+ !path.some((x) => x instanceof HTMLElement && x.dataset.colmenu !== undefined)
1281
+ )
1282
+ closeColMenu();
1283
+ }
1284
+
1285
+ /**
1286
+ * Escape closes popovers. Listened for on the document: re-rendering a menu
1287
+ * can drop the focus to `<body>`, where a widget-level listener would miss it.
1288
+ * @param {KeyboardEvent} e
1289
+ */
1290
+ function onDocKey(e) {
1291
+ if (e.key !== "Escape" || state.colMenu === null) return;
1292
+ closeColMenu();
1293
+ }
1294
+
1295
+ /**
1296
+ * Enter applies the column filter.
1297
+ * @param {KeyboardEvent} e
1298
+ */
1299
+ function onKey(e) {
1300
+ const t = e.target;
1301
+ if (e.key === "Enter" && t instanceof HTMLElement && t.closest(".dfd-colmenu") && t.tagName !== "BUTTON") {
1302
+ e.preventDefault();
1303
+ applyColMenu();
1304
+ }
1305
+ }
1306
+
1307
+ /** @type {ReturnType<typeof setTimeout>|undefined} */
1308
+ let timer;
1309
+ /**
1310
+ * Debounced search.
1311
+ * @param {Event} e
1312
+ */
1313
+ function onInput(e) {
1314
+ if (e.target !== searchInput) return;
1315
+ clearTimeout(timer);
1316
+ timer = setTimeout(() => {
1317
+ state.search = searchInput.value;
1318
+ changed();
1319
+ }, SEARCH_DEBOUNCE_MS);
1320
+ }
1321
+
1322
+ /** New comparison from Python (e.g. widget re-used): reset and refetch. */
1323
+ function onMeta() {
1324
+ M = meta();
1325
+ Object.assign(state, { diffColumn: null, diffEqual: false, detail: null, colMenu: null });
1326
+ state.filters.clear();
1327
+ renderColMenu();
1328
+ changed({ overview: true });
1329
+ }
1330
+ function onSelectionFromPython() {
1331
+ selected = new Set(model.get("selected_ids") || []);
1332
+ renderTable();
1333
+ }
1334
+ const onScroll = () => closeColMenu();
1335
+
1336
+ root.addEventListener("click", onClick);
1337
+ root.addEventListener("change", onChange);
1338
+ root.addEventListener("input", onInput);
1339
+ root.addEventListener("keydown", onKey);
1340
+ $(".dfd-scroll").addEventListener("scroll", onScroll, { passive: true });
1341
+ resizeObserver.observe($(".dfd-scroll"));
1342
+ // web fonts that load after the first render change the header height
1343
+ document.fonts?.ready.then(() => fixTableHeight());
1344
+ document.addEventListener("click", onDocClick);
1345
+ document.addEventListener("keydown", onDocKey);
1346
+ model.on("msg:custom", onMessage);
1347
+ for (const name of ["columns", "summary", "left_short", "right_short", "left_name", "right_name"]) {
1348
+ model.on(`change:${name}`, onMeta);
1349
+ }
1350
+ model.on("change:selected_ids", onSelectionFromPython);
1351
+
1352
+ renderOverview();
1353
+ renderTable();
1354
+ root.style.setProperty("--dfd-bg", detectBg(el) || "Canvas");
1355
+ query();
1356
+
1357
+ return () => {
1358
+ clearTimeout(timer);
1359
+ resizeObserver.disconnect();
1360
+ document.removeEventListener("click", onDocClick);
1361
+ document.removeEventListener("keydown", onDocKey);
1362
+ model.off("msg:custom", onMessage);
1363
+ for (const name of ["columns", "summary", "left_short", "right_short", "left_name", "right_name"]) {
1364
+ model.off(`change:${name}`, onMeta);
1365
+ }
1366
+ model.off("change:selected_ids", onSelectionFromPython);
1367
+ };
1368
+ }
1369
+
1370
+ export default { render };