Version 1 of 1 · · Current version

A drawing library, served at /viz/brain.js.

// The brain (SpecUi 2.26, the Thoughts page with ThoughtFlow on): a map of this project — its classes (from its Spec:
// topics) on an inner ring, its largest features (from Spec, SpecUi, Design and Testing) around them, in App:Dashboard's
// spring layout, colours and caption — that follows what is being written: the newest class or feature named lights up
// and the map glides to it, the ones named earlier stay lit as a trail. Lifted from App:Dashboard (`domain()`, `brain()`,
// `d2parse()`), which keeps its own copy for now (an app can't load the page's scripts). One module, no page in it but
// the elements it is handed; the Thoughts page inlines it, and it runs in Node as it is for the tests.
//
// mount(el, {project, parts, load, store}) → {follow(text), listening(on), level(v), collapse(on), state()}

const HEAD = /^(#{2,4})\s+(.*?)\s*(?:\{#([\w-]+)\})?\s*$/;
const clean = s => s.replace(/\[\[[^\]|]*\|([^\]]*)\]\]/g, "$1").replace(/\[\[([^\]]*)\]\]/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*`]/g, "").replace(/\s+/g, " ").trim();
const title = s => clean(s).replace(/^\d+(\.\d+)*\.?\s+/, "").replace(/\s*\((Razie|decided|design tests)[^)]*\)\s*/gi, " ").trim();

/** The features of a project's Spec and SpecUi (a section's behaviour codes, by prefix), largest first: {id, name, beh,
 * codes (beh's), topic (where it is first)}. */
export function featuresOf(T) {
  const feats = new Map(), seen = new Set();
  for (const topic of ["Spec", "SpecUi"]) {
    let sec = { title: topic };
    for (const line of (T[topic] || "").split("\n")) {
      const h = HEAD.exec(line); if (h) { sec = { title: title(h[2]) }; continue }
      const m = /\^([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*-\d+[a-z]?)\s*$/.exec(line); if (!m || !/^\s*[-*]\s/.test(line)) continue;
      const code = m[1], pre = code.replace(/-\d+[a-z]?$/, "");
      if (seen.has(code)) continue; seen.add(code);
      if (!feats.has(pre)) feats.set(pre, { id: pre, name: sec.title, beh: [], codes: [], topic });
      feats.get(pre).beh.push(clean(line.replace(/^\s*[-*]\s+/, "").replace(/\s*\^[\w-]+\s*$/, ""))); feats.get(pre).codes.push(code);
    }
  }
  return [...feats.values()].sort((a, b) => b.beh.length - a.beh.length);
}
/** The classes declared in these Spec topics' texts: {name, fields: [{name, type, many}], group, tip, also}. */
export function classesOf(texts) {
  const C = [];
  for (const t of texts) {
    const re = /\$class\s+(\w+)\s*\(([\s\S]*?)\n\)([^\n]*)/g; let m;
    while ((m = re.exec(t))) {
      const fields = []; for (const l of m[2].split("\n")) { const f = /^\s*(?:@\w+\s+)*(\w+)\s*:\s*(?:<>)?\s*(\w+)\s*([*?]?)/.exec(l); if (f) fields.push({ name: f[1], type: f[2], many: f[3] === "*" }) }
      // its other names: an *Also called:* line after it, before the next heading or class
      const after = t.slice(re.lastIndex).split(/\n#{1,6}\s|\$class\s/)[0], a = /\*{0,2}Also called:\*{0,2}\s*([^\n]*)/i.exec(after);
      const also = a ? a[1].replace(/\([^)]*\)/g, "").split(/\.(?:\s|$)/)[0].split(/[,;]/).map(x => x.replace(/[`*]/g, "").trim()).filter(Boolean) : [];
      C.push({ name: m[1], fields, group: (/@group\("([^"]*)"\)/.exec(m[3]) || [])[1] || "", tip: (/@tooltip\("([^"]*)"\)/.exec(m[3]) || [])[1] || "", also });
    }
  }
  return C;
}

