@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.
- package/README.md +17 -13
- package/dist/demo-history.js +72 -12
- package/dist/fleet.js +14 -4
- package/dist/history-range.js +2 -0
- package/dist/history-view.js +239 -58
- package/dist/map.js +6 -22
- package/dist/render.js +609 -111
- package/dist/schema.js +12 -3
- package/dist/sessions.js +25 -0
- package/dist/wrapper.js +27 -10
- package/package.json +1 -1
package/dist/history-view.js
CHANGED
|
@@ -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 './
|
|
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
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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 =
|
|
231
|
-
var n =
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
686
|
-
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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('--
|
|
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
|
-
|
|
965
|
-
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
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
|