@wendellresearch-ai/benchwarmers 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.4
1
+ 0.6.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wendellresearch-ai/benchwarmers",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Self-hosted panel gateway for LLMs: ask a collection of models at once, keep every answer side by side, rotate explorer models in, and let your verdicts pick the next panel.",
5
5
  "mcpName": "io.github.WendellResearch-AI/benchwarmers",
6
6
  "license": "MIT",
package/serve.py CHANGED
@@ -2701,7 +2701,14 @@ def vision_card() -> str:
2701
2701
  come from these alone. <b>Useful, no claims</b> is a verdict without them, which on thicken's
2702
2702
  photos means the seat agreed with another seat. Agreement is not a check, since seats fail
2703
2703
  together, so it is never counted as right.</dd>
2704
- <dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks, seats on real work.</dd>
2704
+ <dt>The real-work chart</dt><dd>Read as Real work is. The dot is the share of its judged seats your
2705
+ verdict called useful (checked and helped, or useful with no claims), bigger for more judged; the
2706
+ line is where that share likely sits (95%), and the order is by its low end, so a lot of evidence
2707
+ outranks a little. Green when even the low end is over half, red when even the high end is under,
2708
+ grey when there are too few to say. Beside it: useful of judged, how often it answered at all, and
2709
+ the claims checked by hand, the only count of right and wrong. Seats not judged or not answered
2710
+ are in those words and the table, not the dot.</dd>
2711
+ <dt>The number beside a name</dt><dd>How much there is: fields asked on the tasks.</dd>
2705
2712
  </dl>"""
2706
2713
  held = (f"<p class='hint'>The vision tasks wait until {esc(v['held'])}: no run picks them before then"
2707
2714
  " (<code>vision_from</code> in <code>config/suite.json</code>; remove it to start them now).</p>"
@@ -2709,7 +2716,7 @@ def vision_card() -> str:
2709
2716
  return (f"<div class='panel'>{SHARE}<h2>Vision</h2><p class='sub'>Reading images: fields read right on pictures with"
2710
2717
  " a known answer, then real work on images and how much of it was actually checked.</p>" + held +
2711
2718
  "<h3 class='chart-head'>On the vision tasks, field by field</h3><div class='chart' id='chart-vision-fields'></div>"
2712
- "<h3 class='chart-head'>On real work with images, by how each seat was judged</h3>"
2719
+ "<h3 class='chart-head'>On real work with images: judged useful, and how sure that is</h3>"
2713
2720
  "<div class='chart' id='chart-vision-work'></div>"
2714
2721
  "<details class='key'><summary>Table</summary><div class='toolbar'><input type='search'"
2715
2722
  " data-filter placeholder='Filter by model or connection' aria-label='Filter rows'>"
@@ -3224,7 +3231,7 @@ def uptime_data() -> dict:
3224
3231
 
3225
3232
  def uptime_card() -> str:
3226
3233
  """Was the provider there when the bench called it: a cell a day, by
3227
- provider then by model; 7 days, or 30 on choice (bench.js swaps them)."""
3234
+ provider or by model, as its By says; 7 days, or 30 on choice (bench.js swaps them)."""
3228
3235
  u = uptime_rows()
3229
3236
  first, gone, names = UPTIME_DAYS[0], retired(), {}
3230
3237
 
@@ -3298,9 +3305,11 @@ def uptime_card() -> str:
3298
3305
  "<span><i style='background:var(--signal)'></i>some down, 80% or more answered</span>"
3299
3306
  "<span><i style='background:var(--err)'></i>under 80% answered</span>"
3300
3307
  "<span><i style='background:none;border:1px solid var(--line)'></i>no counted calls</span></div>")
