JonnaMat Claude Opus 5 commited on
Commit
dbb911b
Β·
verified Β·
1 Parent(s): e9e9bbc

Add shareable URL state, speedup column and family-scoped metric legend

Browse files

Three independent features, developed on separate branches and merged here.

Shareable URL state (urlstate.js)
Mirrors base family, variant, metric and every filter column into
location.hash, so a specific chart can be linked:
#family=Llama-3.2&device=agx_orin&metric=tps
Uses replaceState, so clicking filters does not fill the history stack.
Unknown families/metrics/filter values are ignored rather than applied.
The Embed modal now deep-links back to the exact view it was generated
from. Verified that huggingface.co forwards the hash into the Space
iframe, so links work both on the Space page and on *.hf.space.
Disable with "url_state": false.

Speedup column (speedup.js)
The data is inherently paired: an external baseline model and its
embedl variants measured under identical conditions. The tables now
carry a VS BASE column with the ratio for the active metric, always
oriented so higher is better. Baselines read 1.00x, unpaired rows read
em-dash, and an ambiguous pairing renders nothing rather than an
arbitrary number. Disable with "speedup_column": false.

Family-scoped metric legend (app.js)
The legend was built once at load from every configured metric, so a
CV family still listed TPS/TPOT/TTFT/E2E and an LLM family still
listed IPS/LAT/MEM. It now re-renders with the view and lists only the
metrics that actually have data. The "has data" predicate existed in
two copies (chart metric buttons, table columns); this factors it into
a single metricsWithData() that the legend also uses, so all three
agree. Disable with "legend_filter": false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (7) hide show
  1. app.js +52 -11
  2. config.json +1 -0
  3. embed.js +5 -2
  4. index.html +2 -0
  5. speedup.js +127 -0
  6. style.css +21 -0
  7. urlstate.js +213 -0
app.js CHANGED
@@ -371,11 +371,29 @@ function createFilterGroup(label, id) {
371
  return div;
372
  }
373
 
374
- // Metric legend
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  const legendGrid = document.getElementById("legend-grid");
376
- legendGrid.innerHTML = config.metrics.map(m =>
377
- `<div><strong>${m.short || m.column}</strong> ${m.description || m.label}</div>`
378
- ).join("");
 
 
 
 
 
379
 
380
  // ─── State ────────────────────────────────────────────────────────────────────
381
 
@@ -431,9 +449,13 @@ function updateDependentFilters(resetDefaults) {
431
  const strVals = vals.map(String);
432
  const needsReset = resetDefaults || !strVals.includes(String(filters[f.column]));
433
  if (needsReset) {
 
 
434
  // Prefer family-specific default_device for the device/group_by filter
435
  const defaultVal = f.column === GROUP_BY && familyCfg.default_device;
436
- if (defaultVal && strVals.includes(String(defaultVal))) {
 
 
437
  filters[f.column] = defaultVal;
438
  } else {
439
  filters[f.column] = vals[0] ?? "";
@@ -529,9 +551,7 @@ function buildChart(filtered) {
529
  headerRight.className = "chart-header-right";
530
 
531
  // Only show metric buttons for metrics that have non-zero data
532
- const chartVisibleMetrics = config.metrics.filter(m =>
533
- gRows.some(r => r[m.column] !== undefined && r[m.column] !== null && r[m.column] !== 0 && r[m.column] !== "N/A")
534
- );
535
  if (chartVisibleMetrics.length > 1) {
536
  const metricEl = metricGroup.querySelector(".btn-group");
537
  renderBtnGroup(metricEl,
@@ -683,9 +703,7 @@ function buildTables(filtered, chartsShown) {
683
  });
684
 
685
  // Hide metric columns where every value in the filtered data is zero, null, or N/A
686
- const visibleMetrics = config.metrics.filter(m =>
687
- filtered.some(r => r[m.column] !== undefined && r[m.column] !== null && r[m.column] !== 0 && r[m.column] !== "N/A")
688
- );
689
 
690
  // Build column list: Model + visible display cols + metrics
691
  const colDefs = [
@@ -743,6 +761,9 @@ function buildTables(filtered, chartsShown) {
743
  return metricHib ? va - vb : vb - va;
744
  });
745
 
 
 
 
746
  // Track row group for break detection
747
  let prevGroupVal = undefined;
748
 
@@ -759,6 +780,7 @@ function buildTables(filtered, chartsShown) {
759
  const cls = i === firstMetricIdx ? ' class="first-metric metric-cell"' : (c.isMetric ? ' class="metric-cell"' : '');
760
  return `<th${tip}${cls}>${c.label}</th>`;
761
  }).join("");
 
762
  html += `</tr></thead><tbody>`;
763
 
764
  // Compute best metric value per sub-group (tableGroupBy) per column
@@ -805,6 +827,7 @@ function buildTables(filtered, chartsShown) {
805
  html += `<td>${val || "β€”"}</td>`;
806
  }
807
  });
 
808
  html += "</tr>";
809
  });
