JonnaMat's picture
Add shareable URL state, speedup column and family-scoped metric legend (#7)
8d85328
Raw
History Blame Contribute Delete
8.73 kB
// ─── Shareable URL State ─────────────────────────────────────────────────────
//
// Mirrors the current view β€” base family, variant, metric and every
// config.filters column β€” into location.hash so that a view can be linked:
//
// #family=Llama-3.2&device=agx_orin&metric=tps
//
// Extracted module. Call initUrlState(deps) once, after `filters` exists and
// BEFORE the first render: construction applies an incoming hash to `filters`
// in place, so the very first render already shows the linked view (there is
// no visible second render). Returns { desiredFilterValue, sync, currentHash }.
//
// Disable with "url_state": false in config.json.
// eslint-disable-next-line no-unused-vars
function initUrlState(deps) {
const {
config, filters, BASE_FAMILIES, deriveBaseFamily, GROUP_BY,
availableOptions, renderSidebar, updateDependentFilters,
switchBaseFamily, render,
} = deps;
const ENABLED = config.url_state !== false;
const FILTER_COLS = config.filters.map(f => f.column);
const METRIC_COLS = config.metrics.map(m => m.column);
// Filter values requested by the hash but not applied yet. They are consumed
// by updateDependentFilters(), which otherwise resets every filter column to a
// family default whenever the newly loaded data does not contain the current
// value. Cleared by sync() once the view has settled.
let pending = {};
// The hash this module last wrote, so our own replaceState is not re-applied.
let lastWritten = null;
// Guard against re-entrant hashchange handling during an async family switch.
let applying = false;
// ─── Parse ────────────────────────────────────────────────────────────────────
function parseHash(raw) {
const out = {};
const s = String(raw || "").replace(/^#/, "");
if (!s) return out;
s.split("&").forEach(part => {
const i = part.indexOf("=");
if (i < 1) return;
try {
const k = decodeURIComponent(part.slice(0, i)).trim();
const v = decodeURIComponent(part.slice(i + 1)).trim();
if (k) out[k] = v;
} catch {
// Malformed percent-escape β€” drop the pair rather than throw.
}
});
return out;
}
// Accept either a base family ("Llama-3.2") or a config family key
// ("Llama-3.2-1B"), mirroring the ?family= query deep link.
function resolveFamily(fam) {
if (!fam) return null;
if (BASE_FAMILIES[fam]) return { baseFamily: fam, variant: null };
if (config.model_families?.[fam]) {
const base = deriveBaseFamily(fam);
if (BASE_FAMILIES[base]) return { baseFamily: base, variant: fam };
}
return null;
}
// Apply parsed hash params onto `filters`. Unknown families/metrics/variants
// are ignored, never applied. Returns true when the base family changed (the
// caller then has to reload that family's data).
function applyParams(params) {
let familyChanged = false;
const fam = resolveFamily(params.family);
if (fam) {
familyChanged = fam.baseFamily !== filters.baseFamily;
filters.baseFamily = fam.baseFamily;
filters.variant = fam.variant;
}
if (params.variant !== undefined) {
const v = params.variant;
// A variant may be a data-derived model_family key that is absent from
// config, so it can only be checked against the base-family rule here;
// sync() drops it later if the loaded family does not offer it.
filters.variant = (v && deriveBaseFamily(v) === filters.baseFamily) ? v : null;
}
if (params.metric && METRIC_COLS.includes(params.metric)) {
filters.metric = params.metric;
}
pending = {};
FILTER_COLS.forEach(col => {
if (params[col] !== undefined && params[col] !== "") pending[col] = params[col];
});
return familyChanged;
}
// ─── Serialize ────────────────────────────────────────────────────────────────
// Drop a variant the currently loaded family does not actually offer β€” the
// sidebar only shows variants when there is more than one.
function normalizeVariant() {
const variants = BASE_FAMILIES[filters.baseFamily]?.variants || [];
if (filters.variant && (variants.length <= 1 || !variants.includes(filters.variant))) {
filters.variant = null;
}
}
function buildHash() {
const parts = [];
const push = (k, v) => parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(v));
push("family", filters.baseFamily);
if (filters.variant) push("variant", filters.variant);
// Always link the group_by (device) column β€” it is what people share.
// The remaining filter columns are only linked when the loaded family
// actually offers a choice; otherwise their button group is hidden and the
// value is noise that would be re-derived identically anyway.
const opts = availableOptions();
config.filters.forEach(f => {
const v = filters[f.column];
if (v === "" || v === null || v === undefined) return;
if (f.column !== GROUP_BY && (opts[f.column] || []).length <= 1) return;
push(f.column, v);
});
if (filters.metric) push("metric", filters.metric);
return parts.join("&");
}
// ─── Public: called from app.js ──────────────────────────────────────────────
// Hook in updateDependentFilters(): the value the hash asked for, when it is
// available in the freshly loaded data, else null (caller falls back to its
// own default). "all" is only meaningful for the group_by column.
function desiredFilterValue(col, availableStrVals) {
if (!ENABLED) return null;
const want = pending[col];
if (want === undefined) return null;
if (col === GROUP_BY && want === "all") return "all";
return availableStrVals.includes(want) ? want : null;
}
// Hook at the end of render(): the view has settled, publish it. replaceState
// keeps every button click out of the history stack.
function sync() {
if (!ENABLED) return;
pending = {};
normalizeVariant();
const hash = buildHash();
if (hash === lastWritten && String(location.hash).replace(/^#/, "") === hash) return;
lastWritten = hash;
const url = location.pathname + location.search + "#" + hash;
try {
history.replaceState(history.state, "", url);
} catch {
// file:// and a few sandboxes reject replaceState; the assignment below
// fires hashchange, which the lastWritten guard then ignores.
location.hash = hash;
}
}
function currentHash() {
return ENABLED ? "#" + buildHash() : "";
}
// ─── Incoming navigation ─────────────────────────────────────────────────────
async function onHashChange() {
if (!ENABLED || applying) return;
const raw = String(location.hash).replace(/^#/, "");
if (raw === lastWritten) return;
const params = parseHash(raw);
if (!Object.keys(params).length) return;
applying = true;
try {
const familyChanged = applyParams(params);
renderSidebar();
if (familyChanged) {
await switchBaseFamily(filters.baseFamily);
} else {
// Reset first so columns the URL omits fall back to their default,
// then desiredFilterValue() re-applies the ones it does name.
updateDependentFilters(true);
render();
}
} finally {
applying = false;
}
}
// ─── Init ─────────────────────────────────────────────────────────────────────
// Applied here, before populateFilters()/switchBaseFamily() run, so the first
// render already reflects the link. The hash therefore wins over the existing
// ?family= query deep link, which is applied earlier in app.js.
if (ENABLED) {
const initial = parseHash(typeof location !== "undefined" ? location.hash : "");
if (Object.keys(initial).length) applyParams(initial);
if (typeof window !== "undefined") window.addEventListener("hashchange", onHashChange);
}
return { desiredFilterValue, sync, currentHash };
} // end initUrlState