<!DOCTYPE html>
<html lang="en">
<!--
drunk.moe
lathrys.at
adverb.bsky.social
lastnpcalex.agency
flicknow.xyz
-->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pca your friends β€” minor mobius x hoopy frood</title>
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
  }
}
</script>
<style>
:root {
  --bg: #faf9f6; --text: #1a1a1a; --muted: #777; --rule: #ccc; --link: #8b0000;
  --pc1: #b06090; --pc2: #5090b0; --pc3: #70a060;
  --mono: 'SF Mono','Cascadia Code','Fira Code',Menlo,monospace;
  --serif: 'Iowan Old Style','Palatino Linotype',Palatino,Georgia,serif;
}
@media (prefers-color-scheme: dark) {
  :root { --bg:#0f0f0f; --text:#d4d4d4; --muted:#777; --rule:#333; --link:#c45;
          --pc1:#d080b0; --pc2:#70b0d0; --pc3:#90c080; }
}
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:var(--serif);
       line-height:1.7; padding:4rem 2rem; max-width:960px; margin:0 auto; }
h1 { font-family:var(--mono); font-size:0.85rem; font-weight:400; letter-spacing:0.15em;
     text-transform:lowercase; color:var(--muted); margin-bottom:0.5rem; }
h1 a { color:var(--link); text-decoration:none; }
h1 a:hover { color:var(--text); }
.subtitle { font-size:1.15rem; color:var(--text); margin-bottom:1rem; }
.desc { font-size:0.95rem; color:var(--muted); margin-bottom:0.75rem; }
.desc-small { font-size:0.8rem; color:var(--muted); margin-bottom:2.5rem; line-height:1.6; }
.hidden { display:none !important; }
.tabs { display:flex; gap:1rem; margin-bottom:1rem; }
.tab { font-family:var(--mono); font-size:0.7rem; letter-spacing:0.05em; color:var(--muted);
       cursor:pointer; padding-bottom:0.3rem; border:none; border-bottom:1px solid transparent; background:none; }
.tab.active { color:var(--text); border-bottom-color:var(--link); }
.tab:hover { color:var(--text); }
textarea { width:100%; font-family:var(--mono); font-size:0.8rem; padding:0.75rem;
           border:1px solid var(--rule); background:var(--bg); color:var(--text);
           resize:vertical; margin-bottom:0.75rem; }
textarea:focus { outline:none; border-color:var(--link); }
.list-row { display:flex; gap:0.5rem; margin-bottom:0.75rem; }
.list-row input { flex:1; font-family:var(--mono); font-size:0.8rem; padding:0.5rem 0.75rem;
                  border:1px solid var(--rule); background:var(--bg); color:var(--text); }
.list-row input:focus { outline:none; border-color:var(--link); }
button { font-family:var(--mono); font-size:0.75rem; letter-spacing:0.05em;
         padding:0.5rem 1.25rem; border:1px solid var(--rule); background:var(--bg);
         color:var(--text); cursor:pointer; white-space:nowrap; }
button:hover { border-color:var(--link); color:var(--link); }
button:disabled { opacity:0.4; cursor:not-allowed; }
.labeler-row { display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem; flex-wrap:wrap; }
.labeler-row select { font-family:var(--mono); font-size:0.75rem; padding:0.35rem 0.6rem;
                      border:1px solid var(--rule); background:var(--bg); color:var(--text); cursor:pointer; }
.labeler-row select:focus { outline:none; border-color:var(--link); }
.labeler-extra { margin-bottom:1.25rem; }
.labeler-extra input { width:100%; font-family:var(--mono); font-size:0.75rem; padding:0.4rem 0.75rem;
                       border:1px solid var(--rule); background:var(--bg); color:var(--text); }
.labeler-extra input:focus { outline:none; border-color:var(--link); }
.labeler-hint { font-family:var(--mono); font-size:0.7rem; color:var(--muted); margin-top:0.3rem; }
.action-row { display:flex; gap:0.75rem; align-items:center; margin-bottom:2rem; }
.handle-count { font-family:var(--mono); font-size:0.7rem; color:var(--muted); }
.progress-track { width:100%; height:1px; background:var(--rule); margin-bottom:1.5rem; overflow:hidden; }
.progress-fill { height:100%; background:var(--link); width:0%; transition:width 0.3s ease; }
.status-line { font-family:var(--mono); font-size:0.75rem; color:var(--muted); margin-bottom:1.5rem; }
.status-line.error { color:var(--link); }
.view-tabs { display:flex; gap:1rem; margin-bottom:1rem; }
#resultsLayout { display:grid; grid-template-columns:1fr 280px; gap:2rem; align-items:start; margin-bottom:2.5rem; }
#resultsLayout.chart-only { grid-template-columns:1fr; }
#resultsLayout.chart-only #postCol { display:none; }
#postCol { position:sticky; top:1.5rem; max-height:calc(100vh - 3rem); overflow-y:auto; }
.chart-wrap { position:relative; }
.chart-wrap canvas { width:100%; aspect-ratio:8/7; display:block; touch-action:none; }
#threeWrap { width:100%; aspect-ratio:8/7; position:relative; overflow:hidden; }
#threeWrap canvas { width:100%!important; height:100%!important; display:block; }
.ax-label { position:absolute; pointer-events:none; font-family:var(--mono); font-size:0.65rem;
            background:transparent; padding:0.2rem 0.35rem; line-height:1.3; text-align:center;
            transform:translate(-50%,-50%); white-space:nowrap; }
#axisGrid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:2rem; margin-top:2rem; padding-top:1.5rem; border-top:1px solid var(--rule); }
.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); }
.axis-col-desc { font-size:0.82rem; color:var(--text); margin-bottom:0.75rem; line-height:1.55; }
.axis-pole-header { font-family:var(--mono); font-size:0.8rem; font-weight:600; color:#f66; margin:0.75rem 0 0.3rem; }
.axis-post { font-size:0.78rem; color:var(--text); margin-bottom:0.6rem; line-height:1.45; word-break:break-word; }
.axis-post-handle { font-family:var(--mono); font-size:0.65rem; color:var(--muted); display:block; margin-bottom:0.1rem; }
.tooltip { position:absolute; pointer-events:none; font-family:var(--mono); font-size:0.7rem;
           background:var(--bg); border:1px solid var(--rule); padding:0.4rem 0.6rem;
           color:var(--text); white-space:nowrap; z-index:10; line-height:1.4; }
.readout { font-family:var(--mono); font-size:0.75rem; color:var(--muted); min-height:3.5rem;
           display:flex; align-items:center; gap:0.75rem; padding:0.75rem 0;
           border-bottom:1px solid var(--rule); margin-bottom:0.5rem; }
.readout-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; }
.readout-handle { color:var(--text); font-size:0.8rem; font-weight:700; }
.readout-handle a { color:var(--text); text-decoration:none; }
.readout-handle a:hover { text-decoration:underline; }
.readout-scores { display:flex; gap:0.5rem; margin-top:0.25rem; flex-wrap:wrap; }
.readout-score { font-family:var(--mono); font-size:0.65rem; padding:0.1rem 0.35rem; border-radius:1px; }
.section-header { font-family:var(--mono); font-size:0.7rem; color:var(--muted); letter-spacing:0.05em;
                  margin-bottom:0.75rem; padding-bottom:0.5rem; border-bottom:1px solid var(--rule); }
.post-row { display:flex; align-items:flex-start; gap:0.5rem; padding:0.4rem 0;
            border-bottom:1px solid var(--rule); line-height:1.5; }
.post-row:last-child { border-bottom:none; }
.post-pcs { display:flex; flex-direction:column; gap:0.15rem; flex-shrink:0; padding-top:0.15rem; }
.post-pc { font-family:var(--mono); font-size:0.58rem; padding:0.05rem 0.25rem; border-radius:1px; white-space:nowrap; }
.post-text { flex:1; min-width:0; font-size:0.82rem; color:var(--text); word-break:break-word; }
footer { margin-top:4rem; padding-top:1.5rem; border-top:1px solid var(--rule);
         font-family:var(--mono); font-size:0.7rem; color:var(--muted); letter-spacing:0.05em; }
