OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / static / explore.js

687 lines · javascript ref:
// The DISCOVERY view — three.js, force-directed, three dimensions.
//
// ⛔ NOT the debug view, and deliberately a different page. EDGE-GRAPH §5: the two renderings have
// opposite duties and "must not be one screen". The debug view shows every edge including the
// failed ones, unclustered and deterministic, so a diagnosis can be screenshotted and compared. This
// one shows computed structure, and rotates — which is useful for exploring and useless for
// comparing.
//
// ⛔ IT NEVER NAMES A CLUSTER. COMMUNITY-EMERGENCE: "the observation is an intervention." The system
// may surface a cluster; naming it is a political act reserved for humans. There is no auto-label
// here and there must not be one. ⚠ Naming a NODE is a different act and is not that: a handle and
// a profile name are what that feed says about itself, and showing them is reading, not deciding.
//
// ⛔ NO EXPORT. EDGE-GRAPH §6.4: the aggregation attack's mitigation is "no mechanism, not a
// permission check. A graph view with an export button is that mechanism."
//
// ── What the third dimension is FOR ───────────────────────────────────────────────────────────
//
// x/y are consumed by the layout — that is what clustering is. Colour, size and shape are
// low-bandwidth and interfere with each other, and the 2D view already spends them. z is a full
// channel, and it carries TIME: a cluster forming reads as a widening cone, one that died as a
// stub. That is not expressible in 2D without animation, which destroys comparability.

import * as THREE from './three.module.min.js'

const res = await fetch('/api/graph')
const g = await res.json()
const labels = g.labels || {}

const el = document.getElementById('scene')
const tip = document.getElementById('tip')
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(55, el.clientWidth / el.clientHeight, 0.1, 4000)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.setSize(el.clientWidth, el.clientHeight)
el.appendChild(renderer.domElement)

const css = getComputedStyle(document.body)
const cssVar = (n, d) => (css.getPropertyValue(n) || d).trim()
const col = (n, d) => new THREE.Color(cssVar(n, d))
const ACCENT = col('--accent', '#6aa9f0')
const DIM = col('--dim', '#8b98a5')

const short = (id) => id.slice(0, 8) + '…'
// What to call a point. ⚠ Falls back to truncated hex rather than to nothing: an unlabelled sphere
// is the complaint this whole pass exists to answer, and a key IS an answer, just a poor one.
const who = (feed) => {
  const l = labels[feed] || {}
  if (l.handle) return '@' + l.handle
  return l.name || short(feed)
}
const nameOf = (n) => {
  if (n.type === 'actor') return who(n.id)
  // A record's id IS feed/collection/name, so its author and kind are already here — no lookup,
  // and nothing invented. ⚠ "post" alone answered nothing; whose post is the question being asked.
  const parts = n.id.split('/')
  return (parts[1] || 'post') + ' · ' + who(parts[0]) +
    (n.state === 'converged' ? ' · bytes not here' : '')
}

// ── time on z ─────────────────────────────────────────────────────────────────────────────────
const times = g.nodes.map((n) => (n.at ? Date.parse(n.at) : NaN)).filter((t) => !isNaN(t))
const t0 = times.length ? Math.min(...times) : 0
const t1 = times.length ? Math.max(...times) : 1
const zOf = (at) => {
  const t = at ? Date.parse(at) : NaN
  // ⚠ A record with no time sits at the origin plane rather than being placed by a guess. An
  // invented position is indistinguishable from a measured one once it is drawn.
  if (isNaN(t) || t1 === t0) return 0
  return ((t - t0) / (t1 - t0)) * 260 - 130
}

// ── the layout ────────────────────────────────────────────────────────────────────────────────
//
// A plain Verlet force sim: springs along edges, repulsion between every pair, and a weak pull to
// the origin so a disconnected node does not drift out of frame. ⚠ Written here rather than
// imported — a layout library would be another vendored megabyte for sixty lines of physics.
const P = new Map()
g.nodes.forEach((n, i) => {
  const a = (i / g.nodes.length) * Math.PI * 2
  P.set(n.id, { x: Math.cos(a) * 80 + Math.random(), y: Math.sin(a) * 80 + Math.random(), z: zOf(n.at), vx: 0, vy: 0, node: n })
})

