@adrrr/tarmac 0.9.0 → 0.10.0

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.
@@ -17,7 +17,7 @@
17
17
  // `test/history-view`, and shipped to the browser as its own source through `String`. The
18
18
  // page runs the function the suite ran. Everything below `historyScript` is pixels: geometry,
19
19
  // labels and hit-testing, which no assertion can read and none pretends to.
20
- import { INTERACTIVE } from './map.js';
20
+ import { INTERACTIVE } from './sessions.js';
21
21
  /** How many hues the palette has before it starts again. Eight is what a legend can be read at. */
22
22
  const SLOTS = 8;
23
23
  /**
@@ -31,6 +31,16 @@ const SLOTS = 8;
31
31
  const RESET_WATCHED_MS = 600000;
32
32
  const MIN = 60000;
33
33
  const HOUR = 3600000;
34
+ /**
35
+ * The longest window this view will draw over, in days.
36
+ *
37
+ * Both ends of a range's window come off the wire, and the page's rule is that nothing off the
38
+ * wire may take it down. A `to` a century out is 1800 columns and 876,000 hour slots in every
39
+ * band, reallocated on every resize, every tap and every change of scheme — not a throw, and
40
+ * worse than one: a tab that stops answering with no error to read. Sixty times the longest
41
+ * range, which no answer this page understands can reach.
42
+ */
43
+ const MAX_DAYS = 1800;
34
44
  // ── the transforms, which are also the source the browser runs ───────────────────────────
35
45
  //
36
46
  // Written in the page script's own dialect — `var`, plain functions, no destructuring — for
