/* The pantheon field.
 *
 * Thirteen struck seals and six empty niches adrift in a force layout. Houses are functional
 * groups and their links are invisible: grouping reads as proximity, not as drawn edges. The
 * links only surface when you go looking for a house by hovering one of its members.
 *
 * Hierarchy is physics rather than decoration. A house whose members share a machine gives
 * that machine the biggest node and springs its residents in tighter, so Nabu and Pheme
 * visibly cling to Hermes while Daedalus and Hephaestus float as equals.
 *
 * If this file or d3 fails to load, the server-rendered listing in #sn-fallback stays
 * visible and the page still works.
 */
(function () {
  "use strict";

  /* The server-rendered listing ships hidden. If anything here cannot run, show it instead
     of leaving an empty field: no d3, no payload, a hostile browser — the page still works. */
  function giveUp() {
    var listing = document.getElementById("sn-fallback");
    if (listing) listing.hidden = false;
    var hint = document.getElementById("sn-hint");
    if (hint) hint.remove();
    var box = document.getElementById("sn-field");
    if (box) box.style.height = "auto";
  }

  var dataEl = document.getElementById("sn-pantheon");
  var field = document.getElementById("sn-field");
  var svgEl = document.getElementById("sn-graph");
  if (!dataEl || !field || !svgEl || typeof d3 === "undefined") return giveUp();

  var HOUSES;
  try {
    HOUSES = JSON.parse(dataEl.textContent);
  } catch (err) {
    return giveUp();
  }
  if (!HOUSES || !HOUSES.length) return giveUp();

  var drawer = document.getElementById("sn-drawer");
  var hint = document.getElementById("sn-hint");
  var still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  /* Below this the stylesheet hides the field and shows the stacked families instead, which
     need neither script nor physics. Keep the match around so the simulation can be held
     still while it is out of sight — a phone should not be running a force layout for a graph
     nobody is looking at — and picked up again if the thing is turned sideways. */
  var narrow = window.matchMedia("(max-width: 820px)");

  /* A house marked `side` belongs to neither the ring nor the middle: it lives in a drawer off
     the right edge with a little simulation of its own, so the ring never has to seat it. */
  var ALL = HOUSES;
  var SIDE = ALL.filter(function (h) { return h.side; });
  HOUSES = ALL.filter(function (h) { return !h.side; });

  /* ------------------------------------------------------------------ nodes and links */

  var RADIUS = { host: 56, peer: 54, resident: 43, machine: 47 };

  var nodes = [];             /* the ring and its middle */
  var northNodes = [];        /* the cluster in the side drawer */
  var houseById = {};
  ALL.forEach(function (house) {
    houseById[house.id] = house;
    house.members.forEach(function (m, i) {
      var kind = m.role === "host" ? "host"
        : m.inside ? "resident"
          : m.state === "machine" ? "machine" : "peer";
      var into = house.side ? northNodes : nodes;
      into.push(Object.assign({}, m, {
        house: house.id,
        houseName: house.name,
        kind: kind,
        r: RADIUS[kind],
        pinned: house.center === true,     /* the machines are the still point */
        side: house.side === true,
        phase: (nodes.length + northNodes.length) * 0.83 + i
      }));
    });
  });

  var byKey = {};
  nodes.concat(northNodes).forEach(function (n) { byKey[n.key] = n; });

  /* A host holds its residents close; peers hold each other at arm's length. The gap this
     opens up is the hierarchy, so it has to survive the collision padding rather than be
     flattened by it — collide alone would hold everyone the same 36px apart. */
  var REST_RESIDENT = 1.10;
  var REST_PEER = 1.62;

  var links = [];
  var northLinks = [];
  ALL.forEach(function (house) {
    var into = house.side ? northLinks : links;
    /* whoever the family hangs off: an explicit hub, else the machine they run on */
    var hub = house.members.filter(function (m) { return m.hub; })[0]
      || house.members.filter(function (m) { return m.role === "host"; })[0];
    if (hub) {
      /* tenants cling to the machine they run on; kin in the same family stand back */
      house.members.forEach(function (m) {
        if (m.key === hub.key) return;
        /* a resident answers to its own host even when someone else is the hub */
        if (m.inside && m.inside !== hub.key) {
          into.push({
            source: m.inside, target: m.key, house: house.id, rest: REST_RESIDENT
          });
          return;
        }
        into.push({
          source: hub.key, target: m.key, house: house.id,
          rest: m.inside === hub.key ? REST_RESIDENT : REST_PEER
        });
      });
    } else {
      /* No hub, so everyone stands with everyone — unless a member names its own attachments,
         in which case those are the only ones it reaches for. That is what keeps Theseus on
         the two workstations instead of striking a line across the middle of the diamond. */
      for (var i = 0; i < house.members.length; i++) {
        for (var j = i + 1; j < house.members.length; j++) {
          var one = house.members[i], two = house.members[j];
          if (one.attach && one.attach.indexOf(two.key) < 0) continue;
          if (two.attach && two.attach.indexOf(one.key) < 0) continue;
          into.push({
            source: one.key, target: two.key, house: house.id, rest: REST_PEER
          });
        }
      }
    }
  });

  /* ---------------------------------------------------------------------------- canvas */

  /* declared together up here: geometry() fills them in, and a later `var` for the same
     names would hoist and blank them again after measure() had already run */
  var W = 0, H = 0, cx = 0, cy = 0, rx = 0, ry = 0, scale = 1;

  /* how much of each edge is spoken for: the detail panel on the left, the northern drawer
     on the right. The ring centres on whatever is left between them. */
  var inset = 0;
  var outset = 0;

  /* how many seals the ring has to seat — needed by geometry(), so it must be counted before
     the first measure() rather than in a `var` further down that would still be undefined */
  var ringCount = nodes.filter(function (n) {
    return n.origin === "greek" && !n.pinned;
  }).length;

  function measure() {
    W = field.clientWidth;
    H = field.clientHeight;
    svgEl.setAttribute("viewBox", "0 0 " + W + " " + H);
    geometry();
    nodes.forEach(function (n) { n.r = RADIUS[n.kind] * scale; });
  }

  /* Sized before anything is drawn: the seals' radii depend on how much ring there is to
     seat them on, and every SVG attribute below is written from those radii. */
  measure();

  var svg = d3.select(svgEl);

  var defs = svg.append("defs");
  var glow = defs.append("radialGradient").attr("id", "sn-glow");
  glow.append("stop").attr("offset", "0%").attr("stop-color", "#b5976e").attr("stop-opacity", .32);
  glow.append("stop").attr("offset", "68%").attr("stop-color", "#b5976e").attr("stop-opacity", 0);

  var linkLayer = svg.append("g").attr("class", "sn-links");
  var tagLayer = svg.append("g").attr("class", "sn-tags");
  var nodeLayer = svg.append("g").attr("class", "sn-nodes");

  var houseLinkGroups = {};
  HOUSES.forEach(function (house) {
    houseLinkGroups[house.id] = linkLayer.append("g").attr("data-house", house.id);
  });

  var linkSel = linkLayer.selectAll("g").selectAll("line")
    .data(function () { return []; });

  /* draw each link into its own house group so a house can be revealed on its own */
  var lineByHouse = {};
  HOUSES.forEach(function (house) {
    lineByHouse[house.id] = links
      .filter(function (l) { return l.house === house.id; })
      .map(function (l) {
        return { line: houseLinkGroups[house.id].append("line").node(), link: l };
      });
  });

  var tagSel = tagLayer.selectAll("text")
    .data(HOUSES)
    .join("text")
    .attr("class", "sn-house-tag")
    .attr("data-house", function (d) { return d.id; })
    .text(function (d) { return d.name; });

  /* One seal, drawn. Both simulations use this — the ring in the field and the cluster in the
     side drawer — so a change to how a seal looks lands in both places at once. */
  function paint(sel) {
    sel
      .attr("class", "sn-node")
      .attr("data-key", function (d) { return d.key; })
      .attr("data-state", function (d) { return d.state; })
      .attr("tabindex", 0)
      .attr("role", "button")
      .attr("aria-label", function (d) { return d.name + " — " + d.domain + ". " + d.does; });

    sel.append("circle")
      .attr("class", "sn-halo")
      .attr("r", function (d) { return d.r * 1.34; })
      .attr("fill", "url(#sn-glow)");

    sel.each(function (d) {
      var g = d3.select(this);
      if (d.node) {
        g.append("image")
          .attr("href", d.node)
          .attr("x", -d.r).attr("y", -d.r)
          .attr("width", d.r * 2).attr("height", d.r * 2);
      } else {
        g.append("circle")
          .attr("class", "sn-niche-ring")
          .attr("r", d.r * 0.82);
        g.append("text")
          .attr("class", "sn-monogram")
          .attr("y", d.r * 0.19)
          .text(d.name.charAt(0));
      }
      g.append("text")
        .attr("class", "sn-label")
        .attr("y", d.r + 20)
        .text(d.name);
      g.append("circle")                          /* focus ring, and a clean hit area */
        .attr("class", "sn-focus-ring")
        .attr("r", d.r + 4);
      g.append("circle")
        .attr("class", "sn-hit")
        .attr("r", d.r)
        .attr("fill", "transparent");
    });
    return sel;
  }

  /* radii are worked out from the space available, so a resize can change them */
  function resize(sel) {
    sel.each(function (d) {
      var g = d3.select(this);
      g.select(".sn-halo").attr("r", d.r * 1.34);
      g.select("image")
        .attr("x", -d.r).attr("y", -d.r)
        .attr("width", d.r * 2).attr("height", d.r * 2);
      g.select(".sn-niche-ring").attr("r", d.r * 0.82);
      g.select(".sn-monogram").attr("y", d.r * 0.19);
      g.select(".sn-label").attr("y", d.r + 20);
      g.select(".sn-focus-ring").attr("r", d.r + 4);
      g.select(".sn-hit").attr("r", d.r);
    });
  }

  var nodeSel = paint(nodeLayer.selectAll("g")
    .data(nodes, function (d) { return d.key; })
    .join("g"));

  function applyRadii() { resize(nodeSel); }

  /* ------------------------------------------------------------------------ simulation */

  /* The pantheon is a ring around a fixed middle. Radii are worked out from the field so the
     outer Mesopotamians and their labels still clear the edges; on a wide field the ring goes
     slightly elliptical rather than leaving the flanks empty. */
  function geometry() {
    /* the detail panel claims the left of the field, so the ring centres on what remains
       rather than being shoved off the right edge */
    var span = W - inset - outset;
    cx = inset + span / 2;
    cy = H / 2;
    /* Clearance kept outside the ring. It has to cover a tenant pushed clear of its host,
       which is a little over 200px, plus the seal itself — not just a label's worth. */
    var room = 260;
    ry = Math.max(140, Math.min(H / 2 - room, 320));
    rx = Math.max(ry, Math.min(span / 2 - room, ry * 1.25));

    /* Ramanujan's ellipse perimeter. If the ring cannot seat every Greek at full size, the
       seals shrink to fit rather than being shoved off it into a shapeless band. */
    var perimeter = Math.PI * (3 * (rx + ry)
      - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
    var needed = ringCount * (RADIUS.peer * 2 + 34);
    scale = Math.max(0.62, Math.min(1, perimeter / needed));
  }

  /* Where a member rests on the ring. Each gets its own seat rather than the whole house
     sharing one point: the hub sits exactly on the house's angle and its kin fan out either
     side. When they all shared an anchor the tangential arrangement was left entirely to
     charge, so a one-sided tenant like Nanshe shoved her whole family several degrees around
     the ring and the hub was never quite where the house was supposed to be. */
  var SEAT_STEP = RADIUS.peer * 2 + 24;

  function anchor(d) {
    var house = houseById[d.house];
    if (house.center) return [cx, cy];
    var t = house.angle * Math.PI / 180;
    var ax = cx + Math.cos(t) * rx;
    var ay = cy + Math.sin(t) * ry;
    /* house.bias slides the whole family along the ring, in seats: the charge from a one-sided
       tenant still leans on its kin a little, and this is the trim for it */
    var off = ((d.seat || 0) + (house.bias || 0)) * SEAT_STEP * scale;
    return [ax - Math.sin(t) * off, ay + Math.cos(t) * off];   /* along the ring */
  }

  /* Weak, so a seat suggests where to stand rather than nailing anyone to the spot. The hub
     pulls a little harder because its seat is the one with a promise attached: it is the
     house's own angle, and a house sitting visibly off its mark is the thing you notice. */
  var SEAT_K = 0.05;
  var HUB_K = 0.18;

  function seatK(d) { return d.pinned ? 0 : (d.hub ? HUB_K : SEAT_K); }

  /* The three machines hold the exact middle. Three objects cannot share one point, so they
     sit in the tightest triangle that keeps them from touching, centred on it.

     The triangle points up: the first machine listed — Ganymede, the home server — takes the
     apex, with the two workstations below her. */
  var CENTRE_START = -90;

  /* The middle is fixed rather than simulated, so no force can carry it anywhere — moving it
     means moving it ourselves. Written straight in, the triad teleports the instant the panel
     claims the left edge while the ring is still gliding across. This eases the pins instead,
     on a curve that keeps step with the ring: ease-out cubic is half done at a fifth of its
     duration, which is about where the ring's spring reaches halfway too. */
  var PIN_GLIDE_MS = 1300;
  var pinTween = null;

  function place(n, x, y) {
    n.fx = n.x = x;
    n.fy = n.y = y;
    n.vx = n.vy = 0;
  }

  function pinCenter(glide) {
    var pins = nodes.filter(function (n) { return n.pinned; });
    /* Wide enough that nobody crowds their neighbour's name. An odd ring hangs the others
       below the apex, so its name passes over their heads and 39px of clearance does; an even
       one puts two of them level with it, and the name now runs straight at them, so the gap
       has to clear half a name instead. The floors matter because labels are a fixed CSS size:
       when the seals shrink on a narrow field the name does not, so the room it needs stays
       about the same in pixels. */
    var widest = Math.max.apply(null, pins.map(function (n) { return n.r; }));
    var level = pins.length % 2 === 0;
    var spread = pins.length > 1
      ? Math.max(widest + (level ? 48 : 39), level ? 96 : 84)
      : 0;
    var to = pins.map(function (n, i) {
      var t = (CENTRE_START + i * (360 / pins.length)) * Math.PI / 180;
      return [cx + Math.cos(t) * spread, cy + Math.sin(t) * spread];
    });

    if (pinTween) cancelAnimationFrame(pinTween);
    pinTween = null;

    if (!glide || still) {
      pins.forEach(function (n, i) { place(n, to[i][0], to[i][1]); });
      return;
    }

    var from = pins.map(function (n) { return [n.x, n.y]; });
    var started = null;
    pinTween = requestAnimationFrame(function step(now) {
      if (started === null) started = now;
      var k = Math.min(1, (now - started) / PIN_GLIDE_MS);
      var e = 1 - Math.pow(1 - k, 3);
      pins.forEach(function (n, i) {
        place(n, from[i][0] + (to[i][0] - from[i][0]) * e,
                 from[i][1] + (to[i][1] - from[i][1]) * e);
      });
      pinTween = k < 1 ? requestAnimationFrame(step) : null;
    });
  }

  /* A slow, unsynchronised wander so the field floats instead of freezing into a diagram.
     Amplitude is tiny on purpose: it settles to roughly 5-20px per second. */
  function drift() {
    if (still) return;
    var t = Date.now() / 1000;
    nodes.forEach(function (n) {
      if (n.pinned) return;
      n.vx += Math.cos(t * 0.21 + n.phase) * 0.02;
      n.vy += Math.sin(t * 0.17 + n.phase * 1.7) * 0.02;
    });
  }

  /* Greeks are sprung toward the ring along their own radius — pulled in when they stray
     outside it, pushed back out when they fall inside, so they rebound onto it rather than
     collapsing into the middle. Radial only: where they sit *around* the ring is left to the
     house anchor, their links and the charge between them. */
  var RING_K = 1.8;

  function ring(alpha) {
    nodes.forEach(function (n) {
      if (n.pinned || n.origin !== "greek") return;
      var ux = (n.x - cx) / rx;
      var uy = (n.y - cy) / ry;
      var m = Math.hypot(ux, uy) || 1e-6;
      n.vx += (cx + (ux / m) * rx - n.x) * RING_K * alpha;
      n.vy += (cy + (uy / m) * ry - n.y) * RING_K * alpha;
    });
  }

  /* The borrowed gods are pushed gently away from the middle, so each one drifts to the
     outside of the ring while its host's link keeps it from wandering off. This is what
     stretches that link: the settled gap is set by the push, not the rest length. */
  var OUTWARD = 14;

  function antigravity(alpha) {
    nodes.forEach(function (n) {
      if (n.pinned || n.origin !== "mesopotamian") return;
      var dx = n.x - cx;
      var dy = n.y - cy;
      var d = Math.hypot(dx, dy) || 1e-6;
      n.vx += (dx / d) * OUTWARD * alpha;
      n.vy += (dy / d) * OUTWARD * alpha;
    });
  }

  /* Note on alpha: d3 scales the link, charge and x/y forces by alpha but never scales
     collide, and alpha moves toward alphaTarget by alphaDecay per tick — so alphaDecay(0)
     would pin alpha at its initial 1 forever and run the field at full strength. A small
     decay toward a small target is what makes it drift rather than swarm. */
  var CALM = 0.03;

  /* How the field travels when it is re-laid. The ring is a spring at RING_K * alpha, so a
     large alpha snaps the seals across and overshoots — at 0.34 they covered half the move in
     a single frame and sailed 55px past. The panel instead asks for a glide: a small alpha
     with heavy damping crosses without overshoot, half the distance in a quarter second and
     the rest easing in over the next second or so. Damping goes back to normal once it has
     arrived, so the idle wander is unaffected. */
  var SWIM_DECAY = 0.44;
  var GLIDE_ALPHA = 0.10;
  var GLIDE_DECAY = 0.78;
  var GLIDE_MS = 2200;
  var glideTimer;

  var sim = d3.forceSimulation(nodes)
    .force("link", d3.forceLink(links)
      .id(function (d) { return d.key; })
      .distance(function (l) { return (l.source.r + l.target.r) * l.rest; })
      .strength(0.5))
    /* lighter than it was: the ring now does the radial work, so charge only has to spread
       the seals around it. Left strong, it pushed everyone off the ring and into a band. */
    .force("charge", d3.forceManyBody().strength(-500).distanceMax(700))
    .force("collide", d3.forceCollide(function (d) { return d.r + 18; }).iterations(3))
    /* weak, so it decides only which arc of the ring a house occupies */
    .force("x", d3.forceX(function (d) { return anchor(d)[0]; }).strength(seatK))
    .force("y", d3.forceY(function (d) { return anchor(d)[1]; }).strength(seatK))
    .force("ring", ring)
    .force("antigravity", antigravity)
    .force("drift", drift)
    .velocityDecay(SWIM_DECAY)
    .alphaMin(0)
    .alphaDecay(0.03)
    .alphaTarget(still ? 0 : CALM)
    .on("tick", tick);

  /* Hand out the seats: the hub takes 0 — its house's own angle — and its kin fan out either
     side of it. A resident shares the seat of whatever it lives on, since it will be pushed
     clear of it radially anyway. */
  function assignSeats() {
    HOUSES.forEach(function (house) {
      if (house.center) return;
      var hub = house.members.filter(function (m) { return m.hub; })[0]
        || house.members.filter(function (m) { return m.role === "host"; })[0];
      var main = house.members.filter(function (m) { return !m.inside; });
      var hubAt;
      if (hub) {
        main = main.filter(function (m) { return m.key !== hub.key; });
        hubAt = Math.floor(main.length / 2);
        main.splice(hubAt, 0, hub);
      } else {
        hubAt = (main.length - 1) / 2;          /* no hub: straddle the house's angle */
      }
      main.forEach(function (m, i) { byKey[m.key].seat = i - hubAt; });
      house.members.forEach(function (m) {
        if (m.inside) byKey[m.key].seat = byKey[m.inside].seat;
      });
    });
  }

  /* Where a family starts is very nearly where it stays: the ring is strong and the links
     between kin are weak. Seeded with a jitter it was luck whether the hub came to rest in the
     middle of its own family or on the end of it, so start everyone on their seat. */
  function seed() {
    assignSeats();
    nodes.forEach(function (n) {
      if (n.pinned) return;                     /* the middle is pinned, not seeded */
      var a = anchor(n);
      var rad = Math.atan2(a[1] - cy, a[0] - cx);
      var out = n.origin === "mesopotamian" ? 74 : 0;    /* start the tenants outside */
      n.x = a[0] + Math.cos(rad) * out;
      n.y = a[1] + Math.sin(rad) * out;
      n.vx = n.vy = 0;
    });
    pinCenter();
  }

  /* settle offscreen first, then let it ease the last of the way in view */
  seed();
  sim.stop();
  for (var w = 0; w < 260; w++) sim.tick();
  tick();
  if (!still) sim.alpha(0.1).restart();

  function tick() {
    nodes.forEach(function (n) {
      var m = n.r + 14;
      n.x = Math.max(inset + m, Math.min(W - outset - m, n.x));
      n.y = Math.max(m, Math.min(H - m - 22, n.y));
    });

    nodeSel.attr("transform", function (d) { return "translate(" + d.x + "," + d.y + ")"; });

    HOUSES.forEach(function (house) {
      lineByHouse[house.id].forEach(function (entry) {
        var l = entry.link;
        entry.line.setAttribute("x1", l.source.x);
        entry.line.setAttribute("y1", l.source.y);
        entry.line.setAttribute("x2", l.target.x);
        entry.line.setAttribute("y2", l.target.y);
      });
    });

    tagSel
      .attr("x", function (house) { return centroid(house)[0]; })
      .attr("y", function (house) { return centroid(house)[1]; });
  }

  function centroid(house) {
    var ms = house.members.map(function (m) { return byKey[m.key]; });
    var cx = 0, cy = 0, top = Infinity;
    ms.forEach(function (n) {
      cx += n.x;
      cy += n.y;
      top = Math.min(top, n.y - n.r);
    });
    return [cx / ms.length, Math.max(16, top - 22)];
  }

  /* --------------------------------------------------------------------------- looking */

  var active = null, selected = null, onScreen = true;

  /* bring a house forward: its links surface, everything else falls back. Takes a house id
     from either simulation — a seal in the side drawer lights its own kin, not the ring. */
  function lightHouse(id) {
    nodeSel.classed("lit", function (d) { return d.house === id; });
    HOUSES.forEach(function (house) {
      houseLinkGroups[house.id].classed("on", house.id === id);
    });
    tagSel.classed("on", function (house) { return house.id === id; });
    drawers.forEach(function (d) {
      d.sel.classed("lit", function (n) { return n.house === id; });
      d.wires.classed("on", d.house.id === id);
    });
    svgEl.classList.add("busy");
    if (hint) hint.classList.add("away");
  }

  function unlightHouse() {
    nodeSel.classed("lit", false);
    HOUSES.forEach(function (house) { houseLinkGroups[house.id].classed("on", false); });
    tagSel.classed("on", false);
    drawers.forEach(function (d) {
      d.sel.classed("lit", false);
      d.wires.classed("on", false);
    });
    svgEl.classList.remove("busy");
    if (hint) hint.classList.remove("away");
  }

  /* Hovering only changes what is lit — the field keeps wandering underneath. It used to stop
     dead here, which was there to hold a tooltip still against its seal; with the tooltip gone
     the freeze bought nothing and read as the page hanging. */
  function highlight(key) {
    active = key;
    lightHouse(byKey[key].house);
  }

  function release() {
    if (selected) return;
    active = null;
    unlightHouse();
  }

  /* Re-lay the field: after a resize, or when the panel takes or gives back the left edge.
     The simulation animates the move, so the seals glide across rather than jumping.

     Narrowing the ring can change how big the seals can be, and d3 reads a force's radius
     accessor when the force is installed rather than every tick — so if the scale moved, the
     drawing and the two radius-dependent forces have to be rebuilt together. */
  function relayout(heat, damp) {
    var was = scale;
    measure();
    if (Math.abs(scale - was) > 0.02) {
      applyRadii();
      sim.force("collide", d3.forceCollide(function (d) { return d.r + 18; }).iterations(3));
      sim.force("link", d3.forceLink(links)
        .id(function (d) { return d.key; })
        .distance(function (l) { return (l.source.r + l.target.r) * l.rest; })
        .strength(0.5));
    }
    pinCenter(!!damp);
    sim.force("x", d3.forceX(function (d) { return anchor(d)[0]; }).strength(seatK));
    sim.force("y", d3.forceY(function (d) { return anchor(d)[1]; }).strength(seatK));
    if (still) {
      for (var i = 0; i < 260; i++) sim.tick();
      tick();
    } else {
      clearTimeout(glideTimer);
      sim.velocityDecay(damp || SWIM_DECAY).alpha(heat).alphaTarget(CALM).restart();
      if (damp) {
        glideTimer = setTimeout(function () { sim.velocityDecay(SWIM_DECAY); }, GLIDE_MS);
      }
    }
  }

  /* ---------------------------------------------------------------------------- drawer */

  function action(n) {
    if (n.state === "locked") return '<a class="sn-btn" href="/auth/start?next=/profile">Request access</a>';
    if (n.state === "here") return '<a class="sn-btn sn-btn-quiet" href="/profile">Your account</a>';
    if (n.state === "machine") return "";
    /* only claim a thing is unbuilt if it says so itself — something with no address is not
       necessarily nothing, it may just have nowhere for a visitor to go */
    if (!n.host) {
      return n.state === "planned"
        ? '<span class="sn-btn sn-btn-quiet">Not built yet</span>' : "";
    }
    return '<a class="sn-btn" href="https://' + n.host + '">Open ' + n.host + "</a>";
  }

  function open(key) {
    var n = byKey[key];
    selected = key;
    active = key;
    nodeSel.classed("sel", function (d) { return d.key === key; });
    drawers.forEach(function (d) {
      d.sel.classed("sel", function (n) { return n.key === key; });
    });

    lightHouse(n.house);

    var house = houseById[n.house];

    /* Read down the family rather than across it. Whoever lives on this one are its
       descendants; siblings are whoever shares its parent — so a tenant's host does not count
       as a sibling, having already been named on the runs line. */
    var descendants = house.members.filter(function (m) { return m.inside === key; });
    var siblings = house.members.filter(function (m) {
      return m.key !== key && (m.inside || "") === (n.inside || "");
    });

    function named(list) {
      return list.map(function (m) { return m.name; }).join(", ");
    }

    drawer.innerHTML =
      '<div class="sn-drawer-in">' +
        '<div class="sn-drawer-art">' +
          (n.seal
            ? '<img src="' + n.seal + '" alt="The seal of ' + n.name + '" width="720" height="720">'
            : '<div class="sn-empty">' + n.name.charAt(0) + "</div>") +
        "</div>" +
        '<div class="sn-drawer-body">' +
          '<button class="sn-close" type="button" aria-label="Close">&times;</button>' +
          "<h3>" + n.name + "</h3>" +
          '<p class="sn-drawer-domain">' + n.domain + "</p>" +
          n.long.map(function (para) {
            return '<p class="sn-long">' + para + "</p>";
          }).join("") +
          '<dl class="sn-spec">' +
            (n.host ? "<dt>host</dt><dd>" + n.host + "</dd>" : "") +
            '<dt>house</dt><dd class="sn-house-name">' + house.name + "</dd>" +
            (n.inside
              ? "<dt>lives with</dt><dd>" + byKey[n.inside].name + "</dd>" : "") +
            (siblings.length
              ? "<dt>siblings</dt><dd>" + named(siblings) + "</dd>" : "") +
            (descendants.length
              ? "<dt>descendants</dt><dd>" + named(descendants) + "</dd>" : "") +
            "<dt>status</dt><dd>" + n.label + "</dd>" +
          "</dl>" +
          '<div class="sn-actions">' + action(n) + "</div>" +
        "</div>" +
      "</div>";

    drawer.querySelector(".sn-close").addEventListener("click", close);
    drawer.classList.add("open");
    drawer.setAttribute("aria-hidden", "false");

    /* Give the ring the rest of the field. Below a certain width there is nothing to give, so
       the panel just covers the graph instead of squeezing it into a sliver. */
    var panel = drawer.offsetWidth;
    inset = (W - panel >= 520) ? panel + 28 : 0;
    relayout(GLIDE_ALPHA, GLIDE_DECAY);
  }

  function close() {
    selected = null;
    active = null;
    drawer.classList.remove("open");
    /* content stays put while it slides out, so the panel does not blank mid-flight */
    drawer.setAttribute("aria-hidden", "true");
    nodeSel.classed("sel", false);
    drawers.forEach(function (d) { d.sel.classed("sel", false); });
    unlightHouse();
    inset = 0;
    relayout(GLIDE_ALPHA, GLIDE_DECAY);
  }

  /* ------------------------------------------------------------------- the side drawers */

  /* Houses on neither the ring nor the middle. Each is a cluster rather than a ring — one at
     the top, larger, with the rest in pairs beneath it reading left to right — so each gets a
     simulation of its own: every force out in the field is written in terms of a centre and a
     radius a panel has no use for. The seals themselves are painted by the shared code, so
     they look identical either side of the divide.

     The tabs live on a rail that is always visible rather than on the drawers themselves,
     which is what lets a second house be added without the first losing its handle. One opens
     at a time: they share the same strip of field, so two at once would only cover each
     other. */
  var drawersEl = document.getElementById("sn-drawers");
  var railEl = document.getElementById("sn-rail");

  var CROWN = 1.3;          /* how much bigger the one at the top of a cluster is */
  var SIDE_PAD = 20;
  var SIDE_COLS = 2;
  var RAIL = 36;            /* keep in step with --rail-w in the stylesheet */

  var drawers = [];
  var openDrawer = null;

  function makeDrawer(house) {
    var mine = northNodes.filter(function (n) { return n.house === house.id; });
    var wires = northLinks.filter(function (l) { return l.house === house.id; });

    var tab = document.createElement("button");
    tab.className = "sn-side-tab";
    tab.type = "button";
    tab.setAttribute("aria-expanded", "false");
    var caption = document.createElement("span");
    caption.textContent = house.name;
    tab.appendChild(caption);
    railEl.appendChild(tab);

    var body = document.createElement("div");
    body.className = "sn-side-body";
    var box = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    box.setAttribute("class", "sn-side-graph");
    box.setAttribute("aria-hidden", "true");
    body.appendChild(box);
    drawersEl.appendChild(body);

    var pane = d3.select(box);
    var wireLayer = pane.append("g").attr("class", "sn-links");
    var nodeG = pane.append("g").attr("class", "sn-nodes");

    var w = 0, h = 0, sim = null, sel = null, lines = [];

    function crown() { return mine.filter(function (n) { return n.hub; })[0]; }

    function measure() {
      w = body.clientWidth || 320;
      h = body.clientHeight || H;
      box.setAttribute("viewBox", "0 0 " + w + " " + h);
      /* about three across at the widest, with air between and a margin either side */
      var sc = Math.max(0.4, Math.min(0.78, (w - SIDE_PAD * 2) / (3.1 * RADIUS.peer * 2)));
      mine.forEach(function (n) {
        n.r = RADIUS[n.kind] * sc * (n.hub ? CROWN : 1);
      });
    }

    /* How the ones under the crown are banded into rows. A member may name its own `row`, and
       everything sharing a number stands side by side, centred — which is what lets a house lay
       out as one, then two, then one. Say nothing and they simply pack two to a row in order. */
    function bands(rest) {
      var named = rest.filter(function (n) { return typeof n.row === "number"; });
      var out = [];
      if (named.length === rest.length && rest.length) {
        var byRow = {};
        rest.forEach(function (n) {
          (byRow[n.row] = byRow[n.row] || []).push(n);
        });
        Object.keys(byRow).sort(function (a, b) { return a - b; }).forEach(function (k) {
          out.push(byRow[k]);
        });
        return out;
      }
      for (var i = 0; i < rest.length; i += SIDE_COLS) {
        out.push(rest.slice(i, i + SIDE_COLS));
      }
      return out.length ? out : [[]];
    }

    /* Where each one wants to stand: the crown at the top, the rest in pairs stepping down
       under it. Reading order is left to right then down, so a member's `order` lays the
       cluster out the way you would write it. An odd one out takes the middle of its row. */
    function targets() {
      var head = crown();
      var top = SIDE_PAD + (head ? head.r : 40);
      var under = top + (head ? head.r : 0) + 40;
      var rest = mine.filter(function (n) { return !n.hub; });
      var rows = bands(rest);
      /* not the whole drop: filling the panel edge to edge reads as a list, and a cluster
         wants to hang from its crown with somewhere left to fall */
      var room = Math.max(160, (h - under - SIDE_PAD - 26) * 0.7);
      var step = room / rows.length;
      if (head) { head.tx = w / 2; head.ty = top; }
      rows.forEach(function (band, i) {
        band.forEach(function (n, col) {
          n.tx = w / 2 + (band.length < 2
            ? 0
            : (col - (band.length - 1) / 2) * w * 0.38);
          n.ty = under + step * (i + 0.5);
        });
      });
    }

    function place() {
      targets();
      var head = crown();
      if (head) {
        head.fx = head.x = head.tx;
        head.fy = head.y = head.ty;
      }
      mine.forEach(function (n) {
        if (n.hub) return;
        n.x = n.tx;
        n.y = n.ty;
        n.vx = n.vy = 0;
      });
      /* d3 reads these accessors when a force is installed rather than every tick, so moving
         the seats means handing them over again — the trap the ring's collide falls into */
      if (sim) {
        sim.force("x", d3.forceX(function (d) { return d.tx; }).strength(0.07));
        sim.force("y", d3.forceY(function (d) { return d.ty; })
          .strength(function (d) { return d.hub ? 0 : 0.08; }));
        sim.force("collide", d3.forceCollide(function (d) { return d.r + 13; }).iterations(3));
      }
    }

    function tick() {
      mine.forEach(function (n) {
        var m = n.r + 12;
        n.x = Math.max(m, Math.min(w - m, n.x));
        n.y = Math.max(m, Math.min(h - m - 20, n.y));
      });
      sel.attr("transform", function (d) { return "translate(" + d.x + "," + d.y + ")"; });
      lines.forEach(function (entry) {
        entry.line.setAttribute("x1", entry.link.source.x);
        entry.line.setAttribute("y1", entry.link.source.y);
        entry.line.setAttribute("x2", entry.link.target.x);
        entry.line.setAttribute("y2", entry.link.target.y);
      });
    }

    measure();

    lines = wires.map(function (l) {
      return { line: wireLayer.append("line").node(), link: l };
    });

    sel = paint(nodeG.selectAll("g").data(mine, function (d) { return d.key; }).join("g"));
    sel
      .on("mouseenter", function (event, d) { if (!selected) highlight(d.key); })
      .on("mouseleave", release)
      .on("focus", function (event, d) { if (!selected) highlight(d.key); })
      .on("blur", release)
      .on("keydown", function (event, d) {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          selected === d.key ? close() : open(d.key);
        }
      })
      .on("click", function (event, d) {
        event.preventDefault();
        selected === d.key ? close() : open(d.key);
      });

    place();

    sim = d3.forceSimulation(mine)
      /* Light: the seats decide the shape and the links only keep the cluster together. As
         long as the seats are apart, so they stop hauling everyone back up into a fixed
         radius around the crown — the one thing the seats are trying to undo. */
      .force("link", d3.forceLink(wires).id(function (d) { return d.key; })
        .distance(function (l) {
          return Math.max((l.source.r + l.target.r) * 1.25,
            Math.hypot(l.source.tx - l.target.tx, l.source.ty - l.target.ty));
        })
        .strength(0.12))
      .force("charge", d3.forceManyBody().strength(-200).distanceMax(420))
      .force("collide", d3.forceCollide(function (d) { return d.r + 13; }).iterations(3))
      .force("x", d3.forceX(function (d) { return d.tx; }).strength(0.07))
      .force("y", d3.forceY(function (d) { return d.ty; })
        .strength(function (d) { return d.hub ? 0 : 0.08; }))
      .velocityDecay(0.5)
      .alphaMin(0)
      .alphaDecay(0.04)
      .alphaTarget(still ? 0 : CALM)
      .on("tick", tick);

    /* settle it out of sight, then hold still until the drawer is pulled open */
    for (var i = 0; i < 300; i++) sim.tick();
    tick();
    sim.stop();

    var drawer = {
      house: house,
      body: body,
      tab: tab,
      sel: sel,
      wires: wireLayer,
      open: false,
      show: function (on) {
        drawer.open = on;
        body.classList.toggle("open", on);
        tab.classList.toggle("on", on);
        tab.setAttribute("aria-expanded", on ? "true" : "false");
        if (!on) return sim.stop();
        measure();
        resize(sel);
        place();
        if (still) {
          for (var i = 0; i < 300; i++) sim.tick();
          tick();
        } else {
          sim.alpha(0.6).alphaTarget(CALM).restart();
        }
      }
    };

    tab.addEventListener("click", function () { pullDrawer(drawer, !drawer.open); });
    return drawer;
  }

  function pullDrawer(target, on) {
    drawers.forEach(function (d) {
      if (d !== target && d.open) d.show(false);
    });
    target.show(on);
    openDrawer = on ? target : null;

    /* Only take room from the ring if there is room to take. On a narrow field the drawer
       simply lies over the seals, the same bargain the detail panel strikes. */
    var want = on ? target.body.offsetWidth + RAIL : 0;
    outset = (W - inset - want >= 520) ? want : 0;
    relayout(GLIDE_ALPHA, GLIDE_DECAY);
  }

  if (SIDE.length && drawersEl && railEl) {
    drawersEl.hidden = false;
    drawers = SIDE.map(makeDrawer);
  }

  /* ----------------------------------------------------------------------------- input */

  nodeSel
    .on("mouseenter", function (event, d) { if (!selected) highlight(d.key); })
    .on("mouseleave", release)
    .on("focus", function (event, d) { if (!selected) highlight(d.key); })
    .on("blur", release)
    .on("keydown", function (event, d) {
      if (event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        selected === d.key ? close() : open(d.key);
      }
    });

  nodeSel.on("click", function (event, d) {
    event.preventDefault();
    selected === d.key ? close() : open(d.key);
  });

  /* the field runs only when it is both shown and looked at */
  function idle() {
    return still || narrow.matches || !onScreen;
  }

  function wake() {
    if (idle()) {
      sim.stop();
      drawers.forEach(function (d) { if (d.open) d.show(false); });
      return;
    }
    if (!selected && !active) sim.alphaTarget(CALM).restart();
  }

  if (narrow.matches) sim.stop();

  if ("IntersectionObserver" in window) {
    new IntersectionObserver(function (entries) {
      onScreen = entries[0].isIntersecting;
      wake();
    }, { threshold: 0 }).observe(field);
  }

  /* turned sideways, or a window dragged across the breakpoint */
  if (narrow.addEventListener) {
    narrow.addEventListener("change", function () {
      if (narrow.matches) {
        if (selected) close();      /* the panel is hidden down here; do not leave it armed */
        return wake();
      }
      inset = 0;
      outset = 0;
      relayout(0.35);
      wake();
    });
  }

  document.addEventListener("keydown", function (event) {
    if (event.key === "Escape" && selected) close();
  });

  var resizeTimer;
  window.addEventListener("resize", function () {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function () {
      /* the panel's width can change with the viewport, so re-take it before re-laying */
      if (selected) {
        var panel = drawer.offsetWidth;
        inset = (W - panel >= 520) ? panel + 28 : 0;
      }
      if (openDrawer) {
        var want = openDrawer.body.offsetWidth + RAIL;
        outset = (W - inset - want >= 520) ? want : 0;
        openDrawer.show(true);          /* re-measures, re-seats and restarts its cluster */
      }
      relayout(0.25);
    }, 180);
  });
})();
