| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464 |
- <!doctype html>
- <html>
- <head>
- <meta charset="utf-8">
- <title>sm121 — tested recipes</title>
- <style>
- :root { color-scheme: light dark; }
- body { font: 13px/1.45 system-ui, sans-serif; margin: 16px; }
- h1 { font-size: 18px; margin: 0 0 10px; }
- #bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
- #q { flex: 1 1 220px; max-width: 380px; padding: 4px 8px; }
- select, button, #cols summary { padding: 3px 8px; cursor: pointer; }
- #cols { position: relative; display: inline-block; }
- #colbox { position: absolute; z-index: 5; background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
- padding: 6px 10px; max-height: 60vh; overflow: auto; white-space: nowrap; }
- #colbox label { display: block; }
- table { border-collapse: collapse; width: 100%; }
- th, td { border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 3px 8px; text-align: left; vertical-align: top; }
- th { cursor: pointer; user-select: none; white-space: nowrap; position: sticky; top: 0; background: canvas; }
- tr.g1 td { font-weight: 700; background: color-mix(in srgb, currentColor 8%, canvas); cursor: pointer; }
- tr.g2 td { font-style: italic; background: color-mix(in srgb, currentColor 4%, canvas); cursor: pointer; }
- tr.hist td { font-family: ui-monospace, monospace; font-size: 12px; background: color-mix(in srgb, currentColor 3%, canvas); }
- a.open { color: inherit; text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 3px; padding: 0 4px; }
- a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
- tr.row { cursor: pointer; }
- td.recipe { position: relative; }
- td.recipe .copy { position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
- visibility: hidden; cursor: pointer; padding: 0 4px; border-radius: 3px; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
- background: canvas; color: inherit; font: inherit; line-height: 1.3; }
- td.recipe:hover .copy { visibility: visible; }
- td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
- td.recipe .copied { color: green; font-weight: 600; }
- .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
- td.num { text-align: right; white-space: nowrap; }
- #count { color: gray; }
- #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
- #live.dim { color: gray; }
- #bell { position: relative; padding: 3px 8px; cursor: pointer; background: none; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 4px; }
- #bell svg { vertical-align: -3px; }
- #bellcount { position: absolute; top: -7px; right: -7px; min-width: 17px; height: 17px; line-height: 17px; text-align: center;
- font-size: 10px; font-weight: 700; color: #fff; background: #c0392b; border-radius: 9px; padding: 0 3px; }
- #notifbox { position: absolute; z-index: 6; right: 0; top: 100%; width: min(460px, 90vw); max-height: 60vh; overflow: auto;
- background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); border-radius: 5px; padding: 6px; }
- #bar { position: relative; }
- .noti { display: flex; gap: 8px; padding: 5px 6px; border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); cursor: pointer; }
- .noti:hover { background: color-mix(in srgb, currentColor 6%, canvas); }
- .noti:last-child { border-bottom: none; }
- .nic { font-weight: 700; }
- @keyframes ring { 0% { transform: rotate(0) } 20% { transform: rotate(13deg) } 45% { transform: rotate(-11deg) } 70% { transform: rotate(6deg) } 100% { transform: rotate(0) } }
- #bell.ring svg { animation: ring 0.6s ease; transform-origin: 50% 0; }
- .dim { color: gray; }
- </style>
- </head>
- <body>
- <h1>sm121 — tested recipes & benchmark history</h1>
- <div id="bar">
- <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
- <label>Group:
- <select id="group">
- <option value="mq">Model → Quant</option>
- <option value="model">Model</option>
- <option value="quant">Quant</option>
- <option value="engine">Engine</option>
- <option value="" selected>None (flat)</option>
- </select>
- </label>
- <label>TP:
- <select id="tp"><option value="">All</option></select>
- </label>
- <label>Medals:
- <select id="medal">
- <option value="" selected>All</option>
- <option value="m">🥇🥈🥉</option>
- <option value="1">🥇 gold</option>
- <option value="2">🥈 silver</option>
- <option value="3">🥉 bronze</option>
- </select>
- </label>
- <details id="cols"><summary>Columns ▾</summary><div id="colbox"></div></details>
- <label>Rows/page:
- <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
- </label>
- <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
- <span id="live" class="dim" title="live updates">● off</span>
- <button id="bell" title="updates" aria-label="updates">
- <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg>
- <span id="bellcount" style="display:none"></span>
- </button>
- <div id="notifbox" style="display:none"></div>
- </div>
- <table id="tbl"><thead></thead><tbody></tbody></table>
- <script>
- "use strict";
- let DATA = {{DATA}};
- const COLS = [
- ["name", "Recipe"], ["title", "Title"], ["model", "Model"], ["quant", "Quant"],
- ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
- ["deploy", "Deploy"], ["port", "Port"], ["n", "Reports"], ["last", "Last bench"],
- ["points", "Points"], ["pct", "Score /100"], ["stars", "Bench"],
- ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
- ];
- const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars"]);
- const MEDAL = { 1: "🥇 ", 2: "🥈 ", 3: "🥉 " };
- // star grade of the latest completed bench: half-star precision (1 star =
- // 20 points), colored by score band — sort key is the ABSOLUTE score
- function starInfo(r) {
- const e = latest(r, x => x.done && x.pct != null);
- return e ? { pct: e.pct, s: Math.round(e.pct / 10) / 2 } : null;
- }
- const STAR_BAND = p => p < 20 ? "red" : p < 40 ? "brown" : p < 60 ? "orange" : p < 80 ? "gold" : "green";
- function starGlyphs(si) {
- const col = STAR_BAND(si.pct);
- let out = "";
- for (let i = 1; i <= 5; i++) {
- if (si.s >= i) out += `<span style="color:${col}">★</span>`;
- else if (si.s >= i - 0.5)
- out += `<span class="half" style="background:linear-gradient(90deg,${col} 50%,color-mix(in srgb,currentColor 25%,transparent) 50%)">★</span>`;
- else out += '<span class="dim">☆</span>';
- }
- return out;
- }
- // decode speed (tg t/s) from a run's llama-benchy perf cells
- function tg(e, d, c) { const p = e && e.perf ? e.perf.find(p => p.d === d && p.c === c) : null; return p ? p.tg : null; }
- // latest report matching a predicate, for the points / score columns
- function latest(r, f) { for (let i = r.reports.length - 1; i >= 0; i--) if (f(r.reports[i])) return r.reports[i]; return null; }
- function cell(r, k) {
- if (k === "n") return r.reports.length;
- if (k === "last") return r.reports.length ? r.reports[r.reports.length - 1].date : "";
- if (k === "points") { const e = latest(r, e => e.points); return e ? e.points : ""; }
- if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
- if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
- if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
- return r[k];
- }
- // deep-link state: the full view (filter, grouping, sort, columns, paging,
- // collapsed groups) lives in the URL — survives any reload and is shareable
- // Update notifications: pure diff between two rows snapshots → the list of
- // what changed (new bench, new/removed recipe, changed entry or row config).
- // Newest first. Probed by the node test, extracted between the markers.
- /*NOTIFDIFF:START*/
- function notifDiff(prev, next) {
- const out = [];
- const byName = new Map(prev.map(r => [r.name, r]));
- const meta = r => JSON.stringify({ ...r, reports: [] });
- const item = (t, r, e) => ({
- t, name: r.name,
- label: e ? e.label : (t === "recipe" ? (r.model || r.title) : "recipe config"),
- date: e ? e.date : "", time: e ? e.time : null,
- score: e ? e.pct : null, points: e ? e.points : null,
- files: e ? (e.files || [e.file]) : [],
- });
- for (const r of next) {
- const old = byName.get(r.name);
- if (!old) { out.push(item("recipe", r, null)); continue; }
- if (meta(old) !== meta(r)) out.push(item("config", r, null));
- const oldR = new Map(old.reports.map(e => [e.date + "\u0000" + e.label, e]));
- for (const e of r.reports) {
- const oe = oldR.get(e.date + "\u0000" + e.label);
- if (!oe) out.push(item("bench", r, e));
- else if (JSON.stringify(oe) !== JSON.stringify(e)) out.push(item("change", r, e));
- }
- }
- const nextNames = new Set(next.map(r => r.name));
- for (const r of prev)
- if (!nextNames.has(r.name)) out.push(item("removed", r, null));
- return out.reverse();
- }
- /*NOTIFDIFF:END*/
- // LIVE: the server re-scans the reports/ dirs on file-system events and
- // pushes the rows JSON over SSE whenever something changed (new bench,
- // finishing run, new report file). The view state (filter, sort, paging,
- // columns, collapsed groups) is untouched — render() re-derives everything
- // from DATA.
- const liveEl = document.getElementById("live");
- function syncTp() {
- const vals = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
- const sel = document.getElementById("tp");
- if (vals.map(String).join(",") !== [...sel.options].map(o => o.value).join(","))
- sel.innerHTML = '<option value="">All</option>' + vals.map(v => `<option>${v}</option>`).join("");
- if (S.tp && ![...sel.options].some(o => o.value === S.tp)) S.tp = "";
- sel.value = S.tp;
- }
- // Bell + notifications: UNREAD counts updates since the panel was last
- // opened; NOTIFS (capped) is the list the panel shows. Clicking an item
- // expands the matching row in the table.
- const bellEl = document.getElementById("bell");
- const notifBox = document.getElementById("notifbox");
- let UNREAD = 0;
- let NOTIFS = [];
- const NICON = { bench: "+", change: "↻", recipe: "★", config: "✎", removed: "−" };
- function renderBell() {
- const c = document.getElementById("bellcount");
- c.textContent = String(UNREAD);
- c.style.display = UNREAD ? "" : "none";
- }
- function renderNotifs() {
- if (!NOTIFS.length) {
- notifBox.innerHTML = '<div class="dim" style="padding:8px">no updates yet</div>';
- return;
- }
- notifBox.innerHTML = NOTIFS.map(n => {
- const when = n.date ? `${n.date}${n.time ? "·" + n.time : ""}` : "";
- const score = n.score != null ? `${n.score} /100` : n.points || "";
- const links = n.files.map(f =>
- `<a class="open" target="_blank" href="/r/${encodeURIComponent(n.name)}/${encodeURIComponent(f)}" title="open ${esc(f)}">${esc(f.split(".").pop())}</a>`).join(" ");
- return `<div class="noti" data-name="${esc(n.name)}" title="show in table">
- <span class="nic">${NICON[n.t] || "•"}</span>
- <div><b>${esc(n.name)}</b> <span class="dim">${esc(n.label)}</span><br>
- <span class="dim">${esc(when)}</span> ${score ? esc(score) : ""} ${links}</div>
- </div>`;
- }).join("");
- }
- bellEl.addEventListener("click", e => {
- e.stopPropagation();
- const open = notifBox.style.display === "block";
- notifBox.style.display = open ? "none" : "block";
- if (!open) { UNREAD = 0; renderBell(); }
- });
- document.addEventListener("click", () => { notifBox.style.display = "none"; });
- notifBox.addEventListener("click", e => {
- const it = e.target.closest(".noti");
- if (!it) return;
- S.openRow = it.dataset.name;
- notifBox.style.display = "none";
- render();
- });
- const es = new EventSource("/events");
- es.addEventListener("open", () => { liveEl.textContent = "● live"; liveEl.classList.remove("dim"); });
- es.addEventListener("error", () => { liveEl.textContent = "● off"; liveEl.classList.add("dim"); });
- es.onmessage = ev => {
- let rows; try { rows = JSON.parse(ev.data); } catch { return; }
- if (JSON.stringify(rows) === JSON.stringify(DATA)) return;
- const upd = notifDiff(DATA, rows);
- DATA = rows; syncTp(); render();
- liveEl.title = "updated " + new Date().toLocaleTimeString();
- if (upd.length) {
- NOTIFS.unshift(...upd);
- if (NOTIFS.length > 100) NOTIFS.length = 100;
- UNREAD += upd.length;
- renderBell(); renderNotifs();
- bellEl.classList.remove("ring"); void bellEl.offsetWidth; bellEl.classList.add("ring");
- }
- };
- renderBell(); renderNotifs();
- const P = new URLSearchParams(location.search);
- const size0 = +(P.get("size") ?? 50);
- // distinct TP values from the data, for the exact-TP filter
- const TPVALS = [...new Set(DATA.map(r => r.tp))].sort((a, b) => a - b);
- const tpParam = P.get("tp");
- for (const v of TPVALS) {
- const o = document.createElement("option");
- o.value = String(v);
- o.textContent = String(v);
- document.getElementById("tp").appendChild(o);
- }
- const S = {
- q: P.get("q") ?? "",
- group: P.get("group") ?? "",
- sort: P.get("sort") ?? "name",
- dir: P.get("dir") ? +P.get("dir") : 1,
- size: [25, 50, 100, 0].includes(size0) ? size0 : 50,
- page: Math.max(0, +(P.get("page") ?? 0)),
- hidden: new Set((P.get("hidden") ?? "title,image,port,parent").split(",").filter(Boolean)),
- closed: new Set((P.get("closed") ?? "").split("|").map(decodeURIComponent).filter(Boolean)),
- openRow: P.get("open") ?? null,
- medal: P.get("medal") ?? "",
- tp: tpParam != null && TPVALS.includes(+tpParam) ? tpParam : "",
- };
- document.getElementById("q").value = S.q;
- document.getElementById("group").value = S.group;
- document.getElementById("size").value = String(S.size);
- document.getElementById("tp").value = S.tp;
- document.getElementById("medal").value = S.medal;
- function syncUrl() {
- const p = new URLSearchParams();
- if (S.q) p.set("q", S.q);
- if (S.group !== "") p.set("group", S.group);
- if (S.sort !== "name") p.set("sort", S.sort);
- if (S.dir !== 1) p.set("dir", String(S.dir));
- if (S.size !== 50) p.set("size", String(S.size));
- if (S.page) p.set("page", String(S.page));
- const hidden = [...S.hidden].sort();
- if (hidden.join(",") !== ["title", "image", "port"].sort().join(",")) p.set("hidden", hidden.join(","));
- const closed = [...S.closed].map(encodeURIComponent);
- if (closed.length) p.set("closed", closed.join("|"));
- if (S.openRow) p.set("open", S.openRow);
- if (S.medal) p.set("medal", S.medal);
- if (S.tp) p.set("tp", S.tp);
- const qs = p.toString();
- history.replaceState(null, "", qs ? "?" + qs : location.pathname);
- }
- const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&","<":"<",">":">",'"':"""}[c]));
- function filtered() {
- const q = S.q.toLowerCase();
- return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
- (!S.medal || (S.medal === "m" ? r.medal != null : String(r.medal) === S.medal)) &&
- (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
- r.engine + " " + r.image + " " + r.deploy + " " +
- r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q)));
- }
- function sorted(rows) {
- const gk = { mq: r => r.parent + "\0" + r.quant, model: r => r.parent,
- quant: r => r.quant, engine: r => r.engine }[S.group];
- const k = S.sort, d = S.dir;
- const val = r => {
- if (k === "points") { const e = latest(r, e => e.ratio != null); return e ? e.ratio : -1; }
- if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? -1; }
- const v = cell(r, k); return NUM.has(k) || k === "last" ? v : String(v).toLowerCase();
- };
- return rows.slice().sort((a, b) => {
- if (gk) { const ga = gk(a), gb = gk(b); if (ga !== gb) return ga.localeCompare(gb); }
- let x = val(a), y = val(b);
- if (x === y) return a.name.localeCompare(b.name);
- if (typeof x === "number" && typeof y === "number") return (x - y) * d;
- return String(x).localeCompare(String(y)) * d;
- });
- }
- function gkey(r, level) {
- if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
- const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
- return level === 1 ? r[f] : null;
- }
- function render() {
- syncUrl();
- // visible columns
- const vis = COLS.filter(c => !S.hidden.has(c[0]));
- document.querySelector("thead").innerHTML = "<tr>" + vis.map(c => {
- const arrow = S.sort === c[0] ? (S.dir > 0 ? " ▲" : " ▼") : "";
- return `<th data-k="${c[0]}">${esc(c[1])}${arrow}</th>`;
- }).join("") + "</tr>";
- const all = sorted(filtered());
- const size = +document.getElementById("size").value;
- const pages = size ? Math.max(1, Math.ceil(all.length / size)) : 1;
- if (S.page >= pages) S.page = pages - 1;
- const rows = size ? all.slice(S.page * size, S.page * size + size) : all;
- document.getElementById("count").textContent = size
- ? `${all.length ? S.page * size + 1 : 0}–${Math.min(all.length, S.page * size + size)} / ${all.length}` : `${all.length}`;
- // group tree over the current page slice
- const out = [];
- let g1 = null, g2 = null;
- for (const r of rows) {
- if (S.group) {
- const k1 = gkey(r, 1);
- if (k1 !== g1) {
- g1 = k1; g2 = null;
- const closed = S.closed.has("1\0" + k1);
- out.push(`<tr class="g1" data-g="${encodeURIComponent("1\0" + k1)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(k1)} <span class="dim">(${all.filter(x => gkey(x, 1) === k1).length})</span></td></tr>`);
- }
- if (S.closed.has("1\0" + k1)) continue;
- const k2 = gkey(r, 2);
- if (k2 !== null) {
- if (k2 !== g2) {
- g2 = k2;
- const closed = S.closed.has("2\0" + k2);
- out.push(`<tr class="g2" data-g="${encodeURIComponent("2\0" + k2)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(r.quant)} <span class="dim">(${all.filter(x => gkey(x, 2) === k2).length})</span></td></tr>`);
- }
- if (S.closed.has("2\0" + k2)) continue;
- }
- }
- out.push("<tr class=\"row\" data-n=\"" + esc(r.name) + "\">" + vis.map(c => {
- const v = cell(r, c[0]);
- const lr = r.reports.length ? r.reports[r.reports.length - 1] : null;
- const tip = c[0] === "last" && lr
- ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
- const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "");
- let body;
- if (c[0] === "stars") {
- const si = starInfo(r);
- const pe = si && latest(r, e => e.points); // both scores in the hint
- body = !si
- ? r.incomplete
- ? '<span class="dim" title="bench started, not finished">◐</span>'
- : '<span class="dim" title="no bench reports">—</span>'
- : `<span title="${si.pct} /100${pe && pe.points ? " (" + pe.points + ")" : ""}">${starGlyphs(si)}</span>`;
- } else if (v === "" || v == null) {
- body = '<span class="dim">—</span>';
- } else if (c[0] === "name") {
- body = (MEDAL[r.medal] || "") + esc(v) +
- `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
- } else {
- body = esc(v);
- }
- return `<td${cls ? ` class="${cls}"` : ""}${tip}>${body}</td>`;
- }).join("") + "</tr>");
- if (S.openRow === r.name) {
- // one toml section per recipe (the recipe.toml is not versioned);
- // per-run report files stay on each line
- const toml = `<a class="open" href="/r/${encodeURIComponent(r.name)}/recipe.toml" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
- const lines = r.reports.map(e => {
- const fl = e.files && e.files.length ? e.files : [e.file];
- const ts = (e.perf && e.perf.length)
- ? ` <span title="${esc(e.perf.map(p => "d" + p.d + " c" + p.c + ": " + p.tg + " t/s").join(" · "))}"> ${tg(e, 0, 1) ?? "—"} / ${tg(e, 0, 2) ?? "—"} t/s</span>`
- : "";
- return `<span>${fl.map(f =>
- `<a class="open" href="/r/${encodeURIComponent(r.name)}/${encodeURIComponent(f)}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
- `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + " /100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}${ts}`;
- }).join("<br>");
- out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? toml + " " + lines : "no reports"}</td></tr>`);
- }
- }
- document.querySelector("tbody").innerHTML = out.join("");
- }
- document.querySelector("thead").addEventListener("click", e => {
- const th = e.target.closest("th"); if (!th) return;
- const k = th.dataset.k;
- if (S.sort === k) S.dir = -S.dir; else { S.sort = k; S.dir = 1; }
- render();
- });
- // copy a recipe name to the clipboard (clipboard API, textarea fallback)
- function fallbackCopy(text) {
- const ta = document.createElement("textarea");
- ta.value = text; document.body.appendChild(ta); ta.select();
- let ok = false; try { ok = document.execCommand("copy"); } catch {}
- ta.remove(); return ok;
- }
- function copyName(btn) {
- const done = ok => {
- btn.textContent = ok ? "✓" : "✗";
- btn.classList.toggle("copied", ok);
- setTimeout(() => { btn.textContent = "⧉"; btn.classList.remove("copied"); }, 900);
- };
- if (navigator.clipboard) navigator.clipboard.writeText(btn.dataset.copy).then(() => done(true), () => done(fallbackCopy(btn.dataset.copy)));
- else done(fallbackCopy(btn.dataset.copy));
- }
- document.querySelector("tbody").addEventListener("click", e => {
- const cp = e.target.closest("button.copy");
- if (cp) { copyName(cp); return; } // no row toggle under the copy button
- const g = e.target.closest("tr.g1,tr.g2");
- if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
- const row = e.target.closest("tr.row");
- if (row) { S.openRow = S.openRow === row.dataset.n ? null : row.dataset.n; render(); }
- });
- document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
- document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
- document.getElementById("tp").addEventListener("change", e => { S.tp = e.target.value; S.page = 0; render(); });
- document.getElementById("medal").addEventListener("change", e => { S.medal = e.target.value; S.page = 0; render(); });
- document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
- document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
- document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
- // column checkboxes
- document.getElementById("colbox").innerHTML = COLS.map(c =>
- `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
- document.getElementById("colbox").addEventListener("change", e => {
- const k = e.target.dataset.k; if (!k) return;
- e.target.checked ? S.hidden.delete(k) : S.hidden.add(k);
- render();
- });
- render();
- </script>
- </body>
- </html>
|