// 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()