810
 
@@ -932,6 +955,17 @@ async function buildAccuracyTable() {
932
  section.appendChild(card);
933
  }
934
 
 
 
 
 
 
 
 
 
 
 
 
935
  // ─── Embed SVG module ────────────────────────────────────────────────────────
936
 
937
  const embed = initEmbed({
@@ -939,8 +973,12 @@ const embed = initEmbed({
939
  getData: () => DATA, MODEL_COL, FAMILY_COL, GROUP_BY, CHART_CFG,
940
  MODEL_COLORS, MODEL_SHORT, isOOMRow, isExternalModel,
941
  sortModels, parseModelSize,
 
942
  });
943
 
 
 
 
944
  // ─── Render ───────────────────────────────────────────────────────────────────
945
 
946
  function render() {
@@ -959,9 +997,12 @@ function render() {
959
  buildChart(filtered);
960
  const chartsShown = charts.length > 0;
961
  buildTables(filtered, chartsShown);
 
 
962
  buildDemo();
963
  buildAccuracyTable();
964
  buildExperimentSetup();
 
965
  }
966
 
967
  // ─── Switch Base Family (load data + re-render) ───────────────────────────────
 
371
  return div;
372
  }
373
 
374
+ // ─── Metric legend ────────────────────────────────────────────────────────────
375
+
376
+ // [legend] Single source of truth for "which metrics have data in these rows".
377
+ // Used by buildChart (metric buttons), buildTables (metric columns) and the
378
+ // legend below, so all three agree on what is relevant to the current family.
379
+ function metricsWithData(rows) {
380
+ return config.metrics.filter(m =>
381
+ rows.some(r => r[m.column] !== undefined && r[m.column] !== null && r[m.column] !== 0 && r[m.column] !== "N/A")
382
+ );
383
+ }
384
+
385
+ // [legend] The legend explains only the metrics currently on screen: a CV family
386
+ // (ips/lat/mem) no longer shows the LLM keys and vice versa. Re-rendered on every
387
+ // render(). Set config.legend_filter = false to always list every metric.
388
  const legendGrid = document.getElementById("legend-grid");
389
+
390
+ function renderLegend(metrics) {
391
+ const shown = config.legend_filter === false ? config.metrics : metrics;
392
+ legendGrid.innerHTML = shown.map(m =>
393
+ `<div><strong>${m.short || m.column}</strong> ${m.description || m.label}</div>`
394
+ ).join("");
395
+ legendGrid.style.display = shown.length ? "" : "none";
396
+ }
397
 
398
  // ─── State ────────────────────────────────────────────────────────────────────
399
 
 
449
  const strVals = vals.map(String);
450
  const needsReset = resetDefaults || !strVals.includes(String(filters[f.column]));
451
  if (needsReset) {
452
+ // [urlstate] a value asked for by the URL hash wins over the family default
453
+ const wanted = urlstate.desiredFilterValue(f.column, strVals);
454
  // Prefer family-specific default_device for the device/group_by filter
455
  const defaultVal = f.column === GROUP_BY && familyCfg.default_device;
456
+ if (wanted !== null) {
457
+ filters[f.column] = wanted;
458
+ } else if (defaultVal && strVals.includes(String(defaultVal))) {
459
  filters[f.column] = defaultVal;
460
  } else {
461
  filters[f.column] = vals[0] ?? "";
 
551
  headerRight.className = "chart-header-right";
552
 
553
  // Only show metric buttons for metrics that have non-zero data
554
+ const chartVisibleMetrics = metricsWithData(gRows); // [legend] was an inline config.metrics.filter(...)
 
 
555
  if (chartVisibleMetrics.length > 1) {
556
  const metricEl = metricGroup.querySelector(".btn-group");
557
  renderBtnGroup(metricEl,
 
703
  });
704
 
705
  // Hide metric columns where every value in the filtered data is zero, null, or N/A
706
+ const visibleMetrics = metricsWithData(filtered); // [legend] was an inline config.metrics.filter(...)
 
 
707
 
708
  // Build column list: Model + visible display cols + metrics
709
  const colDefs = [
 
761
  return metricHib ? va - vb : vb - va;
762
  });
763
 
764
+ // [speedup] pair optimized rows with their baseline for this table
765
+ speedup.prepare(rows, { tableGroupCols, visibleMetrics, activeMetricCol });
766
+
767
  // Track row group for break detection
768
  let prevGroupVal = undefined;
769
 
 
780
  const cls = i === firstMetricIdx ? ' class="first-metric metric-cell"' : (c.isMetric ? ' class="metric-cell"' : '');
781
  return `<th${tip}${cls}>${c.label}</th>`;
782
  }).join("");
783
+ html += speedup.headerHtml(); // [speedup] extra right-hand column
784
  html += `</tr></thead><tbody>`;
785
 
786
  // Compute best metric value per sub-group (tableGroupBy) per column
 
827
  html += `<td>${val || "β€”"}</td>`;
828
  }
