noodcon's picture
Upload 7 files
cfbec3f verified
Raw History Blame Contribute Delete
26.9 kB
import { CONFIG } from "./config.js";
import { costAt, rateText } from "./cost.js";
import * as hf from "./hf.js";
const CAT_COLORS = { code: "#3b82f6", general: "#f97316", cyber: "#10b981", visual: "#f59e0b", chat: "#ec4899" };
const RUN_COLORS = { pro: "#f0643c", flash: "#3b82f6" };
const UNITS = ["second", "minute", "hour", "day", "month", "year"];
const state = {
data: null,
error: null,
authError: null,
lastSync: null,
tab: "overview",
token: null,
me: null,
isAdmin: false,
dirty: false,
saveStatus: "",
saveFailed: false,
jsonDraft: null,
jsonError: "",
ui: { xMode: "step", yLog: false, smooth: 0, compMode: "count" },
};
let chartSeq = 0;
const chartStore = {};
/* ---------- helpers ---------- */
const $ = (sel) => document.querySelector(sel);
const esc = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmtInt = (n) => (n == null || isNaN(n) ? "—" : Math.round(n).toLocaleString("en-US"));
const fmtMoney = (n) => "$" + fmtInt(n);
function fmtNum(v) {
if (v == null || isNaN(v)) return "—";
const a = Math.abs(v);
if (a >= 1000) return Math.round(v).toLocaleString("en-US");
if (a >= 1) return v.toFixed(2);
if (a === 0) return "0";
return v.toPrecision(3);
}
function fmtBig(n) {
if (n == null || isNaN(n)) return "—";
const a = Math.abs(n);
if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
if (a >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (a >= 1e3) return (n / 1e3).toFixed(0) + "k";
return String(Math.round(n));
}
function fmtUtc(iso) {
const d = new Date(iso);
return isNaN(d) ? "—" : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
}
function fmtTime(t) {
return isNaN(Date.parse(t)) ? String(t ?? "") : fmtUtc(t);
}
function fmtDuration(ms) {
if (isNaN(ms)) return "—";
const s = Math.max(0, Math.floor(ms / 1000));
const d = Math.floor(s / 86400);
const h = String(Math.floor((s % 86400) / 3600)).padStart(2, "0");
const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const sec = String(s % 60).padStart(2, "0");
return `${d}d ${h}:${m}:${sec}`;
}
const last = (vals) => (vals || []).filter((v) => v != null).slice(-1)[0];
const runName = (k) => state.data?.runs?.find((r) => r.id === k)?.name ?? k;
const runColor = (k) => state.data?.runs?.find((r) => r.id === k)?.color ?? RUN_COLORS[k] ?? "#888";
const costNow = () => (state.data ? costAt(state.data.cost, Date.now()) : 0);
function metricLast(group, name, runId) {
const m = state.data.metrics.find((x) => x.group === group && x.name === name);
return m ? last(m.series[runId]) : undefined;
}
function delta(vals) {
const v = (vals || []).filter((x) => x != null);
if (v.length < 2) return "";
const a = v[v.length - 1];
const b = v[v.length - 2];
if (v.every((x) => x >= 0 && x <= 1)) {
const d = (a - b) * 100;
return `${d >= 0 ? "▲" : "▼"} ${Math.abs(d).toFixed(2)} pt`;
}
const p = b ? ((a - b) / Math.abs(b)) * 100 : 0;
return `${p >= 0 ? "▲" : "▼"} ${Math.abs(p).toFixed(1)}%`;
}
const deltaClass = (vals) => (delta(vals).startsWith("▲") ? "up" : "down");
function smooth(vals, w) {
if (w <= 1) return vals;
return vals.map((_, i) => {
const s = vals.slice(Math.max(0, i - w + 1), i + 1).filter((v) => v != null);
return s.length ? s.reduce((x, y) => x + y, 0) / s.length : null;
});
}
// Hours per training step, used for the "time" x-axis option.
function stepHours() {
const r = state.data?.runs?.find((x) => x.step > 0 && x.startedAt);
if (!r) return 1;
const end = r.stoppedAt ? Date.parse(r.stoppedAt) : Date.now();
return (end - Date.parse(r.startedAt)) / 3600000 / r.step;
}
function xLabel(i) {
return state.ui.xMode === "time" ? (i * stepHours()).toFixed(1) + "h" : String(i + 1);
}
/* ---------- charts ---------- */
function chartSvg(series) {
const n = Math.max(0, ...series.map((s) => s.values.length));
const logOn = state.ui.yLog;
const ser = series.map((s) => ({ ...s, values: smooth(s.values, state.ui.smooth) }));
const ok = (v) => v != null && !isNaN(v) && (!logOn || v > 0);
const tv = (v) => (logOn ? Math.log10(v) : v);
const flat = ser.flatMap((s) => s.values.filter(ok));
if (n < 2 || !flat.length) return `<div class="empty">no data</div>`;
let lo = tv(Math.min(...flat));
let hi = tv(Math.max(...flat));
if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
const pad = (hi - lo) * 0.08;
lo -= pad;
hi += pad;
const W = 600, H = 220, pl = 48, pr = 12, pt = 10, pb = 24;
const X = (i) => pl + ((W - pl - pr) * i) / (n - 1);
const Y = (v) => pt + (H - pt - pb) * (1 - (tv(v) - lo) / (hi - lo));
const inv = (t) => (logOn ? 10 ** t : t);
let grid = "";
for (let k = 0; k <= 4; k++) {
const y = pt + (H - pt - pb) * (1 - k / 4);
const val = inv(lo + ((hi - lo) * k) / 4);
grid += `<line class="grid" x1="${pl}" x2="${W - pr}" y1="${y}" y2="${y}"/>`;
grid += `<text x="${pl - 6}" y="${y + 4}" text-anchor="end">${fmtNum(val)}</text>`;
}
const labels = Array.from({ length: n }, (_, i) => xLabel(i));
let xl = "";
for (let k = 0; k <= 4; k++) {
const i = Math.round(((n - 1) * k) / 4);
xl += `<text x="${X(i)}" y="${H - 6}" text-anchor="middle">${labels[i]}</text>`;
}
const paths = ser
.map((s) => {
let d = "";
let pen = false;
s.values.forEach((v, i) => {
if (!ok(v)) { pen = false; return; }
d += (pen ? "L" : "M") + X(i).toFixed(1) + " " + Y(v).toFixed(1) + " ";
pen = true;
});
return `<path d="${d}" fill="none" stroke="${s.color}" stroke-width="1.8"/>`;
})
.join("");
const id = "c" + ++chartSeq;
chartStore[id] = { ser, labels, n, W, pl, pr };
return `<svg class="chart" data-chart="${id}" viewBox="0 0 ${W} ${H}">${grid}${xl}${paths}</svg>`;
}
function stackedSvg(cats) {
const keys = Object.keys(cats);
const n = Math.max(0, ...keys.map((k) => cats[k].length));
if (!n) return `<div class="empty">no data</div>`;
const totals = Array.from({ length: n }, (_, i) => keys.reduce((s, k) => s + (cats[k][i] || 0), 0));
const share = state.ui.compMode === "share";
const W = 600, H = 220, pl = 48, pt = 10, pb = 24;
const max = share ? 1 : Math.max(...totals) || 1;
const bw = (W - pl) / n;
let bars = "";
for (let i = 0; i < n; i++) {
let acc = 0;
for (const k of keys) {
const raw = cats[k][i] || 0;
const v = share ? (totals[i] ? raw / totals[i] : 0) : raw;
const h = ((H - pt - pb) * v) / max;
bars += `<rect x="${(pl + i * bw + 1).toFixed(1)}" y="${(H - pb - acc - h).toFixed(1)}" width="${(bw - 2).toFixed(1)}" height="${h.toFixed(1)}" fill="${CAT_COLORS[k] || "#888"}"><title>step ${i + 1} · ${k}: ${fmtInt(raw)}</title></rect>`;
acc += h;
}
}
const ticks = [0, 0.5, 1]
.map((f) => {
const y = pt + (H - pt - pb) * (1 - f);
const label = share ? Math.round(f * 100) + "%" : fmtInt(max * f);
return `<line class="grid" x1="${pl}" x2="${W}" y1="${y}" y2="${y}"/><text x="${pl - 6}" y="${y + 4}" text-anchor="end">${label}</text>`;
})
.join("");
const xl = [0, Math.floor((n - 1) / 2), n - 1]
.map((i) => `<text x="${pl + i * bw + bw / 2}" y="${H - 6}" text-anchor="middle">${i + 1}</text>`)
.join("");
return `<svg class="chart" viewBox="0 0 ${W} ${H}">${ticks}${bars}${xl}</svg>`;
}
function legendHtml(series, showDelta = false) {
return `<div class="legend">${series
.map(
(s) => `<span><i style="background:${s.color}"></i>${esc(s.name)} <b class="mono">${fmtNum(last(s.values))}</b>${
showDelta ? ` <span class="mono ${deltaClass(s.values)}">${delta(s.values)}</span>` : ""
}</span>`
)
.join("")}</div>`;
}
/* ---------- sections ---------- */
const tabBtn = (id, label) =>
`<button class="tab ${state.tab === id ? "on" : ""}" data-action="tab" data-tab="${id}">${label}</button>`;
const segBtn = (k, v, on) =>
`<button class="${on ? "on" : ""}" data-action="ui" data-k="${k}" data-v="${v}">${v}</button>`;
function authHtml() {
if (state.isAdmin) {
return `<span class="badge">ADMIN · @${esc(state.me.name)}</span><button class="btn ghost" data-action="logout">Log out</button>`;
}
if (state.me) {
return `<span class="muted">@${esc(state.me.name)} · read-only</span><button class="btn ghost" data-action="logout">Log out</button>`;
}
return `<button class="btn" data-action="login">Sign in with Hugging Face</button>`;
}
function saveHtml() {
if (!state.saveStatus) return "";
return (
`<span class="save ${state.saveFailed ? "err" : ""}">${esc(state.saveStatus)}</span>` +
(state.saveFailed ? `<button class="btn" data-action="save">Retry</button>` : "")
);
}
function runCard(r) {
const running = r.status === "running" && !r.stoppedAt;
const start = Date.parse(r.startedAt);
const stop = r.stoppedAt ? Date.parse(r.stoppedAt) : "";
const stat = (label, value) =>
`<div class="stat"><span class="lbl">${label}</span><span class="mono">${value}</span></div>`;
return `
<div class="card">
<div class="run-head">
<span class="dot" style="background:${esc(r.color)}"></span>
<b>${esc(r.name)}</b>
<span class="status ${running ? "live" : ""}">${running ? "running" : "stopped"}</span>
<span class="mono">step ${fmtInt(r.step)}</span>
<span class="mono elapsed" data-start="${start}" data-stop="${stop}">${fmtDuration((stop || Date.now()) - start)}</span>
</div>
<div class="stats">
${stat("avg@n", fmtNum(metricLast("dynsam", "avg@n", r.id)))}
${stat("cost so far", fmtMoney(r.costSoFar))}
${stat("tokens · step", fmtBig(r.tokensAtStep))}
${stat("tokens · total", fmtBig(r.tokensTotal))}
${stat("samples trained", fmtBig(r.samplesTrained))}
${stat("train batch × rollouts", `${fmtInt(r.trainBatch)} × ${r.rollouts ?? "—"}`)}
</div>
</div>`;
}
function noticesHtml() {
const items = [...state.data.notices].sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
const form = state.isAdmin
? `<div class="notice-form" data-admin>
<textarea id="notice-text" rows="2" placeholder="Write a notice for everyone…"></textarea>
<div class="row"><button class="btn" data-action="post-notice">Post notice</button>${saveHtml()}</div>
</div>`
: "";
const list = items.length
? items
.map(
(n) => `
<div class="notice">
<span class="mono muted">${fmtUtc(n.time)}</span>
<span class="ntext">${esc(n.text)}</span>
${state.isAdmin ? `<button class="x" data-action="del-notice" data-id="${esc(n.id)}" title="Delete">×</button>` : ""}
</div>`
)
.join("")
: `<div class="muted">No notices.</div>`;
return `
<section class="card">
<div class="mhead"><b>notices</b><span class="muted">UTC</span></div>
${form}
${list}
</section>`;
}
function readCostForm() {
const raw = $("#cost-correct").value.trim();
const correct = raw === "" ? Math.round(costNow()) : Number(raw);
const inc = Number($("#cost-inc").value);
const every = Number($("#cost-every").value);
const unit = $("#cost-unit").value;
const ok = isFinite(correct) && isFinite(inc) && inc >= 0 && isFinite(every) && every > 0;
return { ok, correct, inc, every, unit };
}
function updateCostPreview() {
const el = $("#cost-preview");
if (!el) return;
const f = readCostForm();
el.textContent = f.ok
? `Preview: shows ${fmtMoney(f.correct)} now, then ${rateText({ increment: f.inc, every: f.every, unit: f.unit })}`
: "Enter valid numbers.";
}
function adminCostHtml() {
const c = state.data.cost;
return `
<section class="card admin" data-admin>
<div class="mhead"><b>admin · cost</b><span class="muted">saved to the dataset repo</span></div>
<div class="form-grid">
<label>Correct current value ($)<input id="cost-correct" type="number" step="any" placeholder="${Math.round(costNow())}"></label>
<label>Increase ($)<input id="cost-inc" type="number" min="0" step="any" value="${c.increment}"></label>
<label>every<input id="cost-every" type="number" min="0.0001" step="any" value="${c.every}"></label>
<label>unit<select id="cost-unit">${UNITS.map((u) => `<option value="${u}" ${u === c.unit ? "selected" : ""}>${u}</option>`).join("")}</select></label>
</div>
<div class="muted" id="cost-preview"></div>
<div class="row"><button class="btn" data-action="save-cost">Save cost</button>${saveHtml()}</div>
</section>`;
}
function benchHtml() {
const bs = state.data.benchmarks;
if (!bs.length) return "";
const cards = bs
.map((b) => {
const series = Object.keys(b.series).map((k) => ({ name: runName(k), color: runColor(k), values: b.series[k] }));
return `
<div class="card">
<div class="mhead"><b>${esc(b.name)}</b><span class="muted">${esc(b.subtitle || "")}</span></div>
${legendHtml(series)}
${chartSvg(series)}
</div>`;
})
.join("");
return `<h3 class="sec">benchmarks</h3><section class="grid">${cards}</section>`;
}
function batchHtml() {
const bc = state.data.batchComposition || {};
const cats = bc.categories || {};
const keys = Object.keys(cats);
if (!keys.length) return "";
const legend = keys
.map((c) => `<span><i style="background:${CAT_COLORS[c] || "#888"}"></i>${esc(c)}</span>`)
.join("");
return `
<h3 class="sec">batch composition</h3>
<section class="card">
<div class="mhead"><span class="muted">what each step's samples are made of · by data source</span></div>
<div class="toolbar">
<div class="seg">${segBtn("compMode", "count", state.ui.compMode === "count")}${segBtn("compMode", "share", state.ui.compMode === "share")}</div>
</div>
<div class="legend">${legend}</div>
${stackedSvg(cats)}
</section>`;
}
function sourcesHtml() {
const src = state.data.sources;
if (!src.length) return "";
const latest = Math.max(...src.map((s) => s.step));
const rows = src
.filter((s) => s.step === latest)
.sort((a, b) => b.accepted - a.accepted)
.map((r) => {
const pct = r.target ? Math.min(100, (r.accepted / r.target) * 100) : 0;
return `<tr>
<td>${esc(runName(r.run))}</td>
<td>${esc(r.name)}</td>
<td><span style="color:${CAT_COLORS[r.category] || "#888"}">● ${esc(r.category)}</span></td>
<td class="num">${fmtInt(r.accepted)} / ${fmtInt(r.target)}<div class="bar"><i style="width:${pct}%"></i></div></td>
<td class="num">${fmtInt(r.remaining)}</td>
<td class="num">${r.judged == null ? "—" : fmtInt(r.judged)}</td>
<td class="num">${fmtInt(r.inFlight)}</td>
</tr>`;
})
.join("");
return `
<h3 class="sec">sources</h3>
<section class="card">
<div class="mhead"><b>latest step · ${latest}</b></div>
<div class="table-wrap"><table>
<thead><tr><th>run</th><th>source</th><th>category</th><th class="num">accepted / target</th><th class="num">remaining</th><th class="num">judged</th><th class="num">in flight</th></tr></thead>
<tbody>${rows}</tbody>
</table></div>
</section>`;
}
function feedHtml() {
const f = state.data.feed;
const items = f
.map((x) => `<div><span class="muted">${esc(fmtTime(x.time))}</span> ${esc(x.message)}</div>`)
.join("");
return `
<h3 class="sec">feed</h3>
<section class="card">
<div class="mhead"><b>feed</b><span class="muted">UTC</span></div>
<div class="feed">${items || `<div class="muted">No events.</div>`}</div>
</section>`;
}
function overviewHtml() {
return `
${state.isAdmin ? adminCostHtml() : ""}
${noticesHtml()}
<section class="runs">${state.data.runs.map(runCard).join("")}</section>
${benchHtml()}
${batchHtml()}
${sourcesHtml()}
${feedHtml()}`;
}
function metricCard(m) {
const series = Object.keys(m.series).map((k) => ({ name: runName(k), color: runColor(k), values: m.series[k] }));
return `
<div class="card">
<div class="mhead"><span class="muted">${esc(m.group)}/</span><b>${esc(m.name)}</b></div>
${legendHtml(series, true)}
${chartSvg(series)}
</div>`;
}
function metricGridHtml() {
const ms = state.data.metrics;
return ms.length ? ms.map(metricCard).join("") : `<div class="muted">No metrics.</div>`;
}
function metricsHtml() {
const ui = state.ui;
return `
<div class="toolbar">
<div class="seg">${segBtn("xMode", "step", ui.xMode === "step")}${segBtn("xMode", "time", ui.xMode === "time")}</div>
<div class="seg">${segBtn("yLog", "linear", !ui.yLog)}${segBtn("yLog", "log", ui.yLog)}</div>
<label class="smooth">smoothing
<input id="smooth" type="range" min="0" max="10" step="1" value="${ui.smooth}">
<span class="mono" id="smooth-val">${ui.smooth > 1 ? ui.smooth + " pts" : "off"}</span>
</label>
</div>
<div class="grid" id="metric-grid">${metricGridHtml()}</div>`;
}
function renderMd(src) {
return esc(src || "")
.split(/\n\s*\n/)
.map((block) => {
if (/^#\s/.test(block)) return `<h2>${block.replace(/^#\s/, "")}</h2>`;
if (/^- /m.test(block)) {
const items = block.split("\n").map((l) => `<li>${l.replace(/^- /, "")}</li>`).join("");
return `<ul>${items}</ul>`;
}
return `<p>${block.replace(/\n/g, "<br>")}</p>`;
})
.join("");
}
function aboutHtml() {
const admin = state.isAdmin
? `<section class="card" data-admin>
<div class="mhead"><b>edit about</b><span class="muted"># heading · - bullets · blank line = new paragraph</span></div>
<textarea id="about-text" rows="12">${esc(state.data.about)}</textarea>
<div class="row"><button class="btn" data-action="save-about">Save</button>${saveHtml()}</div>
</section>`
: "";
return `<section class="card prose">${renderMd(state.data.about)}</section>${admin}`;
}
function jsonHtml() {
const text = state.jsonDraft ?? JSON.stringify(state.data, null, 2);
return `
<section class="card admin" data-admin>
<div class="mhead"><b>Edit data (JSON)</b><span class="muted">validated before saving</span></div>
<textarea id="json-edit" class="json" rows="28" spellcheck="false">${esc(text)}</textarea>
<div class="row">
<span id="json-status" class="${state.jsonError ? "err" : "muted"}">${state.jsonError ? "⚠ " + esc(state.jsonError) : "valid JSON"}</span>
${state.dirty ? `<span class="warn">unsaved changes</span>` : ""}
<button class="btn ghost" data-action="json-discard">Discard</button>
<button class="btn" data-action="json-save">Save</button>
${saveHtml()}
</div>
</section>`;
}
function mainHtml() {
if (state.tab === "metrics") return metricsHtml();
if (state.tab === "about") return aboutHtml();
if (state.tab === "json" && state.isAdmin) return jsonHtml();
return overviewHtml();
}
function footerHtml() {
const f = state.data.footer || {};
const synced = state.lastSync ? ` · synced ${state.lastSync.toLocaleTimeString("en-GB", { hour12: false })}` : "";
return `<footer>@${esc(CONFIG.OWNER_HF_USERNAME)} · streaming since ${fmtUtc(f.streamingSince)} · ${esc(f.tagline || "")}${synced}</footer>`;
}
/* ---------- render & ticks ---------- */
function render() {
const d = state.data;
const banner = state.error || state.authError;
$("#app").innerHTML = `
<header class="topbar">
<div class="brand">RL training</div>
<nav class="tabs">
${tabBtn("overview", "overview")}${tabBtn("metrics", "metrics")}${tabBtn("about", "about")}
${state.isAdmin ? tabBtn("json", "edit data (JSON)") : ""}
</nav>
<div class="cost">
<span class="lbl">total cost</span>
<span class="val" data-cost>${d ? fmtMoney(costNow()) : "—"}</span>
<span class="rate">${d ? rateText(d.cost) : ""}</span>
</div>
<div class="clocks">${CONFIG.DISPLAY_TIMEZONES
.map((z) => `<div><span class="lbl">${esc(z.label)}</span><span class="mono" data-clock="${z.tz}"></span></div>`)
.join("")}</div>
<div class="auth">${authHtml()}<button class="btn ghost" data-action="theme" title="Toggle theme">◐</button></div>
</header>
${banner && d ? `<div class="banner">Sync problem (${esc(banner)}), showing last good data.</div>` : ""}
${banner && !d ? `<div class="banner err">Could not load data: ${esc(banner)}</div>` : ""}
<main>${d ? mainHtml() : `<div class="muted pad">Loading…</div>`}</main>
${d ? footerHtml() : ""}
`;
tickTime();
tickCost();
updateCostPreview();
}
function tickCost() {
if (!state.data) return;
const txt = fmtMoney(costNow());
document.querySelectorAll("[data-cost]").forEach((el) => (el.textContent = txt));
}
function tickTime() {
const now = new Date();
document.querySelectorAll("[data-clock]").forEach((el) => {
el.textContent = new Intl.DateTimeFormat("en-GB", {
timeZone: el.dataset.clock,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).format(now);
});
document.querySelectorAll(".elapsed").forEach((el) => {
const start = Number(el.dataset.start);
const end = el.dataset.stop ? Number(el.dataset.stop) : Date.now();
el.textContent = fmtDuration(end - start);
});
}
/* ---------- data flow ---------- */
function normalize(d) {
const cost = {
base: 0,
baseTime: new Date().toISOString(),
increment: 0,
every: 1,
unit: "hour",
...(d.cost || {}),
};
return {
notices: [],
runs: [],
metrics: [],
benchmarks: [],
sources: [],
feed: [],
about: "",
footer: {},
...d,
cost,
batchComposition: d.batchComposition || { categories: {} },
};
}
async function sync(force = false) {
if (state.dirty && !force) return;
try {
state.data = normalize(await hf.readData());
state.error = null;
state.lastSync = new Date();
} catch (e) {
state.error = e.message;
}
render();
}
async function save() {
if (!state.token) return;
state.saveStatus = "Saving...";
state.saveFailed = false;
render();
try {
await hf.writeData(state.token, state.data);
state.dirty = false;
state.jsonDraft = null;
state.jsonError = "";
state.saveStatus = "Saved";
await sync(true);
} catch (e) {
state.saveStatus = "Failed: " + e.message;
state.saveFailed = true;
render();
}
}
async function refreshIdentity() {
state.me = await hf.whoami(state.token).catch(() => null);
if (!state.me) {
hf.logout();
state.token = null;
}
state.isAdmin = !!state.me && state.me.name?.toLowerCase() === CONFIG.OWNER_HF_USERNAME.toLowerCase();
}
async function init() {
try {
state.token = (await hf.handleRedirect()) || sessionStorage.getItem("hf_token");
} catch (e) {
state.authError = e.message;
}
if (state.token) await refreshIdentity();
await sync(true);
setInterval(() => sync(false), CONFIG.REFRESH_MS);
setInterval(tickCost, 100);
setInterval(tickTime, 1000);
}
/* ---------- events ---------- */
document.addEventListener("click", async (e) => {
const el = e.target.closest("[data-action]");
if (!el) return;
switch (el.dataset.action) {
case "tab":
state.tab = el.dataset.tab;
render();
break;
case "login":
hf.login();
break;
case "logout":
hf.logout();
state.token = null;
state.me = null;
state.isAdmin = false;
state.dirty = false;
render();
break;
case "theme":
document.body.classList.toggle("light");
break;
case "ui": {
const k = el.dataset.k;
const v = el.dataset.v;
state.ui[k] = k === "yLog" ? v === "log" : v;
render();
break;
}
case "post-notice": {
const text = $("#notice-text").value.trim();
if (!text) break;
state.data.notices.push({ id: "n" + Date.now(), time: new Date().toISOString(), text });
save();
break;
}
case "del-notice":
if (confirm("Delete this notice?")) {
state.data.notices = state.data.notices.filter((n) => n.id !== el.dataset.id);
save();
}
break;
case "save-cost": {
const f = readCostForm();
if (!f.ok) {
state.saveStatus = "Invalid cost values";
state.saveFailed = true;
render();
break;
}
state.data.cost = {
base: f.correct,
baseTime: new Date().toISOString(),
increment: f.inc,
every: f.every,
unit: f.unit,
};
save();
break;
}
case "save-about":
state.data.about = $("#about-text").value;
save();
break;
case "json-save": {
try {
const parsed = JSON.parse(state.jsonDraft ?? JSON.stringify(state.data));
state.data = normalize(parsed);
state.jsonError = "";
save();
} catch (err) {
state.jsonError = err.message;
render();
}
break;
}
case "json-discard":
state.jsonDraft = null;
state.jsonError = "";
state.dirty = false;
sync(true);
break;
case "save":
save();
break;
}
});
function handleField(e) {
const t = e.target;
if (t.closest?.("[data-admin]")) state.dirty = true;
if (t.id === "json-edit") {
state.jsonDraft = t.value;
try {
JSON.parse(t.value);
state.jsonError = "";
} catch (err) {
state.jsonError = err.message;
}
const s = $("#json-status");
if (s) {
s.textContent = state.jsonError ? "⚠ " + state.jsonError : "valid JSON";
s.className = state.jsonError ? "err" : "muted";
}
}
if (t.id?.startsWith("cost-")) updateCostPreview();
if (t.id === "smooth") {
state.ui.smooth = Number(t.value);
$("#smooth-val").textContent = state.ui.smooth > 1 ? state.ui.smooth + " pts" : "off";
$("#metric-grid").innerHTML = metricGridHtml();
}
}
document.addEventListener("input", handleField);
document.addEventListener("change", handleField);
document.addEventListener("pointermove", (e) => {
const tip = $("#tip");
const svg = e.target.closest?.("svg[data-chart]");
const c = svg && chartStore[svg.dataset.chart];
if (!c) {
tip.hidden = true;
return;
}
const r = svg.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width) * c.W;
const frac = (x - c.pl) / (c.W - c.pl - c.pr);
const i = Math.max(0, Math.min(c.n - 1, Math.round(frac * (c.n - 1))));
tip.innerHTML =
`<div class="muted">step ${esc(c.labels[i])}</div>` +
c.ser
.map((s) => `<div><i style="background:${s.color}"></i>${esc(s.name)}: <b>${fmtNum(s.values[i])}</b></div>`)
.join("");
tip.hidden = false;
tip.style.left = e.clientX + 14 + "px";
tip.style.top = e.clientY + 14 + "px";
});
init();