1041 lines
41 KiB
JavaScript
1041 lines
41 KiB
JavaScript
/* 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">×</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);
|
|
});
|
|
})();
|