function step () {
  const arr = [...P.values()]
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      const a = arr[i]; const b = arr[j]
      let dx = b.x - a.x; let dy = b.y - a.y
      let d2 = dx * dx + dy * dy
      if (d2 < 1) { d2 = 1; dx = Math.random() - 0.5; dy = Math.random() - 0.5 }
      const f = 900 / d2
      const d = Math.sqrt(d2)
      a.vx -= (dx / d) * f; a.vy -= (dy / d) * f
      b.vx += (dx / d) * f; b.vy += (dy / d) * f
    }
  }
  for (const e of g.edges) {
    const a = P.get(e.from); const b = P.get(e.to)
    if (!a || !b) continue
    const dx = b.x - a.x; const dy = b.y - a.y
    const d = Math.hypot(dx, dy) || 1
    const f = (d - 60) * 0.012
    a.vx += (dx / d) * f; a.vy += (dy / d) * f
    b.vx -= (dx / d) * f; b.vy -= (dy / d) * f
  }
  for (const p of arr) {
    p.vx -= p.x * 0.0015; p.vy -= p.y * 0.0015   // keep it in frame
    p.vx *= 0.82; p.vy *= 0.82                    // damping
    p.x += p.vx; p.y += p.vy
  }
}
for (let i = 0; i < 260; i++) step()

// ── draw ──────────────────────────────────────────────────────────────────────────────────────
const recGeo = new THREE.SphereGeometry(1.5, 8, 6)
const discGeo = new THREE.PlaneGeometry(9, 9)

/**
 * A person, drawn as themselves.
 *
 * ⛔ A DISC, not a sphere. A face wrapped onto a sphere is smeared at the edges and mirrored round
 * the back; a billboarded disc is the shape every other interface uses for a person, which is the
 * reason to use it. The ring is drawn in the accent colour so someone with no picture and someone
 * with one are recognisably the same kind of thing.
 *
 * ⚠ The texture is swapped in when the image arrives. It cannot be awaited — the picture must be on
 * screen before the network finishes, and a node that waited for a 404 would never appear at all.
 */
function actorDisc (label) {
  const c = document.createElement('canvas')
  const D = 128
  c.width = D; c.height = D
  const x = c.getContext('2d')
  const paintRing = () => {
    x.save()
    x.beginPath(); x.arc(D / 2, D / 2, D / 2 - 3, 0, Math.PI * 2)
    x.lineWidth = 5; x.strokeStyle = cssVar('--accent', '#6aa9f0'); x.stroke()
    x.restore()
  }
  x.beginPath(); x.arc(D / 2, D / 2, D / 2 - 5, 0, Math.PI * 2)
  x.fillStyle = cssVar('--accent', '#6aa9f0'); x.fill()
  paintRing()

  const tex = new THREE.CanvasTexture(c)
  if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace
  const mesh = new THREE.Mesh(discGeo, new THREE.MeshBasicMaterial({ map: tex, transparent: true }))
  mesh.userData.billboard = true

  if (label && label.avatar) {
    const img = new Image()
    img.onload = () => {
      x.clearRect(0, 0, D, D)
      x.save()
      // Clipped to the circle, and cover-cropped: an avatar is rarely square and letterboxing one
      // inside a disc looks like a rendering fault.
      x.beginPath(); x.arc(D / 2, D / 2, D / 2 - 5, 0, Math.PI * 2); x.clip()
      const side = Math.min(img.width, img.height)
      x.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, D, D)
      x.restore()
      paintRing()
      tex.needsUpdate = true
    }
    img.src = '/avatar/' + label.avatar
  }
  return mesh
}
// ⛔ The pickable list, in the same order nothing else depends on. A raycast returns meshes, and a
// mesh has to be able to say which node it is — hence userData rather than a parallel array that
// could drift out of step with the scene.
const pickable = []
const billboards = []
for (const p of P.values()) {
  const isActor = p.node.type === 'actor'
  const m = isActor
    ? actorDisc(labels[p.node.id])
    : new THREE.Mesh(recGeo, new THREE.MeshBasicMaterial({
      color: DIM,
      // ⚠ A record whose bytes we cannot read is drawn hollow, the same distinction the debug view
      // makes with a dash. The two views disagree about layout, never about facts.
      wireframe: p.node.state === 'converged'
    }))
  m.position.set(p.x, p.y, p.z)
  m.userData.node = p.node
  scene.add(m)
  pickable.push(m)
  if (m.userData.billboard) billboards.push(m)
}
/**
 * ⛔ ONE PASS PER KIND, so a line can say WHAT it is.
 *
 * Every edge was the same grey thread: the picture could tell you something happened between two
 * points and never what. Colour is the only channel left — x, y and z are spent by the layout and
 * by time — and it is read from the same CSS variables the 2D view's strokes use, so a repost
 * cannot be green in one picture and grey in the other.
 *
 * ⚠ `authored` stays faint. It is now most of the edge set and is always true; drawn at the weight
 * of a reaction it buries every social edge under the structural ones.
 */