@@ -220,15 +230,16 @@ export function ctxLines(samples, cadence, roster) {
220
230
  * reduced to the highest any of them reached — the same reduction the reader already made,
221
231
  * one level up.
222
232
  *
223
- * An hour nobody wrote a line in is absent from the reader's answer, so the grid is rebuilt
224
- * from the clocks rather than from the array's length: a serve that was off for a day leaves a
225
- * day-wide hole, not a day the chart quietly closes up.
233
+ * An hour nobody wrote a line in is absent from the reader's answer, so the grid is the RANGE's
234
+ * rather than the array's length: a serve that was off for a day leaves a day-wide hole, and a
235
+ * journal younger than the range it is asked for starts where it starts instead of being
236
+ * stretched across a week it was not running for.
226
237
  */
227
- export function ctxRows(hours, roster) {
238
+ export function ctxRows(hours, roster, from, to) {
228
239
  if (hours.length === 0)
229
240
  return [];
230
- var t0 = hours[0].t;
231
- var n = gridLen(t0, hours[hours.length - 1].t, HOUR);
241
+ var t0 = hourOf(from);
242
+ var n = hourSlots(from, to);
232
243
  if (n === 0)
233
244
  return [];
234
245
  var anchored = false;
@@ -280,6 +291,66 @@ export function hourOf(t) {
280
291
  d.setMinutes(0, 0, 0);
281
292
  return d.getTime();
282
293
  }
294
+ /**
295
+ * A moment the page may compute with.
296
+ *
297
+ * `typeof` and not `isFinite` alone, because `isFinite(null)` is true and `JSON.stringify(NaN)`
298
+ * is the string `null`: on a JSON wire, `null` is the one non-number that can arrive where a
299
+ * clock belongs, and it is exactly the one a bare `isFinite` waves through — as the zero it
300
+ * coerces to, which is the first of January 1970.
301
+ */
302
+ export function moment(v) {
303
+ return typeof v === 'number' && isFinite(v);
304
+ }
305
+ /** The local midnight that opens the day a moment falls in. */
306
+ export function startOfDay(t) {
307
+ var d = new Date(t);
308
+ d.setHours(0, 0, 0, 0);
309
+ return d.getTime();
310
+ }
311
+ /**
312
+ * The next local midnight, which is 23, 24 or 25 hours along.
313
+ *
314
+ * Calendar arithmetic, never 24-hour blocks: `history-range` walks the day files by this rule and
315
+ * everything drawn under them has to walk by the same one. Stepped by 86400000 instead, the
316
+ * morning a clock falls back lands back inside the day it just left — an eighth slot in a week of
317
+ * seven, the same name twice, and every column after it labelled with the day before.
318
+ */
319
+ export function nextDay(t) {
320
+ var d = new Date(t);
321
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0, 0).getTime();
322
+ }
323
+ /**
324
+ * Every local day a range covers, oldest first: the days that were ASKED for.
325
+ *
326
+ * This is the whole of the fix in #168. A long range used to be drawn over the days that happened
327
+ * to be in the journal, so a serve one day old drew a single column alone in an empty plot — and
328
+ * the identical picture at 7d and at 30d, since one day is one column either way. The window the
329
+ * reader charged its records against is the domain, and a day nobody wrote in is a slot with
330
+ * nothing in it rather than a day the axis closes up.
331
+ *
332
+ * Bounded by `MAX_DAYS`, which every grid in this file is: the two ends come off the wire, and a
333
+ * `to` a century out would walk this loop for as long as the tab was open.
334
+ */
335
+ export function daySlots(from, to) {
336
+ var out = [];
337
+ if (!moment(from) || !moment(to))
338
+ return out;
339
+ for (var d = startOfDay(from); d < to && out.length < MAX_DAYS; d = nextDay(d))
340
+ out.push(d);
341
+ return out;
342
+ }
343
+ /**
344
+ * How many hour slots a range's window holds, to the same ceiling the day grid keeps.
345
+ *
346
+ * One place rather than two identical expressions: the bands and the quota curve are drawn over
347
+ * one window and a grid either of them read differently would be two charts about two ranges.
348
+ */
349
+ export function hourSlots(from, to) {
350
+ if (!moment(from) || !moment(to))
351
+ return 0;
352
+ return Math.min(gridLen(hourOf(from), to - HOUR, HOUR), MAX_DAYS * 24);
353
+ }
283
354
  /**
284
355
  * 24h cost: what each project spent in each HOUR, out of a wire that carries running totals.
285
356
  *
@@ -366,33 +437,51 @@ export function costHourly(samples, roster) {
366
437
  * keeps its colour and its place in the column from Monday to Sunday and can be followed
367
438
  * across the week. A stack sorted by rank would have every project moving up and down the
368
439
  * column as its day went, which is a chart nobody can read sideways.
440
+ *
441
+ * A column a day of the RANGE, not a column a day of the journal. A day nobody wrote in is a
442
+ * column with nothing in it: the bars already draw an unread hour that way at 24h, and the two
443
+ * long ranges used to be the exception — one day on disk was one bar, centred in an empty plot,
444
+ * at 7d and at 30d alike.
369
445
  */
370
- export function costDaily(days, roster) {
446
+ export function costDaily(days, roster, from, to) {
371
447
  var buckets = [];
372
448
  var measured = [];
373
- var z;
449
+ var i, z;
374
450
  for (z = 0; z < roster.length; z++)
375
451
  measured.push(false);
376
- for (var i = 0; i < days.length; i++) {
452
+ // A range nothing was written in keeps the verdict it has always had. Thirty empty columns
453
+ // over "no readings in this range" is a chart claiming to have read a month.
454
+ if (days.length === 0)
455
+ return { projects: roster, buckets: buckets, measured: measured };
456
+ var slots = daySlots(from, to);
457
+ for (i = 0; i < slots.length; i++) {
377
458
  var by = [];
378
- var n = 0;
379
459
  for (z = 0; z < roster.length; z++)
380
460
  by.push(0);
461
+ buckets.push({ t: slots[i], span: nextDay(slots[i]) - slots[i], n: 0, by: by });
462
+ }
463
+ for (i = 0; i < days.length; i++) {
464
+ var t = dayStart(days[i].date);
465
+ var b = -1;
466
+ // A day nothing can date has no place on an axis, and neither has one the range never asked
467
+ // for. Dropped rather than drawn at NaN or at an edge, where it would take the whole plot's
468
+ // geometry or its arithmetic with it: the reader names its files after local days, and a
469
+ // directory can hold something the reader did not put there.
470
+ if (!isFinite(t))
471
+ continue;
472
+ for (z = 0; z < buckets.length; z++)
473
+ if (buckets[z].t === t)
474
+ b = z;
475
+ if (b === -1)
476
+ continue;
381
477
  var list = days[i].byProject || [];
382
478
  for (var j = 0; j < list.length; j++)
383
479
  for (var k = 0; k < roster.length; k++)
384
480
  if (roster[k].name === list[j].project && typeof list[j].costUsd === 'number' && isFinite(list[j].costUsd)) {
385
- by[k] += list[j].costUsd;
481
+ buckets[b].by[k] += list[j].costUsd;
386
482
  measured[k] = true;
387
- n += 1;
483
+ buckets[b].n += 1;
388
484
  }
389
- var t = dayStart(days[i].date);
390
- // A day nothing can date has no place on an axis. It is dropped rather than drawn at NaN,
391
- // where it would take the whole plot's geometry with it: the reader names its files after
392
- // local days, and a directory can hold something the reader did not put there.
393
- if (!isFinite(t))
394
- continue;
395
- buckets.push({ t: t, span: 86400000, n: n, by: by });
396
485
  }
397
486
  return { projects: roster, buckets: buckets, measured: measured };
398
487
  }
@@ -482,12 +571,14 @@ export function quotaOfSamples(samples, cadence) {
482
571
  * the chart draws such a marker faint and says "about": a firm line through a moment nobody
483
572
  * measured is the one thing this view must not draw.
484
573
  */
485
- export function quotaOfHours(hours, resets) {
574
+ export function quotaOfHours(hours, resets, from, to) {
486
575
  var out = { t0: 0, step: HOUR, five: [], seven: [], resets: [] };
487
576
  if (hours.length === 0)
488
577
  return out;
489
- out.t0 = hours[0].t;
490
- var n = gridLen(out.t0, hours[hours.length - 1].t, HOUR);
578
+ // The range's own grid, like the bands next door: a week the serve was up for one day of is a
579
+ // curve at the end of a week, not a curve stretched over one.
580
+ out.t0 = hourOf(from);
581
+ var n = hourSlots(from, to);
491
582
  if (n === 0)
492
583
  return out;
493
584
  for (var z = 0; z < n; z++) {
@@ -592,6 +683,11 @@ const PURE = [
592
683
  ctxLines,
593
684
  ctxRows,
594
685
  hourOf,
686
+ moment,
687
+ startOfDay,
688
+ nextDay,
689
+ daySlots,
690
+ hourSlots,
595
691
  costHourly,
596
692
  costDaily,
597
693
  dayStart,
@@ -622,7 +718,8 @@ export const HISTORY_CSS = `
622
718
  .view-history { display:grid; grid-template-columns:1fr 1fr; gap:1rem; align-items:start; }
623
719
  #ctx, .hist-off, .hist-empty, .view-history > .note { grid-column:1 / -1; }
624
720
  .view-history > .note { margin-top:0; }
625
- .chart { border:1px solid var(--line); border-radius:12px; padding:.65rem .8rem .7rem; min-width:0; }
721
+ .chart { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-lg);
722
+ box-shadow:var(--shadow-1), var(--edge); padding:.65rem .8rem .7rem; min-width:0; }
626
723
  /* The margin is what the way-back-to-now's tap target is drawn into. That overlay reaches
627
724
  .85rem below the button, and anything of it past this margin lands on the canvas and
628
725
  swallows taps meant for the top of the plot. The two numbers are the same on purpose. */
@@ -634,7 +731,7 @@ export const HISTORY_CSS = `
634
731
  .chart-sub.at { color:var(--fg); font-weight:600; font-variant-numeric:tabular-nums; }
635
732
  /* The one number a chart leads with, in the gauge's weight. Never a hero: the fleet is
636
733
  the subject, this is its total. */
637
- .chart-stat { margin-left:auto; font-variant-numeric:tabular-nums; font-weight:650; font-size:.9rem; white-space:nowrap; }
734
+ .chart-stat { margin-left:auto; font-variant-numeric:tabular-nums; font-weight:600; font-size:.9rem; white-space:nowrap; }
638
735
  .to-now { font:inherit; font-size:.72rem; font-weight:600; color:var(--fg); background:transparent;
639
736
  border:1px solid var(--line); border-radius:99px; padding:.02rem .6rem; cursor:pointer; }
640
737
  /* pan-y rather than none: a drag along the chart moves the cursor, and a drag up the page
@@ -673,17 +770,27 @@ export const HISTORY_CSS = `
673
770
  .hist-range .range-name { font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; color:var(--dim); margin-right:.2rem; }
674
771
  .hist-range button { font:inherit; font-size:.8rem; color:var(--fg); background:transparent; border:1px solid var(--line);
675
772
  border-radius:99px; padding:.15rem .8rem; cursor:pointer; font-variant-numeric:tabular-nums; }
676
- .hist-range button[aria-pressed="true"] { font-weight:600; background:color-mix(in srgb, var(--line) 55%, transparent); border-color:var(--dim); }
773
+ /* The chosen range as a raised chip on the page's floor, the same figure the tabs cut — with
774
+ one difference from the tabs that matters. There the ink carries the state (inactive --dim
775
+ at 5.6:1, active --fg at 18.8), so the chip is free to be decoration. Here all three
776
+ buttons are --fg already, so ink says nothing and weight alone would be the whole signal.
777
+ The border keeps --dim (4.8:1 on the floor) rather than --line-strong (1.8): a control this
778
+ page identifies by its edge needs an edge somebody can see. */
779
+ .hist-range button[aria-pressed="true"] { font-weight:600; background:var(--surface);
780
+ border-color:var(--dim); box-shadow:var(--shadow-1); }
677
781
  .hist-range button:disabled { opacity:.4; cursor:default; }
678
782
  .hist-range .covers { color:var(--dim); font-size:.75rem; margin-left:.4rem; }
679
783
  /* Off is not a fault, so it is not a .warn: a framed sentence in the page's own ink, with
680
784
  the one key that turns it on. */
681
- .hist-off { border:1px solid var(--line); border-radius:8px; padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
785
+ .hist-off { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-sm);
786
+ box-shadow:var(--shadow-1), var(--edge); padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
682
787
  /* Same frame as "off" above, and for the same reason: a serve that has been running a minute
683
788
  is not a fault either. It sits directly over the charts it is about, because "where are my
684
789
  curves" is a question asked while looking at the place they will be. */
685
- .hist-empty { border:1px solid var(--line); border-radius:8px; padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
686
- .hist-off code, .hist-empty code, .view-history .note code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.95em; }
790
+ .hist-empty { background:var(--surface); border:1px solid var(--line); border-radius:var(--r-sm);
791
+ box-shadow:var(--shadow-1), var(--edge); padding:.5rem .7rem; font-size:.8rem; line-height:1.5; }
792
+ .hist-off code, .hist-empty code, .view-history .note code { font-family:var(--mono); font-size:.95em;
793
+ background:var(--surface-2); border:1px solid var(--line); border-radius:4px; padding:.05rem .3rem; }
687
794
  /* The other two views are in the shell on every address — the tabs between them are meant to
688
795
  cost nothing — so the one being read hides the pair it stands in front of. History is the
689
796
  exception and ships only on its own address: it carries a script and three canvases, and a
@@ -781,7 +888,7 @@ export function historyScript() {
781
888
  return `
782
889
  (function () {
783
890
  var INTERACTIVE = ${JSON.stringify(INTERACTIVE)};
784
- var SLOTS = ${SLOTS}, RESET_WATCHED_MS = ${RESET_WATCHED_MS}, MIN = ${MIN}, HOUR = ${HOUR};
891
+ var SLOTS = ${SLOTS}, RESET_WATCHED_MS = ${RESET_WATCHED_MS}, MIN = ${MIN}, HOUR = ${HOUR}, MAX_DAYS = ${MAX_DAYS};
785
892
  var H = ${JSON.stringify(HEIGHTS)};
786
893
  var DOW = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
787
894
  var MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@@ -827,18 +934,20 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
827
934
  function dayWord(t) { var d = new Date(t); return DOW[d.getDay()] + ' ' + d.getDate(); }
828
935
  function monWord(t) { var d = new Date(t); return MON[d.getMonth()] + ' ' + d.getDate(); }
829
936
  function money(v) { return '$' + v.toFixed(2); }
830
- function startOfDay(t) { var d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); }
831
- // The next local midnight, which is 23, 24 or 25 hours along. Calendar arithmetic, never
832
- // 24-hour blocks: history-range walks the day files by this rule and the axis under them has
833
- // to walk by the same one. Stepped by 86400000 instead, the morning a clock falls back lands
834
- // back inside the day it just left — an eighth tick in a week of seven, the same name twice,
835
- // and every column after it labelled with the day before.
836
- function nextDay(t) { var d = new Date(t); return new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 0, 0, 0, 0).getTime(); }
837
937
  function cssVar(name) {
838
938
  if (typeof getComputedStyle !== 'function' || !document.documentElement) return '#888';
839
939
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#888';
840
940
  }
841
941
  function slotColor(slot) { return cssVar('--s' + slot); }
942
+ /* How faint a series goes when it is not the one being read — deeper when another has been
943
+ isolated by tapping its key, because then there is a subject and everything else is
944
+ context. Both numbers come from the sheet: a dark surface needs less fading than a white
945
+ one, and the sheet is the only thing here that knows which one it is on. The literals are
946
+ the light values, for a browser that cannot compute a style at all. */
947
+ function fadeAlpha(iso) {
948
+ var v = parseFloat(cssVar(iso !== null ? '--fade-iso' : '--fade'));
949
+ return v > 0 && v <= 1 ? v : (iso !== null ? .3 : .72);
950
+ }
842
951
  var ENT = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
843
952
  function esc(v) { return String(v === null || v === undefined ? '\\u2014' : v).replace(/[&<>"']/g, function (c) { return ENT[c]; }); }
844
953
  var PHONE = typeof matchMedia === 'function' ? matchMedia('(max-width: 46rem)') : { matches: false };
@@ -856,7 +965,7 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
856
965
  canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
857
966
  var c = canvas.getContext('2d');
858
967
  c.setTransform(dpr, 0, 0, dpr, 0, 0);
859
- return { c: c, w: w, h: h, fg: cssVar('--fg'), dim: cssVar('--dim'), line: cssVar('--line'), bg: cssVar('--bg') };
968
+ return { c: c, w: w, h: h, fg: cssVar('--fg'), dim: cssVar('--dim'), line: cssVar('--line'), bg: cssVar('--surface') };
860
969
  }
861
970
  function plotBox(g) { return { l: 8, r: g.w - 8, t: 12, b: g.h - 18 }; }
862
971
  function hair(g, x1, y1, x2, y2, color, alpha) {
@@ -880,16 +989,21 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
880
989
  function timeTicks(g, b, t0, t1, range) {
881
990
  var ticks = [], x, d;
882
991
  if (t1 <= t0) t1 = t0 + 1;
992
+ // The walk's own ceiling, whatever ends it is handed. The grids above are capped in days and
993
+ // in hours; this is the one loop whose length is the SPAN rather than a grid, and an axis
994
+ // carrying more names than a reader can count is not an axis — it is three hundred thousand
995
+ // canvas calls a frame, drawn again on every resize, every tap and every change of scheme.
996
+ var MAX_TICKS = 400;
883
997
  if (range === '24h') {
884
998
  var t = new Date(t0); t.setMinutes(0, 0, 0);
885
- for (x = t.getTime(); x <= t1; x += HOUR) if (new Date(x).getHours() % 6 === 0 && x >= t0) ticks.push({ t: x, text: hhmm(x) });
999
+ for (x = t.getTime(); x <= t1 && ticks.length < MAX_TICKS; x += HOUR) if (new Date(x).getHours() % 6 === 0 && x >= t0) ticks.push({ t: x, text: hhmm(x) });
886
1000
  } else if (range === '7d') {
887
1001
  // The name is centred over the day, so it is given the day's own width rather than a flat
888
1002
  // twenty-four hours: the column a clock changed in is an hour wider or narrower than the
889
1003
  // six beside it, and a centre measured off the wrong width sits in its neighbour.
890
- for (d = startOfDay(t0); d < t1; d = nextDay(d)) if (d >= t0) ticks.push({ t: d, text: dayWord(d), center: nextDay(d) - d });
1004
+ for (d = startOfDay(t0); d < t1 && ticks.length < MAX_TICKS; d = nextDay(d)) if (d >= t0) ticks.push({ t: d, text: dayWord(d), center: nextDay(d) - d });
891
1005
  } else {
892
- for (d = startOfDay(t0); d < t1; d = nextDay(d)) if (d >= t0 && new Date(d).getDate() % 5 === 0) ticks.push({ t: d, text: monWord(d) });
1006
+ for (d = startOfDay(t0); d < t1 && ticks.length < MAX_TICKS; d = nextDay(d)) if (d >= t0 && new Date(d).getDate() % 5 === 0) ticks.push({ t: d, text: monWord(d) });
893
1007
  }
894
1008
  ticks.forEach(function (tk) {
895
1009
  var xx = b.l + ((tk.t - t0) / (t1 - t0)) * (b.r - b.l);
@@ -919,6 +1033,48 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
919
1033
  g.c.stroke(); return end;
920
1034
  }
921
1035
  function xOf(b, cur) { return b.l + cur * (b.r - b.l); }
1036
+ /**
1037
+ * Which slot the reader's finger is over.
1038
+ *
1039
+ * A 24h chart plots POINTS a minute apart and the nearest one wins. A long range plots slots an
1040
+ * hour or a day wide — the one the finger is inside wins, and the last of them ends at the edge
1041
+ * of the range rather than at its own left corner.
1042
+ */
1043
+ function slotAt(cur, n, live) {
1044
+ if (cur == null || !(n > 0)) return null;
1045
+ return live ? Math.round(cur * (n - 1)) : Math.max(0, Math.min(n - 1, Math.floor(cur * n)));
1046
+ }
1047
+ /**
1048
+ * The first moment the record covers, which on a range younger than itself is later than the
1049
+ * range opens. Hours and days are written by one reader over one window, so the earlier of the
1050
+ * two is where the journal begins.
1051
+ */
1052
+ function recordFrom() {
1053
+ var d = state.data, t = null, i, x;
1054
+ var hrs = (d && d.hours) || [], list = (d && d.days) || [];
1055
+ for (i = 0; i < hrs.length; i++) { x = hrs[i].t; if (typeof x === 'number' && isFinite(x) && (t === null || x < t)) t = x; }
1056
+ for (i = 0; i < list.length; i++) { x = dayStart(list[i].date); if (isFinite(x) && (t === null || x < t)) t = x; }
1057
+ return t === null ? null : startOfDay(t);
1058
+ }
1059
+ /**
1060
+ * The one line that explains an empty left half.
1061
+ *
1062
+ * "No readings before", and never "the journal starts here": what this can see is the oldest
1063
+ * trace INSIDE the window, and a journal older than the range with a hole at the front — a
1064
+ * serve that was off all week — hands back the same answer. The sentence says the thing that
1065
+ * is true of both.
1066
+ *
1067
+ * Once a chart and in the page's grey. A serve younger than the range it is being asked for is
1068
+ * not a fault — it is a serve that was started on Tuesday — and a warning would say otherwise
1069
+ * three times over. Dropped where the empty zone is too narrow to hold it: a label wider than
1070
+ * the space it explains is a label over the data.
1071
+ */
1072
+ function startNote(g, b, t0, t1, first) {
1073
+ if (typeof first !== 'number' || !isFinite(first) || !(first > t0)) return;
1074
+ var x = Math.min(b.r, b.l + ((first - t0) / (t1 - t0 || 1)) * (b.r - b.l));
1075
+ if (x - b.l < 96) return;
1076
+ label(g, 'no readings before ' + monWord(first), (b.l + x) / 2, (b.t + b.b) / 2, { align: 'center', size: 10 });
1077
+ }
922
1078
 
923
1079
  // ── the head and the legend ─────────────────────────────────────────────────────────
924
1080
  function head(id, sub, stat, tapped) {
@@ -958,11 +1114,17 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
958
1114
 
959
1115
  function drawCtx() {
960
1116
  var d = state.data, r = roster(), live = state.range === '24h';
961
- var series = live ? ctxLines(d.samples, d.cadence || MIN, r) : ctxRows(d.hours, r);
1117
+ var series = live ? ctxLines(d.samples, d.cadence || MIN, r) : ctxRows(d.hours, r, d.from, d.to);
962
1118
  if (series.length === 0) return blank('ctx', live ? 'per session · 24h' : 'per session · hour max · ' + state.range);
963
1119
  var g = setup(el('ctx-canvas'), height(live ? 'ctx24' : 'ctxRows')), b = plotBox(g);
964
- var t0 = series[0].t0, t1 = t0 + (series[0].v.length - 1) * series[0].step;
965
- var cur = state.cursor.ctx, idx = cur == null ? null : Math.round(cur * (series[0].v.length - 1));
1120
+ // The long ranges close at the end of the WINDOW, not at the last hour that has a slot: the
1121
+ // grid is the range's, so the last slot is an hour wide like the ones before it.
1122
+ // The grid is capped and the window's far end is not, so the axis is drawn over what the
1123
+ // grid actually covers. Handed the raw end, the tick walk turned a window off the wire
1124
+ // into tens of thousands of labels a frame — a tab that stops answering, with no error.
1125
+ var t0 = series[0].t0, t1 = live ? t0 + (series[0].v.length - 1) * series[0].step
1126
+ : Math.min(d.to, t0 + series[0].v.length * series[0].step);
1127
+ var cur = state.cursor.ctx, idx = slotAt(cur, series[0].v.length, live);
966
1128
  var iso = isoOf('ctx'), climbing = 0, i;
967
1129
  for (i = 0; i < series.length; i++) if (rising(series[i])) climbing++;
968
1130
  if (live) {
@@ -971,12 +1133,12 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
971
1133
  // Climbing last, so the lines the reader came for are drawn over the rest of the fleet.
972
1134
  series.slice().sort(function (a, c) { return (rising(a) ? 1 : 0) - (rising(c) ? 1 : 0); }).forEach(function (s) {
973
1135
  var up = rising(s), on = iso !== null ? iso === String(s.name) : up, color = slotColor(s.slot);
974
- g.c.save(); g.c.strokeStyle = color; g.c.lineWidth = on ? 2 : 1.5; g.c.globalAlpha = on ? 1 : (iso !== null ? .2 : .45);
1136
+ g.c.save(); g.c.strokeStyle = color; g.c.lineWidth = on ? 2 : 1.5; g.c.globalAlpha = on ? 1 : fadeAlpha(iso);
975
1137
  g.c.lineJoin = 'round'; g.c.lineCap = 'round';
976
1138
  var end = polyline(g, s, b, t0, t1, yOf); g.c.restore();
977
1139
  if (end) ends.push({ s: s, x: end.x, y: end.y, v: lastOf(s.v), on: on });
978
1140
  });
979
- ends.forEach(function (e) { g.c.save(); g.c.globalAlpha = e.on ? 1 : .45; dot(g, e.x, e.y, slotColor(e.s.slot), e.on ? 4 : 3); g.c.restore(); });
1141
+ ends.forEach(function (e) { g.c.save(); g.c.globalAlpha = e.on ? 1 : fadeAlpha(iso); dot(g, e.x, e.y, slotColor(e.s.slot), e.on ? 4 : 3); g.c.restore(); });
980
1142
  var prevY = -99;
981
1143
  ends.filter(function (e) { return e.on; }).sort(function (a, c) { return a.y - c.y; }).forEach(function (e) {
982
1144
  var y = Math.max(e.y - 7, prevY + 12, b.t + 8);
@@ -1019,6 +1181,9 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1019
1181
  label(g, (v === null ? '—' : Math.round(v) + '%') + (idx === null && up ? ' ↑' : ''), b.r - 2, top + 9, { align: 'right', size: 10, weight: 600, color: g.fg });
1020
1182
  if (idx !== null && s.v[idx] !== null) dot(g, xOf(b, cur), rowY(s.v[idx]), color, 3);
1021
1183
  });
1184
+ // After the bands, never before: each row rules its own baseline straight through the
1185
+ // sentence, and the sentence is what explains the space those baselines cross.
1186
+ startNote(g, b, t0, t1, recordFrom());
1022
1187
  if (idx !== null) { var x2 = xOf(b, cur); hair(g, x2, b.t, x2, b.b, g.fg, .5); }
1023
1188
  head('ctx', idx === null ? 'per session · hour max · ' + state.range : dayWord(t0 + idx * HOUR) + ' ' + hhmm(t0 + idx * HOUR),
1024
1189
  climbing ? climbing + ' climbing' : 'nothing climbing', idx !== null);
@@ -1028,7 +1193,7 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1028
1193
 
1029
1194
  function drawCost() {
1030
1195
  var d = state.data, r = roster(), live = state.range === '24h';
1031
- var cost = live ? costHourly(d.samples, r) : costDaily(d.days, r);
1196
+ var cost = live ? costHourly(d.samples, r) : costDaily(d.days, r, d.from, d.to);
1032
1197
  var buckets = cost.buckets, n = buckets.length;
1033
1198
  if (n === 0) return blank('cost', 'per project · ' + (live ? 'hourly · 24h' : 'daily · ' + state.range));
1034
1199
  var g = setup(el('cost-canvas'), height('cost')), b = plotBox(g);
@@ -1040,9 +1205,12 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1040
1205
  var y = b.b - (v / ymax) * (b.b - b.t); hair(g, b.l, y, b.r, y, g.line);
1041
1206
  if (v > 0) label(g, '$' + (v < 1 ? v.toFixed(1) : v), b.l + 2, y - 3);
1042
1207
  }
1043
- var slotW = (b.r - b.l) / n, barW = Math.min(24, slotW * .72);
1208
+ // Capped, because the columns are the RANGE's now: a week in which the serve ran for one day
1209
+ // is one bar with six empty slots beside it, and a bar given the whole plot to fill is a
1210
+ // single day drawn as a wall. Wide enough to stay a bar, never wide enough to be a panel.
1211
+ var slotW = (b.r - b.l) / n, barW = Math.min(48, slotW * .72);
1044
1212
  var cur = state.cursor.cost, iso = isoOf('cost');
1045
- var sel = cur == null ? null : Math.min(n - 1, Math.floor(cur * n));
1213
+ var sel = slotAt(cur, n, false);
1046
1214
  buckets.forEach(function (bk, i) {
1047
1215
  var x = b.l + i * slotW + (slotW - barW) / 2, acc = 0, yTop = b.b, top = -1;
1048
1216
  bk.by.forEach(function (val, k) { if (val > 0) top = k; });
@@ -1062,7 +1230,9 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1062
1230
  // The column's own total on its cap, only where a week of them has the room.
1063
1231
  if (n <= 7 && totals[i] > 0) label(g, '$' + totals[i].toFixed(totals[i] >= 100 ? 0 : 1), x + barW / 2, yTop - 5, { align: 'center', color: g.fg, weight: 600 });
1064
1232
  });
1065
- timeTicks(g, b, buckets[0].t, buckets[n - 1].t + buckets[n - 1].span, state.range);
1233
+ var xEnd = buckets[n - 1].t + buckets[n - 1].span;
1234
+ timeTicks(g, b, buckets[0].t, xEnd, state.range);
1235
+ if (!live) startNote(g, b, buckets[0].t, xEnd, recordFrom());
1066
1236
  var total = totals.reduce(function (a, v) { return a + v; }, 0);
1067
1237
  var keys = legendByCost(cost), bk2 = sel === null ? null : buckets[sel];
1068
1238
  // A bucket nobody read is not a bucket that cost nothing. The bars already draw it as the
@@ -1083,15 +1253,17 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1083
1253
 
1084
1254
  function drawQuota() {
1085
1255
  var d = state.data, live = state.range === '24h';
1086
- var q = live ? quotaOfSamples(d.samples, d.cadence || MIN) : quotaOfHours(d.hours, d.resets);
1256
+ var q = live ? quotaOfSamples(d.samples, d.cadence || MIN) : quotaOfHours(d.hours, d.resets, d.from, d.to);
1087
1257
  var n = q.five.length;
1088
1258
  if (n === 0) return blank('quota', 'account · ' + state.range);
1089
1259
  var g = setup(el('quota-canvas'), height('quota')), b = plotBox(g);
1090
- var t0 = q.t0, t1 = t0 + (n - 1) * q.step;
1260
+ var t0 = q.t0, t1 = live ? t0 + (n - 1) * q.step : Math.min(d.to, t0 + n * q.step);
1091
1261
  var yOf = function (v) { return b.b - (v / 100) * (b.b - b.t); };
1092
1262
  var xAt = function (t) { return b.l + ((t - t0) / (t1 - t0 || 1)) * (b.r - b.l); };
1093
1263
  var iso = isoOf('quota'), a5 = iso !== null && iso !== '5h' ? .25 : 1, a7 = iso !== null && iso !== '7d' ? .25 : 1;
1264
+ var end5 = null, end7 = null;
1094
1265
  pctGrid(g, b); timeTicks(g, b, t0, t1, state.range);
1266
+ if (!live) startNote(g, b, t0, t1, recordFrom());
1095
1267
  // The seven-day turnover is the event of the week and gets a full line with its name. The
1096
1268
  // five-hour one does not: there are five a day, so a week is thirty lines and a month a
1097
1269
  // hundred and fifty — a picket fence over the chart, each one labelled the same thing. It
@@ -1129,13 +1301,19 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1129
1301
  }
1130
1302
  g.c.restore();
1131
1303
  g.c.save(); g.c.globalAlpha = a5; g.c.strokeStyle = g.dim; g.c.lineWidth = 2; g.c.lineJoin = 'round';
1132
- polyline(g, { v: q.five, step: q.step }, b, t0, t1, yOf); g.c.restore();
1304
+ end5 = polyline(g, { v: q.five, step: q.step }, b, t0, t1, yOf); g.c.restore();
1133
1305
  } else {
1134
1306
  // A hundred and fifty sawtooth windows in a month is a wall: each window is drawn as
1135
1307
  // its own high instead, a bar as wide as the window, its right edge the reset.
1136
1308
  var bounds = [t0], w;
1137
1309
  for (w = 0; w < q.resets.length; w++) if (q.resets[w].limit === 'five_hour') bounds.push(q.resets[w].t);
1138
- bounds.push(t1);
1310
+ // The record's end closes the last window, not the range's. Run to the far end instead,
1311
+ // the bar paints every hour between the last reading and the close of the range at the
1312
+ // height of a peak reached before any of them — the firm line through a moment nobody
1313
+ // measured that this chart exists not to draw.
1314
+ var lastFive = -1;
1315
+ for (w = n - 1; w >= 0 && lastFive < 0; w--) if (q.five[w] !== null) lastFive = w;
1316
+ bounds.push(lastFive < 0 ? t1 : Math.min(t1, t0 + (lastFive + 1) * q.step));
1139
1317
  for (w = 0; w < bounds.length - 1; w++) {
1140
1318
  var i0 = Math.round((bounds[w] - t0) / q.step), i1 = Math.round((bounds[w + 1] - t0) / q.step), peak = null;
1141
1319
  // The hour a window turns over in belongs to BOTH windows, ten minutes to the one that
@@ -1153,11 +1331,14 @@ ${PURE.map((fn) => String(fn)).join('\n\n')}
1153
1331
  }
1154
1332
  }
1155
1333
  g.c.save(); g.c.globalAlpha = a7; g.c.strokeStyle = g.fg; g.c.lineWidth = 2; g.c.lineJoin = 'round';
1156
- polyline(g, { v: q.seven, step: q.step }, b, t0, t1, yOf); g.c.restore();
1334
+ end7 = polyline(g, { v: q.seven, step: q.step }, b, t0, t1, yOf); g.c.restore();
1157
1335
  var e5 = lastOf(q.five), e7 = lastOf(q.seven);
1158
- if (e5 !== null && live) dot(g, b.r, yOf(e5), g.dim);
1159
- if (e7 !== null) dot(g, b.r, yOf(e7), g.fg);
1160
- var cur = state.cursor.quota, idx = cur == null ? null : Math.round(cur * (n - 1));
1336
+ // On the curve's own last point, which is the right edge only where the range ends at a
1337
+ // reading. A dot at the edge, over a week the serve was up for one day of, is a reading
1338
+ // nobody took, dated thirteen hours after the last one anybody did.
1339
+ if (live && end5 !== null) dot(g, end5.x, end5.y, g.dim);
1340
+ if (end7 !== null) dot(g, end7.x, end7.y, g.fg);
1341
+ var cur = state.cursor.quota, idx = slotAt(cur, n, live);
1161
1342
  if (idx !== null) {
1162
1343
  var x3 = xOf(b, cur); hair(g, x3, b.t, x3, b.b, g.fg, .5);
1163
1344
  if (live && q.five[idx] !== null) dot(g, x3, yOf(q.five[idx]), g.dim, 3.5);
package/dist/map.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // A view over the fleet `buildFleet` already produced. It opens no second source: every
4
4
  // field below is derived from a row that is already on the page as a table line.
5
- import { isWaiting } from './sessions.js';
5
+ import { anchoredOnKind, isBackgroundAgent, isWaiting } from './sessions.js';
6
6
  /**
7
7
  * What a berth is labelled when the source published no working directory for it. In the
8
8
  * vocabulary the dials already use for a reading they do not have (`not chained`, `no turn
@@ -35,13 +35,11 @@ export const PULSE_WITHIN_MS = 10_000;
35
35
  * grid's arrangement of it, never the data's.
36
36
  */
37
37
  export function buildMap({ rows }, { pulseWithinMs = PULSE_WITHIN_MS } = {}) {
38
- // Whether this fleet still speaks the kind we know. If NOTHING calls itself `interactive`,
39
- // the word moved rather than every terminal on the machine going background at once — and
40
- // the map says so by drawing them all as what they almost certainly still are. It is the
41
- // tolerance `buildFleet` already applies to telemetry: a signal true of every row is a
42
- // change in the source.
43
- const anchored = rows.some((r) => r.kind === INTERACTIVE);
44
- const roleOf = (r) => !anchored || r.kind === null || r.kind === INTERACTIVE ? 'session' : 'agent';
38
+ // Which entries are agents, by the rule `sessions.ts` states and `buildFleet` reads too: the
39
+ // map draws them differently, and the coverage line counts them out of a population no
40
+ // install can ever cover.
41
+ const anchored = anchoredOnKind(rows);
42
+ const roleOf = (r) => (isBackgroundAgent(r, anchored) ? 'agent' : 'session');
45
43
  const node = (row) => {
46
44
  const reading = readingOf(row);
47
45
  return {
@@ -86,20 +84,6 @@ export function buildMap({ rows }, { pulseWithinMs = PULSE_WITHIN_MS } = {}) {
86
84
  }
87
85
  return { berths };
88
86
  }
89
- /**
90
- * The kind a terminal calls itself, and the anchor this module reasons from. A background
91
- * entry has since been seen beside them — `kind: 'background'`, no `pid`, its word under
92
- * `state` rather than `status` — so the two are no longer a reading of that CLI's help. It is
93
- * still the anchor and never the list: one observed alternative is not the vocabulary, and the
94
- * heuristic above asks only whether anything on this machine still calls itself `interactive`.
95
- *
96
- * An ABSENT kind is not evidence of an agent either: the same rule the session status follows
97
- * one module down, where unrecognised means unknown, never "the quiet one". The two mistakes
98
- * are not the same size — an agent drawn as a session is a node in the wrong shape, while a
99
- * session drawn as an agent is a terminal someone is working in, reduced to a footnote of a
100
- * directory it merely shares.
101
- */
102
- export const INTERACTIVE = 'interactive';
103
87
  /**
104
88
  * `stale` is not recomputed here — it is the collector's verdict, reached against the
105
89
  * threshold this run resolved (`--stale-after`, the environment, the config file). A second