footer a { color:var(--muted); text-decoration:none; }
footer a:hover { color:var(--text); }
@media (max-width:700px) { #resultsLayout { grid-template-columns:1fr; } #postCol { position:static; max-height:none; } #axisGrid { grid-template-columns:1fr; } }
@media (max-width:560px) { body { padding:1.5rem 0.75rem; } }
</style>
</head>
<body>

<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>
<p class="subtitle">No anchors. No labels. Just structure.</p>
<p class="desc">
  Paste bluesky handles, pick an embedder, and click <em>map it</em>.
  Hover over dots to identify users; click one to see their posts in the side panel.
  Axes with their top posts appear below the chart.
  Hit <em>export</em> to download a self-contained interactive HTML you can share.
</p>
<p class="desc-small">
  Each user's recent posts are embedded into high-dimensional vector space β€” either SPLADE vocabulary
  weights (sparse, local), BGE-large dense vectors (local), or Voyage AI embeddings (cloud, fast).
  PCA extracts three orthogonal axes of maximum variance across the full corpus.
  The axes are unlabeled by the algorithm; an LLM reads the top posts along each axis
  and infers what each one is actually about.
</p>

<div class="tabs" id="inputTabs">
  <button class="tab active" data-tab="paste">paste handles</button>
  <button class="tab" data-tab="list">load list</button>
</div>
<div id="pastePanel">
  <textarea id="handleList" rows="5" placeholder="one handle per line&#10;alice.bsky.social&#10;bob.bsky.social" spellcheck="false"></textarea>
</div>
<div id="listPanel" class="hidden">
  <div class="list-row">
    <input type="text" id="listUrl" placeholder="https://bsky.app/profile/.../lists/..." autocomplete="off" spellcheck="false">
    <button id="loadListBtn">load list</button>
  </div>
</div>
<div class="labeler-row">
  <span style="font-family:var(--mono);font-size:0.7rem;color:var(--muted)">embedder:</span>
  <select id="embedderChoice">
    <option value="voyage">voyage-3-lite Β· api Β· fast</option>
    <option value="bge">bge-large Β· local Β· dense</option>
    <option value="splade">splade Β· local Β· vocab</option>
  </select>
  <span style="font-family:var(--mono);font-size:0.7rem;color:var(--muted);margin-left:0.5rem">axis labels:</span>
  <select id="labeler">
    <option value="none">corpus stats</option>
    <option value="browser">browser LLM Β· WebGPU (~500MB)</option>
    <option value="llama">llama-server Β· local</option>
    <option value="anthropic">anthropic api</option>
  </select>
</div>
<div class="labeler-extra hidden" id="embedderExtra">
  <input type="password" id="embedderInput" autocomplete="off" spellcheck="false" placeholder="voyage api key">
  <div class="labeler-hint">key stored in localStorage only</div>
</div>
<div class="labeler-extra hidden" id="labelerExtra">
  <input type="password" id="labelerInput" autocomplete="off" spellcheck="false">
  <div class="labeler-hint" id="labelerHint"></div>
</div>
<div class="action-row">
  <button id="mapBtn" disabled>map</button>
  <span class="handle-count" id="handleCount">0 handles</span>
  <span style="font-family:var(--mono);font-size:0.7rem;color:var(--muted);margin-left:0.5rem">active in last</span>
  <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">
  <span style="font-family:var(--mono);font-size:0.7rem;color:var(--muted)">days</span>
</div>
<div class="progress-track hidden" id="progressBar"><div class="progress-fill" id="progressFill"></div></div>
<div class="status-line hidden" id="status"></div>

<div id="results" class="hidden">
  <div class="view-tabs">
    <button class="tab active" id="viewTernaryBtn">ternary</button>
    <button class="tab" id="view3dBtn">3d</button>
    <button class="tab" id="exportBtn" style="margin-left:auto">export ↓</button>
  </div>
  <div id="resultsLayout">
    <div id="chartCol">
      <div id="ternaryWrap" class="chart-wrap">
        <canvas id="ternaryChart"></canvas>
        <div class="tooltip hidden" id="tooltip"></div>
      </div>
      <div id="threeWrap" class="hidden"></div>
      <div class="readout" id="readout">hover a hex to inspect Β· click to expand</div>
    </div>
    <div id="postCol">
      <div style="display:flex;justify-content:flex-end;margin-bottom:0.5rem">
        <button id="collapseBtn" style="font-size:0.65rem;padding:0.2rem 0.6rem;border-color:var(--rule)">βœ• collapse</button>
      </div>
      <div id="postList"></div>
    </div>
  </div>
  <div id="axisGrid"></div>
</div>

<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { AutoTokenizer, AutoModelForMaskedLM, pipeline as tfPipeline }
  from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3/dist/transformers.min.js';

// ── Constants ──────────────────────────────────────
const DEVICE = navigator.gpu ? 'webgpu' : 'wasm';
const BSKY = 'https://public.api.bsky.app';
const POSTS_PER_USER = 30;
const BATCH_SIZE = navigator.gpu ? 128 : 4;
const MAX_LEN = 64;
const POWER_ITER = 60;
const HEX_RADIUS_MAX = 16;
const HEX_RADIUS_MIN = 8;

const SPLADE_WGSL = `
struct Uni { B: u32, S: u32, V: u32 }
@group(0) @binding(0) var<uniform> uni: Uni;
@group(0) @binding(1) var<storage, read> logits: array<f32>;
@group(0) @binding(2) var<storage, read> mask: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<f32>;
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
  let idx = gid.x;
  if (idx >= uni.B * uni.V) { return; }
  let b = idx / uni.V;
  let v = idx % uni.V;
  var mx: f32 = 0.0;
  for (var t: u32 = 0u; t < uni.S; t = t + 1u) {
    if (mask[b * uni.S + t] == 0u) { continue; }
    let raw = logits[(b * uni.S + t) * uni.V + v];
    if (raw > 0.0) { mx = max(mx, log(1.0 + raw)); }
  }
  out[b * uni.V + v] = mx;
}`;

const GRAM_WGSL = `
struct Uni { N: u32, D: u32 }
@group(0) @binding(0) var<uniform> uni: Uni;
@group(0) @binding(1) var<storage, read> Xc: array<f32>;
@group(0) @binding(2) var<storage, read_write> G: array<f32>;
@compute @workgroup_size(8, 8)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
  let i = id.x; let k = id.y; let N = uni.N; let D = uni.D;
  if (i >= N || k >= N) { return; }
  var s: f32 = 0.0;
  for (var j: u32 = 0u; j < D; j = j + 1u) { s = s + Xc[i*D+j] * Xc[k*D+j]; }
  G[i*N+k] = s;
}`;

// ── State ──────────────────────────────────────────
let tokenizer = null;
let mlmModel = null;
let useDenseEmbedder = false;
let denseEmbedder = null;
let vocabSize = 30522;
let gpuDevice = null;
let gramPipeline = null;
let splatePipeline = null;
let BAKED_DATA = null;
let avatarImages = {};    // canvas-2d usage
let chartState = null;
let threeObjs = null;
let viewMode = 'ternary';
let lockedHandle = null;

// ── Utilities ──────────────────────────────────────
const $ = id => document.getElementById(id);
const show = el => el.classList.remove('hidden');
const hide = el => el.classList.add('hidden');
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function setStatus(msg, err) { const e=$('status'); show(e); e.textContent=msg; e.classList.toggle('error',!!err); }
function setProgress(pct) { show($('progressBar')); $('progressFill').style.width=pct+'%'; }
function getColors() {
  const cs = getComputedStyle(document.documentElement);
  return ['text','muted','rule','bg','pc1','pc2','pc3','link'].reduce((o,k)=>{o[k]=cs.getPropertyValue('--'+k).trim();return o;},{});
}
function getHandles() {
  const raw = $('handleList').value.trim(); if (!raw) return [];
  return raw.split(/[\n,]+/).map(h=>h.trim().replace(/^@/,'')).filter(h=>h.length>0);
}
function updateCount() {
  const n=getHandles().length;
  $('handleCount').textContent=n+' handle'+(n!==1?'s':'');
  $('mapBtn').disabled=n<2;
}

// ── BSKY API ───────────────────────────────────────
async function resolveHandle(handle) {
  if (handle.startsWith('did:')) return handle;
  const r = await fetch(BSKY+'/xrpc/com.atproto.identity.resolveHandle?handle='+encodeURIComponent(handle));
  if (!r.ok) throw new Error('Could not resolve: '+handle);
  return (await r.json()).did;
}
async function fetchProfile(did) {
  const r = await fetch(BSKY+'/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent(did));
  return r.ok ? r.json() : null;
}
async function fetchRecentPosts(did, max) {
  const texts=[]; let cursor, lastPostAt=null;
  let iterations = 0;
  while (texts.length < max && iterations < 10) {
    iterations += 1;
    let url=BSKY+'/xrpc/app.bsky.feed.getAuthorFeed?actor='+encodeURIComponent(did)+'&limit=100&filter=posts_and_author_threads';
    if (cursor) url+='&cursor='+encodeURIComponent(cursor);
    const r=await fetch(url); if (!r.ok) break;
    const data=await r.json(); const feed=data.feed||[]; if (!feed.length) break;
    for (let i=0;i<feed.length&&texts.length<max;i++) {
      const item=feed[i]; if (item.reason) continue;
      if (!lastPostAt && item.post&&item.post.indexedAt) lastPostAt=new Date(item.post.indexedAt);
      const t=item.post&&item.post.record&&item.post.record.text;
      if (t&&t.length>5&&!t.startsWith('…')) texts.push(t);
    }
    cursor=data.cursor; if (!cursor) break;
  }
  return { texts, lastPostAt };
}
async function loadUserData(handle) {
  const did=await resolveHandle(handle); const prof=await fetchProfile(did);
  const { texts, lastPostAt }=await fetchRecentPosts(did, POSTS_PER_USER);
  return { handle:prof?prof.handle:handle, did, avatar:prof?prof.avatar:null, texts, lastPostAt };
}
async function batchLoad(handles, concurrency, onProgress) {
  const results=[]; let idx=0,completed=0;
  async function worker() {
    while (idx<handles.length) {
      const i=idx++;
      try { results.push(await loadUserData(handles[i])); } catch(e) {}
      completed++; if (onProgress) onProgress(completed,handles.length);
    }
  }
  await Promise.all(Array.from({length:Math.min(concurrency,handles.length)},worker));
  return results;
}
function parseListUrl(url) { const m=url.match(/\/profile\/([^/]+)\/lists\/([^/?#]+)/); return m?{actor:m[1],rkey:m[2]}:null; }
async function fetchListMembers(actor, rkey) {
  const did=await resolveHandle(actor);
  const atUri='at://'+did+'/app.bsky.graph.list/'+rkey;
  const handles=[]; let cursor;
  while (true) {
    let url=BSKY+'/xrpc/app.bsky.graph.getList?list='+encodeURIComponent(atUri)+'&limit=100';
    if (cursor) url+='&cursor='+encodeURIComponent(cursor);
    const r=await fetch(url); if (!r.ok) throw new Error('Failed to load list (HTTP '+r.status+')');
    const data=await r.json(); const items=data.items||[]; if (!items.length) break;
    for (const item of items) { if (item.subject&&item.subject.handle) handles.push(item.subject.handle); }
    cursor=data.cursor; if (!cursor) break;
  }
  return handles;
}

// ── Model loading ──────────────────────────────────
async function loadModel(onProgress) {
  const wantSplade = $('embedderChoice').value === 'splade';

  if (!wantSplade) {
    if (!denseEmbedder) {
      onProgress('loading bge-large…');
      denseEmbedder = await tfPipeline('feature-extraction', 'Xenova/bge-large-en-v1.5', {
        device: DEVICE, dtype: 'q8',
        progress_callback: p => { if (p&&p.progress!=null) onProgress('downloading: '+Math.round(p.progress)+'%'); },
      });
    }
    vocabSize = 1024; useDenseEmbedder = true;
    onProgress('bge-large ready');
    return;
  }

  // SPLADE path β€” try proper SPLADE models, fall back to BGE if none load
  if (!mlmModel) {
    for (const modelId of ['naver/splade-cocondenser-selfdistil', 'naver/efficient-splade-VI-BT-large-query']) {
      try {
        onProgress('loading tokenizer (' + modelId + ')…');
        tokenizer = await AutoTokenizer.from_pretrained(modelId);
        onProgress('loading splade model…');
        mlmModel = await AutoModelForMaskedLM.from_pretrained(modelId, {
          device: DEVICE, dtype: 'q8',
          progress_callback: p => { if (p&&p.progress!=null) onProgress('downloading: '+Math.round(p.progress)+'%'); },
        });
        vocabSize = 30522; useDenseEmbedder = false;
        onProgress('splade ready (' + modelId + ')');
        return;
      } catch(e) { tokenizer=null; mlmModel=null; }
    }
    // SPLADE unavailable β€” fall back
    onProgress('splade unavailable, falling back to bge-large…');
    if (!denseEmbedder) {
      denseEmbedder = await tfPipeline('feature-extraction', 'Xenova/bge-large-en-v1.5', {
        device: DEVICE, dtype: 'q8',
        progress_callback: p => { if (p&&p.progress!=null) onProgress('downloading: '+Math.round(p.progress)+'%'); },
      });
    }
    vocabSize = 1024; useDenseEmbedder = true;
    onProgress('bge-large ready (splade unavailable)');
  } else {
    vocabSize = 30522; useDenseEmbedder = false;
    onProgress('splade ready (cached)');
  }
}

// ── SPLADE-style inference ─────────────────────────
// Returns { userVec, postVecs } β€” postVecs is array of per-post Float32Arrays
async function embedUser(texts) {
  const postVecs = [];
  if (!texts.length) return { userVec: new Float32Array(vocabSize), postVecs };

  if (useDenseEmbedder) {
    const userVec = new Float32Array(vocabSize);
    for (let i=0; i<texts.length; i+=BATCH_SIZE) {
      const batch=texts.slice(i,i+BATCH_SIZE);
      const out=await denseEmbedder(batch, { pooling:'mean', normalize:true });
      for (let b=0; b<batch.length; b++) {
        const pv=new Float32Array(vocabSize);
        for (let j=0; j<vocabSize; j++) pv[j]=out.data[b*vocabSize+j];
        postVecs.push(pv);
        for (let j=0; j<vocabSize; j++) userVec[j]+=pv[j];
      }
    }
    const n=texts.length;
    for (let j=0; j<vocabSize; j++) userVec[j]/=n;
    return { userVec, postVecs };
  }

  // SPLADE: relu(log1p(logits)), max-pool over tokens per post, then max across posts for user vec
  const userVec = new Float32Array(vocabSize);
  for (let i=0; i<texts.length; i+=BATCH_SIZE) {
    const batch=texts.slice(i,i+BATCH_SIZE);
    const inputs=await tokenizer(batch, { padding:true, truncation:true, max_length:MAX_LEN, return_tensors:'pt' });
    const { logits }=await mlmModel(inputs);
    const bSize=batch.length;
    const seqLen=inputs.attention_mask.dims[1];
    const attnData=inputs.attention_mask.data;
    const logData=logits.data;
    if (gpuDevice && splatePipeline) {
      const batchOut = await computeSpladePostProcess(logData, attnData, bSize, seqLen);
      for (let b=0; b<bSize; b++) {
        const pv=new Float32Array(vocabSize);
        for (let v=0; v<vocabSize; v++) pv[v]=batchOut[b*vocabSize+v];
        postVecs.push(pv);
        for (let v=0; v<vocabSize; v++) { if (pv[v]>userVec[v]) userVec[v]=pv[v]; }
      }
    } else {
      for (let b=0; b<bSize; b++) {
        const pv=new Float32Array(vocabSize);
        for (let t=0; t<seqLen; t++) {
          if (!attnData[b*seqLen+t]) continue;
          const tOff=(b*seqLen+t)*vocabSize;
          for (let v=0; v<vocabSize; v++) {
            const val=Math.log1p(Math.max(0, logData[tOff+v]));
            if (val>pv[v]) pv[v]=val;
          }
        }
        postVecs.push(pv);
        for (let v=0; v<vocabSize; v++) { if (pv[v]>userVec[v]) userVec[v]=pv[v]; }
      }
    }
  }
  return { userVec, postVecs };
}

async function embedVoyage(texts, apiKey) {
  const resp = await fetch('https://api.voyageai.com/v1/embeddings', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
    body: JSON.stringify({ input: texts, model: 'voyage-3-lite' }),
  });
  if (!resp.ok) throw new Error('Voyage API ' + resp.status + ': ' + await resp.text());
  const { data } = await resp.json();
  return data.sort((a, b) => a.index - b.index).map(d => new Float32Array(d.embedding));
}

// Batch-embed all users together β€” keeps the GPU fed with full batches instead of
// one per-user call at a time (250 Γ— 30-post mini-batches β†’ idle gaps between each).
async function embedAllUsers(userData, onProgress) {
  const N = userData.length;
  const allTexts = [], userOf = [];
  for (let i = 0; i < N; i++) {
    for (const t of userData[i].texts) { allTexts.push(t); userOf.push(i); }
  }
  const total = allTexts.length;
  const userVecs = Array.from({length: N}, () => new Float32Array(vocabSize));
  const allPostVecs = Array.from({length: N}, () => []);
  const postCounts = new Uint32Array(N);

  const voyageKey = ($('embedderInput').value || localStorage.getItem('voyage_api_key') || '').trim();
  const useVoyage = $('embedderChoice').value === 'voyage';

  for (let i = 0; i < total; i += BATCH_SIZE) {
    if (onProgress) onProgress(i, total);
    const batchTexts = allTexts.slice(i, i + BATCH_SIZE);
    const batchUsers = userOf.slice(i, i + BATCH_SIZE);
    let pvs;

    if (useVoyage) {
      pvs = await embedVoyage(batchTexts, voyageKey);
    } else if (useDenseEmbedder) {
      const out = await denseEmbedder(batchTexts, { pooling: 'mean', normalize: true, truncation: true, max_length: 128 });
      pvs = batchTexts.map((_, b) => {
        const pv = new Float32Array(vocabSize);
        for (let v = 0; v < vocabSize; v++) pv[v] = out.data[b * vocabSize + v];
        return pv;
      });
    } else {
      const inputs = await tokenizer(batchTexts, { padding: true, truncation: true, max_length: MAX_LEN, return_tensors: 'pt' });
      const { logits } = await mlmModel(inputs);
      const bSize = batchTexts.length, seqLen = inputs.attention_mask.dims[1];
      const attnData = inputs.attention_mask.data, logData = logits.data;
      pvs = [];
      if (gpuDevice && splatePipeline) {
        const batchOut = await computeSpladePostProcess(logData, attnData, bSize, seqLen);
        for (let b = 0; b < bSize; b++) {
          const pv = new Float32Array(vocabSize);
          for (let v = 0; v < vocabSize; v++) pv[v] = batchOut[b * vocabSize + v];
          pvs.push(pv);
        }
      } else {
        for (let b = 0; b < bSize; b++) {
          const pv = new Float32Array(vocabSize);
          for (let t = 0; t < seqLen; t++) {
            if (!attnData[b * seqLen + t]) continue;
            const tOff = (b * seqLen + t) * vocabSize;
            for (let v = 0; v < vocabSize; v++) {
              const val = Math.log1p(Math.max(0, logData[tOff + v]));
              if (val > pv[v]) pv[v] = val;
            }
          }
          pvs.push(pv);
        }
      }
    }

    for (let b = 0; b < pvs.length; b++) {
      const ui = batchUsers[b], pv = pvs[b];
      allPostVecs[ui].push(pv);
      if (useDenseEmbedder) {
        for (let v = 0; v < vocabSize; v++) userVecs[ui][v] += pv[v];
        postCounts[ui]++;
      } else {
        for (let v = 0; v < vocabSize; v++) { if (pv[v] > userVecs[ui][v]) userVecs[ui][v] = pv[v]; }
      }
    }
  }
  if (useDenseEmbedder) {
    for (let i = 0; i < N; i++) {
      const n = postCounts[i] || 1;
      for (let v = 0; v < vocabSize; v++) userVecs[i][v] /= n;
    }
  }
  return { userVecs, allPostVecs };
}

// ── Corpus-based axis labels ───────────────────────
// Correlates per-user word frequencies with PC projections.
// Top correlated words are the axis labels β€” always readable actual words.
function extractCorpusLabels(userData, projections, k) {
  const N = userData.length;
  const STOP = new Set([
    'the','and','for','are','but','not','you','all','this','that','with','have','from',
    'they','what','their','would','there','been','were','when','more','will','she','was',
    'his','her','has','had','its','who','our','out','can','did','get','him','now','may',
    'use','how','any','came','come','like','just','also','about','said','then','over',
    'very','well','much','them','some','want','know','dont','into','your','its','its',
    'one','two','even','only','back','still','here','after','where','those','being',
    'these','other','such','than','should','through','because','really','people',
    'going','think','feel','good','time','look','make','let','never','always','every',
  ]);

  const userWords = userData.map(u => {
    const wf = {};
    for (const text of u.texts) {
      for (const t of (text.toLowerCase().match(/\b[a-z]{3,15}\b/g) || []))
        if (!STOP.has(t)) wf[t] = (wf[t]||0) + 1;
    }
    return wf;
  });

  return [0,1,2].map(comp => {
    const scores = projections.map(p => p[comp]);
    const mean = scores.reduce((a,b)=>a+b,0)/N;
    const centered = scores.map(s => s-mean);
    const corr = {};
    for (let i=0; i<N; i++)
      for (const [w,c] of Object.entries(userWords[i]))
        corr[w] = (corr[w]||0) + centered[i] * Math.log1p(c);
    return Object.entries(corr)
      .filter(([,v]) => v > 0)
      .sort((a,b) => b[1]-a[1])
      .slice(0, k).map(([w]) => w);
  });
}

// ── PCA directions in vocab space ─────────────────
// V_k = Xc.T @ u_k: the k-th PC direction expressed in vocabulary (or embedding) space.
function computePCDirections(Xc, N, D, eigenvecs) {
  return eigenvecs.map(u => {
    const v = new Float32Array(D);
    for (let j=0; j<D; j++) {
      let s=0; for (let i=0; i<N; i++) s+=Xc[i*D+j]*u[i]; v[j]=s;
    }
    normalizeVec(v);
    return v;
  });
}

// ── Per-post projection ────────────────────────────
// Returns [pc1_pct, pc2_pct, pc3_pct] normalized to sum=100 for display.
function projectPost(postVec, means, Vk) {
  const raw = Vk.map(vk => {
    let s=0;
    for (let j=0; j<postVec.length; j++) s+=(postVec[j]-means[j])*vk[j];
    return s;
  });
  const minR = Math.min(...raw);
  let vs = raw.map(r => Math.max(r-minR, 0.02));
  const tot = vs.reduce((a,b)=>a+b, 0.001);
  return vs.map(v => Math.round(v/tot*100));
}

// ── LLM axis labeling ──────────────────────────────

// Brief prompt for small/browser models β€” fits in ~512 tokens
function axisPrompt(userData, projections, comp) {
  const indexed = userData.map((u, i) => ({ u, score: projections[i][comp] }));
  indexed.sort((a, b) => b.score - a.score);
  const high = indexed.slice(0, 2).flatMap(({ u }) => u.texts.slice(0, 4));
  const low  = indexed.slice(-2).flatMap(({ u }) => u.texts.slice(0, 4));
  return 'HIGH end posts:\n' + high.map(t => '- ' + t.slice(0, 200)).join('\n') +
         '\n\nLOW end posts:\n' + low.map(t => '- ' + t.slice(0, 200)).join('\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.';
}

// Rich prompt for capable local models.
// Uses per-post PC scores (derived from embeddings) to select the most axis-aligned
// posts from across the whole corpus β€” so the embeddings actively curate what the LLM sees.
// corpusWords (word-frequency signals) are passed as an additional vocabulary hint.
function axisPromptRich(userData, projections, comp, corpusWords) {
  // Collect every post with its embedding-derived PC score for this component
  const allPosts = [];
  userData.forEach(u => {
    (u.postScores || []).forEach((ps, pi) => {
      if (u.texts[pi]) allPosts.push({ handle: u.handle, text: u.texts[pi], score: ps[comp] });
    });
  });
  allPosts.sort((a, b) => b.score - a.score);

  const fmt = posts =>
    posts.map(p => `  @${p.handle}: "${p.text.replace(/"/g, '’')}"`).join('\n');

  const hints = corpusWords && corpusWords[comp] && corpusWords[comp].length
    ? `Vocabulary signals for the HIGH end (word-frequency correlation): ${corpusWords[comp].join(', ')}\n\n`
    : '';

  return hints +
    `[LEFT SIDE β€” posts scoring HIGHEST on this dimension]:\n${fmt(allPosts.slice(0, 8))}\n\n` +
    `[RIGHT SIDE β€” posts scoring LOWEST]:\n${fmt(allPosts.slice(-8))}\n\n` +
    `Write a label in the format "LEFT_PHRASE ↔ RIGHT_PHRASE" (2–4 words each). ` +
    `LEFT_PHRASE must describe the LEFT SIDE posts above; RIGHT_PHRASE must describe the RIGHT SIDE posts above. ` +
    `Focus on tone, register, rhetorical stance, or affect β€” how these posts feel to read, not just their topics. ` +
    `Just the label, nothing else.`;
}

// Anthropic claude-haiku
async function labelAxisWithClaude(apiKey, userData, projections, comp) {
  const res = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: { 'Content-Type':'application/json', 'x-api-key':apiKey, 'anthropic-version':'2023-06-01' },
    body: JSON.stringify({ model:'claude-haiku-4-5-20251001', max_tokens:150,
      messages:[{ role:'user', content:axisPrompt(userData, projections, comp) }] }),
  });
  if (!res.ok) throw new Error('anthropic ' + res.status);
  const text = (await res.json()).content[0].text.trim();
  const labelMatch = text.match(/label:\s*(.+)/i);
  const descMatch  = text.match(/description:\s*(.+)/i);
  const label = (labelMatch ? labelMatch[1] : text.split('\n')[0]).trim().replace(/[.!?'"]+$/, '').toLowerCase();
  const description = descMatch ? descMatch[1].trim() : '';
  return { label, description };
}

// llama-server (llama.cpp) β€” all 3 axes in one request so the model can differentiate them
// Run: llama-server --model model.gguf --port 8080 --jinja
async function labelAllAxesWithLlamaServer(port, userData, projections, corpusWords) {
  const sections = [0, 1, 2].map(comp => {
    const allPosts = [];
    userData.forEach(u => {
      (u.postScores || []).forEach((ps, pi) => {
        if (u.texts[pi]) allPosts.push({ handle: u.handle, text: u.texts[pi], score: ps[comp] });
      });
    });
    allPosts.sort((a, b) => b.score - a.score);
    const fmt = posts => posts.map(p => `  @${p.handle}: "${p.text.replace(/"/g, '’')}"`).join('\n');
    const hints = corpusWords && corpusWords[comp] && corpusWords[comp].length
      ? `Vocabulary signals: ${corpusWords[comp].join(', ')}\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))}`;
  });

  const prompt = sections.join('\n\n') +
    '\n\nFor each dimension write a label in the format "LEFT_PHRASE ↔ RIGHT_PHRASE" (2–4 words each). ' +
    'LEFT_PHRASE must describe the [LEFT SIDE posts] above; RIGHT_PHRASE must describe the [RIGHT SIDE posts] above. ' +
    'Also give one sentence describing what the dimension captures. ' +
    '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. ' +
    '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". ' +
    'Avoid dry academic language entirely. Go for warm, playful, a little shitpost-y β€” gentle ribbing, never mean. ' +
    'Reply in exactly this format:\n1: label | description\n2: label | description\n3: label | description';

  const res = await fetch('http://localhost:' + port + '/v1/chat/completions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      messages: [
        { 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.' },
        { role: 'user',   content: prompt },
      ],
      max_tokens: 12000,
      temperature: 0.6,
      stream: false,
    }),
  });
  if (!res.ok) throw new Error('llama-server ' + res.status + ' (port ' + port + ')');

  const msg = (await res.json()).choices[0].message;
  // llama-server separates Qwen3 thinking into reasoning_content; content has the final answer
  const raw = (msg.content || '').trim() || (msg.reasoning_content || '').trim();
  // Also strip any leaked <think> tags just in case
  const text = raw.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<\/?think>/gi, '').trim();

  const results = [null, null, null];
  for (const line of text.split('\n')) {
    const m = line.match(/^([123])[.:)\s]\s*(.+)/);
    if (m) {
      const parts = m[2].split('|');
      results[parseInt(m[1]) - 1] = {
        label: parts[0].trim().replace(/[.!?'"]+$/, '').toLowerCase(),
        description: (parts[1] || '').trim(),
      };
    }
  }
  return results;
}

// Browser LLM via Transformers.js text-generation (WebGPU/WASM)
let labelGen = null;
async function getLabelGen(onProgress) {
  if (labelGen) return labelGen;
  onProgress('loading label model (SmolLM2-360M, ~500MB, cached after first run)…');
  labelGen = await tfPipeline('text-generation', 'HuggingFaceTB/SmolLM2-360M-Instruct', {
    device: DEVICE, dtype: 'q4',
    progress_callback: p => { if (p&&p.progress!=null) onProgress('label model: '+Math.round(p.progress)+'%'); },
  });
  return labelGen;
}

function cleanSmallModelOutput(raw) {
  // Small models often echo prompts or add filler β€” strip it down to 1-3 words
  let t = raw.trim()
    .split(/\n/)[0]                          // first line only
    .replace(/^label:?\s*/i, '')             // remove "Label:" prefix
    .replace(/['"«»""'']/g, '')              // remove quotes
    .replace(/^[-–—‒*]\s*/, '')              // remove bullet prefix
    .replace(/\s*[.!?;,]+\s*$/, '')          // trailing punctuation
    .trim();
  // If it still looks like a fragment of our prompt, give up
  if (/high.end|low.end|posts|end\s*of/i.test(t)) return null;
  return t.split(/\s+/).slice(0, 4).join(' ').toLowerCase() || null;
}

async function labelAxisInBrowser(userData, projections, comp, corpusWords, onProgress) {
  const gen = await getLabelGen(onProgress);
  const hints = (corpusWords[comp] || []).join(', ') || 'various';
  // Single representative post from the top user β€” short enough for tiny models
  const indexed = userData.map((u, i) => ({ u, score: projections[i][comp] }));
  indexed.sort((a, b) => b.score - a.score);
  const post = (indexed[0].u.texts[0] || '').slice(0, 120);
  const messages = [
    { role: 'system', content: 'Reply with a 2-word label only. No explanation.' },
    { role: 'user',   content: `Related words: ${hints}\nExample: "${post}"\nLabel:` },
  ];
  const out = await gen(messages, { max_new_tokens: 12, do_sample: false });
  const reply = out[0].generated_text;
  const raw = Array.isArray(reply) ? reply.at(-1).content : String(reply);
  return { label: cleanSmallModelOutput(raw), description: '' };
}

async function labelAxes(userData, projections, corpusWords, onProgress) {
  const labeler = $('labeler').value;
  if (labeler === 'none') return [null, null, null];
  const input = ($('labelerInput').value || '').trim();

  if (labeler === 'browser') {
    // Sequential β€” one tiny model at a time to avoid OOM
    const results = [];
    for (let comp = 0; comp < 3; comp++) {
      results.push(await labelAxisInBrowser(userData, projections, comp, corpusWords, onProgress).catch(() => null));
    }
    return results;
  }
  if (labeler === 'anthropic') {
    if (!input) return [null,null,null];
    localStorage.setItem('splade_api_key', input);
    return Promise.all([0,1,2].map(comp =>
      labelAxisWithClaude(input, userData, projections, comp).catch(() => null)
    ));
  }
  if (labeler === 'llama') {
    const port = input || '8080';
    return labelAllAxesWithLlamaServer(port, userData, projections, corpusWords).catch(() => [null, null, null]);
  }
  return [null,null,null];
}

// ── WebGPU Gram matrix ─────────────────────────────
async function setupWebGPU() {
  if (!navigator.gpu) return;
  try {
    const adapter=await navigator.gpu.requestAdapter(); if (!adapter) return;
    gpuDevice=await adapter.requestDevice();
    const mod=gpuDevice.createShaderModule({ code:GRAM_WGSL });
    gramPipeline=await gpuDevice.createComputePipelineAsync({ layout:'auto', compute:{ module:mod, entryPoint:'main' } });
    const splMod=gpuDevice.createShaderModule({ code:SPLADE_WGSL });
    splatePipeline=await gpuDevice.createComputePipelineAsync({ layout:'auto', compute:{ module:splMod, entryPoint:'main' } });
  } catch(e) { gpuDevice=null; }
}

async function computeGram(Xc, N, D) {
  if (gpuDevice && gramPipeline) {
    try {
      const uniBuf=gpuDevice.createBuffer({ size:8, usage:GPUBufferUsage.UNIFORM|GPUBufferUsage.COPY_DST });
      gpuDevice.queue.writeBuffer(uniBuf, 0, new Uint32Array([N,D]));
      const xcBuf=gpuDevice.createBuffer({ size:Xc.byteLength, usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_DST });
      gpuDevice.queue.writeBuffer(xcBuf, 0, Xc);
      const gSize=N*N*4;
      const gBuf=gpuDevice.createBuffer({ size:gSize, usage:GPUBufferUsage.STORAGE|GPUBufferUsage.COPY_SRC });
      const rBuf=gpuDevice.createBuffer({ size:gSize, usage:GPUBufferUsage.COPY_DST|GPUBufferUsage.MAP_READ });
      const bg=gpuDevice.createBindGroup({ layout:gramPipeline.getBindGroupLayout(0), entries:[
        {binding:0,resource:{buffer:uniBuf}},{binding:1,resource:{buffer:xcBuf}},{binding:2,resource:{buffer:gBuf}},
      ]});
      const enc=gpuDevice.createCommandEncoder();
      const pass=enc.beginComputePass();
      pass.setPipeline(gramPipeline); pass.setBindGroup(0,bg);
      pass.dispatchWorkgroups(Math.ceil(N/8),Math.ceil(N/8)); pass.end();
      enc.copyBufferToBuffer(gBuf,0,rBuf,0,gSize);
      gpuDevice.queue.submit([enc.finish()]);
      await rBuf.mapAsync(GPUMapMode.READ);
      const result=new Float32Array(rBuf.getMappedRange().slice(0));
      rBuf.unmap(); uniBuf.destroy(); xcBuf.destroy(); gBuf.destroy(); rBuf.destroy();
      return result;
    } catch(e) {}
  }
  const G=new Float32Array(N*N);
  for (let i=0;i<N;i++) for (let k=0;k<=i;k++) {
    let s=0; for (let j=0;j<D;j++) s+=Xc[i*D+j]*Xc[k*D+j];
    G[i*N+k]=s; G[k*N+i]=s;
  }
  return G;
}

async function computeSpladePostProcess(logData, attnData, bSize, seqLen) {
  const V = vocabSize;
  const maskU32 = new Uint32Array(bSize * seqLen);
  for (let i = 0; i < maskU32.length; i++) maskU32[i] = Number(attnData[i]);
  const logF32 = logData instanceof Float32Array ? logData : new Float32Array(logData);
  const uniBuf = gpuDevice.createBuffer({ size: 12, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
  gpuDevice.queue.writeBuffer(uniBuf, 0, new Uint32Array([bSize, seqLen, V]));
  const logBuf = gpuDevice.createBuffer({ size: logF32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  gpuDevice.queue.writeBuffer(logBuf, 0, logF32);
  const maskBuf = gpuDevice.createBuffer({ size: maskU32.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
  gpuDevice.queue.writeBuffer(maskBuf, 0, maskU32);
  const outSize = bSize * V * 4;
  const outBuf = gpuDevice.createBuffer({ size: outSize, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC });
  const readBuf = gpuDevice.createBuffer({ size: outSize, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  const bg = gpuDevice.createBindGroup({ layout: splatePipeline.getBindGroupLayout(0), entries: [
    { binding: 0, resource: { buffer: uniBuf } },
    { binding: 1, resource: { buffer: logBuf } },
    { binding: 2, resource: { buffer: maskBuf } },
    { binding: 3, resource: { buffer: outBuf } },
  ]});
  const enc = gpuDevice.createCommandEncoder();
  const pass = enc.beginComputePass();
  pass.setPipeline(splatePipeline); pass.setBindGroup(0, bg);
  pass.dispatchWorkgroups(Math.ceil(bSize * V / 256)); pass.end();
  enc.copyBufferToBuffer(outBuf, 0, readBuf, 0, outSize);
  gpuDevice.queue.submit([enc.finish()]);
  await readBuf.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(readBuf.getMappedRange().slice(0));
  readBuf.unmap();
  uniBuf.destroy(); logBuf.destroy(); maskBuf.destroy(); outBuf.destroy(); readBuf.destroy();
  return result;
}

// ── PCA via kernel trick ───────────────────────────
async function runPCA(userMatrix, N) {
  const D=vocabSize;
  const means=new Float32Array(D);
  for (let i=0;i<N;i++) for (let j=0;j<D;j++) means[j]+=userMatrix[i*D+j];
  for (let j=0;j<D;j++) means[j]/=N;
  const Xc=new Float32Array(N*D);
  for (let i=0;i<N;i++) for (let j=0;j<D;j++) Xc[i*D+j]=userMatrix[i*D+j]-means[j];

  const G=await computeGram(Xc,N,D);
  const Gw=G.slice(); const eigenvecs=[],eigenvals=[];
  for (let comp=0;comp<3;comp++) {
    let v=new Float32Array(N);
    for (let i=0;i<N;i++) v[i]=Math.random()-0.5; normalizeVec(v);
    let lambda=0;
    for (let iter=0;iter<POWER_ITER;iter++) {
      const w=new Float32Array(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; }
      lambda=vecNorm(w); if (lambda<1e-10) break;
      for (let i=0;i<N;i++) v[i]=w[i]/lambda;
    }
    eigenvecs.push(v.slice()); eigenvals.push(lambda);
    for (let i=0;i<N;i++) for (let k=0;k<N;k++) Gw[i*N+k]-=lambda*v[i]*v[k];
  }
  const projections=[];
  for (let i=0;i<N;i++) projections.push([
    Math.sqrt(Math.max(0,eigenvals[0]))*eigenvecs[0][i],
    Math.sqrt(Math.max(0,eigenvals[1]))*eigenvecs[1][i],
    Math.sqrt(Math.max(0,eigenvals[2]))*eigenvecs[2][i],
  ]);
  return { projections, eigenvals, Xc, eigenvecs, means };
}

function vecNorm(v) { let s=0; for (let i=0;i<v.length;i++) s+=v[i]*v[i]; return Math.sqrt(s); }
function normalizeVec(v) { const l=vecNorm(v)||1; for (let i=0;i<v.length;i++) v[i]/=l; }

// ── Ternary normalization ──────────────────────────
function toTernary(projections) {
  const mins=[Infinity,Infinity,Infinity], maxs=[-Infinity,-Infinity,-Infinity];
  for (const p of projections) p.forEach((v,k) => { if(v<mins[k])mins[k]=v; if(v>maxs[k])maxs[k]=v; });
  return projections.map(p => {
    let vs=p.map((v,k)=>Math.max((v-mins[k])/(maxs[k]-mins[k]||0.001),0.02));
    const tot=vs.reduce((a,b)=>a+b,0);
    return { pc1:Math.round(vs[0]/tot*100), pc2:Math.round(vs[1]/tot*100), pc3:Math.round(vs[2]/tot*100) };
  });
}

// ── Hex geometry ───────────────────────────────────
function pointInTriangle(px,py,ax,ay,bx,by,cx,cy) {
  const d=(by-cy)*(ax-cx)+(cx-bx)*(ay-cy);
  const a=((by-cy)*(px-cx)+(cx-bx)*(py-cy))/d;
  const b=((cy-ay)*(px-cx)+(ax-cx)*(py-cy))/d;
  return a>=-0.001&&b>=-0.001&&(1-a-b)>=-0.001;
}
function generateHexCells(Ax,Ay,Bx,By,Cx,Cy,hexR) {
  const cenX=(Ax+Bx+Cx)/3,cenY=(Ay+By+Cy)/3;
  const scale=Math.max(0.5,1-(hexR*0.9)/((Ay-Cy)*0.5));
  const iAx=cenX+(Ax-cenX)*scale,iAy=cenY+(Ay-cenY)*scale;
  const iBx=cenX+(Bx-cenX)*scale,iBy=cenY+(By-cenY)*scale;
  const iCx=cenX+(Cx-cenX)*scale,iCy=cenY+(Cy-cenY)*scale;
  const hexW=Math.sqrt(3)*hexR,rowH=1.5*hexR;
  const minY=Math.min(iAy,iBy,iCy),maxY=Math.max(iAy,iBy,iCy);
  const minX=Math.min(iAx,iBx,iCx),maxX=Math.max(iAx,iBx,iCx);
  const cells=[]; let row=0;
  for (let y=minY+hexR;y<=maxY;y+=rowH) {
    const off=(row%2)?hexW*0.5:0;
    for (let x=minX+hexW*0.5+off;x<=maxX;x+=hexW)
      if (pointInTriangle(x,y,iAx,iAy,iBx,iBy,iCx,iCy)) cells.push({x,y,occupant:null});
    row++;
  }
  return cells;
}
function assignUsersToCells(cells,users,t2xy) {
  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)};});
  ranked.sort((a,b)=>b.ext-a.ext);
  const occ={};
  for (const {user:u} of ranked) {
    const ideal=t2xy(u.pc1,u.pc2,u.pc3); let bestIdx=-1,bestDist=Infinity;
    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;} }
    if (bestIdx>=0) { occ[bestIdx]=true; cells[bestIdx].occupant=u; }
  }
}
function hexPath(ctx,cx,cy,r) {
  ctx.beginPath();
  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)); }
  ctx.closePath();
}

// ── Ternary draw ───────────────────────────────────
function drawTernary(canvas, data, highlightHandle, pcLabels) {
  const ctx=canvas.getContext('2d');
  const dpr=window.devicePixelRatio||1;
  const rect=canvas.getBoundingClientRect(); if (rect.width===0) return;
  const W=rect.width,H=rect.height;
  canvas.width=W*dpr; canvas.height=H*dpr; ctx.scale(dpr,dpr);
  const col=getColors();
  const mobile=W<480;
  const pad=mobile?28:48;
  const pcFs=mobile?13:20;           // size of PC₁/β‚‚/₃ label
  const maxSemFs=mobile?14:24;       // max size of semantic sub-label
  const botReserve=mobile?76:104;    // vertical space below triangle for bottom labels
  const sideLp=mobile?26:68;         // outward offset for rotated side labels
  ctx.clearRect(0,0,W,H);
  let triW=W-pad*2, triH=triW*Math.sqrt(3)/2;
  if (triH+pad+botReserve>H) { triH=H-pad-botReserve; triW=triH*2/Math.sqrt(3); }
  const cx=W/2;
  const Ax=cx-triW/2,Ay=pad+triH, Bx=cx+triW/2,By=pad+triH, Cx=cx,Cy=pad;

  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}; }

  // Fit text to a max pixel width by reducing font size
  function fitFs(text, maxW, maxSz, minSz=7) {
    let sz=maxSz;
    while (sz>minSz) { ctx.font=sz+'px monospace'; if(ctx.measureText(text).width<=maxW)break; sz--; }
    return sz;
  }

  // Draw semantic label; splits "HIGH ↔ LOW" into two arrow-direction lines.
  // flipArrows=true for rotated edge labels: canvas rotation reverses which way ← and β†’ point
  // in screen space, so HIGH needs ──→ (points toward its vertex) and LOW needs ←── .
  function semLabel(toks, color, lx, y0, flipArrows=false) {
    const joined=toks.join(' Β· '); if (!joined) return;
    ctx.fillStyle=color; ctx.globalAlpha=0.72;
    if (joined.includes('↔')) {
      const [high,low]=joined.split('↔').map(s=>s.trim());
      const line1=flipArrows ? high+' ──→' : '←── '+high;
      const line2=flipArrows ? '←── '+low   : low+' ──→';
      const sf=fitFs(line1.length>=line2.length?line1:line2, maxSemW, maxSemFs);
      ctx.font=sf+'px monospace';
      ctx.fillText(line1, lx, y0);
      ctx.fillText(line2, lx, y0+sf+2);
    } else {
      const sf=fitFs(joined, maxSemW, maxSemFs);
      ctx.font=sf+'px monospace';
      ctx.fillText(joined, lx, y0);
    }
    ctx.globalAlpha=1;
  }

  ctx.strokeStyle=col.rule; ctx.lineWidth=0.5; ctx.globalAlpha=0.4;
  for (let i=1;i<10;i++) {
    const t=i/10;
    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)]];
    for (const [a,b] of pairs) { ctx.beginPath(); ctx.moveTo(a.x,a.y); ctx.lineTo(b.x,b.y); ctx.stroke(); }
  }
  ctx.globalAlpha=1;
  ctx.strokeStyle=col.text; ctx.lineWidth=1.5;
  ctx.beginPath(); ctx.moveTo(Ax,Ay); ctx.lineTo(Bx,By); ctx.lineTo(Cx,Cy); ctx.closePath(); ctx.stroke();

  const toks=pcLabels||[[],[],[]];
  const maxSemW=triW*0.82;  // semantic label targets 82% of edge length

  // PC₁ bottom β€” horizontal
  ctx.textAlign='center';
  ctx.font='bold '+pcFs+'px monospace'; ctx.fillStyle=col.pc1;
  ctx.fillText('PC₁', cx, Ay+8+pcFs);
  semLabel(toks[0], col.pc1, cx, Ay+10+pcFs+maxSemFs);

  // PCβ‚‚ left edge β€” rotated
  ctx.save(); ctx.translate((Ax+Cx)/2-sideLp,(Ay+Cy)/2); ctx.rotate(-Math.PI/3); ctx.textAlign='center';
  ctx.font='bold '+pcFs+'px monospace'; ctx.fillStyle=col.pc2; ctx.fillText('PCβ‚‚',0,0);
  semLabel(toks[1], col.pc2, 0, pcFs+5, true);
  ctx.restore();

  // PC₃ right edge β€” rotated
  ctx.save(); ctx.translate((Bx+Cx)/2+sideLp,(By+Cy)/2); ctx.rotate(Math.PI/3); ctx.textAlign='center';
  ctx.font='bold '+pcFs+'px monospace'; ctx.fillStyle=col.pc3; ctx.fillText('PC₃',0,0);
  semLabel(toks[2], col.pc3, 0, pcFs+5, true);
  ctx.restore();

  let hexR=HEX_RADIUS_MAX;
  const nU=data.length;
  if (nU>0) hexR=Math.max(HEX_RADIUS_MIN,Math.min(HEX_RADIUS_MAX,Math.sqrt(triW*triW/(2.6*nU*1.4))));
  let cells=generateHexCells(Ax,Ay,Bx,By,Cx,Cy,hexR);
  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); }
  assignUsersToCells(cells,data,t2xy);
  canvas._cells=cells; canvas._hexR=hexR; canvas._t2xy=t2xy;

  ctx.strokeStyle=col.rule; ctx.lineWidth=0.5; ctx.globalAlpha=0.25;
  for (const c of cells) { if (!c.occupant) { hexPath(ctx,c.x,c.y,hexR*0.92); ctx.stroke(); } }
  ctx.globalAlpha=1;
  const drawR=hexR*0.92;
  for (const cell of cells) {
    if (!cell.occupant) continue;
    const u=cell.occupant;
    const img=avatarImages[u.handle];
    const hl=u.handle===highlightHandle||u.handle===lockedHandle;
    if (img&&img.complete&&img.naturalWidth>0) {
      ctx.save(); hexPath(ctx,cell.x,cell.y,drawR); ctx.clip();
      const is=drawR*2.2; ctx.drawImage(img,cell.x-is/2,cell.y-is/2,is,is); ctx.restore();
    } else {
      ctx.save(); hexPath(ctx,cell.x,cell.y,drawR); ctx.fillStyle=col.muted+'40'; ctx.fill(); ctx.restore();
    }
    hexPath(ctx,cell.x,cell.y,drawR);
    ctx.strokeStyle=hl?col.link:col.text+'60'; ctx.lineWidth=hl?2.5:0.8; ctx.stroke();
  }
}