const EDGE_STYLE = {
  authored: ['--edge-authored', 0.14],
  follow: ['--edge-follow', 0.4],
  reaction: ['--edge-reaction', 0.6],
  repost: ['--edge-repost', 0.6],
  reply: ['--edge-reply', 0.6]
}
for (const kind of Object.keys(EDGE_STYLE)) {
  const [varName, opacity] = EDGE_STYLE[kind]
  const pts = []
  for (const e of g.edges) {
    if (e.kind !== kind) continue
    const a = P.get(e.from); const b = P.get(e.to)
    if (!a || !b) continue
    pts.push(new THREE.Vector3(a.x, a.y, a.z), new THREE.Vector3(b.x, b.y, b.z))
  }
  if (!pts.length) continue
  scene.add(new THREE.LineSegments(
    new THREE.BufferGeometry().setFromPoints(pts),
    new THREE.LineBasicMaterial({ color: col(varName, '#8b98a5'), transparent: true, opacity })
  ))
}

// ── what is in the picture ────────────────────────────────────────────────────────────────────
//
// ⛔ COUNTS, not rankings. Every number here is a property of the whole set — how many people, how
// many posts, how many of those this node cannot read. A "top actors" list would be a leaderboard
// computed from other people's behaviour, which is the aggregation EDGE-GRAPH §6.4 refuses; the
// same refusal that keeps the export button off this page keeps the ranking off it.
{
  const recs = g.nodes.filter((n) => n.type === 'record')
  const gaps = recs.filter((n) => n.state === 'converged').length
  const byKind = {}
  for (const e of g.edges) byKind[e.kind] = (byKind[e.kind] || 0) + 1
  const span = times.length > 1
    ? Math.round((t1 - t0) / 36e5) + 'h'
    : '—'
  const row = (k, v) => '<div><dt>' + k + '</dt><dd><b>' + v + '</b></dd></div>'
  document.getElementById('stats').innerHTML =
    row('people', g.nodes.length - recs.length) +
    row('posts', recs.length) +
    // ⛔ Named for what it IS. "unresolved" is jargon for the one failure this view was built to
    // show: the pointer converged and the bytes never came.
    row('bytes missing', gaps) +
    Object.keys(byKind).sort().map((k) => row(k + 's', byKind[k])).join('') +
    row('spanning', span)
}

// ── the plane ─────────────────────────────────────────────────────────────────────────────────
//
// Click a point and the post opens ON A PLANE IN THE SCENE rather than in a panel beside it. That
// is the fusion the third dimension was for: the content sits where the node sits, so reading it
// never costs you the structure you found it in.
//
// ⚠ Drawn to a 2D canvas and used as a texture. The alternative — CSS3DRenderer with real DOM —
// is another vendored file and puts HTML in front of the canvas, where it occludes the graph
// instead of living in it.
const PANEL = { w: 520, pad: 22, maxLines: 14 }
let card = null