3308
+ # The window and one view at a time: by provider (shorter, the default) or by model (bench.js).
3301
3309
  pick = ("<div class='toolbar'><label>Days <select data-uptime-days>"
3302
3310
  + "".join(f"<option value='{w}'>last {w}</option>" for w in UPTIME_DAYS)
3303
- + f"</select></label></div>{legend}")
3311
+ + "</select></label><label>By <select data-uptime-by><option value='providers'>provider</option>"
3312
+ f"<option value='models'>model</option></select></label></div>{legend}")
3304
3313
  key = """<dl>
3305
3314
  <dt>Down</dt><dd>The provider was busy or overloaded (a 5xx, high demand, a shared pool's
3306
3315
  429), unreachable (a broken stream, no reply), timed out (the gateway's 420 s included), or
@@ -3326,9 +3335,7 @@ def uptime_card() -> str:
3326
3335
  " and every real call, one cell a day. Busy, unreachable and timed out count as down;"
3327
3336
  " our own quota, our own pacing and setup problems are left out.",
3328
3337
  "chart-uptime-models", "".join(window(w) for w in UPTIME_DAYS), key,
3329
- extra=pick + "<h3 class='chart-head'>By provider</h3>"
3330
- "<div class='chart' id='chart-uptime-providers'></div>"
3331
- "<h3 class='chart-head'>By model</h3>",
3338
+ extra=pick + "<div class='chart' id='chart-uptime-providers'></div>",
3332
3339
  after="".join(odd(w) for w in UPTIME_DAYS))
3333
3340
 
3334
3341
 
@@ -3655,10 +3662,20 @@ def wrong_card() -> str:
3655
3662
 
3656
3663
  def benchmarks_tab() -> str:
3657
3664
  data = json.dumps(charts_data(), ensure_ascii=False).replace("</", "<\\/")
3658
- # Trend, Uptime and Performance side by side: what moves over time, in one stretch.
3659
- return (overview_card() + bughunt_card() + work_card() + wrong_card() + suite_card() + vision_card()
3660
- + trend_card() + uptime_card() + perf_card() + decision_card() + external_card()
3661
- + f"<script type='application/json' id='charts-data'>{data}</script>")
3665
+ # Who to trust, then what moves day to day (Uptime, Trend, Performance), then Real work,
3666
+ # then the rest in the order they had. Each report gets an id; a bar of links to them
3667
+ # stays at the top while the page scrolls, the one in view marked (bench.js jumpSpy).
3668
+ reports = (("trust", "Who to trust", overview_card), ("uptime", "Uptime", uptime_card),
3669
+ ("trend", "Trend", trend_card), ("performance", "Performance", perf_card),
3670
+ ("work", "Real work", work_card), ("hunt", "Bug hunt", bughunt_card),
3671
+ ("wrong", "Confidently wrong", wrong_card), ("code", "Our code", suite_card),
3672
+ ("vision", "Vision", vision_card), ("decisions", "Decision models", decision_card),
3673
+ ("external", "External", external_card))
3674
+ links = "".join(f"<a href='#report-{key}'>{esc(label)}</a>" for key, label, _ in reports)
3675
+ cards = "".join(card().replace("<div class='panel'", f"<div class='panel report' id='report-{key}'", 1)
3676
+ for key, _, card in reports)
3677
+ return (f"<nav class='jump' aria-label='On this page'><p class='jump-title'>On this page</p>{links}</nav>{cards}"
3678
+ f"<script type='application/json' id='charts-data'>{data}</script>")
3662
3679
 
3663
3680
 
3664
3681
  def perf_card() -> str:
@@ -5251,11 +5268,16 @@ def models_page() -> str:
5251
5268
  " comes from, with the endpoint it is reached through and what is known of its limits. A name"
5252
5269
  " opens the model.</p><div class='toolbar'><input type='search' data-filter='page'"
5253
5270
  " placeholder='Find a model or connection' aria-label='Filter models'>"
5254
- # One labelled toggle, as accordions do it (GOV.UK's "Show all sections"): Collapse all
5255
- # while every table is open, else Expand all (bench.js foldButtons keeps it right).
5256
- "<button class='fold-all' type='button' data-fold-all='close' aria-expanded='true'>"
5257
- "<span class='fold-chev' aria-hidden='true'></span>Collapse all</button></div></div>"
5258
- + "".join(cards))
5271
+ f"{fold_toggle(True)}</div></div>" + "".join(cards))
5272
+
5273
+
5274
+ def fold_toggle(all_open: bool) -> str:
5275
+ """One labelled toggle over a page's folds (Models' connections, Logs' logs), as
5276
+ accordions do it (GOV.UK's "Show all sections"): Collapse all while every one is
5277
+ open, else Expand all. bench.js foldButtons keeps it right as folds change."""
5278
+ return (f"<button class='fold-all' type='button' data-fold-all='{'close' if all_open else 'open'}'"
5279
+ f" aria-expanded='{str(all_open).lower()}'><span class='fold-chev' aria-hidden='true'></span>"
5280
+ f"{'Collapse all' if all_open else 'Expand all'}</button>")
5259
5281
 
5260
5282
 
5261
5283
  def quota_tiles() -> list[dict]:
@@ -5431,16 +5453,25 @@ def logs_page() -> str:
5431
5453
  for p in sorted((DATA / "runs").glob("onboard-*.log"), reverse=True)[:30])
5432
5454
 
5433
5455
  def section(title, body, n, open_=False):
5434
- # CSV: this log alone, its own columns, the filter applied (bench.js); for a spreadsheet.
5435
5456
  return (f"<details class='panel'{' open' if open_ else ''} data-log='{esc(title)}'><summary><b>{esc(title)}</b>"
5436
- f" <span class='muted'>· {n}</span> <button class='btn small' type='button' data-csv"
5437
- f" title='Save this log, as filtered, as a CSV file'>CSV</button></summary>{body}</details>")
5457
+ f" <span class='muted'>· {n}</span></summary>{body}</details>")
5458
+ # Export: a popup for the format and the logs (bench.js), each as the page shows it,
5459
+ # the filter applied. Text, every log chosen in one file to read; CSV, a file per log.
5460
+ picks = "".join(f"<label class='check'><input type='checkbox' name='lx-log' value='{esc(t)}' checked> {esc(t)}</label>"
5461
+ for t in ("Gateway calls", "Changes", "Scheduler", "Onboarding runs"))
5462
+ export = ("<dialog id='logs-export-dialog' aria-labelledby='lx-title'><h2 id='lx-title'>Export logs</h2>"
5463
+ "<fieldset><legend class='field'>Format</legend>"
5464
+ "<label class='check'><input type='radio' name='lx-format' value='txt' checked> Text (.txt)</label>"
5465
+ "<label class='check'><input type='radio' name='lx-format' value='csv'> CSV (.csv)</label></fieldset>"
5466
+ f"<fieldset><legend class='field'>Logs</legend>{picks}</fieldset>"
5467
+ "<p class='rules' id='lx-note'></p><div class='errors' id='lx-errors' role='alert'></div>"
5468
+ "<div class='actions'><button class='btn' type='button' id='lx-cancel'>Cancel</button>"
5469
+ "<button class='btn primary' type='button' id='lx-go'>Export</button></div></dialog>")
5438
5470
  return (
5439
5471
  "<div class='panel'><div class='toolbar'><input data-filter='page' placeholder='Filter every log on this page'"
5440
5472
  " aria-label='Filter logs'>"
5441
- # bench.js: what the page shows, filter applied, as one .txt to read offline.
5442
- "<button class='btn small' type='button' id='logs-export' title='Save the logs on this page, as"
5443
- " filtered, to a text file'>Export as text</button></div></div>"
5473
+ "<button class='btn small' type='button' id='logs-export'>Export…</button>"
5474
+ f"{fold_toggle(False)}</div></div>{export}"
5444
5475
  + section("Gateway calls", "<table><thead><tr>" + th("When") + th("Collection") + th("Shape")
5445
5476
  + th("Seats") + f"</tr></thead><tbody>{''.join(gw)}</tbody></table>", len(gw), True)
5446
5477
  + section("Changes", "<table><thead><tr>" + th("When") + th("By") + th("Model") + th("What")
package/ui/bench.css CHANGED
@@ -536,6 +536,34 @@ pre.log.answer { max-height: 40vh; }
536
536
  .chart.muted { font-size: .875rem; }
537
537
  h3.chart-head { font-size: 1rem; margin: 1rem 0 .2rem; }
538
538
  .chart-label { font-size: .875rem; font-weight: 600; margin: .9rem 0 .1rem; }
539
+ /* Benchmarks' "On this page": a link per report, the one in view marked (bench.js jumpSpy),
540
+ after the U.S. Web Design System's in-page navigation. Dressed as the sidebar is: the
541
+ theme's frosted rail, muted links, the highlighter stroke on the current one.
542
+ Narrow: a bar floating at the top of the page, sideways when it does not fit.
543
+ Wide: a rail beside the reports, sticky, every link on its own line. */
544
+ .jump { position: sticky; top: .75rem; z-index: 4; display: flex; align-items: center; gap: 1rem;
545
+ margin: .9rem 1.2rem 0; padding: .45rem 1.1rem; overflow-x: auto; scrollbar-width: none;
546
+ background: var(--rail, var(--paper-2)); border: 1px solid var(--line); border-radius: 999px;
547
+ -webkit-backdrop-filter: var(--glass, blur(10px)); backdrop-filter: var(--glass, blur(10px));
548
+ box-shadow: 0 10px 28px -16px rgb(0 0 0 / .4); }
549
+ .jump::-webkit-scrollbar { display: none; }
550
+ .jump .jump-title { display: none; margin: 0 0 .2rem; font-size: .75rem; font-weight: 700; letter-spacing: .08em;
551
+ text-transform: uppercase; color: color-mix(in oklab, var(--brand) 72%, var(--ink)); }
552
+ .jump a { flex: none; padding: .1rem 0; line-height: 1.15; color: var(--muted); text-decoration: none;
553
+ font-size: .9375rem; font-weight: 500; white-space: nowrap; }
554
+ .jump a:hover, .jump a:focus-visible { color: var(--ink); }
555
+ .jump a[aria-current] { color: var(--ink); font-weight: 700; box-shadow: inset 0 -.42em 0 var(--signal); }
556
+ .panel.report { scroll-margin-top: 4.2rem; } /* a jump lands under the bar, not behind it */
557
+ @media (min-width: 75rem) {
558
+ #tab-suite:has(> .jump) { display: grid; grid-template-columns: minmax(0, 1fr) 11rem; }
559
+ #tab-suite:has(> .jump) > * { grid-column: 1; }
560
+ #tab-suite > .jump { grid-column: 2; grid-row: 1 / span 40; align-self: start; top: 1rem;
561
+ flex-direction: column; align-items: flex-start; gap: .6rem; overflow: visible;
562
+ margin: 1.4rem 1rem 1.4rem 0; padding: 1rem 1.1rem; border-radius: 14px; box-shadow: none; }
563
+ #tab-suite > .jump .jump-title { display: block; }
564
+ #tab-suite > .jump a { white-space: normal; }
565
+ .panel.report { scroll-margin-top: 1rem; }
566
+ }
539
567
  /* Models: each connection a fold, its heading the summary */
540
568
  .model-card > summary h3.chart-head { display: inline; margin: 0; }
541
569
  /* Expand all / Collapse all: a text toggle with a chevron that turns, like a fold's own marker */
package/ui/bench.js CHANGED
@@ -279,8 +279,9 @@
279
279
  // ---- Expand all / Collapse all: every fold of the page's groups (Models' connections).
280
280
  // One labelled toggle, as accordions have it: Collapse all while every one is open,
281
281
  // else Expand all. Kept right after any fold opens or shuts, by hand, by it or by a live swap.
282
+ var FOLDS = 'details.model-card, details[data-log]'; // the groups a page folds: Models' connections, Logs' logs
282
283
  function foldButtons(tab) {
283
- var folds = tab.querySelectorAll('details.model-card'), open = 0;
284
+ var folds = tab.querySelectorAll(FOLDS), open = 0;
284
285
  folds.forEach(function (d) { if (d.open) open++; });
285
286
  var all = open === folds.length;
286
287
  tab.querySelectorAll('[data-fold-all]').forEach(function (b) {
@@ -293,11 +294,11 @@
293
294
  var b = e.target.closest('[data-fold-all]');
294
295
  if (!b) return;
295
296
  var tab = b.closest('.tab');
296
- tab.querySelectorAll('details.model-card').forEach(function (d) { d.open = b.dataset.foldAll === 'open'; });
297
+ tab.querySelectorAll(FOLDS).forEach(function (d) { d.open = b.dataset.foldAll === 'open'; });
297
298
  foldButtons(tab);
298
299
  });
299
300
  document.addEventListener('toggle', function (e) { // toggle does not bubble: caught on its way down
300
- if (e.target.matches && e.target.matches('details.model-card')) foldButtons(e.target.closest('.tab'));
301
+ if (e.target.matches && e.target.matches(FOLDS)) foldButtons(e.target.closest('.tab'));
301
302
  }, true);
302
303
 
303
304
  // ---- Logs: Export as text (every log in one .txt, to read) and CSV (one log, its
@@ -306,42 +307,54 @@
306
307
  var now = new Date(), two = function (n) { return String(n).padStart(2, '0'); };
307
308
  return now.getFullYear() + '-' + two(now.getMonth() + 1) + '-' + two(now.getDate()) + '-' + two(now.getHours()) + two(now.getMinutes());
308
309
  }
309
- // The file itself: Share's saveFile(blob, name) and csv(rows) (RFC 4180, with a BOM).
310
- document.addEventListener('click', function (e) {
311
- var b = e.target.closest('[data-csv]');
312
- if (!b) return;
313
- e.preventDefault(); // in the summary: no fold
314
- var d = b.closest('details'), rows = [];
310
+ // A log as the page shows it (the filter applied): its header and rows, as cells.
311
+ // A log of lines (Scheduler, Onboarding runs) is one column, Line.
312
+ function logRows(d) {
315
313
  var cells = function (tr) {
316
314
  return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); });
317
315
  };
318
- var head = d.querySelector('thead tr');
319
- if (head) {
320
- rows.push(cells(head));
321
- d.querySelectorAll('tbody tr').forEach(function (r) { if (!r.hidden) rows.push(cells(r)); });
322
- } else {
323
- rows.push(['Line']);
324
- d.querySelectorAll('.log-line').forEach(function (r) { if (!r.hidden) rows.push([r.textContent.trim()]); });
325
- }
326
- var slug = d.dataset.log.toLowerCase().replace(/[^a-z0-9]+/g, '-');
327
- saveFile(new Blob([csv(rows)], {type: 'text/csv;charset=utf-8'}), 'benchwarmers-' + slug + '-' + stampNow() + '.csv');
328
- });
316
+ var head = d.querySelector('thead tr'), rows = [head ? cells(head) : ['Line']];
317
+ d.querySelectorAll(head ? 'tbody tr' : '.log-line').forEach(function (r) {
318
+ if (!r.hidden) rows.push(head ? cells(r) : [r.textContent.trim()]);
319
+ });
320
+ return rows;
321
+ }
322
+ // Export…: the popup picks the format and the logs. Text: every log chosen in one
323
+ // .txt to read, tab-separated. CSV: a .csv per log (its own columns), through Share's
324
+ // saveFile(blob, name) and csv(rows) (RFC 4180, with a BOM).
329
325
  on('click', 'logs-export', function () {
330
- var tab = $('tab-logs'), out = ['Benchwarmers logs, exported ' + new Date().toString()];
331
- var q = (tab.querySelector('input[data-filter]') || {}).value;
332
- if (q) out.push('Filtered by: ' + q);
333
- tab.querySelectorAll('details.panel').forEach(function (d) {
334
- out.push('', '== ' + d.dataset.log + ' ==');
335
- var cells = function (tr) {
336
- return Array.prototype.map.call(tr.children, function (c) { return c.textContent.trim().replace(/\s+/g, ' '); }).join('\t');
337
- };
338
- var head = d.querySelector('thead tr');
339
- if (head) out.push(cells(head));
340
- d.querySelectorAll('tbody tr, .log-line').forEach(function (r) {
341
- if (!r.hidden) out.push(r.matches('tr') ? cells(r) : r.textContent.trim());
326
+ var q = ($('tab-logs').querySelector('input[data-filter]') || {}).value;
327
+ $('lx-note').textContent = q ? 'Only the lines that match the filter, "' + q + '", as the page shows them.'
328
+ : 'Every line the page shows: the last 200 of each log.';
329
+ $('lx-errors').textContent = '';
330
+ $('logs-export-dialog').showModal();
331
+ });
332
+ on('click', 'lx-cancel', function () { $('logs-export-dialog').close(); });
333
+ on('click', 'lx-go', function () {
334
+ var tab = $('tab-logs'), dlg = $('logs-export-dialog');
335
+ var format = dlg.querySelector('input[name=lx-format]:checked').value;
336
+ var want = Array.prototype.filter.call(dlg.querySelectorAll('input[name=lx-log]'), function (x) { return x.checked; })
337
+ .map(function (x) { return x.value; });
338
+ if (!want.length) { $('lx-errors').textContent = 'Choose at least one log.'; return; }
339
+ var logs = Array.prototype.filter.call(tab.querySelectorAll('details[data-log]'), function (d) {
340
+ return want.indexOf(d.dataset.log) !== -1;
341
+ });
342
+ var stamp = stampNow(), q = (tab.querySelector('input[data-filter]') || {}).value;
343
+ if (format === 'csv') {
344
+ logs.forEach(function (d) {
345
+ var slug = d.dataset.log.toLowerCase().replace(/[^a-z0-9]+/g, '-');
346
+ saveFile(new Blob([csv(logRows(d))], {type: 'text/csv;charset=utf-8'}), 'benchwarmers-' + slug + '-' + stamp + '.csv');
342
347
  });
343
- });
344
- saveFile(new Blob([out.join('\n') + '\n'], {type: 'text/plain;charset=utf-8'}), 'benchwarmers-logs-' + stampNow() + '.txt');
348
+ } else {
349
+ var out = ['Benchwarmers logs, exported ' + new Date().toString()];
350
+ if (q) out.push('Filtered by: ' + q);
351
+ logs.forEach(function (d) {
352
+ out.push('', '== ' + d.dataset.log + ' ==');
353
+ logRows(d).forEach(function (r) { out.push(r.join('\t')); });
354
+ });
355
+ saveFile(new Blob([out.join('\n') + '\n'], {type: 'text/plain;charset=utf-8'}), 'benchwarmers-logs-' + stamp + '.txt');
356
+ }
357
+ dlg.close();
345
358
  });
346
359
 
347
360
  // ---- a link to another page ----
@@ -2750,6 +2763,9 @@
2750
2763
  // the pages share. It was the whole page, which made every chart wider than
2751
2764
  // its column beside the sidebar, scaled down, with smaller text.
2752
2765
  function width() {
2766
+ // Benchmarks shown with its rail beside the reports: a report's own width, not the column's.
2767
+ var report = document.querySelector('.tab.active .panel.report');
2768
+ if (report && report.clientWidth) return Math.max(520, report.clientWidth - 50);
2753
2769
  var col = document.querySelector('.panes') || document.querySelector('main');
2754
2770
  return Math.max(520, (col.clientWidth || 900) - 50);
2755
2771
  }
@@ -2941,8 +2957,14 @@
2941
2957
  ]
2942
2958
  });
2943
2959
  };
2944
- put('chart-uptime-providers', function (w) { return uptime(U.providers, w); });
2945
- put('chart-uptime-models', function (w) { return uptime(U.models, w); });
2960
+ // One view at a time, as the card's By says: by provider (the default) or by model.
2961
+ var view = (document.querySelector('select[data-uptime-by]') || {}).value || 'providers';
2962
+ ['providers', 'models'].forEach(function (k) {
2963
+ var box = $('chart-uptime-' + k);
2964
+ if (!box) return;
2965
+ box.hidden = k !== view;
2966
+ if (k === view) put('chart-uptime-' + k, function (w) { return uptime(U[k], w); });
2967
+ });
2946
2968
  }
2947
2969
  if (part === 'uptime') return;
2948
2970
  // Decision models: each model's answers grouped by the probability it gave
@@ -3265,41 +3287,59 @@
3265
3287
  });
3266
3288
  });
3267
3289
 
3268
- // Vision, real work: each model's seats on image panels by how they were judged.
3290
+ // Vision, real work: read as Real work is. The one question: when its seat was judged,
3291
+ // did it help? The dot is the share judged useful (checked and helped, or useful with no
3292
+ // claims to check), sized by how many were judged; the line where that share likely sits
3293
+ // (95% Wilson), ranked by its low end. Beside it, in words: how often it answered at all,
3294
+ // and the claims checked by hand. Seats never judged or never answered say nothing of
3295
+ // its worth: they are in the words and the table, not the dot.
3269
3296
  put('chart-vision-work', function (w) {
3270
- var W = C.vision.work, kinds = C.vision.seatKinds, data = [], total = {};
3297
+ var W = C.vision.work, kinds = C.vision.seatKinds, R = [], none = [];
3271
3298
  Object.keys(W).forEach(function (m) {
3272
- kinds.forEach(function (k) { if (W[m][k]) data.push({model: m, kind: k, count: W[m][k]}); total[m] = (total[m] || 0) + W[m][k]; });
3273
- });
3274
- if (!data.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
3275
- // The models doing the most image work first: their bars are the evidence. Under FEW
3276
- // seats a row was mostly "no answer" and said little: those are listed under the chart.
3277
- var all = Object.keys(total).sort(function (a, b) { return total[b] - total[a] || (a < b ? -1 : 1); });
3278
- var models = all.filter(function (m) { return total[m] >= FEW; });
3279
- var few = all.filter(function (m) { return total[m] < FEW; });
3280
- if (!models.length) { models = all; few = []; }
3281
- data = data.filter(function (d) { return models.indexOf(d.model) !== -1; });
3282
- var colour = {'checked, helped': good, 'checked, did not help': err, 'useful, no claims': sky,
3283
- 'no use, no claims': warn, 'not judged': line, 'no answer': muted};
3284
- // The claims checked by hand, beside the bar: the only right-or-wrong count real work has.
3285
- var claims = models.filter(function (m) { return W[m].real + W[m].false; }).map(function (m) {
3286
- return {model: m, text: W[m].real + ' of ' + (W[m].real + W[m].false) + ' claims right'};
3299
+ var v = W[m], n = function (k) { return v[k] || 0; }, total = 0;
3300
+ kinds.forEach(function (k) { total += n(k); });
3301
+ if (!total) return;
3302
+ var judged = n('checked, helped') + n('checked, did not help') + n('useful, no claims') + n('no use, no claims');
3303
+ var useful = n('checked, helped') + n('useful, no claims');
3304
+ var r = {model: m, total: total, judged: judged, useful: useful, answered: total - n('no answer'),
3305
+ real: v.real || 0, wrongC: v.false || 0};
3306
+ if (!judged) { none.push(r); return; }
3307
+ var b = wilson(useful, judged);
3308
+ r.p = useful / judged; r.lo = b[0]; r.hi = b[1];
3309
+ r.verdict = b[0] >= 0.5 ? 'helps' : b[1] < 0.5 ? 'does not help' : 'too few to say';
3310
+ R.push(r);
3287
3311
  });
3312
+ if (!R.length && !none.length) return el('p', {class: 'hint'}, 'No vision panels through the gateway yet.');
3313
+ R.sort(function (a, b) { return b.lo - a.lo || b.judged - a.judged; });
3288
3314
  var narrowV = w < 560, box = el('div');
3289
- box.appendChild(Plot.plot({
3290
- width: w, height: rows(models.length), style: style, marginLeft: narrowV ? 150 : 260, marginRight: narrowV ? 20 : 150,
3291
- x: {label: 'Share of its seats on image panels →', tickFormat: '.0%', grid: true, domain: [0, 1]},
3292
- y: {domain: models, label: null, tickFormat: function (m) { return fit(m, narrowV ? 16 : 30) + ' (' + total[m] + ')'; }},
3293
- color: {legend: true, domain: kinds, range: kinds.map(function (k) { return colour[k]; })},
3315
+ function words(r) {
3316
+ return r.useful + '/' + r.judged + (narrowV ? '' : ' · ' + Math.round(r.answered / r.total * 100) + '% answered'
3317
+ + (r.real + r.wrongC ? ' · claims ' + r.real + '/' + (r.real + r.wrongC) : ''));
3318
+ }
3319
+ if (R.length) box.appendChild(Plot.plot({
3320
+ width: w, height: 24 * R.length + 90, style: style, marginLeft: narrowV ? 130 : 190,
3321
+ marginRight: narrowV ? 50 : 260, marginTop: 34,
3322
+ x: {domain: [0, 1], label: 'Judged useful, share of its judged seats →', tickFormat: '.0%', ticks: 5, grid: true},
3323
+ y: {domain: R.map(function (r) { return r.model; }), label: null,
3324
+ tickFormat: function (m) { return fit(m, narrowV ? 16 : 26); }},
3325
+ r: {domain: [0, Math.max.apply(null, R.map(function (r) { return r.judged; }))], range: [3, 11]},
3326
+ color: {domain: ['helps', 'too few to say', 'does not help'], range: [good, muted, err], legend: true},
3294
3327
  marks: [
3295
- Plot.barX(data, Plot.stackX({x: 'count', y: 'model', fill: 'kind', offset: 'normalize', order: kinds,
3296
- title: function (d) { return d.model + ': ' + d.kind + ', ' + d.count + ' of ' + total[d.model] + ' seats'; }})),
3297
- Plot.text(narrowV ? [] : claims.filter(function (c) { return models.indexOf(c.model) !== -1; }),
3298
- {x: 1, y: 'model', text: 'text', dx: 8, textAnchor: 'start', fill: ink})
3328
+ Plot.ruleX([0.5], {stroke: line}),
3329
+ Plot.ruleY(R, {y: 'model', x1: 'lo', x2: 'hi', stroke: 'verdict', strokeWidth: 2, strokeOpacity: 0.55}),
3330
+ Plot.dot(R, {x: 'p', y: 'model', r: 'judged', fill: 'verdict', stroke: halo, strokeWidth: 1.5,
3331
+ title: function (r) {
3332
+ return r.model + '\njudged useful ' + r.useful + ' of ' + r.judged + ' judged seats ('
3333
+ + Math.round(r.p * 100) + '%), likely between ' + Math.round(r.lo * 100) + '% and ' + Math.round(r.hi * 100)
3334
+ + '%\nanswered ' + r.answered + ' of ' + r.total + ' seats on image panels'
3335
+ + (r.real + r.wrongC ? '\nclaims checked by hand: ' + r.real + ' right of ' + (r.real + r.wrongC) : '');
3336
+ }}),
3337
+ Plot.text(R, {x: 1, y: 'model', dx: 10, textAnchor: 'start', fill: ink, fontSize: 11, text: words})
3299
3338
  ]
3300
3339
  }));
3301
- if (few.length) box.appendChild(el('p', {class: 'hint'}, 'Under ' + FEW + ' seats, so no bar: '
3302
- + few.map(function (m) { return m + ' (' + total[m] + ')'; }).join(', ') + '. The table has them all.'));
3340
+ if (none.length) box.appendChild(el('p', {class: 'hint'}, 'No seat judged yet, so no dot: '
3341
+ + none.map(function (r) { return r.model + ' (' + r.total + ' seats, answered ' + r.answered + ')'; }).join(', ')
3342
+ + '. The table has every seat.'));
3303
3343
  return box;
3304
3344
  });
3305
3345
 
@@ -3338,10 +3378,50 @@
3338
3378
  });
3339
3379
 
3340
3380
  }
3341
- onLoad.suite = function () { drawCharts('suite'); };
3381
+ onLoad.suite = function () { drawCharts('suite'); jumpSpy(); };
3382
+ // Benchmarks' jump bar: the report in view is marked (aria-current) and its link kept in
3383
+ // sight in a bar that scrolls sideways. Set up again after a live swap (setUp, onLoad).
3384
+ var spy = null;
3385
+ function jumpSpy() {
3386
+ if (spy) spy.disconnect();
3387
+ var nav = document.querySelector('#tab-suite .jump');
3388
+ if (!nav || !window.IntersectionObserver) return;
3389
+ var links = {}, seen = {}, current = null;
3390
+ nav.querySelectorAll('a').forEach(function (a) { links[a.getAttribute('href').slice(1)] = a; });
3391
+ // A report counts as in view while it crosses a thin band 15-20% down the window: just
3392
+ // under where a jump lands it, so the one jumped to is the one marked.
3393
+ spy = new IntersectionObserver(function (entries) {
3394
+ entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting; });
3395
+ var id = Object.keys(links).filter(function (k) { return seen[k]; })[0];
3396
+ if (!id || id === current) return;
3397
+ if (current) links[current].removeAttribute('aria-current');
3398
+ current = id;
3399
+ links[id].setAttribute('aria-current', 'true');
3400
+ var a = links[id];
3401
+ if (a.offsetLeft < nav.scrollLeft || a.offsetLeft + a.offsetWidth > nav.scrollLeft + nav.clientWidth) {
3402
+ nav.scrollLeft = a.offsetLeft - 16;
3403
+ }
3404
+ }, {rootMargin: '-15% 0px -80% 0px'});
3405
+ document.querySelectorAll('#tab-suite .panel.report').forEach(function (p) { spy.observe(p); });
3406
+ }
3407
+ // A jump scrolls smoothly (unless motion is reduced) and moves focus to the report, so
3408
+ // a keyboard user carries on from there; the address keeps the report's #, as a link would.
3409
+ document.addEventListener('click', function (e) {
3410
+ var a = e.target.closest('.jump a');
3411
+ if (!a) return;
3412
+ var target = $(a.getAttribute('href').slice(1));
3413
+ if (!target) return;
3414
+ e.preventDefault();
3415
+ var still = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
3416
+ target.scrollIntoView({behavior: still ? 'auto' : 'smooth', block: 'start'});
3417
+ target.setAttribute('tabindex', '-1');
3418
+ target.focus({preventScroll: true});
3419
+ history.replaceState(null, '', a.getAttribute('href'));
3420
+ });
3342
3421
  // Uptime's or Trend's window: that window's tables (and Uptime's list) shown, its charts drawn again.
3343
3422
  document.addEventListener('change', function (ev) {
3344
3423
  var s = ev.target, part = s.matches('select[data-uptime-days]') ? 'uptime' : s.matches('select[data-trend-days]') ? 'trend' : '';
3424
+ if (s.matches('select[data-uptime-by]')) { drawCharts('uptime'); return; } // the other view, same window
3345
3425
  if (!part) return;
3346
3426
  s.closest('.panel').querySelectorAll('[data-' + part + '-window]').forEach(function (d) {
3347
3427
  d.hidden = d.getAttribute('data-' + part + '-window') !== s.value;