Spaces:
Running
Running
| <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 ; } #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> | |