// ── Post list ──────────────────────────────────────
function renderPostList(user) {
  const el=$('postList'); if (!user||!user.texts||!user.texts.length) { el.innerHTML=''; return; }
  $('resultsLayout').classList.remove('chart-only');
  const col=getColors();
  let html='<div class="section-header">posts Β· @'+escHtml(user.handle)+' <span style="font-weight:400">('+user.texts.length+')</span></div>';
  for (let i=0; i<user.texts.length; i++) {
    const ps=user.postScores&&user.postScores[i];
    let pcHtml='';
    if (ps) {
      const dom=ps.indexOf(Math.max(...ps));
      const pcKeys=['pc1','pc2','pc3'];
      pcHtml='<div class="post-pcs">'+ps.map((v,k)=>{
        const c=col[pcKeys[k]]; const w=dom===k?';font-weight:700':'';
        return '<span class="post-pc" style="color:'+c+';border:1px solid '+c+w+'">'+['PC₁','PCβ‚‚','PC₃'][k]+' '+v+'</span>';
      }).join('')+'</div>';
    }
    html+='<div class="post-row">'+pcHtml+'<div class="post-text">'+escHtml(user.texts[i])+'</div></div>';
  }
  el.innerHTML=html;
}

// ── Chart readout ──────────────────────────────────
function updateReadout(user) {
  const el=$('readout');
  if (!user) { el.innerHTML='hover a hex to inspect Β· click to expand'; return; }
  const imgTag=user.avatar?'<img class="readout-avatar" src="'+escHtml(user.avatar)+'" alt="">'
    :'<div class="readout-avatar" style="background:var(--rule)"></div>';
  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>'
    +'<div class="readout-scores">'
    +'<span class="readout-score" style="color:var(--pc1);border:1px solid var(--pc1)">PC₁ '+user.pc1+'</span>'
    +'<span class="readout-score" style="color:var(--pc2);border:1px solid var(--pc2)">PCβ‚‚ '+user.pc2+'</span>'
    +'<span class="readout-score" style="color:var(--pc3);border:1px solid var(--pc3)">PC₃ '+user.pc3+'</span>'
    +'</div></div>';
}