829
  });
830
+ html += speedup.cellHtml(r); // [speedup] extra right-hand column
831
  html += "</tr>";
832
  });
833
 
 
955
  section.appendChild(card);
956
  }
957
 
958
+ // ─── URL state module ────────────────────────────────────────────────────────
959
+
960
+ // [urlstate] Construction applies any incoming #hash to `filters` right here,
961
+ // before populateFilters()/switchBaseFamily() below, so the first render
962
+ // already shows the linked view. render() then keeps the hash in sync.
963
+ const urlstate = initUrlState({
964
+ config, filters, BASE_FAMILIES, deriveBaseFamily, GROUP_BY,
965
+ availableOptions, renderSidebar, updateDependentFilters,
966
+ switchBaseFamily, render,
967
+ });
968
+
969
  // ─── Embed SVG module ────────────────────────────────────────────────────────
970
 
971
  const embed = initEmbed({
 
973
  getData: () => DATA, MODEL_COL, FAMILY_COL, GROUP_BY, CHART_CFG,
974
  MODEL_COLORS, MODEL_SHORT, isOOMRow, isExternalModel,
975
  sortModels, parseModelSize,
976
+ shareHash: urlstate.currentHash, // [urlstate] embed link points at this view
977
  });
978
 
979
+ // [speedup] Speedup vs baseline column module
980
+ const speedup = initSpeedup({ config, MODEL_COL, FAMILY_COL, isExternalModel });
981
+
982
  // ─── Render ───────────────────────────────────────────────────────────────────
983
 
984
  function render() {
 
997
  buildChart(filtered);
998
  const chartsShown = charts.length > 0;
999
  buildTables(filtered, chartsShown);
1000
+ renderLegend(metricsWithData(filtered)); // [legend] follows the table columns
1001
+
1002
  buildDemo();
1003
  buildAccuracyTable();
1004
  buildExperimentSetup();
1005
+ urlstate.sync(); // [urlstate] mirror the settled view into location.hash
1006
  }