function wrap (ctx, text, width, max) {
  const out = []
  for (const para of String(text).split('\n')) {
    if (!para.trim()) { if (out.length) out.push(''); continue }
    let line = ''
    for (const word of para.split(/\s+/)) {
      const next = line ? line + ' ' + word : word
      if (ctx.measureText(next).width > width && line) { out.push(line); line = word } else line = next
      if (out.length >= max) return out
    }
    if (line) out.push(line)
  }
  return out.slice(0, max)
}

/**
 * Draw the card's CONTENT and return the y it ended at. No background, no footer.
 *
 * ⛔ The same function measures and draws. The height used to be its own arithmetic — pad*2 + 96 +
 * bio lines — and it drifted from the drawing until the footer sat on top of the last line. Two
 * expressions for one layout is a bug waiting for someone to edit one of them.
 */
function paint (x, detail, bodyLines) {
  const L = PANEL.pad
  let y = PANEL.pad + 14
  const dim = cssVar('--dim', '#8b98a5')
  const fg = cssVar('--fg', '#14181d')

  if (detail.type === 'actor') {
    x.fillStyle = cssVar('--accent', '#6aa9f0')
    x.font = '600 21px system-ui,sans-serif'
    x.fillText(detail.handle ? '@' + detail.handle : (detail.name || short(detail.id)), L, y)
    y += 26
    if (detail.name && detail.handle) {
      x.fillStyle = fg; x.font = '15px system-ui,sans-serif'
      x.fillText(detail.name, L, y); y += 22
    }
    x.fillStyle = dim; x.font = '13px ui-monospace,monospace'
    x.fillText(short(detail.id), L, y); y += 24
    if (detail.bio) {
      x.fillStyle = fg; x.font = '15px system-ui,sans-serif'
      for (const line of wrap(x, detail.bio, PANEL.w - L * 2, 4)) { x.fillText(line, L, y); y += 21 }
      y += 4
    }
    // ⚠ Why a point can sit there with no edges at all: known of, not held.
    const h = detail.holding || {}
    const state = h.blocked ? 'blocked' : h.following ? 'followed' : h.peeked ? 'peeked' : h.held ? 'held' : 'not held'
    x.fillStyle = dim; x.font = '13px system-ui,sans-serif'
    x.fillText(state, L, y); y += 16
    return y
  }

  x.fillStyle = dim; x.font = '13px system-ui,sans-serif'
  const when = detail.createdAt ? new Date(detail.createdAt).toLocaleString() : 'no time recorded'
  x.fillText((detail.authorName || short(detail.author)) + '  ·  ' + when, L, y)
  y += 26
  if (detail.readable) {
    x.fillStyle = fg; x.font = '16px system-ui,sans-serif'
    for (const line of bodyLines) { x.fillText(line, L, y); y += 23 }
  } else {
    // ⛔ NOT an empty post. EDGE-GRAPH §4 ③: we hold the pointer and the bytes never arrived, and
    // rendering that as blank text destroys the single distinction this view exists to make.
    x.fillStyle = dim; x.font = 'italic 16px system-ui,sans-serif'
    x.fillText('held, bytes never arrived —', L, y); y += 23
    x.fillText('this node cannot read it.', L, y); y += 23
  }

  /**
   * ⛔ WHO, not how many. A line between two points already says an interaction happened; the count
   * alone would repeat that and still not answer what the picture cannot: who did it. So the names
   * come first and the number is only there when the names run out.
   *
   * ⚠ Absent on a reveal, which has no `interactions` — proximity opens a post, and asking three
   * more questions of the daemon for every post you drift past is not the same thing as clicking.
   */
  const inter = detail.interactions
  if (inter) {
    const who = (list) => list.map((r) => r.name || short(r.author))
    const parts = []
    if (inter.reactions.length) {
      // ⚠ Grouped by the reaction itself. Two people who sent different reactions did different
      // things, and folding them into one number says they did the same one.
      const byValue = {}
      for (const r of inter.reactions) (byValue[r.value] = byValue[r.value] || []).push(r)
      for (const v of Object.keys(byValue)) {
        parts.push((v === 'like' ? '\u2665' : v) + ' ' + who(byValue[v]).join(', '))
      }
    }
    if (inter.reposts.length) parts.push('\u21bb ' + who(inter.reposts).join(', '))
    if (inter.replies) parts.push(inter.replies + (inter.replies === 1 ? ' reply' : ' replies'))
    if (parts.length) {
      y += 6
      x.strokeStyle = cssVar('--line', '#d7dbe0'); x.lineWidth = 1
      x.beginPath(); x.moveTo(L, y - 12); x.lineTo(PANEL.w - L, y - 12); x.stroke()
      x.fillStyle = dim; x.font = '13px system-ui,sans-serif'
      for (const line of wrap(x, parts.join('   \u00b7   '), PANEL.w - L * 2, 3)) {
        x.fillText(line, L, y); y += 18
      }
    } else {
      x.fillStyle = dim; x.font = 'italic 13px system-ui,sans-serif'
      y += 6
      // ⚠ Said out loud. A card that simply omitted the row would read the same whether nobody had
      // reacted or the question had never been asked.
      x.fillText('no interactions this node can see', L, y); y += 18
    }
  }
  return y
}

