{
  "$type": "site.standard.document",
  "content": {
    "$type": "pub.leaflet.content",
    "pages": [
      {
        "$type": "pub.leaflet.pages.linearDocument",
        "blocks": [
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "plaintext": "<!DOCTYPE html>\n<html lang=\"en\">\n<!--\ndrunk.moe\nlathrys.at\nadverb.bsky.social\nlastnpcalex.agency\nflicknow.xyz\n-->\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>pca your friends — minor mobius x hoopy frood</title>\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js\",\n    \"three/addons/\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/\"\n  }\n}\n</script>\n<style>\n:root {\n  --bg: #faf9f6; --text: #1a1a1a; --muted: #777; --rule: #ccc; --link: #8b0000;\n  --pc1: #b06090; --pc2: #5090b0; --pc3: #70a060;\n  --mono: 'SF Mono','Cascadia Code','Fira Code',Menlo,monospace;\n  --serif: 'Iowan Old Style','Palatino Linotype',Palatino,Georgia,serif;\n}\n@media (prefers-color-scheme: dark) {\n  :root { --bg:#0f0f0f; --text:#d4d4d4; --muted:#777; --rule:#333; --link:#c45;\n          --pc1:#d080b0; --pc2:#70b0d0; --pc3:#90c080; }\n}\n* { margin:0; padding:0; box-sizing:border-box; }\nbody { background:var(--bg); color:var(--text); font-family:var(--serif);\n       line-height:1.7; padding:4rem 2rem; max-width:960px; margin:0 auto; }\nh1 { font-family:var(--mono); font-size:0.85rem; font-weight:400; letter-spacing:0.15em;\n     text-transform:lowercase; color:var(--muted); margin-bottom:0.5rem; }\nh1 a { color:var(--link); text-decoration:none; }\nh1 a:hover { color:var(--text); }\n.subtitle { font-size:1.15rem; color:var(--text); margin-bottom:1rem; }\n.desc { font-size:0.95rem; color:var(--muted); margin-bottom:0.75rem; }\n.desc-small { font-size:0.8rem; color:var(--muted); margin-bottom:2.5rem; line-height:1.6; }\n.hidden { display:none !important; }\n.tabs { display:flex; gap:1rem; margin-bottom:1rem; }\n.tab { font-family:var(--mono); font-size:0.7rem; letter-spacing:0.05em; color:var(--muted);\n       cursor:pointer; padding-bottom:0.3rem; border:none; border-bottom:1px solid transparent; background:none; }\n.tab.active { color:var(--text); border-bottom-color:var(--link); }\n.tab:hover { color:var(--text); }\ntextarea { width:100%; font-family:var(--mono); font-size:0.8rem; padding:0.75rem;\n           border:1px solid var(--rule); background:var(--bg); color:var(--text);\n           resize:vertical; margin-bottom:0.75rem; }\ntextarea:focus { outline:none; border-color:var(--link); }\n.list-row { display:flex; gap:0.5rem; margin-bottom:0.75rem; }\n.list-row input { flex:1; font-family:var(--mono); font-size:0.8rem; padding:0.5rem 0.75rem;\n                  border:1px solid var(--rule); background:var(--bg); color:var(--text); }\n.list-row input:focus { outline:none; border-color:var(--link); }\nbutton { font-family:var(--mono); font-size:0.75rem; letter-spacing:0.05em;\n         padding:0.5rem 1.25rem; border:1px solid var(--rule); background:var(--bg);\n         color:var(--text); cursor:pointer; white-space:nowrap; }\nbutton:hover { border-color:var(--link); color:var(--link); }\nbutton:disabled { opacity:0.4; cursor:not-allowed; }\n.labeler-row { display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem; flex-wrap:wrap; }\n.labeler-row select { font-family:var(--mono); font-size:0.75rem; padding:0.35rem 0.6rem;\n                      border:1px solid var(--rule); background:var(--bg); color:var(--text); cursor:pointer; }\n.labeler-row select:focus { outline:none; border-color:var(--link); }\n.labeler-extra { margin-bottom:1.25rem; }\n.labeler-extra input { width:100%; font-family:var(--mono); font-size:0.75rem; padding:0.4rem 0.75rem;\n                       border:1px solid var(--rule); background:var(--bg); color:var(--text); }\n.labeler-extra input:focus { outline:none; border-color:var(--link); }\n.labeler-hint { font-family:var(--mono); font-size:0.7rem; color:var(--muted); margin-top:0.3rem; }\n.action-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:2rem; }\n.handle-count { font-family:var(--mono); font-size:0.7rem; color:var(--muted); }\n.progress-track { width:100%; height:1px; background:var(--rule); margin-bottom:1.5rem; overflow:hidden; }\n.progress-fill { height:100%; background:var(--link); width:0%; transition:width 0.3s ease; }\n.status-line { font-family:var(--mono); font-size:0.75rem; color:var(--muted); margin-bottom:1.5rem; }\n.status-line.error { color:var(--link); }\n.view-tabs { display:flex; gap:1rem; margin-bottom:1rem; }\n#resultsLayout { display:grid; grid-template-columns:1fr 280px; gap:2rem; align-items:start; margin-bottom:2.5rem; }\n#resultsLayout.chart-only { grid-template-columns:1fr; }\n#resultsLayout.chart-only #postCol { display:none; }\n#postCol { position:sticky; top:1.5rem; max-height:calc(100vh - 3rem); overflow-y:auto; }\n.chart-wrap { position:relative; }\n.chart-wrap canvas { width:100%; aspect-ratio:8/7; display:block; touch-action:none; }\n#threeWrap { width:100%; aspect-ratio:8/7; position:relative; overflow:hidden; }\n#threeWrap canvas { width:100%!important; height:100%!important; display:block; }\n.ax-label { position:absolute; pointer-events:none; font-family:var(--mono); font-size:0.65rem;\n            background:transparent; padding:0.2rem 0.35rem; line-height:1.3; text-align:center;\n            transform:translate(-50%,-50%); white-space:nowrap; }\n#axisGrid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:2rem; margin-top:2rem; padding-top:1.5rem; border-top:1px solid var(--rule); }\n.axis-col-header { font-family:var(--mono); font-size:0.72rem; font-weight:600; letter-spacing:0.05em; margin-bottom:0.5rem; padding-bottom:0.4rem; border-bottom:1px solid var(--rule); }\n.axis-col-desc { font-size:0.82rem; color:var(--text); margin-bottom:0.75rem; line-height:1.55; }\n.axis-pole-header { font-family:var(--mono); font-size:0.8rem; font-weight:600; color:#f66; margin:0.75rem 0 0.3rem; }\n.axis-post { font-size:0.78rem; color:var(--text); margin-bottom:0.6rem; line-height:1.45; word-break:break-word; }\n.axis-post-handle { font-family:var(--mono); font-size:0.65rem; color:var(--muted); display:block; margin-bottom:0.1rem; }\n.tooltip { position:absolute; pointer-events:none; font-family:var(--mono); font-size:0.7rem;\n           background:var(--bg); border:1px solid var(--rule); padding:0.4rem 0.6rem;\n           color:var(--text); white-space:nowrap; z-index:10; line-height:1.4; }\n.readout { font-family:var(--mono); font-size:0.75rem; color:var(--muted); min-height:3.5rem;\n           display:flex; align-items:center; gap:0.75rem; padding:0.75rem 0;\n           border-bottom:1px solid var(--rule); margin-bottom:0.5rem; }\n.readout-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; }\n.readout-handle { color:var(--text); font-size:0.8rem; font-weight:700; }\n.readout-handle a { color:var(--text); text-decoration:none; }\n.readout-handle a:hover { text-decoration:underline; }\n.readout-scores { display:flex; gap:0.5rem; margin-top:0.25rem; flex-wrap:wrap; }\n.readout-score { font-family:var(--mono); font-size:0.65rem; padding:0.1rem 0.35rem; border-radius:1px; }\n.section-header { font-family:var(--mono); font-size:0.7rem; color:var(--muted); letter-spacing:0.05em;\n                  margin-bottom:0.75rem; padding-bottom:0.5rem; border-bottom:1px solid var(--rule); }\n.post-row { display:flex; align-items:flex-start; gap:0.5rem; padding:0.4rem 0;\n            border-bottom:1px solid var(--rule); line-height:1.5; }\n.post-row:last-child { border-bottom:none; }\n.post-pcs { display:flex; flex-direction:column; gap:0.15rem; flex-shrink:0; padding-top:0.15rem; }\n.post-pc { font-family:var(--mono); font-size:0.58rem; padding:0.05rem 0.25rem; border-radius:1px; white-space:nowrap; }\n.post-text { flex:1; min-width:0; font-size:0.82rem; color:var(--text); word-break:break-word; }\nfooter { margin-top:4rem; padding-top:1.5rem; border-top:1px solid var(--rule);\n         font-family:var(--mono); font-size:0.7rem; color:var(--muted); letter-spacing:0.05em; }\nfooter a { color:var(--muted); text-decoration:none; }\nfooter a:hover { color:var(--text); }\n@media (max-width:700px) { #resultsLayout { grid-template-columns:1fr; } #postCol { position:static; max-height:none; } #axisGrid { grid-template-columns:1fr; } }\n@media (max-width:560px) { body { padding:1.5rem 0.75rem; } }\n</style>\n</head>\n<body>\n\n<h1>pca your friends / <a href=\"https://bsky.app/profile/minormobius.bsky.social\">minor mobius</a> x <a href=\"https://bsky.app/profile/huwupy.kawaii.social\">hoopy frood</a></h1>\n<p class=\"subtitle\">No anchors. No labels. Just structure.</p>\n<p class=\"desc\">\n  Paste bluesky handles, pick an embedder, and click <em>map it</em>.\n  Hover over dots to identify users; click one to see their posts in the side panel.\n  Axes with their top posts appear below the chart.\n  Hit <em>export</em> to download a self-contained interactive HTML you can share.\n</p>\n<p class=\"desc-small\">\n  Each user's recent posts are embedded into high-dimensional vector space — either SPLADE vocabulary\n  weights (sparse, local), BGE-large dense vectors (local), or Voyage AI embeddings (cloud, fast).\n  PCA extracts three orthogonal axes of maximum variance across the full corpus.\n  The axes are unlabeled by the algorithm; an LLM reads the top posts along each axis\n  and infers what each one is actually about.\n</p>\n\n<div class=\"tabs\" id=\"inputTabs\">\n  <button class=\"tab active\" data-tab=\"paste\">paste handles</button>\n  <button class=\"tab\" data-tab=\"list\">load list</button>\n</div>\n<div id=\"pastePanel\">\n  <textarea id=\"handleList\" rows=\"5\" placeholder=\"one handle per line&#10;alice.bsky.social&#10;bob.bsky.social\" spellcheck=\"false\"></textarea>\n</div>\n<div id=\"listPanel\" class=\"hidden\">\n  <div class=\"list-row\">\n    <input type=\"text\" id=\"listUrl\" placeholder=\"https://bsky.app/profile/.../lists/...\" autocomplete=\"off\" spellcheck=\"false\">\n    <button id=\"loadListBtn\">load list</button>\n  </div>\n</div>\n<div class=\"labeler-row\">\n  <span style=\"font-family:var(--mono);font-size:0.7rem;color:var(--muted)\">embedder:</span>\n  <select id=\"embedderChoice\">\n    <option value=\"voyage\">voyage-3-lite · api · fast</option>\n    <option value=\"bge\">bge-large · local · dense</option>\n    <option value=\"splade\">splade · local · vocab</option>\n  </select>\n  <span style=\"font-family:var(--mono);font-size:0.7rem;color:var(--muted);margin-left:0.5rem\">axis labels:</span>\n  <select id=\"labeler\">\n    <option value=\"none\">corpus stats</option>\n    <option value=\"browser\">browser LLM · WebGPU (~500MB)</option>\n    <option value=\"llama\">llama-server · local</option>\n    <option value=\"anthropic\">anthropic api</option>\n  </select>\n</div>\n<div class=\"labeler-extra hidden\" id=\"embedderExtra\">\n  <input type=\"password\" id=\"embedderInput\" autocomplete=\"off\" spellcheck=\"false\" placeholder=\"voyage api key\">\n  <div class=\"labeler-hint\">key stored in localStorage only</div>\n</div>\n<div class=\"labeler-extra hidden\" id=\"labelerExtra\">\n  <input type=\"password\" id=\"labelerInput\" autocomplete=\"off\" spellcheck=\"false\">\n  <div class=\"labeler-hint\" id=\"labelerHint\"></div>\n</div>\n<div class=\"action-row\">\n  <button id=\"mapBtn\" disabled>map</button>\n  <span class=\"handle-count\" id=\"handleCount\">0 handles</span>\n  <span style=\"font-family:var(--mono);font-size:0.7rem;color:var(--muted);margin-left:0.5rem\">active in last</span>\n  <input type=\"number\" id=\"activeDays\" value=\"7\" min=\"1\" title=\"leave blank for all time\" style=\"width:3rem;font-family:var(--mono);font-size:0.75rem;padding:0.3rem 0.4rem;border:1px solid var(--rule);background:var(--bg);color:var(--text);text-align:center\">\n  <span style=\"font-family:var(--mono);font-size:0.7rem;color:var(--muted)\">days</span>\n</div>\n<div class=\"progress-track hidden\" id=\"progressBar\"><div class=\"progress-fill\" id=\"progressFill\"></div></div>\n<div class=\"status-line hidden\" id=\"status\"></div>\n\n<div id=\"results\" class=\"hidden\">\n  <div class=\"view-tabs\">\n    <button class=\"tab active\" id=\"viewTernaryBtn\">ternary</button>\n    <button class=\"tab\" id=\"view3dBtn\">3d</button>\n    <button class=\"tab\" id=\"exportBtn\" style=\"margin-left:auto\">export ↓</button>\n  </div>\n  <div id=\"resultsLayout\">\n    <div id=\"chartCol\">\n      <div id=\"ternaryWrap\" class=\"chart-wrap\">\n        <canvas id=\"ternaryChart\"></canvas>\n        <div class=\"tooltip hidden\" id=\"tooltip\"></div>\n      </div>\n      <div id=\"threeWrap\" class=\"hidden\"></div>\n      <div class=\"readout\" id=\"readout\">hover a hex to inspect · click to expand</div>\n    </div>\n    <div id=\"postCol\">\n      <div style=\"display:flex;justify-content:flex-end;margin-bottom:0.5rem\">\n        <button id=\"collapseBtn\" style=\"font-size:0.65rem;padding:0.2rem 0.6rem;border-color:var(--rule)\">✕ collapse</button>\n      </div>\n      <div id=\"postList\"></div>\n    </div>\n  </div>\n  <div id=\"axisGrid\"></div>\n</div>\n\n<script type=\"module\">\nimport * as THREE from 'three';\nimport { OrbitControls } from 'three/addons/controls/OrbitControls.js';\nimport { AutoTokenizer, AutoModelForMaskedLM, pipeline as tfPipeline }\n  from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js';\n\n// ── Constants ──────────────────────────────────────\nconst DEVICE = navigator.gpu ? 'webgpu' : 'wasm';\nconst BSKY = 'https://public.api.bsky.app';\nconst POSTS_PER_USER = 30;\nconst BATCH_SIZE = navigator.gpu ? 128 : 4;\nconst MAX_LEN = 64;\nconst POWER_ITER = 60;\nconst HEX_RADIUS_MAX = 16;\nconst HEX_RADIUS_MIN = 8;\n\nconst SPLADE_WGSL = `\nstruct Uni { B: u32, S: u32, V: u32 }\n@group(0) @binding(0) var<uniform> uni: Uni;\n@group(0) @binding(1) var<storage, read> logits: array<f32>;\n@group(0) @binding(2) var<storage, read> mask: array<u32>;\n@group(0) @binding(3) var<storage, read_write> out: array<f32>;\n@compute @workgroup_size(256)\nfn main(@builtin(global_invocation_id) gid: vec3<u32>) {\n  let idx = gid.x;\n  if (idx >= uni.B * uni.V) { return; }\n  let b = idx / uni.V;\n  let v = idx % uni.V;\n  var mx: f32 = 0.0;\n  for (var t: u32 = 0u; t < uni.S; t = t + 1u) {\n    if (mask[b * uni.S + t] == 0u) { continue; }\n    let raw = logits[(b * uni.S + t) * uni.V + v];\n    if (raw > 0.0) { mx = max(mx, log(1.0 + raw)); }\n  }\n  out[b * uni.V + v] = mx;\n}`;\n\nconst GRAM_WGSL = `\nstruct Uni { N: u32, D: u32 }\n@group(0) @binding(0) var<uniform> uni: Uni;\n@group(0) @binding(1) var<storage, read> Xc: array<f32>;\n@group(0) @binding(2) var<storage, read_write> G: array<f32>;\n@compute @workgroup_size(8, 8)\nfn main(@builtin(global_invocation_id) id: vec3<u32>) {\n  let i = id.x; let k = id.y; let N = uni.N; let D = uni.D;\n  if (i >= N || k >= N) { return; }\n  var s: f32 = 0.0;\n  for (var j: u32 = 0u; j < D; j = j + 1u) { s = s + Xc[i*D+j] * Xc[k*D+j]; }\n  G[i*N+k] = s;\n}`;\n\n// ── State ──────────────────────────────────────────\nlet tokenizer = null;\nlet mlmModel = null;\nlet useDenseEmbedder = false;\nlet denseEmbedder = null;\nlet vocabSize = 30522;\nlet gpuDevice = null;\nlet gramPipeline = null;\nlet splatePipeline = null;\nlet BAKED_DATA = null;\nlet avatarImages = {};    // canvas-2d usage\nlet chartState = null;\nlet threeObjs = null;\nlet viewMode = 'ternary';\nlet lockedHandle = null;\n\n// ── Utilities ──────────────────────────────────────\nconst $ = id => document.getElementById(id);\nconst show = el => el.classList.remove('hidden');\nconst hide = el => el.classList.add('hidden');\nfunction escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }\nfunction setStatus(msg, err) { const e=$('status'); show(e); e.textContent=msg; e.classList.toggle('error',!!err); }\nfunction setProgress(pct) { show($('progressBar')); $('progressFill').style.width=pct+'%'; }\nfunction getColors() {\n  const cs = getComputedStyle(document.documentElement);\n  return ['text','muted','rule','bg','pc1','pc2','pc3','link'].reduce((o,k)=>{o[k]=cs.getPropertyValue('--'+k).trim();return o;},{});\n}\nfunction getHandles() {\n  const raw = $('handleList').value.trim(); if (!raw) return [];\n  return raw.split(/[\\n,]+/).map(h=>h.trim().replace(/^@/,'')).filter(h=>h.length>0);\n}\nfunction updateCount() {\n  const n=getHandles().length;\n  $('handleCount').textContent=n+' handle'+(n!==1?'s':'');\n  $('mapBtn').disabled=n<2;\n}\n\n// ── BSKY API ───────────────────────────────────────\nasync function resolveHandle(handle) {\n  if (handle.startsWith('did:')) return handle;\n  const r = await fetch(BSKY+'/xrpc/com.atproto.identity.resolveHandle?handle='+encodeURIComponent(handle));\n  if (!r.ok) throw new Error('Could not resolve: '+handle);\n  return (await r.json()).did;\n}\nasync function fetchProfile(did) {\n  const r = await fetch(BSKY+'/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent(did));\n  return r.ok ? r.json() : null;\n}\nasync function fetchRecentPosts(did, max) {\n  const texts=[]; let cursor, lastPostAt=null;\n  let iterations = 0;\n  while (texts.length < max && iterations < 10) {\n    iterations += 1;\n    let url=BSKY+'/xrpc/app.bsky.feed.getAuthorFeed?actor='+encodeURIComponent(did)+'&limit=100&filter=posts_and_author_threads';\n    if (cursor) url+='&cursor='+encodeURIComponent(cursor);\n    const r=await fetch(url); if (!r.ok) break;\n    const data=await r.json(); const feed=data.feed||[]; if (!feed.length) break;\n    for (let i=0;i<feed.length&&texts.length<max;i++) {\n      const item=feed[i]; if (item.reason) continue;\n      if (!lastPostAt && item.post&&item.post.indexedAt) lastPostAt=new Date(item.post.indexedAt);\n      const t=item.post&&item.post.record&&item.post.record.text;\n      if (t&&t.length>5&&!t.startsWith('…')) texts.push(t);\n    }\n    cursor=data.cursor; if (!cursor) break;\n  }\n  return { texts, lastPostAt };\n}\nasync function loadUserData(handle) {\n  const did=await resolveHandle(handle); const prof=await fetchProfile(did);\n  const { texts, lastPostAt }=await fetchRecentPosts(did, POSTS_PER_USER);\n  return { handle:prof?prof.handle:handle, did, avatar:prof?prof.avatar:null, texts, lastPostAt };\n}\nasync function batchLoad(handles, concurrency, onProgress) {\n  const results=[]; let idx=0,completed=0;\n  async function worker() {\n    while (idx<handles.length) {\n      const i=idx++;\n      try { results.push(await loadUserData(handles[i])); } catch(e) {}\n      completed++; if (onProgress) onProgress(completed,handles.length);\n    }\n  }\n  await Promise.all(Array.from({length:Math.min(concurrency,handles.length)},worker));\n  return results;\n}\nfunction parseListUrl(url) { const m=url.match(/\\/profile\\/([^/]+)\\/lists\\/([^/?#]+)/); return m?{actor:m[1],rkey:m[2]}:null; }\nasync function fetchListMembers(actor, rkey) {\n  const did=await resolveHandle(actor);\n  const atUri='at://'+did+'/app.bsky.graph.list/'+rkey;\n  const handles=[]; let cursor;\n  while (true) {\n    let url=BSKY+'/xrpc/app.bsky.graph.getList?list='+encodeURIComponent(atUri)+'&limit=100';\n    if (cursor) url+='&cursor='+encodeURIComponent(cursor);\n    const r=await fetch(url); if (!r.ok) throw new Error('Failed to load list (HTTP '+r.status+')');\n    const data=await r.json(); const items=data.items||[]; if (!items.length) break;\n    for (const item of items) { if (item.subject&&item.subject.handle) handles.push(item.subject.handle); }\n    cursor=data.cursor; if (!cursor) break;\n  }\n  return handles;\n}\n\n// ── Model loading ──────────────────────────────────\nasync function loadModel(onProgress) {\n  const wantSplade = $('embedderChoice').value === 'splade';\n\n  if (!wantSplade) {\n    if (!denseEmbedder) {\n      onProgress('loading bge-large…');\n      denseEmbedder = await tfPipeline('feature-extraction', 'Xenova/bge-large-en-v1.5', {\n        device: DEVICE, dtype: 'q8',\n        progress_callback: p => { if (p&&p.progress!=null) onProgress('downloading: '+Math.round(p.progress)+'%'); },\n      });\n    }\n    vocabSize = 1024; useDenseEmbedder = true;\n    onProgress('bge-large ready');\n    return;\n  }\n\n  // SPLADE path — try proper SPLADE models, fall back to BGE if none load\n  if (!mlmModel) {\n    for (const modelId of ['naver/splade-cocondenser-selfdistil', 'naver/efficient-splade-VI-BT-large-query']) {\n      try {\n        onProgress('loading tokenizer (' + modelId + ')…');\n        tokenizer = await AutoTokenizer.from_pretrained(modelId);\n        onProgress('loading splade model…');\n        mlmModel = await AutoModelForMaskedLM.from_pretrained(modelId, {\n          device: DEVICE, dtype: 'q8',\n          progress_callback: p => { if (p&&p.progress!=null) onProgress('downloading: '+Math.round(p.progress)+'%'); },\n        });\n        vocabSize = 30522; useDenseEmbedder = false;\n        onProgress('splade ready (' + modelId + ')');\n        return;\n      } catch(e) { tokenizer=null; mlmModel=null; }\n    }\n    // SPLADE unavailable — fall back\n    onProgress('splade unavailable, falling back to bge-large…');\n    if (!denseEmbedder) {\n      denseEmbedder = await tfPipeline('feature-extraction', 'Xenova/bge-large-en-v1.5', {\n        device: DEVICE, dtype: 'q8',\n        progress_callback: p => { if (p&&p.progress!=null) onProgress('downloading: '+Math.round(p.progress)+'%'); },\n      });\n    }\n    vocabSize = 1024; useDenseEmbedder = true;\n    onProgress('bge-large ready (splade unavailable)');\n  } else {\n    vocabSize = 30522; useDenseEmbedder = false;\n    onProgress('splade ready (cached)');\n  }\n}\n\n// ── SPLADE-style inference ─────────────────────────\n// Returns { userVec, postVecs } — postVecs is array of per-post Float32Arrays\nasync function embedUser(texts) {\n  const postVecs = [];\n  if (!texts.length) return { userVec: new Float32Array(vocabSize), postVecs };\n\n  if (useDenseEmbedder) {\n    const userVec = new Float32Array(vocabSize);\n    for (let i=0; i<texts.length; i+=BATCH_SIZE) {\n      const batch=texts.slice(i,i+BATCH_SIZE);\n      const out=await denseEmbedder(batch, { pooling:'mean', normalize:true });\n      for (let b=0; b<batch.length; b++) {\n        const pv=new Float32Array(vocabSize);\n        for (let j=0; j<vocabSize; j++) pv[j]=out.data[b*vocabSize+j];\n        postVecs.push(pv);\n        for (let j=0; j<vocabSize; j++) userVec[j]+=pv[j];\n      }\n    }\n    const n=texts.length;\n    for (let j=0; j<vocabSize; j++) userVec[j]/=n;\n    return { userVec, postVecs };\n  }\n\n  // SPLADE: relu(log1p(logits)), max-pool over tokens per post, then max across posts for user vec\n  const userVec = new Float32Array(vocabSize);\n  for (let i=0; i<texts.length; i+=BATCH_SIZE) {\n    const batch=texts.slice(i,i+BATCH_SIZE);\n    const inputs=await tokenizer(batch, { padding:true, truncation:true, max_length:MAX_LEN, return_tensors:'pt' });\n    const { logits }=await mlmModel(inputs);\n    const bSize=batch.length;\n    const seqLen=inputs.attention_mask.dims[1];\n    const attnData=inputs.attention_mask.data;\n    const logData=logits.data;\n    if (gpuDevice && splatePipeline) {\n      const batchOut = await computeSpladePostProcess(logData, attnData, bSize, seqLen);\n      for (let b=0; b<bSize; b++) {\n        const pv=new Float32Array(vocabSize);\n        for (let v=0; v<vocabSize; v++) pv[v]=batchOut[b*vocabSize+v];\n        postVecs.push(pv);\n        for (let v=0; v<vocabSize; v++) { if (pv[v]>userVec[v]) userVec[v]=pv[v]; }\n      }\n    } else {\n      for (let b=0; b<bSize; b++) {\n        const pv=new Float32Array(vocabSize);\n        for (let t=0; t<seqLen; t++) {\n          if (!attnData[b*seqLen+t]) continue;\n          const tOff=(b*seqLen+t)*vocabSize;\n          for (let v=0; v<vocabSize; v++) {\n            const val=Math.log1p(Math.max(0, logData[tOff+v]));\n            if (val>pv[v]) pv[v]=val;\n          }\n        }\n        postVecs.push(pv);\n        for (let v=0; v<vocabSize; v++) { if (pv[v]>userVec[v]) userVec[v]=pv[v]; }\n      }\n    }\n  }\n  return { userVec, postVecs };\n}\n\nasync function embedVoyage(texts, apiKey) {\n  const resp = await fetch('https://api.voyageai.com/v1/embeddings', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },\n    body: JSON.stringify({ input: texts, model: 'voyage-3-lite' }),\n  });\n  if (!resp.ok) throw new Error('Voyage API ' + resp.status + ': ' + await resp.text());\n  const { data } = await resp.json();\n  return data.sort((a, b) => a.index - b.index).map(d => new Float32Array(d.embedding));\n}\n\n// Batch-embed all users together — keeps the GPU fed with full batches instead of\n// one per-user call at a time (250 × 30-post mini-batches → idle gaps between each).\nasync function embedAllUsers(userData, onProgress) {\n  const N = userData.length;\n  const allTexts = [], userOf = [];\n  for (let i = 0; i < N; i++) {\n    for (const t of userData[i].texts) { allTexts.push(t); userOf.push(i); }\n  }\n  const total = allTexts.length;\n  const userVecs = Array.from({length: N}, () => new Float32Array(vocabSize));\n  const allPostVecs = Array.from({length: N}, () => []);\n  const postCounts = new Uint32Array(N);\n\n  const voyageKey = ($('embedderInput').value || localStorage.getItem('voyage_api_key') || '').trim();\n  const useVoyage = $('embedderChoice').value === 'voyage';\n\n  for (let i = 0; i < total; i += BATCH_SIZE) {\n    if (onProgress) onProgress(i, total);\n    const batchTexts = allTexts.slice(i, i + BATCH_SIZE);\n    const batchUsers = userOf.slice(i, i + BATCH_SIZE);\n    let pvs;\n\n    if (useVoyage) {\n      pvs = await embedVoyage(batchTexts, voyageKey);\n    } else if (useDenseEmbedder) {\n      const out = await denseEmbedder(batchTexts, { pooling: 'mean', normalize: true, truncation: true, max_length: 128 });\n      pvs = batchTexts.map((_, b) => {\n        const pv = new Float32Array(vocabSize);\n        for (let v = 0; v < vocabSize; v++) pv[v] = out.data[b * vocabSize + v];\n        return pv;\n      });\n    } else {\n      const inputs = await tokenizer(batchTexts, { padding: true, truncation: true, max_length: MAX_LEN, return_tensors: 'pt' });\n      const { logits } = await mlmModel(inputs);\n      const bSize = batchTexts.length, seqLen = inputs.attention_mask.dims[1];\n      const attnData = inputs.attention_mask.data, logData = logits.data;\n      pvs = [];\n      if (gpuDevice && splatePipeline) {\n        const batchOut = await computeSpladePostProcess(logData, attnData, bSize, seqLen);\n        for (let b = 0; b < bSize; b++) {\n          const pv = new Float32Array(vocabSize);\n          for (let v = 0; v < vocabSize; v++) pv[v] = batchOut[b * vocabSize + v];\n          pvs.push(pv);\n        }\n      } else {\n        for (let b = 0; b < bSize; b++) {\n          const pv = new Float32Array(vocabSize);\n          for (let t = 0; t < seqLen; t++) {\n            if (!attnData[b * seqLen + t]) continue;\n            const tOff = (b * seqLen + t) * vocabSize;\n            for (let v = 0; v < vocabSize; v++) {\n              const val = Math.log1p(Math.max(0, logData[tOff + v]));\n              if (val > pv[v]) pv[v] = val;\n            }\n          }\n          pvs.push(pv);\n        }\n      }\n    }\n\n    for (let b = 0; b < pvs.length; b++) {\n      const ui = batchUsers[b], pv = pvs[b];\n      allPostVecs[ui].push(pv);\n      if (useDenseEmbedder) {\n        for (let v = 0; v < vocabSize; v++) userVecs[ui][v] += pv[v];\n        postCounts[ui]++;\n      } else {\n        for (let v = 0; v < vocabSize; v++) { if (pv[v] > userVecs[ui][v]) userVecs[ui][v] = pv[v]; }\n      }\n    }\n  }\n  if (useDenseEmbedder) {\n    for (let i = 0; i < N; i++) {\n      const n = postCounts[i] || 1;\n      for (let v = 0; v < vocabSize; v++) userVecs[i][v] /= n;\n    }\n  }\n  return { userVecs, allPostVecs };\n}\n\n// ── Corpus-based axis labels ───────────────────────\n// Correlates per-user word frequencies with PC projections.\n// Top correlated words are the axis labels — always readable actual words.\nfunction extractCorpusLabels(userData, projections, k) {\n  const N = userData.length;\n  const STOP = new Set([\n    'the','and','for','are','but','not','you','all','this','that','with','have','from',\n    'they','what','their','would','there','been','were','when','more','will','she','was',\n    'his','her','has','had','its','who','our','out','can','did','get','him','now','may',\n    'use','how','any','came','come','like','just','also','about','said','then','over',\n    'very','well','much','them','some','want','know','dont','into','your','its','its',\n    'one','two','even','only','back','still','here','after','where','those','being',\n    'these','other','such','than','should','through','because','really','people',\n    'going','think','feel','good','time','look','make','let','never','always','every',\n  ]);\n\n  const userWords = userData.map(u => {\n    const wf = {};\n    for (const text of u.texts) {\n      for (const t of (text.toLowerCase().match(/\\b[a-z]{3,15}\\b/g) || []))\n        if (!STOP.has(t)) wf[t] = (wf[t]||0) + 1;\n    }\n    return wf;\n  });\n\n  return [0,1,2].map(comp => {\n    const scores = projections.map(p => p[comp]);\n    const mean = scores.reduce((a,b)=>a+b,0)/N;\n    const centered = scores.map(s => s-mean);\n    const corr = {};\n    for (let i=0; i<N; i++)\n      for (const [w,c] of Object.entries(userWords[i]))\n        corr[w] = (corr[w]||0) + centered[i] * Math.log1p(c);\n    return Object.entries(corr)\n      .filter(([,v]) => v > 0)\n      .sort((a,b) => b[1]-a[1])\n      .slice(0, k).map(([w]) => w);\n  });\n}\n\n// ── PCA directions in vocab space ─────────────────\n// V_k = Xc.T @ u_k: the k-th PC direction expressed in vocabulary (or embedding) space.\nfunction computePCDirections(Xc, N, D, eigenvecs) {\n  return eigenvecs.map(u => {\n    const v = new Float32Array(D);\n    for (let j=0; j<D; j++) {\n      let s=0; for (let i=0; i<N; i++) s+=Xc[i*D+j]*u[i]; v[j]=s;\n    }\n    normalizeVec(v);\n    return v;\n  });\n}\n\n// ── Per-post projection ────────────────────────────\n// Returns [pc1_pct, pc2_pct, pc3_pct] normalized to sum=100 for display.\nfunction projectPost(postVec, means, Vk) {\n  const raw = Vk.map(vk => {\n    let s=0;\n    for (let j=0; j<postVec.length; j++) s+=(postVec[j]-means[j])*vk[j];\n    return s;\n  });\n  const minR = Math.min(...raw);\n  let vs = raw.map(r => Math.max(r-minR, 0.02));\n  const tot = vs.reduce((a,b)=>a+b, 0.001);\n  return vs.map(v => Math.round(v/tot*100));\n}\n\n// ── LLM axis labeling ──────────────────────────────\n\n// Brief prompt for small/browser models — fits in ~512 tokens\nfunction axisPrompt(userData, projections, comp) {\n  const indexed = userData.map((u, i) => ({ u, score: projections[i][comp] }));\n  indexed.sort((a, b) => b.score - a.score);\n  const high = indexed.slice(0, 2).flatMap(({ u }) => u.texts.slice(0, 4));\n  const low  = indexed.slice(-2).flatMap(({ u }) => u.texts.slice(0, 4));\n  return 'HIGH end posts:\\n' + high.map(t => '- ' + t.slice(0, 200)).join('\\n') +\n         '\\n\\nLOW end posts:\\n' + low.map(t => '- ' + t.slice(0, 200)).join('\\n') +\n         '\\n\\nReply in exactly this format:\\nLabel: [2–4 words for HIGH end] ↔ [2–4 words for LOW end]\\nDescription: [1–2 sentences explaining what this dimension captures]\\n\\nThe HIGH-end phrase goes on the left of the arrow, the LOW-end phrase on the right.';\n}\n\n// Rich prompt for capable local models.\n// Uses per-post PC scores (derived from embeddings) to select the most axis-aligned\n// posts from across the whole corpus — so the embeddings actively curate what the LLM sees.\n// corpusWords (word-frequency signals) are passed as an additional vocabulary hint.\nfunction axisPromptRich(userData, projections, comp, corpusWords) {\n  // Collect every post with its embedding-derived PC score for this component\n  const allPosts = [];\n  userData.forEach(u => {\n    (u.postScores || []).forEach((ps, pi) => {\n      if (u.texts[pi]) allPosts.push({ handle: u.handle, text: u.texts[pi], score: ps[comp] });\n    });\n  });\n  allPosts.sort((a, b) => b.score - a.score);\n\n  const fmt = posts =>\n    posts.map(p => `  @${p.handle}: \"${p.text.replace(/\"/g, '’')}\"`).join('\\n');\n\n  const hints = corpusWords && corpusWords[comp] && corpusWords[comp].length\n    ? `Vocabulary signals for the HIGH end (word-frequency correlation): ${corpusWords[comp].join(', ')}\\n\\n`\n    : '';\n\n  return hints +\n    `[LEFT SIDE — posts scoring HIGHEST on this dimension]:\\n${fmt(allPosts.slice(0, 8))}\\n\\n` +\n    `[RIGHT SIDE — posts scoring LOWEST]:\\n${fmt(allPosts.slice(-8))}\\n\\n` +\n    `Write a label in the format \"LEFT_PHRASE ↔ RIGHT_PHRASE\" (2–4 words each). ` +\n    `LEFT_PHRASE must describe the LEFT SIDE posts above; RIGHT_PHRASE must describe the RIGHT SIDE posts above. ` +\n    `Focus on tone, register, rhetorical stance, or affect — how these posts feel to read, not just their topics. ` +\n    `Just the label, nothing else.`;\n}\n\n// Anthropic claude-haiku\nasync function labelAxisWithClaude(apiKey, userData, projections, comp) {\n  const res = await fetch('https://api.anthropic.com/v1/messages', {\n    method: 'POST',\n    headers: { 'Content-Type':'application/json', 'x-api-key':apiKey, 'anthropic-version':'2023-06-01' },\n    body: JSON.stringify({ model:'claude-haiku-4-5-20251001', max_tokens:150,\n      messages:[{ role:'user', content:axisPrompt(userData, projections, comp) }] }),\n  });\n  if (!res.ok) throw new Error('anthropic ' + res.status);\n  const text = (await res.json()).content[0].text.trim();\n  const labelMatch = text.match(/label:\\s*(.+)/i);\n  const descMatch  = text.match(/description:\\s*(.+)/i);\n  const label = (labelMatch ? labelMatch[1] : text.split('\\n')[0]).trim().replace(/[.!?'\"]+$/, '').toLowerCase();\n  const description = descMatch ? descMatch[1].trim() : '';\n  return { label, description };\n}\n\n// llama-server (llama.cpp) — all 3 axes in one request so the model can differentiate them\n// Run: llama-server --model model.gguf --port 8080 --jinja\nasync function labelAllAxesWithLlamaServer(port, userData, projections, corpusWords) {\n  const sections = [0, 1, 2].map(comp => {\n    const allPosts = [];\n    userData.forEach(u => {\n      (u.postScores || []).forEach((ps, pi) => {\n        if (u.texts[pi]) allPosts.push({ handle: u.handle, text: u.texts[pi], score: ps[comp] });\n      });\n    });\n    allPosts.sort((a, b) => b.score - a.score);\n    const fmt = posts => posts.map(p => `  @${p.handle}: \"${p.text.replace(/\"/g, '’')}\"`).join('\\n');\n    const hints = corpusWords && corpusWords[comp] && corpusWords[comp].length\n      ? `Vocabulary signals: ${corpusWords[comp].join(', ')}\\n` : '';\n    return `**Dimension ${comp + 1}**\\n${hints}[LEFT SIDE posts]:\\n${fmt(allPosts.slice(0, 10))}\\n[RIGHT SIDE posts]:\\n${fmt(allPosts.slice(-10))}`;\n  });\n\n  const prompt = sections.join('\\n\\n') +\n    '\\n\\nFor each dimension write a label in the format \"LEFT_PHRASE ↔ RIGHT_PHRASE\" (2–4 words each). ' +\n    'LEFT_PHRASE must describe the [LEFT SIDE posts] above; RIGHT_PHRASE must describe the [RIGHT SIDE posts] above. ' +\n    'Also give one sentence describing what the dimension captures. ' +\n    'The phrases should read like affectionate Bluesky archetypes — the kind you\\'d see in a \"what kind of poster are you\" quiz written by someone who loves their mutuals and is also slightly roasting them. ' +\n    'Think: \"chronically online hot-takers\", \"sends you long threads at 2am\", \"cozy lore-drop enjoyers\", \"unironically uses the word \\'discourse\\'\", \"bestie who replies with one emoji\". ' +\n    'Avoid dry academic language entirely. Go for warm, playful, a little shitpost-y — gentle ribbing, never mean. ' +\n    'Reply in exactly this format:\\n1: label | description\\n2: label | description\\n3: label | description';\n\n  const res = await fetch('http://localhost:' + port + '/v1/chat/completions', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({\n      messages: [\n        { role: 'system', content: 'You are a witty, affectionate analyst of social media personality types. You label clusters of Bluesky posters with warm, playful, slightly shitpost-y archetypes — like writing the loving-roast section of a fandom wiki. Focus on tone, register, and rhetorical vibe, not just topic. Reply only in the exact format requested. No preamble, no explanation.' },\n        { role: 'user',   content: prompt },\n      ],\n      max_tokens: 12000,\n      temperature: 0.6,\n      stream: false,\n    }),\n  });\n  if (!res.ok) throw new Error('llama-server ' + res.status + ' (port ' + port + ')');\n\n  const msg = (await res.json()).choices[0].message;\n  // llama-server separates Qwen3 thinking into reasoning_content; content has the final answer\n  const raw = (msg.content || '').trim() || (msg.reasoning_content || '').trim();\n  // Also strip any leaked <think> tags just in case\n  const text = raw.replace(/<think>[\\s\\S]*?<\\/think>/gi, '').replace(/<\\/?think>/gi, '').trim();\n\n  const results = [null, null, null];\n  for (const line of text.split('\\n')) {\n    const m = line.match(/^([123])[.:)\\s]\\s*(.+)/);\n    if (m) {\n      const parts = m[2].split('|');\n      results[parseInt(m[1]) - 1] = {\n        label: parts[0].trim().replace(/[.!?'\"]+$/, '').toLowerCase(),\n        description: (parts[1] || '').trim(),\n      };\n    }\n  }\n  return results;\n}\n\n// Browser LLM via Transformers.js text-generation (WebGPU/WASM)\nlet labelGen = null;\nasync function getLabelGen(onProgress) {\n  if (labelGen) return labelGen;\n  onProgress('loading label model (SmolLM2-360M, ~500MB, cached after first run)…');\n  labelGen = await tfPipeline('text-generation', 'HuggingFaceTB/SmolLM2-360M-Instruct', {\n    device: DEVICE, dtype: 'q4',\n    progress_callback: p => { if (p&&p.progress!=null) onProgress('label model: '+Math.round(p.progress)+'%'); },\n  });\n  return labelGen;\n}\n\nfunction cleanSmallModelOutput(raw) {\n  // Small models often echo prompts or add filler — strip it down to 1-3 words\n  let t = raw.trim()\n    .split(/\\n/)[0]                          // first line only\n    .replace(/^label:?\\s*/i, '')             // remove \"Label:\" prefix\n    .replace(/['\"«»\"\"'']/g, '')              // remove quotes\n    .replace(/^[-–—•*]\\s*/, '')              // remove bullet prefix\n    .replace(/\\s*[.!?;,]+\\s*$/, '')          // trailing punctuation\n    .trim();\n  // If it still looks like a fragment of our prompt, give up\n  if (/high.end|low.end|posts|end\\s*of/i.test(t)) return null;\n  return t.split(/\\s+/).slice(0, 4).join(' ').toLowerCase() || null;\n}\n\nasync function labelAxisInBrowser(userData, projections, comp, corpusWords, onProgress) {\n  const gen = await getLabelGen(onProgress);\n  const hints = (corpusWords[comp] || []).join(', ') || 'various';\n  // Single representative post from the top user — short enough for tiny models\n  const indexed = userData.map((u, i) => ({ u, score: projections[i][comp] }));\n  indexed.sort((a, b) => b.score - a.score);\n  const post = (indexed[0].u.texts[0] || '').slice(0, 120);\n  const messages = [\n    { role: 'system', content: 'Reply with a 2-word label only. No explanation.' },\n    { role: 'user',   content: `Related words: ${hints}\\nExample: \"${post}\"\\nLabel:` },\n  ];\n  const out = await gen(messages, { max_new_tokens: 12, do_sample: false });\n  const reply = out[0].generated_text;\n  const raw = Array.isArray(reply) ? reply.at(-1).content : String(reply);\n  return { label: cleanSmallModelOutput(raw), description: '' };\n}\n\nasync function labelAxes(userData, projections, corpusWords, onProgress) {\n  const labeler = $('labeler').value;\n  if (labeler === 'none') return [null, null, null];\n  const input = ($('labelerInput').value || '').trim();\n\n  if (labeler === 'browser') {\n    // Sequential — one tiny model at a time to avoid OOM\n    const results = [];\n    for (let comp = 0; comp < 3; comp++) {\n      results.push(await labelAxisInBrowser(userData, projections, comp, corpusWords, onProgress).catch(() => null));\n    }\n    return results;\n  }\n  if (labeler === 'anthropic') {\n    if (!input) return [null,null,null];\n    localStorage.setItem('splade_api_key', input);\n    return Promise.all([0,1,2].map(comp =>\n      labelAxisWithClaude(input, userData, projections, comp).catch(() => null)\n    ));\n  }\n  if (labeler === 'llama') {\n    const port = input || '8080';\n    return labelAllAxesWithLlamaServer(port, userData, projections, corpusWords).catch(() => [null, null, null]);\n  }\n  return [null,null,null];\n}\n\n// ── WebGPU Gram matrix ─────────────────────────────\nasync function setupWebGPU() {\n  if (!navigator.gpu) return;\n  try {\n    const adapter=await navigator.gpu.requestAdapter(); if (!adapter) return;\n    gpuDevice=await adapter.requestDevice();\n    const mod=gpuDevice.createShaderModule({ code:GRAM_WGSL });\n    gramPipeline=await gpuDevice.createComputePipelineAsync({ layout:'auto', compute:{ module:mod, entryPoint:'main' } });\n    const splMod=gpuDevice.createShaderModule({ code:SPLADE_WGSL });\n    splatePipeline=await gpuDevice.createComputePipelineAsync({ layout:'auto', compute:{ module:splMod, entryPoint:'main' } });\n  } catch(e) { gpuDevice=null; }\n}\n\nasync function computeGram(Xc, N, D) {\n  if (gpuDevice && gramPipeline) {\n    try {\n      const uniBuf=gpuDevice.createBuffer({ size:8, usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST });\n      gpuDevice.queue.writeBuffer(uniBuf, 0, new Uint32Array([N,D]));\n      const xcBuf=gpuDevice.createBuffer({ size:Xc.byteLength, usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST });\n      gpuDevice.queue.writeBuffer(xcBuf, 0, Xc);\n      const gSize=N*N*4;\n      const gBuf=gpuDevice.createBuffer({ size:gSize, usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC });\n      const rBuf=gpuDevice.createBuffer({ size:gSize, usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ });\n      const bg=gpuDevice.createBindGroup({ layout:gramPipeline.getBindGroupLayout(0), entries:[\n        {binding:0,resource:{buffer:uniBuf}},{binding:1,resource:{buffer:xcBuf}},{binding:2,resource:{buffer:gBuf}},\n      ]});\n      const enc=gpuDevice.createCommandEncoder();\n      const pass=enc.beginComputePass();\n      pass.setPipeline(gramPipeline); pass.setBindGroup(0,bg);\n      pass.dispatchWorkgroups(Math.ceil(N/8),Math.ceil(N/8)); pass.end();\n      enc.copyBufferToBuffer(gBuf,0,rBuf,0,gSize);\n      gpuDevice.queue.submit([enc.finish()]);\n      await rBuf.mapAsync(GPUMapMode.READ);\n      const result=new Float32Array(rBuf.getMappedRange().slice(0));\n      rBuf.unmap(); uniBuf.destroy(); xcBuf.destroy(); gBuf.destroy(); rBuf.destroy();\n      return result;\n    } catch(e) {}\n  }\n  const G=new Float32Array(N*N);\n  for (let i=0;i<N;i++) for (let k=0;k<=i;k++) {\n    let s=0; for (let j=0;j<D;j++) s+=Xc[i*D+j]*Xc[k*D+j];\n    G[i*N+k]=s; G[k*N+i]=s;\n  }\n  return G;\n}\n\nasync function computeSpladePostProcess(logData, attnData, bSize, seqLen) {\n  const V = vocabSize;\n  const maskU32 = new Uint32Array(bSize * seqLen);\n  for (let i = 0; i < maskU32.length; i++) maskU32[i] = Number(attnData[i]);\n  const logF32 = logData instanceof Float32Array ? logData : new Float32Array(logData);\n  const uniBuf = gpuDevice.createBuffer({ size: 12, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });\n  gpuDevice.queue.writeBuffer(uniBuf, 0, new Uint32Array([bSize, seqLen, V]));\n  const logBuf = gpuDevice.createBuffer({ size: logF32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });\n  gpuDevice.queue.writeBuffer(logBuf, 0, logF32);\n  const maskBuf = gpuDevice.createBuffer({ size: maskU32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });\n  gpuDevice.queue.writeBuffer(maskBuf, 0, maskU32);\n  const outSize = bSize * V * 4;\n  const outBuf = gpuDevice.createBuffer({ size: outSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });\n  const readBuf = gpuDevice.createBuffer({ size: outSize, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });\n  const bg = gpuDevice.createBindGroup({ layout: splatePipeline.getBindGroupLayout(0), entries: [\n    { binding: 0, resource: { buffer: uniBuf } },\n    { binding: 1, resource: { buffer: logBuf } },\n    { binding: 2, resource: { buffer: maskBuf } },\n    { binding: 3, resource: { buffer: outBuf } },\n  ]});\n  const enc = gpuDevice.createCommandEncoder();\n  const pass = enc.beginComputePass();\n  pass.setPipeline(splatePipeline); pass.setBindGroup(0, bg);\n  pass.dispatchWorkgroups(Math.ceil(bSize * V / 256)); pass.end();\n  enc.copyBufferToBuffer(outBuf, 0, readBuf, 0, outSize);\n  gpuDevice.queue.submit([enc.finish()]);\n  await readBuf.mapAsync(GPUMapMode.READ);\n  const result = new Float32Array(readBuf.getMappedRange().slice(0));\n  readBuf.unmap();\n  uniBuf.destroy(); logBuf.destroy(); maskBuf.destroy(); outBuf.destroy(); readBuf.destroy();\n  return result;\n}\n\n// ── PCA via kernel trick ───────────────────────────\nasync function runPCA(userMatrix, N) {\n  const D=vocabSize;\n  const means=new Float32Array(D);\n  for (let i=0;i<N;i++) for (let j=0;j<D;j++) means[j]+=userMatrix[i*D+j];\n  for (let j=0;j<D;j++) means[j]/=N;\n  const Xc=new Float32Array(N*D);\n  for (let i=0;i<N;i++) for (let j=0;j<D;j++) Xc[i*D+j]=userMatrix[i*D+j]-means[j];\n\n  const G=await computeGram(Xc,N,D);\n  const Gw=G.slice(); const eigenvecs=[],eigenvals=[];\n  for (let comp=0;comp<3;comp++) {\n    let v=new Float32Array(N);\n    for (let i=0;i<N;i++) v[i]=Math.random()-0.5; normalizeVec(v);\n    let lambda=0;\n    for (let iter=0;iter<POWER_ITER;iter++) {\n      const w=new Float32Array(N);\n      for (let i=0;i<N;i++) { let s=0; for (let k=0;k<N;k++) s+=Gw[i*N+k]*v[k]; w[i]=s; }\n      lambda=vecNorm(w); if (lambda<1e-10) break;\n      for (let i=0;i<N;i++) v[i]=w[i]/lambda;\n    }\n    eigenvecs.push(v.slice()); eigenvals.push(lambda);\n    for (let i=0;i<N;i++) for (let k=0;k<N;k++) Gw[i*N+k]-=lambda*v[i]*v[k];\n  }\n  const projections=[];\n  for (let i=0;i<N;i++) projections.push([\n    Math.sqrt(Math.max(0,eigenvals[0]))*eigenvecs[0][i],\n    Math.sqrt(Math.max(0,eigenvals[1]))*eigenvecs[1][i],\n    Math.sqrt(Math.max(0,eigenvals[2]))*eigenvecs[2][i],\n  ]);\n  return { projections, eigenvals, Xc, eigenvecs, means };\n}\n\nfunction vecNorm(v) { let s=0; for (let i=0;i<v.length;i++) s+=v[i]*v[i]; return Math.sqrt(s); }\nfunction normalizeVec(v) { const l=vecNorm(v)||1; for (let i=0;i<v.length;i++) v[i]/=l; }\n\n// ── Ternary normalization ──────────────────────────\nfunction toTernary(projections) {\n  const mins=[Infinity,Infinity,Infinity], maxs=[-Infinity,-Infinity,-Infinity];\n  for (const p of projections) p.forEach((v,k) => { if(v<mins[k])mins[k]=v; if(v>maxs[k])maxs[k]=v; });\n  return projections.map(p => {\n    let vs=p.map((v,k)=>Math.max((v-mins[k])/(maxs[k]-mins[k]||0.001),0.02));\n    const tot=vs.reduce((a,b)=>a+b,0);\n    return { pc1:Math.round(vs[0]/tot*100), pc2:Math.round(vs[1]/tot*100), pc3:Math.round(vs[2]/tot*100) };\n  });\n}\n\n// ── Hex geometry ───────────────────────────────────\nfunction pointInTriangle(px,py,ax,ay,bx,by,cx,cy) {\n  const d=(by-cy)*(ax-cx)+(cx-bx)*(ay-cy);\n  const a=((by-cy)*(px-cx)+(cx-bx)*(py-cy))/d;\n  const b=((cy-ay)*(px-cx)+(ax-cx)*(py-cy))/d;\n  return a>=-0.001&&b>=-0.001&&(1-a-b)>=-0.001;\n}\nfunction generateHexCells(Ax,Ay,Bx,By,Cx,Cy,hexR) {\n  const cenX=(Ax+Bx+Cx)/3,cenY=(Ay+By+Cy)/3;\n  const scale=Math.max(0.5,1-(hexR*0.9)/((Ay-Cy)*0.5));\n  const iAx=cenX+(Ax-cenX)*scale,iAy=cenY+(Ay-cenY)*scale;\n  const iBx=cenX+(Bx-cenX)*scale,iBy=cenY+(By-cenY)*scale;\n  const iCx=cenX+(Cx-cenX)*scale,iCy=cenY+(Cy-cenY)*scale;\n  const hexW=Math.sqrt(3)*hexR,rowH=1.5*hexR;\n  const minY=Math.min(iAy,iBy,iCy),maxY=Math.max(iAy,iBy,iCy);\n  const minX=Math.min(iAx,iBx,iCx),maxX=Math.max(iAx,iBx,iCx);\n  const cells=[]; let row=0;\n  for (let y=minY+hexR;y<=maxY;y+=rowH) {\n    const off=(row%2)?hexW*0.5:0;\n    for (let x=minX+hexW*0.5+off;x<=maxX;x+=hexW)\n      if (pointInTriangle(x,y,iAx,iAy,iBx,iBy,iCx,iCy)) cells.push({x,y,occupant:null});\n    row++;\n  }\n  return cells;\n}\nfunction assignUsersToCells(cells,users,t2xy) {\n  const ranked=users.map(u=>{const dp1=u.pc1-33.33,dp2=u.pc2-33.33,dp3=u.pc3-33.33;return{user:u,ext:Math.sqrt(dp1*dp1+dp2*dp2+dp3*dp3)};});\n  ranked.sort((a,b)=>b.ext-a.ext);\n  const occ={};\n  for (const {user:u} of ranked) {\n    const ideal=t2xy(u.pc1,u.pc2,u.pc3); let bestIdx=-1,bestDist=Infinity;\n    for (let c=0;c<cells.length;c++) { if(occ[c])continue; const dx=cells[c].x-ideal.x,dy=cells[c].y-ideal.y; const d=dx*dx+dy*dy; if(d<bestDist){bestDist=d;bestIdx=c;} }\n    if (bestIdx>=0) { occ[bestIdx]=true; cells[bestIdx].occupant=u; }\n  }\n}\nfunction hexPath(ctx,cx,cy,r) {\n  ctx.beginPath();\n  for (let i=0;i<6;i++) { const a=Math.PI/3*i-Math.PI/6; if(i===0)ctx.moveTo(cx+r*Math.cos(a),cy+r*Math.sin(a)); else ctx.lineTo(cx+r*Math.cos(a),cy+r*Math.sin(a)); }\n  ctx.closePath();\n}\n\n// ── Ternary draw ───────────────────────────────────\nfunction drawTernary(canvas, data, highlightHandle, pcLabels) {\n  const ctx=canvas.getContext('2d');\n  const dpr=window.devicePixelRatio||1;\n  const rect=canvas.getBoundingClientRect(); if (rect.width===0) return;\n  const W=rect.width,H=rect.height;\n  canvas.width=W*dpr; canvas.height=H*dpr; ctx.scale(dpr,dpr);\n  const col=getColors();\n  const mobile=W<480;\n  const pad=mobile?28:48;\n  const pcFs=mobile?13:20;           // size of PC₁/₂/₃ label\n  const maxSemFs=mobile?14:24;       // max size of semantic sub-label\n  const botReserve=mobile?76:104;    // vertical space below triangle for bottom labels\n  const sideLp=mobile?26:68;         // outward offset for rotated side labels\n  ctx.clearRect(0,0,W,H);\n  let triW=W-pad*2, triH=triW*Math.sqrt(3)/2;\n  if (triH+pad+botReserve>H) { triH=H-pad-botReserve; triW=triH*2/Math.sqrt(3); }\n  const cx=W/2;\n  const Ax=cx-triW/2,Ay=pad+triH, Bx=cx+triW/2,By=pad+triH, Cx=cx,Cy=pad;\n\n  function t2xy(p1,p2,p3) { const tot=p1+p2+p3||1; return{x:(p1/tot)*Ax+(p2/tot)*Cx+(p3/tot)*Bx,y:(p1/tot)*Ay+(p2/tot)*Cy+(p3/tot)*By}; }\n\n  // Fit text to a max pixel width by reducing font size\n  function fitFs(text, maxW, maxSz, minSz=7) {\n    let sz=maxSz;\n    while (sz>minSz) { ctx.font=sz+'px monospace'; if(ctx.measureText(text).width<=maxW)break; sz--; }\n    return sz;\n  }\n\n  // Draw semantic label; splits \"HIGH ↔ LOW\" into two arrow-direction lines.\n  // flipArrows=true for rotated edge labels: canvas rotation reverses which way ← and → point\n  // in screen space, so HIGH needs ──→ (points toward its vertex) and LOW needs ←── .\n  function semLabel(toks, color, lx, y0, flipArrows=false) {\n    const joined=toks.join(' · '); if (!joined) return;\n    ctx.fillStyle=color; ctx.globalAlpha=0.72;\n    if (joined.includes('↔')) {\n      const [high,low]=joined.split('↔').map(s=>s.trim());\n      const line1=flipArrows ? high+' ──→' : '←── '+high;\n      const line2=flipArrows ? '←── '+low   : low+' ──→';\n      const sf=fitFs(line1.length>=line2.length?line1:line2, maxSemW, maxSemFs);\n      ctx.font=sf+'px monospace';\n      ctx.fillText(line1, lx, y0);\n      ctx.fillText(line2, lx, y0+sf+2);\n    } else {\n      const sf=fitFs(joined, maxSemW, maxSemFs);\n      ctx.font=sf+'px monospace';\n      ctx.fillText(joined, lx, y0);\n    }\n    ctx.globalAlpha=1;\n  }\n\n  ctx.strokeStyle=col.rule; ctx.lineWidth=0.5; ctx.globalAlpha=0.4;\n  for (let i=1;i<10;i++) {\n    const t=i/10;\n    const pairs=[[t2xy(1-t,t,0),t2xy(0,t,1-t)],[t2xy(t,1-t,0),t2xy(t,0,1-t)],[t2xy(1-t,0,t),t2xy(0,1-t,t)]];\n    for (const [a,b] of pairs) { ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); }\n  }\n  ctx.globalAlpha=1;\n  ctx.strokeStyle=col.text; ctx.lineWidth=1.5;\n  ctx.beginPath(); ctx.moveTo(Ax,Ay); ctx.lineTo(Bx,By); ctx.lineTo(Cx,Cy); ctx.closePath(); ctx.stroke();\n\n  const toks=pcLabels||[[],[],[]];\n  const maxSemW=triW*0.82;  // semantic label targets 82% of edge length\n\n  // PC₁ bottom — horizontal\n  ctx.textAlign='center';\n  ctx.font='bold '+pcFs+'px monospace'; ctx.fillStyle=col.pc1;\n  ctx.fillText('PC₁', cx, Ay+8+pcFs);\n  semLabel(toks[0], col.pc1, cx, Ay+10+pcFs+maxSemFs);\n\n  // PC₂ left edge — rotated\n  ctx.save(); ctx.translate((Ax+Cx)/2-sideLp,(Ay+Cy)/2); ctx.rotate(-Math.PI/3); ctx.textAlign='center';\n  ctx.font='bold '+pcFs+'px monospace'; ctx.fillStyle=col.pc2; ctx.fillText('PC₂',0,0);\n  semLabel(toks[1], col.pc2, 0, pcFs+5, true);\n  ctx.restore();\n\n  // PC₃ right edge — rotated\n  ctx.save(); ctx.translate((Bx+Cx)/2+sideLp,(By+Cy)/2); ctx.rotate(Math.PI/3); ctx.textAlign='center';\n  ctx.font='bold '+pcFs+'px monospace'; ctx.fillStyle=col.pc3; ctx.fillText('PC₃',0,0);\n  semLabel(toks[2], col.pc3, 0, pcFs+5, true);\n  ctx.restore();\n\n  let hexR=HEX_RADIUS_MAX;\n  const nU=data.length;\n  if (nU>0) hexR=Math.max(HEX_RADIUS_MIN,Math.min(HEX_RADIUS_MAX,Math.sqrt(triW*triW/(2.6*nU*1.4))));\n  let cells=generateHexCells(Ax,Ay,Bx,By,Cx,Cy,hexR);\n  while (cells.length<nU&&hexR>HEX_RADIUS_MIN) { hexR=Math.max(HEX_RADIUS_MIN,hexR*0.85); cells=generateHexCells(Ax,Ay,Bx,By,Cx,Cy,hexR); }\n  assignUsersToCells(cells,data,t2xy);\n  canvas._cells=cells; canvas._hexR=hexR; canvas._t2xy=t2xy;\n\n  ctx.strokeStyle=col.rule; ctx.lineWidth=0.5; ctx.globalAlpha=0.25;\n  for (const c of cells) { if (!c.occupant) { hexPath(ctx,c.x,c.y,hexR*0.92); ctx.stroke(); } }\n  ctx.globalAlpha=1;\n  const drawR=hexR*0.92;\n  for (const cell of cells) {\n    if (!cell.occupant) continue;\n    const u=cell.occupant;\n    const img=avatarImages[u.handle];\n    const hl=u.handle===highlightHandle||u.handle===lockedHandle;\n    if (img&&img.complete&&img.naturalWidth>0) {\n      ctx.save(); hexPath(ctx,cell.x,cell.y,drawR); ctx.clip();\n      const is=drawR*2.2; ctx.drawImage(img,cell.x-is/2,cell.y-is/2,is,is); ctx.restore();\n    } else {\n      ctx.save(); hexPath(ctx,cell.x,cell.y,drawR); ctx.fillStyle=col.muted+'40'; ctx.fill(); ctx.restore();\n    }\n    hexPath(ctx,cell.x,cell.y,drawR);\n    ctx.strokeStyle=hl?col.link:col.text+'60'; ctx.lineWidth=hl?2.5:0.8; ctx.stroke();\n  }\n}\n\n// ── Post list ──────────────────────────────────────\nfunction renderPostList(user) {\n  const el=$('postList'); if (!user||!user.texts||!user.texts.length) { el.innerHTML=''; return; }\n  $('resultsLayout').classList.remove('chart-only');\n  const col=getColors();\n  let html='<div class=\"section-header\">posts · @'+escHtml(user.handle)+' <span style=\"font-weight:400\">('+user.texts.length+')</span></div>';\n  for (let i=0; i<user.texts.length; i++) {\n    const ps=user.postScores&&user.postScores[i];\n    let pcHtml='';\n    if (ps) {\n      const dom=ps.indexOf(Math.max(...ps));\n      const pcKeys=['pc1','pc2','pc3'];\n      pcHtml='<div class=\"post-pcs\">'+ps.map((v,k)=>{\n        const c=col[pcKeys[k]]; const w=dom===k?';font-weight:700':'';\n        return '<span class=\"post-pc\" style=\"color:'+c+';border:1px solid '+c+w+'\">'+['PC₁','PC₂','PC₃'][k]+' '+v+'</span>';\n      }).join('')+'</div>';\n    }\n    html+='<div class=\"post-row\">'+pcHtml+'<div class=\"post-text\">'+escHtml(user.texts[i])+'</div></div>';\n  }\n  el.innerHTML=html;\n}\n\n// ── Chart readout ──────────────────────────────────\nfunction updateReadout(user) {\n  const el=$('readout');\n  if (!user) { el.innerHTML='hover a hex to inspect · click to expand'; return; }\n  const imgTag=user.avatar?'<img class=\"readout-avatar\" src=\"'+escHtml(user.avatar)+'\" alt=\"\">'\n    :'<div class=\"readout-avatar\" style=\"background:var(--rule)\"></div>';\n  el.innerHTML=imgTag+'<div><div class=\"readout-handle\"><a href=\"https://bsky.app/profile/'+escHtml(user.handle)+'\" target=\"_blank\" rel=\"noopener\">@'+escHtml(user.handle)+'</a></div>'\n    +'<div class=\"readout-scores\">'\n    +'<span class=\"readout-score\" style=\"color:var(--pc1);border:1px solid var(--pc1)\">PC₁ '+user.pc1+'</span>'\n    +'<span class=\"readout-score\" style=\"color:var(--pc2);border:1px solid var(--pc2)\">PC₂ '+user.pc2+'</span>'\n    +'<span class=\"readout-score\" style=\"color:var(--pc3);border:1px solid var(--pc3)\">PC₃ '+user.pc3+'</span>'\n    +'</div></div>';\n}\n\n// ── Ternary interaction ────────────────────────────\nfunction setupTernaryInteraction(canvas) {\n  const tooltip=$('tooltip');\n  function findHit(mx,my) {\n    const cells=canvas._cells||[]; const r=(canvas._hexR||22)*0.92;\n    for (const c of cells) { if (!c.occupant) continue; const dx=c.x-mx,dy=c.y-my; if(dx*dx+dy*dy<r*r) return c; }\n    return null;\n  }\n  function redraw() { drawTernary(canvas,chartState.ternaryData,canvas._lastHl,chartState.pcTopTokens); }\n  function handleHover(cx,cy) {\n    const rect=canvas.getBoundingClientRect(); const mx=cx-rect.left,my=cy-rect.top;\n    const hit=findHit(mx,my);\n    if (!hit) {\n      if (canvas._lastHl) { canvas._lastHl=null; redraw(); updateReadout(null); }\n      hide(tooltip); return;\n    }\n    const r=hit.occupant;\n    if (canvas._lastHl!==r.handle) { canvas._lastHl=r.handle; redraw(); updateReadout(r); }\n    tooltip.textContent='@'+r.handle; show(tooltip);\n    let tx=mx+14,ty=my-28; if(tx+180>rect.width)tx=mx-180; if(ty<0)ty=my+16;\n    tooltip.style.left=tx+'px'; tooltip.style.top=ty+'px';\n  }\n  canvas.onmousemove=e=>handleHover(e.clientX,e.clientY);\n  canvas.onmouseleave=()=>hide(tooltip);\n  canvas.onclick=e=>{\n    const rect=canvas.getBoundingClientRect();\n    const hit=findHit(e.clientX-rect.left,e.clientY-rect.top);\n    if (!hit||!hit.occupant) { lockedHandle=null; renderPostList(null); redraw(); return; }\n    const r=hit.occupant;\n    if (lockedHandle===r.handle) { lockedHandle=null; renderPostList(null); } else { lockedHandle=r.handle; renderPostList(r); }\n    redraw();\n  };\n  let _lastTouch = null;\n  canvas.ontouchstart=e=>{e.preventDefault(); if(e.touches.length){const t=e.touches[0];_lastTouch={x:t.clientX,y:t.clientY};handleHover(t.clientX,t.clientY);}};\n  canvas.ontouchmove=e=>{e.preventDefault(); if(e.touches.length){const t=e.touches[0];_lastTouch={x:t.clientX,y:t.clientY};handleHover(t.clientX,t.clientY);}};\n  canvas.ontouchend=e=>{\n    e.preventDefault();\n    if (!_lastTouch) return;\n    const rect=canvas.getBoundingClientRect();\n    const hit=findHit(_lastTouch.x-rect.left,_lastTouch.y-rect.top);\n    _lastTouch=null;\n    if (!hit||!hit.occupant) { lockedHandle=null; renderPostList(null); redraw(); return; }\n    const r=hit.occupant;\n    if (lockedHandle===r.handle) { lockedHandle=null; renderPostList(null); } else { lockedHandle=r.handle; renderPostList(r); }\n    redraw();\n    setTimeout(()=>$('postCol').scrollIntoView({behavior:'smooth',block:'start'}),50);\n  };\n}\n\n// ── Avatar preloading ──────────────────────────────\nfunction preloadAvatars(data) {\n  return new Promise(resolve => {\n    let rem=0;\n    for (const u of data) {\n      if (!u.avatar) continue; rem++;\n      const img=new Image();\n      img.onload=img.onerror=()=>{ if(--rem<=0)resolve(); };\n      img.src=u.avatar; avatarImages[u.handle]=img;\n    }\n    if (!rem) resolve();\n    setTimeout(resolve, 5000);\n  });\n}\n\n// ── Three.js 3D view ───────────────────────────────\nfunction makeCircleTex(size, color) {\n  const c=document.createElement('canvas'); c.width=c.height=size;\n  const ctx=c.getContext('2d');\n  ctx.beginPath(); ctx.arc(size/2,size/2,size/2-1,0,Math.PI*2);\n  ctx.fillStyle=color; ctx.fill();\n  return new THREE.CanvasTexture(c);\n}\n\nfunction init3D(data3d, pcTopTokens) {\n  const wrap=$('threeWrap'); wrap.innerHTML='';\n  const W=wrap.clientWidth||600, H=W*7/8;\n  const renderer=new THREE.WebGLRenderer({ antialias:true });\n  renderer.setSize(W,H); renderer.setPixelRatio(window.devicePixelRatio);\n  renderer.setClearColor(0x0a0a0a);\n  wrap.appendChild(renderer.domElement);\n  const scene=new THREE.Scene();\n  const camera=new THREE.PerspectiveCamera(55,W/H,0.01,100);\n  camera.position.set(0,0,6);\n  const controls=new OrbitControls(camera,renderer.domElement);\n  controls.enableDamping=true; controls.dampingFactor=0.08;\n\n  // Normalize axes to unit std-dev\n  const N=data3d.length;\n  const axes=[[],[],[]];\n  for (const u of data3d) { axes[0].push(u.raw[0]); axes[1].push(u.raw[1]); axes[2].push(u.raw[2]); }\n  const scaled=data3d.map(u=>axes.map((ax,k)=>{\n    const mean=ax.reduce((a,b)=>a+b,0)/N;\n    const std=Math.sqrt(ax.reduce((a,b)=>a+(b-mean)**2,0)/N)||1;\n    return (u.raw[k]-mean)/std;\n  }));\n\n  // Axis lines\n  const axColors=[0xd080b0,0x70b0d0,0x90c080];\n  [[1,0,0],[0,1,0],[0,0,1]].forEach(([x,y,z],k)=>{\n    const geo=new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0,0,0),new THREE.Vector3(x*2.5,y*2.5,z*2.5)]);\n    scene.add(new THREE.Line(geo,new THREE.LineBasicMaterial({color:axColors[k],opacity:0.3,transparent:true})));\n  });\n\n  // HTML axis labels (positioned over canvas each frame)\n  const axLabelEls=[];\n  const toks=pcTopTokens||[[],[],[]];\n  ['PC₁','PC₂','PC₃'].forEach((label,k)=>{\n    const div=document.createElement('div');\n    div.className='ax-label';\n    div.style.color='#'+axColors[k].toString(16).padStart(6,'0');\n    div.innerHTML='<b>'+label+'</b>'+(toks[k].length?'<br>'+toks[k].join(' · '):'');\n    wrap.appendChild(div);\n    axLabelEls.push(div);\n  });\n  const axTips=[new THREE.Vector3(2.6,0,0),new THREE.Vector3(0,2.6,0),new THREE.Vector3(0,0,2.6)];\n\n  function updateAxLabels() {\n    const rect=renderer.domElement.getBoundingClientRect();\n    axTips.forEach((tip,k)=>{\n      const v=tip.clone().project(camera);\n      const x=(v.x+1)/2*rect.width, y=(1-v.y)/2*rect.height;\n      axLabelEls[k].style.left=x+'px'; axLabelEls[k].style.top=y+'px';\n    });\n  }\n\n  // Tooltip\n  const tooltip=document.createElement('div');\n  tooltip.style.cssText='position:absolute;pointer-events:none;font-family:monospace;font-size:0.7rem;background:var(--bg);border:1px solid var(--rule);padding:0.3rem 0.5rem;color:var(--text);display:none;z-index:10';\n  wrap.appendChild(tooltip);\n\n  // Sprites — colored circles (bsky CDN doesn't support CORS, so WebGL avatar textures aren't possible)\n  const sprites=[];\n  data3d.forEach((u,i)=>{\n    const [x,y,z]=scaled[i];\n    const hue=(i*137.5)%360;\n    const obj=new THREE.Sprite(new THREE.SpriteMaterial({ map:makeCircleTex(64,`hsl(${hue},65%,60%)`), transparent:true }));\n    obj.scale.setScalar(0.22); obj.position.set(x,y,z); obj.userData={handle:u.handle};\n    scene.add(obj); sprites.push(obj);\n  });\n\n  // Interaction\n  const raycaster=new THREE.Raycaster(), mouse=new THREE.Vector2();\n  function getHit(e) {\n    const rect=renderer.domElement.getBoundingClientRect();\n    mouse.x=((e.clientX-rect.left)/rect.width)*2-1;\n    mouse.y=-((e.clientY-rect.top)/rect.height)*2+1;\n    raycaster.setFromCamera(mouse,camera);\n    const hits=raycaster.intersectObjects(sprites);\n    return hits.length?hits[0].object:null;\n  }\n  renderer.domElement.addEventListener('mousemove', e=>{\n    const hit=getHit(e);\n    if (hit) {\n      const rect=renderer.domElement.getBoundingClientRect();\n      const u=chartState.ternaryData.find(u=>u.handle===hit.userData.handle);\n      tooltip.textContent='@'+hit.userData.handle; tooltip.style.display='block';\n      tooltip.style.left=(e.clientX-rect.left+12)+'px'; tooltip.style.top=(e.clientY-rect.top-24)+'px';\n      updateReadout(u);\n    } else { tooltip.style.display='none'; }\n  });\n  renderer.domElement.addEventListener('click', e=>{\n    const hit=getHit(e);\n    if (!hit) { lockedHandle=null; renderPostList(null); return; }\n    const h=hit.userData.handle;\n    const u=chartState.ternaryData.find(u=>u.handle===h);\n    if (lockedHandle===h) { lockedHandle=null; renderPostList(null); } else { lockedHandle=h; renderPostList(u); }\n  });\n\n  let animId;\n  function animate() { animId=requestAnimationFrame(animate); controls.update(); renderer.render(scene,camera); updateAxLabels(); }\n  animate();\n  threeObjs={ renderer, animId, controls };\n}\n\n// ── Label helpers ──────────────────────────────────\nfunction applyLabels(labels) {\n  if (!chartState) return;\n  chartState.pcTopTokens = labels;\n  const canvas = $('ternaryChart');\n  drawTernary(canvas, chartState.ternaryData, canvas._lastHl, labels);\n  if (threeObjs) {\n    $('threeWrap').querySelectorAll('.ax-label').forEach((el, k) => {\n      el.innerHTML = '<b>'+['PC₁','PC₂','PC₃'][k]+'</b>'+(labels[k].length?'<br>'+labels[k].join(' · '):'');\n      el.onclick = () => showAxisPanel(k);\n    });\n  }\n}\n\nfunction applyStatusLine() {\n  const labels = chartState ? chartState.pcTopTokens : [[],[],[]];\n  const embedLabel = chartState?.statusPrefix\n    ? chartState.statusPrefix\n    : (($('embedderChoice')?.value === 'voyage') ? 'voyage-3-lite' : (useDenseEmbedder ? 'dense' : 'splade'))\n      + ' · ' + (gpuDevice ? 'webgpu' : 'js') + ' gram';\n  const prefix = escHtml(embedLabel);\n  const pcColors = ['var(--pc1)','var(--pc2)','var(--pc3)'];\n  const axParts = labels.map((t,i) =>\n    `<span style=\"color:${pcColors[i]}\">PC${i+1}: ${escHtml(t.length?t.join(' · '):'—')}</span>`\n  ).join('&nbsp;&nbsp;&nbsp;');\n  const el=$('status'); show(el);\n  el.innerHTML = prefix+'&nbsp;&nbsp;&nbsp;'+axParts;\n  el.classList.remove('error');\n}\n\nasync function relabel() {\n  if (!chartState) return;\n  const labeler = $('labeler').value;\n  if (labeler === 'none') {\n    applyLabels(chartState.corpusLabels);\n    applyStatusLine();\n    return;\n  }\n  // Reconstruct from cached state\n  const userData = chartState.ternaryData;          // has texts\n  const projections = chartState.data3d.map(u=>u.raw);\n  const corpus = chartState.corpusLabels;\n  setStatus('labelling axes…');\n  try {\n    const llmResults = await labelAxes(userData, projections, corpus, msg=>setStatus(msg));\n    applyLabels(corpus.map((toks,k) => llmResults[k]?.label ? [llmResults[k].label] : toks));\n    chartState.axisDescriptions = llmResults.map(r => r?.description || '');\n    applyStatusLine();\n    renderAxisGrid();\n  } catch(e) { setStatus('labeling failed: '+e.message, true); }\n}\n\n// ── Main run ───────────────────────────────────────\nasync function run() {\n  const handles=getHandles(); if (handles.length<2) return;\n  const btn=$('mapBtn'); btn.disabled=true;\n  avatarImages={}; chartState=null; lockedHandle=null;\n  $('postList').innerHTML='';\n  const embedderChoice = $('embedderChoice').value;\n  useDenseEmbedder = embedderChoice !== 'splade';\n  if (embedderChoice === 'voyage') {\n    vocabSize = 512;\n    const key = ($('embedderInput').value || localStorage.getItem('voyage_api_key') || '').trim();\n    if (!key) { setStatus('paste a voyage api key to use voyage-3-lite', true); btn.disabled=false; return; }\n  }\n  if (threeObjs) { cancelAnimationFrame(threeObjs.animId); threeObjs.renderer.dispose(); threeObjs=null; }\n\n  setProgress(5); setStatus('fetching posts…');\n  let userData;\n  try {\n    userData=await batchLoad(handles,6,(done,total)=>{\n      setProgress(5+(done/total)*25); setStatus('fetching posts… '+done+'/'+total);\n    });\n  } catch(e) { setStatus('fetch error: '+e.message,true); btn.disabled=false; return; }\n  userData=userData.filter(u=>u.texts&&u.texts.length>=2);\n  const activeDaysVal=$('activeDays').value.trim();\n  if (activeDaysVal) {\n    const cutoff=new Date(Date.now()-Number(activeDaysVal)*86400000);\n    userData=userData.filter(u=>u.lastPostAt&&u.lastPostAt>=cutoff);\n  }\n  if (userData.length<2) { setStatus('not enough posts retrieved',true); btn.disabled=false; return; }\n\n  if (embedderChoice !== 'voyage') {\n    setProgress(30); setStatus('loading model…');\n    try { await loadModel(msg=>setStatus(msg)); }\n    catch(e) { setStatus('model error: '+e.message,true); btn.disabled=false; return; }\n  }\n\n  setProgress(45); setStatus('computing embeddings…');\n  const N=userData.length;\n  const userMatrix=new Float32Array(N*vocabSize);\n  let userVecs, allPostVecs;\n  try {\n    ({ userVecs, allPostVecs }=await embedAllUsers(userData, (done, total)=>{\n      setStatus('embedding '+done+'/'+total+' posts…');\n      setProgress(45+(done/total)*30);\n    }));\n  } catch(e) { setStatus('embedding error: '+e.message,true); btn.disabled=false; return; }\n  for (let i=0;i<N;i++) userMatrix.set(userVecs[i], i*vocabSize);\n\n  setProgress(78); setStatus('computing PCA'+(gpuDevice?' (WebGPU)':'')+'…');\n  let pcaResult;\n  try { await setupWebGPU(); pcaResult=await runPCA(userMatrix,N); }\n  catch(e) { setStatus('PCA error: '+e.message,true); btn.disabled=false; return; }\n\n  // PC directions in vocab space (for per-post projection)\n  const Vk=computePCDirections(pcaResult.Xc,N,vocabSize,pcaResult.eigenvecs);\n\n  setProgress(85); setStatus('labelling axes…');\n  const pcTopTokens=extractCorpusLabels(userData, pcaResult.projections, 3);\n\n  setProgress(88); setStatus('projecting posts…');\n  const allPostScores=allPostVecs.map(postVecs=>\n    postVecs.map(pv=>projectPost(pv,pcaResult.means,Vk))\n  );\n\n  setProgress(92); setStatus('preloading avatars…');\n  await preloadAvatars(userData);\n\n  const ternaryScores=toTernary(pcaResult.projections);\n  const ternaryData=userData.map((u,i)=>({\n    handle:u.handle, avatar:u.avatar, texts:u.texts,\n    pc1:ternaryScores[i].pc1, pc2:ternaryScores[i].pc2, pc3:ternaryScores[i].pc3,\n    postScores:allPostScores[i],\n  }));\n  const data3d=userData.map((u,i)=>({ handle:u.handle, avatar:u.avatar, raw:pcaResult.projections[i] }));\n\n  chartState = { ternaryData, data3d, pcTopTokens, corpusLabels: pcTopTokens, axisDescriptions: [] };\n\n  setProgress(100);\n  show($('results'));\n  $('resultsLayout').classList.add('chart-only');\n  const canvas=$('ternaryChart');\n  drawTernary(canvas,ternaryData,null,pcTopTokens);\n  setupTernaryInteraction(canvas);\n  if (viewMode==='3d') requestAnimationFrame(()=>init3D(data3d,pcTopTokens));\n  window.addEventListener('resize',()=>{\n    if (viewMode==='ternary'&&chartState) drawTernary(canvas,chartState.ternaryData,canvas._lastHl,chartState.pcTopTokens);\n  });\n  btn.disabled=false;\n\n  applyStatusLine();\n  renderAxisGrid();\n  if ($('labeler').value !== 'none') relabel();\n}\n\nfunction renderAxisGrid() {\n  const grid = $('axisGrid');\n  if (!grid || !chartState) return;\n  const pcNames = ['PC₁','PC₂','PC₃'];\n  const colors = ['var(--pc1)','var(--pc2)','var(--pc3)'];\n  grid.innerHTML = pcNames.map((name, k) => {\n    const labels = chartState.pcTopTokens[k] || [];\n    const desc = (chartState.axisDescriptions || [])[k] || '';\n    const joined = labels.join(' · ');\n    const poles = joined.includes('↔') ? joined.split('↔').map(s => s.trim()) : null;\n    const allPosts = [];\n    for (const u of chartState.ternaryData) {\n      for (let j = 0; j < (u.texts||[]).length; j++) {\n        if (u.texts[j] && u.postScores?.[j]) {\n          allPosts.push({ handle: u.handle, text: u.texts[j], score: u.postScores[j][k] });\n        }\n      }\n    }\n    allPosts.sort((a,b) => b.score - a.score);\n    const postRow = p => `<div class=\"axis-post\"><span class=\"axis-post-handle\">@${escHtml(p.handle)}</span>${escHtml(p.text)}</div>`;\n    let postsHtml;\n    if (poles) {\n      const [highName, lowName] = poles;\n      postsHtml =\n        `<div class=\"axis-pole-header\">←── ${escHtml(highName)}</div>` +\n        allPosts.slice(0, 5).map(postRow).join('') +\n        `<div class=\"axis-pole-header\">${escHtml(lowName)} ──→</div>` +\n        allPosts.slice(-5).reverse().map(postRow).join('');\n    } else {\n      postsHtml = allPosts.slice(0, 10).map(postRow).join('');\n    }\n    return `<div class=\"axis-col\">\n      <div class=\"axis-col-header\" style=\"color:${colors[k]}\">${name} — ${escHtml(joined || '—')}</div>\n      ${desc ? `<div class=\"axis-col-desc\">${escHtml(desc)}</div>` : ''}\n      ${postsHtml}\n    </div>`;\n  }).join('');\n}\n\nasync function exportPage() {\n  if (!chartState) return;\n  try {\n    const src = await fetch(location.href).then(r => r.text());\n    const embedChoice = $('embedderChoice')?.value;\n    const embedLabel = embedChoice === 'voyage' ? 'voyage-3-lite' : (useDenseEmbedder ? 'dense' : 'splade');\n    const payload = JSON.stringify({\n      ternaryData: chartState.ternaryData,\n      data3d: chartState.data3d,\n      pcTopTokens: chartState.pcTopTokens,\n      axisDescriptions: chartState.axisDescriptions || [],\n      statusPrefix: embedLabel + ' · ' + (gpuDevice ? 'webgpu' : 'js') + ' gram',\n    });\n    const out = src.replace('let BAKED_DATA = null;', `let BAKED_DATA = ${payload};`);\n    const a = document.createElement('a');\n    a.href = URL.createObjectURL(new Blob([out], { type: 'text/html' }));\n    a.download = 'splade-export-' + new Date().toISOString().slice(0,10) + '.html';\n    a.click();\n  } catch(e) { alert('export failed: ' + e.message); }\n}\n\nasync function showBakedResults(data) {\n  ['inputTabs','pastePanel','listPanel','embedderExtra','labelerExtra'].forEach(id => {\n    const el = $(id); if (el) hide(el);\n  });\n  document.querySelectorAll('.labeler-row,.action-row').forEach(el => hide(el));\n  document.querySelector('.desc').innerHTML =\n    'Hover over dots to identify users; click one to see their posts. ' +\n    'Axes with their top posts appear below the chart.';\n  document.querySelector('.desc-small').innerHTML =\n    'Each user’s recent posts were embedded into high-dimensional vector space, ' +\n    'then PCA extracted three orthogonal axes of maximum variance across the corpus. ' +\n    'An LLM read the top posts along each axis and inferred what each one is actually about.';\n\n  chartState = {\n    ternaryData: data.ternaryData,\n    data3d: data.data3d,\n    pcTopTokens: data.pcTopTokens,\n    corpusLabels: data.pcTopTokens,\n    axisDescriptions: data.axisDescriptions || [],\n    statusPrefix: data.statusPrefix || null,\n  };\n\n  show($('results'));\n  $('resultsLayout').classList.add('chart-only');\n  await preloadAvatars(data.ternaryData);\n  const canvas = $('ternaryChart');\n  drawTernary(canvas, data.ternaryData, null, data.pcTopTokens);\n  setupTernaryInteraction(canvas);\n  window.addEventListener('resize', () => {\n    if (viewMode === 'ternary' && chartState)\n      drawTernary(canvas, chartState.ternaryData, canvas._lastHl, chartState.pcTopTokens);\n  });\n  applyStatusLine();\n  renderAxisGrid();\n}\n\n// ── View toggle ────────────────────────────────────\n$('viewTernaryBtn').addEventListener('click',()=>{\n  if (viewMode==='ternary') return;\n  viewMode='ternary';\n  $('viewTernaryBtn').classList.add('active'); $('view3dBtn').classList.remove('active');\n  show($('ternaryWrap')); hide($('threeWrap'));\n});\n$('view3dBtn').addEventListener('click',()=>{\n  if (viewMode==='3d') return;\n  viewMode='3d';\n  $('view3dBtn').classList.add('active'); $('viewTernaryBtn').classList.remove('active');\n  hide($('ternaryWrap')); show($('threeWrap'));\n  if (chartState&&!threeObjs) requestAnimationFrame(()=>init3D(chartState.data3d,chartState.pcTopTokens));\n});\n\n// ── Input UI ───────────────────────────────────────\ndocument.querySelectorAll('#inputTabs .tab').forEach(tab=>{\n  tab.addEventListener('click',()=>{\n    document.querySelectorAll('#inputTabs .tab').forEach(t=>t.classList.toggle('active',t===tab));\n    if (tab.dataset.tab==='paste') { show($('pastePanel')); hide($('listPanel')); }\n    else { hide($('pastePanel')); show($('listPanel')); }\n  });\n});\n$('loadListBtn').addEventListener('click',async()=>{\n  const url=$('listUrl').value.trim(); if (!url) return;\n  const parsed=parseListUrl(url); if (!parsed) { setStatus('could not parse list URL',true); return; }\n  const btn=$('loadListBtn'); btn.disabled=true; btn.textContent='loading…';\n  setStatus('fetching list members…'); setProgress(20);\n  try {\n    const handles=await fetchListMembers(parsed.actor,parsed.rkey);\n    if (!handles.length) { setStatus('empty list',true); setProgress(0); return; }\n    $('handleList').value=handles.join('\\n');\n    show($('pastePanel')); hide($('listPanel'));\n    document.querySelectorAll('#inputTabs .tab').forEach(t=>t.classList.toggle('active',t.dataset.tab==='paste'));\n    updateCount(); setStatus(handles.length+' members loaded'); setProgress(100);\n  } catch(e) { setStatus('error: '+e.message,true); setProgress(0); }\n  finally { btn.disabled=false; btn.textContent='load list'; }\n});\n$('handleList').addEventListener('input',updateCount);\n$('mapBtn').addEventListener('click',run);\nupdateCount();\n\n// Labeler UI\nconst LABELER_CONFIG = {\n  none:      { show: false },\n  browser:   { show: false },\n  anthropic: { show: true,  placeholder: 'anthropic api key',          hint: 'key is only stored in localStorage', type: 'password', storageKey: 'splade_api_key' },\n  llama:     { show: true,  placeholder: 'port (default 8080)',            hint: 'llama-server --model model.gguf --port 8080 --jinja', type: 'text', storageKey: 'splade_llama_port' },\n};\nfunction updateLabelerUI() {\n  const cfg = LABELER_CONFIG[$('labeler').value];\n  const extra = $('labelerExtra');\n  const input = $('labelerInput');\n  const hint  = $('labelerHint');\n  if (cfg.show) {\n    show(extra);\n    input.type = cfg.type || 'text';\n    input.placeholder = cfg.placeholder || '';\n    hint.textContent = cfg.hint || '';\n    const saved = cfg.storageKey && localStorage.getItem(cfg.storageKey);\n    if (saved && !input.value) input.value = saved;\n  } else {\n    hide(extra);\n  }\n}\nfunction updateEmbedderUI() {\n  const isVoyage = $('embedderChoice').value === 'voyage';\n  isVoyage ? show($('embedderExtra')) : hide($('embedderExtra'));\n  if (isVoyage) {\n    const saved = localStorage.getItem('voyage_api_key');\n    if (saved && !$('embedderInput').value) $('embedderInput').value = saved;\n  }\n}\n$('embedderChoice').addEventListener('change', updateEmbedderUI);\n$('embedderInput').addEventListener('change', () => {\n  const v = $('embedderInput').value.trim();\n  if (v) localStorage.setItem('voyage_api_key', v);\n});\nupdateEmbedderUI();\n$('labeler').addEventListener('change', () => { updateLabelerUI(); if (chartState) relabel(); });\n$('labelerInput').addEventListener('keydown', e => { if (e.key==='Enter' && chartState) relabel(); });\nupdateLabelerUI();\n$('exportBtn').addEventListener('click', exportPage);\n$('collapseBtn').addEventListener('click', () => $('resultsLayout').classList.add('chart-only'));\nif (BAKED_DATA) showBakedResults(BAKED_DATA);\n</script>\n</body>\n</html>\n",
              "syntaxHighlightingTheme": "github-dark"
            }
          }
        ],
        "id": "019e865e-06ec-7aa3-b9b6-be46c901e106"
      }
    ]
  },
  "description": "",
  "path": "/3mnbnlgrwu225",
  "publishedAt": "2026-06-02T03:27:07.630Z",
  "site": "https://leaflet.pub/p/did:plc:vw4e7blkwzdokanwp24k3igr",
  "tags": [],
  "theme": {
    "pageWidth": 1200,
    "showPageBackground": true
  },
  "title": "pca your friends"
}