topic-feed-lab / index.html
davanstrien's picture
davanstrien HF Staff
Deep links (view state in the URL, copy link, parent query sync) and card-only flag (worker has_files/n_files + file-set re-check; hidden by default in the UI)
043a903 verified
Raw History Blame Contribute Delete
64.7 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Topic Feed Lab</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
--bg:#FBFBFA; --ink:#1F2328; --muted:#656D76; --faint:#9AA1A9; --line:#E3E5E8; --soft:#F1F3F5;
--bar:#D3D7DC; --accent:#3B6EA5; --ok:#2E7D4F; --bad:#B0453A;
color-scheme:light;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#111418; --ink:#E4E7EB; --muted:#9BA3AD; --faint:#6B737D; --line:#2A2F36; --soft:#1A1E24;
--bar:#3A4048; --accent:#7FA9D8; --ok:#5DBB84; --bad:#E07A6E; color-scheme:dark;
}
}
*{box-sizing:border-box}
[hidden]{display:none !important}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 "IBM Plex Sans",system-ui,-apple-system,Segoe UI,sans-serif}
.wrap{max-width:1180px;margin:0 auto;padding:24px 16px 56px}
.mono,.num{font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace;font-variant-numeric:tabular-nums}
h1{font-size:22px;font-weight:600;margin:0}
h2{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0;font-weight:600}
.lede{color:var(--muted);margin:2px 0 0;max-width:78ch;font-size:14px}
a{color:inherit}
button{font:inherit;color:inherit;cursor:pointer}
button:disabled{opacity:.45;cursor:default}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.row{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center}
.btn{font-size:14px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);border-radius:6px;padding:6px 13px;font-weight:500}
.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line)}
.btn.small{font-size:12.5px;padding:3px 9px}
.link{border:0;background:none;padding:0;color:var(--muted);font-size:13px;text-decoration:underline;text-underline-offset:2px}
.status{font-size:13px;color:var(--muted)}
input,select{font:inherit;font-size:14px;background:transparent;color:var(--ink);border:1px solid var(--line);border-radius:5px;padding:4px 7px}
/* controls */
.controls{display:grid;gap:12px;margin:18px 0 8px;padding-bottom:14px;border-bottom:1px solid var(--line)}
.seg{display:inline-flex;gap:14px}
.seg button{border:0;background:none;padding:2px 0;font-size:14px;color:var(--muted);border-bottom:2px solid transparent}
.seg button[aria-pressed="true"]{color:var(--ink);font-weight:600;border-color:var(--ink)}
.topicbar{display:flex;flex-wrap:wrap;gap:4px 16px;align-items:baseline}
.tt{border:0;background:none;padding:1px 0;font-size:14px;color:var(--muted)}
.tt[aria-pressed="true"]{color:var(--ink);font-weight:500;box-shadow:inset 0 -2px 0 var(--accent)}
.tt .c{font-size:12px;color:var(--faint);margin-left:4px}
/* topic editor */
.editor{display:grid;gap:6px;padding:12px 0 4px}
.erow{display:grid;grid-template-columns:20px minmax(0,14rem) minmax(0,1fr) auto;gap:8px;align-items:center}
.erow input[type=checkbox]{width:16px;height:16px;accent-color:var(--accent);margin:0}
.erow input{width:100%}
.erow input.name{font-weight:500}
.erow input[aria-invalid="true"]{border-color:var(--bad)}
.erow .err{grid-column:1/-1;color:var(--bad);font-size:12.5px;margin-top:-2px}
.erow .x{border:0;background:none;color:var(--faint);font-size:18px;line-height:1;padding:2px 6px}
.erow .x:hover{color:var(--bad)}
.presets{font-size:13px;color:var(--muted)}
@media (max-width:640px){.erow{grid-template-columns:20px minmax(0,1fr) auto}.erow .desc{grid-column:2/3;grid-row:2}}
.summary{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline}
.summary .ft{font-size:14px}
.summary .ft .c{font-size:12px;color:var(--faint);margin-left:3px}
.tl{display:grid;border-top:1px solid var(--line)}
.tr{display:grid;grid-template-columns:minmax(0,1fr) 2rem 1.6rem 1.6rem 4.6rem auto;gap:8px;align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.tr .num{font-size:12px;color:var(--muted);text-align:right}
.tr .nm{overflow-wrap:anywhere}
.tlh{font-size:11px;color:var(--faint);border-bottom:0;padding:0}
button.tn{border:0;background:none;padding:0;text-align:left;font-size:13px}
button.tn:hover{color:var(--accent);text-decoration:underline}
/* layout */
.layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,360px);gap:36px;align-items:start;margin-top:10px}
@media (max-width:900px){.layout{grid-template-columns:minmax(0,1fr)}}
.feedhead{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap;padding:6px 0}
/* feed items */
.item{padding:14px 0 12px;border-top:1px solid var(--line);display:grid;gap:6px}
.item .ttl{font-weight:600;text-decoration:none;overflow-wrap:anywhere}
.item .ttl:hover{text-decoration:underline}
.meta{font-size:12px;color:var(--faint);overflow-wrap:anywhere;margin-left:8px}
.snip{font-size:13.5px;color:var(--muted);margin:0;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.scores{display:grid;gap:1px;margin-top:2px}
.srow{display:grid;grid-template-columns:22px minmax(0,13rem) minmax(60px,160px) 2.6rem;gap:8px;align-items:center;font-size:13px}
.srow .tn{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar{position:relative;height:6px;background:var(--soft)}
.bar i{position:absolute;left:0;top:0;bottom:0;background:var(--bar)}
.bar i.on{background:var(--accent)}
.bar b{position:absolute;top:-3px;bottom:-3px;width:1px;background:var(--ink);opacity:.55}
.srow .num{font-size:12px;color:var(--muted);text-align:right}
.srow.on .tn,.srow.on .num{color:var(--ink)}
.tg{width:18px;height:18px;border:1.5px solid var(--faint);border-radius:4px;background:none;padding:0;font-size:12px;line-height:14px;text-align:center;color:transparent}
.tg[aria-pressed="true"]{border-color:var(--accent);color:var(--accent)}
.item.done .tg[aria-pressed="true"]{background:var(--ok);border-color:var(--ok);color:var(--bg)}
.item.done .tg[aria-pressed="false"]{border-color:var(--line)}
.ifoot{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;font-size:12.5px;color:var(--muted);margin-top:2px}
.ifoot .state{color:var(--faint)}
.item.done .ifoot .state{color:var(--ok)}
.ifoot .also{margin-left:auto;color:var(--faint)}
.empty{color:var(--muted);font-size:14px;padding:18px 0;border-top:1px solid var(--line)}
/* side */
aside{display:grid;gap:26px;position:sticky;top:12px}
.side{display:grid;gap:9px}
.side .hd{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.strip{width:100%;height:46px;display:block}
input[type=range]{width:100%;margin:0;accent-color:var(--accent);border:0;padding:0}
.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:-4px}
.note{font-size:12.5px;color:var(--muted);margin:0}
.queue{display:grid;max-height:360px;overflow:auto;border-top:1px solid var(--line)}
.q{display:grid;grid-template-columns:auto minmax(0,1fr) 2.4rem;gap:8px;align-items:center;padding:5px 0;border-bottom:1px solid var(--line);font-size:13px}
.q a{text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.q a:hover{text-decoration:underline}
.q .num{font-size:12px;color:var(--muted);text-align:right}
.yn{display:inline-flex;gap:3px}
.yn button{width:22px;height:20px;border:1px solid var(--line);background:none;border-radius:4px;padding:0;font-size:12px;line-height:18px;color:var(--faint)}
.yn .y[aria-pressed="true"]{background:var(--ok);border-color:var(--ok);color:var(--bg)}
.yn .n[aria-pressed="true"]{background:var(--bad);border-color:var(--bad);color:var(--bg)}
.meter{height:4px;background:var(--soft)}
.meter i{display:block;height:100%;background:var(--ok)}
.tsum{width:100%;border-collapse:collapse;font-size:12.5px}
.tsum td,.tsum th{padding:2px 0;text-align:left;font-weight:400}
.tsum th{color:var(--faint);font-size:11px}
.tsum td.num,.tsum th.num{text-align:right;width:3rem}
.warn{color:var(--bad)}
.cgroups{display:grid;gap:6px}
.cgroups details{border-top:1px solid var(--line);padding:6px 0 2px}
.cgroups summary{cursor:pointer;font-size:13px;color:var(--muted);list-style-position:outside}
.cgroups summary b{font-weight:600;color:var(--ink);letter-spacing:.02em}
.cgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(15rem,1fr));gap:2px 18px;padding:6px 0 4px}
.cgrid label{display:flex;gap:7px;align-items:center;font-size:13.5px;cursor:pointer}
.cgrid input{accent-color:var(--accent);margin:0}
.cust{font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);margin-left:4px}
.badge{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);margin-right:8px}
.badge.updated{color:var(--accent)}
.stream{font-size:12px;color:var(--faint);padding:2px 0 6px}
.flow{border-top:1px solid var(--line);padding:8px 0 10px;display:grid;gap:4px}
.flow canvas{width:100%;display:block}
.tr .src{font-size:11px;color:var(--faint);text-align:right}
.tr .src.marks{color:var(--ok)}
.tr .src.manual{color:var(--accent)}
.modes{display:inline-flex;gap:18px}
.modes button{border:0;background:none;padding:2px 0;font-size:16px;color:var(--muted);border-bottom:2px solid transparent;font-weight:500}
.modes button[aria-pressed="true"]{color:var(--ink);font-weight:600;border-color:var(--ink)}
.bctl{display:grid;gap:10px}
.chipx{display:inline-flex;gap:4px;align-items:baseline;font-size:14px}
.chipx button{border:0;background:none;padding:0 2px;color:var(--faint);font-size:15px;line-height:1}
.chipx button:hover{color:var(--bad)}
.sumbox{display:grid;gap:4px;padding:8px 0 10px;font-size:13px;color:var(--muted)}
.sumbox .co button{border:0;background:none;padding:0;color:var(--ink);text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px;font-size:13px}
.sumbox .co button:hover{text-decoration-color:var(--accent)}
.divider{display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent);padding:10px 0 2px}
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--accent);opacity:.35}
.srcl{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-right:8px}
.more{margin:12px 0}
.badge.co{color:var(--bad)}
.new-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-right:6px;vertical-align:middle}
input[type=search]{min-width:14rem}
.ticker{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--muted);padding:4px 0 2px}
.pulse{width:7px;height:7px;border-radius:50%;background:var(--faint);flex:none}
.pulse.on{background:var(--ok);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.item.fresh{animation:fresh 5s ease-out}
@keyframes fresh{0%{background:color-mix(in srgb,var(--accent) 14%,transparent);transform:translateY(-6px)}15%{transform:none}100%{background:transparent}}
@media (prefers-reduced-motion: reduce){.pulse.on{animation:none}.item.fresh{animation:none;box-shadow:inset 3px 0 0 var(--accent)}}
.item.dim{opacity:.55}
.tlabels{font-size:12px;color:var(--faint)}
.tlabels b{font-weight:500;color:var(--muted)}
.tlabels b.mine{color:var(--accent)}
.gsel{font-size:12px;margin-left:8px}
.about{margin-top:10px;font-size:13.5px;color:var(--muted);max-width:80ch}
.about summary{cursor:pointer;color:var(--ink);font-size:13px}
.about p,.about ul{margin:6px 0}
.about ul{padding-left:18px}
</style>
</head>
<body>
<div class="wrap">
<header class="row" style="justify-content:space-between;align-items:baseline">
<div>
<h1>Topic Feed Lab</h1>
<p class="lede">New Hub models, datasets and papers, tagged with 48 topics. Follow the topics you care about in <b>Live</b>, or dig into one in <b>Browse</b>. Confirm the guesses on items to tune each topic for you.</p>
<details class="about"><summary>About: how it works</summary>
<p>A small open model, <a href="https://huggingface.co/fastino/GLiNER2.5-Decide" target="_blank" rel="noopener">fastino/GLiNER2.5-Decide</a>, reads each item once and gives it a score from 0 to 1 for each of 48 topics, in one call. It runs on CPU. It is a classifier, not an LLM: it does not write text.</p>
<ul>
<li><b>What is scored:</b> new and updated public Hub models and datasets that have a real card (at least a few sentences), and papers from Hugging Face Daily Papers. The first week also includes arXiv computer-science papers from a one-off backfill. Quantized and converted models take their base model's topics. Items with no README or an empty template card are skipped.</li>
<li><b>When it shows up:</b> a worker process in this Space follows the Hub, checks whether each card changed, scores changed items and stores the scores, usually within a minute or two of the change. The page reads the stored scores.</li>
<li><b>Thresholds:</b> an item is tagged with a topic when its score reaches that topic's threshold. The defaults are set so each topic tags about as many items as a larger LLM did on a reference sample.</li>
<li><b>Your confirmations:</b> tick or untick the topics on an item and press Confirm. After 3 ✓ for a topic, its threshold is fitted to your confirmations. You can also set it by hand under calibrate. This is stored in your browser only.</li>
<li><b>Caveats:</b> the topics are zero-shot, so some tags are wrong, and broad topics catch loosely related items. Likes and downloads are a snapshot from when the item was scored.</li>
</ul>
</details>
</div>
</header>
<section class="controls" aria-label="Controls">
<div class="row" style="justify-content:space-between">
<div class="modes" id="modes" role="tablist" aria-label="Mode"></div>
<span class="row" style="gap:12px"><span class="status" id="status">Connecting…</span><button class="link" id="copyLink" title="Copy a link to this view">copy link</button></span>
</div>
<div class="summary" id="summary"><h2 style="margin-right:4px">Your feed</h2><span id="feedTopics" class="summary"></span><button class="link" id="editBtn" aria-expanded="false">choose topics</button></div>
<div class="bctl" id="bctl" hidden>
<div class="summary"><h2 style="margin-right:4px">Browse</h2><span id="browseTopics" class="summary"></span><button class="link" id="bEditBtn" aria-expanded="false">add topics</button></div>
<div class="row">
<span class="seg" id="daysSeg" role="group" aria-label="Time range"></span>
<span class="row" id="srcBoxes" style="gap:10px" role="group" aria-label="Sources"></span>
<input type="search" id="q" placeholder="search title, id, snippet, tags" aria-label="Search">
<label class="status" title="Repos whose card is up but that have no data or weight files yet"><input type="checkbox" id="coBrowse"> include card-only (no data yet)</label>
<label class="status">sort <select id="bsort"><option value="score">topic score</option><option value="newest">newest</option><option value="likes">likes</option><option value="downloads">downloads / upvotes</option><option value="trending">trending</option></select></label>
</div>
</div>
<div class="editor" id="editor" hidden>
<div class="row"><h2 id="editorTitle">Choose your feed topics</h2><span class="gsel">select <button class="link" data-all="*">all</button> · <button class="link" data-none="*">none</button></span></div>
<p class="note">All 48 catalogue topics are scored together, so their scores are comparable. Hover a name to see what it covers.</p>
<div class="cgroups" id="cgroups"></div>
<p class="note">Custom topics need the model, so they are coming via the worker.</p>
<div class="row"><button class="btn small" id="doneBtn" style="margin-left:auto">Done</button></div>
</div>
</section>
<div class="layout">
<section aria-label="Items">
<div class="feedhead"><h2 id="listTitle">Live</h2><span class="note row" style="gap:10px">
<span class="seg" id="scopeSeg" role="group" aria-label="Which items"></span>
<label class="status" title="Repos whose card is up but that have no data or weight files yet"><input type="checkbox" id="coLive"> card-only</label>
<span id="feedCount"></span><button class="link" id="flowBtn" aria-expanded="true">hide how it works</button></span></div>
<div class="ticker" id="tickerBox"><span class="pulse" id="pulse"></span><span id="ticker">Checking the worker…</span></div>
<div class="stream mono" id="stream"></div>
<div class="flow" id="flowBox">
<canvas id="flow" height="160" aria-label="Replay of the latest arrivals moving through the worker, the model and your topics" role="img"></canvas>
<div class="row note mono" style="justify-content:space-between"><span id="flowStats"></span><button class="link" id="replay">replay</button></div>
</div>
<div class="sumbox" id="sumbox" hidden></div>
<div id="feed"><div class="empty">Loading about 28K scored items…</div></div>
<button class="btn ghost small more" id="more" hidden>Show more</button>
</section>
<aside>
<section class="side" id="topicPanel" aria-label="Feed topics">
<div class="hd"><h2>Feed topics</h2><button class="link" id="editBtn2">choose</button></div>
<div class="tl" id="tlist"></div>
<p class="note">An item enters your feed when a topic's score reaches its threshold. The threshold comes from your confirmations once a topic has 3 ✓; before that it is the catalogue default. Moving the slider sets it by hand.</p>
</section>
<section class="side" id="calPanel" aria-label="Calibrate" hidden>
<div class="hd"><h2>Calibrating: <span id="calName" style="color:var(--ink);text-transform:none;letter-spacing:0;font-size:14px"></span></h2><button class="btn ghost small" id="calDone">Done</button></div>
<svg class="strip" id="strip" viewBox="0 0 300 46" preserveAspectRatio="none" role="img" aria-label="Score distribution"></svg>
<input type="range" id="thr" min="0.01" max="0.99" step="0.01" aria-label="Threshold">
<div class="axis mono"><span>0</span><span>threshold <span id="thrVal"></span></span><span>1</span></div>
<p class="note">Ticks are this topic's scores for the items you have checked and the items on screen. Top row: you said it belongs. Bottom row: it doesn't. Middle: not checked.</p>
<p class="note mono" id="calStats"></p>
<div class="row"><button class="link" id="resetThr">use automatic threshold</button><button class="link" id="copy">copy my data</button></div>
<div class="queue" id="queue"></div>
</section>
</aside>
</div>
</div>
<script type="module">
// JSON API: GET /api/<name>. Arrays become repeated params (?topics=a&topics=b); objects are sent as JSON strings.
async function api(name, params = {}){
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)){
if (v === undefined || v === null) continue;
if (Array.isArray(v)) v.forEach(x => q.append(k, x));
else if (typeof v === "object") q.append(k, JSON.stringify(v));
else q.append(k, String(v));
}
const r = await fetch(`api/${name}?${q}`, {headers: {Accept: "application/json"}});
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
}
const MIN_POS_FOR_MARKS = 3; // positives needed before a topic's threshold comes from the user's confirmations
const PAGE = 30, POLL_MS = 20000, EVENTS_MS = 8000;
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const store = {
get(k, d){ try { const v = localStorage.getItem("tfl-" + k); return v ? JSON.parse(v) : d; } catch(e){ return d; } },
set(k, v){ try { localStorage.setItem("tfl-" + k, JSON.stringify(v)); } catch(e){} }
};
const reduceMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const fmt = n => n == null ? "" : n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : String(n);
/* ---------- state ---------- */
let CAT = null, narrowOf = {};
let mode = store.get("mode", "live");
let selected = store.get("selected", ["agents and tool use", "cultural heritage and history", "documents and OCR"]);
let browseSel = store.get("browseSel", ["robotics"]);
let bDays = store.get("bDays", 7), bSources = store.get("bSources", ["model", "dataset", "paper"]), bSort = store.get("bSort", "score");
let manualThr = {};
// confirmed: {itemKey: {title, url, text, src, topics: [positives], considered: [topics judged], scores, cat}}
let confirmed = store.get("confirmed", {});
const prevVisit = store.get("lastVisit", null); // shown as "new since your last visit"
let items = [], total = 0, offset = 0, lastLive = null, lastBrowse = null, calTopic = null, editing = null;
let marksThr = {};
let liveAll = store.get("liveAll", false), flowOpen = store.get("flowOpen", true), cardOnly = store.get("cardOnly", false);
/* ---------- deep links ----------
?view=live|browse&topics=a,b&items=your|all&days=7&sources=model,dataset&sort=likes&q=...&how=closed&cardonly=1
URL params set this page view only; saved preferences change only when the user edits something. */
const SPACE_URL = "https://huggingface.co/spaces/davanstrien/topic-feed-lab"; // forwards its query to this app
(function readUrl(){
const u = new URLSearchParams(location.search), list = k => (u.get(k) || "").split(",").map(x => x.trim()).filter(Boolean);
if (["live", "browse"].includes(u.get("view"))) mode = u.get("view");
if (u.has("topics")) (mode === "browse" ? (browseSel = list("topics")) : (selected = list("topics")));
if (u.has("items")) liveAll = u.get("items") === "all";
if (u.has("days") && [0, 1, 3, 7].includes(+u.get("days"))) bDays = +u.get("days");
if (u.has("sources")) bSources = list("sources").filter(x => ["model", "dataset", "paper"].includes(x));
if (["score", "newest", "likes", "downloads", "trending"].includes(u.get("sort"))) bSort = u.get("sort");
if (u.has("how")) flowOpen = u.get("how") !== "closed";
if (u.has("cardonly")) cardOnly = u.get("cardonly") === "1";
if (u.has("q")) queueMicrotask(() => { $("q").value = u.get("q"); });
})();
function viewQuery(){
const q = new URLSearchParams();
q.set("view", mode);
const ts = mode === "live" ? selected : browseSel;
if (ts.length) q.set("topics", ts.join(","));
if (mode === "live"){ if (liveAll) q.set("items", "all"); if (!flowOpen) q.set("how", "closed"); }
else {
q.set("days", bDays);
if (bSources.length && bSources.length < 3) q.set("sources", bSources.join(","));
if (bSort !== "score") q.set("sort", bSort);
if ($("q").value.trim()) q.set("q", $("q").value.trim());
}
if (cardOnly) q.set("cardonly", "1");
return "?" + q.toString();
}
function syncUrl(){
const qs = viewQuery();
try { history.replaceState(null, "", location.pathname + qs); } catch (e) {}
// Embedded on huggingface.co: ask the Space page to show the same query in its address bar.
if (window.parent !== window) try { window.parent.postMessage({queryString: qs}, "https://huggingface.co"); } catch (e) {}
}
async function copyText(t){
try { await navigator.clipboard.writeText(t); return true; } catch (e) {}
const ta = document.createElement("textarea"); ta.value = t; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
let ok = false; try { ok = document.execCommand("copy"); } catch (e) {}
ta.remove(); return ok;
}
const drafts = {}, draftRows = {};
let seenKeys = new Set(), freshKeys = new Set();
const byKey = new Map();
const saveConfirmed = () => store.set("confirmed", confirmed);
const thrKey = () => "thr-" + (CAT?.catalogue_version || "none");
const key = it => it.source + ":" + it.id;
const raw = (scores, t) => scores?.[t] ?? 0;
const eff = (scores, t) => Math.max(raw(scores, t), ...(narrowOf[t] || []).map(n => raw(scores, n))); // broad = max(broad, narrow)
const sc = (it, t) => eff(it.scores, t);
const judged = (k, t) => { const r = confirmed[k]; return r && r.considered.includes(t) ? r.topics.includes(t) : undefined; };
const defaultThr = t => CAT?.thresholds?.[t] ?? 0.21;
const thrSource = t => t in manualThr ? "manual" : marksThr[t] != null ? "marks" : "default";
const thr = t => manualThr[t] ?? marksThr[t] ?? defaultThr(t);
const thrMap = ts => Object.fromEntries(ts.map(t => [t, thr(t)]));
const topicNames = () => CAT?.labels || [];
const feedTopics = () => selected.filter(t => topicNames().includes(t));
const browseTopics = () => browseSel.filter(t => topicNames().includes(t));
const shown = () => mode === "live" ? feedTopics() : browseTopics();
/* ---------- topic pickers ---------- */
const openGroups = new Set(["domains", "modalities", "methods"]);
function target(){ return editing === "browse" ? browseSel : selected; }
function setTarget(v){ if (editing === "browse"){ browseSel = v; store.set("browseSel", v); } else { selected = v; store.set("selected", v); } }
function renderEditor(){
if (!CAT || !editing) return;
$("editorTitle").textContent = editing === "browse" ? "Choose topics to browse" : "Choose your feed topics";
const sel = target();
$("cgroups").innerHTML = Object.entries(CAT.groups).map(([g, ts]) => {
const n = ts.filter(t => sel.includes(t)).length;
return `<details data-g="${esc(g)}" ${openGroups.has(g) ? "open" : ""}><summary><b>${esc(g[0].toUpperCase() + g.slice(1))}</b> · ${n} of ${ts.length} chosen <span class="gsel"><button class="link" data-all="${esc(g)}">all</button> · <button class="link" data-none="${esc(g)}">none</button></span></summary>
<div class="cgrid">${ts.map(t => `<label title="${esc(CAT.notes[t] || "")}"><input type="checkbox" data-t="${esc(t)}" ${sel.includes(t) ? "checked" : ""}>${esc(t)}</label>`).join("")}</div></details>`;
}).join("");
}
$("editor").addEventListener("click", e => {
const b = e.target.closest("[data-all],[data-none]"); if (!b) return;
e.preventDefault(); e.stopPropagation();
const g = b.dataset.all ?? b.dataset.none, ts = g === "*" ? CAT.labels : CAT.groups[g];
const sel = target();
setTarget("all" in b.dataset ? [...new Set([...sel, ...ts])] : sel.filter(t => !ts.includes(t)));
renderEditor(); renderToggles();
});
$("cgroups").addEventListener("toggle", e => { const g = e.target.dataset?.g; if (g) e.target.open ? openGroups.add(g) : openGroups.delete(g); }, true);
$("cgroups").addEventListener("change", e => {
const t = e.target.dataset.t; if (!t) return;
const sel = target();
setTarget(e.target.checked ? [...new Set([...sel, t])] : sel.filter(x => x !== t));
renderEditor(); renderToggles();
});
function setEditing(which){
editing = which;
$("editor").hidden = !which;
$("summary").hidden = which === "feed" || mode !== "live";
$("editBtn").setAttribute("aria-expanded", which === "feed"); $("bEditBtn").setAttribute("aria-expanded", which === "browse");
if (which){ renderEditor(); $("editor").scrollIntoView({block: "nearest"}); }
else { renderAll(); reload(); }
}
$("editBtn").onclick = () => setEditing("feed");
$("editBtn2").onclick = () => setEditing("feed");
$("bEditBtn").onclick = () => setEditing("browse");
$("doneBtn").onclick = () => setEditing(null);
/* ---------- thresholds from confirmations ---------- */
function recScores(k, r){
if (r.scores && r.cat === CAT?.catalogue_version) return r.scores;
return byKey.get(k)?.scores || null;
}
function judgedRows(t){
const out = [];
for (const [k, r] of Object.entries(confirmed)){
if (!r.considered.includes(t)) continue;
const s = recScores(k, r); if (!s) continue;
out.push({k, s: eff(s, t), v: r.topics.includes(t)});
}
return out;
}
function counts(t){
let pos = 0, neg = 0;
for (const r of Object.values(confirmed)) if (r.considered.includes(t)) r.topics.includes(t) ? pos++ : neg++;
return {pos, neg};
}
function fitMarks(t){
const rows = judgedRows(t);
if (rows.filter(r => r.v).length < MIN_POS_FOR_MARKS) return null;
let bestF = -1, plateau = [];
for (let i = 1; i <= 99; i++){ // max-F1 thresholds; take the middle of the best plateau
const th = i / 100; let tp = 0, fp = 0, fn = 0;
for (const r of rows){ const p = r.s >= th; if (p && r.v) tp++; else if (p) fp++; else if (r.v) fn++; }
const f = 2 * tp / Math.max(1, 2 * tp + fp + fn);
if (f > bestF + 1e-9){ bestF = f; plateau = [th]; } else if (Math.abs(f - bestF) <= 1e-9) plateau.push(th);
}
return plateau[Math.floor((plateau.length - 1) / 2)];
}
function refitMarks(){ marksThr = {}; for (const t of topicNames()) marksThr[t] = fitMarks(t); }
/* ---------- summaries + topic panel ---------- */
const passCount = t => items.length ? items.filter(it => sc(it, t) >= thr(t)).length : null;
function renderToggles(){
const feed = feedTopics();
$("feedTopics").innerHTML = (feed.length ? feed.map(t => `<span class="ft">${esc(t)}</span>`).join('<span class="note">·</span>')
: `<span class="note">No topics chosen.</span>`) + `<span class="note">(${feed.length} of ${topicNames().length})</span>`;
const bt = browseTopics();
$("browseTopics").innerHTML = bt.length ? bt.map(t => `<span class="chipx">${esc(t)}<button data-rm="${esc(t)}" aria-label="Remove ${esc(t)}" title="Remove">×</button></span>`).join("")
: `<span class="note">All topics (add some to narrow down).</span>`;
$("tlist").innerHTML = `<div class="tr tlh"><span>topic</span><span class="num">here</span><span class="num">✓</span><span class="num">✗</span><span class="num">threshold</span><span></span></div>` +
(feed.length ? feed.map(t => { const n = mode === "live" ? passCount(t) : null, c = counts(t), src = thrSource(t);
return `<div class="tr"><span class="nm" title="${esc(CAT?.notes?.[t] || t)}">${esc(t)}</span><span class="num">${n ?? "–"}</span>
<span class="num">${c.pos}</span><span class="num">${c.neg}</span>
<span class="src ${src}" title="${src === "marks" ? "Fitted on your confirmations" : src === "manual" ? "Set by hand" : `Catalogue default; needs ${MIN_POS_FOR_MARKS} ✓ to use yours`}"><span class="mono">${thr(t).toFixed(2)}</span> ${src === "marks" ? "yours" : src}</span>
<button class="link" data-cal="${esc(t)}">calibrate</button></div>`; }).join("")
: `<p class="note" style="padding:6px 0">Choose topics with <b>choose</b>.</p>`);
}
$("browseTopics").addEventListener("click", e => { const t = e.target.dataset.rm; if (!t) return; browseSel = browseSel.filter(x => x !== t); store.set("browseSel", browseSel); renderToggles(); reload(); });
function openCal(t){ calTopic = t; renderCal(); $("calPanel").scrollIntoView({block: "nearest"}); }
$("tlist").addEventListener("click", e => { const b = e.target.closest("[data-cal]"); if (b) openCal(b.dataset.cal); });
$("calDone").onclick = () => { calTopic = null; renderCal(); };
/* ---------- modes + browse controls ---------- */
function renderControls(){
$("coLive").checked = $("coBrowse").checked = cardOnly;
$("coLive").closest("label").hidden = mode !== "live";
$("modes").innerHTML = [["live","Live"],["browse","Browse"]].map(([k,n]) => `<button role="tab" aria-pressed="${k===mode}" data-k="${k}">${n}</button>`).join("");
$("summary").hidden = mode !== "live" || editing === "feed"; $("bctl").hidden = mode !== "browse";
$("listTitle").textContent = mode === "live" ? "Live · newest first" : "Browse";
$("flowBtn").hidden = mode !== "live";
$("flowBox").hidden = mode !== "live" || !flowOpen; if ($("flowBox").hidden) cancelAnimationFrame(flowRAF);
$("flowBtn").textContent = flowOpen ? "hide how it works" : "how it works"; $("flowBtn").setAttribute("aria-expanded", flowOpen);
$("stream").hidden = $("tickerBox").hidden = mode !== "live"; $("sumbox").hidden = mode !== "browse";
$("scopeSeg").hidden = mode !== "live";
$("scopeSeg").innerHTML = [[false, "your topics"], [true, "all items"]].map(([v, n]) => `<button aria-pressed="${v === liveAll}" data-v="${v}">${n}</button>`).join("");
$("daysSeg").innerHTML = [[1,"today"],[3,"3 days"],[7,"7 days"],[0,"all"]].map(([d,n]) => `<button aria-pressed="${d===bDays}" data-d="${d}"${d ? ` title="Hub items: the last ${d * 24} h. Papers: the latest ${d} paper day(s)."` : ""}>${n}</button>`).join("");
$("srcBoxes").innerHTML = [["model","models"],["dataset","datasets"],["paper","papers"]].map(([s,n]) => `<label class="status"><input type="checkbox" data-s="${s}" ${bSources.includes(s) ? "checked" : ""}> ${n}</label>`).join("");
$("bsort").value = bSort;
}
$("modes").addEventListener("click", e => { const b = e.target.closest("button"); if (!b || b.dataset.k === mode) return; mode = b.dataset.k; store.set("mode", mode); if (editing) setEditing(null); renderControls(); renderToggles(); reload(); });
$("scopeSeg").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; liveAll = b.dataset.v === "true"; store.set("liveAll", liveAll); renderControls(); reload(); });
$("daysSeg").addEventListener("click", e => { const b = e.target.closest("button"); if (!b) return; bDays = +b.dataset.d; store.set("bDays", bDays); renderControls(); reload(); });
$("srcBoxes").addEventListener("change", e => { const s = e.target.dataset.s; bSources = e.target.checked ? [...new Set([...bSources, s])] : bSources.filter(x => x !== s); store.set("bSources", bSources); reload(); });
$("bsort").onchange = e => { bSort = e.target.value; store.set("bSort", bSort); reload(); };
let qTimer = null;
$("q").addEventListener("input", () => { clearTimeout(qTimer); qTimer = setTimeout(reload, 300); });
/* ---------- data ---------- */
let reqId = 0;
async function reload(append = false, poll = false){
if (!CAT || editing) return;
if (!append && !poll) syncUrl();
const my = ++reqId;
if (!append){ offset = 0; }
try {
let d;
if (mode === "live"){
d = (await api("live", {topics: feedTopics(), thresholds: thrMap(feedTopics()), limit: PAGE, offset, since: prevVisit || "", all_items: liveAll, card_only: cardOnly}));
lastLive = d;
} else {
d = (await api("browse", {topics: browseTopics(), thresholds: thrMap(browseTopics()), days: bDays, sources: bSources,
q: $("q").value, sort: bSort, limit: PAGE, offset, match: "any", card_only: cardOnly}));
lastBrowse = d;
}
if (my !== reqId) return; // a newer request superseded this one
for (const it of d.items) if (it.s){ it.scores = Object.fromEntries(CAT.labels.map((l, i) => [l, it.s[i]])); delete it.s; }
if (mode === "live" && !append && poll && seenKeys.size) freshKeys = new Set(d.items.map(key).filter(k => !seenKeys.has(k)));
else if (!append) freshKeys = new Set();
if (mode === "live" && !append) seenKeys = new Set(d.items.map(key));
items = append ? [...items, ...d.items] : d.items; total = d.total;
for (const it of d.items) byKey.set(key(it), it);
renderStatus(d.status); refitMarks(); renderAll();
if (mode === "live" && !append){ store.set("lastVisit", new Date().toISOString()); if (!$("flowBox").hidden && !poll) flowStart(); }
} catch (e) {
const msg = e?.message || String(e);
$("status").textContent = "Could not load items: " + msg;
if (!append && !items.length) $("feed").innerHTML = `<div class="empty">Could not load items (${esc(msg)}). The Space may be starting; reload the page in a minute.</div>`;
}
}
$("more").onclick = () => { offset += PAGE; reload(true); };
setInterval(() => { if (mode === "live" && !document.hidden && !Object.keys(drafts).length && offset === 0) reload(false, true); }, POLL_MS);
const ago = iso => { if (!iso) return ""; const m = (Date.now() - Date.parse(iso)) / 60000;
return m < 1 ? "just now" : m < 60 ? `${Math.round(m)} min ago` : m < 48 * 60 ? `${Math.round(m / 60)} h ago` : `${Math.round(m / 1440)} d ago`; };
const hhmm = iso => iso ? new Date(iso).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}) : "–";
function renderStatus(st){
if (!st) return;
lastStatus = st; lastListAt = Date.now(); renderStatusLine();
const ws = st.worker_status;
let worker = "backfill only (no worker yet)";
if (ws && !ws.error){
const srcs = Object.values(ws.sources || {});
const scoredH = srcs.reduce((a, x) => a + (x.last_hour?.scored || 0), 0);
const lat = ws.sources?.model?.latency_modified_to_scored_s?.last_hour_p50 ?? ws.sources?.model?.latency_modified_to_scored_s?.p50;
const stale = ws.updated_at && Date.now() - Date.parse(ws.updated_at) > 15 * 60000;
worker = `worker ${stale ? `silent since ${ago(ws.updated_at)}` : esc(ws.state || "running")} · ${scoredH} scored in the last hour`
+ (ws.queue?.total != null ? ` · queue ${ws.queue.total.toLocaleString()}` : "")
+ (lat ? ` · typical delay ${Math.round(lat / 60)} min after a change` : "");
}
$("stream").textContent = `stream · ${st.last_24h.toLocaleString()} items changed in 24 h · ${worker}`;
}
let lastStatus = null, lastListAt = 0;
function renderStatusLine(){
const st = lastStatus; if (!st) return;
const s = Math.round((Date.now() - lastListAt) / 1000);
$("status").textContent = st.error ? `Data problem: ${st.error}` : `${st.rows.toLocaleString()} items · newest ${ago(st.newest)} · list refreshed ${s < 60 ? s + " s" : Math.round(s / 60) + " min"} ago`;
}
setInterval(renderStatusLine, 1000);
/* ---------- items ---------- */
function stateOf(k, it, t){
if (drafts[k]) return drafts[k].has(t);
const j = judged(k, t);
return j !== undefined ? j : sc(it, t) >= thr(t);
}
// Topics shown as rows on an item (and judged by Confirm). With many topics chosen (e.g. all 48), only the ones
// above threshold or already judged are shown, at most 6; the set is frozen while the item is being edited.
function rowTopics(it){
const k = key(it), sel = shown();
if (draftRows[k]) return draftRows[k];
if (sel.length <= 6) return sel;
return sel.filter(t => sc(it, t) >= thr(t) || judged(k, t) !== undefined).sort((a, b) => sc(it, b) - sc(it, a)).slice(0, 6);
}
function itemStatus(k, it){
if (drafts[k]) return "edited, not saved";
const r = confirmed[k];
if (!r) return "model guess";
return rowTopics(it).every(t => r.considered.includes(t)) ? "confirmed" : "partly confirmed";
}
function viaNarrow(it, t){
const b = raw(it.scores, t), n = (narrowOf[t] || []).find(x => raw(it.scores, x) > b && raw(it.scores, x) === sc(it, t));
return n ? ` title="Score from narrower topic: ${esc(n)}"` : "";
}
const isNew = it => prevVisit && it.t && Date.parse(it.t) > Date.parse(prevVisit);
function itemHTML(it){
const k = key(it), sel = rowTopics(it), status = itemStatus(k, it), done = status === "confirmed";
const above = topicNames().filter(t => sc(it, t) >= thr(t)).sort((a, b) => (sc(it, b) - thr(b)) - (sc(it, a) - thr(a)));
const mine = new Set(shown());
const rows = sel.map(t => { const s = sc(it, t), on = s >= thr(t), p = stateOf(k, it, t), via = viaNarrow(it, t);
return `<div class="srow ${on ? "on" : ""}">
<button class="tg" aria-pressed="${p}" data-t="${esc(t)}" title="${p ? "Belongs" : "Doesn't belong"}: click to change" aria-label="${esc(t)}: belongs">✓</button>
<button class="tn" data-cal="${esc(t)}" title="Calibrate ${esc(t)}">${esc(t)}</button>
<span class="bar" aria-hidden="true"><i class="${on ? "on" : ""}" style="width:${(s*100).toFixed(1)}%"></i><b style="left:${(thr(t)*100).toFixed(1)}%"></b></span>
<span class="num"${via}>${s.toFixed(2)}${via ? "*" : ""}</span></div>`; }).join("");
const pop = it.source === "paper" ? (it.upvotes ? `▲ ${fmt(it.upvotes)}` : "") : [it.trend ? `↑ ${fmt(it.trend)} trending` : "", it.likes ? `♥ ${fmt(it.likes)}` : "", it.downloads ? `↓ ${fmt(it.downloads)}` : ""].filter(Boolean).join(" ");
const when = it.date_only ? new Date(it.t).toLocaleDateString([], {month: "short", day: "numeric", timeZone: "UTC"})
: it.badge === "new" && it.created ? `created ${ago(it.created)}` : ago(it.modified || it.t);
const badge = it.source === "paper" ? "" : `<span class="badge ${esc(it.badge)}">${esc(it.badge)}</span>`;
const labels = above.length ? `<div class="tlabels">topics: ${above.slice(0, 6).map(t => `<b class="${mine.has(t) ? "mine" : ""}">${esc(t)}</b>`).join(" · ")}${above.length > 6 ? ` · +${above.length - 6}` : ""}</div>`
: `<div class="tlabels">no topic above threshold</div>`;
return `<article class="item ${done ? "done" : ""} ${above.length ? "" : "dim"} ${freshKeys.has(k) ? "fresh" : ""}" data-k="${esc(k)}">
<div>${mode === "live" && isNew(it) ? `<span class="new-dot" title="New since your last visit"></span>` : ""}<span class="srcl">${esc(it.source)}</span>${badge}${it.card_only ? `<span class="badge co" title="Card only: no data or weight files yet">card only</span>` : ""}<span class="badge">${esc(when)}</span>
<a class="ttl" href="${esc(it.url)}" target="_blank" rel="noopener">${esc(it.title)}</a>
<span class="meta mono">${esc(pop)}${it.tags?.length ? " · " + esc(it.tags.slice(0, 5).join(" · ")) : ""}</span></div>
${it.snip ? `<p class="snip">${esc(it.snip)}</p>` : ""}
${labels}
${sel.length ? `<div class="scores">${rows}</div>
<div class="ifoot"><button class="btn small ${done ? "ghost" : ""}" data-a="confirm">${done ? "Confirmed" : "Confirm"}</button>
<button class="btn ghost small" data-a="none">None of these</button>
${confirmed[k] ? `<button class="link" data-a="clear">undo</button>` : ""}
<span class="state">${status}</span></div>` : ""}
</article>`;
}
function renderFeed(){
if (!CAT) return;
$("feedCount").textContent = items.length ? `${items.length} of ${total.toLocaleString()}` : "";
$("more").hidden = items.length >= total;
if (mode === "browse") renderSummary();
if (!items.length){
$("feed").innerHTML = `<div class="empty">${mode === "live" ? (liveAll ? "No items yet." : feedTopics().length ? "No items pass the thresholds for your topics yet. Try “all items”." : "Choose some topics for your feed, or look at “all items”.") : "No items match. Widen the time range or remove a filter."}</div>`;
return;
}
let html = "", marked = false;
const newCount = mode === "live" ? items.filter(isNew).length : 0;
items.forEach((it, i) => {
if (mode === "live" && prevVisit && !marked && !isNew(it) && i > 0){
html += `<div class="divider">earlier than your last visit (${hhmm(prevVisit)})</div>`; marked = true;
}
html += itemHTML(it);
});
if (mode === "live" && newCount) html = `<div class="divider">${lastLive?.new_since ?? newCount} new since your last visit</div>` + html;
$("feed").innerHTML = html;
}
function renderSummary(){
const s = lastBrowse?.summary; if (!s){ $("sumbox").innerHTML = ""; return; }
const src = ["model", "dataset", "paper"].filter(k => s.by_source[k]).map(k => `${s.by_source[k].toLocaleString()} ${k}s`).join(" · ");
$("sumbox").innerHTML = `<div><span class="mono" style="color:var(--ink)">${s.total.toLocaleString()}</span> items${src ? ": " + src : ""}</div>
${s.co_topics.length ? `<div class="co">${browseTopics().length ? "Also about" : "Most common topics"}: ${s.co_topics.slice(0, 8).map(c => `<button data-add="${esc(c.topic)}" title="Add ${esc(c.topic)} to the browse topics">${esc(c.topic)}</button> <span class="mono">${c.n}</span>`).join(" · ")}</div>` : ""}`;
}
$("sumbox").addEventListener("click", e => { const t = e.target.dataset.add; if (!t) return; browseSel = [...new Set([...browseSel, t])]; store.set("browseSel", browseSel); renderToggles(); reload(); });
function rerenderItem(k){
const el = $("feed").querySelector(`.item[data-k="${CSS.escape(k)}"]`);
const it = byKey.get(k); if (el && it) el.outerHTML = itemHTML(it);
}
const snapshot = it => ({scores: it.scores, cat: CAT.catalogue_version});
const recFor = (it, k) => ({title: it.title, url: it.url, text: (it.title + ". " + (it.snip || "")).slice(0, 500), src: it.source, ...snapshot(it)});
function saveItem(k, positives){
const it = byKey.get(k), old = confirmed[k], sel = rowTopics(it);
const keepPos = (old?.topics || []).filter(t => !sel.includes(t));
confirmed[k] = {...recFor(it, k), topics: [...new Set([...keepPos, ...positives])], considered: [...new Set([...(old?.considered || []), ...sel])]};
delete drafts[k]; delete draftRows[k]; saveConfirmed(); afterMarks(k);
}
$("feed").addEventListener("click", e => {
const el = e.target.closest(".item"); if (!el) return;
const cal = e.target.closest("[data-cal]"); if (cal){ openCal(cal.dataset.cal); return; }
const k = el.dataset.k, it = byKey.get(k), tg = e.target.closest(".tg"), a = e.target.closest("[data-a]")?.dataset.a;
const sel = rowTopics(it);
if (tg){
draftRows[k] ||= sel;
const d = drafts[k] ||= new Set(sel.filter(t => stateOf(k, it, t)));
d.has(tg.dataset.t) ? d.delete(tg.dataset.t) : d.add(tg.dataset.t);
rerenderItem(k);
} else if (a === "confirm") saveItem(k, sel.filter(t => stateOf(k, it, t)));
else if (a === "none") saveItem(k, []);
else if (a === "clear"){ delete confirmed[k]; delete drafts[k]; delete draftRows[k]; saveConfirmed(); afterMarks(k); }
});
function afterMarks(k){ refitMarks(); if (k) rerenderItem(k); renderToggles(); renderCal(); }
/* ---------- calibration ---------- */
function metrics(t, th){
let tp = 0, fp = 0, fn = 0;
const rows = judgedRows(t);
for (const r of rows){ const p = r.s >= th; if (p && r.v) tp++; else if (p) fp++; else if (r.v) fn++; }
return {n: rows.length, p: tp+fp ? tp/(tp+fp) : null, r: tp+fn ? tp/(tp+fn) : null};
}
function renderStrip(t){
// Checked items (stored scores) plus unchecked items on screen.
const pts = judgedRows(t).map(r => ({s: r.s, v: r.v}));
for (const it of items) if (judged(key(it), t) === undefined) pts.push({s: sc(it, t), v: undefined});
const ticks = pts.map(p => {
const x = (p.s * 300).toFixed(1);
const [y1, y2, c, o] = p.v === true ? [2, 14, "var(--ok)", 1] : p.v === false ? [32, 44, "var(--bad)", 1] : [18, 28, "var(--faint)", .6];
return `<line x1="${x}" x2="${x}" y1="${y1}" y2="${y2}" stroke="${c}" stroke-opacity="${o}" stroke-width="1.5" vector-effect="non-scaling-stroke"/>`;
}).join("");
const tx = (thr(t) * 300).toFixed(1);
$("strip").innerHTML = `<line x1="0" x2="300" y1="23" y2="23" stroke="var(--line)" vector-effect="non-scaling-stroke"/>${ticks}
<line x1="${tx}" x2="${tx}" y1="0" y2="46" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>`;
}
function renderCal(){
if (calTopic && !topicNames().includes(calTopic)) calTopic = null;
$("calPanel").hidden = !calTopic; $("topicPanel").hidden = !!calTopic;
if (!calTopic) return;
const t = calTopic, src = thrSource(t), c = counts(t);
$("calName").textContent = t;
$("thr").value = thr(t); $("thrVal").textContent = thr(t).toFixed(2) + (src === "marks" ? " (yours)" : src === "manual" ? " (by hand)" : " (default)");
$("resetThr").hidden = src !== "manual";
renderStrip(t);
const m = metrics(t, thr(t)), pct = x => x === null ? "–" : Math.round(x * 100) + "%";
const auto = marksThr[t] != null ? `from your marks ${marksThr[t].toFixed(2)}` : `needs ${MIN_POS_FOR_MARKS - Math.min(c.pos, MIN_POS_FOR_MARKS)} more ✓ for yours`;
$("calStats").textContent = `${c.pos} ✓ · ${c.neg} ✗ · precision ${pct(m.p)} · recall ${pct(m.r)} · ${auto}`;
const byScore = items.slice().sort((a, b) => sc(b, t) - sc(a, t));
const top = byScore.slice(0, 10);
const near = byScore.slice(10).sort((a, b) => Math.abs(sc(a, t) - thr(t)) - Math.abs(sc(b, t) - thr(t))).slice(0, 15);
$("queue").innerHTML = [...top, ...near].map(it => { const k = key(it), v = judged(k, t);
return `<div class="q" data-k="${esc(k)}"><span class="yn"><button class="y" aria-pressed="${v === true}" data-v="1" aria-label="belongs" title="Belongs to ${esc(t)}">✓</button><button class="n" aria-pressed="${v === false}" data-v="0" aria-label="doesn't belong" title="Doesn't belong">✗</button></span>
<a href="${esc(it.url)}" target="_blank" rel="noopener" title="${esc(it.snip)}">${esc(it.title)}</a><span class="num">${sc(it, t).toFixed(2)}</span></div>`; }).join("")
|| `<p class="note" style="padding:6px 0">No items on screen. Browse this topic to find some.</p>`;
}
$("queue").addEventListener("click", e => {
const b = e.target.closest(".yn button"); if (!b) return;
const k = b.closest(".q").dataset.k, v = b.dataset.v === "1", t = calTopic, it = byKey.get(k);
const r = confirmed[k] ||= {...recFor(it, k), topics: [], considered: []};
Object.assign(r, snapshot(it));
if (judged(k, t) === v){ r.considered = r.considered.filter(x => x !== t); r.topics = r.topics.filter(x => x !== t); }
else { if (!r.considered.includes(t)) r.considered.push(t); r.topics = v ? [...new Set([...r.topics, t])] : r.topics.filter(x => x !== t); }
if (!r.considered.length) delete confirmed[k];
delete drafts[k]; saveConfirmed(); afterMarks(k);
});
let thrTimer = null;
$("thr").oninput = e => { manualThr[calTopic] = +e.target.value; store.set(thrKey(), manualThr); renderToggles(); renderFeed(); renderCal();
clearTimeout(thrTimer); thrTimer = setTimeout(() => reload(), 500); };
$("resetThr").onclick = () => { delete manualThr[calTopic]; store.set(thrKey(), manualThr); renderAll(); reload(); };
$("copy").onclick = () => navigator.clipboard.writeText(JSON.stringify({catalogue_version: CAT?.catalogue_version, manual_thresholds: manualThr, marks_thresholds: marksThr,
items: Object.entries(confirmed).map(([k, r]) => ({key: k, text: r.text, src: r.src, topics: r.topics, considered: r.considered}))}, null, 1))
.then(() => { $("copy").textContent = "copied"; setTimeout(() => $("copy").textContent = "copy my data", 1500); })
.catch(() => { $("copy").textContent = "copy failed"; });
/* ---------- worker ticker ---------- */
let evts = [], evSeen = new Set(), lastEvT = "", evInfo = null;
const agoS = iso => { const s = (Date.now() - Date.parse(iso)) / 1000; return s < 60 ? `${Math.max(0, Math.round(s))} s ago` : ago(iso); };
async function pollEvents(){
if (!CAT || mode !== "live" || document.hidden) return;
try {
const d = await api("events", {topics: feedTopics(), thresholds: thrMap(feedTopics()), since: lastEvT, limit: 800});
evInfo = d;
const fresh = d.events.filter(e => { const k = e.t + "|" + e.source + "|" + e.id + "|" + e.event; if (evSeen.has(k)) return false; evSeen.add(k); return true; });
if (fresh.length){
evts = [...evts, ...fresh].slice(-2000); lastEvT = fresh[fresh.length - 1].t;
if (flow.kind === "events") flowAdd(fresh);
else if (flowOpen && evts.length >= 20 && flow.kind !== "events") flowStart();
}
} catch (e) { /* the ticker shows the last known state */ }
renderTicker();
}
function renderTicker(){
if (mode !== "live") return;
const d = evInfo;
if (!d){ $("ticker").textContent = "Checking the worker…"; return; }
const on = d.active, ws = d.worker_status || {};
$("pulse").className = "pulse" + (on ? " on" : "");
if (!d.last_event_at){ $("ticker").textContent = "backfill only · no worker activity yet"; return; }
const lastScored = [...evts].reverse().find(e => e.stage === "scored");
const n = d.last_10min || {};
if (!on){
$("ticker").textContent = `paused · showing the latest data (last scored ${lastScored ? ago(lastScored.t) : ago(d.last_event_at)})`;
return;
}
// Most worker traffic is skips (unchanged or empty cards), so show the latest event of any kind, not only scores.
const last = [...evts].reverse().find(e => e.stage !== "detected") || evts[evts.length - 1];
const tNew = Date.parse(evts[evts.length - 1]?.t || 0);
const perMin = evts.filter(e => e.stage === "detected" && Date.parse(e.t) > tNew - 60000).length;
const inflight = ws.queue?.inflight;
$("ticker").innerHTML = `${last ? `<span class="mono">${esc(last.id)}</span> ${esc(STAGE_WORDS[last.event] || STAGE_WORDS[last.stage] || last.stage)} ${agoS(last.t)}` : "working"}`
+ ` · ${perMin} changes checked in the last minute · +${n.scored || 0} scored in 10 min`
+ (inflight ? ` · scoring now: <span class="mono">${esc(inflight)}</span>` : "")
+ (ws.queue?.total != null ? ` · queue ${ws.queue.total.toLocaleString()}` : "");
}
const STAGE_WORDS = {scored: "scored", skip_no_readme: "skipped (no card)", skip_thin_card: "skipped (thin card)",
skip_yaml_only: "skipped (only metadata changed)", skip_same_etag: "skipped (card unchanged)", skip_unchanged_etag: "skipped (card unchanged)",
skip_private_or_disabled: "skipped (private)", superseded_in_queue: "replaced by a newer change", enqueued: "queued", queued: "queued",
detected: "detected", error: "failed", filtered: "skipped", unchanged: "skipped (card unchanged)"};
setInterval(pollEvents, EVENTS_MS);
setInterval(renderTicker, 1000);
/* ---------- how it works: real worker events, or a replay of the latest arrivals ---------- */
let flowRAF = null;
const flow = {kind: null};
$("flowBtn").onclick = () => {
flowOpen = !flowOpen; store.set("flowOpen", flowOpen); renderControls(); syncUrl();
if (flowOpen) flowStart(); else { cancelAnimationFrame(flowRAF); flowRAF = null; }
};
$("replay").onclick = () => flowStart();
function flowSetup(stations){
const cv = $("flow"), css = getComputedStyle(document.documentElement), col = n => css.getPropertyValue(n).trim();
const C = {ink: col("--ink"), muted: col("--muted"), faint: col("--faint"), line: col("--line"), accent: col("--accent")};
const lanes = feedTopics().slice(0, 8);
const W = cv.clientWidth || 600, laneTop = 58, laneH = 15, notY = laneTop + lanes.length * laneH + 14, H = notY + 16;
const dpr = window.devicePixelRatio || 1;
cv.width = W * dpr; cv.height = H * dpr; cv.style.height = H + "px";
const ctx = cv.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
Object.assign(flow, {ctx, C, lanes, W, H, laneTop, laneH, notY, mainY: 34, xEnd: W - 26, stations: stations.map(([f, s]) => [W * f, s]),
parts: [], byId: new Map(), pile: {}, begin: performance.now()});
flow.xLast = flow.stations[flow.stations.length - 1][0];
}
// A particle moves along `path` (list of [x, y] points) from `start` (ms since begin) over `dur` ms.
function flowPart(p){ flow.parts.push(p); return p; }
function laneTarget(fate){
const f = flow;
const lane = typeof fate === "number" ? fate : "not";
const slot = f.pile[lane] = (f.pile[lane] || 0) + 1;
return {lane, x: Math.max(f.xLast + 12, f.xEnd - (slot - 1) * 2.5), y: lane === "not" ? f.notY : f.laneTop + fate * f.laneH};
}
function pathFor(fate, fromX, dropX){
const f = flow, jit = (Math.random() - 0.5) * 6, y0 = f.mainY + jit;
// Repeated points = a short rest at a station, so a still frame is rarely empty.
if (fate === "filtered" || fate === "unchanged"){ const t = laneTarget(fate); return {pts: [[fromX, y0], [dropX, y0], [dropX, y0], [t.x, t.y]], lane: t.lane}; }
if (fate === "queued"){ const x = f.qx + (Math.random() - 0.5) * 16; return {pts: [[fromX, y0], [x, f.mainY + 8 + Math.random() * 10]], lane: "queue"}; }
const t = laneTarget(fate);
return {pts: [[fromX, y0], [f.xDecide, y0], [f.xDecide, y0], [f.xLast, y0], [t.x, t.y]], lane: t.lane};
}
function at(p, now){
const u = Math.min(1, Math.max(0, (now - p.start) / p.dur)), pts = p.pts;
const seg = Math.min(pts.length - 2, Math.floor(u * (pts.length - 1))), v = u * (pts.length - 1) - seg;
const [x0, y0] = pts[seg], [x1, y1] = pts[seg + 1];
return {x: x0 + (x1 - x0) * v, y: y0 + (y1 - y0) * v, done: u >= 1};
}
function flowDraw(now){
const f = flow, ctx = f.ctx, C = f.C;
ctx.clearRect(0, 0, f.W, f.H);
ctx.font = "11px 'IBM Plex Sans', system-ui, sans-serif"; ctx.textBaseline = "middle"; ctx.lineWidth = 1;
ctx.strokeStyle = C.line; ctx.beginPath(); ctx.moveTo(0, f.mainY); ctx.lineTo(f.xLast, f.mainY); ctx.stroke();
f.stations.forEach(([x, s]) => { ctx.beginPath(); ctx.moveTo(x, 18); ctx.lineTo(x, f.mainY + 10); ctx.stroke();
ctx.fillStyle = C.muted; ctx.textAlign = "center"; ctx.fillText(typeof s === "function" ? s() : s, x, 10); });
ctx.textAlign = "left";
f.lanes.forEach((t, i) => { const y = f.laneTop + i * f.laneH; ctx.strokeStyle = C.line;
ctx.beginPath(); ctx.moveTo(f.xLast + 8, y); ctx.lineTo(f.xEnd, y); ctx.stroke();
ctx.fillStyle = C.muted; ctx.fillText(t.length > 28 ? t.slice(0, 27) + "…" : t, f.xLast + 8, y - 6); });
ctx.strokeStyle = C.line; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.moveTo(f.stations[0][0], f.notY); ctx.lineTo(f.xEnd, f.notY); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = C.faint; ctx.fillText("not for you", f.stations[0][0] + 4, f.notY - 7);
const done = {}; let moving = false;
for (const p of f.parts){
if (now < p.start) { moving = true; continue; }
const q = at(p, now); if (!q.done) moving = true; else done[p.lane] = (done[p.lane] || 0) + 1;
if (!q.done && !reduceMotion()){ // short fading trail behind moving items
ctx.fillStyle = C.faint;
[[90, .35], [180, .2], [270, .1]].forEach(([dt, a]) => { if (now - dt < p.start) return;
const g = at(p, now - dt); ctx.globalAlpha = a; ctx.fillRect(g.x - 1, g.y - 1.5, 2, 3); });
}
const fading = p.lane === "not" && q.done;
ctx.globalAlpha = fading ? 0.45 : 1;
ctx.fillStyle = typeof p.lane === "number" ? C.accent : p.lane === "queue" ? C.muted : q.done ? C.faint : C.ink;
ctx.fillRect(q.x - 1, q.y - (q.done ? 4 : 2), 2, q.done ? 8 : 4);
}
ctx.globalAlpha = 1; ctx.textAlign = "right"; ctx.fillStyle = C.muted;
f.lanes.forEach((t, i) => ctx.fillText(String(done[i] || 0), f.W - 2, f.laneTop + i * f.laneH));
ctx.fillStyle = C.faint; ctx.fillText(String(done.not || 0), f.W - 2, f.notY);
return moving;
}
function flowLoop(){
if (flowRAF) return;
const tick = ts => { const moving = flowDraw(ts - flow.begin); flowRAF = moving && flowOpen && mode === "live" ? requestAnimationFrame(tick) : null; };
if (reduceMotion()){ flowDraw(1e12); return; } // static final state
flowRAF = requestAnimationFrame(tick);
}
const DUR = 3400;
function fateOf(e){
if (e.stage === "filtered") return "filtered";
if (e.stage === "unchanged" || e.stage === "superseded") return "unchanged";
if (e.stage === "scored") return e.lane && flow.lanes.includes(e.lane) ? flow.lanes.indexOf(e.lane) : "unmatched";
return "queued"; // detected / queued: waiting for Decide
}
// Events: one particle per item; a later event for the same item moves it on (e.g. queued -> scored).
function flowEvent(e, startAt){
const f = flow, k = e.source + ":" + e.id, fate = fateOf(e);
const p = f.byId.get(k);
if (p){
if (fate === "queued" || p.final) return;
const rest = p.pts[p.pts.length - 1], t = laneTarget(fate);
const pts = typeof fate === "number" || fate === "unmatched"
? [rest, [f.xDecide, f.mainY], [f.xDecide, f.mainY], [f.xLast, f.mainY], [t.x, t.y]] : [rest, [t.x, t.y]];
Object.assign(p, {pts, lane: t.lane, start: Math.max(startAt, p.start + p.dur), dur: DUR * 0.6, final: true});
return;
}
const np = pathFor(fate, 0, f.stations[0][0]);
const q = flowPart({pts: fate === "queued" || fate === "filtered" || fate === "unchanged" ? np.pts : [[0, np.pts[0][1]], [f.qx, np.pts[0][1]], ...np.pts.slice(1)],
lane: np.lane, start: startAt, dur: DUR, final: fate !== "queued"});
f.byId.set(k, q);
}
function flowStart(){
cancelAnimationFrame(flowRAF); flowRAF = null;
if (!flowOpen || mode !== "live") return;
if (evts.length >= 20) flowFromEvents(); else flowReplay();
}
function flowFromEvents(){
const ws = () => evInfo?.worker_status?.queue?.total;
flowSetup([[0.14, "card checks"], [0.33, () => `queue${ws() != null ? " · " + ws().toLocaleString() : ""}`], [0.49, `Decide · ${CAT?.labels.length || 48} topics`], [0.62, "your topics"]]);
flow.kind = "events"; flow.qx = flow.stations[1][0]; flow.xDecide = flow.stations[2][0];
const recent = evts.filter(e => e.stage !== "detected").slice(-500);
const t0 = Date.parse(recent[0]?.t), t1 = Date.parse(recent[recent.length - 1]?.t), REPLAY = 10000;
recent.forEach((e, i) => flowEvent(e, t1 > t0 ? (Date.parse(e.t) - t0) / (t1 - t0) * REPLAY : i * 20));
flowStats(recent);
flowLoop();
}
function flowAdd(fresh){
const now = performance.now() - flow.begin;
fresh.filter(e => e.stage !== "detected").forEach((e, i) => flowEvent(e, now + i * 150));
flowStats(evts.filter(e => e.stage !== "detected").slice(-500));
if (flowOpen) flowLoop();
}
function flowStats(recent){
const c = {}; for (const e of recent) c[e.stage] = (c[e.stage] || 0) + 1;
const mins = recent.length ? (Date.parse(recent[recent.length - 1].t) - Date.parse(recent[0].t)) / 60000 : 0;
const kept = recent.filter(e => e.stage === "scored" && e.lane && flow.lanes.includes(e.lane)).length;
$("flowStats").textContent = `real worker events, last ${Math.max(1, Math.round(mins))} min: ${c.unchanged || 0} unchanged cards, ${c.filtered || 0} filtered (no card or thin card), `
+ `${c.queued || 0} queued, ${c.scored || 0} scored by Decide, ${kept} for your topics`;
}
// No worker events yet: replay the latest scored items; filtered marks are estimated from the backfill rate.
function flowReplay(){
flowSetup([[0.16, "cheap filters"], [0.38, `Decide · ${CAT?.labels.length || 48} topics`], [0.58, "your topics"]]);
flow.kind = "replay"; flow.qx = flow.stations[0][0]; flow.xDecide = flow.stations[1][0];
const arr = (lastLive?.arrivals || []).filter(a => a.t);
const bs = lastLive?.status?.backfill_stats?.fetch || {};
const listed = ["model", "dataset"].reduce((a, s) => a + (bs[s]?.listed || 0), 0), keptN = ["model", "dataset"].reduce((a, s) => a + (bs[s]?.kept || 0), 0);
const keepRate = listed ? keptN / listed : 1;
const tArr = arr.map(a => Date.parse(a.t)), tMin = Math.min(...tArr), tMax = Math.max(...tArr);
const nDrop = Math.round(arr.filter(a => a.source !== "paper").length * (1 / keepRate - 1));
const all = [...arr.map(a => ({t: Date.parse(a.t), fate: a.lane && flow.lanes.includes(a.lane) ? flow.lanes.indexOf(a.lane) : "unmatched"})),
...Array.from({length: nDrop}, () => ({t: tMin + Math.random() * (tMax - tMin), fate: "filtered"}))].sort((a, b) => a.t - b.t);
all.forEach((p, i) => { const np = pathFor(p.fate, 0, flow.stations[0][0]);
flowPart({pts: np.pts, lane: np.lane, dur: DUR, start: tMax > tMin ? (p.t - tMin) / (tMax - tMin) * 10000 : i * 20}); });
const matched = all.filter(p => typeof p.fate === "number").length, mins = (tMax - tMin) / 60000;
$("flowStats").textContent = arr.length ? `replay of the last ${arr.length} scored items (${Math.round(mins)} min) · cheap filters keep ~${Math.round(keepRate * 100)}% (backfill rate; dropped marks estimated) · your topics kept ${matched}` : "No items yet.";
flowLoop();
}
/* ---------- start ---------- */
function renderAll(){ renderToggles(); renderFeed(); renderCal(); }
for (const id of ["coLive", "coBrowse"]) $(id).addEventListener("change", e => { cardOnly = e.target.checked; store.set("cardOnly", cardOnly); renderControls(); reload(); });
$("copyLink").onclick = async () => {
const url = SPACE_URL + viewQuery();
const ok = await copyText(url);
$("copyLink").textContent = ok ? "link copied" : "copy failed";
if (!ok) window.prompt("Copy this link:", url);
setTimeout(() => { $("copyLink").textContent = "copy link"; }, 1800);
};
async function init(){
renderControls();
try { CAT = await api("catalogue"); }
catch (e) {
$("status").textContent = "Could not connect: " + (e?.message || e);
$("feed").innerHTML = `<div class="empty">Could not reach the app. If the Space was asleep it may still be starting; reload the page in a minute.</div>`;
return;
}
narrowOf = {}; for (const [b, n] of CAT.pairs) (narrowOf[b] ||= []).push(n);
manualThr = store.get(thrKey(), null);
if (manualThr === null){ // new catalogue version: keep hand-set thresholds from older versions (defaults are recomputed)
manualThr = {};
for (const v of ["cat48-v1"]) Object.assign(manualThr, store.get("thr-" + v, {}));
store.set(thrKey(), manualThr);
}
$("status").textContent = `Loading about 28K scored items… (catalogue ${CAT.catalogue_version})`;
refitMarks(); renderControls(); renderAll(); await reload(); pollEvents();
}
init();
</script>
</body>
</html>