const STOP = new Set(["the", "and", "for", "with", "from", "that", "this", "page", "pages", "your", "their", "into", "over", "when", "what", "each", "every", "more"]);
// a node and the words that name it: a class by its name (and plural, split camel case) and its *Also called* names, a
// feature by its name and id, both by the longer words of their label; a behaviour only by its code written out
const classNode = c => ({ id: "c:" + c.name, kind: "class", label: c.name, group: c.group, tip: c.tip, words: [c.name.toLowerCase(), c.name.toLowerCase() + "s", c.name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(), ...(c.also || []).map(x => x.toLowerCase())], r: 7 });
const featureNode = f => ({ id: "f:" + f.id, kind: "feature", label: f.name, sub: f.beh.length + " behaviours", words: [f.name.toLowerCase(), f.id], r: 4, f });
function keyed(n) { const ks = new Set(n.words); if (n.kind !== "behaviour") for (const w of n.label.toLowerCase().split(/[^a-z0-9]+/)) if (w.length >= 4 && !STOP.has(w)) ks.add(w); n.keys = [...ks].filter(k => k.length >= 3); return n }
/** The model a text can name, unplaced: classes, every feature, every behaviour (by its code). */
export function modelNodes(C, F) {
  const N = [...C.map(classNode), ...F.map(featureNode)];
  for (const f of F) (f.codes || []).forEach((code, i) => N.push({ id: "b:" + code, kind: "behaviour", label: code.toUpperCase(), words: [code], f, code, beh: f.beh[i] }));
  return N.map(keyed);
}
/** The map: nodes (classes, then the 36 largest features) with the words that name them, links, laid out by springs. */
export function graph(C, Fall) {
  const byName = new Map(C.map(c => [c.name, c])), N = [], E = [];
  for (const c of C) N.push(classNode(c));
  const node = new Map(N.map(n => [n.id, n]));
  for (const c of C) for (const f of c.fields) if (byName.has(f.type) && f.type !== c.name) E.push({ a: node.get("c:" + c.name), b: node.get("c:" + f.type), label: f.name + (f.many ? " (many)" : "") });
  const F = Fall.slice(0, 36);
  for (const f of F) {
    const n = featureNode(f); N.push(n);
    const text = f.beh.join(" ").toLowerCase();
    for (const c of C) { const k = text.split(c.name.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()).length - 1; if (k >= 2) E.push({ a: n, b: node.get("c:" + c.name), soft: true, label: "mentions" }) }
  }
  for (const n of N) keyed(n);
  // classes on an inner ring by group, features on an outer one, then a few rounds of springs
  const cls = N.filter(n => n.kind === "class").sort((x, y) => x.group.localeCompare(y.group)), fe = N.filter(n => n.kind === "feature");
  cls.forEach((n, i) => { const t = i / Math.max(1, cls.length) * 6.283; n.x = Math.cos(t) * 150; n.y = Math.sin(t) * 110 });
  fe.forEach((n, i) => { const t = i / Math.max(1, fe.length) * 6.283 + .3; n.x = Math.cos(t) * (cls.length ? 330 : 190); n.y = Math.sin(t) * (cls.length ? 240 : 140) });
  for (let k = 0; k < 160; k++) {
    for (const a of N) for (const b of N) { if (a === b) continue; const dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy + 1, f = 2600 / d2; a.x += dx / Math.sqrt(d2) * f; a.y += dy / Math.sqrt(d2) * f }
    for (const e of E) { const dx = e.b.x - e.a.x, dy = e.b.y - e.a.y, d = Math.sqrt(dx * dx + dy * dy) + 1, f = (d - (e.soft ? 170 : 125)) * .02; e.a.x += dx / d * f; e.a.y += dy / d * f; e.b.x -= dx / d * f; e.b.y -= dy / d * f }
  }
  return { N, E, C, F };
}
/**
 * What the text names, as a trail: each node named (a whole word or words of its keys: its name, plural, split camel
 * case, the longer words of its label), in the order of where it was named last; the last is the newest.
 */
