forked from
lass.pds.lassul.us/superconfig
This repository has no description
23 kB
522 lines
1<!doctype html>
2<html>
3 <head>
4 <meta charset="utf-8" />
5 <title>Unmaintained packages</title>
6 <style>
7 :root {
8 --bg: #0d1117;
9 --panel: #161b22;
10 --fg: #c9d1d9;
11 --dim: #8b949e;
12 --line: #30363d;
13 --red: #f85149;
14 --blue: #58a6ff;
15 --green: #3fb950;
16 --yellow: #d29922;
17 }
18 * {
19 box-sizing: border-box;
20 }
21 body {
22 margin: 0;
23 font:
24 13px/1.4 ui-monospace,
25 SFMono-Regular,
26 Menlo,
27 monospace;
28 background: var(--bg);
29 color: var(--fg);
30 display: grid;
31 grid-template-columns: 340px 1fr 380px;
32 grid-template-rows: auto 1fr;
33 height: 100vh;
34 }
35 header {
36 grid-column: 1 / -1;
37 display: flex;
38 gap: 12px;
39 align-items: center;
40 padding: 8px 12px;
41 background: var(--panel);
42 border-bottom: 1px solid var(--line);
43 }
44 header h1 {
45 font-size: 14px;
46 margin: 0 12px 0 0;
47 }
48 input,
49 select,
50 button {
51 font: inherit;
52 background: var(--bg);
53 color: var(--fg);
54 border: 1px solid var(--line);
55 border-radius: 4px;
56 padding: 3px 6px;
57 }
58 button {
59 cursor: pointer;
60 }
61 button:hover {
62 border-color: var(--blue);
63 }
64 #list {
65 overflow: auto;
66 border-right: 1px solid var(--line);
67 }
68 #list .row {
69 display: flex;
70 justify-content: space-between;
71 gap: 8px;
72 padding: 3px 10px;
73 cursor: pointer;
74 white-space: nowrap;
75 }
76 #list .row:hover,
77 #list .row.sel {
78 background: #1f2630;
79 }
80 #list .row .n {
81 overflow: hidden;
82 text-overflow: ellipsis;
83 }
84 #list .row .c {
85 color: var(--dim);
86 }
87 #list .meta {
88 padding: 6px 10px;
89 color: var(--dim);
90 border-bottom: 1px solid var(--line);
91 }
92 .inst {
93 color: var(--yellow);
94 }
95 #graph {
96 position: relative;
97 overflow: hidden;
98 }
99 #graph svg {
100 width: 100%;
101 height: 100%;
102 display: block;
103 }
104 #toolbar {
105 position: absolute;
106 top: 8px;
107 left: 8px;
108 display: flex;
109 gap: 6px;
110 flex-wrap: wrap;
111 }
112 #legend {
113 position: absolute;
114 bottom: 8px;
115 left: 8px;
116 color: var(--dim);
117 background: #161b22cc;
118 padding: 6px 8px;
119 border-radius: 4px;
120 }
121 #legend span {
122 margin-right: 10px;
123 }
124 .dot {
125 display: inline-block;
126 width: 9px;
127 height: 9px;
128 border-radius: 50%;
129 margin-right: 4px;
130 vertical-align: middle;
131 }
132 #details {
133 overflow: auto;
134 border-left: 1px solid var(--line);
135 padding: 10px 12px;
136 }
137 #details .desc {
138 margin: 4px 0 8px;
139 font-size: 13px;
140 }
141 #details h2 {
142 font-size: 14px;
143 margin: 0 0 6px;
144 word-break: break-all;
145 }
146 #details .k {
147 color: var(--dim);
148 }
149 #details ul {
150 margin: 4px 0 10px;
151 padding-left: 16px;
152 }
153 #details li {
154 cursor: pointer;
155 word-break: break-all;
156 }
157 #details li:hover {
158 color: var(--blue);
159 }
160 a {
161 color: var(--blue);
162 }
163 .u {
164 color: var(--red);
165 }
166 text {
167 fill: var(--fg);
168 font-size: 10px;
169 pointer-events: none;
170 paint-order: stroke;
171 stroke: var(--bg);
172 stroke-width: 3px;
173 }
174 .link {
175 stroke: #3d444d;
176 stroke-width: 1.2;
177 }
178 .link.via {
179 stroke: var(--red);
180 stroke-dasharray: 4 3;
181 opacity: 0.7;
182 }
183 .node circle {
184 stroke: var(--bg);
185 stroke-width: 1.5;
186 cursor: pointer;
187 }
188 .node.focus circle {
189 stroke: #fff;
190 stroke-width: 2.5;
191 }
192 .node.inst circle {
193 stroke: var(--yellow);
194 stroke-width: 2.5;
195 }
196 </style>
197 </head>
198 <body>
199 <header>
200 <h1 id="title">Unmaintained packages</h1>
201 <label
202 >machine
203 <select id="machine"></select
204 ></label>
205 <input id="search" placeholder="filter name or description…" size="28" />
206 <label
207 ><input type="checkbox" id="onlyInst" /> directly installed only</label
208 >
209 <span id="summary" class="k"></span>
210 </header>
211 <div id="list"></div>
212 <div id="graph">
213 <div id="toolbar">
214 <button
215 id="bExpand"
216 title="add packages that use the selected node (or double-click a node)"
217 >
218 expand users
219 </button>
220 <button
221 id="bPath"
222 title="all shortest chains from the system toplevel down to the selected node"
223 >
224 chains to system
225 </button>
226 <button
227 id="bUnm"
228 title="nearest unmaintained packages that (transitively) use the selected node; adds the 30 biggest, the side panel lists all"
229 >
230 unmaintained users
231 </button>
232 <button id="bReset">reset</button>
233 </div>
234 <div id="legend">
235 <span
236 ><i class="dot" style="background: var(--red)"></i>unmaintained</span
237 >
238 <span><i class="dot" style="background: var(--dim)"></i>other</span>
239 <span
240 ><i class="dot" style="background: var(--green)"></i>system-path</span
241 >
242 <span
243 ><i class="dot" style="background: var(--blue)"></i>system
244 toplevel</span
245 >
246 <span
247 ><i class="dot" style="border: 2px solid var(--yellow)"></i>directly
248 installed</span
249 >
250 <span
251 >arrow: user → used · dashed: via maintained packages · node size:
252 #dependents</span
253 >
254 </div>
255 <svg></svg>
256 </div>
257 <div id="details">
258 <p class="k">
259 Pick a package on the left. The graph shows every shortest chain through
260 which the system (blue, top) pulls it in. Click nodes to inspect,
261 double-click to expand who uses them.
262 </p>
263 </div>
264
265 <script>
266 /*D3*/
267 </script>
268 <script>
269 const D = /*DATA*/;
270 const N = D.names.length, M = D.machines.length;
271 const isU = new Uint8Array(N); for (const k in D.udep) isU[+k] = 1;
272 const rootSet = new Set(D.roots);
273 const children = Array.from({ length: N }, () => []);
274 D.parents.forEach((ps, c) => { for (const p of ps) children[p].push(c); });
275 const direct = new Map(Object.entries(D.direct).map(([k, v]) => [+k, v]));
276 const nameCount = new Map(); for (const n of D.names) nameCount.set(n, (nameCount.get(n) || 0) + 1);
277 const label = i => nameCount.get(D.names[i]) > 1 ? `${D.names[i]} (${D.hash[i]})` : D.names[i];
278
279 const $ = s => document.querySelector(s);
280 const msel = $("#machine");
281 msel.innerHTML = `<option value="-1">all (${M})</option>` + D.machines.map((m, i) => `<option value="${i}">${m}</option>`).join("");
282 let mi = -1;
283 const bitOf = i => 1 << i;
284 const inMachine = i => mi < 0 || (D.mask[i] & bitOf(mi));
285 const depOf = i => mi < 0 ? D.dep[i] : (isU[i] ? D.udep[i][mi] : D.dep[i]);
286 const instOn = i => direct.get(i) || 0;
287 const isInst = i => mi < 0 ? instOn(i) !== 0 : (instOn(i) & bitOf(mi)) !== 0;
288 const machinesOf = bits => D.machines.filter((_, k) => bits & bitOf(k));
289 const parentsOf = i => D.parents[i].filter(inMachine);
290 // [path shown, link or null]: from the warning for unmaintained packages, else from the package's meta.position
291 const fileLink = i => {
292 const f = D.file[i] || metaOf(D.names[i])?.file; if (!f) return "";
293 return f[1] ? `<a target="_blank" href="${f[1]}">${f[0]}</a>` : f[0];
294 };
295 const maintainedBy = n => {
296 const m = metaOf(n); if (!m) return "";
297 return [...(m.maintainers || []), ...(m.teams || []).map(t => `team ${t}`)].map(esc).join(", ");
298 };
299 document.title = $("#title").textContent = D.title;
300
301 // package meta (description, homepage, ...) by package name; strings come from nixpkgs, so escape them
302 const metaOf = n => (D.meta || {})[n];
303 const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
304 const urlHtml = s => /^https?:\/\//.test(s) ? `<a target="_blank" rel="noreferrer" href="${esc(s)}">${esc(s)}</a>` : esc(s);
305 function metaHtml(n) {
306 const m = metaOf(n); if (!m) return "";
307 return [
308 m.description && `<div class="desc">${esc(m.description)}</div>`,
309 m.homepage && `<div><span class="k">homepage</span> ${urlHtml(m.homepage)}</div>`,
310 m.license?.length && `<div><span class="k">license</span> ${esc(m.license.join(", "))}</div>`,
311 m.changelog && `<div><span class="k">changelog</span> ${urlHtml(m.changelog)}</div>`,
312 m.attr && `<div><span class="k">attribute</span> pkgs.${esc(m.attr)}${m.otherVersion ? ` <span class="k">(meta taken from ${esc(m.otherVersion)})</span>` : ""}</div>`,
313 m.vulnerabilities?.length && `<div class="u">known vulnerabilities:<ul>${m.vulnerabilities.map(v => `<li>${esc(v)}</li>`).join("")}</ul></div>`,
314 ].filter(Boolean).join("");
315 }
316
317 // ---------- list ----------
318 let selected = -1;
319 $("#list").addEventListener("click", e => { const r = e.target.closest(".row"); if (r) focusNode(+r.dataset.i); });
320 $("#search").addEventListener("input", renderList);
321 $("#onlyInst").addEventListener("change", renderList);
322 msel.addEventListener("change", () => { mi = +msel.value; renderList(); if (selected >= 0) focusNode(selected); });
323
324 // one list row per package name; variants = all drvs with that name (different deps/stages)
325 const variants = new Map();
326 for (const k in D.udep) { const i = +k, n = D.names[i]; if (!variants.has(n)) variants.set(n, []); variants.get(n).push(i); }
327 function renderList() {
328 const q = $("#search").value.toLowerCase(), oi = $("#onlyInst").checked;
329 const rows = [];
330 for (const [n, vs] of variants) {
331 if (q && !n.toLowerCase().includes(q) && !(metaOf(n)?.description || "").toLowerCase().includes(q)) continue;
332 const v = vs.filter(inMachine); if (!v.length) continue;
333 if (oi && !v.some(isInst)) continue;
334 v.sort((a, b) => depOf(b) - depOf(a));
335 rows.push({ n, best: v[0], count: v.length, dep: depOf(v[0]), inst: v.some(isInst) });
336 }
337 rows.sort((a, b) => b.dep - a.dep);
338 $("#summary").textContent = `${rows.length} unmaintained packages` + (mi < 0 ? " across all machines" : ` on ${D.machines[mi]}`);
339 const shown = rows.slice(0, 800);
340 const sel = selected >= 0 ? D.names[selected] : null;
341 $("#list").innerHTML = `<div class="meta">sorted by #dependents (best variant)${rows.length > shown.length ? ` · showing ${shown.length} of ${rows.length}, filter to narrow` : ""}</div>` +
342 shown.map(r => `<div class="row${r.n === sel ? " sel" : ""}" data-i="${r.best}" title="${esc(metaOf(r.n)?.description || "")}"><span class="n">${r.inst ? '<span class="inst">●</span> ' : ""}${metaOf(r.n)?.vulnerabilities?.length ? '<span class="u" title="known vulnerabilities">⚠</span> ' : ""}${r.n}${r.count > 1 ? ` <span class="k">×${r.count}</span>` : ""}</span><span class="c">${r.dep}</span></div>`).join("");
343 }
344 const svg = d3.select("#graph svg"), g = svg.append("g");
345 svg.append("defs").append("marker").attr("id", "arrow").attr("viewBox", "0 -4 8 8").attr("refX", 8).attr("refY", 0)
346 .attr("markerWidth", 7).attr("markerHeight", 7).attr("orient", "auto").append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#3d444d");
347 const zoom = d3.zoom().scaleExtent([0.05, 4]).on("zoom", e => g.attr("transform", e.transform));
348 svg.call(zoom).on("dblclick.zoom", null);
349 const gl = g.append("g"), gn = g.append("g");
350 let vis = new Map(); // node id -> {id, level, x, y}
351 let via = []; // [{source, target, steps}] dashed shortcuts
352 let focusId = -1;
353 const sim = d3.forceSimulation()
354 .force("link", d3.forceLink().id(d => d.id).distance(90).strength(0.3))
355 .force("charge", d3.forceManyBody().strength(-450))
356 .force("collide", d3.forceCollide(d => radius(d.id) + 10))
357 .force("y", d3.forceY(d => -d.level * 140).strength(0.6))
358 .force("x", d3.forceX(0).strength(0.01))
359 .on("tick", tick);
360
361 const radius = i => 4 + 2.2 * Math.log10(Math.max(0, depOf(i)) + 1);
362 const color = i => rootSet.has(i) ? "var(--blue)" : D.names[i] === "system-path" ? "var(--green)" : isU[i] ? "var(--red)" : "var(--dim)";
363
364 function add(i, level) {
365 if (!vis.has(i)) { const near = [...vis.values()].find(n => n.level === level - 1); vis.set(i, { id: i, level, x: (near?.x || 0) + (Math.random() - .5) * 80, y: -level * 110 }); }
366 }
367 function expand(i, limit = 40) {
368 const lv = vis.get(i).level;
369 const ps = parentsOf(i).sort((a, b) => (isU[b] - isU[a]) || (depOf(b) - depOf(a)));
370 ps.slice(0, limit).forEach(p => add(p, lv + 1));
371 return ps.length;
372 }
373 function bfsUp(start, stop) { // breadth-first over users until stop(i) holds on each branch
374 const prev = new Map([[start, -1]]), q = [start], hits = [];
375 for (let h = 0; h < q.length; h++) {
376 const i = q[h];
377 if (i !== start && stop(i)) { hits.push(i); continue; }
378 for (const p of parentsOf(i)) if (!prev.has(p)) { prev.set(p, i); q.push(p); }
379 }
380 return { prev, hits };
381 }
382 // distance (in "uses" steps) from i up to every node that transitively uses it
383 function distUp(i) {
384 const d = new Map([[i, 0]]), q = [i];
385 for (let h = 0; h < q.length; h++) { const v = q[h]; for (const p of parentsOf(v)) if (!d.has(p)) { d.set(p, d.get(v) + 1); q.push(p); } }
386 return d;
387 }
388 // how far the selected machine's system (or the nearest one, for "all") is above i
389 function systemDistance(i, d = distUp(i)) {
390 const reach = (mi < 0 ? D.roots : [D.roots[mi]]).filter(r => d.has(r));
391 if (!reach.length) return null;
392 const L = Math.min(...reach.map(r => d.get(r)));
393 return { L, roots: reach.filter(r => d.get(r) === L) };
394 }
395 // add every node on a shortest chain system -> ... -> i (walk down from the roots along edges that get one step closer to i)
396 function chainsToSystem(i, cap = 150) {
397 const d = distUp(i), s = systemDistance(i, d);
398 if (!s) return null;
399 const on = new Set(s.roots), q = [...s.roots];
400 for (let h = 0; h < q.length && on.size < cap; h++) {
401 const v = q[h];
402 for (const c of children[v]) if (inMachine(c) && d.get(c) === d.get(v) - 1 && !on.has(c)) { on.add(c); q.push(c); }
403 }
404 const base = vis.get(i)?.level ?? 0;
405 for (const v of on) add(v, base + d.get(v));
406 return { ...s, nodes: on.size, capped: on.size >= cap };
407 }
408 function unmaintainedUsers(i, limit = 30) { // adds the `limit` biggest; the details panel lists all
409 const { prev, hits } = bfsUp(i, j => isU[j]);
410 const lv = vis.get(i).level;
411 hits.sort((a, b) => depOf(b) - depOf(a));
412 for (const h of hits.slice(0, limit)) {
413 let steps = 0; for (let j = h; j !== i; j = prev.get(j)) steps++;
414 add(h, lv + 1);
415 if (steps > 1) via.push({ source: h, target: i, steps });
416 }
417 return hits;
418 }
419
420 function draw() {
421 const nodes = [...vis.values()];
422 const links = [];
423 for (const n of nodes) for (const p of D.parents[n.id]) if (vis.has(p) && inMachine(p)) links.push({ source: p, target: n.id });
424 const vlinks = via.filter(v => vis.has(v.source) && vis.has(v.target)).map(v => ({ ...v, via: true }));
425 const all = links.concat(vlinks);
426 const ls = gl.selectAll("line").data(all, d => `${d.source.id ?? d.source}-${d.target.id ?? d.target}-${!!d.via}`);
427 ls.exit().remove();
428 ls.enter().append("line").attr("class", d => d.via ? "link via" : "link").attr("marker-end", "url(#arrow)")
429 .append("title").text(d => d.via ? `uses via ${d.steps - 1} maintained package(s)` : "uses");
430 const ns = gn.selectAll("g.node").data(nodes, d => d.id);
431 ns.exit().remove();
432 const ne = ns.enter().append("g").attr("class", "node")
433 .call(d3.drag().on("start", (e, d) => { if (!e.active) sim.alphaTarget(.3).restart(); d.fx = d.x; d.fy = d.y; })
434 .on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; })
435 .on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = d.fy = null; }))
436 .on("click", (e, d) => { e.stopPropagation(); showDetails(d.id); })
437 .on("dblclick", (e, d) => { e.stopPropagation(); expand(d.id); draw(); showDetails(d.id); });
438 ne.append("circle");
439 ne.append("text").attr("dy", -9).attr("text-anchor", "middle");
440 ne.append("title");
441 const nm = ne.merge(ns);
442 nm.attr("class", d => `node${d.id === focusId ? " focus" : ""}${isInst(d.id) ? " inst" : ""}`);
443 nm.select("circle").attr("r", d => radius(d.id)).attr("fill", d => color(d.id));
444 nm.select("text").text(d => label(d.id));
445 nm.select("title").text(d => `${label(d.id)}${metaOf(D.names[d.id])?.description ? "\n" + metaOf(D.names[d.id]).description : ""}\n${depOf(d.id)} dependents · ${parentsOf(d.id).length} direct users`);
446 sim.nodes(nodes);
447 sim.force("link").links(all);
448 sim.alpha(0.8).restart();
449 fitSoon();
450 }
451 function tick() {
452 gl.selectAll("line").each(function (d) {
453 const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y, len = Math.hypot(dx, dy) || 1, r = radius(d.target.id) + 2;
454 this.setAttribute("x1", d.source.x); this.setAttribute("y1", d.source.y);
455 this.setAttribute("x2", d.target.x - dx / len * r); this.setAttribute("y2", d.target.y - dy / len * r);
456 });
457 gn.selectAll("g.node").attr("transform", d => `translate(${d.x},${d.y})`);
458 }
459 function fit() {
460 const ns = sim.nodes().filter(n => isFinite(n.x)); if (!ns.length) return;
461 const { width, height } = svg.node().getBoundingClientRect(), pad = 60;
462 const [x0, x1] = d3.extent(ns, n => n.x), [y0, y1] = d3.extent(ns, n => n.y);
463 const k = Math.min(1.5, (width - 2 * pad) / Math.max(1, x1 - x0), (height - 2 * pad) / Math.max(1, y1 - y0));
464 svg.transition().duration(400).call(zoom.transform, d3.zoomIdentity.translate(width / 2 - k * (x0 + x1) / 2, height / 2 - k * (y0 + y1) / 2).scale(k));
465 }
466 let fitTimer; const fitSoon = () => { clearTimeout(fitTimer); fitTimer = setTimeout(fit, 700); };
467
468 function focusNode(i) {
469 selected = focusId = i;
470 vis = new Map(); via = [];
471 gl.selectAll("*").remove(); gn.selectAll("*").remove();
472 add(i, 0);
473 if (!chainsToSystem(i)) expand(i);
474 draw(); showDetails(i); renderList();
475 history.replaceState(null, "", "#" + D.hash[i]);
476 }
477
478 let detailId = -1;
479 function showDetails(i) {
480 detailId = i;
481 const ps = parentsOf(i).sort((a, b) => (isU[b] - isU[a]) || (depOf(b) - depOf(a)));
482 const ms = machinesOf(D.mask[i]), inst = machinesOf(instOn(i));
483 const li = j => `<li data-i="${j}" class="${isU[j] ? "u" : ""}">${label(j)} <span class="k">(${depOf(j)})</span></li>`;
484 $("#details").innerHTML = `
485 <h2 class="${isU[i] ? "u" : ""}">${label(i)}</h2>
486 <div>${isU[i] ? '<span class="u">no maintainer</span>' : maintainedBy(D.names[i]) ? `<span class="k">maintainers</span> ${maintainedBy(D.names[i])}` : metaOf(D.names[i]) ? '<span class="k">no maintainer listed (not checked, e.g. fixed-output)</span>' : '<span class="k">no package meta (generated file, source, …)</span>'}</div>
487 ${metaHtml(D.names[i])}
488 ${fileLink(i) ? `<div><span class="k">file</span> ${fileLink(i)}</div>` : ""}
489 <div><span class="k">drv</span> ${D.hash[i]}…-${D.names[i]}.drv</div>
490 <div><span class="k">dependents</span> ${depOf(i)} ${mi < 0 ? "(max over machines)" : "on " + D.machines[mi]}</div>
491 <div><span class="k">distance to system</span> ${(s => s ? `${s.L} step(s)${mi < 0 ? ` (nearest: ${s.roots.map(r => D.machines[D.roots.indexOf(r)]).join(" ")})` : ""}` : "not reachable")(systemDistance(i))}</div>
492 ${isU[i] ? `<div><span class="k">per machine</span> ${D.machines.map((m, k) => D.udep[i][k] >= 0 ? `${m}:${D.udep[i][k]}` : "").filter(Boolean).join(" ")}</div>` : ""}
493 <div><span class="k">in ${ms.length} machine(s)</span> ${ms.join(" ")}</div>
494 <div><span class="k">directly installed on</span> ${inst.length ? `<span class="inst">${inst.join(" ")}</span>` : "—"}</div>
495 <p><button id="dFocus">focus here</button> <button id="dExpand">expand users</button></p>
496 <div class="k">used directly by ${ps.length} (click to add to graph, red = unmaintained):</div>
497 <ul id="dParents">${ps.slice(0, 300).map(li).join("")}${ps.length > 300 ? `<li class="k">… ${ps.length - 300} more</li>` : ""}</ul>
498 ${(variants.get(D.names[i]) || []).length > 1 ? `<div class="k">other variants of ${D.names[i]} (separate derivations, e.g. bootstrap stages):</div><ul id="dVar">${variants.get(D.names[i]).filter(j => j !== i && inMachine(j)).sort((a, b) => depOf(b) - depOf(a)).map(li).join("")}</ul>` : ""}
499 <div id="dUnm"></div>`;
500 $("#dFocus").onclick = () => focusNode(i);
501 $("#dExpand").onclick = () => { if (!vis.has(i)) add(i, 0); expand(i, 1e9); draw(); };
502 $("#dParents").onclick = e => { const l = e.target.closest("li[data-i]"); if (!l) return; const j = +l.dataset.i; if (!vis.has(i)) add(i, 0); add(j, vis.get(i).level + 1); draw(); };
503 if ($("#dVar")) $("#dVar").onclick = e => { const l = e.target.closest("li[data-i]"); if (l) focusNode(+l.dataset.i); };
504 if (isU[i]) {
505 const { hits } = bfsUp(i, j => isU[j]);
506 hits.sort((a, b) => depOf(b) - depOf(a));
507 $("#dUnm").innerHTML = `<div class="k">nearest unmaintained users (${hits.length}):</div><ul>${hits.slice(0, 200).map(li).join("")}</ul>`;
508 $("#dUnm").onclick = e => { const l = e.target.closest("li[data-i]"); if (l) focusNode(+l.dataset.i); };
509 }
510 }
511 const needSel = f => () => { if (detailId < 0) return; if (!vis.has(detailId)) add(detailId, 0); f(detailId); draw(); };
512 $("#bExpand").onclick = needSel(i => expand(i));
513 $("#bPath").onclick = needSel(i => chainsToSystem(i));
514 $("#bUnm").onclick = needSel(unmaintainedUsers);
515 $("#bReset").onclick = () => { if (focusId >= 0) focusNode(focusId); };
516
517 renderList();
518 const h = location.hash.slice(1); const hi = h ? D.hash.indexOf(h) : -1;
519 if (hi >= 0) focusNode(hi);
520 </script>
521 </body>
522</html>