@scp-dev/dashboard 0.0.0-stage → 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,735 @@
1
+ (() => {
2
+ const root = document.querySelector("[data-graph]");
3
+ const live = document.querySelector("[data-live]");
4
+ if (!root || !live) return;
5
+ const bar = document.querySelector("[data-view-bar]");
6
+ const full = root.hasAttribute("data-full");
7
+ const NS = "http://www.w3.org/2000/svg";
8
+ const ICONS = {"claude":"<path d=\"m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z\" fill=\"currentColor\"/>","codex":"<path d=\"M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z\" fill=\"currentColor\"/>","opencode":"<path d=\"M21.6 24H2.4V0h19.2zM16.8 4.8H7.2v14.4h9.6z\" fill=\"currentColor\"/><path d=\"M16.8 9.6H7.2v9.6h9.6z\" fill=\"currentColor\" opacity=\".45\"/>","gemini":"<path d=\"M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81\" fill=\"currentColor\"/>","agy":"<path d=\"M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z\" fill=\"currentColor\"/>","grok":"<g transform=\"translate(1.2 1.2) scale(.9)\"><path d=\"M6.469 8.776 16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9 2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z\" fill=\"currentColor\"/></g>","muse":"<g transform=\"translate(-1.2 -1.2) scale(1.1)\"><path d=\"M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z\" fill=\"currentColor\"/></g>","pi":"<path d=\"M0 0h18v6H0zM0 6h6v18H0zM12 6h6v6h-6zM6 12h6v6H6zM18 12h6v12h-6z\" fill=\"currentColor\"/>","t3code":"<path d=\"M5.177,18.414V7.823H1V5.362H12.374V7.823H8.197V18.414H5.177ZM17.593,18.638C16.686,18.638 15.784,18.52 14.889,18.283C13.994,18.035 13.236,17.687 12.615,17.239L13.789,14.927C14.287,15.288 14.865,15.574 15.523,15.785C16.182,15.996 16.847,16.102 17.518,16.102C18.277,16.102 18.873,15.953 19.308,15.654C19.743,15.356 19.961,14.946 19.961,14.424C19.961,13.927 19.768,13.535 19.383,13.249C18.998,12.963 18.376,12.82 17.518,12.82H16.139V10.825L19.774,6.705L20.11,7.786H13.267V5.362H22.403V7.32L18.786,11.441L17.257,10.564H18.134C19.737,10.564 20.949,10.925 21.769,11.646C22.59,12.367 23,13.293 23,14.424C23,15.157 22.807,15.847 22.422,16.494C22.037,17.127 21.446,17.643 20.651,18.041C19.855,18.439 18.836,18.638 17.593,18.638Z\" fill=\"currentColor\"/>","generic":"<g transform=\"scale(1.5)\"><rect x=\"1.75\" y=\"2.75\" width=\"12.5\" height=\"10.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M4.5 6.25 6.5 8l-2 1.75M8 10h3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>","human":"<g transform=\"scale(1.5)\"><circle cx=\"8\" cy=\"5.25\" r=\"2.75\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\"/><path d=\"M2.75 14c.5-3 2.6-4.75 5.25-4.75S12.75 11 13.25 14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" stroke-linecap=\"round\"/></g>"};
9
+ const MARK = "<path d=\"M3 3h10v4H7v6H3zM29 29H19v-4h6v-6h4z\"/><rect x=\"11\" y=\"11\" width=\"10\" height=\"10\"/>";
10
+ const COLORED = new Set(["claude","codex","opencode","gemini","agy","grok","muse","pi","t3code"]);
11
+ const BOLT = "M9.5 1 3 9h4.5L6.5 15 13 7H8.5z";
12
+ const R = 16, PAD = 20, HEAD = 26, ID_LINE = 14, GAP = 26, BAND_GAP = 44, LABEL_MAX = 176, NAME_MAX = 220, ID_MAX = 200, MIN_W = 96, CAP = 1.6;
13
+ const TRAILS = [1, 0.5, 0.25];
14
+ const LAYOUT = {"gap":14,"ring":84,"reach":160,"span":2.4,"wide":1.4,"sub":1.6,"apart":64,"aspect":1.8};
15
+ function slot(e, w, h, min) {
16
+ return { x0: e.x0, x1: Math.max(e.x1, e.x0 + w, e.x0 + min), y0: e.y0 - h, y1: e.y1 };
17
+ }
18
+ function cluster(ns, B, o) {
19
+ const at = new Map(ns.map((n, i) => [n.address, i]));
20
+ const by = (a, b) => (ns[a].address < ns[b].address ? -1 : ns[a].address > ns[b].address ? 1 : 0);
21
+ const kids = ns.map(() => []);
22
+ const up = ns.map((n, i) => { const p = n.parent ? at.get(n.parent) : undefined; return p === undefined || p === i ? -1 : p; });
23
+ up.forEach((p, i) => { if (p >= 0) kids[p].push(i); });
24
+ for (const k of kids) k.sort(by);
25
+ const P = ns.map(() => ({ x: 0, y: 0 }));
26
+ const seen = new Set();
27
+ const foot = (i) => Math.max(2 * B[i].hw, B[i].up + B[i].down) + o.gap;
28
+ const step = (a, b) => (foot(a) + foot(b)) / 2;
29
+ const fan = (i, dir, span, out) => {
30
+ const c = kids[i].filter((k) => !seen.has(k));
31
+ if (!c.length) return;
32
+ for (const k of c) { seen.add(k); out.push(k); }
33
+ const rise = Math.max(...c.map((k) => B[k].up + B[k].down)) + o.gap;
34
+ let all = 0;
35
+ for (let k = 1; k < c.length; k++) all += step(c[k - 1], c[k]);
36
+ const per = Math.ceil(c.length / Math.max(1, Math.ceil(all / (span * o.reach))));
37
+ let r = Math.max(o.ring, B[i].down + rise - B[i].up);
38
+ for (let j = 0; j < c.length; j += per) {
39
+ const ring = c.slice(j, j + per);
40
+ let arc = 0;
41
+ for (let k = 1; k < ring.length; k++) arc += step(ring[k - 1], ring[k]);
42
+ r = Math.max(r, arc / span / o.wide);
43
+ const rx = r * o.wide;
44
+ let t = dir + arc / rx / 2;
45
+ ring.forEach((k, n) => {
46
+ let dx = Math.cos(t) * rx, dy = Math.sin(t) * r;
47
+ if (Math.sin(dir) > 0.5) dy = Math.max(dy, B[i].down + B[k].up + o.gap);
48
+ P[k] = { x: P[i].x + dx, y: P[i].y + dy, t };
49
+ if (n + 1 < ring.length) t -= step(k, ring[n + 1]) / rx;
50
+ });
51
+ r += rise;
52
+ }
53
+ for (const k of c) fan(k, (P[k].t + Math.PI / 2) / 2, o.sub, out);
54
+ };
55
+ const grow = (i) => { seen.add(i); const out = [i]; P[i] = { x: 0, y: 0 }; fan(i, Math.PI / 2, o.span, out); return out; };
56
+ const roots = ns.map((_, i) => i).filter((i) => up[i] < 0).sort(by);
57
+ const groups = roots.filter((i) => kids[i].length).map(grow).sort((a, b) => b.length - a.length);
58
+ for (const i of ns.map((_, i) => i).sort(by)) if (!seen.has(i) && up[i] >= 0) groups.push(grow(i));
59
+ const solo = roots.filter((i) => !kids[i].length);
60
+ if (solo.length) {
61
+ const cols = Math.ceil(Math.sqrt(solo.length));
62
+ const cw = Math.max(...solo.map(foot)), ch = Math.max(...solo.map((i) => B[i].up + B[i].down)) + o.gap;
63
+ solo.forEach((i, k) => { P[i] = { x: (k % cols) * cw, y: Math.floor(k / cols) * ch }; });
64
+ groups.push(solo);
65
+ }
66
+ const ext = groups.map((m) => {
67
+ const Q = m.map((i) => P[i]), C = m.map((i) => B[i]), one = m.map(() => 1);
68
+ for (let k = 0; k < 120; k++) if (!collide(Q, C, one)) break;
69
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
70
+ for (const i of m) { x0 = Math.min(x0, P[i].x - B[i].hw); x1 = Math.max(x1, P[i].x + B[i].hw); y0 = Math.min(y0, P[i].y - B[i].up); y1 = Math.max(y1, P[i].y + B[i].down); }
71
+ return { m, x0, y0, w: x1 - x0, h: y1 - y0 };
72
+ });
73
+ const area = ext.reduce((a, e) => a + (e.w + o.apart) * (e.h + o.apart), 0);
74
+ const target = Math.max(Math.sqrt(area * o.aspect), ...ext.map((e) => e.w));
75
+ const rows = [];
76
+ for (const e of ext) {
77
+ const row = rows[rows.length - 1];
78
+ if (row && row.w + o.apart + e.w <= target) { row.items.push(e); row.w += o.apart + e.w; }
79
+ else rows.push({ items: [e], w: e.w });
80
+ }
81
+ const width = Math.max(0, ...rows.map((r) => r.w));
82
+ let y = 0;
83
+ for (const row of rows) {
84
+ let x = (width - row.w) / 2;
85
+ for (const e of row.items) {
86
+ for (const i of e.m) { P[i].x += x - e.x0; P[i].y += y - e.y0; }
87
+ x += e.w + o.apart;
88
+ }
89
+ y += Math.max(...row.items.map((e) => e.h)) + o.apart * 1.5;
90
+ }
91
+ return P.map((p) => ({ x: p.x, y: p.y }));
92
+ }
93
+
94
+ /** pushes overlapping node and label boxes apart along their shallower axis; true while any overlapped */
95
+ function collide(P, B, mob) {
96
+ let hit = false;
97
+ for (let i = 0; i < P.length; i++) for (let j = i + 1; j < P.length; j++) {
98
+ const dx = P[j].x - P[i].x, dy = P[j].y - P[i].y;
99
+ const ox = B[i].hw + B[j].hw - Math.abs(dx);
100
+ const oy = (dy >= 0 ? B[i].down + B[j].up : B[i].up + B[j].down) - Math.abs(dy);
101
+ if (ox <= 0 || oy <= 0) continue;
102
+ hit = true;
103
+ const mi = mob[i], mj = mob[j], sum = mi + mj || 1;
104
+ if (ox < oy) { const s = (dx >= 0 ? 1 : -1) * (ox + 0.5); P[i].x -= s * mi / sum; P[j].x += s * mj / sum; }
105
+ else { const s = (dy >= 0 ? 1 : -1) * (oy + 0.5); P[i].y -= s * mi / sum; P[j].y += s * mj / sum; }
106
+ }
107
+ return hit;
108
+ }
109
+ function bands(items, o) {
110
+ const byKey = new Map();
111
+ for (const it of items) { if (!byKey.has(it.key)) byKey.set(it.key, { key: it.key, items: [] }); byKey.get(it.key).items.push(it); }
112
+ const groups = [...byKey.values()];
113
+ for (const g of groups) {
114
+ g.inset = g.key ? o.pad : 0;
115
+ g.top = g.key ? o.badge : 0;
116
+ g.w = g.items.reduce((w, it) => w + it.w, 0) + o.gap * (g.items.length - 1) + 2 * g.inset;
117
+ g.h = Math.max(...g.items.map((it) => it.h)) + g.top + 2 * g.inset;
118
+ }
119
+ const area = groups.reduce((a, g) => a + (g.w + o.groupGap) * (g.h + o.groupGap), 0);
120
+ const target = Math.max(Math.sqrt(area * o.aspect), ...groups.map((g) => g.w));
121
+ const rows = [];
122
+ for (const g of groups) {
123
+ const row = rows[rows.length - 1];
124
+ if (row && row.w + o.groupGap + g.w <= target) { row.groups.push(g); row.w += o.groupGap + g.w; }
125
+ else rows.push({ groups: [g], w: g.w });
126
+ }
127
+ const width = Math.max(1, ...rows.map((r) => r.w));
128
+ const mid = Math.floor(rows.length / 2);
129
+ const places = new Map(), rects = [];
130
+ let y = 0, hub = null;
131
+ rows.forEach((row, r) => {
132
+ if (o.hub && r === mid) {
133
+ hub = { x: width / 2, y: rows.length === 1 ? o.hub / 2 - 10 : y + o.hub / 2 - o.groupGap / 2 };
134
+ y += o.hub;
135
+ }
136
+ let x = (width - row.w) / 2;
137
+ for (const g of row.groups) {
138
+ rects.push({ key: g.key, x, y, w: g.w, h: g.h, n: g.items.length });
139
+ let ix = x + g.inset;
140
+ for (const it of g.items) { places.set(it.name, { x: ix, y: y + g.inset + g.top }); ix += it.w + o.gap; }
141
+ x += g.w + o.groupGap;
142
+ }
143
+ y += Math.max(...row.groups.map((g) => g.h)) + o.groupGap;
144
+ });
145
+ return { places, bands: rects, hub, width, height: Math.max(y - o.groupGap, 1) };
146
+ }
147
+ function drawn(n, o, talks) {
148
+ if (n.presence === "offline") return o.offline || (n.kind === "human" && talks.has(n.address));
149
+ if (n.presence === "idle" && n.kind === "agent" && !n.wake) return o.nextTurn;
150
+ return true;
151
+ }
152
+ const svg = root.querySelector(".graph-svg");
153
+ const canvas = root.querySelector(".graph-canvas");
154
+ const tip = root.querySelector(".graph-tip");
155
+ const offline = document.querySelector("[data-graph-offline]");
156
+ const nextTurn = document.querySelector("[data-graph-nextturn]");
157
+ const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
158
+ const load = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
159
+ const save = (k, v) => { try { localStorage.setItem(k, v); } catch {} };
160
+ const el = (tag, attrs, parent) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.appendChild(e); return e; };
161
+ const ease = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
162
+ const lerp = (a, b, k) => a + (b - a) * k;
163
+ const r1 = (v) => Math.round(v * 10) / 10;
164
+ const machineOf = (a) => { const p = a.split("/"); return p.length >= 3 ? p[1] : ""; };
165
+
166
+ const defs = el("defs", {}, svg);
167
+ el("path", { d: "M0 0L7 3.5L0 7z", class: "arrow" }, el("marker", { id: "graph-arrow", viewBox: "0 0 7 7", refX: "1", refY: "3.5", markerWidth: "6", markerHeight: "6", markerUnits: "userSpaceOnUse", orient: "auto" }, defs));
168
+ const layers = {};
169
+ for (const k of ["regions", "edges", "hub", "labels", "pulses", "nodes"]) layers[k] = el("g", { class: "layer-" + k }, svg);
170
+
171
+ let data = null;
172
+ let focus = null;
173
+ let first = true;
174
+ let streamOpen = false;
175
+ const sim = new Map();
176
+ const shapes = new Map();
177
+ const shown = new Map();
178
+ const nodeEls = new Map();
179
+ const edgeEls = new Map();
180
+ const regionEls = new Map();
181
+ let place = new Map();
182
+ let hubAt = null;
183
+ let hubEl = null;
184
+ let viewTarget = null;
185
+ let viewShown = null;
186
+ let anim = 0;
187
+ let trail = TRAILS.includes(Number(load("scp.graph.trails"))) ? Number(load("scp.graph.trails")) : 1;
188
+
189
+ const measure = document.createElement("canvas").getContext("2d");
190
+ const widths = new Map();
191
+ function textWidth(text, weight, size = 11) {
192
+ const key = weight + "|" + size + "|" + text;
193
+ if (!widths.has(key)) {
194
+ measure.font = weight + " " + size + "px " + (getComputedStyle(document.documentElement).getPropertyValue("--mono") || "monospace");
195
+ widths.set(key, measure.measureText(text).width);
196
+ }
197
+ return widths.get(key);
198
+ }
199
+ function fit(text, weight = 400, max = LABEL_MAX, size = 11) {
200
+ if (textWidth(text, weight, size) <= max) return text;
201
+ let s = text;
202
+ while (s.length > 1 && textWidth(s + "…", weight, size) > max) s = s.slice(0, -1);
203
+ return s + "…";
204
+ }
205
+
206
+ function read() {
207
+ const block = live.querySelector("script[data-graph-data]");
208
+ if (!block) return null;
209
+ try { return JSON.parse(block.textContent); } catch { return null; }
210
+ }
211
+
212
+ function setView(v) {
213
+ document.documentElement.setAttribute("data-view", v);
214
+ if (bar) for (const b of bar.querySelectorAll("[data-view-set]")) b.setAttribute("aria-pressed", String(b.getAttribute("data-view-set") === v));
215
+ save("scp.overview.view", v);
216
+ if (v === "graph") apply();
217
+ }
218
+
219
+ function visible(g) {
220
+ const o = { offline: !!(offline && offline.checked), nextTurn: !!(nextTurn && nextTurn.checked) };
221
+ const talks = new Set();
222
+ for (const e of g.edges) if (e.kind === "message") { talks.add(e.from); talks.add(e.to); }
223
+ const nodes = g.nodes.filter((n) => drawn(n, o, talks));
224
+ const on = new Set(nodes.map((n) => n.address));
225
+ const edges = g.edges.filter((e) => on.has(e.from) && on.has(e.to));
226
+ const byName = new Map();
227
+ for (const m of g.machines) byName.set(m.name, { name: m.name, online: m.online, identity: m.identity || null, nodes: [] });
228
+ for (const n of nodes) {
229
+ if (!byName.has(n.machine)) byName.set(n.machine, { name: n.machine, online: n.presence === "online", identity: null, nodes: [] });
230
+ byName.get(n.machine).nodes.push(n);
231
+ }
232
+ const groups = [...byName.values()].filter((gr) => gr.nodes.length || gr.online);
233
+ const who = (gr) => (gr.identity ? gr.identity.name : "");
234
+ groups.sort((a, b) => (a.name === "") - (b.name === "") || !who(a) - !who(b) || who(a).localeCompare(who(b)) || a.name.localeCompare(b.name));
235
+ return { nodes, edges, groups };
236
+ }
237
+
238
+ function box(n) {
239
+ return { hw: Math.max(R + 4, textWidth(fit(n.label), 400) / 2 + 6), up: R + 6, down: R + 22 };
240
+ }
241
+
242
+ /** one machine's layout, recomputed only when its nodes, labels or spawn links change, so a refresh never moves anything */
243
+ function settle(g, edges) {
244
+ const ns = g.nodes;
245
+ const ids = new Set(ns.map((n) => n.address));
246
+ const spawn = edges.filter((e) => e.kind === "spawn" && ids.has(e.from) && ids.has(e.to));
247
+ const sig = ns.map((n) => n.address + ":" + fit(n.label)).sort().join(" ") + "|" + spawn.map((e) => e.from + ">" + e.to).sort().join(" ");
248
+ if (shapes.get(g.name) === sig) return;
249
+ shapes.set(g.name, sig);
250
+ const up = new Map(spawn.map((e) => [e.to, e.from]));
251
+ const P = cluster(ns.map((n) => ({ address: n.address, parent: up.get(n.address) || null })), ns.map(box), LAYOUT);
252
+ ns.forEach((n, i) => sim.set(n.address, P[i]));
253
+ }
254
+
255
+ /** a region's header: the machine name and agent count, the identity on a second line, each cut to a sane width */
256
+ function headOf(g) {
257
+ const full = g.name || "org";
258
+ const name = fit(full, 500, NAME_MAX);
259
+ const id = g.identity ? fit("identity: " + g.identity.name, 400, ID_MAX, 10) : "";
260
+ const count = g.nodes.length + (g.nodes.length === 1 ? " agent" : " agents") + (g.online ? "" : ", offline");
261
+ const w = Math.max(textWidth(name, 500) + 8 + textWidth(count, 400), id ? textWidth(id, 400, 10) : 0) + 34;
262
+ return { name, id, count, title: full + (g.identity ? "\nidentity: " + g.identity.name : ""), w, h: HEAD + (id ? ID_LINE : 0) };
263
+ }
264
+
265
+ /** a region's extent around its own origin, header included; an empty machine is one short row for its note */
266
+ function extent(g) {
267
+ const h = headOf(g);
268
+ if (!g.nodes.length) return slot({ x0: 0, x1: 0, y0: 0, y1: 12 }, h.w, h.h, MIN_W);
269
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
270
+ for (const n of g.nodes) {
271
+ const p = sim.get(n.address), b = box(n);
272
+ x0 = Math.min(x0, p.x - b.hw); x1 = Math.max(x1, p.x + b.hw); y0 = Math.min(y0, p.y - b.up); y1 = Math.max(y1, p.y + b.down);
273
+ }
274
+ return slot({ x0, x1, y0, y1 }, h.w, h.h, MIN_W);
275
+ }
276
+
277
+ /** regions with one identity's machines side by side, wrapped to the window's shape; the hub sits above a single row, else between the middle rows */
278
+ function pack(list) {
279
+ const cw = Math.max(280, canvas.clientWidth), ch = full ? Math.max(240, canvas.clientHeight) : 0;
280
+ const aspect = full ? cw / ch : Math.max(1.4, cw / 560);
281
+ const ext = list.map(extent);
282
+ const items = list.map((g, i) => ({ name: g.name, key: g.identity ? g.identity.name : "", w: ext[i].x1 - ext[i].x0 + 2 * PAD, h: ext[i].y1 - ext[i].y0 + 2 * PAD }));
283
+ const L = bands(items, { gap: GAP, groupGap: BAND_GAP, pad: 0, badge: 0, hub: list.length > 1 ? 96 : 0, aspect: aspect * 1.15 });
284
+ const out = new Map();
285
+ list.forEach((g, i) => {
286
+ const p = L.places.get(g.name), e = ext[i];
287
+ out.set(g.name, { ox: p.x + PAD - e.x0, oy: p.y + PAD - e.y0, x: p.x, y: p.y, w: items[i].w, h: items[i].h });
288
+ });
289
+ hubAt = L.hub;
290
+ const widest = L.width;
291
+ const height = L.height;
292
+ const margin = full ? Math.min(120, cw * 0.06) : 24;
293
+ let top = margin, bottom = margin;
294
+ const at = full ? canvas.getBoundingClientRect().top : 0;
295
+ if (full) for (const o of root.querySelectorAll(".g-over")) {
296
+ const b = o.getBoundingClientRect();
297
+ if (b.top - at < ch / 2) top = Math.max(top, b.bottom - at + 16);
298
+ else bottom = Math.max(bottom, ch - (b.top - at) + 16);
299
+ }
300
+ const scale = full
301
+ ? Math.min(CAP, (cw - 2 * margin) / widest, (ch - top - bottom) / height)
302
+ : Math.min(CAP, (cw - 2 * margin) / widest, 760 / (height + 2 * margin));
303
+ const vw = cw / scale;
304
+ const vh = full ? ch / scale : Math.max(340 / scale, height + (2 * margin) / scale);
305
+ const cy = full ? height / 2 - (top - bottom) / 2 / scale : height / 2;
306
+ viewTarget = { x: widest / 2 - vw / 2, y: cy - vh / 2, w: vw, h: vh, px: full ? 0 : vh * scale };
307
+ return out;
308
+ }
309
+
310
+ function counts(v) {
311
+ const out = document.querySelector("[data-graph-counts]");
312
+ if (!out) return;
313
+ const agents = v.nodes.filter((n) => n.kind === "agent");
314
+ const on = agents.filter((n) => n.presence === "online").length;
315
+ const m = v.groups.filter((g) => g.name).length;
316
+ out.textContent = m + (m === 1 ? " machine, " : " machines, ") + agents.length + (agents.length === 1 ? " agent, " : " agents, ") + on + " online";
317
+ }
318
+
319
+ function apply() {
320
+ if (!data) return;
321
+ const has = data.nodes.length > 0 || data.machines.length > 0;
322
+ if (bar) bar.hidden = !has;
323
+ if (!full) root.hidden = !has;
324
+ if (!has && !full) { document.documentElement.removeAttribute("data-view"); return; }
325
+ if (bar && !document.documentElement.hasAttribute("data-view")) return setView(load("scp.overview.view") === "table" ? "table" : "graph");
326
+ if (bar && document.documentElement.getAttribute("data-view") !== "graph") return;
327
+ for (const m of root.querySelectorAll("[data-graph-minutes]")) m.textContent = String(data.minutes);
328
+ const v = visible(data);
329
+ counts(v);
330
+ for (const g of v.groups) settle(g, v.edges);
331
+ place = pack(v.groups);
332
+ sync(v);
333
+ start();
334
+ }
335
+
336
+ /** where a node rests, in view coordinates */
337
+ function target(a) {
338
+ const s = sim.get(a), o = place.get(machineOf(a));
339
+ return s && o ? { x: o.ox + s.x, y: o.oy + s.y } : null;
340
+ }
341
+
342
+ function sync(v) {
343
+ const keep = new Set();
344
+ for (const g of v.groups) {
345
+ keep.add(g.name);
346
+ let r = regionEls.get(g.name);
347
+ if (!r) {
348
+ const gr = el("g", { class: "region" }, layers.regions);
349
+ const head = el("g", { class: "region-head" }, layers.labels);
350
+ r = { g: gr, head, rim: el("path", { class: "hull-rim" }, gr), hull: el("path", { class: "hull" }, gr), dot: el("circle", { class: "mdot", r: "3" }, head), label: el("text", { class: "mlabel" }, head), idl: el("text", { class: "mid" }, head), note: el("text", { class: "empty-note" }, gr) };
351
+ r.title = el("title", {}, head);
352
+ r.name = el("tspan", {}, r.label);
353
+ r.n = el("tspan", { class: "n", dx: "8" }, r.label);
354
+ regionEls.set(g.name, r);
355
+ }
356
+ const head = headOf(g);
357
+ r.g.classList.toggle("offline", !g.online);
358
+ r.head.classList.toggle("offline", !g.online);
359
+ r.dot.setAttribute("class", "mdot" + (g.online ? " online" : ""));
360
+ r.name.textContent = head.name;
361
+ r.idl.textContent = head.id;
362
+ r.title.textContent = head.title;
363
+ r.n.textContent = head.count;
364
+ r.note.textContent = g.nodes.length ? "" : "no agents";
365
+ r.g.classList.toggle("ided", !!g.identity);
366
+ r.g.style.setProperty("--id", g.identity ? "var(--id-" + g.identity.tone + ")" : "");
367
+ r.head.style.setProperty("--id", g.identity ? "var(--id-" + g.identity.tone + ")" : "");
368
+ r.group = g;
369
+ }
370
+ for (const [k, r] of regionEls) if (!keep.has(k)) { r.g.remove(); r.head.remove(); regionEls.delete(k); }
371
+
372
+
373
+ const on = new Set();
374
+ for (const n of v.nodes) {
375
+ on.add(n.address);
376
+ const e = nodeEls.get(n.address) || makeNode(n);
377
+ e.n = n;
378
+ paintNode(e);
379
+ }
380
+ for (const [a, e] of nodeEls) if (!on.has(a)) {
381
+ nodeEls.delete(a);
382
+ shown.delete(a);
383
+ e.g.classList.add("enter");
384
+ setTimeout(() => e.g.remove(), 320);
385
+ }
386
+
387
+ const edgesOn = new Set();
388
+ for (const e of v.edges) {
389
+ const key = e.kind + ":" + e.from + ">" + e.to;
390
+ edgesOn.add(key);
391
+ let p = edgeEls.get(key);
392
+ if (!p) {
393
+ p = el("path", { class: "edge " + (e.kind === "message" ? "msg" : "spawn") }, layers.edges);
394
+ if (e.kind === "message") p.setAttribute("marker-end", "url(#graph-arrow)");
395
+ p.dataset.from = e.from;
396
+ p.dataset.to = e.to;
397
+ edgeEls.set(key, p);
398
+ }
399
+ p.edge = e;
400
+ paintEdge(p);
401
+ }
402
+ for (const [k, p] of edgeEls) if (!edgesOn.has(k)) { p.remove(); edgeEls.delete(k); }
403
+
404
+ if (hubAt && !hubEl) {
405
+ hubEl = el("g", { class: "hub", "aria-hidden": "true" }, layers.hub);
406
+ el("circle", { r: "15" }, hubEl);
407
+ el("svg", { x: "-7", y: "-7", width: "14", height: "14", viewBox: "0 0 32 32", fill: "currentColor" }, hubEl).innerHTML = MARK;
408
+ el("text", { y: "-22" }, hubEl).textContent = "hub";
409
+ }
410
+ if (!hubAt && hubEl) { hubEl.remove(); hubEl = null; }
411
+ applyFocus();
412
+ }
413
+
414
+ function makeNode(n) {
415
+ const g = el("g", { class: "node enter", tabindex: "0", role: "button" }, layers.nodes);
416
+ g.dataset.address = n.address;
417
+ const e = { g, n };
418
+ e.ring = el("circle", { class: "ring", r: String(R) }, g);
419
+ e.disc = n.kind === "human" ? el("rect", { class: "disc", x: -R, y: -R, width: 2 * R, height: 2 * R, rx: 6 }, g) : el("circle", { class: "disc", r: String(R) }, g);
420
+ e.glyph = el("svg", { class: "glyph", x: "-7.5", y: "-7.5", width: "15", height: "15", viewBox: "0 0 24 24" }, g);
421
+ e.pdot = el("circle", { class: "pdot", cx: String(r1(R * 0.72)), cy: String(r1(-R * 0.72)), r: "4" }, g);
422
+ e.bolt = el("g", { class: "bolt-g", transform: "translate(" + r1(R * 0.72) + " " + r1(R * 0.72) + ")" }, g);
423
+ el("circle", { r: "6" }, e.bolt);
424
+ el("path", { d: BOLT, transform: "translate(-4.4 -4.4) scale(.55)", fill: "currentColor" }, e.bolt);
425
+ e.label = el("text", { class: "nlabel", y: String(R + 15) }, g);
426
+ const from = n.parent && shown.get(n.parent);
427
+ const t = target(n.address);
428
+ if (t) shown.set(n.address, from ? { ...from } : { ...t });
429
+ nodeEls.set(n.address, e);
430
+ requestAnimationFrame(() => requestAnimationFrame(() => g.classList.remove("enter")));
431
+ return e;
432
+ }
433
+
434
+ function paintNode(e) {
435
+ const n = e.n, c = e.g.classList;
436
+ for (const p of ["online", "idle", "offline"]) c.toggle(p, n.presence === p);
437
+ c.toggle("human", n.kind === "human");
438
+ e.g.setAttribute("aria-label", n.name + ", " + n.presence + (n.how ? ", " + n.how : ""));
439
+ if (e.icon !== n.icon) {
440
+ e.icon = n.icon;
441
+ e.glyph.innerHTML = ICONS[n.icon] || ICONS.generic;
442
+ e.glyph.style.color = COLORED.has(n.icon) ? "var(--h-" + n.icon + ")" : "";
443
+ }
444
+ e.pdot.setAttribute("class", "pdot " + n.presence);
445
+ e.pdot.style.display = n.kind === "human" ? "none" : "";
446
+ e.bolt.style.display = n.wake && n.kind !== "human" ? "" : "none";
447
+ e.label.textContent = fit(n.label);
448
+ }
449
+
450
+ /** message edges fade over the graph's window and thicken with their count */
451
+ function paintEdge(p) {
452
+ const e = p.edge;
453
+ if (e.kind !== "message") return;
454
+ const age = Math.max(0, Date.now() - Date.parse(e.last_at)) / (data.minutes * 60000);
455
+ p.style.opacity = String(r1(0.22 + 0.66 * Math.max(0, 1 - age)));
456
+ p.style.strokeWidth = String(r1(1.1 + Math.min(2.6, Math.log2(e.count) * 0.55)));
457
+ }
458
+
459
+ /** an edge's path: straight for a spawn within a machine and an S curve from the parent down to a child on another machine; a message bows gently within a machine and between machines rises to the hub and runs through it, each direction on its own lane */
460
+ function curve(e, a, b) {
461
+ const trim = (p, c, by) => { const ux = c.x - p.x, uy = c.y - p.y, l = Math.max(Math.hypot(ux, uy), 1); return { x: p.x + ux / l * by, y: p.y + uy / l * by }; };
462
+ const pt = (p) => r1(p.x) + " " + r1(p.y);
463
+ const end = R + (e.kind === "message" ? 8 : 3);
464
+ if (e.kind === "message" && hubAt && machineOf(e.from) !== machineOf(e.to)) {
465
+ const hy = hubAt.y + (b.x >= a.x ? -3 : 3), h = { x: hubAt.x, y: hy };
466
+ const c1 = { x: a.x, y: lerp(a.y, hy, 0.6) }, c2 = { x: lerp(a.x, h.x, 0.55), y: hy };
467
+ const c3 = { x: lerp(b.x, h.x, 0.55), y: hy }, c4 = { x: b.x, y: lerp(b.y, hy, 0.6) };
468
+ return "M" + pt(trim(a, c1, R + 3)) + "C" + pt(c1) + " " + pt(c2) + " " + pt(h) + "C" + pt(c3) + " " + pt(c4) + " " + pt(trim(b, c4, end));
469
+ }
470
+ if (e.kind === "spawn" && machineOf(e.from) !== machineOf(e.to)) {
471
+ const k = Math.max(Math.abs(b.y - a.y), Math.abs(b.x - a.x) * 0.35, 60) * 0.5;
472
+ const c1 = { x: a.x, y: a.y + k }, c2 = { x: b.x, y: b.y - k };
473
+ return "M" + pt(trim(a, c1, R + 3)) + "C" + pt(c1) + " " + pt(c2) + " " + pt(trim(b, c2, end));
474
+ }
475
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.max(Math.hypot(dx, dy), 1), nx = -dy / d, ny = dx / d;
476
+ const bow = e.kind === "spawn" ? 0 : Math.min(46, d * 0.2);
477
+ const c1 = { x: a.x + dx / 3 + nx * bow, y: a.y + dy / 3 + ny * bow }, c2 = { x: a.x + dx * 2 / 3 + nx * bow, y: a.y + dy * 2 / 3 + ny * bow };
478
+ return "M" + pt(trim(a, c1, R + 3)) + "C" + pt(c1) + " " + pt(c2) + " " + pt(trim(b, c2, end));
479
+ }
480
+
481
+ function hullOf(pts) {
482
+ const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
483
+ if (p.length < 3) return p;
484
+ const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
485
+ const lo = [], hi = [];
486
+ for (const q of p) { while (lo.length >= 2 && cross(lo[lo.length - 2], lo[lo.length - 1], q) <= 0) lo.pop(); lo.push(q); }
487
+ for (const q of p.reverse()) { while (hi.length >= 2 && cross(hi[hi.length - 2], hi[hi.length - 1], q) <= 0) hi.pop(); hi.push(q); }
488
+ return lo.slice(0, -1).concat(hi.slice(0, -1));
489
+ }
490
+
491
+ function draw() {
492
+ for (const [a, e] of nodeEls) {
493
+ const p = shown.get(a);
494
+ if (p) e.g.setAttribute("transform", "translate(" + r1(p.x) + " " + r1(p.y) + ")");
495
+ }
496
+ for (const [, p] of edgeEls) {
497
+ const a = shown.get(p.edge.from), b = shown.get(p.edge.to);
498
+ if (a && b) p.setAttribute("d", curve(p.edge, a, b));
499
+ }
500
+ for (const [name, r] of regionEls) {
501
+ const g = r.group, o = place.get(name);
502
+ if (!o) continue;
503
+ const pts = [];
504
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
505
+ for (const n of g.nodes) {
506
+ const p = shown.get(n.address);
507
+ if (!p) continue;
508
+ const b = box(n);
509
+ x0 = Math.min(x0, p.x - b.hw); x1 = Math.max(x1, p.x + b.hw); y0 = Math.min(y0, p.y - b.up); y1 = Math.max(y1, p.y + b.down);
510
+ pts.push([p.x - b.hw + 6, p.y - b.up + 6], [p.x + b.hw - 6, p.y - b.up + 6], [p.x - b.hw + 6, p.y + b.down - 6], [p.x + b.hw - 6, p.y + b.down - 6]);
511
+ }
512
+ const head = headOf(g);
513
+ if (!pts.length) { x0 = o.x + PAD; y0 = o.y + PAD + head.h; x1 = o.x + o.w - PAD; y1 = o.y + o.h - PAD; pts.push([x0, y0], [x1, y0], [x0, y1], [x1, y1]); }
514
+ const hw = x0 + Math.max(head.w, MIN_W);
515
+ if (hw > x1) { x1 = hw; pts.push([x0, y1], [x1, y1]); }
516
+ pts.push([x0, y0 - head.h], [x1, y0 - head.h]);
517
+ const d = "M" + hullOf(pts).map((q) => r1(q[0]) + " " + r1(q[1])).join("L") + "Z";
518
+ r.hull.setAttribute("d", d);
519
+ r.hull.setAttribute("stroke-width", String(2 * PAD));
520
+ r.rim.setAttribute("d", d);
521
+ r.rim.setAttribute("stroke-width", String(2 * PAD + 2));
522
+ const lx = x0 - PAD + 16, ly = y0 - head.h - PAD + 22;
523
+ r.dot.setAttribute("cx", r1(lx));
524
+ r.dot.setAttribute("cy", r1(ly - 4));
525
+ r.label.setAttribute("x", r1(lx + 9));
526
+ r.label.setAttribute("y", r1(ly));
527
+ r.idl.setAttribute("x", r1(lx + 9));
528
+ r.idl.setAttribute("y", r1(ly + ID_LINE));
529
+ r.note.setAttribute("x", r1(lx));
530
+ r.note.setAttribute("y", r1(y0 + 9));
531
+ r.box = { x0: x0 - PAD, y0: y0 - head.h - PAD, x1: x1 + PAD, y1: y1 + PAD };
532
+ }
533
+ if (hubEl && hubAt) hubEl.setAttribute("transform", "translate(" + r1(hubAt.x) + " " + r1(hubAt.y) + ")");
534
+ if (viewShown) {
535
+ svg.setAttribute("viewBox", [viewShown.x, viewShown.y, viewShown.w, viewShown.h].map(r1).join(" "));
536
+ if (!full) svg.style.height = Math.round(viewShown.px) + "px";
537
+ }
538
+ }
539
+
540
+ /** eases every node from where it shows to where it rests, and the view with it */
541
+ function start() {
542
+ cancelAnimationFrame(anim);
543
+ const from = new Map();
544
+ for (const [a] of nodeEls) { const t = target(a); if (t) from.set(a, shown.get(a) || { ...t }); }
545
+ const v0 = viewShown || viewTarget;
546
+ const dur = first || reduce ? 0 : 700;
547
+ first = false;
548
+ const t0 = performance.now();
549
+ const step = (now) => {
550
+ const k = dur ? ease(Math.min(1, (now - t0) / dur)) : 1;
551
+ for (const [a, f] of from) { const t = target(a); if (t) shown.set(a, { x: lerp(f.x, t.x, k), y: lerp(f.y, t.y, k) }); }
552
+ viewShown = { x: lerp(v0.x, viewTarget.x, k), y: lerp(v0.y, viewTarget.y, k), w: lerp(v0.w, viewTarget.w, k), h: lerp(v0.h, viewTarget.h, k), px: lerp(v0.px, viewTarget.px, k) };
553
+ draw();
554
+ if (k < 1) anim = requestAnimationFrame(step);
555
+ };
556
+ step(t0);
557
+ }
558
+
559
+ /** a dot from sender to recipient along their edge, about a second end to end; `bump` counts the message on the edge */
560
+ function pulse(from, to, kind, bump) {
561
+ if (!data) return;
562
+ let e = data.edges.find((x) => x.kind === "message" && x.from === from && x.to === to);
563
+ if (e && bump) { e.count++; e.last_at = new Date().toISOString(); }
564
+ else if (!e) {
565
+ if (!data.nodes.some((n) => n.address === from) || !data.nodes.some((n) => n.address === to)) return refresh();
566
+ data.edges.push({ kind: "message", from, to, count: 1, last_at: new Date().toISOString() });
567
+ apply();
568
+ }
569
+ const p = edgeEls.get("message:" + from + ">" + to);
570
+ if (!p) return;
571
+ paintEdge(p);
572
+ p.classList.add("live");
573
+ setTimeout(() => p.classList.remove("live"), 1600 / trail);
574
+ const dest = nodeEls.get(to);
575
+ const hit = () => {
576
+ if (!dest) return;
577
+ dest.g.classList.remove("hit");
578
+ void dest.g.getBBox();
579
+ dest.g.classList.add("hit");
580
+ setTimeout(() => dest.g.classList.remove("hit"), 750);
581
+ };
582
+ if (reduce || document.hidden) return hit();
583
+ const cls = kind === "reply" ? " reply" : "";
584
+ const halo = el("circle", { class: "pulse-halo" + cls, r: "11" }, layers.pulses);
585
+ const dot = el("circle", { class: "pulse" + cls, r: "4.5" }, layers.pulses);
586
+ const t0 = performance.now(), dur = 950 / trail;
587
+ const step = (now) => {
588
+ const k = Math.min(1, (now - t0) / dur);
589
+ let q = null;
590
+ try { q = p.getPointAtLength(ease(k) * p.getTotalLength()); } catch {}
591
+ if (q) for (const c of [halo, dot]) { c.setAttribute("cx", r1(q.x)); c.setAttribute("cy", r1(q.y)); }
592
+ if (k < 1 && p.isConnected) requestAnimationFrame(step);
593
+ else { halo.remove(); dot.remove(); hit(); }
594
+ };
595
+ requestAnimationFrame(step);
596
+ }
597
+
598
+ function applyFocus() {
599
+ if (focus && !nodeEls.has(focus)) focus = null;
600
+ svg.classList.toggle("focus", !!focus);
601
+ const near = new Set(focus ? [focus] : []);
602
+ for (const [, p] of edgeEls) {
603
+ const on = !!focus && (p.dataset.from === focus || p.dataset.to === focus);
604
+ p.classList.toggle("hl", on);
605
+ if (on) { near.add(p.dataset.from); near.add(p.dataset.to); }
606
+ }
607
+ for (const [a, e] of nodeEls) { e.g.classList.toggle("hl", near.has(a)); e.g.classList.toggle("on", a === focus); }
608
+ }
609
+
610
+ function showTip(g) {
611
+ const n = data && data.nodes.find((x) => x.address === g.dataset.address);
612
+ if (!n) return;
613
+ const div = (c, t) => { const d = document.createElement("div"); d.className = c; d.textContent = t; return d; };
614
+ const head = document.createElement("div");
615
+ head.className = "t-head";
616
+ const icon = document.createElementNS(NS, "svg");
617
+ icon.setAttribute("viewBox", "0 0 24 24");
618
+ icon.innerHTML = ICONS[n.icon] || ICONS.generic;
619
+ if (COLORED.has(n.icon)) icon.style.color = "var(--h-" + n.icon + ")";
620
+ head.append(icon, document.createTextNode(n.name));
621
+ const sum = (side) => data.edges.filter((e) => e.kind === "message" && e[side] === n.address).reduce((s, e) => s + e.count, 0);
622
+ const out = sum("from"), inn = sum("to");
623
+ const wake = n.how ? n.how : n.kind === "agent" && n.presence === "idle" ? "reads at its next turn" : "";
624
+ const rows = [
625
+ ["presence", n.presence + (wake ? ", " + wake : "")],
626
+ ["status", n.status ? n.status.state + (n.status.note ? ": " + n.status.note : "") : ""],
627
+ ["harness", n.kind === "agent" ? n.harness || "unknown" : ""],
628
+ ["machine", n.machine],
629
+ ["member", (data.machines.find((m) => m.name === n.machine)?.identity || {}).name || ""],
630
+ ["parent", n.parent ? n.parent.split("/").slice(2).join("/") : ""],
631
+ ["messages", out || inn ? out + " sent, " + inn + " received in " + data.minutes + " min" : ""],
632
+ ].filter((r) => r[1]);
633
+ const kv = document.createElement("dl");
634
+ kv.className = "t-kv";
635
+ for (const [k, v] of rows) { const dt = document.createElement("dt"); dt.textContent = k; const dd = document.createElement("dd"); dd.textContent = v; kv.append(dt, dd); }
636
+ tip.replaceChildren(head, div("t-addr", n.address), ...(n.title ? [div("t-row", n.title)] : []), kv);
637
+ tip.hidden = false;
638
+ const c = canvas.getBoundingClientRect(), r = g.getBoundingClientRect();
639
+ let left = r.right - c.left + 10;
640
+ if (left + tip.offsetWidth > canvas.clientWidth - 8) left = r.left - c.left - tip.offsetWidth - 10;
641
+ tip.style.left = Math.max(8, left) + "px";
642
+ tip.style.top = Math.max(8, Math.min(r.top - c.top - 6, canvas.clientHeight - tip.offsetHeight - 8)) + "px";
643
+ }
644
+
645
+ function hideTip() { tip.hidden = true; }
646
+
647
+ function refresh() { document.dispatchEvent(new CustomEvent("scp:refresh")); }
648
+
649
+ /** a refreshed live region; without the stream, a newer message on an edge pulses it */
650
+ function update() {
651
+ const next = read();
652
+ if (!next) return;
653
+ const before = data;
654
+ data = next;
655
+ apply();
656
+ if (!before || streamOpen) return;
657
+ for (const e of next.edges) {
658
+ if (e.kind !== "message") continue;
659
+ const o = before.edges.find((x) => x.kind === "message" && x.from === e.from && x.to === e.to);
660
+ if (!o || e.last_at > o.last_at) pulse(e.from, e.to, "message", false);
661
+ }
662
+ }
663
+
664
+ function onEvent(ev) {
665
+ if (!data || !ev || typeof ev !== "object") return;
666
+ if (ev.type === "message" && Array.isArray(ev.to)) for (const to of ev.to) pulse(ev.from, to, ev.kind, true);
667
+ else if (ev.type === "presence") {
668
+ const n = data.nodes.find((x) => x.address === ev.endpoint);
669
+ if (!n || typeof ev.waking === "boolean" || n.presence === "hibernated") return refresh();
670
+ if (n.presence === ev.presence) return;
671
+ n.presence = ev.presence;
672
+ const m = data.machines.find((x) => x.name === n.machine);
673
+ if (m && ev.presence !== "offline" && ev.presence !== "hibernated") m.online = true;
674
+ apply();
675
+ } else if (ev.type === "endpoints" || ev.type === "hello") refresh();
676
+ }
677
+
678
+ let backoff = 1000;
679
+ function connect() {
680
+ const path = root.getAttribute("data-events");
681
+ if (!path || typeof WebSocket !== "function") return;
682
+ let ws, ping = 0;
683
+ try { ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + path); } catch { return; }
684
+ ws.onopen = () => { backoff = 1000; setStream(true); ping = setInterval(() => { try { ws.send("ping"); } catch {} }, 30000); };
685
+ ws.onmessage = (m) => { if (typeof m.data !== "string" || m.data === "pong") return; try { onEvent(JSON.parse(m.data)); } catch {} };
686
+ ws.onclose = (c) => {
687
+ clearInterval(ping);
688
+ setStream(false);
689
+ if (c.code === 4003) return;
690
+ setTimeout(connect, backoff);
691
+ backoff = Math.min(backoff * 2, 30000);
692
+ };
693
+ }
694
+ function setStream(open) {
695
+ streamOpen = open;
696
+ document.documentElement.setAttribute("data-stream", open ? "open" : "poll");
697
+ for (const s of document.querySelectorAll("[data-graph-stream]")) s.textContent = open ? "live" : "polling";
698
+ }
699
+
700
+ const nodeAt = (e) => (e.target instanceof Element ? e.target.closest(".node") : null);
701
+ svg.addEventListener("click", (e) => { const g = nodeAt(e); focus = g && g.dataset.address !== focus ? g.dataset.address : null; applyFocus(); });
702
+ svg.addEventListener("keydown", (e) => {
703
+ const g = nodeAt(e);
704
+ if (!g || (e.key !== "Enter" && e.key !== " ")) return;
705
+ e.preventDefault();
706
+ focus = g.dataset.address !== focus ? g.dataset.address : null;
707
+ applyFocus();
708
+ });
709
+ svg.addEventListener("pointerover", (e) => { const g = nodeAt(e); if (g) showTip(g); });
710
+ svg.addEventListener("pointerout", (e) => { const g = nodeAt(e); if (g && !g.contains(e.relatedTarget)) hideTip(); });
711
+ svg.addEventListener("focusin", (e) => { const g = nodeAt(e); if (g) showTip(g); });
712
+ svg.addEventListener("focusout", hideTip);
713
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && focus) { focus = null; applyFocus(); } });
714
+ const trailButtons = document.querySelectorAll("[data-trail]");
715
+ function paintTrail() {
716
+ svg.style.setProperty("--trail", String(trail));
717
+ for (const b of trailButtons) b.setAttribute("aria-pressed", String(Number(b.getAttribute("data-trail")) === trail));
718
+ }
719
+ for (const b of trailButtons) b.addEventListener("click", () => { trail = Number(b.getAttribute("data-trail")); save("scp.graph.trails", String(trail)); paintTrail(); });
720
+ paintTrail();
721
+ if (bar) for (const b of bar.querySelectorAll("[data-view-set]")) b.addEventListener("click", () => setView(b.getAttribute("data-view-set")));
722
+ for (const [input, key] of [[offline, "scp.overview.offline"], [nextTurn, "scp.graph.nextturn"]]) {
723
+ if (!input) continue;
724
+ input.checked = load(key) === "1";
725
+ input.addEventListener("change", () => { save(key, input.checked ? "1" : "0"); apply(); });
726
+ }
727
+ let size = "";
728
+ new ResizeObserver(() => { const s = canvas.clientWidth + "x" + (full ? canvas.clientHeight : 0); if (canvas.clientWidth && s !== size) { size = s; apply(); } }).observe(canvas);
729
+ new MutationObserver(update).observe(live, { childList: true });
730
+ setInterval(() => { for (const [, p] of edgeEls) paintEdge(p); }, 20000);
731
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { widths.clear(); shapes.clear(); apply(); });
732
+ setStream(false);
733
+ update();
734
+ connect();
735
+ })();