function makeCard (detail, opts = {}) {
  const S = 2 // draw at 2x so the texture survives being looked at closely

  // ⛔ MEASURE FIRST, with the very code that will draw. A canvas has to be sized before anything
  // is drawn on it, and a fixed height left a two-line post in a half-empty sheet — which reads as
  // a rendering fault rather than as a short post.
  const probe = document.createElement('canvas').getContext('2d')
  probe.font = '16px system-ui,sans-serif'
  const bodyLines = detail.type === 'record' && detail.readable
    ? wrap(probe, detail.body || '', PANEL.w - PANEL.pad * 2, PANEL.maxLines)
    : []
  const H = Math.round(paint(probe, detail, bodyLines) + (opts.hint === false ? 0 : 14) + PANEL.pad)

  const c = document.createElement('canvas')
  c.width = PANEL.w * S; c.height = H * S
  const x = c.getContext('2d')
  x.scale(S, S)
  x.fillStyle = cssVar('--bg', '#fff')
  x.strokeStyle = cssVar('--line', '#d7dbe0')
  x.lineWidth = 1
  x.beginPath()
  if (x.roundRect) x.roundRect(0.5, 0.5, PANEL.w - 1, H - 1, 10)
  else x.rect(0.5, 0.5, PANEL.w - 1, H - 1)
  x.fill(); x.stroke()

  paint(x, detail, bodyLines)
  if (opts.hint !== false) {
    x.fillStyle = cssVar('--dim', '#8b98a5')
    x.font = '12px system-ui,sans-serif'
    x.fillText('click again to open  ·  click away to close', PANEL.pad, H - PANEL.pad + 6)
  }

  const tex = new THREE.CanvasTexture(c)
  if (THREE.SRGBColorSpace) tex.colorSpace = THREE.SRGBColorSpace
  const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry(PANEL.w * 0.24, H * 0.24),
    new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthTest: false })
  )
  // ⚠ Drawn last and without depth testing, or a sphere in front of it punches a hole in the text.
  mesh.renderOrder = 10
  mesh.userData.h = H
  return mesh
}

function closeCard () {
  if (!card) return
  scene.remove(card)
  card.material.map.dispose(); card.material.dispose(); card.geometry.dispose()
  card = null
}

async function openCard (mesh) {
  const node = mesh.userData.node
  // A second click on the point that is already open follows the link out to the real record.
  if (card && card.userData.id === node.id) { location.href = card.userData.href; return }
  closeCard()
  // ⚠ Drop any proximity reveal of this same post first: two planes for one record, one excerpted
  // and one whole, drawn at the same anchor.
  const r = revealed.get(node.id)
  if (r) {
    scene.remove(r); r.material.map.dispose(); r.material.dispose(); r.geometry.dispose()
    revealed.delete(node.id)
  }
  let detail
  try {
    const r = await fetch('/api/node?id=' + encodeURIComponent(node.id))
    detail = await r.json()
  } catch { return }
  card = makeCard(detail)
  card.userData.id = node.id
  card.userData.href = detail.href || '/'
  card.userData.anchor = mesh.position.clone()
  scene.add(card)
}

