relmap.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. /* Relation map: who talks to whom.
  2. *
  3. * pisg writes the data as JSON in <script id="relmap-data"> and this script draws it as
  4. * an SVG into <svg id="relmap-svg">, with details in <div id="relmap-info">.
  5. * No libraries. The layout is a small force simulation with a fixed starting point, so
  6. * the same data always gives the same picture.
  7. *
  8. * data = { nodes: [{id, lines, words, hours:[4], partners:[[nick, strength]...]}],
  9. * edges: [{a, b, w, ab:[direct, mentions, replies], ba:[...]}], // a, b = node index
  10. * i18n: { text key: text } }
  11. */
  12. (function () {
  13. "use strict";
  14. var dataEl = document.getElementById("relmap-data");
  15. var svg = document.getElementById("relmap-svg");
  16. var info = document.getElementById("relmap-info");
  17. if (!dataEl || !svg || !info) return;
  18. var data;
  19. try { data = JSON.parse(dataEl.textContent); } catch (e) { return; }
  20. var nodes = data.nodes || [], edges = data.edges || [], T = data.i18n || {};
  21. if (nodes.length < 2 || !edges.length) return;
  22. var NS = "http://www.w3.org/2000/svg";
  23. var W = 960, H = 620, PAD = 40;
  24. function fmt(s, vars) {
  25. return String(s || "").replace(/\[:(\w+)\]/g, function (m, k) { return vars && vars[k] !== undefined ? vars[k] : m; });
  26. }
  27. function num(n) { return Number(n || 0).toLocaleString(); }
  28. function el(name, attrs, text) {
  29. var e = document.createElementNS(NS, name);
  30. for (var k in attrs) if (Object.prototype.hasOwnProperty.call(attrs, k)) e.setAttribute(k, attrs[k]);
  31. if (text !== undefined) e.textContent = text;
  32. return e;
  33. }
  34. function html(tag, cls, text) {
  35. var e = document.createElement(tag);
  36. if (cls) e.className = cls;
  37. if (text !== undefined) e.textContent = text;
  38. return e;
  39. }
  40. // ---- sizes and colours -------------------------------------------------------------
  41. var maxLines = 1, maxW = 1;
  42. nodes.forEach(function (n) { if (n.lines > maxLines) maxLines = n.lines; });
  43. edges.forEach(function (e) { if (e.w > maxW) maxW = e.w; });
  44. nodes.forEach(function (n) {
  45. n.r = 7 + 20 * Math.sqrt(n.lines / maxLines);
  46. var best = 0;
  47. for (var i = 1; i < 4; i++) if ((n.hours[i] || 0) > (n.hours[best] || 0)) best = i;
  48. n.t = best; // 0 night, 1 morning, 2 afternoon, 3 evening
  49. });
  50. // ---- layout (Fruchterman-Reingold, fixed start, so it is repeatable) -----------------
  51. function layout() {
  52. var n = nodes.length, i, j, k;
  53. var pos = nodes.map(function (nd, idx) {
  54. var a = (2 * Math.PI * idx) / n;
  55. return { x: W / 2 + 0.36 * W * Math.cos(a), y: H / 2 + 0.36 * H * Math.sin(a), dx: 0, dy: 0 };
  56. });
  57. var area = (W - 2 * PAD) * (H - 2 * PAD);
  58. var kk = 0.75 * Math.sqrt(area / n);
  59. var steps = 320;
  60. for (var s = 0; s < steps; s++) {
  61. var temp = (W / 9) * (1 - s / steps) + 0.5;
  62. for (i = 0; i < n; i++) { pos[i].dx = 0; pos[i].dy = 0; }
  63. for (i = 0; i < n; i++) { // everyone pushes everyone away
  64. for (j = i + 1; j < n; j++) {
  65. var dx = pos[i].x - pos[j].x, dy = pos[i].y - pos[j].y;
  66. var d = Math.sqrt(dx * dx + dy * dy) || 0.01;
  67. var minGap = nodes[i].r + nodes[j].r + 14;
  68. var f = (kk * kk) / d + (d < minGap ? (minGap - d) * 2 : 0);
  69. pos[i].dx += (dx / d) * f; pos[i].dy += (dy / d) * f;
  70. pos[j].dx -= (dx / d) * f; pos[j].dy -= (dy / d) * f;
  71. }
  72. }
  73. for (k = 0; k < edges.length; k++) { // linked people pull together, harder when they talk more
  74. var e = edges[k], a = pos[e.a], b = pos[e.b];
  75. var ex = a.x - b.x, ey = a.y - b.y;
  76. var ed = Math.sqrt(ex * ex + ey * ey) || 0.01;
  77. var pull = ((ed * ed) / kk) * (0.15 + 0.85 * Math.sqrt(e.w / maxW));
  78. a.dx -= (ex / ed) * pull; a.dy -= (ey / ed) * pull;
  79. b.dx += (ex / ed) * pull; b.dy += (ey / ed) * pull;
  80. }
  81. for (i = 0; i < n; i++) { // a little gravity keeps loners on the page
  82. pos[i].dx -= (pos[i].x - W / 2) * 0.05; pos[i].dy -= (pos[i].y - H / 2) * 0.05;
  83. var len = Math.sqrt(pos[i].dx * pos[i].dx + pos[i].dy * pos[i].dy) || 0.01;
  84. var move = Math.min(len, temp);
  85. pos[i].x += (pos[i].dx / len) * move; pos[i].y += (pos[i].dy / len) * move;
  86. }
  87. }
  88. // Not to scale, on purpose. Fitting the drawing to its farthest nodes squeezes the busy middle
  89. // into a blob, so keep only each node's direction from the centre and re-space them by rank:
  90. // the nearest goes near the middle, the farthest to the edge, evenly in between.
  91. var cx = median(pos.map(function (q) { return q.x; })), cy = median(pos.map(function (q) { return q.y; }));
  92. var sx = Math.max(median(pos.map(function (q) { return Math.abs(q.x - cx); })), 1);
  93. var sy = Math.max(median(pos.map(function (q) { return Math.abs(q.y - cy); })), 1);
  94. var polar = pos.map(function (q, idx) {
  95. var nx = (q.x - cx) / sx, ny = (q.y - cy) / sy;
  96. return { i: idx, a: Math.atan2(ny, nx), d: Math.sqrt(nx * nx + ny * ny) };
  97. });
  98. polar.sort(function (p, q) { return p.d - q.d || p.i - q.i; });
  99. var RX = W / 2 - PAD - 34, RY = H / 2 - PAD - 26;
  100. polar.forEach(function (p, rank) {
  101. var t = Math.pow((rank + 0.6) / (n + 0.2), 0.62); // <1 keeps more room near the middle
  102. nodes[p.i].x = W / 2 + Math.cos(p.a) * RX * t;
  103. nodes[p.i].y = H / 2 + Math.sin(p.a) * RY * t;
  104. });
  105. // stretch the result to fill the picture, so no band of it stays empty
  106. var x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
  107. nodes.forEach(function (nd) { x0 = Math.min(x0, nd.x); x1 = Math.max(x1, nd.x); y0 = Math.min(y0, nd.y); y1 = Math.max(y1, nd.y); });
  108. nodes.forEach(function (nd) {
  109. nd.x = (PAD + 30) + ((nd.x - x0) / ((x1 - x0) || 1)) * (W - 2 * (PAD + 30));
  110. nd.y = (PAD + 8) + ((nd.y - y0) / ((y1 - y0) || 1)) * (H - 2 * PAD - 44);
  111. });
  112. separate();
  113. }
  114. function median(a) { return pct(a, 0.5); }
  115. function pct(a, p) {
  116. var b = a.slice().sort(function (x, y) { return x - y; });
  117. return b[Math.min(b.length - 1, Math.floor(p * b.length))];
  118. }
  119. // Push apart circles that still overlap, keeping every node (and room for its label) in the picture.
  120. function separate() {
  121. var n = nodes.length;
  122. for (var pass = 0; pass < 200; pass++) {
  123. var moved = false;
  124. for (var i = 0; i < n; i++) {
  125. for (var j = i + 1; j < n; j++) {
  126. var dx = nodes[i].x - nodes[j].x, dy = nodes[i].y - nodes[j].y;
  127. var d = Math.sqrt(dx * dx + dy * dy);
  128. var gap = nodes[i].r + nodes[j].r + 10;
  129. if (d < gap) {
  130. if (d < 0.01) { dx = 1; dy = 0; d = 1; }
  131. var push = (gap - d) / 2 + 0.05;
  132. nodes[i].x += (dx / d) * push; nodes[i].y += (dy / d) * push;
  133. nodes[j].x -= (dx / d) * push; nodes[j].y -= (dy / d) * push;
  134. moved = true;
  135. }
  136. }
  137. }
  138. for (i = 0; i < n; i++) {
  139. nodes[i].x = Math.max(nodes[i].r + 8, Math.min(W - nodes[i].r - 8, nodes[i].x));
  140. nodes[i].y = Math.max(nodes[i].r + 8, Math.min(H - nodes[i].r - 22, nodes[i].y));
  141. }
  142. if (!moved) break;
  143. }
  144. }
  145. layout();
  146. // Labels: busiest nodes first, each tried below, above, right, then left of its circle, and kept
  147. // only where it touches no other label and no circle. The rest show on hover or when selected.
  148. function placeLabels() {
  149. var boxes = [], order = nodes.map(function (_, i) { return i; })
  150. .sort(function (a, b) { return nodes[b].lines - nodes[a].lines; });
  151. function hits(b) {
  152. for (var k = 0; k < boxes.length; k++) {
  153. var o = boxes[k];
  154. if (b.x < o.x + o.w && b.x + b.w > o.x && b.y < o.y + o.h && b.y + b.h > o.y) return true;
  155. }
  156. for (k = 0; k < nodes.length; k++) {
  157. var c = nodes[k];
  158. if (c.x + c.r + 3 > b.x && c.x - c.r - 3 < b.x + b.w && c.y + c.r + 3 > b.y && c.y - c.r - 3 < b.y + b.h) return true;
  159. }
  160. return false;
  161. }
  162. order.forEach(function (i) {
  163. var n = nodes[i], w = shorten(n.id).length * 6.7 + 4, h = 14;
  164. var tries = [
  165. { a: "middle", x: n.x, y: n.y + n.r + 12, bx: n.x - w / 2, by: n.y + n.r + 1 },
  166. { a: "middle", x: n.x, y: n.y - n.r - 4, bx: n.x - w / 2, by: n.y - n.r - 15 },
  167. { a: "start", x: n.x + n.r + 4, y: n.y + 4, bx: n.x + n.r + 3, by: n.y - 7 },
  168. { a: "end", x: n.x - n.r - 4, y: n.y + 4, bx: n.x - n.r - w - 3, by: n.y - 7 }
  169. ];
  170. n.label = null;
  171. for (var t = 0; t < tries.length; t++) {
  172. var b = { x: tries[t].bx, y: tries[t].by, w: w, h: h };
  173. if (b.x < 2 || b.x + b.w > W - 2 || b.y < 2 || b.y + b.h > H - 2) continue;
  174. if (!hits(b)) { n.label = tries[t]; boxes.push(b); break; }
  175. }
  176. if (!n.label) n.label = { a: "middle", x: n.x, y: n.y + n.r + 12, hidden: true };
  177. });
  178. }
  179. function shorten(s) { return s.length > 15 ? s.slice(0, 14) + "\u2026" : s; }
  180. placeLabels();
  181. // ---- drawing --------------------------------------------------------------------------
  182. var edgeLayer = el("g", { "class": "rm-edges" }), nodeLayer = el("g", { "class": "rm-nodes" });
  183. svg.appendChild(edgeLayer);
  184. svg.appendChild(nodeLayer);
  185. var nodeEls = [], edgeEls = [], neighbours = nodes.map(function () { return {}; });
  186. edges.forEach(function (e, idx) {
  187. var a = nodes[e.a], b = nodes[e.b];
  188. neighbours[e.a][e.b] = idx; neighbours[e.b][e.a] = idx;
  189. var width = 1 + 6 * Math.sqrt(e.w / maxW);
  190. var g = el("g", { "class": "rm-edge" });
  191. g.appendChild(el("line", { "class": "rm-hit", x1: a.x, y1: a.y, x2: b.x, y2: b.y, "stroke-width": Math.max(14, width + 8) }));
  192. g.appendChild(el("line", { "class": "rm-line", x1: a.x, y1: a.y, x2: b.x, y2: b.y, "stroke-width": width.toFixed(2) }));
  193. g.appendChild(el("title", {}, fmt(T.rel_between, { a: a.id, b: b.id }) + " - " + T.rel_strength + " " + num(e.w)));
  194. g.addEventListener("click", function (ev) { ev.stopPropagation(); selectEdge(idx); });
  195. edgeLayer.appendChild(g);
  196. edgeEls.push(g);
  197. });
  198. nodes.forEach(function (n, idx) {
  199. var g = el("g", { "class": "rm-node t" + n.t, tabindex: "0", role: "button",
  200. "aria-label": n.id + ", " + num(n.lines) + " " + T.rel_lines });
  201. g.appendChild(el("circle", { cx: n.x, cy: n.y, r: n.r.toFixed(1) }));
  202. g.appendChild(el("text", { x: n.label.x.toFixed(1), y: n.label.y.toFixed(1), "text-anchor": n.label.a,
  203. "class": n.label.hidden ? "rm-lbl off" : "rm-lbl" }, shorten(n.id)));
  204. g.appendChild(el("title", {}, n.id + " - " + num(n.lines) + " " + T.rel_lines));
  205. g.addEventListener("click", function (ev) { ev.stopPropagation(); selectNode(idx); });
  206. g.addEventListener("keydown", function (ev) {
  207. if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); selectNode(idx); }
  208. });
  209. g.addEventListener("mouseenter", function () { focusNode(idx); });
  210. g.addEventListener("mouseleave", function () { if (selected === null) clearFocus(); else focusSelection(); });
  211. nodeLayer.appendChild(g);
  212. nodeEls.push(g);
  213. });
  214. // ---- highlighting ------------------------------------------------------------------------
  215. var selected = null; // {type: "node"|"edge", i}
  216. function setClasses(nodeSet, edgeSet, selNode, selEdge) {
  217. var dim = !!(nodeSet || edgeSet);
  218. svg.classList.toggle("dim", dim);
  219. nodeEls.forEach(function (g, i) {
  220. g.classList.toggle("hl", !!(nodeSet && nodeSet[i]));
  221. g.classList.toggle("sel", selNode === i);
  222. });
  223. edgeEls.forEach(function (g, i) {
  224. g.classList.toggle("hl", !!(edgeSet && edgeSet[i]));
  225. g.classList.toggle("sel", selEdge === i);
  226. });
  227. }
  228. function focusNode(i) {
  229. var ns = {}, es = {};
  230. ns[i] = true;
  231. for (var j in neighbours[i]) { ns[j] = true; es[neighbours[i][j]] = true; }
  232. setClasses(ns, es, selected && selected.type === "node" ? selected.i : null, null);
  233. }
  234. function clearFocus() { setClasses(null, null, null, null); }
  235. function focusSelection() {
  236. if (!selected) return clearFocus();
  237. if (selected.type === "node") focusNode(selected.i);
  238. else {
  239. var e = edges[selected.i], ns = {}, es = {};
  240. ns[e.a] = true; ns[e.b] = true; es[selected.i] = true;
  241. setClasses(ns, es, null, selected.i);
  242. }
  243. }
  244. // ---- details panel --------------------------------------------------------------------------
  245. var BUCKETS = ["rel_time0", "rel_time1", "rel_time2", "rel_time3"];
  246. function reset() {
  247. selected = null;
  248. clearFocus();
  249. info.textContent = T.rel_pick || "";
  250. }
  251. function line(label, parts) { var p = html("p", "ri-row"); p.appendChild(html("b", "", label + " ")); p.appendChild(document.createTextNode(parts)); return p; }
  252. function selectNode(i) {
  253. selected = { type: "node", i: i };
  254. focusSelection();
  255. var n = nodes[i];
  256. info.textContent = "";
  257. info.appendChild(html("h4", "ri-title", n.id));
  258. info.appendChild(html("p", "ri-meta", num(n.lines) + " " + T.rel_lines + " · " + num(n.words) + " " + T.rel_words));
  259. info.appendChild(html("p", "ri-meta", T.rel_active + " " + (T[BUCKETS[n.t]] || "")));
  260. if (n.partners && n.partners.length) {
  261. info.appendChild(html("h5", "ri-sub", T.rel_partners));
  262. var ul = html("ul", "ri-list");
  263. n.partners.forEach(function (p) {
  264. var li = html("li", ""), idx = -1;
  265. nodes.forEach(function (o, k) { if (o.id === p[0]) idx = k; });
  266. if (idx >= 0) {
  267. var b = html("button", "ri-link", p[0]);
  268. b.type = "button";
  269. b.addEventListener("click", function () { selectNode(idx); });
  270. li.appendChild(b);
  271. } else li.appendChild(document.createTextNode(p[0]));
  272. li.appendChild(html("span", "ri-num", num(p[1])));
  273. ul.appendChild(li);
  274. });
  275. info.appendChild(ul);
  276. }
  277. }
  278. function selectEdge(i) {
  279. selected = { type: "edge", i: i };
  280. focusSelection();
  281. var e = edges[i], a = nodes[e.a].id, b = nodes[e.b].id;
  282. info.textContent = "";
  283. info.appendChild(html("h4", "ri-title", fmt(T.rel_between, { a: a, b: b })));
  284. info.appendChild(html("p", "ri-meta", T.rel_strength + ": " + num(e.w)));
  285. function dir(from, to, v) {
  286. return line(fmt(T.rel_toward, { a: from, b: to }) + ":",
  287. num(v[0]) + " " + T.rel_direct + ", " + num(v[1]) + " " + T.rel_mentions);
  288. }
  289. info.appendChild(dir(a, b, e.ab));
  290. info.appendChild(dir(b, a, e.ba));
  291. info.appendChild(line(T.rel_replies + ":", num(e.ab[2] + e.ba[2])));
  292. }
  293. svg.addEventListener("click", reset);
  294. document.addEventListener("keydown", function (ev) { if (ev.key === "Escape" && selected) reset(); });
  295. info.textContent = T.rel_pick || "";
  296. })();