web-graph-embeddings / index.html
malteos's picture
viewer: tile-loading spinner + zoom-adaptive point sizing
efb3310 verified
Raw
History Blame Contribute Delete
34.8 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Web-graph embeddings β€” 2D map (streaming)</title>
<!-- Streaming (fetch-based) LOD viewer. Reads ./manifest.json + ./t/<id>.bin tiles produced by
embed_2d_tile.py, lazy-loading tiles by viewport + zoom so the full ~53 M-node map fits without
inlining anything. Serve its directory over HTTP (python -m http.server) and open this file. -->
<style>
:root {
--bg: #0b0e15; --panel: rgba(18,23,34,.80); --solid: #141a26; --ink: #e7ecf5;
--muted: #8b95a8; --line: rgba(255,255,255,.10); --accent: #56c7f5; --hot: #ffb454;
--shadow: 0 8px 30px rgba(0,0,0,.45);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f3f3ee; --panel: rgba(255,255,255,.86); --solid: #ffffff; --ink: #1a2230;
--muted: #5c6675; --line: rgba(0,0,0,.10); --accent: #0a84c4; --hot: #c9700c;
--shadow: 0 8px 26px rgba(30,40,60,.16);
}
}
:root[data-theme="dark"] {
--bg: #0b0e15; --panel: rgba(18,23,34,.80); --solid: #141a26; --ink: #e7ecf5;
--muted: #8b95a8; --line: rgba(255,255,255,.10); --accent: #56c7f5; --hot: #ffb454;
--shadow: 0 8px 30px rgba(0,0,0,.45);
}
:root[data-theme="light"] {
--bg: #f3f3ee; --panel: rgba(255,255,255,.86); --solid: #ffffff; --ink: #1a2230;
--muted: #5c6675; --line: rgba(0,0,0,.10); --accent: #0a84c4; --hot: #c9700c;
--shadow: 0 8px 26px rgba(30,40,60,.16);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg); color: var(--ink); overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
#stage { position: fixed; inset: 0; }
canvas { display: block; width: 100%; height: 100%; cursor: crosshair; touch-action: none; }
.panel {
position: fixed; background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; box-shadow: var(--shadow);
backdrop-filter: blur(14px) saturate(1.2); -webkit-backdrop-filter: blur(14px) saturate(1.2);
}
#rail { top: 16px; left: 16px; width: 268px; padding: 14px 15px 15px; display: flex;
flex-direction: column; gap: 13px; }
.brand { display: flex; align-items: baseline; gap: 8px; }
.brand h1 { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: -.01em; }
.brand .n { margin-left: auto; font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--accent); font-variant-numeric: tabular-nums; }
.lede { margin: 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.lede a { color: var(--accent); overflow-wrap: anywhere; word-break: break-word; }
.grp { display: flex; flex-direction: column; gap: 6px; }
.lbl { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; color: var(--muted);
font-weight: 600; }
.seg { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
.seg button {
appearance: none; border: 1px solid var(--line); background: transparent; color: var(--ink);
padding: 7px 8px; border-radius: 8px; font: 500 12px/1 system-ui, sans-serif; cursor: pointer;
transition: background .12s, border-color .12s; text-align: left;
}
.seg button:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.seg button[aria-pressed="true"] {
border-color: var(--accent); background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--ink); font-weight: 600;
}
.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.search { position: relative; }
.search input {
width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--solid); color: var(--ink); font: 500 12.5px ui-monospace, Menlo, monospace;
}
.search input::placeholder { color: var(--muted); }
.search input:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; border-color: var(--accent); }
#hits { list-style: none; margin: 5px 0 0; padding: 0; max-height: 176px; overflow-y: auto;
border-radius: 8px; }
#hits li { padding: 6px 9px; border-radius: 6px; cursor: pointer;
font: 500 12px ui-monospace, Menlo, monospace; color: var(--ink); display: flex; gap: 8px; }
#hits li:hover, #hits li[aria-selected="true"] { background: color-mix(in srgb, var(--hot) 22%, transparent); }
#hits li .deg { margin-left: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
#legend { left: 16px; bottom: 16px; max-width: 268px; padding: 11px 13px; display: none;
flex-direction: column; gap: 8px; }
#legend.on { display: flex; }
#legend .lbl { margin-bottom: 1px; }
.swatches { display: flex; flex-direction: column; gap: 2px; max-height: 240px; overflow-y: auto; }
.sw { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--ink);
appearance: none; border: 1px solid transparent; background: transparent; text-align: left;
width: 100%; padding: 3px 6px; border-radius: 6px; cursor: pointer; font-family: inherit;
transition: background .12s, border-color .12s; }
.sw:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.sw[aria-pressed="true"] { border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, transparent); }
.sw:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.sw i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; flex: none; }
.sw .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sw .ct { color: var(--muted); font-variant-numeric: tabular-nums;
font: 500 10px ui-monospace, Menlo, monospace; }
#tip {
position: fixed; pointer-events: none; z-index: 40; padding: 8px 10px; border-radius: 9px;
background: var(--solid); border: 1px solid var(--line); box-shadow: var(--shadow);
font: 500 12px ui-monospace, SFMono-Regular, Menlo, monospace; max-width: 300px; display: none;
transform: translate(12px, 12px);
}
#tip .host { color: var(--accent); word-break: break-all; font-weight: 600; }
#tip .row { color: var(--muted); margin-top: 3px; display: flex; gap: 7px; }
#tip .row b { color: var(--ink); font-weight: 600; }
#hud { right: 16px; bottom: 16px; padding: 8px 11px; display: flex; gap: 12px; align-items: center;
font: 500 11px ui-monospace, Menlo, monospace; color: var(--muted); font-variant-numeric: tabular-nums; }
#hud b { color: var(--ink); font-weight: 600; }
#hud button { appearance: none; border: 1px solid var(--line); background: transparent; color: var(--muted);
border-radius: 7px; padding: 4px 8px; cursor: pointer; font: inherit; }
#hud button:hover { color: var(--ink); border-color: var(--accent); }
#spin { position: fixed; top: 16px; right: 16px; z-index: 45; width: 17px; height: 17px;
border-radius: 50%; border: 2.5px solid var(--line); border-top-color: var(--accent);
box-shadow: var(--shadow); opacity: 0; pointer-events: none; transition: opacity .2s;
animation: spin .7s linear infinite; }
#spin.on { opacity: .95; }
@keyframes spin { to { transform: rotate(360deg); } }
#err { position: fixed; inset: 0; display: none; place-items: center; padding: 24px; text-align: center; }
#err div { max-width: 400px; color: var(--muted); font-size: 13px; line-height: 1.5; }
@media (max-width: 560px) {
#rail { width: calc(100vw - 32px); }
#legend { max-width: calc(100vw - 32px); }
}
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } #spin { animation: none; } }
</style>
</head>
<body>
<div id="stage"><canvas id="cv" aria-label="2D map of host embeddings"></canvas></div>
<section id="rail" class="panel" aria-label="Controls">
<div class="brand"><h1>Web-graph embeddings</h1><span class="n" id="nnodes">β€”</span></div>
<p class="lede" id="lede">The link-prediction embeddings, projected to 2D and streamed by tile.
Structure the model learned is the shape you see. Recolor to ask which properties it separates.</p>
<div class="grp">
<span class="lbl">Color by</span>
<div class="seg" id="colorby" role="group" aria-label="Color by"></div>
</div>
<div class="grp search">
<span class="lbl" id="findlbl">Find a top-20k host</span>
<input id="q" type="search" placeholder="search hostname…" autocomplete="off" spellcheck="false" />
<ul id="hits" role="listbox" aria-label="Search results"></ul>
</div>
</section>
<aside id="legend" class="panel" aria-label="Legend"></aside>
<div id="spin" role="status" aria-label="Loading tiles" aria-hidden="true"></div>
<div id="tip" role="tooltip"></div>
<div id="hud" class="panel">
<span><b id="fps">β€”</b> fps</span><span><b id="tiles">0</b> tiles</span>
<span>zoom <b id="zoom">1.0Γ—</b></span><button id="reset" title="Reset view">reset</button>
</div>
<div id="err"><div id="errmsg"></div></div>
<script>
(function () {
"use strict";
const $ = (id) => document.getElementById(id);
function fail(msg) { $("errmsg").textContent = msg; $("err").style.display = "grid"; }
const params = new URLSearchParams(location.search);
const BASE = (params.get("base") || ".").replace(/\/$/, ""); // tile root; default = page dir
// ── palettes ──────────────────────────────────────────────────────────────────────────────
// 24-colour categorical palette, greedy farthest-point ordered so the most frequent classes
// (rank 0,1,2,…) are the most distinct; legible on both dark and light grounds.
const CAT = [
[85, 158, 231], [237, 70, 29], [29, 237, 29], [240, 76, 208],
[190, 211, 105], [29, 237, 153], [142, 76, 240], [133, 237, 29],
[200, 65, 119], [237, 174, 29], [223, 144, 152], [105, 211, 115],
[29, 237, 237], [184, 144, 223], [65, 92, 200], [144, 207, 223],
[200, 119, 65], [90, 226, 185], [65, 200, 65], [29, 133, 237],
[244, 123, 244], [29, 237, 91], [184, 218, 47], [186, 65, 200],
];
const UNLAB = [110, 120, 135];
function rampColor(t) {
t = Math.max(0, Math.min(1, t));
const stops = [[36, 52, 96], [50, 130, 190], [86, 199, 245], [255, 180, 84]];
const s = t * (stops.length - 1); const i = Math.floor(s), f = s - i;
const a = stops[i], b = stops[Math.min(i + 1, stops.length - 1)];
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
}
const DIM = 26; // dimmed alpha (~10%) for filtered-out nodes
let DEG_BUCKETS = 6;
function degBucket(b) { return Math.min(DEG_BUCKETS - 1, Math.floor(b / (256 / DEG_BUCKETS))); }
function fmtN(n) {
n = Math.round(n);
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
return "" + n;
}
// ── WebGL ─────────────────────────────────────────────────────────────────────────────────
const cv = $("cv");
const gl = cv.getContext("webgl", { antialias: true, alpha: false, premultipliedAlpha: false });
if (!gl) { fail("This viewer needs WebGL, which your browser has disabled."); return; }
function sh(type, src) {
const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, `
attribute vec2 a_xy; attribute vec3 a_col; attribute float a_deg; attribute float a_alpha;
uniform vec2 u_scale; uniform vec2 u_off; uniform float u_pt; uniform float u_ptMin; uniform float u_matchOnly;
varying vec3 v_col; varying float v_alpha;
void main() {
vec2 p = a_xy * u_scale + u_off;
gl_Position = vec4(p, 0.0, 1.0);
gl_PointSize = max(u_ptMin, u_pt * (0.65 + 1.9 * a_deg));
v_col = a_col; v_alpha = a_alpha;
// foreground pass: draw only the filter-matched points (alpha==1), cull the dimmed rest
if (u_matchOnly > 0.5 && a_alpha < 0.99) { gl_PointSize = 0.0; gl_Position = vec4(2.0, 2.0, 2.0, 1.0); }
}`));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, `
precision mediump float; varying vec3 v_col; varying float v_alpha; uniform float u_alpha;
void main() {
vec2 d = gl_PointCoord - 0.5; float r = dot(d, d);
if (r > 0.25) discard;
float a = smoothstep(0.25, 0.14, r);
gl_FragColor = vec4(v_col, a * u_alpha * v_alpha);
}`));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { fail("Shader link failed."); return; }
gl.useProgram(prog);
const A = {
xy: gl.getAttribLocation(prog, "a_xy"), col: gl.getAttribLocation(prog, "a_col"),
deg: gl.getAttribLocation(prog, "a_deg"), alpha: gl.getAttribLocation(prog, "a_alpha"),
};
const U = {
scale: gl.getUniformLocation(prog, "u_scale"), off: gl.getUniformLocation(prog, "u_off"),
pt: gl.getUniformLocation(prog, "u_pt"), alpha: gl.getUniformLocation(prog, "u_alpha"),
ptMin: gl.getUniformLocation(prog, "u_ptMin"), matchOnly: gl.getUniformLocation(prog, "u_matchOnly"),
};
gl.enableVertexAttribArray(A.xy); gl.enableVertexAttribArray(A.col);
gl.enableVertexAttribArray(A.deg); gl.enableVertexAttribArray(A.alpha);
// ── state ─────────────────────────────────────────────────────────────────────────────────
let META = null, N = 0, nCat = 0, active = 0, filterVal = -1, defaultSet = 0;
let view = { s: 0.9, cx: 0, cy: 0 };
let asp = 1, dpr = Math.min(window.devicePixelRatio || 1, 2), dirty = true;
const tileMeta = new Map(); // id -> manifest tile {id,z,n,bbox}
const colorRank = {}; // setIdx -> [classIdx -> frequency rank] for CAT color
const ready = new Map(); // id -> loaded tile object
let activeIds = new Set();
const MAXKEEP = 600; // evict non-visible tiles beyond this many
let inflight = 0; const MAXFETCH = 8; const queue = [];
let pin = null; // {id, li} pinned/searched node
// ── boot: fetch manifest ────────────────────────────────────────────────────────────────────
fetch(BASE + "/manifest.json").then((r) => {
if (!r.ok) throw new Error("manifest.json " + r.status);
return r.json();
}).then((m) => { META = m; boot(); }).catch((e) =>
fail("Could not load manifest.json from " + BASE + " β€” serve this folder over HTTP "
+ "(python -m http.server) and open the page from there. (" + e.message + ")"));
function boot() {
N = META.n; DEG_BUCKETS = META.degBuckets || 6;
nCat = META.labelSets.filter((l) => l.kind === "cat").length;
META.tiles.forEach((t) => tileMeta.set(t.id, t));
// colors follow frequency rank (most frequent class -> CAT[0]) so the top classes are distinct
META.labelSets.forEach((ls, i) => {
if (ls.kind !== "cat") return;
const cnt = ls.counts || ls.classes.map(() => 0);
const rank = new Array(ls.classes.length);
ls.classes.map((_, k) => k).sort((a, b) => cnt[b] - cnt[a]).forEach((k, pos) => { rank[k] = pos; });
colorRank[i] = rank;
});
$("nnodes").textContent = N >= 1e6 ? (N / 1e6).toFixed(N % 1e6 ? 1 : 0) + "M"
: (N / 1e3).toFixed(0) + "k";
if (META.lede) $("lede").innerHTML = META.lede; // trusted manifest string (may contain a link)
defaultSet = Math.max(0, META.labelSets.findIndex((l) => l.name === "language")); // color by language on load
const nSearch = (META.search || []).length; // label the search box with its actual coverage
if (nSearch) $("findlbl").textContent = "Find a top-" + fmtN(nSearch) + " host";
buildColorBy(); buildSearch();
resize(); recolor(defaultSet); requestAnimationFrame(loop);
}
// ── tile loading ────────────────────────────────────────────────────────────────────────────
function pump() {
while (inflight < MAXFETCH && queue.length) { const id = queue.shift(); doLoad(id); }
}
function startLoad(id) {
if (ready.has(id) || queue.includes(id)) return;
ready.set(id, { id, status: "queued" }); queue.push(id); pump();
}
function doLoad(id) {
const t = ready.get(id); const meta = tileMeta.get(id); if (!t || !meta) return;
// tiles are sharded into t/<d>/ subdirs (manifest ``d``); fall back to flat t/ for old manifests
const tdir = "/t/" + (meta.d != null ? meta.d + "/" : "") + id;
t.status = "loading"; inflight++;
fetch(BASE + tdir + ".bin").then((r) => r.arrayBuffer()).then((buf) => {
const K = meta.n; let o = 0;
t.n = K;
t.xy = new Int16Array(buf, o, K * 2); o += K * 4;
t.labs = [];
for (let c = 0; c < nCat; c++) { t.labs.push(new Uint8Array(buf, o, K)); o += K; }
t.deg = new Uint8Array(buf, o, K); o += K;
t.bXY = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, t.bXY);
gl.bufferData(gl.ARRAY_BUFFER, t.xy, gl.STATIC_DRAW);
t.bDeg = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, t.bDeg);
gl.bufferData(gl.ARRAY_BUFFER, t.deg, gl.STATIC_DRAW);
t.bCol = gl.createBuffer(); t.bAlpha = gl.createBuffer();
t.names = null;
colorTile(t); alphaTile(t);
t.status = "ready"; dirty = true;
if (META.hasNames) fetch(BASE + tdir + ".nm").then((r) => r.text())
.then((txt) => { t.names = txt.length ? txt.split("\n") : []; });
}).catch(() => { ready.delete(id); }).finally(() => { inflight--; pump(); });
}
function evictTile(t) {
if (t.bXY) gl.deleteBuffer(t.bXY); if (t.bDeg) gl.deleteBuffer(t.bDeg);
if (t.bCol) gl.deleteBuffer(t.bCol); if (t.bAlpha) gl.deleteBuffer(t.bAlpha);
}
function colorTile(t) {
if (t.status === "queued" || !t.deg) return;
const set = META.labelSets[active], K = t.n, buf = new Uint8Array(K * 3);
if (set.kind === "degree") {
for (let i = 0; i < K; i++) { const c = rampColor(t.deg[i] / 255), j = i * 3;
buf[j] = c[0]; buf[j + 1] = c[1]; buf[j + 2] = c[2]; }
} else {
const lab = t.labs[active], rank = colorRank[active];
for (let i = 0; i < K; i++) { const v = lab[i], c = v === 255 ? UNLAB : CAT[rank[v] % CAT.length];
const j = i * 3; buf[j] = c[0]; buf[j + 1] = c[1]; buf[j + 2] = c[2]; }
}
gl.bindBuffer(gl.ARRAY_BUFFER, t.bCol); gl.bufferData(gl.ARRAY_BUFFER, buf, gl.STATIC_DRAW);
}
function alphaTile(t) {
if (t.status === "queued" || !t.deg) return;
const set = META.labelSets[active], K = t.n, buf = new Uint8Array(K);
if (filterVal < 0) {
buf.fill(255);
} else if (set.kind === "degree") {
for (let i = 0; i < K; i++) buf[i] = degBucket(t.deg[i]) === filterVal ? 255 : DIM;
} else {
const lab = t.labs[active];
for (let i = 0; i < K; i++) buf[i] = lab[i] === filterVal ? 255 : DIM;
}
gl.bindBuffer(gl.ARRAY_BUFFER, t.bAlpha); gl.bufferData(gl.ARRAY_BUFFER, buf, gl.DYNAMIC_DRAW);
}
// ── LOD + viewport β†’ which tiles are active ─────────────────────────────────────────────────
// LOD = highest degree band shown at the current zoom. The bands themselves are baked into the
// tiles; this only controls WHEN each appears. LOD_RATE = bands revealed per zoom-doubling (lower
// β‡’ low-degree hosts hold off until you zoom deeper); LOD_BIAS = bands the overview already shows.
const LOD_RATE = 0.7, LOD_BIAS = 0.4;
function lodLevel() {
return Math.max(0, Math.min(META.zMax, Math.round(Math.log2(view.s) * LOD_RATE + LOD_BIAS)));
}
function viewport() {
const hw = 1 / (view.s * asp), hh = 1 / view.s, m = 0.25; // 25% prefetch margin
return [view.cx - hw * (1 + m), view.cy - hh * (1 + m),
view.cx + hw * (1 + m), view.cy + hh * (1 + m)];
}
function updateActive() {
// Tiles loaded = current LOD band + viewport, ALWAYS (a filter never changes this). Filtering
// only dims the non-matching points among what this zoom already shows β€” it must not pull in the
// low-degree hosts that are meant to stay hidden until you zoom in.
const maxZ = lodLevel(), vp = viewport(); activeIds = new Set();
for (const t of META.tiles) {
if (t.z > maxZ) continue;
const b = t.bbox;
if (b[0] < vp[2] && b[2] > vp[0] && b[1] < vp[3] && b[3] > vp[1]) {
activeIds.add(t.id); if (!ready.has(t.id)) startLoad(t.id);
}
}
if (ready.size > MAXKEEP) { // drop non-visible tiles to bound GPU memory
for (const [id, t] of ready) {
if (ready.size <= MAXKEEP) break;
if (!activeIds.has(id) && t.status === "ready") { evictTile(t); ready.delete(id); }
}
}
}
// ── draw ────────────────────────────────────────────────────────────────────────────────────
function drawTiles() { // bind + draw each ready active tile; returns count
let drawn = 0;
for (const id of activeIds) {
const t = ready.get(id); if (!t || t.status !== "ready") continue;
gl.bindBuffer(gl.ARRAY_BUFFER, t.bXY); gl.vertexAttribPointer(A.xy, 2, gl.SHORT, true, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, t.bCol); gl.vertexAttribPointer(A.col, 3, gl.UNSIGNED_BYTE, true, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, t.bDeg); gl.vertexAttribPointer(A.deg, 1, gl.UNSIGNED_BYTE, true, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, t.bAlpha); gl.vertexAttribPointer(A.alpha, 1, gl.UNSIGNED_BYTE, true, 0, 0);
gl.drawArrays(gl.POINTS, 0, t.n); drawn++;
}
return drawn;
}
function draw() {
const bg = getComputedStyle(document.body).backgroundColor.match(/\d+/g) || [11, 14, 21];
gl.clearColor(bg[0] / 255, bg[1] / 255, bg[2] / 255, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.uniform2f(U.scale, view.s * asp, view.s);
gl.uniform2f(U.off, -view.cx * view.s * asp, -view.cy * view.s);
// point size = a hump in log-zoom: grow through the hub-only overview (declutter), then SHRINK
// once LOD reveals the dense low-degree crowd at high zoom (screen density peaks there, so smaller
// dots keep the map readable). Peaks ~4Γ— zoom, decays toward a 2px floor at 150–500Γ—+.
const L = Math.log2(view.s);
gl.uniform1f(U.pt, Math.max(1.2, dpr * (2.0 + 5.0 * Math.exp(-(L - 2.0) * (L - 2.0) / 8.0))));
// floor on-screen size so points stay visible when zoomed way in: off through the good low/mid
// zoom (≀~22Γ—), then ramps in to a 10px minimum by ~128Γ— (per-point max, so it lifts only the
// too-small dots rather than inflating the whole map).
gl.uniform1f(U.ptMin, dpr * 10 * Math.min(1, Math.max(0, (L - 4.6) / 2.4)));
// pass 1: whole scene (filtered-out points already dimmed to ~10% via per-vertex alpha)
gl.uniform1f(U.matchOnly, 0);
gl.uniform1f(U.alpha, N > 4e6 ? 0.5 : 0.75);
const drawn = drawTiles();
// pass 2 (filter active): redraw only the matched points, opaque and on top (foreground)
if (filterVal >= 0) {
gl.uniform1f(U.matchOnly, 1);
gl.uniform1f(U.alpha, N > 4e6 ? 0.9 : 1.0);
drawTiles();
gl.uniform1f(U.matchOnly, 0);
}
$("tiles").textContent = drawn;
if (pin) drawPin();
}
const bPin = gl.createBuffer();
function drawPin() {
const t = ready.get(pin.id); if (!t || t.status !== "ready") return;
gl.bindBuffer(gl.ARRAY_BUFFER, bPin);
gl.bufferData(gl.ARRAY_BUFFER, new Int16Array([t.xy[pin.li * 2], t.xy[pin.li * 2 + 1]]), gl.STATIC_DRAW);
gl.vertexAttribPointer(A.xy, 2, gl.SHORT, true, 0, 0);
const hot = getComputedStyle(document.body).getPropertyValue("--hot").trim();
const m = hot.match(/\w\w/g).map((h) => parseInt(h, 16));
gl.disableVertexAttribArray(A.col); gl.disableVertexAttribArray(A.deg);
gl.disableVertexAttribArray(A.alpha);
gl.vertexAttrib3f(A.col, m[0] / 255, m[1] / 255, m[2] / 255);
gl.vertexAttrib1f(A.deg, 1); gl.vertexAttrib1f(A.alpha, 1);
gl.uniform1f(U.pt, 16 * dpr); gl.uniform1f(U.alpha, 1);
gl.drawArrays(gl.POINTS, 0, 1);
gl.enableVertexAttribArray(A.col); gl.enableVertexAttribArray(A.deg);
gl.enableVertexAttribArray(A.alpha);
}
// ── recolor + legend ────────────────────────────────────────────────────────────────────────
function recolor(idx) {
active = idx; filterVal = -1;
for (const t of ready.values()) { colorTile(t); alphaTile(t); }
drawLegend(idx); dirty = true;
}
function buildColorBy() {
const seg = $("colorby");
META.labelSets.forEach((ls, i) => {
const b = document.createElement("button"); b.textContent = ls.name;
b.setAttribute("aria-pressed", i === defaultSet ? "true" : "false");
b.onclick = () => { [...seg.children].forEach((c) => c.setAttribute("aria-pressed", "false"));
b.setAttribute("aria-pressed", "true"); recolor(i); };
seg.appendChild(b);
});
}
function degLabel(b) { return Math.round(Math.pow(10, (b / 255) * (META.degLogMax || 4))); }
function drawLegend(idx) {
const ls = META.labelSets[idx], el = $("legend");
el.innerHTML = "";
const head = document.createElement("span"); head.className = "lbl";
head.textContent = ls.name + (ls.note ? " Β· " + ls.note : "");
el.appendChild(head);
let entries;
if (ls.kind === "degree") {
const w = 256 / DEG_BUCKETS, c = META.degCounts || []; entries = [];
for (let k = 0; k < DEG_BUCKETS; k++) {
const lo = degLabel(Math.round(k * w)), hi = degLabel(Math.round(Math.min(255, (k + 1) * w)));
entries.push({ val: k, color: rampColor((k + 0.5) * w / 255),
text: fmtN(lo) + "–" + fmtN(hi), count: c[k] || 0 });
}
} else {
const c = ls.counts || ls.classes.map(() => 0), rank = colorRank[idx];
entries = ls.classes.map((cl, k) => ({ val: k, color: CAT[rank[k] % CAT.length], text: cl,
count: c[k] || 0 })).filter((e) => e.count > 0).sort((a, b) => b.count - a.count);
const na = N - c.reduce((s, v) => s + (v || 0), 0); // unlabelled = 255 sentinel
if (na > 0) entries.push({ val: 255, color: UNLAB, text: "N/A", count: na });
}
const wrap = document.createElement("div"); wrap.className = "swatches";
entries.forEach((e) => {
const b = document.createElement("button"); b.type = "button"; b.className = "sw";
b.setAttribute("aria-pressed", filterVal === e.val ? "true" : "false");
b.title = "Show only these β€” click again to clear";
const c = e.color;
b.innerHTML = `<i style="background:rgb(${c[0] | 0},${c[1] | 0},${c[2] | 0})"></i>` +
`<span class="nm">${e.text}</span><span class="ct">${fmtN(e.count)}</span>`;
b.onclick = () => {
filterVal = (filterVal === e.val) ? -1 : e.val;
for (const t of ready.values()) alphaTile(t);
drawLegend(idx); dirty = true;
};
wrap.appendChild(b);
});
el.appendChild(wrap); el.classList.add("on");
}
// ── hover pick (linear scan over visible tiles) ─────────────────────────────────────────────
const PICK_PX = 15;
function pick(px, py) {
const w = cv.clientWidth, h = cv.clientHeight;
const ndcx = (px / w) * 2 - 1, ndcy = 1 - (py / h) * 2;
const wx = ndcx / (view.s * asp) + view.cx, wy = ndcy / view.s + view.cy;
const sx = wx * 32768, sy = wy * 32768;
const supx = (2 / w) / (view.s * asp) * 32768, supy = (2 / h) / view.s * 32768;
let best = null, bd = PICK_PX * PICK_PX;
for (const id of activeIds) {
const t = ready.get(id); if (!t || t.status !== "ready") continue;
const xy = t.xy;
for (let i = 0; i < t.n; i++) {
const dxp = (xy[i * 2] - sx) / supx, dyp = (xy[i * 2 + 1] - sy) / supy;
const d = dxp * dxp + dyp * dyp;
if (d < bd) { bd = d; best = { id, li: i, t }; }
}
}
return best;
}
function hover(px, py) {
const hit = pick(px, py); const tip = $("tip");
if (!hit) { tip.style.display = "none"; return; }
const t = hit.t, i = hit.li;
const name = t.names ? (t.names[i] || null) : null;
let rows = "";
META.labelSets.forEach((ls, k) => {
if (ls.kind === "degree") return;
const v = t.labs[k][i], nm = v === 255 ? "β€”" : ls.classes[v];
rows += `<span class="row"><b>${ls.name}</b> ${nm}</span>`;
});
tip.innerHTML = `<div class="host">${name || "(loading name…)"}</div>` +
`<span class="row"><b>degree</b> ~${degLabel(t.deg[i])}</span>` + rows;
tip.style.left = px + "px"; tip.style.top = py + "px"; tip.style.display = "block";
}
// ── search (global top-degree index from manifest) ──────────────────────────────────────────
function buildSearch() {
const idxList = META.search || [];
const lo = idxList.map((e) => e[0].toLowerCase());
const q = $("q"), hits = $("hits");
q.addEventListener("input", () => {
const term = q.value.trim().toLowerCase(); hits.innerHTML = "";
if (!term) return;
const out = [];
for (let k = 0; k < idxList.length && out.length < 30; k++)
if (lo[k].includes(term)) out.push(idxList[k]);
out.forEach((e) => {
const li = document.createElement("li"); li.setAttribute("role", "option");
li.innerHTML = `<span>${e[0]}</span><span class="deg">~${degLabel(e[3])}</span>`;
li.onclick = () => flyTo(e); hits.appendChild(li);
});
});
}
function flyTo(entry) { // entry = [name, tileId, localIdx, deg8]
const [, tid, liLocal] = entry; const meta = tileMeta.get(tid); if (!meta) return;
startLoad(tid);
const b = meta.bbox, tx = (b[0] + b[2]) / 2, ty = (b[1] + b[3]) / 2; // fallback: tile centre
const finish = () => {
const t = ready.get(tid);
const px = (t && t.xy) ? t.xy[liLocal * 2] / 32768 : tx;
const py = (t && t.xy) ? t.xy[liLocal * 2 + 1] / 32768 : ty;
pin = { id: tid, li: liLocal };
const s0 = view.s, cx0 = view.cx, cy0 = view.cy, ts = Math.max(40, view.s), t0 = performance.now(), D = 620;
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
(function step(now) {
const p = reduce ? 1 : Math.min(1, (now - t0) / D), e = 1 - Math.pow(1 - p, 3);
view.s = s0 + (ts - s0) * e; view.cx = cx0 + (px - cx0) * e; view.cy = cy0 + (py - cy0) * e;
dirty = true; if (p < 1) requestAnimationFrame(step);
})(t0);
};
const t = ready.get(tid);
if (t && t.status === "ready") finish();
else { const iv = setInterval(() => { const tt = ready.get(tid);
if (tt && tt.status === "ready") { clearInterval(iv); finish(); } }, 60); }
}
// ── interaction ─────────────────────────────────────────────────────────────────────────────
let drag = null;
cv.addEventListener("pointerdown", (e) => { drag = { x: e.clientX, y: e.clientY }; cv.setPointerCapture(e.pointerId); });
cv.addEventListener("pointerup", () => { drag = null; });
cv.addEventListener("pointermove", (e) => {
if (drag) {
const w = cv.clientWidth, h = cv.clientHeight;
view.cx -= ((e.clientX - drag.x) / w) * 2 / (view.s * asp);
view.cy += ((e.clientY - drag.y) / h) * 2 / view.s;
drag.x = e.clientX; drag.y = e.clientY; dirty = true; $("tip").style.display = "none";
} else { hover(e.clientX, e.clientY); }
});
cv.addEventListener("pointerleave", () => { $("tip").style.display = "none"; });
cv.addEventListener("wheel", (e) => {
e.preventDefault();
const w = cv.clientWidth, h = cv.clientHeight;
const ndcx = (e.clientX / w) * 2 - 1, ndcy = 1 - (e.clientY / h) * 2;
const wx = (ndcx / (view.s * asp)) + view.cx, wy = (ndcy / view.s) + view.cy;
const f = Math.exp(-e.deltaY * 0.0016); view.s = Math.max(0.15, Math.min(1200, view.s * f));
view.cx = wx - (ndcx / (view.s * asp)); view.cy = wy - (ndcy / view.s); dirty = true;
}, { passive: false });
$("reset").onclick = () => { view = { s: 0.9, cx: 0, cy: 0 }; pin = null; dirty = true; };
function resize() {
const w = cv.clientWidth, h = cv.clientHeight;
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
gl.viewport(0, 0, cv.width, cv.height); asp = h / w; dirty = true;
}
window.addEventListener("resize", resize);
// ── render loop ─────────────────────────────────────────────────────────────────────────────
let fFrames = 0, fT = performance.now(), spinOn = false, spinUntil = 0;
function syncSpinner(now) { // ring visible while any tile is queued/fetching
const busy = inflight > 0 || queue.length > 0; // ...held β‰₯350ms so fast local loads still flash
if (busy) spinUntil = now + 350;
const show = busy || now < spinUntil;
if (show !== spinOn) { spinOn = show; $("spin").classList.toggle("on", show); }
}
function loop(now) {
syncSpinner(now);
if (dirty) {
updateActive(); draw(); dirty = false;
$("zoom").textContent = view.s.toFixed(view.s < 10 ? 1 : 0) + "Γ—";
}
fFrames++;
if (now - fT > 500) { $("fps").textContent = Math.round((fFrames * 1000) / (now - fT)); fFrames = 0; fT = now; }
requestAnimationFrame(loop);
}
})();
</script>
</body>
</html>