pathnote 0.0.0-stage → 0.2.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,407 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Pathnote</title>
7
+ <style>
8
+ :root {
9
+ --bg: #ffffff; --surface: #f5f5f7; --ink: #1d1d1f; --muted: #6e6e73; --line: #d2d2d7; --node: #c7c7cc;
10
+ --accent: #0066cc; --accent-soft: rgba(0, 102, 204, 0.12); --warn: #b3261e;
11
+ --font: "SF Pro Text", system-ui, -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", Inter, sans-serif;
12
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
13
+ color-scheme: light;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root { --bg: #000; --surface: #1d1d1f; --ink: #f5f5f7; --muted: #a1a1a6; --line: #3a3a3c; --node: #48484a;
17
+ --accent: #2997ff; --accent-soft: rgba(41, 151, 255, 0.16); --warn: #ff6b5b; color-scheme: dark; }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ [hidden] { display: none !important; }
21
+ html, body { height: 100%; margin: 0; }
22
+ body { background: var(--bg); color: var(--ink); font: 400 14px/1.43 var(--font); display: flex; flex-direction: column; }
23
+ .bar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
24
+ .bar strong { font-weight: 600; }
25
+ .vault { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; }
26
+ .chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 10px; border-radius: 9999px; background: var(--surface); color: var(--muted); }
27
+ .chip i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); }
28
+ .chip.on { background: var(--accent-soft); color: var(--accent); }
29
+ .chip.on i { background: var(--accent); }
30
+ .main { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 340px; }
31
+ .graph { position: relative; min-width: 0; min-height: 0; border-right: 1px solid var(--line); }
32
+ svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
33
+ svg.drag { cursor: grabbing; }
34
+ .note { position: absolute; left: 12px; right: 12px; bottom: 12px; padding: 10px 14px; border-radius: 11px; background: var(--surface); color: var(--muted); font-size: 12px; }
35
+ .note b { color: var(--ink); font-weight: 600; }
36
+ .empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; pointer-events: none; }
37
+ .log { display: flex; flex-direction: column; min-height: 0; }
38
+ .log h2 { font-size: 14px; font-weight: 600; margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
39
+ ol { list-style: none; margin: 0; padding: 8px 0; flex: 1; min-height: 0; overflow-y: auto; }
40
+ li { padding: 8px 16px; display: grid; grid-template-columns: 1fr auto; gap: 2px 8px; }
41
+ li.now { background: var(--accent-soft); }
42
+ .tool { font: 500 12px var(--mono); color: var(--accent); }
43
+ .undo .tool { color: var(--muted); }
44
+ time { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
45
+ .text { grid-column: 1 / -1; overflow-wrap: anywhere; }
46
+ .why { grid-column: 1 / -1; color: var(--muted); }
47
+ .err { grid-column: 1 / -1; color: var(--warn); font-size: 12px; }
48
+ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
49
+ .btn { font: 400 13px/1 var(--font); min-height: 30px; padding: 0 14px; border-radius: 9999px; border: 1px solid var(--accent); background: transparent; color: var(--accent); cursor: pointer; }
50
+ .btn:active { transform: scale(0.95); }
51
+ .btn:focus-visible { outline: 2px solid #0071e3; outline-offset: 2px; }
52
+ .btn:disabled { opacity: .4; cursor: default; }
53
+ .hint { padding: 16px; color: var(--muted); }
54
+
55
+ /* --k = user units per screen pixel, so dots, labels and lines keep their on-screen size while zooming */
56
+ svg { --k: 1; }
57
+ line { vector-effect: non-scaling-stroke; }
58
+ .tree { stroke: var(--line); stroke-width: 1; }
59
+ .link { stroke: var(--accent); stroke-width: 1; stroke-dasharray: 3 4; opacity: .35; }
60
+ .trail { stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; transition: stroke-dashoffset .6s ease-in-out, opacity .4s; }
61
+ .src { stroke: var(--accent); stroke-width: 1.5; opacity: .6; }
62
+ .node .dot { fill: var(--node); transition: fill .3s; r: calc(var(--k) * 3.5px); }
63
+ .node.dir .dot { r: calc(var(--k) * 5px); }
64
+ .node.root .dot { r: calc(var(--k) * 8px); fill: var(--ink); }
65
+ .node text { font-family: var(--font); font-size: calc(var(--k) * 11px); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: calc(var(--k) * 4px); stroke-linejoin: round; pointer-events: none; display: none; }
66
+ .node.root text { font-size: calc(var(--k) * 13px); font-weight: 600; fill: var(--ink); }
67
+ .node.root text, .node.top text, .node.hit text, .node.visited text, .node.written text, svg.near .node.dir text, svg.close .node text { display: inline; }
68
+ .node .ring { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; opacity: 0; r: calc(var(--k) * 11px); vector-effect: non-scaling-stroke; }
69
+ .node .pulse { r: calc(var(--k) * 6px); }
70
+ .node.hit .ring { opacity: 1; }
71
+ .node.visited .dot, .node.written .dot { fill: var(--accent); }
72
+ .node.visited text, .node.written text { fill: var(--ink); font-weight: 600; }
73
+ .node.current .pulse { fill: var(--accent); animation: pulse 1.2s ease-out infinite; transform-box: fill-box; transform-origin: center; }
74
+ .node .pulse { opacity: 0; }
75
+ .node.gone { opacity: .25; }
76
+ .node.gone text { text-decoration: line-through; }
77
+ @keyframes pulse { from { transform: scale(1); opacity: .35; } to { transform: scale(3.2); opacity: 0; } }
78
+ @media (max-width: 760px) {
79
+ .main { grid-template-columns: 1fr; grid-template-rows: 55% 45%; }
80
+ .graph { border-right: 0; border-bottom: 1px solid var(--line); }
81
+ }
82
+ @media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
83
+ </style>
84
+ </head>
85
+ <body>
86
+ <div class="bar">
87
+ <strong>Pathnote</strong>
88
+ <span class="vault" id="roots"></span>
89
+ <span class="chip" id="status"><i></i><span id="statusText"></span></span>
90
+ </div>
91
+ <div class="main">
92
+ <div class="graph">
93
+ <svg id="g" role="img"></svg>
94
+ <div class="empty" id="empty" hidden></div>
95
+ <p class="note" id="note" hidden></p>
96
+ </div>
97
+ <div class="log">
98
+ <h2><span id="logTitle"></span><button class="btn" id="clear" type="button"></button></h2>
99
+ <ol id="log" aria-live="polite"></ol>
100
+ <p class="hint" id="hint"></p>
101
+ </div>
102
+ </div>
103
+ <script>
104
+ const ko = (navigator.language || '').toLowerCase().startsWith('ko');
105
+ const L = ko ? {
106
+ live: '연결됨', off: '연결 끊김', activity: '작업 기록', clear: '경로 지우기', undo: '되돌리기', undone: '되돌림',
107
+ wait: 'AI가 파일을 찾거나 읽으면 여기에 기록이 쌓입니다.', empty: '연결된 폴더가 비어 있습니다.',
108
+ hits: n => `${n}개 찾음`, created: '새 파일', graph: '연결된 폴더 구조와 AI가 지나간 경로',
109
+ capped: n => `파일이 많아 ${n}개까지만 그렸습니다.`, folders: n => `폴더 ${n}개`,
110
+ blocked: 'macOS가 막고 있어 비어 보이는 폴더: ', allow: ' 시스템 설정 → 개인정보 보호 및 보안 → 파일 및 폴더에서 Pathnote를 실행하는 앱(Claude 등)에 허용한 뒤 새로고침하세요.'
111
+ } : {
112
+ live: 'Live', off: 'Disconnected', activity: 'Activity', clear: 'Clear path', undo: 'Undo', undone: 'undone',
113
+ wait: 'When the AI looks through or reads your files, it shows up here.', empty: 'The connected folders are empty.',
114
+ hits: n => `${n} found`, created: 'new file', graph: 'Connected folders and the path the AI took',
115
+ capped: n => `Too many files; drew the first ${n}.`, folders: n => `${n} folders`,
116
+ blocked: 'macOS is blocking these folders, so they look empty: ', allow: ' Allow them in System Settings → Privacy & Security → Files and Folders for the app that runs Pathnote (e.g. Claude), then reload.'
117
+ };
118
+ document.documentElement.lang = ko ? 'ko' : 'en';
119
+ document.getElementById('logTitle').textContent = L.activity;
120
+ document.getElementById('clear').textContent = L.clear;
121
+ document.getElementById('hint').textContent = L.wait;
122
+
123
+ const NS = 'http://www.w3.org/2000/svg';
124
+ const svg = document.getElementById('g');
125
+ svg.setAttribute('aria-label', L.graph);
126
+ const view = el('g'), gTree = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
127
+ view.append(gTree, gLinks, gTrail, gNodes);
128
+ svg.append(view);
129
+ const pos = new Map(), nodeEl = new Map(), angleOf = new Map(), depthOf = new Map();
130
+ const RING = 160;
131
+ // connected folders sit on a wide first ring so their names don't crowd the middle
132
+ const radius = depth => (depth + 1.5) * RING;
133
+ let current = null, sources = new Set(), vb = { x: -500, y: -500, w: 1000, h: 1000 };
134
+
135
+ function el(tag, attrs = {}) {
136
+ const e = document.createElementNS(NS, tag);
137
+ for (const k in attrs) e.setAttribute(k, attrs[k]);
138
+ return e;
139
+ }
140
+ const base = p => p.split('/').filter(Boolean).pop() || p;
141
+ const parentOf = p => p.slice(0, p.lastIndexOf('/')) || '/';
142
+
143
+ // Radial tree: each folder gets a slice of the circle sized by how many files it holds,
144
+ // and sits one ring further out than its parent
145
+ function layout(items) {
146
+ const kids = new Map([['', []]]);
147
+ for (const it of items) {
148
+ const p = it.kind === 'root' ? '' : it.parent;
149
+ if (!kids.has(p)) kids.set(p, []);
150
+ kids.get(p).push(it.id);
151
+ }
152
+ const leaves = new Map();
153
+ const count = id => {
154
+ const c = kids.get(id) || [];
155
+ const n = c.length ? c.reduce((s, k) => s + count(k), 0) : 1;
156
+ leaves.set(id, n);
157
+ return n;
158
+ };
159
+ count('');
160
+ // a connected folder with few files still gets a readable share of the circle
161
+ const total = leaves.get('');
162
+ for (const r of kids.get('')) leaves.set(r, Math.max(leaves.get(r), total * 0.1));
163
+ leaves.set('', kids.get('').reduce((s, r) => s + leaves.get(r), 0));
164
+ const place = (id, a0, a1, depth) => {
165
+ const a = (a0 + a1) / 2;
166
+ if (id) {
167
+ pos.set(id, [Math.cos(a) * radius(depth), Math.sin(a) * radius(depth)]);
168
+ angleOf.set(id, a); depthOf.set(id, depth);
169
+ }
170
+ let at = a0;
171
+ const sum = (kids.get(id) || []).reduce((s, k) => s + leaves.get(k), 0);
172
+ for (const k of kids.get(id) || []) {
173
+ const span = (a1 - a0) * leaves.get(k) / sum;
174
+ place(k, at, at + span, depth + 1);
175
+ at += span;
176
+ }
177
+ };
178
+ place('', -Math.PI / 2, Math.PI * 1.5, 0);
179
+ }
180
+
181
+ function setView() {
182
+ svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
183
+ const k = vb.w / Math.max(1, svg.clientWidth);
184
+ svg.style.setProperty('--k', k);
185
+ svg.classList.toggle('near', k < 1.2);
186
+ svg.classList.toggle('close', k < 0.5);
187
+ }
188
+
189
+ function fit() {
190
+ const pts = [...pos.values()];
191
+ if (!pts.length) return setView();
192
+ const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
193
+ const pad = 80, x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad;
194
+ const w = Math.max(...xs) + pad - x0, h = Math.max(...ys) + pad - y0;
195
+ // keep the drawing's aspect so it isn't squashed in a wide panel
196
+ const r = svg.clientWidth / Math.max(1, svg.clientHeight);
197
+ // leave room for file names, which run sideways past the outermost dots
198
+ const label = 220 * Math.max(w, h * r) / Math.max(1, svg.clientWidth);
199
+ const size = Math.max(w + 2 * label, h * r);
200
+ vb = { x: x0 + w / 2 - size / 2, y: y0 + h / 2 - size / r / 2, w: size, h: size / r };
201
+ setView();
202
+ }
203
+
204
+ function addNode(id, kind, top) {
205
+ const [x, y] = pos.get(id);
206
+ const g = el('g', { class: `node ${kind}${top ? ' top' : ''}` });
207
+ g.append(el('circle', { class: 'pulse', cx: x, cy: y }), el('circle', { class: 'ring', cx: x, cy: y }), el('circle', { class: 'dot', cx: x, cy: y }));
208
+ const a = angleOf.get(id) ?? 0, right = Math.cos(a) >= 0;
209
+ const t = el('text', { x, y, dx: right ? '0.9em' : '-0.9em', dy: '0.35em', 'text-anchor': right ? 'start' : 'end' });
210
+ t.textContent = base(id);
211
+ const title = el('title'); title.textContent = id;
212
+ g.append(t, title);
213
+ gNodes.append(g);
214
+ nodeEl.set(id, g);
215
+ }
216
+
217
+ function treeLine(a, b) {
218
+ const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
219
+ gTree.append(el('line', { class: 'tree', x1, y1, x2, y2 }));
220
+ }
221
+
222
+ async function loadGraph() {
223
+ const g = await (await fetch('/api/graph')).json();
224
+ document.getElementById('roots').textContent = g.roots.map(base).join(' · ');
225
+ document.getElementById('roots').title = g.roots.join('\n');
226
+ const empty = document.getElementById('empty');
227
+ empty.hidden = g.items.some(i => i.kind !== 'root') && !g.capped;
228
+ empty.textContent = g.capped ? L.capped(g.items.length) : L.empty;
229
+ const note = document.getElementById('note');
230
+ note.hidden = !g.blocked?.length;
231
+ if (g.blocked?.length) {
232
+ note.textContent = '';
233
+ const b = document.createElement('b'); b.textContent = L.blocked + g.blocked.map(base).join(', ') + '.';
234
+ note.append(b, L.allow);
235
+ }
236
+ layout(g.items);
237
+ const isRoot = new Set(g.roots);
238
+ for (const it of g.items) if (it.parent && pos.has(it.parent)) treeLine(it.parent, it.id);
239
+ for (const it of g.items) addNode(it.id, it.kind, it.kind === 'dir' && isRoot.has(it.parent));
240
+ for (const [a, b] of g.links) {
241
+ const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
242
+ gLinks.append(el('line', { class: 'link', x1, y1, x2, y2 }));
243
+ }
244
+ fit();
245
+ }
246
+
247
+ function line(cls, a, b, animate) {
248
+ const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
249
+ const l = el('line', { class: cls, x1, y1, x2, y2 });
250
+ l.dataset.to = b;
251
+ if (animate) {
252
+ const len = Math.hypot(x2 - x1, y2 - y1);
253
+ l.style.strokeDasharray = len; l.style.strokeDashoffset = len;
254
+ gTrail.append(l); l.getBoundingClientRect(); l.style.strokeDashoffset = 0;
255
+ } else gTrail.append(l);
256
+ }
257
+
258
+ function setCurrent(id) {
259
+ if (current) nodeEl.get(current)?.classList.remove('current');
260
+ current = id;
261
+ nodeEl.get(id)?.classList.add('current');
262
+ }
263
+
264
+ // A file the AI created: put it one ring outside its folder
265
+ function ensureNode(id, kind = 'file') {
266
+ if (pos.has(id)) return;
267
+ const parent = parentOf(id);
268
+ if (pos.has(parent)) {
269
+ const a = (angleOf.get(parent) ?? 0) + (Math.random() - 0.5) * 0.08, d = (depthOf.get(parent) ?? 1) + 1;
270
+ pos.set(id, [Math.cos(a) * radius(d), Math.sin(a) * radius(d)]); angleOf.set(id, a); depthOf.set(id, d);
271
+ treeLine(parent, id);
272
+ } else {
273
+ pos.set(id, [vb.x + vb.w / 2, vb.y + vb.h / 2]);
274
+ }
275
+ addNode(id, kind);
276
+ document.getElementById('empty').hidden = true;
277
+ }
278
+
279
+ // Move along the path to a node: line from the last place the AI was
280
+ function visit(id, live) {
281
+ if (!nodeEl.has(id)) return;
282
+ if (current && current !== id) line('trail', current, id, live);
283
+ const g = nodeEl.get(id);
284
+ g.classList.remove('hit'); g.classList.add('visited');
285
+ setCurrent(id);
286
+ }
287
+
288
+ const logEl = document.getElementById('log');
289
+ function addLog(e, tool, text, live) {
290
+ document.getElementById('hint').hidden = true;
291
+ for (const li of logEl.children) li.classList.remove('now');
292
+ const li = document.createElement('li');
293
+ if (live) li.classList.add('now');
294
+ if (e.type === 'undo') li.classList.add('undo');
295
+ const a = document.createElement('span'); a.className = 'tool'; a.textContent = tool;
296
+ const tm = document.createElement('time'); tm.textContent = new Date(e.t).toLocaleTimeString();
297
+ const b = document.createElement('span'); b.className = 'text'; b.textContent = text; b.title = e.path || '';
298
+ li.append(a, tm, b);
299
+ if (e.reason) { const w = document.createElement('span'); w.className = 'why'; w.textContent = e.reason; li.append(w); }
300
+ if (e.changeId && e.type !== 'undo') {
301
+ const btn = document.createElement('button');
302
+ btn.className = 'btn'; btn.type = 'button'; btn.textContent = L.undo; btn.dataset.change = e.changeId;
303
+ btn.onclick = async () => {
304
+ btn.disabled = true;
305
+ const res = await fetch('/api/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: e.changeId }) });
306
+ if (!res.ok) {
307
+ const err = document.createElement('span'); err.className = 'err';
308
+ err.textContent = (await res.json()).error; li.append(err); btn.disabled = false;
309
+ }
310
+ };
311
+ li.append(btn);
312
+ }
313
+ logEl.append(li);
314
+ logEl.scrollTop = logEl.scrollHeight;
315
+ }
316
+
317
+ // Show paths shortly: the connected folder's name, then the rest
318
+ function short(p) {
319
+ const root = rootsList.find(r => p === r || p.startsWith(r + '/'));
320
+ return root ? base(root) + p.slice(root.length) : p;
321
+ }
322
+ let rootsList = [];
323
+
324
+ function onEvent(e, live) {
325
+ if (e.type === 'search') {
326
+ for (const h of e.hits) nodeEl.get(h)?.classList.add('hit');
327
+ addLog(e, 'search_files', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
328
+ } else if (e.type === 'list') {
329
+ visit(e.path, live);
330
+ addLog(e, 'list_files', short(e.path) + '/', live);
331
+ } else if (e.type === 'read') {
332
+ visit(e.path, live);
333
+ if (nodeEl.has(e.path)) sources.add(e.path);
334
+ addLog(e, 'read_file', short(e.path), live);
335
+ } else if (e.type === 'write' || e.type === 'edit') {
336
+ ensureNode(e.path);
337
+ for (const s of sources) if (s !== e.path && pos.has(s)) line('src', s, e.path, live);
338
+ const g = nodeEl.get(e.path);
339
+ g.classList.remove('gone'); g.classList.add('written');
340
+ setCurrent(e.path);
341
+ addLog(e, e.type === 'write' ? 'write_file' : 'edit_file', e.created ? `${short(e.path)} (${L.created})` : short(e.path), live);
342
+ } else if (e.type === 'move') {
343
+ visit(e.from, live);
344
+ ensureNode(e.path, e.dir ? 'dir' : 'file');
345
+ if (pos.has(e.from)) line('src', e.from, e.path, live);
346
+ nodeEl.get(e.from)?.classList.add('gone');
347
+ const g = nodeEl.get(e.path);
348
+ g.classList.remove('gone'); g.classList.add('written');
349
+ setCurrent(e.path);
350
+ addLog(e, 'move_file', `${short(e.from)} → ${short(e.path)}`, live);
351
+ } else if (e.type === 'undo') {
352
+ if (e.back) nodeEl.get(e.back)?.classList.remove('gone');
353
+ if (e.created) {
354
+ nodeEl.get(e.path)?.classList.add('gone');
355
+ gTrail.querySelectorAll(`[data-to="${CSS.escape(e.path)}"]`).forEach(l => l.style.opacity = 0);
356
+ }
357
+ logEl.querySelectorAll(`[data-change="${e.changeId}"]`).forEach(b => { b.disabled = true; b.textContent = L.undone; });
358
+ addLog(e, 'undo', short(e.path), live);
359
+ }
360
+ }
361
+
362
+ document.getElementById('clear').onclick = () => {
363
+ gTrail.textContent = '';
364
+ for (const g of nodeEl.values()) g.classList.remove('hit', 'visited', 'current', 'written');
365
+ current = null; sources = new Set();
366
+ };
367
+
368
+ // Drag to pan, wheel or pinch to zoom
369
+ let drag = null;
370
+ svg.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY, vb: { ...vb } }; svg.classList.add('drag'); svg.setPointerCapture(e.pointerId); });
371
+ svg.addEventListener('pointermove', e => {
372
+ if (!drag) return;
373
+ const k = vb.w / svg.clientWidth;
374
+ vb.x = drag.vb.x - (e.clientX - drag.x) * k; vb.y = drag.vb.y - (e.clientY - drag.y) * k;
375
+ setView();
376
+ });
377
+ svg.addEventListener('pointerup', () => { drag = null; svg.classList.remove('drag'); });
378
+ svg.addEventListener('wheel', e => {
379
+ e.preventDefault();
380
+ const k = Math.exp(e.deltaY * 0.0015), r = svg.getBoundingClientRect();
381
+ const mx = vb.x + (e.clientX - r.left) / r.width * vb.w, my = vb.y + (e.clientY - r.top) / r.height * vb.h;
382
+ vb = { x: mx - (mx - vb.x) * k, y: my - (my - vb.y) * k, w: vb.w * k, h: vb.h * k };
383
+ setView();
384
+ }, { passive: false });
385
+ addEventListener('resize', fit);
386
+
387
+ (async () => {
388
+ const info = await (await fetch('/api/info')).json();
389
+ rootsList = info.roots;
390
+ await loadGraph();
391
+ const status = document.getElementById('status'), statusText = document.getElementById('statusText');
392
+ statusText.textContent = L.off;
393
+ let replay = true;
394
+ const es = new EventSource('/api/events');
395
+ es.onopen = () => { status.classList.add('on'); statusText.textContent = L.live; setTimeout(() => { replay = false; }, 300); };
396
+ es.onerror = () => { status.classList.remove('on'); statusText.textContent = L.off; };
397
+ const seen = new Set(); // a reconnect replays events the page already showed
398
+ es.onmessage = m => {
399
+ const e = JSON.parse(m.data);
400
+ if (seen.has(e.key)) return;
401
+ seen.add(e.key);
402
+ onEvent(e, !replay);
403
+ };
404
+ })();
405
+ </script>
406
+ </body>
407
+ </html>