export function trailOf(text, N) {
  const last = new Map();
  for (const h of hitsOf(text, N)) if (!last.has(h.n) || last.get(h.n) < h.i) last.set(h.n, h.i);
  return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([n]) => n);
}
/** How often the texts name each node, and which texts do: Map node → {count, by: [the texts' indexes]}. */
export function mentionsOf(texts, N) {
  const out = new Map();
  texts.forEach((text, j) => { for (const h of hitsOf(text, N)) { const m = out.get(h.n) || { count: 0, by: [] }; m.count++; if (!m.by.includes(j)) m.by.push(j); out.set(h.n, m) } });
  return out;
}
// each place a node is named: whole words, case-insensitive, a trailing s allowed
function hitsOf(text, N) {
  const t = String(text).toLowerCase(), hits = [], word = c => /[a-z0-9]/.test(c || " ");
  for (const n of N) for (const k of n.keys) for (let i = t.indexOf(k); i >= 0; i = t.indexOf(k, i + 1)) {
    if (word(t[i - 1])) continue; const e = i + k.length;
    if (!word(t[e])) hits.push({ n, i, len: k.length }); else if (t[e] === "s" && !word(t[e + 1])) hits.push({ n, i, len: k.length + 1 });
  }
  // where several are named by the same words, the longest name wins, then a class over a feature ("focus view" is
  // FocusView, not Focus; "focus" alone is the class Focus, not the feature *Focus pages*); words inside a longer name
  // that won are that name's
  hits.sort((a, b) => b.len - a.len || (a.n.kind === "class" ? 0 : 1) - (b.n.kind === "class" ? 0 : 1));
  const taken = [];
  for (const h of hits) if (!taken.some(x => h.i < x.i + x.len && x.i < h.i + h.len)) taken.push(h);
  return taken;
}

/** The data, read once a page view and kept for the session: the project's Spec: topics (the first 12 by name), then
 *  Spec, SpecUi, Design, Testing; `partial` when a read failed twice (shown, never kept). */
export async function loadMap(get, store, project, build) {
  // kept for the session under the project and the build, for ten minutes (the Spec moves on); a load in which any read
  // failed — a 429 from the burst, a timeout — is never kept, so one bad load can't stand for the whole session
  const key = "d2.brain." + (project || "") + "." + (build || ""), now = Date.now();
  try { const c = store && JSON.parse(store.getItem(key) || "null"); if (c && c.at && now - c.at < 600000 && c.d) return c.d } catch (e) { }
  let partial = false;
  // a failed read is tried again once; a topic that isn't there (404) is no failure, just empty
  const gone = e => /^404\b/.test(String(e && e.message));
  const once = async u => { try { return await get(u) } catch (e) { if (gone(e)) return null; try { return await get(u) } catch (x) { if (!gone(x)) partial = true; return null } } };
  const text = async n => { const r = await once("/api/v2/topics/" + n + "?format=json"); return (r && r.text) || "" };
  // at most four reads at a time, in the order given
  const pool = async (xs, f) => { const out = new Array(xs.length); let k = 0; await Promise.all(Array.from({ length: Math.min(4, xs.length) }, async () => { while (k < xs.length) { const i = k++; out[i] = await f(xs[i]) } })); return out };
  const list = await once("/api/v2/topics?category=Spec");
  if (!list) partial = true;
  const names = ((list && list.data) || []).map(t => t.name).sort().slice(0, 12);   // sorted first: the same 12 whatever order the list comes in
  const C = classesOf(await pool(names, text));
  const T = {}, tn = ["Spec", "SpecUi", "Design", "Testing"], tt = await pool(tn, text); tn.forEach((n, i) => { T[n] = tt[i] });
  const d = { C, F: featuresOf(T).slice(0, 36).map(f => ({ id: f.id, name: f.name, beh: f.beh.slice(0, 40) })), ...(partial ? { partial: true } : {}) };
  if (!partial) try { store && store.setItem(key, JSON.stringify({ at: now, d })) } catch (e) { }
  return d;
}

const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
/**
 * The brain in `el`. `o.parts` are its elements ({canvas, cap, meter}); `o.load()` gives {C, F}; `o.open(n)` opens a
 * dot. Without a canvas context (no browser) it keeps its state and caption and draws nothing.
 */