// ── Ternary interaction ────────────────────────────
function setupTernaryInteraction(canvas) {
  const tooltip=$('tooltip');
  function findHit(mx,my) {
    const cells=canvas._cells||[]; const r=(canvas._hexR||22)*0.92;
    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; }
    return null;
  }
  function redraw() { drawTernary(canvas,chartState.ternaryData,canvas._lastHl,chartState.pcTopTokens); }
  function handleHover(cx,cy) {
    const rect=canvas.getBoundingClientRect(); const mx=cx-rect.left,my=cy-rect.top;
    const hit=findHit(mx,my);
    if (!hit) {
      if (canvas._lastHl) { canvas._lastHl=null; redraw(); updateReadout(null); }
      hide(tooltip); return;
    }
    const r=hit.occupant;
    if (canvas._lastHl!==r.handle) { canvas._lastHl=r.handle; redraw(); updateReadout(r); }
    tooltip.textContent='@'+r.handle; show(tooltip);
    let tx=mx+14,ty=my-28; if(tx+180>rect.width)tx=mx-180; if(ty<0)ty=my+16;
    tooltip.style.left=tx+'px'; tooltip.style.top=ty+'px';
  }
  canvas.onmousemove=e=>handleHover(e.clientX,e.clientY);
  canvas.onmouseleave=()=>hide(tooltip);
  canvas.onclick=e=>{
    const rect=canvas.getBoundingClientRect();
    const hit=findHit(e.clientX-rect.left,e.clientY-rect.top);
    if (!hit||!hit.occupant) { lockedHandle=null; renderPostList(null); redraw(); return; }
    const r=hit.occupant;
    if (lockedHandle===r.handle) { lockedHandle=null; renderPostList(null); } else { lockedHandle=r.handle; renderPostList(r); }
    redraw();
  };
  let _lastTouch = null;
  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);}};
  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);}};
  canvas.ontouchend=e=>{
    e.preventDefault();
    if (!_lastTouch) return;
    const rect=canvas.getBoundingClientRect();
    const hit=findHit(_lastTouch.x-rect.left,_lastTouch.y-rect.top);
    _lastTouch=null;
    if (!hit||!hit.occupant) { lockedHandle=null; renderPostList(null); redraw(); return; }
    const r=hit.occupant;
    if (lockedHandle===r.handle) { lockedHandle=null; renderPostList(null); } else { lockedHandle=r.handle; renderPostList(r); }
    redraw();
    setTimeout(()=>$('postCol').scrollIntoView({behavior:'smooth',block:'start'}),50);
  };
}