/**
 * ── proximity reveal ─────────────────────────────────────────────────────────────────────────
 *
 * Fly close to a post and it opens. No click.
 *
 * ⛔ DISTANCE TO THE CAMERA, in world units, against a fixed threshold — not "the nearest N". The
 * orbit camera sits at a radius you control with the wheel, so this falls out of the zoom for free:
 * pulled back, the whole cloud is beyond the threshold and nothing opens; pushed in among the
 * points, whatever you are actually near opens by itself. A "nearest N" rule would instead keep
 * five cards on screen at every zoom, including the one where you asked to see the whole shape.
 *
 * ⚠ CAPPED, and nearest-first. Flying into a dense cluster otherwise renders thirty overlapping
 * planes, which is less legible than the dots were.
 */
// ⛔ DERIVED FROM THE LAYOUT, not a constant. 110 world units was picked against one fixture and
// meant nothing: measured at closest zoom it admitted 1 of 14 posts, because the cloud's size is a
// function of how many nodes there are and the spring rest length, and a fixed number is only ever
// right for one graph. Scaled to the cloud's own radius, "close" means the same thing whether this
// node holds twenty records or two thousand.
const RADIUS = Math.max(60, Math.hypot(
  ...['x', 'y'].map((k) => Math.max(...[...P.values()].map((p) => Math.abs(p[k]))))
))
const REVEAL = RADIUS * 0.8
const REVEAL_MAX = 5
// The share of the viewport a revealed card occupies. It is also what decides how near the edge a
// post may be and still open: the card needs room, and the node is not the thing being placed.
const REVEAL_FRACTION = 0.34
const excerpts = new Map()   // "feed/collection/name" -> { excerpt, createdAt }
const fetchedFeeds = new Set()
const revealed = new Map()   // node id -> mesh

// One request per FEED, once. The endpoint scans a feed to answer, and doing that per point as the
// camera drifts would re-scan the same feed for every post in it.
//
// ⛔ A FAILED fetch must not blacklist the feed. Marking it before the request meant one dropped
// response — a daemon restarting, a slow scan, a lost connection — left every post in that feed
// permanently unrevealable for the life of the page, with `.catch(() => {})` making sure nobody
// could ever find out why. In flight is not the same as done.
const inflight = new Set()
function wantFeed (feed) {
  if (fetchedFeeds.has(feed) || inflight.has(feed)) return
  inflight.add(feed)
  fetch('/api/records?feed=' + feed)
    .then((r) => r.json())
    .then((d) => {
      for (const key of Object.keys(d.records || {})) excerpts.set(feed + '/' + key, d.records[key])
      fetchedFeeds.add(feed)
    })
    .catch((err) => { console.warn('favela: records fetch failed for ' + feed.slice(0, 8), err) })
    .finally(() => inflight.delete(feed))
}

function revealCard (node) {
  const parts = node.id.split('/')
  const known = excerpts.get(node.id)
  const mesh = makeCard({
    type: 'record',
    id: node.id,
    author: parts[0],
    authorName: (labels[parts[0]] || {}).name || null,
    collection: parts[1],
    // ⛔ An unreadable record reveals as unreadable. The reveal must not be the one surface where
    // "held, bytes never arrived" quietly becomes an empty card. By here the caller has already
    // established that one of the two is true: we have the text, or the bytes are genuinely absent.
    readable: !!known,
    body: known ? known.excerpt : null,
    createdAt: known ? known.createdAt : node.at
  }, { hint: false })
  mesh.material.opacity = 0
  mesh.material.transparent = true
  return mesh
}

const _view = new THREE.Vector3()
const _ndc = new THREE.Vector3()

