Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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 Download index.html from davanstrien/topic-feed-lab: direct link, hf CLI and curl.
- Browser
- Download file 64.7 kB
-
https://huggingface.co/spaces/davanstrien/topic-feed-lab/resolve/main/index.html
- Command line
-
hf download hf://spaces/davanstrien/topic-feed-lab/index.html
-
curl -L -o index.html https://huggingface.co/spaces/davanstrien/topic-feed-lab/resolve/main/index.html
64.7 kB
| <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 } | |
| 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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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> | |