// ── Avatar preloading ──────────────────────────────
function preloadAvatars(data) {
  return new Promise(resolve => {
    let rem=0;
    for (const u of data) {
      if (!u.avatar) continue; rem++;
      const img=new Image();
      img.onload=img.onerror=()=>{ if(--rem<=0)resolve(); };
      img.src=u.avatar; avatarImages[u.handle]=img;
    }
    if (!rem) resolve();
    setTimeout(resolve, 5000);
  });
}

// ── Three.js 3D view ───────────────────────────────
function makeCircleTex(size, color) {
  const c=document.createElement('canvas'); c.width=c.height=size;
  const ctx=c.getContext('2d');
  ctx.beginPath(); ctx.arc(size/2,size/2,size/2-1,0,Math.PI*2);
  ctx.fillStyle=color; ctx.fill();
  return new THREE.CanvasTexture(c);
}

function init3D(data3d, pcTopTokens) {
  const wrap=$('threeWrap'); wrap.innerHTML='';
  const W=wrap.clientWidth||600, H=W*7/8;
  const renderer=new THREE.WebGLRenderer({ antialias:true });
  renderer.setSize(W,H); renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setClearColor(0x0a0a0a);
  wrap.appendChild(renderer.domElement);
  const scene=new THREE.Scene();
  const camera=new THREE.PerspectiveCamera(55,W/H,0.01,100);
  camera.position.set(0,0,6);
  const controls=new OrbitControls(camera,renderer.domElement);
  controls.enableDamping=true; controls.dampingFactor=0.08;

  // Normalize axes to unit std-dev
  const N=data3d.length;
  const axes=[[],[],[]];
  for (const u of data3d) { axes[0].push(u.raw[0]); axes[1].push(u.raw[1]); axes[2].push(u.raw[2]); }
  const scaled=data3d.map(u=>axes.map((ax,k)=>{
    const mean=ax.reduce((a,b)=>a+b,0)/N;
    const std=Math.sqrt(ax.reduce((a,b)=>a+(b-mean)**2,0)/N)||1;
    return (u.raw[k]-mean)/std;
  }));

  // Axis lines
  const axColors=[0xd080b0,0x70b0d0,0x90c080];
  [[1,0,0],[0,1,0],[0,0,1]].forEach(([x,y,z],k)=>{
    const geo=new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0,0,0),new THREE.Vector3(x*2.5,y*2.5,z*2.5)]);
    scene.add(new THREE.Line(geo,new THREE.LineBasicMaterial({color:axColors[k],opacity:0.3,transparent:true})));
  });

  // HTML axis labels (positioned over canvas each frame)
  const axLabelEls=[];
  const toks=pcTopTokens||[[],[],[]];
  ['PC₁','PCβ‚‚','PC₃'].forEach((label,k)=>{
    const div=document.createElement('div');
    div.className='ax-label';
    div.style.color='#'+axColors[k].toString(16).padStart(6,'0');
    div.innerHTML='<b>'+label+'</b>'+(toks[k].length?'<br>'+toks[k].join(' Β· '):'');
    wrap.appendChild(div);
    axLabelEls.push(div);
  });
  const axTips=[new THREE.Vector3(2.6,0,0),new THREE.Vector3(0,2.6,0),new THREE.Vector3(0,0,2.6)];

  function updateAxLabels() {
    const rect=renderer.domElement.getBoundingClientRect();
    axTips.forEach((tip,k)=>{
      const v=tip.clone().project(camera);
      const x=(v.x+1)/2*rect.width, y=(1-v.y)/2*rect.height;
      axLabelEls[k].style.left=x+'px'; axLabelEls[k].style.top=y+'px';
    });
  }

  // Tooltip
  const tooltip=document.createElement('div');
  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';
  wrap.appendChild(tooltip);

  // Sprites β€” colored circles (bsky CDN doesn't support CORS, so WebGL avatar textures aren't possible)
  const sprites=[];
  data3d.forEach((u,i)=>{
    const [x,y,z]=scaled[i];
    const hue=(i*137.5)%360;
    const obj=new THREE.Sprite(new THREE.SpriteMaterial({ map:makeCircleTex(64,`hsl(${hue},65%,60%)`), transparent:true }));
    obj.scale.setScalar(0.22); obj.position.set(x,y,z); obj.userData={handle:u.handle};
    scene.add(obj); sprites.push(obj);
  });

  // Interaction
  const raycaster=new THREE.Raycaster(), mouse=new THREE.Vector2();
  function getHit(e) {
    const rect=renderer.domElement.getBoundingClientRect();
    mouse.x=((e.clientX-rect.left)/rect.width)*2-1;
    mouse.y=-((e.clientY-rect.top)/rect.height)*2+1;
    raycaster.setFromCamera(mouse,camera);
    const hits=raycaster.intersectObjects(sprites);
    return hits.length?hits[0].object:null;
  }
  renderer.domElement.addEventListener('mousemove', e=>{
    const hit=getHit(e);
    if (hit) {
      const rect=renderer.domElement.getBoundingClientRect();
      const u=chartState.ternaryData.find(u=>u.handle===hit.userData.handle);
      tooltip.textContent='@'+hit.userData.handle; tooltip.style.display='block';
      tooltip.style.left=(e.clientX-rect.left+12)+'px'; tooltip.style.top=(e.clientY-rect.top-24)+'px';
      updateReadout(u);
    } else { tooltip.style.display='none'; }
  });
  renderer.domElement.addEventListener('click', e=>{
    const hit=getHit(e);
    if (!hit) { lockedHandle=null; renderPostList(null); return; }
    const h=hit.userData.handle;
    const u=chartState.ternaryData.find(u=>u.handle===h);
    if (lockedHandle===h) { lockedHandle=null; renderPostList(null); } else { lockedHandle=h; renderPostList(u); }
  });

  let animId;
  function animate() { animId=requestAnimationFrame(animate); controls.update(); renderer.render(scene,camera); updateAxLabels(); }
  animate();
  threeObjs={ renderer, animId, controls };
}

