htmlineage 0.0.0 → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +64 -2
- package/index.js +4 -0
- package/package.json +52 -3
- package/src/check/rules.js +379 -0
- package/src/check/summary.js +154 -0
- package/src/cli.js +99 -0
- package/src/commands/check.js +71 -0
- package/src/commands/index.js +7 -0
- package/src/commands/sql.js +65 -0
- package/src/commands/view.js +80 -0
- package/src/errors.js +39 -0
- package/src/graph/duplicates.js +78 -0
- package/src/graph/expr.js +54 -0
- package/src/graph/ids.js +24 -0
- package/src/graph/numbers.js +61 -0
- package/src/graph/parse.js +196 -0
- package/src/graph/schema.js +465 -0
- package/src/graph/trace.js +45 -0
- package/src/help.js +25 -0
- package/src/html/embed.js +142 -0
- package/src/html/page.js +101 -0
- package/src/meta.js +13 -0
- package/src/sql/compose.js +215 -0
- package/src/sql/style.js +193 -0
- package/src/viewer/client/bridge.js +141 -0
- package/src/viewer/client/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/JetBrainsMono-SemiBold.woff2 +0 -0
- package/src/viewer/client/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/viewer/client/fonts/OFL-SourceSerif4.txt +93 -0
- package/src/viewer/client/fonts/SourceSerif4-Medium.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-Regular.woff2 +0 -0
- package/src/viewer/client/fonts/SourceSerif4-SemiBold.woff2 +0 -0
- package/src/viewer/client/index.html +95 -0
- package/src/viewer/client/lineage.js +650 -0
- package/src/viewer/client/themes.css +76 -0
- package/src/viewer/client/viewer.css +1418 -0
- package/src/viewer/client/viewer.js +761 -0
- package/src/viewer/payload.js +45 -0
- package/src/viewer/server.js +188 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
// The viewer page: click a number, see how it was computed, read from the
|
|
2
|
+
// number down to its sources as an outline of rows that open in place. All
|
|
3
|
+
// the logic with no page in it is in lineage.js; this file is the DOM, the
|
|
4
|
+
// events, the report frame and the clipboard.
|
|
5
|
+
import {
|
|
6
|
+
exprParts,
|
|
7
|
+
isArithmetic,
|
|
8
|
+
builtFrom,
|
|
9
|
+
builtOn,
|
|
10
|
+
chainSql,
|
|
11
|
+
checkStatus,
|
|
12
|
+
copyText,
|
|
13
|
+
fromReport,
|
|
14
|
+
groundOf,
|
|
15
|
+
grounding,
|
|
16
|
+
headline,
|
|
17
|
+
highlightSql,
|
|
18
|
+
inputRow,
|
|
19
|
+
mismatches,
|
|
20
|
+
nodesOf,
|
|
21
|
+
outline,
|
|
22
|
+
outlineKeys,
|
|
23
|
+
ownCard,
|
|
24
|
+
reportCounts,
|
|
25
|
+
reportSql,
|
|
26
|
+
selection,
|
|
27
|
+
stepFacts,
|
|
28
|
+
stepHead,
|
|
29
|
+
stepSql,
|
|
30
|
+
stepText,
|
|
31
|
+
title,
|
|
32
|
+
untraced,
|
|
33
|
+
} from "./lineage.js";
|
|
34
|
+
|
|
35
|
+
const $ = (sel, root = document) => root.querySelector(sel);
|
|
36
|
+
const esc = (s) =>
|
|
37
|
+
String(s ?? "").replace(
|
|
38
|
+
/[&<>"']/g,
|
|
39
|
+
(c) =>
|
|
40
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
41
|
+
c
|
|
42
|
+
],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const ICON = {
|
|
46
|
+
back: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5"/></svg>',
|
|
47
|
+
copy: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="9" height="9" rx="1"/><path d="M11 5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2"/></svg>',
|
|
48
|
+
chev: '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M4.5 2.5 8 6l-3.5 3.5"/></svg>',
|
|
49
|
+
caret:
|
|
50
|
+
'<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2.5 7.5 6 4l3.5 3.5"/></svg>',
|
|
51
|
+
page: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M9 2h5v5M14 2 7.5 8.5M12 9.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h3.5"/></svg>',
|
|
52
|
+
point:
|
|
53
|
+
'<svg viewBox="0 0 26 26" aria-hidden="true"><path d="M4 13h13M12 8l5 5-5 5"/><path d="M21 4v18M24 4v18"/></svg>',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ---------- state ----------
|
|
57
|
+
const S = {
|
|
58
|
+
fx: null,
|
|
59
|
+
nodes: {},
|
|
60
|
+
sel: null,
|
|
61
|
+
history: [],
|
|
62
|
+
sqlOpen: new Map(),
|
|
63
|
+
// the outline rows the reviewer has opened, as keys from `outline`: going
|
|
64
|
+
// down, and under "what uses this"
|
|
65
|
+
openDown: new Set(),
|
|
66
|
+
openUp: new Set(),
|
|
67
|
+
showUses: false,
|
|
68
|
+
counts: {},
|
|
69
|
+
cycle: {},
|
|
70
|
+
drawer: false,
|
|
71
|
+
flash: null,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// A new selection starts with every row closed.
|
|
75
|
+
function startOver() {
|
|
76
|
+
S.openDown = new Set();
|
|
77
|
+
S.openUp = new Set();
|
|
78
|
+
S.showUses = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function load(fx) {
|
|
82
|
+
S.fx = fx;
|
|
83
|
+
S.nodes = nodesOf(fx.graph);
|
|
84
|
+
S.sel = null;
|
|
85
|
+
S.history = [];
|
|
86
|
+
S.sqlOpen = new Map();
|
|
87
|
+
startOver();
|
|
88
|
+
S.cycle = {};
|
|
89
|
+
S.drawer = false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- reading the graph ----------
|
|
93
|
+
const stepWord = (id) => stepHead(S.fx.graph, id).kind;
|
|
94
|
+
// The swatch class for what a node rests on.
|
|
95
|
+
const SWATCH = { sql: "full", file: "partial", none: "ungrounded" };
|
|
96
|
+
const state = (id) => SWATCH[groundOf(S.fx, id)];
|
|
97
|
+
const val = (id) => {
|
|
98
|
+
const n = S.nodes[id];
|
|
99
|
+
return n ? String(n.display ?? n.value ?? "") : "";
|
|
100
|
+
};
|
|
101
|
+
const nameOf = (id) => title(S.fx.graph, id);
|
|
102
|
+
const visual = (id) => {
|
|
103
|
+
const v = S.nodes[id]?.encoding?.visual;
|
|
104
|
+
return v ? (v === "table" ? "table" : `${v} chart`) : "view";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const fmtDate = (d) => {
|
|
108
|
+
const [y, m, day] = String(d).split("-").map(Number);
|
|
109
|
+
if (!y || !m || !day) return esc(d);
|
|
110
|
+
return new Date(Date.UTC(y, m - 1, day)).toLocaleDateString("en-GB", {
|
|
111
|
+
day: "numeric",
|
|
112
|
+
month: "short",
|
|
113
|
+
year: "numeric",
|
|
114
|
+
timeZone: "UTC",
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
// let long identifiers break after dots and underscores
|
|
118
|
+
const wb = (s) => esc(s).replace(/([._])/g, "$1<wbr>");
|
|
119
|
+
const period = (p) => (p ? `${fmtDate(p.from)} – ${fmtDate(p.to)}` : "");
|
|
120
|
+
|
|
121
|
+
const codeify = (s) => esc(s).replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
122
|
+
|
|
123
|
+
// ---------- small renderers ----------
|
|
124
|
+
const idBtn = (id) =>
|
|
125
|
+
S.nodes[id]
|
|
126
|
+
? `<button class="id" type="button" data-go="${esc(id)}" title="${esc(stepWord(id))}: ${esc(nameOf(id))}">${esc(id)}</button>`
|
|
127
|
+
: `<span class="id missing" title="Not in the graph">${esc(id)}</span>`;
|
|
128
|
+
const sw = (st) => `<span class="sw ${st}" aria-hidden="true"></span>`;
|
|
129
|
+
// A copy button. When there is nothing to copy it is disabled and says why.
|
|
130
|
+
const copyBtn = (attr, id, { sql, reason }, label, tip) =>
|
|
131
|
+
`<button class="btn" type="button" ${attr}="${esc(id)}" ${sql === null ? "disabled" : ""} title="${esc(reason ?? tip)}">${ICON.copy}${label}</button>`;
|
|
132
|
+
const STATE_WORD = {
|
|
133
|
+
sql: "Traced to the warehouse",
|
|
134
|
+
file: "Rests on a file",
|
|
135
|
+
none: "Nothing behind it",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A source's name and what the reviewer should know about its period.
|
|
139
|
+
const sourceRow = (row, { count }) => {
|
|
140
|
+
const mart = row.kind === "sql";
|
|
141
|
+
const numbers = count
|
|
142
|
+
? ` · ${row.numbers} number${row.numbers === 1 ? "" : "s"}`
|
|
143
|
+
: "";
|
|
144
|
+
const when = mart
|
|
145
|
+
? `${row.period ? esc(period(row.period)) : "No period"}${row.pinned ? "" : " · not pinned"}${numbers}`
|
|
146
|
+
: `external file${numbers}`;
|
|
147
|
+
return `<li>${sw(mart ? "full" : "partial")}<span class="what"><code>${wb(row.name ?? "(no name)")}</code></span><span class="when${mart && !row.pinned ? " warn" : ""}">${when}</span></li>`;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const C3_WORDS =
|
|
151
|
+
"Recalculate this number: the figure in the graph does not match the one on the screen";
|
|
152
|
+
const c3Values = (m) =>
|
|
153
|
+
[
|
|
154
|
+
m.page.length ? `page ${m.page.join(", ")}` : "",
|
|
155
|
+
m.graph !== null ? `graph ${m.graph}` : "",
|
|
156
|
+
]
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(" · ");
|
|
159
|
+
|
|
160
|
+
const colsLine = (cols) =>
|
|
161
|
+
Array.isArray(cols) && cols.length
|
|
162
|
+
? `<p class="reads cols">Columns: ${cols.map((c) => `<code>${esc(c)}</code>`).join(", ")}</p>`
|
|
163
|
+
: "";
|
|
164
|
+
|
|
165
|
+
// A step's SQL, highlighted, with Copy. A source's or view's columns come
|
|
166
|
+
// first, and an expression figure shows its expression as written.
|
|
167
|
+
function sqlBlock(id) {
|
|
168
|
+
const n = S.nodes[id];
|
|
169
|
+
const sql = typeof n.sql === "string" && n.sql.trim() ? n.sql : "";
|
|
170
|
+
const shown = (sql || String(n.expr ?? "")).replace(/\s+$/, "");
|
|
171
|
+
const lines = shown.split("\n").length;
|
|
172
|
+
const cols = n.step === "figure" ? "" : colsLine(n.columns);
|
|
173
|
+
return `${cols}<div class="snip"><pre>${highlightSql(shown)}</pre><div class="snip-bar"><span class="muted" style="font-size:12.5px">${lines} line${lines > 1 ? "s" : ""}</span><span class="gap"></span>${sql ? copyBtn("data-copy-sql", id, stepSql(S.fx, id), "Copy SQL", "") : ""}</div></div>`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// How many places a node has on the page, as the report said when it loaded.
|
|
177
|
+
const pageCount = (id) => S.counts[id] || 0;
|
|
178
|
+
|
|
179
|
+
// The formula of an expression figure: with ids, then values, then the result.
|
|
180
|
+
function formula(f) {
|
|
181
|
+
const tokens = (fn) =>
|
|
182
|
+
exprParts(f.expr, (t) => Boolean(S.nodes[t]))
|
|
183
|
+
.map((p) => (p.id ? fn(p.id) : esc(p.text)))
|
|
184
|
+
.join("");
|
|
185
|
+
return `<div class="formula" title="${isArithmetic(f.expr) ? "Arithmetic" : "A SQL expression"} over other numbers"><span class="eq"></span><span>${tokens((t) => idBtn(t))}</span><span class="eq">=</span><span>${tokens((t) => esc(val(t)))}</span><span class="eq">=</span><span class="res res-line">${esc(f.value ?? "")}</span></div>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// "N columns", with the names on hover.
|
|
189
|
+
const colsMeta = (cols) =>
|
|
190
|
+
`<span title="${esc(cols.join(", "))}">${cols.length} column${cols.length === 1 ? "" : "s"}</span>`;
|
|
191
|
+
|
|
192
|
+
// One step's card, in three parts:
|
|
193
|
+
// line its title from stepHead: the desc, or what the step is called;
|
|
194
|
+
// a source leads with its mart and puts its desc under it
|
|
195
|
+
// meta kind, then the facts from its fields, then the SQL toggle
|
|
196
|
+
// more a note, the formula, the SQL when open
|
|
197
|
+
// The clicked step drops what the head already shows: stepHead gives it no
|
|
198
|
+
// kind or value. A `bare` card, inside an open row, drops its title line and
|
|
199
|
+
// kind too, which the row above it shows; a source keeps its desc.
|
|
200
|
+
function stepCard(id, { isTop = false, bare = false } = {}) {
|
|
201
|
+
const n = S.nodes[id];
|
|
202
|
+
const head = stepHead(S.fx.graph, id, { top: isTop });
|
|
203
|
+
const { note, show } = stepText(S.fx.graph, id);
|
|
204
|
+
const f = n && !n.ungrounded ? stepFacts(S.fx, id) : null;
|
|
205
|
+
const from = (ids) => (ids || []).map(idBtn).join(" ");
|
|
206
|
+
|
|
207
|
+
let line = "";
|
|
208
|
+
let sub = "";
|
|
209
|
+
let right = "";
|
|
210
|
+
let clamped = false;
|
|
211
|
+
const meta = [];
|
|
212
|
+
if (head.kind !== null && !bare)
|
|
213
|
+
meta.push(`<span class="kind">${esc(head.kind)}</span>`);
|
|
214
|
+
|
|
215
|
+
if (!n) {
|
|
216
|
+
line = `<code>${esc(id)}</code>`;
|
|
217
|
+
meta.push(`<span class="stop">not in the graph</span>`);
|
|
218
|
+
} else if (n.ungrounded) {
|
|
219
|
+
line = esc(head.title ?? "");
|
|
220
|
+
meta.push(
|
|
221
|
+
`<span class="stop">nothing behind it · ask the builder where it comes from</span>`,
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
// a source's title is its mart, a name rather than words to clamp
|
|
225
|
+
clamped = (isTop || n.step !== "source") && Boolean(head.title);
|
|
226
|
+
line =
|
|
227
|
+
n.step === "source" && !isTop
|
|
228
|
+
? `<code>${wb(head.title ?? "(no name)")}</code>`
|
|
229
|
+
: esc(head.title ?? "");
|
|
230
|
+
if (head.sub)
|
|
231
|
+
sub = `<p class="sub" title="${esc(head.sub)}">${esc(head.sub)}</p>`;
|
|
232
|
+
if (head.value !== null)
|
|
233
|
+
right = `<span class="fig">${esc(head.value)}</span>`;
|
|
234
|
+
if (f.kind === "sql") {
|
|
235
|
+
meta.push(
|
|
236
|
+
`<span${f.pinned ? "" : ` class="warn"`}>${esc(f.period ? period(f.period) : "no period")}${f.pinned ? "" : " · not pinned"}</span>`,
|
|
237
|
+
);
|
|
238
|
+
if (f.how) meta.push(esc(f.how));
|
|
239
|
+
} else if (f.kind === "external") {
|
|
240
|
+
meta.push(
|
|
241
|
+
`<span class="warn">external ${esc(n.type || "file")} · htmlineage can't see inside it</span>`,
|
|
242
|
+
);
|
|
243
|
+
if (f.columns.length) meta.push(colsMeta(f.columns));
|
|
244
|
+
} else if (f.kind === "view") {
|
|
245
|
+
if (head.kind !== null)
|
|
246
|
+
right = pageCount(id)
|
|
247
|
+
? `<button class="link fig-link" type="button" data-scroll="${esc(id)}" title="Show it on the page">${esc(visual(id))}</button>`
|
|
248
|
+
: `<span class="fig small">${esc(visual(id))}</span>`;
|
|
249
|
+
if (f.from.length) meta.push(`from ${from(f.from)}`);
|
|
250
|
+
if (f.columns.length) meta.push(colsMeta(f.columns));
|
|
251
|
+
} else if (f.kind === "pick") {
|
|
252
|
+
if (f.from.length) meta.push(`picks from ${from(f.from)}`);
|
|
253
|
+
} else if (f.kind === "claim") {
|
|
254
|
+
const parts = f.from.map(
|
|
255
|
+
(p) =>
|
|
256
|
+
`${idBtn(p)}${S.nodes[p]?.step === "figure" ? ` <b>${esc(val(p))}</b>` : ""}`,
|
|
257
|
+
);
|
|
258
|
+
meta.push(
|
|
259
|
+
parts.length
|
|
260
|
+
? `rests on ${parts.join(", ")}`
|
|
261
|
+
: `<span class="stop">rests on nothing · ask the builder where it comes from</span>`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const open = show !== null && (S.sqlOpen.get(id) ?? show.open);
|
|
267
|
+
if (show)
|
|
268
|
+
meta.push(
|
|
269
|
+
`<button class="tog" type="button" data-sql="${esc(id)}" aria-expanded="${open}">${ICON.chev}${esc(show.label)}</button>`,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// the clicked step has no kind word, so its facts start the sentence
|
|
273
|
+
if (isTop && meta.length)
|
|
274
|
+
meta[0] = meta[0].replace(
|
|
275
|
+
/^(<span[^>]*>)?([a-z])/,
|
|
276
|
+
(_, tag = "", c) => tag + c.toUpperCase(),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
let more = "";
|
|
280
|
+
if (note) more += `<p class="reads"><b>Note</b> ${esc(note)}</p>`;
|
|
281
|
+
if (f?.kind === "formula") more += formula(f);
|
|
282
|
+
if (open) more += sqlBlock(id);
|
|
283
|
+
|
|
284
|
+
// a long desc is cut to two lines until its SQL is open; hover shows it whole
|
|
285
|
+
const clamp = clamped && !open ? " clamp" : "";
|
|
286
|
+
const tip = clamp ? ` title="${esc(head.title)}"` : "";
|
|
287
|
+
const titleRow =
|
|
288
|
+
!bare && (line || right)
|
|
289
|
+
? `<div class="step-h">${isTop ? "" : idBtn(id)}<span class="ttl${clamp}"${tip}>${line}</span>${right}</div>`
|
|
290
|
+
: "";
|
|
291
|
+
return `<div class="card${bare ? " bare" : ""}">
|
|
292
|
+
${titleRow}${sub}
|
|
293
|
+
${meta.length ? `<p class="meta">${meta.join(`<span class="sep" aria-hidden="true">·</span>`)}</p>` : ""}
|
|
294
|
+
${more}
|
|
295
|
+
</div>`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------- the outline ----------
|
|
299
|
+
// A step's rows, closed until the reviewer opens them. `branch` is the
|
|
300
|
+
// clicked step, then each row opened on the way down to this one; which rows
|
|
301
|
+
// there are, their keys and the caption come from `outline`.
|
|
302
|
+
function rowsHtml(branch, up) {
|
|
303
|
+
const { label, rows } = outline(S.fx.graph, branch, { up });
|
|
304
|
+
if (!rows.length) return "";
|
|
305
|
+
const opened = up ? S.openUp : S.openDown;
|
|
306
|
+
const onPage = Object.keys(S.counts);
|
|
307
|
+
const items = rows
|
|
308
|
+
.map((r) => {
|
|
309
|
+
const info = inputRow(S.fx, r.id, { onPage });
|
|
310
|
+
const open = !r.loop && opened.has(r.key);
|
|
311
|
+
const source = info.kind === "Source";
|
|
312
|
+
const ttl = source
|
|
313
|
+
? `<code>${wb(info.title ?? r.id)}</code>`
|
|
314
|
+
: esc(info.title ?? "");
|
|
315
|
+
const meta = [`<span class="kind">${esc(info.kind)}</span>`];
|
|
316
|
+
if (r.loop)
|
|
317
|
+
meta.push(`<span class="stop">loops back to ${esc(r.id)}</span>`);
|
|
318
|
+
else if (info.note)
|
|
319
|
+
meta.push(`<span class="stop">${esc(info.note)}</span>`);
|
|
320
|
+
if (info.onPage) meta.push(`<span class="onpage">on the page</span>`);
|
|
321
|
+
if (!up && !source && !r.loop && info.ends.length)
|
|
322
|
+
meta.push(
|
|
323
|
+
`<span class="ends">from ${info.ends.map((e) => `<code>${wb(e)}</code>`).join(", ")}</span>`,
|
|
324
|
+
);
|
|
325
|
+
const known = Boolean(S.nodes[r.id]);
|
|
326
|
+
const body = open
|
|
327
|
+
? `<div class="ol-body">
|
|
328
|
+
${known ? stepCard(r.id, { bare: true }) : ""}
|
|
329
|
+
<div class="ol-acts">${known ? `<button class="link" type="button" data-go="${esc(r.id)}">Select ${esc(r.id)}</button>` : ""}${info.onPage ? `<button class="link" type="button" data-scroll="${esc(r.id)}">↗ On the page</button>` : ""}</div>
|
|
330
|
+
${rowsHtml([...branch, r.id], up)}
|
|
331
|
+
</div>`
|
|
332
|
+
: "";
|
|
333
|
+
return `<li class="ol-item${open ? " is-open" : ""}" data-row="${esc(r.id)}">
|
|
334
|
+
<button class="ol-row" type="button" ${r.loop ? "disabled" : `data-tog="${esc(r.key)}"${up ? " data-up" : ""} aria-expanded="${open}"`}>
|
|
335
|
+
<span class="ol-chev" aria-hidden="true">${r.loop ? "" : ICON.chev}</span>
|
|
336
|
+
${sw(state(r.id))}
|
|
337
|
+
<span class="ol-main"><span class="ol-t"><span class="id">${esc(r.id)}</span> ${ttl}</span><span class="ol-meta">${meta.join(`<span class="sep" aria-hidden="true">·</span>`)}</span></span>
|
|
338
|
+
${info.value !== null ? `<span class="fig">${esc(info.value)}</span>` : ""}
|
|
339
|
+
</button>
|
|
340
|
+
${body}
|
|
341
|
+
</li>`;
|
|
342
|
+
})
|
|
343
|
+
.join("");
|
|
344
|
+
return `<p class="ol-cap">${esc(label)}</p><ul class="ol">${items}</ul>`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------- default state: the summary ----------
|
|
348
|
+
// A row that opens a node, or plain text when it has none.
|
|
349
|
+
const goRow = (node, swatch, html) =>
|
|
350
|
+
node === null
|
|
351
|
+
? `<li><div class="plain">${sw(swatch)}<span class="what">${html}</span></div></li>`
|
|
352
|
+
: `<li><button type="button" data-go="${esc(node)}" data-from-summary>${sw(swatch)}<span class="id">${esc(node)}</span><span class="what">${html}</span></button></li>`;
|
|
353
|
+
|
|
354
|
+
const NOT_TRACED = {
|
|
355
|
+
figure: "Number",
|
|
356
|
+
bare: "Number in the text",
|
|
357
|
+
claim: "Claim",
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
function renderSummary() {
|
|
361
|
+
const lines = headline(grounding(S.fx));
|
|
362
|
+
const built = builtOn(S.fx);
|
|
363
|
+
const checks = mismatches(S.fx);
|
|
364
|
+
const open = untraced(S.fx);
|
|
365
|
+
const lede = [lines.numbers, lines.claims]
|
|
366
|
+
.filter(Boolean)
|
|
367
|
+
.map((l) => {
|
|
368
|
+
// the first sentence leads; the counts that follow sit quieter under it
|
|
369
|
+
const [lead, ...more] = l.split(/(?<=\.)\s+/);
|
|
370
|
+
return `<p class="lede-sub">${esc(lead)}${more.length ? `<span class="lede-more">${esc(more.join(" "))}</span>` : ""}</p>`;
|
|
371
|
+
})
|
|
372
|
+
.join("");
|
|
373
|
+
return `<div class="sec">${lede}</div>
|
|
374
|
+
${built.length ? `<div class="sec"><h2 class="h">Built on</h2><ul class="built">${built.map((r) => sourceRow(r, { count: true })).join("")}</ul></div>` : ""}
|
|
375
|
+
${checks.length ? `<div class="sec"><h2 class="h">Check these numbers <small>${checks.length}</small></h2><ul class="attn">${checks.map((m) => goRow(m.node, "stopw", `<b>${C3_WORDS}.</b> <span class="muted">${esc(c3Values(m))}</span>`)).join("")}</ul></div>` : ""}
|
|
376
|
+
${open.length ? `<div class="sec"><h2 class="h">Not traced <small>${open.length}</small></h2><ul class="attn">${open.map((u) => goRow(u.node, "ungrounded", `${NOT_TRACED[u.kind]}${u.kind === "bare" && u.node ? ` in ${esc(u.node)}` : ""}: ${esc(u.text ?? "")}`)).join("")}</ul></div>` : ""}
|
|
377
|
+
<p class="prompt">${ICON.point}<span>Click any number, sentence, table or chart in the report to see how it was computed.</span></p>`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ---------- the status bar ----------
|
|
381
|
+
// Left: what check said. Middle: what the report holds. Right: what is
|
|
382
|
+
// selected, or a passing message ("Copied to the clipboard").
|
|
383
|
+
const many = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
384
|
+
function holds() {
|
|
385
|
+
const { numbers, claims, views, sources } = reportCounts(S.fx);
|
|
386
|
+
return [
|
|
387
|
+
many(numbers, "number"),
|
|
388
|
+
many(claims, "claim"),
|
|
389
|
+
many(views, "view"),
|
|
390
|
+
many(sources, "source"),
|
|
391
|
+
].join(" · ");
|
|
392
|
+
}
|
|
393
|
+
function here({ id, kind, value, name }) {
|
|
394
|
+
const what =
|
|
395
|
+
value === null
|
|
396
|
+
? esc(name ?? "")
|
|
397
|
+
: `<b>${esc(value)}</b> ${esc(name ?? "")}`;
|
|
398
|
+
return `<span class="here" title="${esc(kind)} ${esc(id)}: ${esc(name ?? "")}"><span class="here-id">${esc(id)}</span><span class="kind">${esc(kind)}</span><span class="here-what">${what}</span></span><span class="key"><kbd>Esc</kbd> back</span>`;
|
|
399
|
+
}
|
|
400
|
+
function statusBar() {
|
|
401
|
+
const { line, issues, reason } = checkStatus(S.fx);
|
|
402
|
+
const drawer =
|
|
403
|
+
S.drawer && issues.length
|
|
404
|
+
? `<ul class="drawer">${issues.map((i) => `<li>${i.node && S.nodes[i.node] ? idBtn(i.node) : ""}<span><b>${esc(i.code)}</b> ${codeify(i.message)}</span></li>`).join("")}</ul>`
|
|
405
|
+
: "";
|
|
406
|
+
const label = `${esc(line)}${issues.length ? ` ${ICON.chev}` : ""}`;
|
|
407
|
+
const check = issues.length
|
|
408
|
+
? `<button class="link" type="button" data-status aria-expanded="${S.drawer}">${label}</button>`
|
|
409
|
+
: `<span>${label}</span>`;
|
|
410
|
+
const right = S.flash
|
|
411
|
+
? `<span class="flash${S.flash.warn ? " warn" : ""}">${esc(S.flash.msg)}</span>`
|
|
412
|
+
: S.sel
|
|
413
|
+
? here(selection(S.fx.graph, S.sel))
|
|
414
|
+
: "";
|
|
415
|
+
return `<div class="statusbar${reason ? " bad" : ""}">${drawer}<div class="bar-line">${check}${reason ? `<span class="reason">${esc(reason)}</span>` : ""}<span class="holds">${holds()}</span><span class="bar-r">${right}</span></div></div>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------- selected state ----------
|
|
419
|
+
function renderSelected(id) {
|
|
420
|
+
const n = S.nodes[id];
|
|
421
|
+
const st = state(id);
|
|
422
|
+
const step = stepWord(id);
|
|
423
|
+
|
|
424
|
+
// head: what this is, in plain words
|
|
425
|
+
const meta = `<div class="head-meta"><span class="id static">${esc(id)}</span><span class="kind">${esc(step)}</span><span class="state">${sw(st)}${esc(STATE_WORD[groundOf(S.fx, id)])}</span></div>`;
|
|
426
|
+
let head = `<div class="head">`;
|
|
427
|
+
if (n.step === "figure") {
|
|
428
|
+
head += `<p class="value">${esc(val(id))}</p><p class="label">${n.label ? esc(n.label) : `<span class="muted">No label was recorded for this number.</span>`}</p>${meta}`;
|
|
429
|
+
} else if (n.step === "insight") {
|
|
430
|
+
let text = esc(n.text);
|
|
431
|
+
for (const p of n.from || []) {
|
|
432
|
+
const d = val(p);
|
|
433
|
+
if (d && S.nodes[p]?.step === "figure")
|
|
434
|
+
text = text.split(esc(d)).join(`<mark>${esc(d)}</mark>`);
|
|
435
|
+
}
|
|
436
|
+
head += `<p class="claim">${text}</p>${meta}`;
|
|
437
|
+
} else if (n.step === "view") {
|
|
438
|
+
head += `<p class="claim">${esc(nameOf(id))}</p><p class="label muted">${esc(visual(id))} · ${(n.columns || []).length} columns</p>${meta}`;
|
|
439
|
+
} else {
|
|
440
|
+
head += `<p class="claim"><code>${wb(nameOf(id))}</code></p><p class="label muted">${n.kind === "sql" ? esc(period(n.period)) : esc(n.given || "")}</p>${meta}`;
|
|
441
|
+
}
|
|
442
|
+
const c3 = mismatches(S.fx).find((m) => m.node === id);
|
|
443
|
+
if (c3)
|
|
444
|
+
head += `<p class="c3"><b>${C3_WORDS}.</b> <span>${esc(c3Values(c3))}</span></p>`;
|
|
445
|
+
head += `<div class="head-act">
|
|
446
|
+
${copyBtn("data-copy-chain", id, chainSql(S.fx, id), `Copy SQL for this ${n.step === "insight" ? "claim" : n.step}`, "Every query this rests on, composed into one runnable script")}
|
|
447
|
+
${pageCount(id) ? `<button class="btn" type="button" data-scroll="${esc(id)}">${ICON.page}Show on the page</button>` : ""}
|
|
448
|
+
</div></div>`;
|
|
449
|
+
|
|
450
|
+
// built from: the sources under this node
|
|
451
|
+
const sources = builtFrom(S.fx, id);
|
|
452
|
+
const rests = sources.length
|
|
453
|
+
? `<div class="rests"><h2 class="h">Built from</h2><ul>${sources.map((r) => sourceRow(r, { count: false })).join("")}</ul></div>`
|
|
454
|
+
: "";
|
|
455
|
+
|
|
456
|
+
// used by: the same outline, going up
|
|
457
|
+
const users = outline(S.fx.graph, [id], { up: true }).rows.length;
|
|
458
|
+
const uses = `<div class="uses">${
|
|
459
|
+
users
|
|
460
|
+
? `<button class="btn" type="button" data-uses aria-expanded="${S.showUses}">${S.showUses ? ICON.caret.replace("M2.5 7.5 6 4l3.5 3.5", "M2.5 4.5 6 8l3.5-3.5") : ICON.caret}${S.showUses ? "Hide what uses this" : `Show what uses this (${users})`}</button>`
|
|
461
|
+
: `<button class="btn" type="button" disabled>Nothing else uses this</button>`
|
|
462
|
+
}</div>`;
|
|
463
|
+
const usesList =
|
|
464
|
+
S.showUses && users
|
|
465
|
+
? `<div class="ol-sec">${rowsHtml([id], true)}</div>`
|
|
466
|
+
: "";
|
|
467
|
+
|
|
468
|
+
// how it was computed: the clicked step's own card, then its inputs
|
|
469
|
+
const keys = outlineKeys(S.fx.graph, id);
|
|
470
|
+
const allOpen = keys.every((k) => S.openDown.has(k));
|
|
471
|
+
const openAll = keys.length
|
|
472
|
+
? `<button class="link" type="button" data-openall="${allOpen ? "" : "1"}">${allOpen ? "Close all" : "Open all"}</button>`
|
|
473
|
+
: "";
|
|
474
|
+
// a claim with no card of its own still shows its transform note
|
|
475
|
+
const { note } = stepText(S.fx.graph, id);
|
|
476
|
+
const own = ownCard(S.fx.graph, id)
|
|
477
|
+
? `<div class="ol-own">${stepCard(id, { isTop: true })}</div>`
|
|
478
|
+
: note
|
|
479
|
+
? `<div class="ol-own"><div class="card"><p class="reads"><b>Note</b> ${esc(note)}</p></div></div>`
|
|
480
|
+
: "";
|
|
481
|
+
const score = `<div class="score-h"><h2 class="h">How it was computed</h2>${openAll}</div>
|
|
482
|
+
<div class="ol-sec">${own}${rowsHtml([id], false)}</div>`;
|
|
483
|
+
|
|
484
|
+
return head + rests + uses + usesList + score;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---------- render ----------
|
|
488
|
+
function nav() {
|
|
489
|
+
if (!S.sel) return "";
|
|
490
|
+
const prev = S.history[S.history.length - 1];
|
|
491
|
+
const backLabel = prev ? `Back to ${prev}` : "Summary";
|
|
492
|
+
return `<div class="nav"><button class="back" type="button" data-back>${ICON.back}${esc(backLabel)}</button><span class="gap"></span>${
|
|
493
|
+
prev
|
|
494
|
+
? `<button class="link" type="button" data-home style="font-size:13px">Report summary</button>`
|
|
495
|
+
: ""
|
|
496
|
+
}</div>`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Redrawing in place keeps keyboard focus on the button that was pressed. A
|
|
500
|
+
// toggle only adds or removes what comes after it, so that button keeps its
|
|
501
|
+
// place among the panel's buttons.
|
|
502
|
+
function render({ keepScroll = false } = {}) {
|
|
503
|
+
const pane = $("#lineage");
|
|
504
|
+
const top = pane.scrollTop;
|
|
505
|
+
const buttons = () => [...pane.querySelectorAll("button")];
|
|
506
|
+
const focused = keepScroll ? buttons().indexOf(document.activeElement) : -1;
|
|
507
|
+
pane.innerHTML = nav() + (S.sel ? renderSelected(S.sel) : renderSummary());
|
|
508
|
+
paintStatus();
|
|
509
|
+
pane.scrollTop = keepScroll ? top : 0;
|
|
510
|
+
if (focused >= 0) buttons()[focused]?.focus({ preventScroll: true });
|
|
511
|
+
paintPage();
|
|
512
|
+
writeHash();
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------- the report pane ----------
|
|
516
|
+
// The report runs in a sandboxed frame; the panel and the bridge script the
|
|
517
|
+
// server adds to it talk only through messages.
|
|
518
|
+
const tell = (message) =>
|
|
519
|
+
$("#report").contentWindow?.postMessage(
|
|
520
|
+
{ lineage: message.type, ...message },
|
|
521
|
+
"*",
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
function mountReport() {
|
|
525
|
+
window.addEventListener("message", (e) => {
|
|
526
|
+
if (e.source !== $("#report").contentWindow) return;
|
|
527
|
+
const message = fromReport(S.fx, e.data);
|
|
528
|
+
if (message === null) return;
|
|
529
|
+
if (message.type === "select") select(message.id, { fromPage: true });
|
|
530
|
+
else if (message.type === "back") back();
|
|
531
|
+
else onPageReady(message.counts);
|
|
532
|
+
});
|
|
533
|
+
$("#report").src = `/report/${encodeURIComponent(S.fx.report)}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function paintPage() {
|
|
537
|
+
tell({ type: "mark", id: S.sel });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function scrollPage(id) {
|
|
541
|
+
const n = pageCount(id);
|
|
542
|
+
if (!n) return;
|
|
543
|
+
const k = (S.cycle[id] = ((S.cycle[id] ?? -1) + 1) % n);
|
|
544
|
+
tell({ type: "scroll", id, index: k });
|
|
545
|
+
if (n > 1) toast(`${k + 1} of ${n} places on the page`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function peek(id, on) {
|
|
549
|
+
tell({ type: "peek", id: on && id !== S.sel ? id : null });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ---------- actions ----------
|
|
553
|
+
function select(id, { fromPage = false, push = true } = {}) {
|
|
554
|
+
if (!S.nodes[id]) return;
|
|
555
|
+
if (S.sel && push && S.sel !== id) S.history.push(S.sel);
|
|
556
|
+
if (!S.sel && push) S.history = [];
|
|
557
|
+
S.sel = id;
|
|
558
|
+
startOver();
|
|
559
|
+
render();
|
|
560
|
+
if (!fromPage && pageCount(id)) {
|
|
561
|
+
S.cycle[id] = -1;
|
|
562
|
+
scrollPage(id);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function back() {
|
|
566
|
+
if (!S.sel) return;
|
|
567
|
+
const prev = S.history.pop();
|
|
568
|
+
if (prev) {
|
|
569
|
+
S.sel = prev;
|
|
570
|
+
startOver();
|
|
571
|
+
render();
|
|
572
|
+
if (pageCount(prev)) {
|
|
573
|
+
S.cycle[prev] = -1;
|
|
574
|
+
scrollPage(prev);
|
|
575
|
+
}
|
|
576
|
+
} else home();
|
|
577
|
+
}
|
|
578
|
+
function home() {
|
|
579
|
+
S.sel = null;
|
|
580
|
+
S.history = [];
|
|
581
|
+
startOver();
|
|
582
|
+
render();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The old way to copy, for when the Clipboard API is missing or refuses.
|
|
586
|
+
function execCopy(text) {
|
|
587
|
+
const ta = document.createElement("textarea");
|
|
588
|
+
ta.value = text;
|
|
589
|
+
ta.style.position = "fixed";
|
|
590
|
+
ta.style.opacity = "0";
|
|
591
|
+
document.body.appendChild(ta);
|
|
592
|
+
ta.select();
|
|
593
|
+
try {
|
|
594
|
+
return document.execCommand("copy");
|
|
595
|
+
} finally {
|
|
596
|
+
ta.remove();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function copy(text, btn) {
|
|
601
|
+
if (!(await copyText(text, { clipboard: navigator.clipboard, execCopy }))) {
|
|
602
|
+
return toast("Could not copy to the clipboard", { warn: true });
|
|
603
|
+
}
|
|
604
|
+
if (btn) {
|
|
605
|
+
const old = btn.innerHTML;
|
|
606
|
+
btn.classList.add("done");
|
|
607
|
+
btn.innerHTML = btn.innerHTML.replace(/Copy[^<]*/, "Copied");
|
|
608
|
+
setTimeout(() => {
|
|
609
|
+
btn.classList.remove("done");
|
|
610
|
+
btn.innerHTML = old;
|
|
611
|
+
}, 1400);
|
|
612
|
+
}
|
|
613
|
+
toast("Copied to the clipboard");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// The status bar is drawn again when the selection or a message changes;
|
|
617
|
+
// keyboard focus on its button stays put.
|
|
618
|
+
function paintStatus() {
|
|
619
|
+
const status = $("#status");
|
|
620
|
+
const focused = status.contains(document.activeElement);
|
|
621
|
+
status.innerHTML = statusBar();
|
|
622
|
+
if (focused) $("[data-status]", status)?.focus({ preventScroll: true });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A message at the right of the status bar for a moment, read out by the live
|
|
626
|
+
// region beside it. A failure is styled as a warning. The region is emptied
|
|
627
|
+
// first so that the same message twice is announced twice.
|
|
628
|
+
let flashT;
|
|
629
|
+
let sayT;
|
|
630
|
+
function toast(msg, { warn = false } = {}) {
|
|
631
|
+
S.flash = { msg, warn };
|
|
632
|
+
const say = $("#say");
|
|
633
|
+
say.textContent = "";
|
|
634
|
+
clearTimeout(sayT);
|
|
635
|
+
sayT = setTimeout(() => (say.textContent = msg), 50);
|
|
636
|
+
paintStatus();
|
|
637
|
+
clearTimeout(flashT);
|
|
638
|
+
flashT = setTimeout(() => {
|
|
639
|
+
S.flash = null;
|
|
640
|
+
paintStatus();
|
|
641
|
+
}, 1800);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---------- events in the panel ----------
|
|
645
|
+
const pane = $("#lineage");
|
|
646
|
+
const onClick = (e) => {
|
|
647
|
+
const t = e.target.closest("button, a");
|
|
648
|
+
if (!t) return;
|
|
649
|
+
if (t.dataset.go) return select(t.dataset.go);
|
|
650
|
+
if (t.hasAttribute("data-status")) {
|
|
651
|
+
S.drawer = !S.drawer;
|
|
652
|
+
return render({ keepScroll: true });
|
|
653
|
+
}
|
|
654
|
+
if (t.hasAttribute("data-back")) return back();
|
|
655
|
+
if (t.hasAttribute("data-home")) return home();
|
|
656
|
+
if (t.dataset.sql) {
|
|
657
|
+
const id = t.dataset.sql;
|
|
658
|
+
const { show } = stepText(S.fx.graph, id);
|
|
659
|
+
S.sqlOpen.set(id, !(S.sqlOpen.get(id) ?? show.open));
|
|
660
|
+
return render({ keepScroll: true });
|
|
661
|
+
}
|
|
662
|
+
if (t.dataset.copySql) return copy(stepSql(S.fx, t.dataset.copySql).sql, t);
|
|
663
|
+
if (t.dataset.copyChain)
|
|
664
|
+
return copy(chainSql(S.fx, t.dataset.copyChain).sql, t);
|
|
665
|
+
if (t.dataset.scroll) return scrollPage(t.dataset.scroll);
|
|
666
|
+
if (t.hasAttribute("data-uses")) {
|
|
667
|
+
S.showUses = !S.showUses;
|
|
668
|
+
return render({ keepScroll: true });
|
|
669
|
+
}
|
|
670
|
+
if (t.dataset.tog) {
|
|
671
|
+
const opened = t.hasAttribute("data-up") ? S.openUp : S.openDown;
|
|
672
|
+
const key = t.dataset.tog;
|
|
673
|
+
opened.has(key) ? opened.delete(key) : opened.add(key);
|
|
674
|
+
return render({ keepScroll: true });
|
|
675
|
+
}
|
|
676
|
+
if (t.hasAttribute("data-openall")) {
|
|
677
|
+
for (const key of outlineKeys(S.fx.graph, S.sel))
|
|
678
|
+
t.dataset.openall ? S.openDown.add(key) : S.openDown.delete(key);
|
|
679
|
+
return render({ keepScroll: true });
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
pane.addEventListener("click", onClick);
|
|
683
|
+
$("#status").addEventListener("click", onClick);
|
|
684
|
+
// hovering a row outlines its step on the page
|
|
685
|
+
pane.addEventListener("mouseover", (e) => {
|
|
686
|
+
const row = e.target.closest("[data-row]");
|
|
687
|
+
peek(row?.dataset.row, Boolean(row));
|
|
688
|
+
});
|
|
689
|
+
pane.addEventListener("mouseleave", () => peek(null, false));
|
|
690
|
+
document.addEventListener("keydown", (e) => {
|
|
691
|
+
if (e.key === "Escape" && !e.target.closest("input, select, textarea"))
|
|
692
|
+
back();
|
|
693
|
+
});
|
|
694
|
+
$("[data-home]").addEventListener("click", (e) => {
|
|
695
|
+
e.preventDefault();
|
|
696
|
+
home();
|
|
697
|
+
});
|
|
698
|
+
$("#copyReport").addEventListener("click", (e) =>
|
|
699
|
+
copy(reportSql(S.fx).sql, e.currentTarget),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// ---------- hash (for links) ----------
|
|
703
|
+
const H = Object.fromEntries(new URLSearchParams(location.hash.slice(1)));
|
|
704
|
+
function writeHash() {
|
|
705
|
+
const p = new URLSearchParams();
|
|
706
|
+
if (S.sel) p.set("sel", S.sel);
|
|
707
|
+
history.replaceState(null, "", `#${p}`);
|
|
708
|
+
}
|
|
709
|
+
let ready = null;
|
|
710
|
+
// The report has loaded: now the panel knows where each node is on the page.
|
|
711
|
+
function onPageReady(counts) {
|
|
712
|
+
S.counts = counts;
|
|
713
|
+
const r = ready;
|
|
714
|
+
ready = null;
|
|
715
|
+
if (r?.sel && S.nodes[r.sel]) select(r.sel, { push: false });
|
|
716
|
+
else render({ keepScroll: true });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function boot() {
|
|
720
|
+
const res = await fetch("/data.json");
|
|
721
|
+
if (!res.ok) {
|
|
722
|
+
$("#lineage").textContent = await res.text();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
load(await res.json());
|
|
726
|
+
$("#reportName").textContent = S.fx.report;
|
|
727
|
+
document.title = `${S.fx.report} · htmlineage`;
|
|
728
|
+
const all = reportSql(S.fx);
|
|
729
|
+
const btn = $("#copyReport");
|
|
730
|
+
btn.disabled = all.sql === null;
|
|
731
|
+
btn.title = all.reason ?? "";
|
|
732
|
+
render();
|
|
733
|
+
mountReport();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// light or dark: follows the OS until the reviewer picks one
|
|
737
|
+
const root = document.documentElement;
|
|
738
|
+
const setMode = (m) => {
|
|
739
|
+
root.dataset.theme = m === "dark" ? "console" : "console-light";
|
|
740
|
+
};
|
|
741
|
+
let saved = null;
|
|
742
|
+
try {
|
|
743
|
+
saved = localStorage.getItem("tl-mode");
|
|
744
|
+
} catch {
|
|
745
|
+
/* private mode */
|
|
746
|
+
}
|
|
747
|
+
if (H.mode) saved = H.mode;
|
|
748
|
+
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
|
749
|
+
if (!saved) setMode(e.matches ? "dark" : "light");
|
|
750
|
+
});
|
|
751
|
+
$("#mode").addEventListener("click", () => {
|
|
752
|
+
saved = root.dataset.theme === "console" ? "light" : "dark";
|
|
753
|
+
setMode(saved);
|
|
754
|
+
try {
|
|
755
|
+
localStorage.setItem("tl-mode", saved);
|
|
756
|
+
} catch {
|
|
757
|
+
/* private mode */
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
ready = H;
|
|
761
|
+
boot();
|