explorer-twitter / index.html
truevislies's picture
Upload folder using huggingface_hub
5289521 verified
Raw
History Blame Contribute Delete
87 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TrueVisLies β€” Embedding Explorer</title>
<link href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=" rel="icon" type="image/x-icon" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
/* ── Design tokens β€” dark (default) ── */
:root {
--bg: #0d0f14;
--bg2: #13161d;
--bg3: #1a1e28;
--border: #252a38;
--text: #c8cdd8;
--text-dim: #5a6070;
--text-hi: #eef0f4;
--accent: #4f8ef7;
--accent2: #e05c5c;
--accent3: #52c994;
--yellow: #f0c040;
--legend-bg: rgba(13,15,20,0.88);
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
--radius: 4px;
}
/* ── Light theme overrides ── */
body.light {
--bg: #f5f6f8;
--bg2: #eceef2;
--bg3: #e2e5eb;
--border: #cdd1da;
--text: #1e2230;
--text-dim: #7a8099;
--text-hi: #0a0c12;
--accent: #2563eb;
--accent2: #c0392b;
--accent3: #1a7a4a;
--yellow: #b45309;
--legend-bg: rgba(245,246,248,0.92);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 13px; overflow: hidden; height: 100vh; display: flex; flex-direction: column; transition: background 0.2s, color 0.2s; }
/* ── Top bar ── */
#topbar { display: flex; align-items: center; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0; }
#topbar h1 { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent); letter-spacing: 0.05em; white-space: nowrap; }
#topbar h1 span { color: var(--text-dim); }
.sep { width: 1px; height: 18px; background: var(--border); }
.ctrl-group { display: flex; align-items: center; gap: 6px; }
.ctrl-label { font-family: var(--mono); font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap; }
select, input[type=text] { background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: 11px; padding: 4px 8px; border-radius: var(--radius); outline: none; cursor: pointer; }
select:hover, input[type=text]:hover { border-color: var(--accent); }
select:focus, input[type=text]:focus { border-color: var(--accent); }
option { background: var(--bg2); }
#status { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--text-dim); }
#status.loading { color: var(--yellow); }
#status.error { color: var(--accent2); }
#status.ready { color: var(--accent3); }
/* ── Main layout ── */
#main { display: flex; flex: 1; overflow: hidden; }
/* ── Left panel: scatterplot ── */
#left-panel { display: flex; flex-direction: column; flex: 0 0 50vw; border-right: 1px solid var(--border); }
#plot-header { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; background: var(--bg2); }
#plot-header .title { font-family: var(--mono); font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; }
#plot-controls { display: flex; gap: 6px; align-items: center; }
#plot-wrap { flex: 1; position: relative; overflow: hidden; }
#scatter-svg { width: 100%; height: 100%; }
/* Brush */
.brush .selection { fill: var(--accent); fill-opacity: 0.1; stroke: var(--accent); stroke-width: 1px; }
/* Dots */
.dot { transition: r 0.1s; cursor: pointer; }
.dot:hover { r: 5; }
.dot.selected { stroke: var(--text-hi); stroke-width: 1.5px; }
.dot.faded { opacity: 0.08; }
/* Legend */
#legend { position: absolute; bottom: 10px; left: 10px; background: var(--legend-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; font-family: var(--mono); font-size: 10px; }
#legend .legend-row { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
#legend .legend-row:last-child { margin-bottom: 0; }
#legend .swatch { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
/* Selection count badge */
#sel-badge { position: absolute; top: 10px; right: 10px; background: var(--accent); color: white; font-family: var(--mono); font-size: 10px; padding: 3px 7px; border-radius: 10px; display: none; }
/* ── Middle panel: items list ── */
#mid-panel { display: flex; flex-direction: column; flex: 0 0 280px; border-right: 1px solid var(--border); }
#mid-header { padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); display: flex; align-items: center; justify-content: space-between; }
#mid-header .title { font-family: var(--mono); font-size: 10px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; }
#mid-header .count { font-family: var(--mono); font-size: 10px; color: var(--accent); }
#item-list { flex: 1; overflow-y: auto; }
.item-row { padding: 8px 12px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; flex-direction: column; gap: 3px; transition: background 0.1s; }
.item-row:hover { background: var(--bg3); }
.item-row.active { background: var(--bg3); border-left: 2px solid var(--accent); padding-left: 10px; }
.item-row .item-id { font-family: var(--mono); font-size: 10px; color: var(--accent); }
.item-row .item-tag { display: inline-block; font-family: var(--mono); font-size: 9px; padding: 1px 5px; border-radius: 3px; }
.item-row .item-tag.misleading { background: rgba(224,92,92,0.2); color: var(--accent2); }
.item-row .item-tag.not-misleading { background: rgba(82,201,148,0.15); color: var(--accent3); }
.item-row .item-preview { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-row .item-mean { font-family: var(--mono); font-size: 9px; color: var(--text-dim); }
/* Analyze button */
#analyse-btn { margin: 10px 12px; padding: 7px 12px; background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: 10px; border-radius: var(--radius); cursor: pointer; text-align: center; letter-spacing: 0.05em; transition: border-color 0.15s, color 0.15s; }
#analyse-btn:hover { border-color: var(--accent); color: var(--accent); }
#analyse-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Right panel: detail ── */
#right-panel { display: flex; flex-direction: column; flex: 1; min-width: 0; }
#detail-tabs { display: flex; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0; }
.tab { padding: 8px 14px; font-family: var(--mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); cursor: pointer; border-bottom: 2px solid transparent; transition: color 0.15s; white-space: nowrap; }
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
#detail-content { flex: 1; overflow-y: auto; padding: 14px; }
/* Detail: image + caption */
#pane-image { display: flex; gap: 14px; }
#tweet-img { max-width: 280px; max-height: 240px; object-fit: contain; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg3); }
#tweet-img.missing { display: flex; align-items: center; justify-content: center; width: 200px; height: 140px; color: var(--text-dim); font-family: var(--mono); font-size: 10px; }
#caption-box { flex: 1; }
#caption-text { font-size: 12px; line-height: 1.6; color: var(--text); background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; max-height: 200px; overflow-y: auto; }
.detail-label { font-family: var(--mono); font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-dim); margin-bottom: 5px; }
/* Detail: agreement heatmap */
#heatmap-wrap { overflow-x: auto; }
#heatmap-svg text { font-family: var(--mono); font-size: 8px; fill: var(--text-dim); }
/* Detail: model responses */
#responses-list { display: flex; flex-direction: column; gap: 10px; }
.response-card { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; }
.response-card .model-name { font-family: var(--mono); font-size: 10px; color: var(--accent); margin-bottom: 6px; }
.response-card .verdict { display: inline-block; font-family: var(--mono); font-size: 9px; padding: 1px 6px; border-radius: 3px; margin-bottom: 6px; }
.response-card .verdict.true { background: rgba(224,92,92,0.2); color: var(--accent2); }
.response-card .verdict.false { background: rgba(82,201,148,0.15); color: var(--accent3); }
.response-card .field-label { font-family: var(--mono); font-size: 9px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 6px; margin-bottom: 3px; }
.response-card .field-text { font-size: 11px; line-height: 1.55; color: var(--text); }
.response-card .scores-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
.score-chip { font-family: var(--mono); font-size: 9px; padding: 1px 5px; background: var(--bg2); border: 1px solid var(--border); border-radius: 3px; }
/* Detail: analysis output */
#analysis-output { background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; font-size: 12px; line-height: 1.65; color: var(--text); white-space: pre-wrap; min-height: 120px; }
#analysis-output.placeholder { color: var(--text-dim); font-style: italic; }
#analysis-output.loading { color: var(--yellow); }
#analysis-settings { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
.settings-row { display: flex; gap: 8px; align-items: center; }
.settings-row label { font-family: var(--mono); font-size: 10px; color: var(--text-dim); width: 90px; flex-shrink: 0; }
.settings-row input[type=text], .settings-row select { flex: 1; }
.settings-row input[type=password] { flex: 1; background: var(--bg3); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: 11px; padding: 4px 8px; border-radius: var(--radius); outline: none; }
#run-analysis-btn { padding: 7px 14px; background: var(--accent); border: none; color: white; font-family: var(--mono); font-size: 11px; border-radius: var(--radius); cursor: pointer; letter-spacing: 0.04em; transition: opacity 0.15s; }
#run-analysis-btn:hover { opacity: 0.85; }
#run-analysis-btn:disabled { opacity: 0.4; cursor: default; }
/* ID search input */
#id-search-input { flex: 1; }
#id-search-btn:hover { opacity: 0.85; }
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* Empty state */
.empty-state { color: var(--text-dim); font-family: var(--mono); font-size: 11px; padding: 24px 12px; text-align: center; }
/* Color-by model legend scroll */
#legend { max-height: 180px; overflow-y: auto; }
</style>
</head>
<body>
<!-- ── Top bar ── -->
<div id="topbar">
<h1>TrueVisLies <span>/ Embedding Explorer</span></h1>
<div class="sep"></div>
<div class="ctrl-group">
<span class="ctrl-label">Experiment</span>
<select id="sel-experiment">
<option value="E1A">1A β€” Rhetoric / Free</option>
<option value="E1B">1B β€” Rhetoric / Forced</option>
<option value="E1C">1C β€” Rhetoric / Forced+</option>
<option value="E2A">2A β€” Intent / Free</option>
<option value="E2B">2B β€” Intent / Forced</option>
<option value="E2C">2C β€” Intent / Forced+</option>
</select>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Key</span>
<select id="sel-key">
<optgroup label="Analysis">
<option value="a___analysis">analysis</option>
<option value="a___whymis">why misleading</option>
<option value="a___analysis_whymis">analysis + why misleading</option>
<option value="a___full_response">full response</option>
<option value="a___behavior_signature">behavior signature</option>
</optgroup>
<optgroup label="Rhetoric">
<option value="r___all">rhetoric (all)</option>
<option value="r___information_access_rhetoric">info access</option>
<option value="r___mapping_rhetoric">mapping</option>
<option value="r___linguistic_based_rhetoric">linguistic</option>
<option value="r___provenance_rhetoric">provenance</option>
<option value="r___procedural_rhetoric">procedural</option>
</optgroup>
<optgroup label="Intent">
<option value="i___all">intent (all)</option>
<option value="i___aesthetic_driven_misrepresentation">aesthetic misrep.</option>
<option value="i___bias_exploitation">bias exploitation</option>
<option value="i___claim_supporting_manipulation">claim manipulation</option>
<option value="i___context_distortion">context distortion</option>
<option value="i___deliberate_reader_confusion">reader confusion</option>
<option value="i___lack_of_visualization_literacy">viz literacy</option>
<option value="i___selective_reporting">selective reporting</option>
<option value="i___space_and_format_constraints">space constraints</option>
<option value="i___unintentional_context_omission">uninten. omission</option>
</optgroup>
<optgroup label="Extracted Spans">
<option value="e___causal_reasoning">causal reasoning</option>
<option value="e___evidence">evidence</option>
<option value="e___interpretive_conclusion">conclusion</option>
<option value="e___uncertainty">uncertainty</option>
<option value="e___visual_focus">visual focus</option>
<option value="e___caption_reasoning">caption reasoning</option>
<option value="e___data_claim_gap">data claim gap</option>
<option value="e___normative_baseline">normative baseline</option>
<option value="e___intent_attribution">intent attribution</option>
<option value="e___viewer_impact">viewer impact</option>
</optgroup>
</select>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Color by</span>
<select id="sel-color">
<option value="misleading">Misleading</option>
<option value="mean_sim">Mean agreement</option>
<option value="model">Model</option>
</select>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Filter</span>
<select id="sel-filter">
<option value="all">All items</option>
<option value="misleading">Misleading only</option>
<option value="not_misleading">Not misleading only</option>
</select>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Plot mode</span>
<select id="sel-plotmode">
<option value="B" title="One point per (item, model) β€” exact brush, composite key">B Β· per response</option>
<option value="A" title="One point per (item, model) β€” brush selects all model-dots for matched items">A Β· by item (all models)</option>
<option value="C" title="One point per image β€” x/y averaged across models">C Β· per image (avg)</option>
</select>
</div>
<button id="btn-theme" style="
margin-left:4px;
background:none;
border:1px solid var(--border);
color:var(--text-dim);
font-family:var(--mono);
font-size:9px;
padding:3px 9px;
border-radius:var(--radius);
cursor:pointer;
transition:border-color 0.15s,color 0.15s;
white-space:nowrap;
" title="Toggle light/dark theme">☾ Dark</button>
<span id="status" class="loading">Initializing DuckDB…</span>
</div>
<!-- ── Main ── -->
<div id="main">
<!-- Left: scatterplot -->
<div id="left-panel">
<div id="plot-header">
<span class="title">UMAP Β· Agreement space</span>
<div id="plot-controls">
<button id="btn-reset" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--mono);font-size:9px;padding:3px 7px;border-radius:var(--radius);cursor:pointer;">reset</button>
<button id="btn-clear" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--mono);font-size:9px;padding:3px 7px;border-radius:var(--radius);cursor:pointer;">clear sel.</button>
</div>
</div>
<div id="plot-wrap">
<svg id="scatter-svg"></svg>
<div id="legend"></div>
<div id="sel-badge">0 selected</div>
</div>
</div>
<!-- Middle: item list -->
<div id="mid-panel">
<div id="mid-header">
<span class="title">Items</span>
<span id="item-count" class="count">β€”</span>
</div>
<!-- ID search / select -->
<div id="id-search-box" style="
padding: 6px 10px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 5px;
">
<div style="display:flex;gap:5px;align-items:center;">
<input
id="id-search-input"
type="text"
placeholder="item id, or id1, id2, id3 …"
style="flex:1;font-size:10px;padding:3px 6px;"
/>
<button id="id-search-btn" style="
background: var(--accent);
border: none;
color: #fff;
font-family: var(--mono);
font-size: 9px;
padding: 4px 8px;
border-radius: var(--radius);
cursor: pointer;
white-space: nowrap;
">Select</button>
</div>
<div id="id-search-msg" style="
font-family: var(--mono);
font-size: 9px;
color: var(--text-dim);
min-height: 12px;
"></div>
</div>
<button id="analyse-btn" disabled>✦ Analyze selection with LLM</button>
<div id="item-list"><div class="empty-state">Load data to see items</div></div>
</div>
<!-- Right: detail -->
<div id="right-panel">
<div id="detail-tabs">
<div class="tab active" data-pane="image">Image & Caption</div>
<div class="tab" data-pane="heatmap">Agreement & Scores</div>
<div class="tab" data-pane="responses">Model Responses</div>
<div class="tab" data-pane="analysis">LLM Analysis</div>
</div>
<div id="detail-content">
<div class="empty-state" style="margin-top:40px;">Select an item to inspect</div>
</div>
</div>
</div>
<script type="module">
// ═══════════════════════════════════════════════════════
// CONFIG β€” adjust paths to match your server layout
// ═══════════════════════════════════════════════════════
// https://huggingface.co/datasets/<namespace>/<dataset_name>/resolve/main/<path_in_repo>
const DATASET = 'twitter';
const CONFIG = {
dataset: DATASET,
datasetBase: `dataset`,
resultsBase: `results`,
umapBase: `results/umap`,
imagesBase: `dataset/images`,
dataBase: `dataset`,
responsesBase: `results/responses`,
};
// Resolve a relative path to an absolute URL based on the current page origin.
// DuckDB-WASM requires absolute URLs for read_parquet() over HTTP.
function absUrl(relativePath) {
return new URL(relativePath, window.location.origin + '/').href;
}
// ═══════════════════════════════════════════════════════
// DuckDB bootstrap
// ═══════════════════════════════════════════════════════
import * as duckdb from "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@1.29.0/+esm";
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
let db, conn;
async function initDuckDB() {
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
const worker_url = URL.createObjectURL(
new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' })
);
const worker = new Worker(worker_url);
const logger = new duckdb.ConsoleLogger();
db = new duckdb.AsyncDuckDB(logger, worker);
await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
conn = await db.connect();
await conn.query(`SET enable_http_metadata_cache=true; SET enable_object_cache=true;`);
}
// ═══════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════
let state = {
experiment: 'E1A',
key: 'a___whymis',
colorBy: 'misleading',
filter: 'all',
plotMode: 'A', // A=by-item(all dots), B=by-response(composite key), C=per-tweet(avg)
umapData: [], // raw: [{item_id, model, x, y}]
plotData: [], // derived from umapData according to plotMode
cs1Data: [], // [{item_id, model_a, model_b, cosine_sim}]
itemSummary: new Map(), // item_id β†’ {mean_sim, min_sim, max_sim, is_misleading}
models: [],
// Mode B: selectedKeys is a Set of "item_id::model" composite strings
// Mode A/C: selectedIds is a Set of item_id strings
selectedIds: new Set(), // used in modes A and C
selectedKeys: new Set(), // used in mode B
activeItemId: null,
activeTab: 'image',
responseCache: new Map(),
metadataCache: null, // loaded once from metadata.json
};
// ═══════════════════════════════════════════════════════
// Status helper
// ═══════════════════════════════════════════════════════
function setStatus(msg, cls='') {
const el = document.getElementById('status');
el.textContent = msg;
el.className = cls;
}
// ═══════════════════════════════════════════════════════
// Data loading via DuckDB
// ═══════════════════════════════════════════════════════
function getUmapPath(key, experiment) {
const base = CONFIG.resultsBase;
return absUrl(`${base}/umap/${key}/${experiment}.parquet`);
}
function getCs1Path(key, experiment) {
const base = CONFIG.resultsBase
return absUrl(`${base}/similarity/model_agreement/${key}/${experiment}.parquet`);
}
async function loadData() {
const { experiment, key } = state;
setStatus('Loading…', 'loading');
const umapPath = getUmapPath(key, experiment);
const cs1Path = getCs1Path(key, experiment);
try {
// Load UMAP
const umapRes = await conn.query(`SELECT * FROM read_parquet('${umapPath}')`);
state.umapData = umapRes.toArray().map(r => ({
item_id: String(r.item_id ?? r.image_id),
model: r.model ? String(r.model) : null,
x: Number(r.x),
y: Number(r.y),
})).filter(r => !isNaN(r.x) && !isNaN(r.y));
// Load CS1
const cs1Res = await conn.query(`SELECT * FROM read_parquet('${cs1Path}')`);
state.cs1Data = cs1Res.toArray().map(r => ({
item_id: String(r.item_id ?? r.image_id),
model_a: String(r.model_a),
model_b: String(r.model_b),
cosine_sim: Number(r.cosine_sim),
}));
// Extract unique models
const mset = new Set();
state.cs1Data.forEach(r => { mset.add(r.model_a); mset.add(r.model_b); });
state.models = Array.from(mset).sort();
// Build per-item summary from CS1
buildItemSummary();
clearSelection();
state.responseCache.clear();
buildPlotData();
setStatus(`${state.plotData.length} pts Β· ${state.models.length} models Β· mode ${state.plotMode}`, 'ready');
renderPlot();
renderItemList(Array.from(state.itemSummary.keys()));
// Load ground truth in the background β€” re-render once done
loadGroundTruth().then(() => {
renderPlot();
renderItemList(Array.from(
selectedItemIds().size > 0 ? selectedItemIds() : state.itemSummary.keys()
));
});
} catch(e) {
setStatus(`Error: ${e.message}`, 'error');
console.error(e);
}
}
// ═══════════════════════════════════════════════════════
// Ground truth loading
// ═══════════════════════════════════════════════════════
// Uses DuckDB to bulk-read all item JSON files in one query via glob,
// extracting item_id (from filename) and the "misleading" boolean field.
// Falls back to per-file fetch if glob is not supported by the server.
async function loadGroundTruth() {
const allIds = Array.from(state.itemSummary.keys());
if (allIds.length === 0) return;
let loaded = 0;
// Strategy 1: read index.csv via DuckDB
try {
const csvUrl = absUrl(`${CONFIG.dataBase}/index.csv`);
const res = await conn.query(`SELECT image_id, is_misleading FROM read_csv_auto('${csvUrl}')`);
res.toArray().forEach(r => {
const itemId = String(r.image_id);
const isMis = Boolean(Number(r.is_misleading));
if (state.itemSummary.has(itemId)) {
state.itemSummary.get(itemId).is_misleading = isMis;
loaded++;
}
});
if (loaded > 0) {
setStatus(`${state.plotData.length} pts Β· ${state.models.length} models Β· ${loaded} labels loaded`, 'ready');
return;
}
} catch(e) {
console.warn('DuckDB CSV failed, falling back to fetch:', e.message);
}
// Strategy 2: fetch index.csv directly
try {
const resp = await fetch(absUrl(`${CONFIG.dataBase}/index.csv`));
if (resp.ok) {
const text = await resp.text();
const lines = text.trim().split('\n');
const header = lines[0].split(',');
const idCol = header.indexOf('image_id');
const misCol = header.indexOf('is_misleading');
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',');
const itemId = cols[idCol];
const isMis = cols[misCol] === '1' || cols[misCol]?.toLowerCase() === 'true';
if (state.itemSummary.has(itemId)) {
state.itemSummary.get(itemId).is_misleading = isMis;
loaded++;
}
}
}
} catch(e) {
console.warn('CSV fetch also failed:', e.message);
}
setStatus(`${state.plotData.length} pts Β· ${state.models.length} models Β· ${loaded} labels loaded`, 'ready');
}
function buildItemSummary() {
const agg = new Map();
state.cs1Data.forEach(r => {
if (!agg.has(r.item_id)) agg.set(r.item_id, []);
agg.get(r.item_id).push(r.cosine_sim);
});
state.itemSummary.clear();
agg.forEach((sims, id) => {
let sum = 0, lo = Infinity, hi = -Infinity;
for (let i = 0; i < sims.length; i++) {
const v = sims[i];
sum += v;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
state.itemSummary.set(id, {
mean_sim: sum / sims.length,
min_sim: lo,
max_sim: hi,
is_misleading: null,
});
});
}
// ── Derive plotData from umapData according to the current plotMode ──────────
// Mode A: raw rows (item_id, model, x, y) β€” one dot per (item Γ— model)
// selection key = item_id β†’ all model-dots for a tweet highlight together
// Mode B: raw rows (item_id, model, x, y) β€” one dot per (item Γ— model)
// selection key = "item_id::model" β†’ only the exact brushed dots highlight
// Mode C: one dot per item_id β€” x/y averaged across models
function buildPlotData() {
const { umapData, plotMode } = state;
if (plotMode === 'A' || plotMode === 'B') {
state.plotData = umapData;
} else {
// Mode C: average x/y per item_id
const agg = new Map();
umapData.forEach(d => {
if (!agg.has(d.item_id)) agg.set(d.item_id, { xs: [], ys: [], model: d.model });
agg.get(d.item_id).xs.push(d.x);
agg.get(d.item_id).ys.push(d.y);
});
state.plotData = Array.from(agg.entries()).map(([item_id, v]) => ({
item_id,
model: null,
x: v.xs.reduce((a,b)=>a+b,0)/v.xs.length,
y: v.ys.reduce((a,b)=>a+b,0)/v.ys.length,
}));
}
}
// Helpers for mode-aware selection key
function dotKey(d) {
return state.plotMode === 'B' ? `${d.item_id}::${d.model}` : d.item_id;
}
function isSelected(d) {
return state.plotMode === 'B'
? state.selectedKeys.has(dotKey(d))
: state.selectedIds.has(d.item_id);
}
function clearSelection() {
state.selectedIds.clear();
state.selectedKeys.clear();
}
function addToSelection(d) {
if (state.plotMode === 'B') state.selectedKeys.add(dotKey(d));
else state.selectedIds.add(d.item_id);
}
// Return the set of unique item_ids currently selected (for item list / cluster analysis)
function selectedItemIds() {
if (state.plotMode === 'B') {
return new Set(Array.from(state.selectedKeys).map(k => k.split('::')[0]));
}
return state.selectedIds;
}
// ═══════════════════════════════════════════════════════
// Load metadata.json once and cache it
async function ensureMetadata() {
if (state.metadataCache) return state.metadataCache;
try {
const r = await fetch(absUrl(`${CONFIG.dataBase}/metadata.json`));
if (!r.ok) return {};
state.metadataCache = await r.json();
return state.metadataCache;
} catch { state.metadataCache = {}; return {}; }
}
// Load item caption from cached metadata
async function loadCaption(itemId) {
const meta = await ensureMetadata();
const entry = meta[itemId];
if (!entry) return null;
return entry.caption || entry.text || null;
}
// ═══════════════════════════════════════════════════════
// Load model responses for an item from experiment parquet
// ═══════════════════════════════════════════════════════
async function loadResponses(itemId) {
if (state.responseCache.has(itemId)) return state.responseCache.get(itemId);
const { experiment } = state;
const result = {};
try {
const path = absUrl(`${CONFIG.responsesBase}/${experiment}.parquet`);
const res = await conn.query(
`SELECT * FROM read_parquet('${path}') WHERE CAST(image_id AS VARCHAR) = '${itemId}'`
);
const schema = res.schema.fields.map(f => f.name);
const rows = res.toArray();
for (const r of rows) {
const model = String(r.model);
// Convert Arrow proxy to plain object (handles pipe chars in column names)
const obj = {};
for (const col of schema) obj[col] = r[col];
// Reconstruct nested taxonomy objects from flat pipe-delimited columns
// e.g. "r|mapping_rhetoric|score" β†’ visualization_rhetoric.mapping_rhetoric.misleading_contribution_score
// e.g. "i|bias_exploitation|why" β†’ author_intents.bias_exploitation.why
const rhetoric = {}, intents = {};
for (const col of schema) {
const parts = col.split('|');
if (parts.length !== 3) continue;
const [prefix, cat, field] = parts;
const target = prefix === 'r' ? rhetoric : prefix === 'i' ? intents : null;
if (!target) continue;
if (!target[cat]) target[cat] = {};
if (field === 'score') target[cat].misleading_contribution_score = obj[col];
else if (field === 'why') target[cat].why_contribute_to_misleading = obj[col];
else target[cat][field] = obj[col];
}
if (Object.keys(rhetoric).length) obj.visualization_rhetoric = rhetoric;
if (Object.keys(intents).length) obj.author_intents = intents;
result[model] = obj;
}
} catch(e) {
console.warn('Failed to load responses:', e.message);
}
state.responseCache.set(itemId, result);
return result;
}
// ═══════════════════════════════════════════════════════
// Scatterplot
// ═══════════════════════════════════════════════════════
const MODEL_COLORS = d3.schemeTableau10.concat(d3.schemeDark2);
function getModelColor(model) {
const idx = state.models.indexOf(model);
return MODEL_COLORS[idx % MODEL_COLORS.length] || '#888';
}
function getDotColor(item_id, model) {
const { colorBy } = state;
if (colorBy === 'misleading') {
const s = state.itemSummary.get(item_id);
if (!s || s.is_misleading === null) return '#5a6070';
return s.is_misleading ? '#e05c5c' : '#52c994';
}
if (colorBy === 'mean_sim') {
const s = state.itemSummary.get(item_id);
if (!s) return '#5a6070';
return d3.interpolateRdYlGn(s.mean_sim * 0.5 + 0.5);
}
if (colorBy === 'model' && model) {
return getModelColor(model);
}
return '#4f8ef7';
}
// Plot mode subtitle shown below the plot header title
const PLOT_MODE_DESC = {
A: 'Mode A Β· one dot per (image Γ— model) Β· brush selects all model-dots for matched images',
B: 'Mode B Β· one dot per (image Γ— model) Β· brush selects only the exact brushed dots',
C: 'Mode C Β· one dot per image Β· position = average across models',
};
function renderPlot() {
const svg = d3.select('#scatter-svg');
const wrap = document.getElementById('plot-wrap');
const W = wrap.clientWidth;
const H = wrap.clientHeight;
svg.attr('viewBox', `0 0 ${W} ${H}`).attr('width', W).attr('height', H);
svg.selectAll('*').remove();
// Update subtitle
document.querySelector('#plot-header .title').textContent =
`UMAP Β· ${PLOT_MODE_DESC[state.plotMode]}`;
buildPlotData();
let data = state.plotData;
// Filter by misleadingness
if (state.filter === 'misleading') {
data = data.filter(d => state.itemSummary.get(d.item_id)?.is_misleading === true);
} else if (state.filter === 'not_misleading') {
data = data.filter(d => state.itemSummary.get(d.item_id)?.is_misleading === false);
}
if (data.length === 0) return;
const pad = 30;
const xS = d3.scaleLinear().domain(d3.extent(data, d => d.x)).range([pad, W-pad]);
const yS = d3.scaleLinear().domain(d3.extent(data, d => d.y)).range([H-pad, pad]);
state._xS = xS; state._yS = yS;
const hasSel = state.plotMode === 'B'
? state.selectedKeys.size > 0
: state.selectedIds.size > 0;
const g = svg.append('g');
g.selectAll('circle')
.data(data)
.join('circle')
.attr('class', d => {
let cls = 'dot';
if (hasSel && !isSelected(d)) cls += ' faded';
if (isSelected(d)) cls += ' selected';
return cls;
})
.attr('cx', d => xS(d.x))
.attr('cy', d => yS(d.y))
.attr('r', state.plotMode === 'C' ? 4 : 3)
.attr('fill', d => getDotColor(d.item_id, d.model))
.attr('fill-opacity', 0.75)
.on('click', (evt, d) => {
evt.stopPropagation();
selectItem(d.item_id);
});
// Brush β€” mode-aware selection logic
const brush = d3.brush()
.extent([[0,0],[W,H]])
.on('end', (evt) => {
if (!evt.selection) { clearSelection(); updateBadge(); rerenderDots(); return; }
const [[x0,y0],[x1,y1]] = evt.selection;
clearSelection();
data.forEach(d => {
const cx = xS(d.x), cy = yS(d.y);
if (cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1) addToSelection(d);
});
updateBadge();
rerenderDots();
const selIds = selectedItemIds();
renderItemList(Array.from(selIds.size > 0 ? selIds : state.itemSummary.keys()));
});
svg.append('g').attr('class','brush').call(brush);
renderLegend(data);
}
function rerenderDots() {
const hasSel = state.plotMode === 'B'
? state.selectedKeys.size > 0
: state.selectedIds.size > 0;
d3.selectAll('.dot')
.attr('class', d => {
let cls = 'dot';
if (hasSel && !isSelected(d)) cls += ' faded';
if (isSelected(d)) cls += ' selected';
return cls;
});
}
function updateBadge() {
const badge = document.getElementById('sel-badge');
const selIds = selectedItemIds();
const n = selIds.size;
if (n > 0) {
const modeNote = state.plotMode === 'B'
? ` (${state.selectedKeys.size} responses)`
: '';
badge.textContent = `${n} tweets${modeNote}`;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
document.getElementById('analyse-btn').disabled = n === 0;
}
function renderLegend(data) {
const leg = document.getElementById('legend');
leg.innerHTML = '';
const { colorBy } = state;
if (colorBy === 'misleading') {
[['#e05c5c','Misleading'],['#52c994','Not misleading'],['#5a6070','Unknown']]
.forEach(([c,l]) => {
const row = document.createElement('div');
row.className = 'legend-row';
row.innerHTML = `<div class="swatch" style="background:${c}"></div><span>${l}</span>`;
leg.appendChild(row);
});
} else if (colorBy === 'model') {
state.models.forEach(m => {
const row = document.createElement('div');
row.className = 'legend-row';
row.innerHTML = `<div class="swatch" style="background:${getModelColor(m)}"></div><span>${m.substring(0,22)}</span>`;
leg.appendChild(row);
});
} else if (colorBy === 'mean_sim') {
leg.innerHTML = `<div class="legend-row"><span style="font-size:9px;color:var(--text-dim)">Low agreement β†’ High agreement</span></div>
<div style="height:8px;width:120px;background:linear-gradient(to right,#d73027,#fee08b,#1a9850);border-radius:2px;margin-top:4px;"></div>`;
}
}
// ═══════════════════════════════════════════════════════
// Item list
// ═══════════════════════════════════════════════════════
function renderItemList(ids) {
// ids may be a Set or Array
ids = Array.isArray(ids) ? ids : Array.from(ids);
const list = document.getElementById('item-list');
document.getElementById('item-count').textContent = `${ids.length}`;
if (ids.length === 0) {
list.innerHTML = '<div class="empty-state">No items match</div>';
return;
}
// Sort by mean agreement ascending (most disagreement first β€” more interesting)
const sorted = [...ids].sort((a,b) => {
const sa = state.itemSummary.get(a)?.mean_sim ?? 1;
const sb = state.itemSummary.get(b)?.mean_sim ?? 1;
return sa - sb;
});
list.innerHTML = '';
sorted.slice(0, 300).forEach(id => {
const s = state.itemSummary.get(id);
const row = document.createElement('div');
row.className = 'item-row' + (id === state.activeItemId ? ' active' : '');
row.dataset.id = id;
const tagCls = s?.is_misleading === true ? 'misleading' : s?.is_misleading === false ? 'not-misleading' : '';
const tagLbl = s?.is_misleading === true ? 'misleading' : s?.is_misleading === false ? 'not misleading' : '?';
const mean = s ? (s.mean_sim * 100).toFixed(0) + '% agreement' : '';
row.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;">
<span class="item-id">${id}</span>
<span class="item-tag ${tagCls}">${tagLbl}</span>
</div>
<span class="item-mean">${mean}</span>`;
row.addEventListener('click', () => selectItem(id));
list.appendChild(row);
});
if (sorted.length > 300) {
const more = document.createElement('div');
more.className = 'empty-state';
more.textContent = `+ ${sorted.length - 300} more`;
list.appendChild(more);
}
}
// ═══════════════════════════════════════════════════════
// Select item
// ═══════════════════════════════════════════════════════
function selectItem(itemId) {
state.activeItemId = itemId;
// Update item list highlight
document.querySelectorAll('.item-row').forEach(r => {
r.classList.toggle('active', r.dataset.id === itemId);
});
renderDetailPane();
}
// ═══════════════════════════════════════════════════════
// Detail pane routing
// ═══════════════════════════════════════════════════════
async function renderDetailPane() {
const { activeTab, activeItemId } = state;
if (!activeItemId) return;
if (activeTab === 'image') renderImagePane(activeItemId);
if (activeTab === 'heatmap') await renderHeatmapPane(activeItemId);
if (activeTab === 'responses') renderResponsesPane(activeItemId);
if (activeTab === 'analysis') renderAnalysisPane();
}
// ── Image & Caption ──────────────────────────────────
async function renderImagePane(itemId) {
const dc = document.getElementById('detail-content');
dc.innerHTML = `
<div id="pane-image">
<div>
<div class="detail-label">Visualization</div>
<img id="tweet-img" src="${CONFIG.imagesBase}/${itemId}.png"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex';"
alt="tweet visualization"/>
<div id="img-missing" class="tweet-img missing" style="display:none;width:200px;height:140px;background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);align-items:center;justify-content:center;color:var(--text-dim);font-family:var(--mono);font-size:10px;">no image</div>
</div>
<div id="caption-box">
<div class="detail-label">Caption</div>
<div id="caption-text" class="caption-text">Loading…</div>
<div style="margin-top:10px;" class="detail-label">Item ID</div>
<div style="font-family:var(--mono);font-size:11px;color:var(--accent);margin-top:4px;">${itemId}</div>
<div style="margin-top:8px;" class="detail-label">Agreement stats</div>
<div id="agreement-stats" style="font-family:var(--mono);font-size:11px;margin-top:4px;"></div>
</div>
</div>`;
const s = state.itemSummary.get(itemId);
if (s) {
document.getElementById('agreement-stats').innerHTML =
`mean: <span style="color:var(--accent)">${(s.mean_sim*100).toFixed(1)}%</span>&nbsp;&nbsp;
min: <span style="color:var(--accent2)">${(s.min_sim*100).toFixed(1)}%</span>&nbsp;&nbsp;
max: <span style="color:var(--accent3)">${(s.max_sim*100).toFixed(1)}%</span>`;
}
const caption = await loadCaption(itemId);
const captionEl = document.getElementById('caption-text');
if (captionEl) captionEl.textContent = caption || '(no caption available)';
}
// ── Agreement Heatmap tab β€” two sections ─────────────
async function renderHeatmapPane(itemId) {
const dc = document.getElementById('detail-content');
dc.innerHTML = '<div class="empty-state">Loading…</div>';
const cs1rows = state.cs1Data.filter(r => r.item_id === itemId);
const responses = await loadResponses(itemId);
// Determine taxonomy from experiment name
const isRhetoric = state.experiment.startsWith('E1');
const taxKey = isRhetoric ? 'visualization_rhetoric' : 'author_intents';
// Short display names for categories
const CAT_LABELS = {
information_access_rhetoric: 'Info Access',
provenance_rhetoric: 'Provenance',
mapping_rhetoric: 'Mapping',
linguistic_based_rhetoric: 'Linguistic',
procedural_rhetoric: 'Procedural',
aesthetic_driven_misrepresentation:'Aesthetic',
bias_exploitation: 'Bias Exploit.',
claim_supporting_manipulation: 'Claim Manip.',
context_distortion: 'Context Dist.',
deliberate_reader_confusion: 'Reader Conf.',
lack_of_visualization_literacy: 'Viz Literacy',
selective_reporting: 'Selective Rep.',
space_and_format_constraints: 'Space Constr.',
unintentional_context_omission: 'Uninten. Omit.',
};
dc.innerHTML = `
<div style="display:flex;flex-direction:column;gap:24px;">
<!-- Section 1: Embedding cosine similarity heatmap -->
<div>
<div class="detail-label" style="margin-bottom:6px;">
Embedding similarity (cosine) β€” semantic agreement on "${state.key}" responses
</div>
<div class="detail-sublabel" style="font-family:var(--mono);font-size:9px;color:var(--text-dim);margin-bottom:8px;">
Measures how semantically similar two models' text responses were. High similarity β‰  same score.
</div>
<div id="heatmap-embed" style="overflow-x:auto;"></div>
</div>
<!-- Section 2: Score heatmap (models Γ— categories) -->
<div>
<div class="detail-label" style="margin-bottom:6px;">
Contribution scores β€” models Γ— ${isRhetoric ? 'rhetoric' : 'intent'} categories
</div>
<div class="detail-sublabel" style="font-family:var(--mono);font-size:9px;color:var(--text-dim);margin-bottom:8px;">
Score scale: 0 = none Β· 1–6 = minimal β†’ very strong Β· grey = unknown (βˆ’1)
</div>
<div id="heatmap-scores" style="overflow-x:auto;"></div>
</div>
<!-- Section 3: Per-category agreement (std dev bar + mean dot strip) -->
<div>
<div class="detail-label" style="margin-bottom:6px;">
Score distribution per category β€” dots = individual models Β· bar = std deviation
</div>
<div class="detail-sublabel" style="font-family:var(--mono);font-size:9px;color:var(--text-dim);margin-bottom:8px;">
Tall bar = high disagreement between models Β· low bar = consensus Β· dot position = score value
</div>
<div id="heatmap-dist" style="overflow-x:auto;"></div>
</div>
</div>`;
const dimColor = getComputedStyle(document.documentElement)
.getPropertyValue('--text-dim').trim();
const bgColor = getComputedStyle(document.documentElement)
.getPropertyValue('--bg3').trim();
// ── Section 1: Cosine similarity heatmap ──────────────────
if (cs1rows.length > 0) {
const models = state.models;
const n = models.length;
const mat = Array.from({length:n}, () => Array(n).fill(null));
cs1rows.forEach(r => {
const i = models.indexOf(r.model_a);
const j = models.indexOf(r.model_b);
if (i >= 0 && j >= 0) { mat[i][j] = r.cosine_sim; mat[j][i] = r.cosine_sim; }
});
for(let k=0;k<n;k++) mat[k][k] = 1.0;
const cell = 20, lpad = 110, tpad = 100;
const W = lpad + n*cell + 10, H = tpad + n*cell + 10;
const colorEmb = d3.scaleSequential(d3.interpolateRdYlGn).domain([-0.2, 1]);
const svgE = d3.select('#heatmap-embed').append('svg')
.attr('width', W).attr('height', H)
.style('font-family','var(--mono)');
const gE = svgE.append('g').attr('transform', `translate(${lpad},${tpad})`);
models.forEach((ma, i) => {
models.forEach((mb, j) => {
const v = mat[i][j];
gE.append('rect')
.attr('x', j*cell).attr('y', i*cell)
.attr('width', cell-1).attr('height', cell-1)
.attr('fill', v !== null ? colorEmb(v) : bgColor)
.append('title').text(`${ma} Γ— ${mb}: ${v !== null ? v.toFixed(3) : 'N/A'}`);
});
});
models.forEach((m,i) => {
gE.append('text').attr('x',-4).attr('y',i*cell+cell/2+3)
.attr('text-anchor','end').attr('font-size',8).attr('fill',dimColor)
.text(m.length>16 ? m.substring(0,15)+'…' : m);
});
models.forEach((m,j) => {
gE.append('text').attr('x',j*cell+cell/2).attr('y',-4)
.attr('text-anchor','start').attr('font-size',8).attr('fill',dimColor)
.attr('transform',`rotate(-45,${j*cell+cell/2},-4)`)
.text(m.length>16 ? m.substring(0,15)+'…' : m);
});
} else {
document.getElementById('heatmap-embed').innerHTML =
'<div class="empty-state">No CS1 data for this key/experiment</div>';
}
// ── Extract scores from responses ──────────────────────────
const modelKeys = Object.keys(responses).sort();
if (modelKeys.length === 0) {
['heatmap-scores','heatmap-dist'].forEach(id => {
document.getElementById(id).innerHTML =
'<div class="empty-state">No response data β€” open Model Responses tab first to cache</div>';
});
return;
}
// Collect categories (union of all models' keys)
const catSet = new Set();
modelKeys.forEach(m => {
const resp = responses[m].response || responses[m];
const tax = resp[taxKey];
if (tax) Object.keys(tax).forEach(c => catSet.add(c));
});
const categories = Array.from(catSet).sort();
if (categories.length === 0) {
['heatmap-scores','heatmap-dist'].forEach(id => {
document.getElementById(id).innerHTML =
`<div class="empty-state">No ${taxKey} scores in responses</div>`;
});
return;
}
// score matrix: modelKeys Γ— categories
const scoreMatrix = modelKeys.map(m => {
const resp = responses[m].response || responses[m];
const tax = resp[taxKey] || {};
return categories.map(cat => {
const entry = tax[cat];
return entry !== undefined ? Number(entry.misleading_contribution_score ?? 0) : 0;
});
});
// ── Section 2: Score heatmap (models Γ— categories) ─────────
{
const cellW = 52, cellH = 18;
const lpad = 110, tpad = 100;
const nc = categories.length, nm = modelKeys.length;
const W = lpad + nc*cellW + 10, H = tpad + nm*cellH + 10;
// Color: grey for -1, sequential blue 0β†’6 for 0β†’6
const colorScore = (v) => {
const n = Number(v);
if (n === -1) return '#555566';
return d3.interpolateBlues(n / 6);
};
const svgS = d3.select('#heatmap-scores').append('svg')
.attr('width', W).attr('height', H)
.style('font-family','var(--mono)');
const gS = svgS.append('g').attr('transform',`translate(${lpad},${tpad})`);
modelKeys.forEach((m, i) => {
categories.forEach((cat, j) => {
const v = scoreMatrix[i][j];
const fill = colorScore(v);
gS.append('rect')
.attr('x', j*cellW).attr('y', i*cellH)
.attr('width', cellW-1).attr('height', cellH-1)
.attr('fill', fill)
.append('title').text(`${m} Β· ${cat}: ${v === -1 ? 'unknown' : v}`);
// Score label inside cell
if (v !== 0) {
gS.append('text')
.attr('x', j*cellW + cellW/2).attr('y', i*cellH + cellH/2 + 4)
.attr('text-anchor','middle').attr('font-size',9)
.attr('fill', v >= 3 ? '#fff' : dimColor)
.attr('pointer-events','none')
.text(v === -1 ? '?' : String(v));
}
});
});
// Model row labels
modelKeys.forEach((m,i) => {
gS.append('text').attr('x',-4).attr('y',i*cellH+cellH/2+3)
.attr('text-anchor','end').attr('font-size',8).attr('fill',dimColor)
.text(m.length>16 ? m.substring(0,15)+'…' : m);
});
// Category col labels (rotated)
categories.forEach((cat,j) => {
const label = CAT_LABELS[cat] || cat.replace(/_/g,' ');
gS.append('text')
.attr('x', j*cellW + cellW/2).attr('y', -4)
.attr('text-anchor','start').attr('font-size',8).attr('fill',dimColor)
.attr('transform',`rotate(-40,${j*cellW+cellW/2},-4)`)
.text(label);
});
// Color scale legend
const legX = 0, legY = nm*cellH + 16;
[0,1,2,3,4,5,6].forEach((v,k) => {
gS.append('rect')
.attr('x', legX+k*22).attr('y', legY)
.attr('width',21).attr('height',8)
.attr('fill', colorScore(v));
gS.append('text')
.attr('x', legX+k*22+10).attr('y', legY+18)
.attr('text-anchor','middle').attr('font-size',8).attr('fill',dimColor)
.text(v);
});
gS.append('rect').attr('x', legX+7*22).attr('y', legY)
.attr('width',21).attr('height',8).attr('fill','#555566');
gS.append('text').attr('x', legX+7*22+10).attr('y', legY+18)
.attr('text-anchor','middle').attr('font-size',8).attr('fill',dimColor)
.text('?');
}
// ── Section 3: Dot strip + std-dev bar per category ────────
{
const barH = 60; // height of each category panel
const dotArea = 40; // vertical range for dots (scores 0–6)
const barArea = 12; // height of the std-dev bar
const gap = 8;
const catW = 72;
const lpad = 10, tpad = 14;
const nc = categories.length;
const W = lpad + nc*catW + 20;
const H = tpad + barH + 40;
const yScore = d3.scaleLinear().domain([0,6]).range([dotArea, 0]);
const svgD = d3.select('#heatmap-dist').append('svg')
.attr('width', W).attr('height', H)
.style('font-family','var(--mono)');
const gD = svgD.append('g').attr('transform',`translate(${lpad},${tpad})`);
categories.forEach((cat, j) => {
const scores = modelKeys.map((_,i) => scoreMatrix[i][j])
.filter(v => Number(v) >= 0).map(Number); // exclude -1 (unknown), coerce to number
const mean = scores.length ? scores.reduce((a,b)=>a+b,0)/scores.length : 0;
const std = scores.length > 1
? Math.sqrt(scores.map(v=>(v-mean)**2).reduce((a,b)=>a+b,0)/scores.length)
: 0;
const cx = j*catW + catW/2;
const g = gD.append('g').attr('transform',`translate(${cx},0)`);
// Background bar showing score range 0–6
g.append('rect')
.attr('x',-catW/2+4).attr('y',0)
.attr('width',catW-8).attr('height',dotArea)
.attr('fill','none').attr('stroke',dimColor).attr('stroke-opacity',0.12)
.attr('rx',2);
// Std-dev bar (centered on mean y)
if (scores.length > 0) {
const meanY = yScore(mean);
const stdPx = std * (dotArea/6);
g.append('rect')
.attr('x',-8).attr('y', Math.max(0, meanY - stdPx))
.attr('width',16)
.attr('height', Math.min(dotArea, stdPx*2 + 1))
.attr('fill','var(--accent)').attr('opacity',0.18);
// Mean line
g.append('line')
.attr('x1',-14).attr('x2',14).attr('y1',meanY).attr('y2',meanY)
.attr('stroke','var(--accent)').attr('stroke-width',1.5);
// Individual model dots (jittered x)
const nonZero = scores.filter(v => v > 0);
const all0 = nonZero.length === 0;
scores.forEach((v, k) => {
const jitter = (k % 5 - 2) * 2.5;
g.append('circle')
.attr('cx', jitter).attr('cy', yScore(v))
.attr('r', 3)
.attr('fill', v === 0 ? dimColor : 'var(--accent2)')
.attr('opacity', v === 0 ? 0.25 : 0.8)
.append('title').text(`${modelKeys[modelKeys.findIndex((_,i2)=>scoreMatrix[i2][j]===v)] ?? '?'}: ${v}`);
});
}
// Category label (below)
const label = CAT_LABELS[cat] || cat.replace(/_/g,' ');
const words = label.split(' ');
words.forEach((w, wi) => {
g.append('text')
.attr('x',0).attr('y', dotArea + gap + 10 + wi*10)
.attr('text-anchor','middle').attr('font-size',8).attr('fill',dimColor)
.text(w);
});
// Std value
g.append('text')
.attr('x',0).attr('y', dotArea + gap + 10 + words.length*10 + 2)
.attr('text-anchor','middle').attr('font-size',7)
.attr('fill','var(--accent)').attr('opacity',0.7)
.text(`Οƒ=${std.toFixed(1)}`);
});
// Y axis labels (0, 3, 6)
[0,3,6].forEach(v => {
gD.append('text')
.attr('x', nc*catW + 6).attr('y', yScore(v) + 3)
.attr('font-size',7).attr('fill',dimColor).text(v);
});
gD.append('text')
.attr('x', nc*catW + 6).attr('y', -2)
.attr('font-size',7).attr('fill',dimColor).text('score');
}
}
// ── Model Responses ──────────────────────────────────
async function renderResponsesPane(itemId) {
const dc = document.getElementById('detail-content');
dc.innerHTML = `<div class="empty-state">Loading responses…</div>`;
const responses = await loadResponses(itemId);
const keys = Object.keys(responses);
if (keys.length === 0) {
dc.innerHTML = '<div class="empty-state">No responses found for this item</div>';
return;
}
dc.innerHTML = '<div id="responses-list"></div>';
const list = document.getElementById('responses-list');
keys.sort().forEach(model => {
const r = responses[model];
const resp = r.response || r;
const isMis = resp.is_misleading;
const card = document.createElement('div');
card.className = 'response-card';
// Scores row β€” extract from pipe-delimited columns (e.g. i|bias_exploitation|score)
let scoresHtml = '';
const scoreEntries = [];
for (const [col, val] of Object.entries(resp)) {
if (col.endsWith('|score') && Number(val) > 0) {
const parts = col.split('|');
const cat = parts.slice(1, -1).join(' ').replace(/_/g, ' ');
scoreEntries.push({ cat, score: Number(val) });
}
}
if (scoreEntries.length > 0) {
scoresHtml = `<div class="field-label">Scores</div><div class="scores-row">`;
scoreEntries.forEach(({ cat, score }) => {
scoresHtml += `<span class="score-chip" title="${cat}">${cat}: ${score}</span>`;
});
scoresHtml += `</div>`;
}
card.innerHTML = `
<div class="model-name">${model}</div>
<span class="verdict ${isMis}">${isMis ? '⚠ misleading' : 'βœ“ not misleading'}</span>
${resp.analysis ? `<div class="field-label">Analysis</div><div class="field-text">${resp.analysis.substring(0,400)}${resp.analysis.length>400?'…':''}</div>` : ''}
${resp.why_misleading ? `<div class="field-label">Why misleading</div><div class="field-text">${resp.why_misleading.substring(0,300)}${resp.why_misleading.length>300?'…':''}</div>` : ''}
${scoresHtml}`;
list.appendChild(card);
// Update is_misleading in summary from actual response data
// Use majority vote across models
});
// Update is_misleading via majority vote
let trueCount = 0, falseCount = 0;
keys.forEach(m => {
const v = (responses[m].response || responses[m]).is_misleading;
if (v === true) trueCount++;
if (v === false) falseCount++;
});
if (state.itemSummary.has(itemId)) {
state.itemSummary.get(itemId).is_misleading = trueCount >= falseCount;
}
}
// ── LLM Analysis ─────────────────────────────────────
let llmConfig = {
provider: 'anthropic',
apiKey: '',
model: 'claude-sonnet-4-20250514',
endpoint: '',
};
function renderAnalysisPane() {
const dc = document.getElementById('detail-content');
dc.innerHTML = `
<div id="analysis-settings">
<div class="settings-row">
<label>Provider</label>
<select id="llm-provider">
<option value="local" ${llmConfig.provider==='local'?'selected':''}>Local (OpenAI-compatible)</option>
<option value="openai" ${llmConfig.provider==='openai'?'selected':''}>OpenAI</option>
<option value="anthropic" ${llmConfig.provider==='anthropic'?'selected':''}>Anthropic</option>
</select>
</div>
<div class="settings-row">
<label>Model</label>
<input type="text" id="llm-model" value="${llmConfig.model}" placeholder="model name"/>
</div>
<div class="settings-row">
<label>API Key</label>
<input type="password" id="llm-apikey" value="${llmConfig.apiKey}" placeholder="sk-… or leave empty for local"/>
</div>
<div class="settings-row" id="endpoint-row" style="${llmConfig.provider==='local'?'':'display:none'}">
<label>Endpoint</label>
<input type="text" id="llm-endpoint" value="${llmConfig.endpoint}" placeholder="http://localhost:11434/v1"/>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;">
<button id="run-analysis-btn">β–Ά Run analysis</button>
<span style="font-family:var(--mono);font-size:10px;color:var(--text-dim);">on selected item Β· ${state.activeItemId || 'β€”'}</span>
</div>
<div class="detail-label" style="margin-bottom:6px;">Output</div>
<div id="analysis-output" class="placeholder">Configure provider above and click Run to analyse the selected item across all models.</div>`;
document.getElementById('llm-provider').addEventListener('change', e => {
llmConfig.provider = e.target.value;
document.getElementById('endpoint-row').style.display = e.target.value === 'local' ? '' : 'none';
// Set sensible default model
if (e.target.value === 'anthropic') document.getElementById('llm-model').value = 'claude-sonnet-4-20250514';
if (e.target.value === 'openai') document.getElementById('llm-model').value = 'gpt-5.4';
if (e.target.value === 'local') document.getElementById('llm-model').value = 'openai/gpt-oss-120b';
});
document.getElementById('run-analysis-btn').addEventListener('click', runLLMAnalysis);
}
// Helper: build a structured taxonomy block for one response
function buildTaxonomyBlock(resp, taxKey) {
const tax = resp[taxKey];
if (!tax) return ' (no taxonomy data)';
return Object.entries(tax).map(([cat, val]) => {
const score = Number(val?.misleading_contribution_score ?? 0);
const why = (val?.why_contribute_to_misleading || '').trim();
const scoreStr = score === -1 ? 'unknown' : score === 0 ? 'none (0)' : `${score}/6`;
const whyStr = why ? `\n Explanation: "${why.substring(0, 180)}${why.length > 180 ? '…' : ''}"` : '';
return ` β€’ ${cat.replace(/_/g,' ')}: ${scoreStr}${whyStr}`;
}).join('\n');
}
// Helper: compute per-category score statistics across all models
function buildScoreStats(responses, taxKey) {
const allCats = new Set();
Object.values(responses).forEach(r => {
const resp = r.response || r;
const tax = resp[taxKey];
if (tax) Object.keys(tax).forEach(c => allCats.add(c));
});
const modelList = Object.keys(responses).sort();
const lines = [];
allCats.forEach(cat => {
const scores = modelList.map(m => {
const resp = responses[m].response || responses[m];
const val = resp[taxKey]?.[cat];
return Number(val?.misleading_contribution_score ?? 0);
}).filter(v => v >= 0); // exclude -1
if (scores.length === 0) return;
const mean = scores.reduce((a,b)=>a+b,0) / scores.length;
const std = Math.sqrt(scores.map(v=>(v-mean)**2).reduce((a,b)=>a+b,0)/scores.length);
const nonZero = scores.filter(v => v > 0);
const activated = nonZero.length;
const maxScore = Math.max(...scores);
const minScore = Math.min(...scores);
// Find models with highest and lowest scores
const scored = modelList.map((m,i) => ({m, v: scores[i]})).filter(x => x.v >= 0);
scored.sort((a,b) => b.v - a.v);
const topModels = scored.slice(0,3).map(x=>`${x.m}(${x.v})`).join(', ');
const botModels = scored.slice(-2).reverse().map(x=>`${x.m}(${x.v})`).join(', ');
lines.push(
` ${cat.replace(/_/g,' ')}:\n` +
` Activated by ${activated}/${modelList.length} models Β· mean=${mean.toFixed(2)} Β· Οƒ=${std.toFixed(2)} Β· range=[${minScore}–${maxScore}]\n` +
` Highest: ${topModels}\n` +
` Lowest: ${botModels}`
);
});
return lines.join('\n\n') || ' (no score data)';
}
async function runLLMAnalysis() {
const itemId = state.activeItemId;
if (!itemId) return;
llmConfig.provider = document.getElementById('llm-provider').value;
llmConfig.model = document.getElementById('llm-model').value;
llmConfig.apiKey = document.getElementById('llm-apikey').value;
llmConfig.endpoint = document.getElementById('llm-endpoint')?.value || '';
const output = document.getElementById('analysis-output');
output.className = 'loading';
output.textContent = 'Loading responses and preparing prompt…';
const responses = await loadResponses(itemId);
const caption = await loadCaption(itemId);
const s = state.itemSummary.get(itemId);
const isRhetoric = state.experiment.startsWith('E1');
const taxKey = isRhetoric ? 'visualization_rhetoric' : 'author_intents';
const taxLabel = isRhetoric ? 'Visualization Rhetoric' : 'Author Intent';
const modelList = Object.keys(responses).sort();
const nModels = modelList.length;
// Verdict summary
let trueCount = 0, falseCount = 0, unknownCount = 0;
modelList.forEach(m => {
const v = (responses[m].response || responses[m]).is_misleading;
if (v === true) trueCount++; else if (v === false) falseCount++; else unknownCount++;
});
const verdictSummary = `${trueCount} misleading / ${falseCount} not misleading / ${unknownCount} unknown`;
// Score statistics block
const scoreStats = buildScoreStats(responses, taxKey);
// Per-model detailed taxonomy block (all models, full data)
const perModelBlocks = modelList.map(m => {
const resp = responses[m].response || responses[m];
const verdict = resp.is_misleading ? '⚠ misleading' : 'βœ“ not misleading';
const analysis = (resp.analysis || '').substring(0, 350);
const whyMis = (resp.why_misleading || '').substring(0, 250);
const taxBlock = buildTaxonomyBlock(resp, taxKey);
return [
`Model: ${m}`,
` Verdict: ${verdict}`,
analysis ? ` Analysis: "${analysis}${resp.analysis?.length>350?'…':''}"` : '',
whyMis ? ` Why misleading: "${whyMis}${resp.why_misleading?.length>250?'…':''}"` : '',
` ${taxLabel} scores:`,
taxBlock,
].filter(Boolean).join('\n');
}).join('\n\n' + '─'.repeat(60) + '\n\n');
output.textContent = 'Building prompt…';
const prompt = `You are an expert in data visualization research, media studies, and large language model evaluation. You are performing a deep analytical study of how 16 state-of-the-art multimodal LLMs reason about a COVID-19 social media visualization that may or may not be misleading.
═══════════════════════════════════════════════════════
ITEM CONTEXT
═══════════════════════════════════════════════════════
Item ID: ${itemId}
Experiment: ${state.experiment} (${isRhetoric ? 'Rhetoric task' : 'Authorial intent task'})
Caption: ${caption || '(unavailable)'}
Ground truth misleading: ${s?.is_misleading === true ? 'YES' : s?.is_misleading === false ? 'NO' : 'unknown'}
Embedding cosine agreement (${state.key}): ${s ? (s.mean_sim*100).toFixed(1)+'%' : 'N/A'}
═══════════════════════════════════════════════════════
VERDICT SUMMARY ACROSS ${nModels} MODELS
═══════════════════════════════════════════════════════
${verdictSummary}
═══════════════════════════════════════════════════════
${taxLabel.toUpperCase()} SCORE STATISTICS (per category)
Scores: 0=none, 1=minimal, 2=limited, 3=moderate, 4=considerable, 5=strong, 6=very strong
═══════════════════════════════════════════════════════
${scoreStats}
═══════════════════════════════════════════════════════
FULL PER-MODEL RESPONSES WITH TAXONOMY SCORES
═══════════════════════════════════════════════════════
${perModelBlocks}
═══════════════════════════════════════════════════════
YOUR ANALYSIS TASK
═══════════════════════════════════════════════════════
Please provide a thorough, structured analysis covering:
1. VERDICT AGREEMENT
- Do models agree on whether this visualization is misleading?
- If there is disagreement, which models dissent and what distinguishes their reasoning?
- Is the majority verdict consistent with the ground truth?
2. ${taxLabel.toUpperCase()} PATTERNS
- Which ${isRhetoric ? 'rhetoric' : 'intent'} categories were most frequently activated (high mean score)?
- Which categories show the highest inter-model disagreement (high Οƒ)?
- Which categories show strong consensus (low Οƒ, consistent scores)?
- Are there categories where only a minority of models activated them β€” and do those models share a common reasoning pattern?
3. DIVERGENCE DEEP DIVE
- Identify the most striking disagreements: which specific model pairs diverge most, on which categories, and why?
- For the most contested category, compare the verbatim explanations of agreeing vs disagreeing models. What do they notice differently about the visualization?
- Is there a pattern in which types of models (by size, architecture family, or training) tend to agree with each other?
4. CONVERGENCE PATTERNS
- What elements of the visualization did nearly all models mention or focus on?
- Is there a "common narrative" that most models share, even if they differ on scores?
- Which specific visual or textual features appear in multiple models' explanations?
5. QUALITY AND DEPTH ASSESSMENT
- Which models provided the most specific, grounded explanations (citing concrete visual elements)?
- Which models were vague or generic?
- Are there cases where a model's score and its textual explanation are internally inconsistent?
6. RESEARCH IMPLICATIONS
- What does the pattern of agreement/disagreement on this item suggest about LLM capabilities in detecting this type of misleading visualization?
- If you were to select this item as an illustrative example for a research paper, what point would it best illustrate?
Be analytical, specific, and cite model names and scores when making claims. Structure your response clearly with the numbered sections above.`;
output.textContent = 'Calling API…';
try {
let text;
if (llmConfig.provider === 'anthropic') {
text = await callAnthropic(prompt);
} else {
text = await callOpenAI(prompt);
}
output.className = '';
output.textContent = text;
} catch(e) {
output.className = 'placeholder';
output.textContent = `Error: ${e.message}`;
}
}
async function callAnthropic(prompt) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': llmConfig.apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
},
body: JSON.stringify({
model: llmConfig.model,
max_tokens: 64000,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) throw new Error(`Anthropic API error ${res.status}: ${await res.text()}`);
const j = await res.json();
return j.content?.[0]?.text || '(empty response)';
}
async function callOpenAI(prompt) {
const baseUrl = llmConfig.provider === 'local'
? (llmConfig.endpoint || 'http://localhost:11434/v1')
: 'https://api.openai.com/v1';
const res = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${llmConfig.apiKey || 'none'}`,
},
body: JSON.stringify({
model: llmConfig.model,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
const j = await res.json();
return j.choices?.[0]?.message?.content || '(empty response)';
}
// ═══════════════════════════════════════════════════════
// Analyze button β€” for cluster (multi-item) analysis
// ═══════════════════════════════════════════════════════
document.getElementById('analyse-btn').addEventListener('click', async () => {
const ids = Array.from(selectedItemIds());
if (ids.length === 0) return;
switchTab('analysis');
renderAnalysisPane();
// Trigger cluster analysis automatically after a short delay for rendering
setTimeout(() => runClusterAnalysis(ids), 100);
});
async function runClusterAnalysis(ids) {
const output = document.getElementById('analysis-output');
output.className = 'loading';
output.textContent = `Preparing cluster analysis for ${ids.length} items…`;
// Collect summary stats for the cluster
const sims = ids.map(id => state.itemSummary.get(id)?.mean_sim ?? null).filter(v => v !== null);
const meanSim = sims.length ? (sims.reduce((a,b)=>a+b,0)/sims.length * 100).toFixed(1) : 'N/A';
const minSim = sims.length ? (Math.min(...sims)*100).toFixed(1) : 'N/A';
const misleadingCount = ids.filter(id => state.itemSummary.get(id)?.is_misleading === true).length;
// ids are already item_id strings from selectedItemIds()
// Read LLM config from form (may have been set by user)
llmConfig.provider = document.getElementById('llm-provider')?.value || llmConfig.provider;
llmConfig.model = document.getElementById('llm-model')?.value || llmConfig.model;
llmConfig.apiKey = document.getElementById('llm-apikey')?.value || llmConfig.apiKey;
llmConfig.endpoint = document.getElementById('llm-endpoint')?.value || llmConfig.endpoint;
// If no API key is set, show a rich mock response
if (!llmConfig.apiKey && llmConfig.provider !== 'local') {
output.className = '';
output.textContent = generateMockClusterAnalysis(ids, meanSim, minSim, misleadingCount);
return;
}
// Build cluster-level prompt (sample up to 10 items for context length)
const sample = ids.slice(0, 10);
output.textContent = `Loading responses for ${sample.length} sampled items…`;
const itemContexts = await Promise.all(sample.map(async id => {
const responses = await loadResponses(id);
const caption = await loadCaption(id);
const s = state.itemSummary.get(id);
const verdicts = Object.entries(responses).map(([m,r]) => {
const resp = r.response || r;
return `${m}: ${resp.is_misleading ? 'misleading' : 'not misleading'}`;
}).join(', ');
return `Item ${id} (mean agreement: ${s ? (s.mean_sim*100).toFixed(0) : '?'}%):
Caption: ${(caption || '(n/a)').substring(0, 120)}
Verdicts: ${verdicts}`;
}));
const prompt = buildClusterPrompt(ids, itemContexts, meanSim, minSim, misleadingCount);
output.textContent = 'Calling API…';
try {
let text;
if (llmConfig.provider === 'anthropic') {
text = await callAnthropic(prompt);
} else {
text = await callOpenAI(prompt);
}
output.className = '';
output.textContent = text;
} catch(e) {
output.className = 'placeholder';
output.textContent = `API error: ${e.message}\n\nMock response shown instead:\n\n` + generateMockClusterAnalysis(ids, meanSim, minSim, misleadingCount);
}
}
function buildClusterPrompt(ids, itemContexts, meanSim, minSim, misleadingCount) {
return `You are a visualization research expert. You are analyzing a cluster of ${ids.length} images containing data visualizations, selected because they share similar embedding geometry in the "${state.key}" embedding space (experiment: ${state.experiment}).
Cluster statistics:
- Items: ${ids.length} (showing ${itemContexts.length} sampled)
- Misleading: ${misleadingCount} / ${ids.length}
- Mean inter-model agreement: ${meanSim}%
- Min agreement: ${minSim}%
Sampled items:
${itemContexts.join('\n\n')}
Please analyze:
1. What common visual or rhetorical features likely caused these items to cluster together?
2. Is model agreement high or low in this cluster β€” what does that suggest about this type of visualization?
3. Are the misleading and non-misleading items separable within this cluster, or mixed?
4. What would make this cluster analytically useful for studying LLM reasoning on misleading visualizations?
Be specific and concise.`;
}
function generateMockClusterAnalysis(ids, meanSim, minSim, misleadingCount) {
return `[MOCK RESPONSE β€” configure an API key in the Analysis tab to get a real response]
Cluster Analysis: ${ids.length} items Β· key: ${state.key} Β· experiment: ${state.experiment}
━━━ Cluster Characteristics ━━━
This cluster contains ${ids.length} items with a mean inter-model agreement of ${meanSim}% and minimum agreement of ${minSim}%. ${misleadingCount} of ${ids.length} items are labeled misleading (${(misleadingCount/ids.length*100).toFixed(0)}%).
━━━ Hypothetical Interpretation ━━━
Items clustering together in the "${state.key}" embedding space likely share similar semantic patterns in how models discussed this dimension. ${meanSim > 70 ? 'The high mean agreement suggests models converged on similar reasoning for these items β€” this cluster may represent visualizations with clear, unambiguous misleading mechanisms.' : meanSim > 40 ? 'The moderate agreement level suggests partial consensus β€” some model pairs agree while others diverge, possibly reflecting ambiguity in the underlying misleading technique.' : 'The low agreement level suggests substantial disagreement between models β€” this cluster may contain complex or ambiguous visualizations that challenge model reasoning.'}
━━━ Suggested Next Steps ━━━
β€’ Open individual items to inspect the agreement heatmap and compare model responses
β€’ Filter by misleading/not-misleading to check if the cluster is semantically coherent
β€’ Compare with another experiment (e.g., 1B) to see if providing the verdict label changes the clustering
━━━ Note ━━━
This is a placeholder. Set your API key, model, and provider in the Analysis tab settings above, then click "Run analysis" on a specific item or re-trigger cluster analysis via the "Analyze selection with LLM" button.`;
}
// ═══════════════════════════════════════════════════════
// Tab switching
// ═══════════════════════════════════════════════════════
function switchTab(tab) {
state.activeTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.pane === tab));
renderDetailPane();
}
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => switchTab(t.dataset.pane));
});
// ═══════════════════════════════════════════════════════
// Controls
// ═══════════════════════════════════════════════════════
document.getElementById('sel-experiment').addEventListener('change', e => { state.experiment = e.target.value; loadData(); });
document.getElementById('sel-key').addEventListener('change', e => { state.key = e.target.value; loadData(); });
document.getElementById('sel-color').addEventListener('change', e => { state.colorBy = e.target.value; renderPlot(); });
document.getElementById('sel-filter').addEventListener('change', e => { state.filter = e.target.value; renderPlot(); });
document.getElementById('sel-plotmode').addEventListener('change', e => {
state.plotMode = e.target.value;
clearSelection();
buildPlotData();
updateBadge();
renderPlot();
renderItemList(Array.from(state.itemSummary.keys()));
});
document.getElementById('btn-reset').addEventListener('click', () => { loadData(); });
document.getElementById('btn-clear').addEventListener('click', () => {
clearSelection();
updateBadge();
rerenderDots();
renderItemList(Array.from(state.itemSummary.keys()));
});
// ═══════════════════════════════════════════════════════
// Resize
// ═══════════════════════════════════════════════════════
window.addEventListener('resize', () => { if (state.umapData.length) renderPlot(); });
// ═══════════════════════════════════════════════════════
// ID-based selection
// ═══════════════════════════════════════════════════════
function selectByIds(rawInput) {
const msg = document.getElementById('id-search-msg');
// Parse: trim whitespace, split on commas and/or newlines, drop empties
const requested = rawInput
.split(/[,\n]+/)
.map(s => s.trim())
.filter(Boolean);
if (requested.length === 0) {
msg.textContent = 'Enter at least one item ID.';
msg.style.color = 'var(--accent2)';
return;
}
const known = new Set(state.itemSummary.keys());
const found = requested.filter(id => known.has(id));
const notFound = requested.filter(id => !known.has(id));
if (found.length === 0) {
msg.textContent = `No matching IDs found (${requested.length} tried).`;
msg.style.color = 'var(--accent2)';
return;
}
// Apply selection β€” merge with existing or replace depending on mode
clearSelection();
found.forEach(id => {
if (state.plotMode === 'B') {
// In mode B, select all model-dots for the given item_id
state.plotData
.filter(d => d.item_id === id)
.forEach(d => state.selectedKeys.add(`${d.item_id}::${d.model}`));
} else {
state.selectedIds.add(id);
}
});
updateBadge();
rerenderDots();
renderItemList(Array.from(found));
// Auto-open the first found item in the detail panel
if (found.length === 1) selectItem(found[0]);
// Feedback message
if (notFound.length === 0) {
msg.textContent = `${found.length} item${found.length > 1 ? 's' : ''} selected.`;
msg.style.color = 'var(--accent3)';
} else {
msg.textContent = `${found.length} selected Β· ${notFound.length} not found: ${notFound.slice(0,3).join(', ')}${notFound.length > 3 ? '…' : ''}`;
msg.style.color = 'var(--yellow)';
}
}
document.getElementById('id-search-btn').addEventListener('click', () => {
selectByIds(document.getElementById('id-search-input').value);
});
document.getElementById('id-search-input').addEventListener('keydown', e => {
if (e.key === 'Enter') selectByIds(e.target.value);
});
// ═══════════════════════════════════════════════════════
// Theme toggle
// ═══════════════════════════════════════════════════════
(function() {
const btn = document.getElementById('btn-theme');
const body = document.body;
// Restore saved preference
const saved = localStorage.getItem('tvl-theme');
if (saved === 'light') { body.classList.add('light'); btn.textContent = 'β˜€ Light'; }
btn.addEventListener('click', () => {
const isLight = body.classList.toggle('light');
btn.textContent = isLight ? 'β˜€ Light' : '☾ Dark';
localStorage.setItem('tvl-theme', isLight ? 'light' : 'dark');
// Re-render plot so SVG text colors update (they use JS-computed colors)
if (state.plotData?.length) renderPlot();
});
})();
// ═══════════════════════════════════════════════════════
// Boot
// ═══════════════════════════════════════════════════════
try {
await initDuckDB();
setStatus('DuckDB ready', 'ready');
await loadData();
} catch(e) {
setStatus(`Boot error: ${e.message}`, 'error');
console.error(e);
}
</script>
</body>
</html>