// ── Label helpers ──────────────────────────────────
function applyLabels(labels) {
  if (!chartState) return;
  chartState.pcTopTokens = labels;
  const canvas = $('ternaryChart');
  drawTernary(canvas, chartState.ternaryData, canvas._lastHl, labels);
  if (threeObjs) {
    $('threeWrap').querySelectorAll('.ax-label').forEach((el, k) => {
      el.innerHTML = '<b>'+['PC₁','PCβ‚‚','PC₃'][k]+'</b>'+(labels[k].length?'<br>'+labels[k].join(' Β· '):'');
      el.onclick = () => showAxisPanel(k);
    });
  }
}

function applyStatusLine() {
  const labels = chartState ? chartState.pcTopTokens : [[],[],[]];
  const embedLabel = chartState?.statusPrefix
    ? chartState.statusPrefix
    : (($('embedderChoice')?.value === 'voyage') ? 'voyage-3-lite' : (useDenseEmbedder ? 'dense' : 'splade'))
      + ' Β· ' + (gpuDevice ? 'webgpu' : 'js') + ' gram';
  const prefix = escHtml(embedLabel);
  const pcColors = ['var(--pc1)','var(--pc2)','var(--pc3)'];
  const axParts = labels.map((t,i) =>
    `<span style="color:${pcColors[i]}">PC${i+1}: ${escHtml(t.length?t.join(' Β· '):'β€”')}</span>`
  ).join('&nbsp;&nbsp;&nbsp;');
  const el=$('status'); show(el);
  el.innerHTML = prefix+'&nbsp;&nbsp;&nbsp;'+axParts;
  el.classList.remove('error');
}

