OXYGENHUB
# c7161ec1a607 spectra / crates / radar / assets / constellation.js
7.9 KiB · JavaScript · last changed Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 1 day ago source blame history raw
1
// The Constellation (spec §17.2): projects as prisms on a ring, agents in
2
// orbit, the owner at the centre, and every event from the stream drawn as
3
// light. Plain SVG and script, no library; the page's still picture stands
4
// in when this does not run.
5
(function () {
6
  "use strict";
7
  var svg = document.getElementById("sky");
8
  var dataEl = document.getElementById("sky-data");
9
  if (!svg || !dataEl || !window.requestAnimationFrame) return;
10
  var NS = "http://www.w3.org/2000/svg";
11
  var CX = 450, CY = 320, RING = 205, ORBIT = 54, PRISM = 24;
12
  var HUES = [0, 30, 55, 120, 190, 230, 275, 320];
13
  var PRESENCE = { online: "#3fb950", busy: "#a371f7", idle: "#d29922", paused: "#d29922", blocked: "#f0883e", offline: "#6e7681" };
14
  var calm = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
15
  var data = JSON.parse(dataEl.textContent);
16
  var start = performance.now();
17
  var prisms = {}, agents = {}, layer = {};
18
19
  function el(name, attrs, parent) {
20
    var e = document.createElementNS(NS, name);
21
    for (var k in attrs) e.setAttribute(k, attrs[k]);
22
    if (parent) parent.appendChild(e);
23
    return e;
24
  }
25
  function text(parent, cls, s) { var t = el("text", { "class": cls }, parent); t.textContent = s; return t; }
26
27
  // A sky that does not change between redraws.
28
  var seed = 7;
29
  function rand() { seed = (seed * 16807) % 2147483647; return seed / 2147483647; }
30
31
  function build() {
32
    var defs = svg.querySelector("defs");
33
    while (svg.firstChild) svg.removeChild(svg.firstChild);
34
    if (defs) svg.appendChild(defs);
35
    prisms = {}; agents = {};
36
    seed = 7;
37
    layer.stars = el("g", {}, svg);
38
    for (var s = 0; s < 90; s++) {
39
      var st = el("circle", { "class": "star", cx: (rand() * 900).toFixed(1), cy: (rand() * 640).toFixed(1), r: (rand() * 1.2 + 0.2).toFixed(2) }, layer.stars);
40
      st.setAttribute("opacity", (rand() * 0.6 + 0.15).toFixed(2));
41
      st.twinkle = rand() * 6.28;
42
    }
43
    layer.lines = el("g", {}, svg);
44
    layer.beams = el("g", {}, svg);
45
    layer.nodes = el("g", {}, svg);
46
    var ps = data.projects || [];
47
    var n = Math.max(ps.length, 1);
48
    ps.forEach(function (p, i) {
49
      var a = -Math.PI / 2 + 2 * Math.PI * i / n;
50
      var x = CX + RING * Math.cos(a), y = CY + RING * Math.sin(a), hue = HUES[i % HUES.length];
51
      prisms[p.name] = { x: x, y: y, hue: hue };
52
      el("line", { "class": "spoke", x1: CX, y1: CY, x2: x.toFixed(1), y2: y.toFixed(1) }, layer.lines);
53
      el("circle", { "class": "orbit", cx: x.toFixed(1), cy: y.toFixed(1), r: ORBIT }, layer.nodes);
54
      var g = el("g", {}, layer.nodes);
55
      prisms[p.name].g = g;
56
      el("polygon", {
57
        "class": "prism", fill: "hsl(" + hue + ",80%,60%)",
58
        points: [0, -PRISM, PRISM * 0.87, PRISM * 0.5, -PRISM * 0.87, PRISM * 0.5].join(" ")
59
      }, g);
60
      var label = text(layer.nodes, "plabel", p.name);
61
      label.setAttribute("x", x.toFixed(1)); label.setAttribute("y", (y + ORBIT + 22).toFixed(1));
62
      (p.agents || []).forEach(function (ag, j, all) {
63
        var node = el("circle", { "class": "agent", r: 7, fill: PRESENCE[ag.presence] || PRESENCE.offline }, layer.nodes);
64
        var t = text(layer.nodes, "alabel", ag.name);
65
        var title = el("title", {}, node); title.textContent = ag.name + " · " + ag.kind + " · " + ag.presence;
66
        agents[ag.name] = { project: p.name, phase: 2 * Math.PI * j / Math.max(all.length, 1), node: node, label: t, presence: ag.presence };
67
      });
68
    });
69
    ps.forEach(function (p) {
70
      var a = prisms[p.name];
71
      (p.depends_on || []).forEach(function (q) {
72
        var b = prisms[q];
73
        if (a && b) el("path", { "class": "link", d: "M" + a.x.toFixed(1) + "," + a.y.toFixed(1) + " Q" + CX + "," + CY + " " + b.x.toFixed(1) + "," + b.y.toFixed(1) }, layer.lines);
74
      });
75
    });
76
    el("circle", { "class": "core", cx: CX, cy: CY, r: 22 }, layer.nodes);
77
    var o = text(layer.nodes, "olabel", data.owner || "you");
78
    o.setAttribute("x", CX); o.setAttribute("y", CY + 44);
79
  }
80
81
  function agentAt(name, t) {
82
    var a = agents[name];
83
    if (!a) return null;
84
    var p = prisms[a.project];
85
    // Busy agents move faster; offline ones stand still.
86
    var speed = a.presence === "busy" ? 1 / 4000 : a.presence === "offline" ? 0 : 1 / 9000;
87
    var ang = a.phase + (calm ? 0 : t * speed);
88
    return { x: p.x + ORBIT * Math.cos(ang), y: p.y + ORBIT * Math.sin(ang) };
89
  }
90
91
  function frame(now) {
92
    var t = now - start;
93
    for (var name in agents) {
94
      var q = agentAt(name, t), a = agents[name];
95
      a.node.setAttribute("cx", q.x.toFixed(1)); a.node.setAttribute("cy", q.y.toFixed(1));
96
      a.label.setAttribute("x", q.x.toFixed(1)); a.label.setAttribute("y", (q.y + 18).toFixed(1));
97
    }
98
    for (var p in prisms) {
99
      var r = calm ? 0 : (t / 60) % 360;
100
      prisms[p].g.setAttribute("transform", "translate(" + prisms[p].x.toFixed(1) + "," + prisms[p].y.toFixed(1) + ") rotate(" + r.toFixed(1) + ")");
101
    }
102
    if (!calm) {
103
      var stars = layer.stars.childNodes;
104
      for (var i = 0; i < stars.length; i += 3) stars[i].setAttribute("opacity", (0.35 + 0.3 * Math.sin(t / 900 + stars[i].twinkle)).toFixed(2));
105
    }
106
    requestAnimationFrame(frame);
107
  }
108
109
  function animate(ms, step, done) {
110
    var t0 = performance.now();
111
    function tick(now) {
112
      var k = Math.min((now - t0) / ms, 1);
113
      step(k);
114
      if (k < 1) requestAnimationFrame(tick); else if (done) done();
115
    }
116
    requestAnimationFrame(tick);
117
  }
118
119
  // A ring of light that grows and fades at a point.
120
  function pulse(at, colour, size) {
121
    if (!at) return;
122
    var c = el("circle", { "class": "ring", cx: at.x.toFixed(1), cy: at.y.toFixed(1), r: 6, stroke: colour }, layer.beams);
123
    animate(calm ? 1 : 900, function (k) { c.setAttribute("r", (6 + k * size).toFixed(1)); c.setAttribute("opacity", (1 - k).toFixed(2)); },
124
      function () { c.remove(); });
125
  }
126
127
  // A beam that shoots from one point to another, then fades.
128
  function beam(from, to, hue) {
129
    if (!from || !to) return;
130
    var colour = "hsl(" + hue + ",95%,62%)";
131
    var line = el("line", { "class": "beam", x1: from.x.toFixed(1), y1: from.y.toFixed(1), x2: from.x.toFixed(1), y2: from.y.toFixed(1), stroke: colour }, layer.beams);
132
    animate(calm ? 1 : 650, function (k) {
133
      var e = 1 - Math.pow(1 - k, 3);
134
      line.setAttribute("x2", (from.x + (to.x - from.x) * e).toFixed(1));
135
      line.setAttribute("y2", (from.y + (to.y - from.y) * e).toFixed(1));
136
    }, function () {
137
      pulse(to, colour, 30);
138
      animate(calm ? 1 : 700, function (k) { line.setAttribute("opacity", (1 - k).toFixed(2)); }, function () { line.remove(); });
139
    });
140
  }
141
142
  function from(actor) {
143
    if (actor === "human") return { x: CX, y: CY };
144
    return agentAt(String(actor || "").replace(/^agent:/, ""), performance.now() - start);
145
  }
146
147
  var timer = null;
148
  function reload() {
149
    clearTimeout(timer);
150
    timer = setTimeout(function () {
151
      fetch("/radar/constellation.json", { credentials: "same-origin" })
152
        .then(function (r) { return r.ok ? r.json() : null; })
153
        .then(function (d) { if (d) { data = d; build(); } });
154
    }, 250);
155
  }
156
157
  build();
158
  requestAnimationFrame(frame);
159
  if (!window.EventSource) return;
160
  var es = new EventSource("/radar/events");
161
  es.onmessage = function (m) {
162
    var e;
163
    try { e = JSON.parse(m.data); } catch (_) { return; }
164
    var k = e.kind || "";
165
    if (k === "message.posted") {
166
      var p = e.project && prisms[e.project];
167
      var to = p ? { x: p.x, y: p.y } : { x: CX, y: CY };
168
      var hue = p ? p.hue : 200;
169
      if (e.actor === "human" && !p) { pulse({ x: CX, y: CY }, "hsl(200,95%,62%)", 60); return; }
170
      beam(from(e.actor), to, e.actor === "human" ? 50 : hue);
171
    } else if (k === "tool.called") {
172
      pulse(from(e.actor), "#58a6ff", 16);
173
    } else if (k.indexOf("guard.") === 0) {
174
      pulse(from(e.subject ? "agent:" + e.subject : e.actor) || { x: CX, y: CY }, "#f85149", 70);
175
    } else if (/^(agent\.|project\.)/.test(k)) {
176
      reload();
177
    }
178
  };
179
})();