@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.
- package/README.md +56 -2
- package/dist/public/app.css +187 -0
- package/dist/public/app.js +80 -0
- package/dist/public/graph.css +84 -0
- package/dist/public/graph.js +735 -0
- package/dist/public/index.html +1 -0
- package/dist/public/selfhost.js +1307 -0
- package/dist/server.js +458 -0
- package/package.json +20 -4
|
@@ -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
|
+
})();
|