@clazic/urban 0.2.4 → 0.2.6

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,521 @@
1
+ import Graph from "graphology";
2
+ import Sigma from "sigma";
3
+ import FA2Layout from "graphology-layout-forceatlas2/worker";
4
+ import { getJson, postJson } from "../api.js";
5
+ import { qs } from "../dom.js";
6
+
7
+ // ── Color tokens ───────────────────────────────────────────────────────────────
8
+ const TYPE_COLOR = { report: "#2563eb", topic: "#0f766e", org: "#c2410c" };
9
+ const CLUSTER_PALETTE = [
10
+ "#2563eb","#0f766e","#c2410c","#7c3aed",
11
+ "#0369a1","#065f46","#92400e","#86198f",
12
+ ];
13
+
14
+ // ── Module state ───────────────────────────────────────────────────────────────
15
+ let sig = null;
16
+ let graph = null;
17
+ let layout = null;
18
+ let selectedId = null;
19
+ let selNeighbors = null; // Set<id> of selectedId's neighbors
20
+ let egoSet = null; // Set<id> when ego mode active
21
+ let clusterMode = false;
22
+
23
+ // ── Graph building ─────────────────────────────────────────────────────────────
24
+ function buildGraph(data) {
25
+ const g = new Graph({ multi: false });
26
+ for (const n of (data.nodes ?? [])) {
27
+ const size = Math.max(5, Math.min(20, 5 + Math.sqrt(n.degree ?? 1) * 1.8));
28
+ const fullLabel = n.label ?? n.id;
29
+ const label = fullLabel.length > 24 ? fullLabel.slice(0, 23) + "…" : fullLabel;
30
+ g.addNode(n.id, {
31
+ label,
32
+ fullLabel,
33
+ nodeType: n.type ?? "other",
34
+ degree: n.degree ?? 0,
35
+ cluster: n.cluster_id ?? null,
36
+ size,
37
+ color: TYPE_COLOR[n.type] ?? "#64748b",
38
+ x: (Math.random() - 0.5) * 100,
39
+ y: (Math.random() - 0.5) * 100,
40
+ });
41
+ }
42
+ for (const e of (data.edges ?? [])) {
43
+ if (g.hasNode(e.source) && g.hasNode(e.target)) {
44
+ try { g.addEdgeWithKey(e.id ?? `${e.source}-${e.target}`, e.source, e.target, { weight: e.weight ?? 1 }); } catch {}
45
+ }
46
+ }
47
+ return g;
48
+ }
49
+
50
+ // ── Reducers ───────────────────────────────────────────────────────────────────
51
+ function nodeReducer(nodeId, data) {
52
+ const res = { ...data };
53
+ if (clusterMode && data.cluster != null)
54
+ res.color = CLUSTER_PALETTE[data.cluster % CLUSTER_PALETTE.length];
55
+ if (egoSet && !egoSet.has(nodeId)) { res.hidden = true; return res; }
56
+ if (selectedId && nodeId !== selectedId && !selNeighbors?.has(nodeId)) {
57
+ res.color = res.color + "40";
58
+ res.label = "";
59
+ }
60
+ return res;
61
+ }
62
+
63
+ function edgeReducer(edgeId, data) {
64
+ const res = { ...data };
65
+ const src = graph.source(edgeId);
66
+ const tgt = graph.target(edgeId);
67
+ if (egoSet && (!egoSet.has(src) || !egoSet.has(tgt))) { res.hidden = true; return res; }
68
+ if (selectedId) {
69
+ if (src !== selectedId && tgt !== selectedId) {
70
+ res.hidden = true;
71
+ } else {
72
+ res.color = "#2563eb";
73
+ res.size = 2;
74
+ }
75
+ }
76
+ return res;
77
+ }
78
+
79
+ // ── Sigma init ─────────────────────────────────────────────────────────────────
80
+ function initSigma() {
81
+ const container = qs("#graph-container");
82
+ if (!container || !graph) return;
83
+ sig = new Sigma(graph, container, {
84
+ nodeReducer,
85
+ edgeReducer,
86
+ renderEdgeLabels: false,
87
+ defaultEdgeColor: "rgba(100,100,120,0.45)",
88
+ defaultEdgeType: "line",
89
+ labelFont: '"Pretendard Variable", sans-serif',
90
+ labelSize: 12,
91
+ labelWeight: "400",
92
+ labelColor: { color: "rgba(15,23,42,0.85)" },
93
+ labelDensity: 0.07,
94
+ labelGridCellSize: 120,
95
+ labelRenderedSizeThreshold: 6,
96
+ minCameraRatio: 0.05,
97
+ maxCameraRatio: 10,
98
+ });
99
+ sig.on("afterRender", drawHulls);
100
+ }
101
+
102
+ // ── FA2 layout ─────────────────────────────────────────────────────────────────
103
+ function startLayout() {
104
+ if (!graph || !sig) return;
105
+ layout = new FA2Layout(graph, {
106
+ settings: {
107
+ barnesHutOptimize: true, barnesHutTheta: 0.5,
108
+ slowDown: 5, gravity: 0.05, scalingRatio: 20, linLogMode: true,
109
+ },
110
+ });
111
+ layout.start();
112
+ let stable = 0, snap = null;
113
+ const check = setInterval(() => {
114
+ if (!layout) { clearInterval(check); return; }
115
+ const s = JSON.stringify(
116
+ graph.nodes().slice(0, 5).map(n => {
117
+ const { x, y } = graph.getNodeAttributes(n);
118
+ return [Math.round(x * 10), Math.round(y * 10)];
119
+ })
120
+ );
121
+ if (s === snap) { if (++stable >= 3) { layout.stop(); clearInterval(check); fitAll(); } }
122
+ else { stable = 0; snap = s; }
123
+ }, 500);
124
+ setTimeout(() => { layout?.stop(); clearInterval(check); fitAll(); }, 8000);
125
+ }
126
+
127
+ // ── BFS ────────────────────────────────────────────────────────────────────────
128
+ function bfsHops(startId, maxHops) {
129
+ const visited = new Set([startId]);
130
+ let front = [startId];
131
+ for (let h = 0; h < maxHops; h++) {
132
+ const next = [];
133
+ for (const id of front)
134
+ for (const nb of graph.neighbors(id))
135
+ if (!visited.has(nb)) { visited.add(nb); next.push(nb); }
136
+ front = next;
137
+ if (!front.length) break;
138
+ }
139
+ return visited;
140
+ }
141
+
142
+ // ── Camera ─────────────────────────────────────────────────────────────────────
143
+ function fitToNodes(nodeIds) {
144
+ if (!sig || !nodeIds?.size) return;
145
+
146
+ // sigma 정규화 공식 기준: normX = (x-minX)/ratio, normY = 1-(y-minY)/ratio
147
+ // 전체 그래프 범위로 normRatio 계산
148
+ let fmnX = Infinity, fmxX = -Infinity, fmnY = Infinity, fmxY = -Infinity;
149
+ graph.forEachNode((id, attrs) => {
150
+ if (attrs.x < fmnX) fmnX = attrs.x; if (attrs.x > fmxX) fmxX = attrs.x;
151
+ if (attrs.y < fmnY) fmnY = attrs.y; if (attrs.y > fmxY) fmxY = attrs.y;
152
+ });
153
+ const normRatio = Math.max(fmxX - fmnX, fmxY - fmnY, 1);
154
+
155
+ // 서브셋 노드를 sigma 정규화 공간으로 변환 후 바운딩 박스 계산
156
+ let mnX = Infinity, mxX = -Infinity, mnY = Infinity, mxY = -Infinity;
157
+ for (const id of nodeIds) {
158
+ const { x, y } = graph.getNodeAttributes(id);
159
+ const nx = (x - fmnX) / normRatio;
160
+ const ny = 1 - (y - fmnY) / normRatio;
161
+ if (nx < mnX) mnX = nx; if (nx > mxX) mxX = nx;
162
+ if (ny < mnY) mnY = ny; if (ny > mxY) mxY = ny;
163
+ }
164
+
165
+ const cx = (mnX + mxX) / 2;
166
+ const cy = (mnY + mxY) / 2;
167
+ const span = Math.max(mxX - mnX, mxY - mnY, 0.01);
168
+ sig.getCamera().animate({ x: cx, y: cy, ratio: Math.max(0.05, span / 0.7) }, { duration: 500 });
169
+ }
170
+
171
+ function fitAll() {
172
+ if (!sig || !graph) return;
173
+ fitToNodes(new Set(graph.nodes()));
174
+ }
175
+
176
+ // ── Tooltip ────────────────────────────────────────────────────────────────────
177
+ function bindTooltip() {
178
+ const tip = qs("#graph-tooltip");
179
+ if (!tip || !sig) return;
180
+ sig.on("enterNode", ({ node }) => {
181
+ const attrs = graph.getNodeAttributes(node);
182
+ const vp = sig.graphToViewport(attrs);
183
+ tip.innerHTML = `
184
+ <strong class="graph-tooltip-title">${attrs.label}</strong>
185
+ <span class="graph-tooltip-meta">${attrs.nodeType} · 연결 ${graph.degree(node)}개${
186
+ attrs.cluster != null ? ` · 클러스터 ${attrs.cluster}` : ""
187
+ }</span>`;
188
+ tip.style.cssText = `display:block;left:${vp.x + 14}px;top:${vp.y + 14}px`;
189
+ });
190
+ sig.on("leaveNode", () => { tip.style.display = "none"; });
191
+ }
192
+
193
+ // ── Drag ───────────────────────────────────────────────────────────────────────
194
+ let _dragNode = null;
195
+ let _wasDragged = false;
196
+ let _dragGroup = null; // Set<nodeId> — 드래그 노드 + 직접 연결된 이웃
197
+ let _prevDragPos = null; // { x, y } graph 좌표 — 전 프레임 마우스 위치
198
+
199
+ function bindDrag() {
200
+ if (!sig || !graph) return;
201
+ const container = qs("#graph-container");
202
+
203
+ sig.on("downNode", ({ node }) => {
204
+ _dragNode = node;
205
+ _wasDragged = false;
206
+ _prevDragPos = null;
207
+
208
+ // 드래그 노드 + 직접 연결된 모든 이웃을 그룹으로 묶음
209
+ _dragGroup = new Set([node, ...graph.neighbors(node)]);
210
+ for (const id of _dragGroup) graph.setNodeAttribute(id, "fixed", true);
211
+
212
+ if (container) container.style.cursor = "grabbing";
213
+ });
214
+
215
+ sig.getMouseCaptor().on("mousemovebody", (e) => {
216
+ if (!_dragNode || !_dragGroup) return;
217
+ _wasDragged = true;
218
+
219
+ const pos = sig.viewportToGraph(e);
220
+
221
+ if (_prevDragPos) {
222
+ const dx = pos.x - _prevDragPos.x;
223
+ const dy = pos.y - _prevDragPos.y;
224
+ for (const id of _dragGroup) {
225
+ graph.setNodeAttribute(id, "x", graph.getNodeAttribute(id, "x") + dx);
226
+ graph.setNodeAttribute(id, "y", graph.getNodeAttribute(id, "y") + dy);
227
+ }
228
+ }
229
+
230
+ _prevDragPos = pos;
231
+ e.preventSigmaDefault();
232
+ });
233
+
234
+ sig.getMouseCaptor().on("mouseup", () => {
235
+ if (_dragGroup) {
236
+ for (const id of _dragGroup) graph.setNodeAttribute(id, "fixed", false);
237
+ }
238
+ _dragNode = null; _dragGroup = null; _prevDragPos = null;
239
+ if (container) container.style.cursor = "";
240
+ });
241
+
242
+ sig.on("enterNode", () => { if (!_dragNode && container) container.style.cursor = "grab"; });
243
+ sig.on("leaveNode", () => { if (!_dragNode && container) container.style.cursor = ""; });
244
+ }
245
+
246
+ // ── Node events ────────────────────────────────────────────────────────────────
247
+ function bindNodeEvents() {
248
+ if (!sig) return;
249
+ sig.on("clickNode", ({ node }) => {
250
+ if (_wasDragged) { _wasDragged = false; return; }
251
+ selectedId = selectedId === node ? null : node;
252
+ selNeighbors = selectedId ? new Set(graph.neighbors(selectedId)) : null;
253
+ updateInspector(selectedId ? graph.getNodeAttributes(selectedId) : null, selectedId);
254
+ const eg = qs("#graph-ego-group");
255
+ if (eg) eg.hidden = !selectedId;
256
+ sig.refresh();
257
+ });
258
+ sig.on("clickStage", () => {
259
+ selectedId = null; selNeighbors = null; egoSet = null;
260
+ updateInspector(null, null);
261
+ const eg = qs("#graph-ego-group");
262
+ if (eg) eg.hidden = true;
263
+ sig.refresh();
264
+ });
265
+ sig.on("doubleClickNode", ({ node, event }) => {
266
+ event.preventSigmaDefault();
267
+ fitToNodes(bfsHops(node, 1));
268
+ });
269
+ }
270
+
271
+ // ── Convex hull ────────────────────────────────────────────────────────────────
272
+ function convexHull(pts) {
273
+ if (pts.length < 3) return pts;
274
+ const s = [...pts].sort((a, b) => a.x - b.x || a.y - b.y);
275
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
276
+ const lo = [], hi = [];
277
+ for (const p of s) {
278
+ while (lo.length >= 2 && cross(lo.at(-2), lo.at(-1), p) <= 0) lo.pop();
279
+ lo.push(p);
280
+ }
281
+ for (let i = s.length - 1; i >= 0; i--) {
282
+ const p = s[i];
283
+ while (hi.length >= 2 && cross(hi.at(-2), hi.at(-1), p) <= 0) hi.pop();
284
+ hi.push(p);
285
+ }
286
+ hi.pop(); lo.pop();
287
+ return lo.concat(hi);
288
+ }
289
+
290
+ function drawHulls() {
291
+ const hc = qs("#graph-hull-canvas");
292
+ const wrap = qs("#graph-canvas");
293
+ if (!hc || !wrap) return;
294
+ hc.width = wrap.clientWidth;
295
+ hc.height = wrap.clientHeight;
296
+ const ctx = hc.getContext("2d");
297
+ ctx.clearRect(0, 0, hc.width, hc.height);
298
+ if (!clusterMode || !sig || !graph) return;
299
+
300
+ const clusters = new Map();
301
+ graph.forEachNode((id, attrs) => {
302
+ if (attrs.cluster == null || (egoSet && !egoSet.has(id))) return;
303
+ if (!clusters.has(attrs.cluster)) clusters.set(attrs.cluster, []);
304
+ clusters.get(attrs.cluster).push(id);
305
+ });
306
+ for (const [cid, ids] of clusters) {
307
+ if (ids.length < 3) continue;
308
+ const pts = ids.map(id => sig.graphToViewport(graph.getNodeAttributes(id)));
309
+ const hull = convexHull(pts);
310
+ if (!hull.length) continue;
311
+ const color = CLUSTER_PALETTE[cid % CLUSTER_PALETTE.length];
312
+ ctx.beginPath();
313
+ ctx.moveTo(hull[0].x, hull[0].y);
314
+ for (let i = 1; i < hull.length; i++) ctx.lineTo(hull[i].x, hull[i].y);
315
+ ctx.closePath();
316
+ ctx.fillStyle = color + "14";
317
+ ctx.strokeStyle = color + "50";
318
+ ctx.lineWidth = 1.5;
319
+ ctx.fill();
320
+ ctx.stroke();
321
+ }
322
+ }
323
+
324
+ // ── Inspector ──────────────────────────────────────────────────────────────────
325
+ function updateInspector(attrs, nodeId) {
326
+ const el = qs("#graph-inspector");
327
+ const fc = qs("#graph-focus-count");
328
+ if (fc) fc.textContent = attrs ? "1" : "-";
329
+ if (!el) return;
330
+ if (!attrs) { el.textContent = "노드를 선택하면 상세 정보가 표시됩니다."; return; }
331
+ let link = "";
332
+ if (attrs.nodeType === "report") {
333
+ link = `<a class="graph-inspector-link" href="#" data-nav="/catalog" data-focus="${nodeId}">보고서 보기 →</a>`;
334
+ } else if (attrs.nodeType === "topic") {
335
+ link = `<a class="graph-inspector-link" href="#" data-nav="/wiki" data-hash="${encodeURIComponent(attrs.fullLabel ?? attrs.label)}">위키 보기 →</a>`;
336
+ }
337
+ el.innerHTML = `
338
+ <div class="graph-inspector-title">${attrs.fullLabel ?? attrs.label}</div>
339
+ <div class="graph-inspector-meta">${attrs.nodeType} · 연결 ${graph.degree(nodeId)}개</div>
340
+ ${attrs.cluster != null ? `<div class="graph-inspector-meta">클러스터 ${attrs.cluster}</div>` : ""}
341
+ ${link}
342
+ `;
343
+ el.querySelectorAll("[data-nav]").forEach(a => {
344
+ a.addEventListener("click", e => {
345
+ e.preventDefault();
346
+ const route = a.dataset.nav;
347
+ const focus = a.dataset.focus;
348
+ const hash = a.dataset.hash;
349
+ if (focus) window.app?.navigateTo(`${route}?focus=${focus}`);
350
+ else if (hash) window.app?.navigateTo(`/wiki#${decodeURIComponent(hash)}`);
351
+ else window.app?.navigateTo(route);
352
+ });
353
+ });
354
+ }
355
+
356
+ // ── Search ─────────────────────────────────────────────────────────────────────
357
+ function bindSearch() {
358
+ const input = qs("#graph-search");
359
+ const box = qs("#graph-search-results");
360
+ if (!input || !box) return;
361
+
362
+ input.addEventListener("input", () => {
363
+ const q = input.value.trim().toLowerCase();
364
+ box.innerHTML = "";
365
+ if (!q || !graph) { box.hidden = true; return; }
366
+ const hits = [];
367
+ graph.forEachNode((id, attrs) => {
368
+ const searchTarget = (attrs.fullLabel ?? attrs.label).toLowerCase();
369
+ if (searchTarget.includes(q)) hits.push({ id, attrs });
370
+ });
371
+ hits.sort((a, b) => (a.attrs.fullLabel ?? a.attrs.label).localeCompare(b.attrs.fullLabel ?? b.attrs.label, "ko"));
372
+ hits.slice(0, 8).forEach(({ id, attrs }) => {
373
+ const btn = document.createElement("button");
374
+ btn.className = "graph-search-item";
375
+ const displayLabel = attrs.fullLabel ?? attrs.label;
376
+ btn.innerHTML = `<span class="gsi-type" data-type="${attrs.nodeType}">${attrs.nodeType[0].toUpperCase()}</span><span>${displayLabel}</span>`;
377
+ btn.addEventListener("click", () => {
378
+ box.hidden = true; input.value = "";
379
+ selectedId = id;
380
+ selNeighbors = new Set(graph.neighbors(id));
381
+ updateInspector(attrs, id);
382
+ const eg = qs("#graph-ego-group");
383
+ if (eg) eg.hidden = false;
384
+ sig?.refresh();
385
+ fitToNodes(bfsHops(id, 1));
386
+ });
387
+ box.appendChild(btn);
388
+ });
389
+ box.hidden = !hits.length;
390
+ });
391
+
392
+ document.addEventListener("click", e => {
393
+ if (!input.contains(e.target) && !box.contains(e.target)) box.hidden = true;
394
+ });
395
+ }
396
+
397
+ // ── Stats ──────────────────────────────────────────────────────────────────────
398
+ function updateStats(data) {
399
+ const set = (id, v) => { const el = qs(id); if (el) el.textContent = v; };
400
+ set("#graph-node-count", (data.nodes?.length ?? data.stats?.nodeCount ?? 0).toLocaleString("ko-KR"));
401
+ set("#graph-edge-count", (data.edges?.length ?? data.stats?.edgeCount ?? 0).toLocaleString("ko-KR"));
402
+ set("#graph-cluster-count", new Set((data.nodes ?? []).map(n => n.cluster_id).filter(v => v != null)).size || "-");
403
+ }
404
+
405
+ // ── Load ───────────────────────────────────────────────────────────────────────
406
+ async function loadGraph() {
407
+ const empty = qs("#graph-empty");
408
+ if (empty) { empty.hidden = false; empty.querySelector("span").textContent = "그래프 로드 중..."; }
409
+
410
+ layout?.stop(); layout?.kill(); layout = null;
411
+ sig?.kill(); sig = null;
412
+ graph = null; selectedId = null; selNeighbors = null; egoSet = null;
413
+
414
+ const params = new URLSearchParams({ compact: "true", top: qs("#graph-top-n")?.value ?? "100" });
415
+ const type = qs("#graph-type-filter")?.value;
416
+ if (type) params.set("type", type);
417
+
418
+ const data = await getJson(`/api/graph?${params}`);
419
+ graph = buildGraph(data);
420
+ initSigma();
421
+ if (!sig) return;
422
+
423
+ bindTooltip();
424
+ bindNodeEvents();
425
+ bindDrag();
426
+ startLayout();
427
+ updateStats(data);
428
+ updateInspector(null, null);
429
+ if (qs("#graph-top-n-val")) qs("#graph-top-n-val").textContent = qs("#graph-top-n").value;
430
+ if (empty) empty.hidden = true;
431
+ }
432
+
433
+ // ── Fullscreen ─────────────────────────────────────────────────────────────────
434
+ function bindFullscreen() {
435
+ const overlay = qs("#graph-fullscreen-overlay");
436
+ const canvasEl = qs("#graph-canvas");
437
+ let origParent = null;
438
+
439
+ const enter = () => {
440
+ if (!overlay || !canvasEl || !overlay.hidden) return;
441
+ origParent = canvasEl.parentElement;
442
+ overlay.appendChild(canvasEl);
443
+ overlay.hidden = false;
444
+ document.body.style.overflow = "hidden";
445
+ setTimeout(() => { sig?.refresh(); drawHulls(); }, 60);
446
+ };
447
+
448
+ const exit = () => {
449
+ if (!overlay || !canvasEl || overlay.hidden) return;
450
+ origParent?.appendChild(canvasEl);
451
+ overlay.hidden = true;
452
+ document.body.style.overflow = "";
453
+ setTimeout(() => { sig?.refresh(); drawHulls(); }, 60);
454
+ };
455
+
456
+ qs("#graph-fullscreen")?.addEventListener("click", enter);
457
+ qs("#graph-fullscreen-close")?.addEventListener("click", exit);
458
+ document.addEventListener("keydown", e => { if (e.key === "Escape" && !overlay?.hidden) exit(); });
459
+ }
460
+
461
+ // ── Controls ───────────────────────────────────────────────────────────────────
462
+ function bindControls() {
463
+ qs("#graph-refresh")?.addEventListener("click", loadGraph);
464
+ qs("#graph-report")?.addEventListener("click", () => postJson("/api/graph/report"));
465
+ qs("#graph-type-filter")?.addEventListener("change", loadGraph);
466
+ qs("#graph-top-n")?.addEventListener("change", loadGraph);
467
+ qs("#graph-top-n")?.addEventListener("input", () => {
468
+ const v = qs("#graph-top-n-val");
469
+ if (v) v.textContent = qs("#graph-top-n").value;
470
+ });
471
+
472
+ document.querySelectorAll("[data-zoom]").forEach(btn => {
473
+ btn.addEventListener("click", () => {
474
+ if (!sig) return;
475
+ const cam = sig.getCamera();
476
+ if (btn.dataset.zoom === "reset") { fitAll(); return; }
477
+ const f = btn.dataset.zoom === "in" ? 0.7 : 1.4;
478
+ cam.animate({ ratio: cam.ratio * f }, { duration: 200 });
479
+ });
480
+ });
481
+
482
+ qs("#graph-cluster-mode")?.addEventListener("change", e => {
483
+ clusterMode = e.target.checked;
484
+ sig?.refresh();
485
+ });
486
+
487
+ qs("#graph-ego-1")?.addEventListener("click", () => {
488
+ if (!selectedId || !sig) return;
489
+ egoSet = bfsHops(selectedId, 1);
490
+ sig.refresh();
491
+ fitToNodes(egoSet);
492
+ });
493
+ qs("#graph-ego-2")?.addEventListener("click", () => {
494
+ if (!selectedId || !sig) return;
495
+ egoSet = bfsHops(selectedId, 2);
496
+ sig.refresh();
497
+ fitToNodes(egoSet);
498
+ });
499
+ qs("#graph-ego-restore")?.addEventListener("click", () => {
500
+ egoSet = null;
501
+ sig?.refresh();
502
+ });
503
+ }
504
+
505
+ // ── Mount / Unmount ────────────────────────────────────────────────────────────
506
+ export async function mount() {
507
+ selectedId = null; selNeighbors = null; egoSet = null; clusterMode = false;
508
+ bindControls();
509
+ bindSearch();
510
+ bindFullscreen();
511
+ await loadGraph();
512
+ }
513
+
514
+ export async function unmount() {
515
+ layout?.stop(); layout?.kill(); layout = null;
516
+ sig?.kill(); sig = null;
517
+ graph = null;
518
+ selectedId = null; selNeighbors = null; egoSet = null;
519
+ }
520
+
521
+ export default { mount, unmount };