function updateReveals () {
  const near = []
  for (const m of pickable) {
    const n = m.userData.node
    if (n.type !== 'record') continue
    // ⛔ NEAR IS NOT ENOUGH — it has to be near AND IN FRONT OF YOU. Distance to the camera is a
    // sphere, so at close zoom the nearest points are as often behind your head as ahead of you:
    // MEASURED, three posts inside the threshold and not one of them on screen. Opening a card you
    // are facing away from is work nobody can see.
    _view.copy(m.position).applyMatrix4(camera.matrixWorldInverse)
    if (_view.z > -1) continue
    const d = camera.position.distanceTo(m.position)
    if (d >= REVEAL) continue
    // ⚠ And far enough from the edges FOR THE CARD, which is the thing that has to fit. Clamping
    // the NODE let a post at the very edge open a panel half outside the box.
    _ndc.copy(m.position).project(camera)
    if (Math.abs(_ndc.x) > 1 - REVEAL_FRACTION || Math.abs(_ndc.y) > 0.75) continue
    near.push({ id: n.id, node: n, d, at: m.position })
  }
  near.sort((a, b) => a.d - b.d)
  const keep = new Set(near.slice(0, REVEAL_MAX).map((x) => x.id))

  for (const [id, mesh] of revealed) {
    if (keep.has(id)) continue
    scene.remove(mesh)
    mesh.material.map.dispose(); mesh.material.dispose(); mesh.geometry.dispose()
    revealed.delete(id)
  }
  for (const x of near.slice(0, REVEAL_MAX)) {
    wantFeed(x.id.split('/')[0])
    // ⛔ NOTHING until there is something true to say. A card built before its text arrives has to
    // render as unreadable — and "held, bytes never arrived" is a FACT about replication, not a
    // description of a fetch that has not returned yet. Saying it early would make the one
    // distinction this view exists to draw into noise you learn to ignore.
    if (!excerpts.has(x.id) && x.node.state !== 'converged') continue
    let mesh = revealed.get(x.id)
    if (!mesh) {
      mesh = revealCard(x.node)
      mesh.userData.anchor = x.at.clone()
      scene.add(mesh)
      revealed.set(x.id, mesh)
    }
    // Fades in as you approach, so arriving reads as arriving rather than as a card appearing.
    //
    // ⛔ Fading across the WHOLE threshold made every card that qualified sit at the far edge of it
    // and render at about five percent — present, invisible, and therefore indistinguishable from
    // the bug it looked like. The fade belongs in the outer third; inside that, it is simply there.
    const t = (REVEAL - x.d) / (REVEAL * 0.33)
    mesh.material.opacity = 0.35 + 0.65 * Math.min(1, Math.max(0, t))
    // ⚠ Smaller than the one a click opens. A reveal is ambient — five of them at the click card's
    // size is a wall of paper where the graph was.
    place(mesh, mesh.userData.anchor, REVEAL_FRACTION)
  }
}

// ── picking ───────────────────────────────────────────────────────────────────────────────────
const ray = new THREE.Raycaster()
const ndc = new THREE.Vector2()
function pick (ev) {
  const r = renderer.domElement.getBoundingClientRect()
  ndc.x = ((ev.clientX - r.left) / r.width) * 2 - 1
  ndc.y = -((ev.clientY - r.top) / r.height) * 2 + 1
  ray.setFromCamera(ndc, camera)
  return ray.intersectObjects(pickable, false)[0] || null
}

/**
 * Put a plane in front of its node and keep it legible.
 *
 * ⛔ CONSTANT APPARENT SIZE. A fixed world size meant 520 pixels of text landing on about 145 screen
 * pixels — drawn, and unreadable, which for a panel whose whole job is to be read is the same as not
 * drawn. The scale is solved from the projection instead: how many world units one pixel spans at
 * the plane's distance, times the fraction of the viewport it should occupy.
 */
function place (mesh, anchor, fraction) {
  mesh.quaternion.copy(camera.quaternion)
  const d = camera.position.distanceTo(anchor)
  const worldPerPx = (2 * Math.tan((camera.fov * Math.PI / 180) / 2) * d) / el.clientHeight
  mesh.scale.setScalar((el.clientWidth * fraction * worldPerPx) / (PANEL.w * 0.24))
  // ⚠ Above the node, or below it when the node is already high on screen — a plane anchored
  // always-upward clips off the top of the box for exactly the nodes nearest the camera.
  const ndc = anchor.clone().project(camera)
  const up = new THREE.Vector3(0, ndc.y > 0 ? -1 : 1, 0).applyQuaternion(camera.quaternion)
  const half = (mesh.userData.h * 0.24 * mesh.scale.x) / 2
  mesh.position.copy(anchor).addScaledVector(up, half + 6 * mesh.scale.x)
}