export function mount(el, o = {}) {
  const p = o.parts || {}, cv = p.canvas, cap = p.cap, meter = p.meter, W0 = typeof window !== "undefined" ? window : {};
  const still = !!(W0.matchMedia && W0.matchMedia("(prefers-reduced-motion: reduce)").matches);
  const g = cv && cv.getContext ? cv.getContext("2d") : null, raf = W0.requestAnimationFrame ? f => W0.requestAnimationFrame(f) : null;
  let M = null, trail = [], focus = null, text = "", on = false, lvl = 0, folded = false;
  let W = 0, H = 0, dpr = 1, cam = { x: 0, y: 0, z: 1 }, to = { x: 0, y: 0, z: 1 }, pulses = [], t0 = 0, drawing = false;
  const nb = n => M.E.filter(e => e.a === n || e.b === n).map(e => ({ n: e.a === n ? e.b : e.a, e }));
  const setCap = h => { if (cap) cap.innerHTML = h };
  function size() { if (!cv) return; dpr = Math.min(2, W0.devicePixelRatio || 1); W = cv.clientWidth || 0; H = cv.clientHeight || 0; cv.width = W * dpr; cv.height = H * dpr }
  function fit() { if (!M) return; let x0 = 1e9, x1 = -1e9, y0 = 1e9, y1 = -1e9; for (const n of M.N) { x0 = Math.min(x0, n.x); x1 = Math.max(x1, n.x); y0 = Math.min(y0, n.y); y1 = Math.max(y1, n.y) }
    to = M.N.length ? { x: (x0 + x1) / 2, y: (y0 + y1) / 2, z: Math.min(W / (x1 - x0 + 120), H / (y1 - y0 + 70)) || 1 } : { x: 0, y: 0, z: 1 } }
  function rest() { const C = M.C, F = M.F;
    return (C.length ? "<b>" + C.length + " classes</b> and their links" + (F.length ? ", with the " + F.length + " largest features around them" : "") + ". Name one in your thought, or tap a dot."
      : F.length ? "<b>" + F.length + " features</b>. This project has no class model yet, so the map shows its features only." : "No classes or features to draw yet: the map fills in from the project's Spec.")
      + (M.partial ? ' <span class="th-bpart">map partial</span>' : "") }
  function go(n) {
    focus = n; if (!M) return;
    if (!n) { fit(); pulses = []; return setCap(rest()) }
    const ns = nb(n); let x0 = n.x, x1 = n.x, y0 = n.y, y1 = n.y;
    for (const { n: m } of ns) { x0 = Math.min(x0, m.x); x1 = Math.max(x1, m.x); y0 = Math.min(y0, m.y); y1 = Math.max(y1, m.y) }
    to = { x: (x0 + x1) / 2, y: (y0 + y1) / 2, z: Math.min(2.2, W / (x1 - x0 + 220), H / (y1 - y0 + 120)) || 1 };
    pulses = still ? [] : ns.slice(0, 12).map(({ n: m }, i) => ({ a: n, b: m, p: -i * .12 }));
    setCap(caption(n));
  }
  // each concept's name is its link (what Open was); folded to a strip, the active one's related concepts follow the
  // trail, lighter, as many as the strip has room for
  const name = (x, n) => { const t = x === n ? "<b>" + esc(x.label) + "</b>" : esc(x.label); return o.href ? '<a class="th-bn" href="' + esc(o.href(x)) + '">' + t + "</a>" : t };
  function caption(n) {
    let h = "Following you: " + trail.map(x => name(x, n)).join(" → ");
    if (!folded) return h;
    let room = (cap && cap.clientWidth ? Math.floor(cap.clientWidth / 7) : 110) - 15 - trail.reduce((a, x) => a + x.label.length + 3, 0);
    const rel = [];
    for (const x of related(n)) { if (x.label.length + 3 > room) break; room -= x.label.length + 3; rel.push(x) }
    return rel.length ? h + ' <span class="th-brel">→ ' + rel.map(x => name(x, null)).join(" · ") + "</span>" : h;
  }
  /** The concepts related to n, nearest first: its references in the class model, then the features that mention it; none on the trail. */
  function related(n) {
    const d = m => (m.x - n.x) ** 2 + (m.y - n.y) ** 2, ns = nb(n).filter(x => !trail.includes(x.n));
    return [...ns.filter(x => !x.e.soft).sort((a, b) => d(a.n) - d(b.n)), ...ns.filter(x => x.e.soft).sort((a, b) => d(a.n) - d(b.n))].map(x => x.n).filter((m, i, a) => a.indexOf(m) === i);
  }
  function draw(ts) {
    if (!g) return; drawing = true;
    const dt = Math.min(.05, (ts - t0) / 1000 || 0); t0 = ts; const k = still ? 1 : Math.min(1, dt * 4);
    cam.x += (to.x - cam.x) * k; cam.y += (to.y - cam.y) * k; cam.z += (to.z - cam.z) * k;
    const css = getComputedStyle(document.documentElement), col = n => css.getPropertyValue(n).trim();
    const ink = col("--ink"), line = col("--line"), acc = col("--accent") || "#4a7", attn = col("--attn") || acc, leaf = col("--leaf") || "#6a6", soft = col("--soft");
    g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, W, H); g.translate(W / 2, H / 2); g.scale(cam.z, cam.z); g.translate(-cam.x, -cam.y);
    const lit = new Set(trail), hot = new Set(focus ? [focus, ...nb(focus).map(x => x.n)] : []);
    for (const e of M.E) { const hi = focus && (e.a === focus || e.b === focus); g.beginPath(); g.moveTo(e.a.x, e.a.y); g.lineTo(e.b.x, e.b.y); g.strokeStyle = hi ? acc : line; g.globalAlpha = hi ? .9 : focus ? .25 : e.soft ? .45 : .9; g.lineWidth = (hi ? 1.6 : 1) / cam.z; g.setLineDash(e.soft ? [4 / cam.z, 4 / cam.z] : []); g.stroke() }
    g.setLineDash([]); g.globalAlpha = 1;
    if (!still) for (const q of pulses) { q.p += dt * .9; if (q.p > 1) q.p -= 1.25; if (q.p < 0) continue; g.beginPath(); g.arc(q.a.x + (q.b.x - q.a.x) * q.p, q.a.y + (q.b.y - q.a.y) * q.p, 3 / cam.z, 0, 6.3); g.fillStyle = attn; g.fill() }
    for (const n of M.N) { const on1 = hot.has(n) || lit.has(n), r = n === focus ? n.r + 3 : lit.has(n) ? n.r + 1.5 : n.r; g.globalAlpha = focus && !on1 ? .3 : 1; g.beginPath(); g.arc(n.x, n.y, r, 0, 6.3); g.fillStyle = n === focus || lit.has(n) ? acc : n.kind === "class" ? ink : leaf; g.fill();
      if (focus ? on1 : (n.kind === "class" || cam.z > 1.5)) { g.font = (n === focus ? "700 " : n.kind === "class" ? "600 " : "400 ") + (12 / Math.max(.75, cam.z) * (n === focus ? 1.15 : 1)) + "px system-ui,sans-serif"; g.fillStyle = n === focus || lit.has(n) ? acc : n.kind === "class" ? ink : soft; g.textAlign = "center"; g.fillText(n.label.length > 26 ? n.label.slice(0, 25) + "…" : n.label, n.x, n.y - r - 5 / cam.z) } }
    g.globalAlpha = 1;
    if (lvl > 0 && !on) lvl = 0;
    if (raf) raf(draw);
  }
  function follow(t) {
    text = String(t ?? ""); if (!M) return;
    const was = focus; trail = trailOf(text, M.N);
    const n = trail.length ? trail[trail.length - 1] : null;
    if (n !== was || n) go(n);
  }
  const ready = (async () => {
    try { const d = await (o.load ? o.load() : { C: [], F: [] }); M = graph(d.C || [], d.F || []); M.partial = !!d.partial } catch (e) { M = graph([], []); M.partial = true }
    size(); go(null); cam = { ...to }; follow(text);
    if (cv && W0.ResizeObserver) new W0.ResizeObserver(() => { size(); if (!focus) fit() }).observe(cv);
    if (cv && cv.addEventListener) cv.addEventListener("click", e => { const r = cv.getBoundingClientRect(), x = (e.clientX - r.left - W / 2) / cam.z + cam.x, y = (e.clientY - r.top - H / 2) / cam.z + cam.y; let hit = null, d = 1e9;
      for (const n of M.N) { const q = (n.x - x) ** 2 + (n.y - y) ** 2; if (q < d && q < (18 / cam.z) ** 2) { d = q; hit = n } }
      if (hit && hit === focus && o.open) return o.open(hit); if (hit && !trail.includes(hit)) trail = [...trail, hit]; go(hit) });
    if (g && raf && !drawing) raf(draw);
  })();
  return {
    ready, follow,
    /** while listening: the edge glows */
    listening(v) { on = !!v; el.classList && el.classList[on ? "add" : "remove"]("th-listening"); if (!on) this.level(0) },
    /** the meter, 0..1 */
    level(v) { lvl = Math.max(0, Math.min(1, +v || 0)); if (meter && meter.style) meter.style.transform = "scaleY(" + (on ? Math.max(.08, lvl) : 0).toFixed(2) + ")" },
    collapse(v) { folded = !!v; el.classList && el.classList[folded ? "add" : "remove"]("th-bfolded"); if (!folded) { size(); if (!focus) fit() } if (M && focus) setCap(caption(focus)) },
    state: () => ({ focus: focus && focus.label, trail: trail.map(n => n.label), caption: cap ? cap.innerHTML : "", listening: on, collapsed: folded, level: lvl, nodes: M ? M.N.length : 0 }),
  };
}