0xa89f3db4…071asent to0xecb92cc7…1463·#25,767,121·view on Etherscan
k', toggleArtists);
pbEl.addEventListener('click', togglePatrons);
// ---------------- names (a: artists, p: patrons) ----------------
// ENS reverse records, batched in one eth_call; short address as fallback.
// labels ride a separate overlay canvas so they never pollute the ink.
const RR = '0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C'; // ReverseRecords, mainnet
const ENS_KEY = 'networked.ens.v1';
const short = a => a.slice(0, 6) + '…' + a.slice(-4);
function encodeGetNames(addrs) {
let d = '0xcbf8b66c' + '20'.padStart(64, '0') + addrs.length.toString(16).padStart(64, '0');
for (const a of addrs) d += a.slice(2).toLowerCase().padStart(64, '0');
return d;
}
function decodeStrings(res) {
const h = res.slice(2);
const N = i => parseInt(h.slice(i * 64, i * 64 + 64), 16);
const base = N(0) / 32;
const n = N(base);
const dec = new TextDecoder();
const out = [];
for (let k = 0; k < n; k++) {
const so = base + 1 + N(base + 1 + k) / 32;
const len = N(so);
const bytes = h.slice((so + 1) * 64, (so + 1) * 64 + len * 2);
out.push(dec.decode(Uint8Array.from(bytes.match(/../g) || [], x => parseInt(x, 16))));
}
return out;
}
// one wallet with a malicious resolver reverts a whole ReverseRecords batch —
// bisect on revert until the poisoned address is isolated and skipped
async function fetchNames(batch, cache) {
try {
const names = decodeStrings(await rpc('eth_call', [{ to: RR, data: encodeGetNames(batch) }, 'latest']));
batch.forEach((a, j) => { cache[a] = names[j] || ''; });
} catch (e) {
if (!/revert|execution/i.test(String(e && e.message))) throw e; // transport problem, not poison
if (batch.length === 1) { cache[batch[0]] = ''; return; }
const mid = batch.length >> 1;
await Promise.all([fetchNames(batch.slice(0, mid), cache),
fetchNames(batch.slice(mid), cache)]);
}
}
// the name book: one shared address→name cache, streamed in by batches
let BOOK = null;
function loadBook() {
if (BOOK) return;
BOOK = {};
try { BOOK = JSON.parse(localStorage.getItem(ENS_KEY)) || {}; } catch {}
}
const nameOf = a => { loadBook(); return BOOK[a] || short(a); };
const namesPending = new Set();
// the reasonable ceiling: ~10 batched calls. beyond it, later arrivals keep
// short addresses — the label budget only names the earliest anyway, and
// selections resolve their own neighborhood on demand.
const MAX_ENS = 4000;
async function ensureNames(list) {
if (!list || !list.length) return;
loadBook();
let missing = list.filter(a => BOOK[a] === undefined && !namesPending.has(a));
if (missing.length > MAX_ENS) missing = missing.slice(0, MAX_ENS);
missing.forEach(a => namesPending.add(a));
for (let k = 0; k < missing.length; k += 400) {
try { await fetchNames(missing.slice(k, k + 400), BOOK); }
catch { missing.slice(k).forEach(a => namesPending.delete(a)); return; } // retry later
try { localStorage.setItem(ENS_KEY, JSON.stringify(BOOK)); } catch {}
}
}
// ---------------- selection (click a node to focus its network) ----------------
let SEL = null;
function neighborsOf(node) {
const nbrs = new Set();
const { dP, dA, links } = G;
for (let i = 0; i < links; i++) {
if (dP[i] === node) nbrs.add(dA[i]);
else if (dA[i] === node) nbrs.add(dP[i]);
}
return nbrs;
}
const addrOf = n => n < G.A ? (G.addrs ? G.addrs[n] : null)
: (G.paddrs ? G.paddrs[n - G.A] : null);
function reselect(node) {
SEL = { node, nbrs: neighborsOf(node) };
if (G.addrs) { // name the neighborhood on demand — always cheap
const want = [addrOf(node)];
let n = 0;
for (const nb of SEL.nbrs) { if (n++ > 600) break; want.push(addrOf(nb)); }
ensureNames(want.filter(Boolean));
}
}
function select(node) {
reselect(node);
// rotate the globe so this node comes front and center
const x = mx[node], y = my[node], z = mz[node];
const hxz = Math.hypot(x, z) || 1e-6;
let ty = Math.atan2(x, -z);
ty += Math.round((view.yaw - ty) / (2 * Math.PI)) * 2 * Math.PI; // nearest whole turn
view.tYaw = ty;
view.tPitch = Math.min(1.5, Math.max(-1.5, Math.atan2(-y, hxz)));
view.lastInput = performance.now();
view.dirty = true;
stats();
}
function clearSel() { if (SEL) { SEL = null; view.dirty = true; stats(); } }
function inSel(i) { return G.dP[i] === SEL.node || G.dA[i] === SEL.node; }
function hitTest(x, y) {
const maxN = G.A + Math.min(G.P, 200000); // huge sims: artists only past this
let best = -1, bd = 144; // within 12px
for (let i = 0; i < maxN; i++) {
const p = project(i);
const dx = p[0] - x, dy = p[1] - y;
const d = dx * dx + dy * dy;
if (d < bd) { bd = d; best = i; }
}
return best;
}
let labelBoxes = []; // a visible name IS its node
function hitLabel(x, y) {
for (const b of labelBoxes)
if (x >= b.x - 3 && x <= b.x + b.w + 3 && y >= b.y - 9 && y <= b.y + 9) return b.n;
return -1;
}
function drawLabels() {
const w = window.innerWidth, h = window.innerHeight;
octx.clearRect(0, 0, w, h);
labelBoxes = [];
if (!G) return;
octx.font = '10px monospace';
octx.textBaseline = 'middle';
octx.lineWidth = 3;
octx.strokeStyle = '#fff';
octx.fillStyle = '#000';
const done = new Set();
const put = (node, text) => {
if (done.has(node)) return;
done.add(node);
const p = project(node);
if (p[0] < -120 || p[0] > w + 40 || p[1] < -10 || p[1] > h + 10) return;
octx.strokeText(text, p[0] + 5, p[1] - 6);
octx.fillText(text, p[0] + 5, p[1] - 6);
labelBoxes.push({ n: node, x: p[0] + 5, y: p[1] - 6, w: octx.measureText(text).width });
};
const aName = i => G.addrs ? nameOf(G.addrs[i]) : 'artist ' + i;
const pName = n => G.paddrs && G.paddrs[n - G.A] ? nameOf(G.paddrs[n - G.A]) : 'patron ' + (n - G.A);
const name = n => n < G.A ? aName(n) : pName(n);
if (SEL) { // the focused network is always named & marked
const mark = (node, r) => {
const p = project(node);
octx.fillStyle = '#fff';
octx.fillRect(p[0] - r / 2 - 1, p[1] - r / 2 - 1, r + 2, r + 2);
octx.fillStyle = '#000';
octx.fillRect(p[0] - r / 2, p[1] - r / 2, r, r);
};
let m = 0;
for (const nb of SEL.nbrs) { if (m++ > 600) break; mark(nb, 3); }
mark(SEL.node, 6);
put(SEL.node, name(SEL.node));
let n = 0;
for (const nb of SEL.nbrs) { if (n++ > 400) break; put(nb, name(nb)); }
}
// label budget: at future scale a full sweep would be soup — the earliest
// arrivals (the founders) get named first, zoom + click reach the rest
if (st.artists) for (let i = 0; i < G.A && done.size < 900; i++) put(i, aName(i));
if (st.patrons) for (let p = 0; p < G.P && done.size < 1800; p++) put(G.A + p, pName(G.A + p));
}
// ---------------- main loop ----------------
// moving: cleared sketch each frame (sampled threads, boosted alpha)
// at rest: one clear, then the full ink settles back in over frames
const SKETCH_MAX = 30000;
let rafId = 0;
function tick(now) {
if (!G) { rafId = requestAnimationFrame(tick); return; }
const w = window.innerWidth, h = window.innerHeight;
// idle: the globe turns, slowly, always — the ink gets its 3s to settle first
if (now - view.lastInput > 3000 && !drag.on) view.tYaw += 0.0008;
// stats fade with the observer's attention; never while the chain is streaming
sBox.classList.toggle('hid',
now - uiLast > 4000 && !st.progress && !!G && st.drawn >= G.links);
const d = Math.abs(view.tYaw - view.yaw) + Math.abs(view.tPitch - view.pitch) +
Math.abs(view.tZoom - view.zoom);
const moving = d > 0.0006;
if (moving) {
view.yaw += (view.tYaw - view.yaw) * 0.12;
view.pitch += (view.tPitch - view.pitch) * 0.12;
view.zoom += (view.tZoom - view.zoom) * 0.18;
}
setMatrix(w, h);
// growth advances regardless of rotation
if (st.drawn < G.links) {
st.drawn = Math.min(G.links, st.drawn + st.chunk);
stats();
}
if (moving) {
clearAll();
const stride = Math.max(1, Math.ceil(st.drawn / SKETCH_MAX));
if (st.drawn) drawLinks3(0, st.drawn, Math.min(0.7, st.alpha * Math.sqrt(stride)), stride);
drawArtists();
view.dirty = true;
view.full = 0;
} else {
if (view.dirty) { // first still frame: restart the ink
clearAll();
drawArtists();
view.full = 0;
view.dirty = false;
}
if (view.full < st.drawn) {
const fast = Math.max(st.chunk * 12, 20000);
const to = Math.min(st.drawn, view.full + fast);
drawLinks3(view.full, to, st.alpha, 1);
view.full = to;
}
}
drawLabels();
if (FLASH.length) { // fresh threads glow, then fade into the web
FLASH = FLASH.filter(f => now - f.t < 5000);
for (const f of FLASH) {
const a = 0.9 * (1 - (now - f.t) / 5000);
const p1 = project(G.dP[f.i]);
const x1 = p1[0], y1 = p1[1];
const p2 = project(G.dA[f.i]);
octx.strokeStyle = `rgba(0,0,0,${a})`;
octx.lineWidth = 1.6;
octx.beginPath();
octx.moveTo(x1, y1);
octx.lineTo(p2[0], p2[1]);
octx.stroke();
}
}
rafId = requestAnimationFrame(tick);
}
// ---------------- input ----------------
// grab the globe: drag rotates so the face under the cursor follows the hand,
// release keeps a little inertia
const drag = { on: false, x: 0, y: 0, x0: 0, y0: 0, vx: 0, vy: 0 };
const clampPitch = v => Math.min(1.4, Math.max(-1.4, v));
function dragBy(dx, dy) {
const k = Math.PI / Math.min(window.innerWidth, window.innerHeight);
drag.vx = -dx * k; drag.vy = dy * k;
view.tYaw += drag.vx;
view.tPitch = clampPitch(view.tPitch + drag.vy);
view.lastInput = performance.now();
}
cv.style.cursor = 'grab';
window.addEventListener('mousedown', e => {
drag.on = true; drag.x = drag.x0 = e.clientX;