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.
- dference/__init__.py +33 -0
- dference/_compare.py +726 -0
- dference/_query.py +492 -0
- dference/_widget.py +251 -0
- dference/py.typed +0 -0
- dference/static/widget.css +952 -0
- dference/static/widget.js +1370 -0
- dference-0.1.0.dist-info/METADATA +283 -0
- dference-0.1.0.dist-info/RECORD +11 -0
- dference-0.1.0.dist-info/WHEEL +4 -0
- dference-0.1.0.dist-info/licenses/LICENSE +15 -0
|
@@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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 };
|