uml-viewer 0.1.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.
@@ -0,0 +1,526 @@
1
+ (() => {
2
+ const params = new URLSearchParams(location.search);
3
+ const token = params.get("token") || sessionStorage.getItem("uml-token");
4
+ if (params.get("token")) {
5
+ sessionStorage.setItem("uml-token", params.get("token"));
6
+ history.replaceState(null, "", location.pathname);
7
+ }
8
+ const svg = document.getElementById("diagram");
9
+ const menu = document.getElementById("menu");
10
+ const banner = document.getElementById("banner");
11
+ const crumbs = document.getElementById("crumbs");
12
+ const popup = document.getElementById("popup");
13
+ const choice = document.getElementById("proposal");
14
+ const BOXES = ["namespace", "folder", "component"];
15
+ const DECLUTTER = ["none", "one arrow per pair", "triangles", "hide members", "hide classes"];
16
+ const declutterButton = document.getElementById("declutter");
17
+ const NS = "http://www.w3.org/2000/svg";
18
+ const LINE = 16;
19
+ const MAX_MEMBERS = 15;
20
+ const ZOOM_STEP = 1.25;
21
+ let graph = null;
22
+ let graphTime = null;
23
+ let drawError = null;
24
+ let scanError = null;
25
+ let drawing = false;
26
+ let menuTarget = null;
27
+ let drill = ["root"];
28
+ let zoom = 1;
29
+ let size = { width: 0, height: 0 };
30
+ let pendingScroll = null;
31
+ let saveTimer = null;
32
+ let pendingView = null;
33
+ let dirty = false;
34
+ let currentArrows = [];
35
+ let proposalId = null;
36
+ let declutter = 0;
37
+ const scrolls = {};
38
+
39
+ const api = (path, options = {}) =>
40
+ fetch(path, { ...options, headers: { "X-UML-Token": token, "Content-Type": "application/json", ...(options.headers || {}) } });
41
+
42
+ const el = (name, attrs = {}, parent = svg) => {
43
+ const node = document.createElementNS(NS, name);
44
+ Object.entries(attrs).forEach(([key, value]) => node.setAttribute(key, value));
45
+ parent.appendChild(node);
46
+ return node;
47
+ };
48
+
49
+ const levelText = (levels) => (levels ? (levels[0] === levels[1] ? `${levels[0]}` : `${levels[0]}..${levels[1]}`) : "");
50
+
51
+ const hue = (grade) => Math.round(((grade - 1) / 9) * 120);
52
+ const hasGrade = (grade) => grade !== null && grade !== undefined;
53
+ const boxFill = (grade) => (hasGrade(grade) ? `hsl(${hue(grade)}, 55%, var(--grade-l))` : "var(--box)");
54
+ const dotFill = (grade) => (hasGrade(grade) ? `hsl(${hue(grade)}, 65%, 45%)` : "var(--muted)");
55
+ const proposalOf = (id) => (graph.proposals || []).find((proposal) => proposal.id === id);
56
+ const views = () => (proposalId && proposalOf(proposalId) ? proposalOf(proposalId).views : graph.views);
57
+
58
+ const chain = (id) => {
59
+ const ids = [];
60
+ for (let view = views()[id]; view; view = views()[view.parent]) ids.unshift(view.id);
61
+ return ids;
62
+ };
63
+
64
+ const settleDrill = () => {
65
+ const existing = [...drill].reverse().find((id) => views()[id]) || "root";
66
+ drill = chain(existing);
67
+ };
68
+
69
+ const memberLines = (node) => {
70
+ const members = declutter >= 3 ? [] : node.members || [];
71
+ const lines = members.slice(0, MAX_MEMBERS).map((member) => ({ id: member.id, kind: member.type, text: (BOXES.includes(member.type) ? "▸ " : "") + member.label }));
72
+ if (members.length > MAX_MEMBERS) lines.push({ more: true, text: `+${members.length - MAX_MEMBERS} more` });
73
+ return lines;
74
+ };
75
+
76
+ const pairArrows = (arrows) => {
77
+ const merged = new Map();
78
+ arrows.forEach((arrow) => {
79
+ const key = [arrow.from, arrow.to].sort().join("\n");
80
+ const seen = merged.get(key);
81
+ merged.set(key, seen
82
+ ? { ...seen, both: true, violating: seen.violating || arrow.violating, pairs: [...seen.pairs, ...arrow.pairs] }
83
+ : { ...arrow, both: false });
84
+ });
85
+ return [...merged.values()];
86
+ };
87
+
88
+ const showDeclutter = () => {
89
+ declutterButton.textContent = `declutter: ${DECLUTTER[declutter]}`;
90
+ };
91
+
92
+ const cycleDeclutter = () => {
93
+ declutter = (declutter + 1) % DECLUTTER.length;
94
+ showDeclutter();
95
+ render();
96
+ saveSession();
97
+ };
98
+
99
+ const caption = (node) => (node.type === "class" && node.stereotype !== "class" ? `«${node.stereotype}» ` : "") + node.label;
100
+
101
+ const sized = (node) => {
102
+ const lines = memberLines(node);
103
+ const title = caption(node);
104
+ const width = 24 + 7.4 * Math.max(title.length + 6, ...lines.map((line) => line.text.length));
105
+ return { node, lines, title, width, height: 40 + LINE * lines.length };
106
+ };
107
+
108
+ const showBanner = () => {
109
+ const coverage = graph && graph.summary && graph.summary.coverage && graph.summary.coverage.banner;
110
+ const warnings = (graph && graph.summary && graph.summary.warnings) || [];
111
+ const warning = warnings.length === 1 ? `scan warning: ${warnings[0]}` : warnings.length ? `${warnings.length} scan warnings: ${warnings[0]}; run uml-viewer scan for all` : "";
112
+ banner.textContent = drawError || (scanError ? `${scanError}; showing last good graph` : "") || coverage || warning;
113
+ };
114
+
115
+ const expired = () => {
116
+ banner.textContent = "session expired: reopen with uml-viewer";
117
+ };
118
+
119
+ const applyZoom = () => {
120
+ svg.setAttribute("viewBox", `0 0 ${size.width} ${size.height}`);
121
+ svg.setAttribute("width", size.width * zoom);
122
+ svg.setAttribute("height", size.height * zoom);
123
+ };
124
+
125
+ const saveSession = () => {
126
+ clearTimeout(saveTimer);
127
+ saveTimer = setTimeout(() => {
128
+ api("/session", { method: "PUT", body: JSON.stringify({ drill, zoom, scroll: { x: scrollX, y: scrollY }, proposal: proposalId, declutter }) }).catch(() => {});
129
+ }, 1000);
130
+ };
131
+
132
+ const renderCrumbs = () => {
133
+ crumbs.replaceChildren(...drill.map((id, index) => {
134
+ const crumb = document.createElement("button");
135
+ crumb.className = "crumb";
136
+ crumb.dataset.id = id;
137
+ crumb.textContent = id === "root" ? (proposalOf(proposalId) || {}).name || graph.title : views()[id].label;
138
+ crumb.disabled = index === drill.length - 1;
139
+ return crumb;
140
+ }));
141
+ };
142
+
143
+ const drawNode = (placed, { node, lines, title }) => {
144
+ const group = el("g", { class: `box ${node.type}`, "data-id": node.id, "data-type": node.type, transform: `translate(${placed.x + 20},${placed.y + 20})` });
145
+ if (node.type === "foreign") {
146
+ el("ellipse", { cx: placed.width / 2, cy: placed.height / 2, rx: placed.width / 2, ry: placed.height / 2 }, group);
147
+ el("text", { class: "title", x: placed.width / 2, y: placed.height / 2 + 4, "text-anchor": "middle" }, group).textContent = title;
148
+ return;
149
+ }
150
+ group.setAttribute("data-grade", hasGrade(node.grade) ? node.grade : "");
151
+ el("rect", { width: placed.width, height: placed.height, rx: 4, style: `fill: ${boxFill(node.grade)}` }, group);
152
+ [["c", node.c_grade], ["m", node.m_grade]].forEach(([kind, grade], index) => {
153
+ el("circle", { class: `dot ${kind}`, cx: placed.width - 26 + 12 * index, cy: placed.height - 8, r: 4, "data-grade": hasGrade(grade) ? grade : "", style: `fill: ${dotFill(grade)}` }, group);
154
+ });
155
+ el("text", { class: "title", x: 10, y: 18 }, group).textContent = title;
156
+ el("text", { class: "level", x: placed.width - 10, y: 18, "text-anchor": "end" }, group).textContent = levelText(node.levels);
157
+ lines.forEach((line, index) => {
158
+ const attrs = { x: 14, y: 36 + LINE * index, class: line.more ? "more" : "member" };
159
+ if (!line.more) Object.assign(attrs, { "data-id": line.id, "data-kind": line.kind });
160
+ el("text", attrs, group).textContent = line.text;
161
+ });
162
+ };
163
+
164
+ function drawArrow(edge, arrow, index) {
165
+ (edge.sections || []).forEach((section) => {
166
+ const points = [section.startPoint, ...(section.bendPoints || []), section.endPoint];
167
+ const d = points.map((point, i) => `${i ? "L" : "M"}${point.x + 20},${point.y + 20}`).join(" ");
168
+ el("path", { class: `arrow${arrow.violating ? " violating" : ""}${arrow.both ? " both" : ""}`, d });
169
+ el("path", { class: `hit${arrow.violating ? " violating" : ""}`, d, "data-index": index, "data-from": arrow.from, "data-to": arrow.to });
170
+ });
171
+ }
172
+
173
+ async function draw() {
174
+ settleDrill();
175
+ renderCrumbs();
176
+ const view = views()[drill[drill.length - 1]];
177
+ const shown = declutter >= 4 ? view.nodes.filter((node) => node.type !== "class") : view.nodes;
178
+ const ids = new Set(shown.map((node) => node.id));
179
+ const kept = view.arrows.filter((arrow) => ids.has(arrow.from) && ids.has(arrow.to));
180
+ const arrows = declutter >= 1 ? pairArrows(kept) : kept;
181
+ const nodes = shown.map(sized);
182
+ const top = Math.max(0, ...shown.flatMap((node) => node.levels || [])) + 1;
183
+ const layout = await new ELK().layout({
184
+ id: "root",
185
+ layoutOptions: {
186
+ "elk.algorithm": "layered", "elk.direction": "UP", "elk.edgeRouting": "ORTHOGONAL",
187
+ "elk.partitioning.activate": "true", "elk.spacing.nodeNode": "40", "elk.layered.spacing.nodeNodeBetweenLayers": "60"
188
+ },
189
+ children: nodes.map(({ node, width, height }) => ({
190
+ id: node.id, width, height,
191
+ layoutOptions: { "elk.partitioning.partition": String(node.levels ? node.levels[0] : top) }
192
+ })),
193
+ edges: arrows.map((arrow, index) => ({ id: `a${index}`, sources: [arrow.from], targets: [arrow.to] }))
194
+ });
195
+ currentArrows = arrows;
196
+ svg.replaceChildren();
197
+ svg.classList.toggle("triangles", declutter >= 2);
198
+ const defs = el("defs");
199
+ [["head", "var(--line)"], ["head-red", "var(--red)"]].forEach(([id, color]) => {
200
+ const marker = el("marker", { id, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse" }, defs);
201
+ el("path", { d: "M0,0 L10,5 L0,10 z", fill: color }, marker);
202
+ });
203
+ const hidesAll = !shown.length && view.nodes.length > 0;
204
+ size = hidesAll ? { width: 560, height: 60 } : { width: layout.width + 40, height: layout.height + 40 };
205
+ applyZoom();
206
+ if (hidesAll) el("text", { class: "note", x: 20, y: 34 }).textContent = `declutter hides all ${view.nodes.length} classes in this view: press d`;
207
+ const byId = Object.fromEntries(nodes.map((entry) => [entry.node.id, entry]));
208
+ layout.children.forEach((placed) => drawNode(placed, byId[placed.id]));
209
+ (layout.edges || []).forEach((edge, index) => drawArrow(edge, arrows[index], index));
210
+ if (pendingScroll) {
211
+ scrollTo(pendingScroll.x, pendingScroll.y);
212
+ pendingScroll = null;
213
+ }
214
+ }
215
+
216
+ async function render() {
217
+ if (drawing) {
218
+ dirty = true;
219
+ return;
220
+ }
221
+ drawing = true;
222
+ dirty = false;
223
+ try {
224
+ await draw();
225
+ drawError = null;
226
+ } catch (error) {
227
+ drawError = `could not draw the graph: ${error.message}`;
228
+ } finally {
229
+ drawing = false;
230
+ }
231
+ showBanner();
232
+ const id = pendingView;
233
+ pendingView = null;
234
+ if (id) openView(id);
235
+ if (dirty && !drawing) render();
236
+ }
237
+
238
+ async function loadGraph() {
239
+ const response = await api("/graph.json");
240
+ if (response.status === 403) {
241
+ expired();
242
+ return false;
243
+ }
244
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
245
+ graph = await response.json();
246
+ return true;
247
+ }
248
+
249
+ const showStatus = (version) => {
250
+ const alive = version.agent && Date.now() / 1000 - version.agent < 10;
251
+ const failed = version.failed ? ` · command ${version.failed.id} failed: ${version.failed.reason}` : "";
252
+ document.getElementById("status").textContent =
253
+ (alive ? `watcher running (${version.queue} waiting)` : `no agent: ${version.queue} commands wait in the queue`) + failed;
254
+ };
255
+
256
+ function renderChoice() {
257
+ if (proposalId && !proposalOf(proposalId)) {
258
+ toast(`proposal ${proposalId} is gone: showing the real diagram`);
259
+ proposalId = null;
260
+ drill = ["root"];
261
+ saveSession();
262
+ }
263
+ choice.replaceChildren(...[{ id: "", name: "Real diagram" }, ...(graph.proposals || [])].map((proposal) => {
264
+ const option = document.createElement("option");
265
+ option.value = proposal.id;
266
+ option.textContent = proposal.name;
267
+ return option;
268
+ }));
269
+ choice.value = proposalId || "";
270
+ }
271
+
272
+ async function poll() {
273
+ let version;
274
+ try {
275
+ const response = await api("/version");
276
+ if (response.status === 403) return expired();
277
+ version = await response.json();
278
+ } catch (error) {
279
+ banner.textContent = "server not reachable";
280
+ return;
281
+ }
282
+ showStatus(version);
283
+ scanError = version.error;
284
+ if (version.graph !== graphTime && !drawing) {
285
+ try {
286
+ if (!(await loadGraph())) return;
287
+ graphTime = version.graph;
288
+ renderChoice();
289
+ await render();
290
+ } catch (error) {
291
+ drawError = error instanceof TypeError ? "server not reachable" : `could not draw the graph: ${error.message}`;
292
+ }
293
+ }
294
+ showBanner();
295
+ }
296
+
297
+ const openView = (id) => {
298
+ if (!graph || !views()[id]) return;
299
+ if (drawing) {
300
+ pendingView = id;
301
+ return;
302
+ }
303
+ scrolls[drill[drill.length - 1]] = { x: scrollX, y: scrollY };
304
+ drill = chain(id);
305
+ pendingScroll = scrolls[id] || { x: 0, y: 0 };
306
+ render();
307
+ saveSession();
308
+ };
309
+
310
+ function toast(text) {
311
+ const node = document.getElementById("toast");
312
+ node.textContent = text;
313
+ setTimeout(() => { if (node.textContent === text) node.textContent = ""; }, 4000);
314
+ }
315
+
316
+ const SIGNS = { public: "+", private: "-", protected: "#" };
317
+
318
+ const append = (parent, tag, text, className) => {
319
+ const node = document.createElement(tag);
320
+ node.textContent = text;
321
+ if (className) node.className = className;
322
+ parent.appendChild(node);
323
+ return node;
324
+ };
325
+
326
+ function openCard(id) {
327
+ const entry = graph && graph.classes.find((item) => item.id === id);
328
+ if (!entry) return toast(`class ${id} is not in the graph`);
329
+ const card = document.getElementById("card");
330
+ card.replaceChildren();
331
+ append(card, "h2", entry.id);
332
+ const level = entry.level === null || entry.level === undefined ? "no level" : `${proposalId ? "real level" : "level"} ${entry.level}`;
333
+ append(card, "p", `«${entry.stereotype}» · ${entry.kind_folder} · ${level}`);
334
+ append(card, "p", entry.crap ? `C grade ${entry.crap.grade} (mu ${entry.crap.mu.toFixed(1)}, sigma ${entry.crap.sigma.toFixed(1)})` : "C grade: no data", "grade c");
335
+ append(card, "p", entry.mutation ? `M grade ${entry.mutation.grade} (score ${Math.round(entry.mutation.score * 100)}%)` : "M grade: no data", "grade m");
336
+ (entry.coverage_notes || []).forEach((note) => append(card, "p", note, "note coverage"));
337
+ entry.files.forEach((file) => append(card, "p", file, "file"));
338
+ if (entry.parse_errors.length) {
339
+ append(card, "h3", "parse errors", "error");
340
+ entry.parse_errors.forEach((error) => append(card, "p", error, "error"));
341
+ }
342
+ const list = append(card, "ul", "");
343
+ entry.ops.forEach((op) => {
344
+ const metrics = hasGrade(op.coverage) ? ` cov ${Math.round(op.coverage * 100)}% crap ${op.crap.toFixed(1)}` : "";
345
+ const item = append(list, "li", `${SIGNS[op.visibility]} ${op.name} cc ${op.cc}${metrics}`, "op");
346
+ item.dataset.file = op.file;
347
+ item.dataset.line = op.line_start;
348
+ });
349
+ append(card, "p", "static scan: dynamic references (constantize, send) are not seen", "note");
350
+ card.hidden = false;
351
+ }
352
+
353
+ async function openSource(file, line) {
354
+ const panel = document.getElementById("source");
355
+ panel.replaceChildren();
356
+ append(panel, "h2", `${file}:${line}`);
357
+ panel.hidden = false;
358
+ let response;
359
+ try {
360
+ response = await api(`/source?file=${encodeURIComponent(file)}&line=${line}`);
361
+ } catch (error) {
362
+ return append(panel, "p", "server not reachable", "error");
363
+ }
364
+ const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
365
+ if (!response.ok) return append(panel, "p", body.error, "error");
366
+ const pre = append(panel, "pre", "");
367
+ body.text.split("\n").forEach((text, index) => {
368
+ append(pre, "div", `${String(index + 1).padStart(4)} ${text}`, index + 1 === line ? "line current" : "line");
369
+ });
370
+ pre.querySelector(".current")?.scrollIntoView({ block: "center" });
371
+ }
372
+
373
+ document.getElementById("card").addEventListener("click", (event) => {
374
+ const op = event.target.closest("li.op");
375
+ if (op) openSource(op.dataset.file, Number(op.dataset.line));
376
+ });
377
+
378
+ svg.addEventListener("mouseover", (event) => {
379
+ const hit = event.target.closest("path.hit");
380
+ if (!hit) return;
381
+ const arrow = currentArrows[Number(hit.dataset.index)];
382
+ if (!arrow) return;
383
+ popup.replaceChildren();
384
+ arrow.pairs.forEach((pair) => append(popup, "div", `${pair.from} -> ${pair.to} (${pair.kinds.join(", ")})`, pair.violating ? "pair violating" : "pair"));
385
+ popup.style.left = `${event.clientX + 12}px`;
386
+ popup.style.top = `${event.clientY + 12}px`;
387
+ popup.hidden = false;
388
+ });
389
+
390
+ svg.addEventListener("mouseout", (event) => {
391
+ if (event.target.closest("path.hit")) popup.hidden = true;
392
+ });
393
+
394
+ document.getElementById("regen").addEventListener("click", async () => {
395
+ try {
396
+ const response = await api("/scans", { method: "POST" });
397
+ if (response.status !== 202) toast(`rescan not started: HTTP ${response.status}`);
398
+ } catch (error) {
399
+ toast("rescan not started: server not reachable");
400
+ }
401
+ });
402
+
403
+ function openMenu(x, y) {
404
+ menu.classList.add("open");
405
+ const { width, height } = menu.getBoundingClientRect();
406
+ menu.style.left = `${Math.max(0, Math.min(x, innerWidth - width - 4))}px`;
407
+ menu.style.top = `${Math.max(0, Math.min(y, innerHeight - height - 4))}px`;
408
+ }
409
+
410
+ function onKey(event) {
411
+ if (event.key === "Escape" && menu.classList.contains("open")) {
412
+ menu.classList.remove("open");
413
+ return;
414
+ }
415
+ if (event.key === "Escape") {
416
+ const source = document.getElementById("source");
417
+ const card = document.getElementById("card");
418
+ if (!source.hidden) {
419
+ source.hidden = true;
420
+ return;
421
+ }
422
+ if (!card.hidden) {
423
+ card.hidden = true;
424
+ return;
425
+ }
426
+ }
427
+ if (event.key === "Escape" && drill.length > 1) {
428
+ openView(drill[drill.length - 2]);
429
+ return;
430
+ }
431
+ if (event.key === "d" && !event.ctrlKey && !event.metaKey && !event.altKey && !["INPUT", "SELECT", "TEXTAREA"].includes(document.activeElement?.tagName)) {
432
+ cycleDeclutter();
433
+ return;
434
+ }
435
+ if ((event.ctrlKey || event.metaKey) && ["=", "+", "-", "0"].includes(event.key)) {
436
+ event.preventDefault();
437
+ zoom = event.key === "0" ? 1 : event.key === "-" ? zoom / ZOOM_STEP : zoom * ZOOM_STEP;
438
+ applyZoom();
439
+ saveSession();
440
+ }
441
+ }
442
+
443
+ function onDoubleClick(event) {
444
+ const member = event.target.closest("text.member");
445
+ const box = event.target.closest("g.box");
446
+ if (member && BOXES.includes(member.dataset.kind)) return openView(member.dataset.id);
447
+ if (!member && box && BOXES.includes(box.dataset.type)) return openView(box.dataset.id);
448
+ if (member && member.dataset.kind === "class") return openCard(member.dataset.id);
449
+ if (!member && box && box.dataset.type === "class") return openCard(box.dataset.id);
450
+ }
451
+
452
+ svg.addEventListener("dblclick", (event) => onDoubleClick(event));
453
+ document.addEventListener("keydown", (event) => onKey(event));
454
+ crumbs.addEventListener("click", (event) => {
455
+ const crumb = event.target.closest("button.crumb");
456
+ if (crumb && !crumb.disabled) openView(crumb.dataset.id);
457
+ });
458
+ addEventListener("scroll", saveSession);
459
+
460
+ svg.addEventListener("contextmenu", (event) => {
461
+ const member = event.target.closest("text.member");
462
+ const box = event.target.closest("g.box");
463
+ if (!member && (!box || box.dataset.type === "foreign")) return;
464
+ event.preventDefault();
465
+ const type = member ? member.dataset.kind : box.dataset.type;
466
+ menuTarget = { id: member ? member.dataset.id : box.dataset.id, kind: type === "class" ? "class" : "box", proposal_id: proposalId };
467
+ openMenu(event.clientX, event.clientY);
468
+ });
469
+
470
+ async function sendCommand(body) {
471
+ let response;
472
+ try {
473
+ response = await api("/commands", { method: "POST", body: JSON.stringify(body) });
474
+ } catch (error) {
475
+ toast("command not sent: server not reachable");
476
+ return;
477
+ }
478
+ if (response.status === 201) return;
479
+ const reply = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
480
+ toast(response.status === 429 ? reply.error : `command not sent: ${reply.error}`);
481
+ }
482
+
483
+ menu.addEventListener("click", (event) => {
484
+ const op = event.target.dataset.op;
485
+ menu.classList.remove("open");
486
+ if (op && menuTarget) sendCommand({ op, target: menuTarget });
487
+ });
488
+
489
+ choice.addEventListener("change", () => {
490
+ proposalId = choice.value || null;
491
+ drill = ["root"];
492
+ pendingScroll = { x: 0, y: 0 };
493
+ render();
494
+ saveSession();
495
+ sendCommand({ op: "context", target: { proposal_id: proposalId } });
496
+ choice.blur();
497
+ });
498
+
499
+ document.getElementById("propose").addEventListener("click", () => sendCommand({ op: "propose" }));
500
+ declutterButton.addEventListener("click", cycleDeclutter);
501
+
502
+ document.addEventListener("click", (event) => { if (!menu.contains(event.target)) menu.classList.remove("open"); });
503
+
504
+ const restore = (session) => {
505
+ if (Array.isArray(session.drill) && session.drill.length) drill = session.drill;
506
+ if (typeof session.zoom === "number" && session.zoom > 0) zoom = session.zoom;
507
+ if (session.scroll) pendingScroll = session.scroll;
508
+ if (typeof session.proposal === "string") proposalId = session.proposal;
509
+ if (Number.isInteger(session.declutter) && session.declutter >= 0 && session.declutter < DECLUTTER.length) declutter = session.declutter;
510
+ };
511
+
512
+ async function start() {
513
+ if (!token) return expired();
514
+ try {
515
+ const response = await api("/session");
516
+ if (response.ok) restore(await response.json().catch(() => ({})));
517
+ } catch (error) {
518
+ banner.textContent = "server not reachable";
519
+ }
520
+ showDeclutter();
521
+ poll();
522
+ setInterval(poll, 1000);
523
+ }
524
+
525
+ start();
526
+ })();