async function relabel() {
  if (!chartState) return;
  const labeler = $('labeler').value;
  if (labeler === 'none') {
    applyLabels(chartState.corpusLabels);
    applyStatusLine();
    return;
  }
  // Reconstruct from cached state
  const userData = chartState.ternaryData;          // has texts
  const projections = chartState.data3d.map(u=>u.raw);
  const corpus = chartState.corpusLabels;
  setStatus('labelling axes…');
  try {
    const llmResults = await labelAxes(userData, projections, corpus, msg=>setStatus(msg));
    applyLabels(corpus.map((toks,k) => llmResults[k]?.label ? [llmResults[k].label] : toks));
    chartState.axisDescriptions = llmResults.map(r => r?.description || '');
    applyStatusLine();
    renderAxisGrid();
  } catch(e) { setStatus('labeling failed: '+e.message, true); }
}

// ── Main run ───────────────────────────────────────
async function run() {
  const handles=getHandles(); if (handles.length<2) return;
  const btn=$('mapBtn'); btn.disabled=true;
  avatarImages={}; chartState=null; lockedHandle=null;
  $('postList').innerHTML='';
  const embedderChoice = $('embedderChoice').value;
  useDenseEmbedder = embedderChoice !== 'splade';
  if (embedderChoice === 'voyage') {
    vocabSize = 512;
    const key = ($('embedderInput').value || localStorage.getItem('voyage_api_key') || '').trim();
    if (!key) { setStatus('paste a voyage api key to use voyage-3-lite', true); btn.disabled=false; return; }
  }
  if (threeObjs) { cancelAnimationFrame(threeObjs.animId); threeObjs.renderer.dispose(); threeObjs=null; }

  setProgress(5); setStatus('fetching posts…');
  let userData;
  try {
    userData=await batchLoad(handles,6,(done,total)=>{
      setProgress(5+(done/total)*25); setStatus('fetching posts… '+done+'/'+total);
    });
  } catch(e) { setStatus('fetch error: '+e.message,true); btn.disabled=false; return; }
  userData=userData.filter(u=>u.texts&&u.texts.length>=2);
  const activeDaysVal=$('activeDays').value.trim();
  if (activeDaysVal) {
    const cutoff=new Date(Date.now()-Number(activeDaysVal)*86400000);
    userData=userData.filter(u=>u.lastPostAt&&u.lastPostAt>=cutoff);
  }
  if (userData.length<2) { setStatus('not enough posts retrieved',true); btn.disabled=false; return; }

  if (embedderChoice !== 'voyage') {
    setProgress(30); setStatus('loading model…');
    try { await loadModel(msg=>setStatus(msg)); }
    catch(e) { setStatus('model error: '+e.message,true); btn.disabled=false; return; }
  }

  setProgress(45); setStatus('computing embeddings…');
  const N=userData.length;
  const userMatrix=new Float32Array(N*vocabSize);
  let userVecs, allPostVecs;
  try {
    ({ userVecs, allPostVecs }=await embedAllUsers(userData, (done, total)=>{
      setStatus('embedding '+done+'/'+total+' posts…');
      setProgress(45+(done/total)*30);
    }));
  } catch(e) { setStatus('embedding error: '+e.message,true); btn.disabled=false; return; }
  for (let i=0;i<N;i++) userMatrix.set(userVecs[i], i*vocabSize);

  setProgress(78); setStatus('computing PCA'+(gpuDevice?' (WebGPU)':'')+'…');
  let pcaResult;
  try { await setupWebGPU(); pcaResult=await runPCA(userMatrix,N); }
  catch(e) { setStatus('PCA error: '+e.message,true); btn.disabled=false; return; }

  // PC directions in vocab space (for per-post projection)
  const Vk=computePCDirections(pcaResult.Xc,N,vocabSize,pcaResult.eigenvecs);

  setProgress(85); setStatus('labelling axes…');
  const pcTopTokens=extractCorpusLabels(userData, pcaResult.projections, 3);

  setProgress(88); setStatus('projecting posts…');
  const allPostScores=allPostVecs.map(postVecs=>
    postVecs.map(pv=>projectPost(pv,pcaResult.means,Vk))
  );

  setProgress(92); setStatus('preloading avatars…');
  await preloadAvatars(userData);

  const ternaryScores=toTernary(pcaResult.projections);
  const ternaryData=userData.map((u,i)=>({
    handle:u.handle, avatar:u.avatar, texts:u.texts,
    pc1:ternaryScores[i].pc1, pc2:ternaryScores[i].pc2, pc3:ternaryScores[i].pc3,
    postScores:allPostScores[i],
  }));
  const data3d=userData.map((u,i)=>({ handle:u.handle, avatar:u.avatar, raw:pcaResult.projections[i] }));

  chartState = { ternaryData, data3d, pcTopTokens, corpusLabels: pcTopTokens, axisDescriptions: [] };

  setProgress(100);
  show($('results'));
  $('resultsLayout').classList.add('chart-only');
  const canvas=$('ternaryChart');
  drawTernary(canvas,ternaryData,null,pcTopTokens);
  setupTernaryInteraction(canvas);
  if (viewMode==='3d') requestAnimationFrame(()=>init3D(data3d,pcTopTokens));
  window.addEventListener('resize',()=>{
    if (viewMode==='ternary'&&chartState) drawTernary(canvas,chartState.ternaryData,canvas._lastHl,chartState.pcTopTokens);
  });
  btn.disabled=false;

  applyStatusLine();
  renderAxisGrid();
  if ($('labeler').value !== 'none') relabel();
}