1007
 
1008
  // ─── Switch Base Family (load data + re-render) ───────────────────────────────
config.json CHANGED
@@ -5,6 +5,7 @@
5
  "model_family_column": "model_family",
6
  "model_link_prefix": "https://huggingface.co/",
7
  "optimized_org": "embedl",
 
8
  "base_names": {
9
  "Llama-3.2-1B": "Llama-3.2-1B-Instruct",
10
  "Llama-3.2-3B": "Llama-3.2-3B-Instruct",
 
5
  "model_family_column": "model_family",
6
  "model_link_prefix": "https://huggingface.co/",
7
  "optimized_org": "embedl",
8
+ "speedup_column": true,
9
  "base_names": {
10
  "Llama-3.2-1B": "Llama-3.2-1B-Instruct",
11
  "Llama-3.2-3B": "Llama-3.2-3B-Instruct",
embed.js CHANGED
@@ -10,7 +10,7 @@ const {
10
  config, filters, activeFamilyKey, getActiveModelSet,
11
  getData, MODEL_COL, FAMILY_COL, GROUP_BY, CHART_CFG,
12
  MODEL_COLORS, MODEL_SHORT, isOOMRow, isExternalModel,
13
- sortModels, parseModelSize,
14
  } = deps;
15
 
16
  let embedModal = null;
@@ -310,7 +310,10 @@ async function showEmbedModal() {
310
  const fileName = embedFileName();
311
  const svgUrl = "https://huggingface.co/datasets/" + HF_DOCS_REPO + "/resolve/main/" + HF_SVG_FOLDER + "/" + fileName;
312
 
313
- const embedCode = '<a href="' + HF_SPACES_URL + '" target="_blank" rel="noopener">\n <img\n src="' + svgUrl + '"\n alt="Edge Inference Benchmarks for ' + famKey + '"\n width="100%"\n />\n</a>';
 
 
 
314
 
315
  embedModal.querySelector("#embed-code").value = embedCode;
316
  embedModal.classList.add("visible");
 
10
  config, filters, activeFamilyKey, getActiveModelSet,
11
  getData, MODEL_COL, FAMILY_COL, GROUP_BY, CHART_CFG,
12
  MODEL_COLORS, MODEL_SHORT, isOOMRow, isExternalModel,
13
+ sortModels, parseModelSize, shareHash,
14
  } = deps;
15
 
16
  let embedModal = null;
 
310
  const fileName = embedFileName();
311
  const svgUrl = "https://huggingface.co/datasets/" + HF_DOCS_REPO + "/resolve/main/" + HF_SVG_FOLDER + "/" + fileName;
312
 
313
+ // Deep-link the embed back to the exact view it was generated from.
314
+ const spacesUrl = HF_SPACES_URL + (typeof shareHash === "function" ? shareHash() : "");
315
+
316
+ const embedCode = '<a href="' + spacesUrl + '" target="_blank" rel="noopener">\n <img\n src="' + svgUrl + '"\n alt="Edge Inference Benchmarks for ' + famKey + '"\n width="100%"\n />\n</a>';
317
 
318
  embedModal.querySelector("#embed-code").value = embedCode;
319
  embedModal.classList.add("visible");
index.html CHANGED
@@ -86,6 +86,8 @@
86
 
87
  <script src="demo/demo.js"></script>
88
  <script src="embed.js"></script>
 
 
89
  <script src="app.js"></script>
90
  </body>
91
  </html>
 
86
 
87
  <script src="demo/demo.js"></script>
88
  <script src="embed.js"></script>
89
+ <script src="speedup.js"></script>
90
+ <script src="urlstate.js"></script>
91
  <script src="app.js"></script>
92
  </body>
93
  </html>
speedup.js ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─── Speedup vs Baseline Column ──────────────────────────────────────────────
2
+ //
3
+ // Extracted module. Call initSpeedup(deps) from the main app once globals are
4
+ // ready. Returns { prepare, headerHtml, cellHtml }.
5
+ //
6
+ // The benchmark data is inherently paired: an external baseline model (e.g.
7
+ // meta-llama/Llama-3.2-1B-Instruct) and the optimized embedl variants of it
8
+ // (embedl/Llama-3.2-1B-Instruct-FlashHead, …) measured under identical
9
+ // conditions. This module pairs them up and renders their ratio for the
10
+ // active metric as an extra right-hand column in the benchmark tables.
11
+
12
+ // eslint-disable-next-line no-unused-vars
13
+ function initSpeedup(deps) {
14
+
15
+ const {
16
+ config, MODEL_COL, FAMILY_COL, isExternalModel,
17
+ } = deps;
18
+
19
+ const ENABLED = config.speedup_column !== false;
20
+ const LABEL = config.speedup_label || "VS BASE";
21
+
22
+ // Two rows are comparable only when everything that defines the measurement
23
+ // matches: the model family, every filter column (type / batch / device) and
24
+ // every configured display column (res / fps / frames / ctx). Leaving the
25
+ // display columns out would happily pair a 1920x1080 run with a 854x480 one.
26
+ const KEY_COLS = [
27
+ FAMILY_COL,
28
+ ...config.filters.map(f => f.column),
29
+ ...(config.display_columns || []).map(d => d.column),
30
+ ].filter(Boolean);
31
+
32
+ function pairKey(row, cols) {
33
+ return cols.map(c => String(row[c] ?? "")).join("\0");
34
+ }
35
+
36
+ /** Metric value as a usable positive number; undefined for OOM / not-measured / "N/A" / 0. */
37
+ function usable(val) {
38
+ return typeof val === "number" && isFinite(val) && val > 0 ? val : undefined;
39
+ }
40
+
41
+ function formatRatio(ratio) {
42
+ return ratio.toFixed(2) + "Γ—";
43
+ }
44
+
45
+ // Per-table state, rebuilt by prepare() before each table is rendered.
46
+ let state = null;
47
+
48
+ // ─── Pairing ─────────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Compute the baseline pairing for one rendered table.
52
+ * @param rows the rows of this table (already filtered to one group_by value)
53
+ * @param opts { tableGroupCols, visibleMetrics, activeMetricCol }
54
+ */
55
+ function prepare(rows, opts) {
56
+ state = null;
57
+ if (!ENABLED || !rows || !rows.length) return;
58
+
59
+ const visibleMetrics = opts.visibleMetrics || [];
60
+ const metricCfg = visibleMetrics.find(m => m.column === opts.activeMetricCol)
61
+ || visibleMetrics[0];
62
+ if (!metricCfg) return;
63
+
64
+ const metricCol = metricCfg.column;
65
+ const hib = metricCfg.higher_is_better !== false;
66
+ const cols = [...new Set(KEY_COLS.concat(opts.tableGroupCols || []))];
67
+
68
+ // Bucket the external (baseline) rows by comparison key.
69
+ const baselines = {};
70
+ rows.forEach(r => {
71
+ if (!isExternalModel(r[MODEL_COL])) return;
72
+ const k = pairKey(r, cols);
73
+ (baselines[k] = baselines[k] || []).push(r);
74
+ });
75
+
76
+ const ratios = new Map(); // optimized row -> ratio
77
+ const isBase = new Set(); // baseline rows that actually anchor a pair
78
+
79
+ rows.forEach(r => {
80
+ if (isExternalModel(r[MODEL_COL])) return;
81
+ const cands = baselines[pairKey(r, cols)];
82
+ if (!cands || !cands.length) return;
83
+ // Ambiguous: several *different* baseline models match the same key.
84
+ // Show nothing rather than an arbitrary ratio.
85
+ if (new Set(cands.map(c => c[MODEL_COL])).size > 1) return;
86
+ const base = cands[0];
87
+ const b = usable(base[metricCol]);
88
+ const o = usable(r[metricCol]);
89
+ if (b === undefined || o === undefined) return;
90
+ const ratio = hib ? o / b : b / o;
91
+ if (!isFinite(ratio) || ratio <= 0) return;
92
+ ratios.set(r, ratio);
93
+ isBase.add(base);
94
+ });
95
+
96
+ if (!ratios.size) return; // no real pair in this table -> no column
97
+ state = { ratios, isBase, metricCfg };
98
+ }
99
+
100
+ // ─── Rendering ───────────────────────────────────────────────────────────────
101
+
102
+ function headerHtml() {
103
+ if (!state) return "";
104
+ const m = state.metricCfg;
105
+ const tip = `Ratio of this model's ${m.label || m.column} against the original `
106
+ + `(non-${config.optimized_org || "embedl"}) model measured under identical conditions. `
107
+ + `Always oriented so that higher is better: 1.26Γ— means 26% better than the baseline.`;
108
+ return `<th class="metric-cell speedup-cell" data-tip="${tip.replace(/"/g, "&quot;")}">${LABEL}</th>`;
109
+ }
110
+
111
+ function cellHtml(row) {
112
+ if (!state) return "";
113
+ if (state.isBase.has(row)) {
114
+ // The baseline is the reference point, so it is 1.00x by definition.
115
+ // Spelling it out keeps "β€”" unambiguously meaning "no comparison".
116
+ return `<td class="metric-cell speedup-cell"><span class="speedup-ref">1.00Γ—</span></td>`;
117
+ }
118
+ const ratio = state.ratios.get(row);
119
+ if (ratio === undefined) return `<td class="metric-cell speedup-cell">β€”</td>`;
120
+ const down = ratio < 0.995 ? " is-down" : "";
121
+ return `<td class="metric-cell speedup-cell">`
122
+ + `<span class="speedup-val${down}">${formatRatio(ratio)}</span></td>`;
123
+ }
124
+
125
+ return { prepare, headerHtml, cellHtml };
126
+
127
+ }
style.css CHANGED
@@ -798,6 +798,27 @@ tbody tr.row-group-break td {
798
  border-color: var(--btn-active-border);
799
  }
800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  /* ── Responsive ──────────────────────────────────────── */
802
  @media (max-width: 900px) {
803
  .page {
 
798
  border-color: var(--btn-active-border);
799
  }
800
 
801
+ /* ── Speedup ─────────────────────────────────────────── */
802
+ td.speedup-cell {
803
+ position: relative;
804
+ color: var(--text);
805
+ font-variant-numeric: tabular-nums;
806
+ }
807
+
808
+ .speedup-val {
809
+ position: relative;
810
+ font-weight: 600;
811
+ color: var(--green);
812
+ }
813
+
814
+ .speedup-val.is-down {
815
+ color: var(--red);
816
+ }
817
+
818
+ .speedup-ref {
819
+ color: var(--text-dim);
820
+ }
821
+
822
  /* ── Responsive ──────────────────────────────────────── */
823
  @media (max-width: 900px) {
824
  .page {
urlstate.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ─── Shareable URL State ─────────────────────────────────────────────────────
2
+ //
3
+ // Mirrors the current view β€” base family, variant, metric and every
4
+ // config.filters column β€” into location.hash so that a view can be linked:
5
+ //
6
+ // #family=Llama-3.2&device=agx_orin&metric=tps
7
+ //
8
+ // Extracted module. Call initUrlState(deps) once, after `filters` exists and
9
+ // BEFORE the first render: construction applies an incoming hash to `filters`
10
+ // in place, so the very first render already shows the linked view (there is
11
+ // no visible second render). Returns { desiredFilterValue, sync, currentHash }.
12
+ //
13
+ // Disable with "url_state": false in config.json.
14
+
15
+ // eslint-disable-next-line no-unused-vars
16
+ function initUrlState(deps) {
17
+
18
+ const {
19
+ config, filters, BASE_FAMILIES, deriveBaseFamily, GROUP_BY,
20
+ availableOptions, renderSidebar, updateDependentFilters,
21
+ switchBaseFamily, render,
22
+ } = deps;
23
+
24
+ const ENABLED = config.url_state !== false;
25
+ const FILTER_COLS = config.filters.map(f => f.column);
26
+ const METRIC_COLS = config.metrics.map(m => m.column);
27
+
28
+ // Filter values requested by the hash but not applied yet. They are consumed
29
+ // by updateDependentFilters(), which otherwise resets every filter column to a
30
+ // family default whenever the newly loaded data does not contain the current
31
+ // value. Cleared by sync() once the view has settled.
32
+ let pending = {};
33
+
34
+ // The hash this module last wrote, so our own replaceState is not re-applied.
35
+ let lastWritten = null;
36
+
37
+ // Guard against re-entrant hashchange handling during an async family switch.
38
+ let applying = false;
39
+
40
+ // ─── Parse ────────────────────────────────────────────────────────────────────
41
+
42
+ function parseHash(raw) {
43
+ const out = {};
44
+ const s = String(raw || "").replace(/^#/, "");
45
+ if (!s) return out;
46
+ s.split("&").forEach(part => {
47
+ const i = part.indexOf("=");
48
+ if (i < 1) return;
49
+ try {
50
+ const k = decodeURIComponent(part.slice(0, i)).trim();
51
+ const v = decodeURIComponent(part.slice(i + 1)).trim();
52
+ if (k) out[k] = v;
53
+ } catch {
54
+ // Malformed percent-escape β€” drop the pair rather than throw.
55
+ }
56
+ });
57
+ return out;
58
+ }
59
+
60
+ // Accept either a base family ("Llama-3.2") or a config family key
61
+ // ("Llama-3.2-1B"), mirroring the ?family= query deep link.
62
+ function resolveFamily(fam) {
63
+ if (!fam) return null;
64
+ if (BASE_FAMILIES[fam]) return { baseFamily: fam, variant: null };
65
+ if (config.model_families?.[fam]) {
66
+ const base = deriveBaseFamily(fam);
67
+ if (BASE_FAMILIES[base]) return { baseFamily: base, variant: fam };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Apply parsed hash params onto `filters`. Unknown families/metrics/variants
73
+ // are ignored, never applied. Returns true when the base family changed (the
74
+ // caller then has to reload that family's data).
75
+ function applyParams(params) {
76
+ let familyChanged = false;
77
+
78
+ const fam = resolveFamily(params.family);
79
+ if (fam) {
80
+ familyChanged = fam.baseFamily !== filters.baseFamily;
81
+ filters.baseFamily = fam.baseFamily;
82
+ filters.variant = fam.variant;
83
+ }
84
+
85
+ if (params.variant !== undefined) {
86
+ const v = params.variant;
87
+ // A variant may be a data-derived model_family key that is absent from
88
+ // config, so it can only be checked against the base-family rule here;
89
+ // sync() drops it later if the loaded family does not offer it.
90
+ filters.variant = (v && deriveBaseFamily(v) === filters.baseFamily) ? v : null;
91
+ }
92
+
93
+ if (params.metric && METRIC_COLS.includes(params.metric)) {
94
+ filters.metric = params.metric;
95
+ }
96
+
97
+ pending = {};
98
+ FILTER_COLS.forEach(col => {
99
+ if (params[col] !== undefined && params[col] !== "") pending[col] = params[col];
100
+ });
101
+
102
+ return familyChanged;
103
+ }
104
+
105
+ // ─── Serialize ────────────────────────────────────────────────────────────────
106
+
107
+ // Drop a variant the currently loaded family does not actually offer β€” the
108
+ // sidebar only shows variants when there is more than one.
109
+ function normalizeVariant() {
110
+ const variants = BASE_FAMILIES[filters.baseFamily]?.variants || [];
111
+ if (filters.variant && (variants.length <= 1 || !variants.includes(filters.variant))) {
112
+ filters.variant = null;
113
+ }
114
+ }
115
+
116
+ function buildHash() {
117
+ const parts = [];
118
+ const push = (k, v) => parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(v));
119
+
120
+ push("family", filters.baseFamily);
121
+ if (filters.variant) push("variant", filters.variant);
122
+
123
+ // Always link the group_by (device) column β€” it is what people share.
124
+ // The remaining filter columns are only linked when the loaded family
125
+ // actually offers a choice; otherwise their button group is hidden and the
126
+ // value is noise that would be re-derived identically anyway.
127
+ const opts = availableOptions();
128
+ config.filters.forEach(f => {
129
+ const v = filters[f.column];
130
+ if (v === "" || v === null || v === undefined) return;
131
+ if (f.column !== GROUP_BY && (opts[f.column] || []).length <= 1) return;
132
+ push(f.column, v);
133
+ });
134
+
135
+ if (filters.metric) push("metric", filters.metric);
136
+ return parts.join("&");
137
+ }
138
+
139
+ // ─── Public: called from app.js ──────────────────────────────────────────────
140
+
141
+ // Hook in updateDependentFilters(): the value the hash asked for, when it is
142
+ // available in the freshly loaded data, else null (caller falls back to its
143
+ // own default). "all" is only meaningful for the group_by column.
144
+ function desiredFilterValue(col, availableStrVals) {
145
+ if (!ENABLED) return null;
146
+ const want = pending[col];
147
+ if (want === undefined) return null;
148
+ if (col === GROUP_BY && want === "all") return "all";
149
+ return availableStrVals.includes(want) ? want : null;
150
+ }
151
+
152
+ // Hook at the end of render(): the view has settled, publish it. replaceState
153
+ // keeps every button click out of the history stack.
154
+ function sync() {
155
+ if (!ENABLED) return;
156
+ pending = {};
157
+ normalizeVariant();
158
+ const hash = buildHash();
159
+ if (hash === lastWritten && String(location.hash).replace(/^#/, "") === hash) return;
160
+ lastWritten = hash;
161
+ const url = location.pathname + location.search + "#" + hash;
162
+ try {
163
+ history.replaceState(history.state, "", url);
164
+ } catch {
165
+ // file:// and a few sandboxes reject replaceState; the assignment below
166
+ // fires hashchange, which the lastWritten guard then ignores.
167
+ location.hash = hash;
168
+ }
169
+ }
170
+
171
+ function currentHash() {
172
+ return ENABLED ? "#" + buildHash() : "";
173
+ }
174
+
175
+ // ─── Incoming navigation ─────────────────────────────────────────────────────
176
+
177
+ async function onHashChange() {
178
+ if (!ENABLED || applying) return;
179
+ const raw = String(location.hash).replace(/^#/, "");
180
+ if (raw === lastWritten) return;
181
+ const params = parseHash(raw);
182
+ if (!Object.keys(params).length) return;
183
+ applying = true;
184
+ try {
185
+ const familyChanged = applyParams(params);
186
+ renderSidebar();
187
+ if (familyChanged) {
188
+ await switchBaseFamily(filters.baseFamily);
189
+ } else {
190
+ // Reset first so columns the URL omits fall back to their default,
191
+ // then desiredFilterValue() re-applies the ones it does name.
192
+ updateDependentFilters(true);
193
+ render();
194
+ }
195
+ } finally {
196
+ applying = false;
197
+ }
198
+ }
199
+
200
+ // ─── Init ─────────────────────────────────────────────────────────────────────
201
+
202
+ // Applied here, before populateFilters()/switchBaseFamily() run, so the first
203
+ // render already reflects the link. The hash therefore wins over the existing
204
+ // ?family= query deep link, which is applied earlier in app.js.
205
+ if (ENABLED) {
206
+ const initial = parseHash(typeof location !== "undefined" ? location.hash : "");
207
+ if (Object.keys(initial).length) applyParams(initial);
208
+ if (typeof window !== "undefined") window.addEventListener("hashchange", onHashChange);
209
+ }
210
+
211
+ return { desiredFilterValue, sync, currentHash };
212
+
213
+ } // end initUrlState