0xa89f3db4…071asent to0xecb92cc7…1463·#25,767,885·view on Etherscan
(i) ? '#fff' : 'rgba(255,255,255,0.12)';
const p = project(i);
const r = Math.max(1, (2.4 - 1.6 * (i / G.A)) * p[2] * 0.8);
ctx.fillRect(p[0] - r / 2, p[1] - r / 2, r, r);
}
}
// one stroking pass over [from,to): fixed absolute batches so accumulation is
// repaint-invariant; pred filters which links belong to this pass
function strokePass(from, to, stride, alpha, pred, lw) {
const { dP, dA } = G;
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
ctx.lineWidth = lw || (G.links > 150000 ? 0.5 : 0.8);
const B = (G.links > 200000 ? 64 : 8) * stride;
let i = from;
while (i < to) {
const end = Math.min(to, (Math.floor(i / B) + 1) * B);
ctx.beginPath();
for (; i < end; i += stride) {
if (pred && !pred(i)) continue;
const a = project(dP[i]);
const x1 = a[0], y1 = a[1];
const b = project(dA[i]);
ctx.moveTo(x1, y1);
ctx.lineTo(b[0], b[1]);
}
ctx.stroke();
}
}
function drawLinks3(from, to, alpha, stride) {
if (SEL) {
// the focused node's threads at full strength, the rest of the web ghosted
strokePass(from, to, stride, alpha * 0.1, i => !inSel(i));
strokePass(from, to, stride, Math.min(0.75, Math.max(0.5, alpha * 3)), inSel, 1.4);
} else {
strokePass(from, to, stride, alpha, null);
}
if (st.dots && stride === 1) {
const { dP } = G;
for (let j = from; j < to; j++)
if (dP[j] >= G.A) {
ctx.fillStyle = !SEL || dP[j] === SEL.node || SEL.nbrs.has(dP[j])
? 'rgba(255,255,255,0.9)' : 'rgba(255,255,255,0.12)';
const p = project(dP[j]);
const r = Math.max(0.6, p[2] * 0.9);
ctx.fillRect(p[0] - r / 2, p[1] - r / 2, r, r);
}
}
}
const fmt = n => n.toLocaleString('en-US');
function status(t) { sEl.textContent = t; }
// while a node is focused, the stats line says exactly what you are looking at
function selInfo() {
let a = 0, p = 0;
for (const nb of SEL.nbrs) nb < G.A ? a++ : p++;
const nm = SEL.node < G.A
? (G.addrs ? nameOf(G.addrs[SEL.node]) : 'artist ' + SEL.node)
: (G.paddrs && G.paddrs[SEL.node - G.A] ? nameOf(G.paddrs[SEL.node - G.A]) : 'patron ' + (SEL.node - G.A));
const parts = [];
if (a) parts.push(`collects from ${fmt(a)} artist${a > 1 ? 's' : ''}`);
if (p) parts.push(`collected by ${fmt(p)} patron${p > 1 ? 's' : ''}`);
return nm + (parts.length ? ' · ' + parts.join(' · ') : ' · no connections yet');
}
function stats() {
// while growing, count only the wallets the web has reached so far;
// complete, count the whole constellation (unlinked artists included)
// say what is shown: the woven crew, against the whole crew aboard
const w = st.drawn < G.links ? G.wcum[st.drawn] : G.A + G.P;
const lead = HOLDERS ? `${fmt(w)} of ${fmt(HOLDERS.size)} holders` : `${fmt(w)} wallets`;
status((SEL ? selInfo()
: `${lead} · ${fmt(st.drawn)} connections`) +
(st.progress ? ` · ${st.progress}` : ''));
const show = G && G.addrs ? '' : 'none'; // names need real addresses
abEl.style.display = show;
pbEl.style.display = show;
}
function toggleArtists() {
st.artists = !st.artists;
abEl.classList.toggle('on', st.artists);
if (st.artists && G) ensureNames(G.addrs);
}
function togglePatrons() {
st.patrons = !st.patrons;
pbEl.classList.toggle('on', st.patrons);
if (st.patrons && G) ensureNames(G.paddrs);
}
abEl.addEventListener('click', 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 = '#000';
octx.fillStyle = '#fff';
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 = '#000';
octx.fillRect(p[0] - r / 2 - 1, p[1] - r / 2 - 1, r + 2, r + 2);
octx.fillStyle = '#fff';
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)); }
}
// labe