function renderAxisGrid() {
  const grid = $('axisGrid');
  if (!grid || !chartState) return;
  const pcNames = ['PC₁','PCβ‚‚','PC₃'];
  const colors = ['var(--pc1)','var(--pc2)','var(--pc3)'];
  grid.innerHTML = pcNames.map((name, k) => {
    const labels = chartState.pcTopTokens[k] || [];
    const desc = (chartState.axisDescriptions || [])[k] || '';
    const joined = labels.join(' Β· ');
    const poles = joined.includes('↔') ? joined.split('↔').map(s => s.trim()) : null;
    const allPosts = [];
    for (const u of chartState.ternaryData) {
      for (let j = 0; j < (u.texts||[]).length; j++) {
        if (u.texts[j] && u.postScores?.[j]) {
          allPosts.push({ handle: u.handle, text: u.texts[j], score: u.postScores[j][k] });
        }
      }
    }
    allPosts.sort((a,b) => b.score - a.score);
    const postRow = p => `<div class="axis-post"><span class="axis-post-handle">@${escHtml(p.handle)}</span>${escHtml(p.text)}</div>`;
    let postsHtml;
    if (poles) {
      const [highName, lowName] = poles;
      postsHtml =
        `<div class="axis-pole-header">←── ${escHtml(highName)}</div>` +
        allPosts.slice(0, 5).map(postRow).join('') +
        `<div class="axis-pole-header">${escHtml(lowName)} ──→</div>` +
        allPosts.slice(-5).reverse().map(postRow).join('');
    } else {
      postsHtml = allPosts.slice(0, 10).map(postRow).join('');
    }
    return `<div class="axis-col">
      <div class="axis-col-header" style="color:${colors[k]}">${name} β€” ${escHtml(joined || 'β€”')}</div>
      ${desc ? `<div class="axis-col-desc">${escHtml(desc)}</div>` : ''}
      ${postsHtml}
    </div>`;
  }).join('');
}

async function exportPage() {
  if (!chartState) return;
  try {
    const src = await fetch(location.href).then(r => r.text());
    const embedChoice = $('embedderChoice')?.value;
    const embedLabel = embedChoice === 'voyage' ? 'voyage-3-lite' : (useDenseEmbedder ? 'dense' : 'splade');
    const payload = JSON.stringify({
      ternaryData: chartState.ternaryData,
      data3d: chartState.data3d,
      pcTopTokens: chartState.pcTopTokens,
      axisDescriptions: chartState.axisDescriptions || [],
      statusPrefix: embedLabel + ' Β· ' + (gpuDevice ? 'webgpu' : 'js') + ' gram',
    });
    const out = src.replace('let BAKED_DATA = null;', `let BAKED_DATA = ${payload};`);
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([out], { type: 'text/html' }));
    a.download = 'splade-export-' + new Date().toISOString().slice(0,10) + '.html';
    a.click();
  } catch(e) { alert('export failed: ' + e.message); }
}

async function showBakedResults(data) {
  ['inputTabs','pastePanel','listPanel','embedderExtra','labelerExtra'].forEach(id => {
    const el = $(id); if (el) hide(el);
  });
  document.querySelectorAll('.labeler-row,.action-row').forEach(el => hide(el));
  document.querySelector('.desc').innerHTML =
    'Hover over dots to identify users; click one to see their posts. ' +
    'Axes with their top posts appear below the chart.';
  document.querySelector('.desc-small').innerHTML =
    'Each user’s recent posts were embedded into high-dimensional vector space, ' +
    'then PCA extracted three orthogonal axes of maximum variance across the corpus. ' +
    'An LLM read the top posts along each axis and inferred what each one is actually about.';

  chartState = {
    ternaryData: data.ternaryData,
    data3d: data.data3d,
    pcTopTokens: data.pcTopTokens,
    corpusLabels: data.pcTopTokens,
    axisDescriptions: data.axisDescriptions || [],
    statusPrefix: data.statusPrefix || null,
  };

  show($('results'));
  $('resultsLayout').classList.add('chart-only');
  await preloadAvatars(data.ternaryData);
  const canvas = $('ternaryChart');
  drawTernary(canvas, data.ternaryData, null, data.pcTopTokens);
  setupTernaryInteraction(canvas);
  window.addEventListener('resize', () => {
    if (viewMode === 'ternary' && chartState)
      drawTernary(canvas, chartState.ternaryData, canvas._lastHl, chartState.pcTopTokens);
  });
  applyStatusLine();
  renderAxisGrid();
}

// ── View toggle ────────────────────────────────────
$('viewTernaryBtn').addEventListener('click',()=>{
  if (viewMode==='ternary') return;
  viewMode='ternary';
  $('viewTernaryBtn').classList.add('active'); $('view3dBtn').classList.remove('active');
  show($('ternaryWrap')); hide($('threeWrap'));
});
$('view3dBtn').addEventListener('click',()=>{
  if (viewMode==='3d') return;
  viewMode='3d';
  $('view3dBtn').classList.add('active'); $('viewTernaryBtn').classList.remove('active');
  hide($('ternaryWrap')); show($('threeWrap'));
  if (chartState&&!threeObjs) requestAnimationFrame(()=>init3D(chartState.data3d,chartState.pcTopTokens));
});

// ── Input UI ───────────────────────────────────────
document.querySelectorAll('#inputTabs .tab').forEach(tab=>{
  tab.addEventListener('click',()=>{
    document.querySelectorAll('#inputTabs .tab').forEach(t=>t.classList.toggle('active',t===tab));
    if (tab.dataset.tab==='paste') { show($('pastePanel')); hide($('listPanel')); }
    else { hide($('pastePanel')); show($('listPanel')); }
  });
});
$('loadListBtn').addEventListener('click',async()=>{
  const url=$('listUrl').value.trim(); if (!url) return;
  const parsed=parseListUrl(url); if (!parsed) { setStatus('could not parse list URL',true); return; }
  const btn=$('loadListBtn'); btn.disabled=true; btn.textContent='loading…';
  setStatus('fetching list members…'); setProgress(20);
  try {
    const handles=await fetchListMembers(parsed.actor,parsed.rkey);
    if (!handles.length) { setStatus('empty list',true); setProgress(0); return; }
    $('handleList').value=handles.join('\n');
    show($('pastePanel')); hide($('listPanel'));
    document.querySelectorAll('#inputTabs .tab').forEach(t=>t.classList.toggle('active',t.dataset.tab==='paste'));
    updateCount(); setStatus(handles.length+' members loaded'); setProgress(100);
  } catch(e) { setStatus('error: '+e.message,true); setProgress(0); }
  finally { btn.disabled=false; btn.textContent='load list'; }
});
$('handleList').addEventListener('input',updateCount);
$('mapBtn').addEventListener('click',run);
updateCount();

// Labeler UI
const LABELER_CONFIG = {
  none:      { show: false },
  browser:   { show: false },
  anthropic: { show: true,  placeholder: 'anthropic api key',          hint: 'key is only stored in localStorage', type: 'password', storageKey: 'splade_api_key' },
  llama:     { show: true,  placeholder: 'port (default 8080)',            hint: 'llama-server --model model.gguf --port 8080 --jinja', type: 'text', storageKey: 'splade_llama_port' },
};
function updateLabelerUI() {
  const cfg = LABELER_CONFIG[$('labeler').value];
  const extra = $('labelerExtra');
  const input = $('labelerInput');
  const hint  = $('labelerHint');
  if (cfg.show) {
    show(extra);
    input.type = cfg.type || 'text';
    input.placeholder = cfg.placeholder || '';
    hint.textContent = cfg.hint || '';
    const saved = cfg.storageKey && localStorage.getItem(cfg.storageKey);
    if (saved && !input.value) input.value = saved;
  } else {
    hide(extra);
  }
}
function updateEmbedderUI() {
  const isVoyage = $('embedderChoice').value === 'voyage';
  isVoyage ? show($('embedderExtra')) : hide($('embedderExtra'));
  if (isVoyage) {
    const saved = localStorage.getItem('voyage_api_key');
    if (saved && !$('embedderInput').value) $('embedderInput').value = saved;
  }
}
$('embedderChoice').addEventListener('change', updateEmbedderUI);
$('embedderInput').addEventListener('change', () => {
  const v = $('embedderInput').value.trim();
  if (v) localStorage.setItem('voyage_api_key', v);
});
updateEmbedderUI();
$('labeler').addEventListener('change', () => { updateLabelerUI(); if (chartState) relabel(); });
$('labelerInput').addEventListener('keydown', e => { if (e.key==='Enter' && chartState) relabel(); });
updateLabelerUI();
$('exportBtn').addEventListener('click', exportPage);
$('collapseBtn').addEventListener('click', () => $('resultsLayout').classList.add('chart-only'));
if (BAKED_DATA) showBakedResults(BAKED_DATA);
</script>
</body>
</html>