<!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 alice.bsky.social 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
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(' ');
const el=$('status'); show(el);
el.innerHTML = prefix+' '+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>