// ── orbit ─────────────────────────────────────────────────────────────────────────────────────
// ⚠ Thirty lines rather than vendoring OrbitControls. The bar for another file in static/ is that
// nothing short can do the job, and dragging a camera around a point is short.
let rx = 0.5; let ry = 0.6; let dist = 340
let drag = null
let moved = 0
const dom = renderer.domElement
dom.addEventListener('pointerdown', (e) => { drag = { x: e.clientX, y: e.clientY }; moved = 0; dom.setPointerCapture(e.pointerId) })
dom.addEventListener('pointerup', (e) => {
  drag = null
  // ⛔ A drag is not a click. Without this every rotation that happened to finish over a sphere
  // opened it, which makes the view feel like it is fighting you.
  if (moved > 5) return
  const hit = pick(e)
  if (hit) openCard(hit.object); else closeCard()
})
dom.addEventListener('pointermove', (e) => {
  if (!drag) {
    const hit = pick(e)
    dom.style.cursor = hit ? 'pointer' : 'grab'
    if (hit) {
      const r = el.getBoundingClientRect()
      tip.textContent = nameOf(hit.object.userData.node)
      tip.style.left = (e.clientX - r.left + 12) + 'px'
      tip.style.top = (e.clientY - r.top + 12) + 'px'
      tip.hidden = false
    } else tip.hidden = true
    return
  }
  tip.hidden = true
  moved += Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y)
  ry += (e.clientX - drag.x) * 0.006
  rx = Math.max(-1.4, Math.min(1.4, rx + (e.clientY - drag.y) * 0.006))
  drag = { x: e.clientX, y: e.clientY }
})
dom.addEventListener('pointerleave', () => { tip.hidden = true })
dom.addEventListener('wheel', (e) => { e.preventDefault(); dist = Math.max(RADIUS * 0.35, Math.min(900, dist + e.deltaY * 0.4)) }, { passive: false })

// ── fullscreen ────────────────────────────────────────────────────────────────────────────────
// ⚠ The whole explorer goes fullscreen, not just the canvas: a picture with no legend is the state
// this pass set out to fix, and it would be exactly the state fullscreen put you back into.
const box = el.closest('.explorer') || el
const fsBtn = document.getElementById('fullscreen')
fsBtn.addEventListener('click', () => {
  if (document.fullscreenElement) document.exitFullscreen()
  else if (box.requestFullscreen) box.requestFullscreen()
})
function resize () {
  renderer.setSize(el.clientWidth, el.clientHeight)
  camera.aspect = el.clientWidth / el.clientHeight
  camera.updateProjectionMatrix()
}
document.addEventListener('fullscreenchange', () => {
  fsBtn.textContent = document.fullscreenElement ? 'exit fullscreen' : 'fullscreen'
  // ⚠ After the layout has actually changed. Measuring in the same tick reads the old box and the
  // scene comes back the size of the window it left.
  requestAnimationFrame(resize)
})
addEventListener('resize', resize)

function frame () {
  camera.position.set(
    Math.cos(rx) * Math.sin(ry) * dist,
    Math.sin(rx) * dist,
    Math.cos(rx) * Math.cos(ry) * dist
  )
  camera.lookAt(0, 0, 0)
  // ⚠ lookAt sets the quaternion; it does not refresh the matrices that `project` and
  // `matrixWorldInverse` read. Without this every visibility test this frame answers for where the
  // camera was on the LAST one — wrong by exactly one frame, every frame you are moving.
  camera.updateMatrixWorld()
  // Every plane faces you. They are pages in the scene, not surfaces in the model.
  for (const b of billboards) b.quaternion.copy(camera.quaternion)
  if (card) place(card, card.userData.anchor, 0.44)
  updateReveals()
  renderer.render(scene, camera)
  requestAnimationFrame(frame)
}
frame()