Files
istrain-public/dashboard/panels/strip.js
T
istrain 559caead36 istrain-public: from-scratch build of a passive RF train detector
Public community release. The complete working system — DSP + decoders (scripts/),
web dashboard + API (dashboard/), container stack (docker/), config templates (config/) —
plus APOCALYPSE-EDITION.md, the full build guide for a human or a coding agent. GPLv3
(PyEOT dependency). Scrubbed of all secrets, internal IPs, hostnames, and location detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:19:16 -04:00

128 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// strip.js — shared "week of 15-minute bins" strip, used by EVERY band panel (EOT/BOT/mid-train/voice and
// the correlation synthesis). One source of truth for binning so their columns LINE UP: bins are keyed to
// absolute wall-clock 15-min slots (floor(ts/900)), not relative-to-now — so the same 15-min slot lands in
// the same column on every panel. Shared canvas draw + one hover-tooltip format (date + time range, ET).
export const BUCKET = 900, WEEK = 7 * 86400, NB = Math.round(WEEK / BUCKET); // 672 — a week of 15-min bins
export const TZ = 'America/New_York';
// two grids (2026-07-05): the shared WEEK grid keeps every panel's columns aligned (correlate's job —
// the overview); RECENT1M is the zoom the band strips use so the most recent stretch of correlate can
// be inspected at 1-min resolution (360 bins ≈ same canvas cost as the week's 672).
export const WEEK15 = { bucket: BUCKET, nb: NB, note: 'week · 15-min bins' };
export const RECENT1M = { bucket: 60, nb: 360, note: 'last 6h · 1-min bins' };
// absolute slot alignment — rightmost bin (nb-1) is the current slot
export function binIndex(ts, now, s = WEEK15) {
if (!ts || ts <= 0) return -1;
const idx = s.nb - 1 - (Math.floor(now / s.bucket) - Math.floor(ts / s.bucket));
return (idx < 0 || idx > s.nb - 1) ? -1 : idx;
}
export function fillBins(events, now, getTs = e => e.ts, s = WEEK15) {
const b = new Int16Array(s.nb);
for (const e of events) { const i = binIndex(getTs(e), now, s); if (i >= 0) b[i]++; }
return b;
}
// wall-clock window [start,end) covered by bin idx — for tooltips
export function binTimes(idx, now, s = WEEK15) {
const start = (Math.floor(now / s.bucket) - (s.nb - 1 - idx)) * s.bucket;
return { start, end: start + s.bucket };
}
export function drawStrip(canvas, buckets, rgb, gmaxOverride) {
const w = Math.max(1, canvas.clientWidth | 0), h = (canvas.clientHeight | 0) || 32;
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, w, h);
const gmax = gmaxOverride || Math.max(1, ...buckets);
const N = buckets.length; // works for either grid
for (let x = 0; x < w; x++) {
const b0 = Math.floor(x / w * N), b1 = Math.max(b0 + 1, Math.floor((x + 1) / w * N));
let v = 0;
for (let b = b0; b < b1 && b < N; b++) if (buckets[b] > v) v = buckets[b];
if (!v) continue;
const t = v / gmax, bh = Math.max(1, Math.round(t * h));
ctx.fillStyle = `rgba(${rgb},${(0.4 + 0.6 * t).toFixed(3)})`;
ctx.fillRect(x, h - bh, 1, bh);
}
}
const fmtStart = t => new Date(t * 1000).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: TZ });
const fmtEnd = t => new Date(t * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: TZ });
// Shared hover tooltip (reuses .act-tip). resolve(ev) -> {canvas, buckets, now, unit} | null.
// Default-to-LEFT of cursor (busy bins sit at the right edge, where a right popup gets clipped).
export function attachTip(el, bodyEl, resolve) {
const tip = document.createElement('div');
tip.className = 'act-tip'; tip.style.display = 'none';
el.appendChild(tip);
bodyEl.addEventListener('mousemove', ev => {
const r = resolve(ev);
if (!r) { tip.style.display = 'none'; return; }
const s = r.scale || WEEK15;
const rect = r.canvas.getBoundingClientRect();
let idx = Math.floor((ev.clientX - rect.left) / Math.max(1, rect.width) * s.nb);
idx = Math.max(0, Math.min(s.nb - 1, idx));
const { start, end } = binTimes(idx, r.now, s);
const label = r.label ? r.label(idx) : `${r.buckets[idx]} ${r.unit || 'tx'}`;
tip.textContent = `${label} · ${fmtStart(start)}${fmtEnd(end)} ET`;
tip.style.display = 'block';
const gap = 12;
let lx = ev.clientX - gap - tip.offsetWidth;
if (lx < 4) lx = ev.clientX + gap;
tip.style.left = lx + 'px';
tip.style.top = Math.max(4, ev.clientY - 30) + 'px';
});
bodyEl.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
}
// Factory for a single-band PRESENCE panel (EOT/BOT/mid-train). opts:
// {id, title, tag, freqTag, rgb, endpoint, nearSec, nearLabel, emptyHtml, scale?}
// scale defaults to the shared week grid; pass RECENT1M for the 1-min zoom (endpoint should then
// carry ?hours=6 so the server only ships the window being drawn).
export function mountBandStrip(el, o) {
const scale = o.scale || WEEK15;
el.innerHTML =
`<h2>${o.title} <span class="tag" id="${o.id}-tag">${o.tag}</span></h2><div id="${o.id}-body"></div>`;
const tagEl = el.querySelector(`#${o.id}-tag`);
const bodyEl = el.querySelector(`#${o.id}-body`);
let lastKey = null, lastBuckets = null, lastNow = 0;
const fmtT = e => new Date(e * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: TZ });
const paint = () => {
const cv = bodyEl.querySelector('canvas.eot-strip');
if (cv && lastBuckets) drawStrip(cv, lastBuckets, o.rgb);
};
attachTip(el, bodyEl, ev => {
const cv = ev.target.closest && ev.target.closest('canvas.eot-strip');
return (cv && lastBuckets) ? { canvas: cv, buckets: lastBuckets, now: lastNow, unit: 'bursts', scale } : null;
});
async function refresh() {
let d = { bursts: [], total: 0 };
try { const r = await fetch(o.endpoint, { cache: 'no-store' }); if (r.ok) d = await r.json(); }
catch { return; }
const bursts = d.bursts || [], now = Date.now() / 1000, w = bodyEl.clientWidth | 0;
const newest = bursts.length ? bursts[0].ts : 0;
const key = (d.total || 0) + '|' + newest + '|' + w + '|' + Math.floor(now / scale.bucket);
if (key === lastKey) return;
lastKey = key; lastNow = now;
lastBuckets = fillBins(bursts, now, e => e.ts, scale);
tagEl.textContent = d.total ? `${o.freqTag} · ${d.total} bursts · ${scale.note}` : o.tag;
if (!bursts.length) { bodyEl.innerHTML = `<div class="placeholder">${o.emptyHtml}</div>`; return; }
const last = newest, hr = bursts.filter(b => now - b.ts <= 3600).length, near = last && (now - last <= o.nearSec);
bodyEl.innerHTML =
`<div class="eot-head">` +
`<span class="eot-state"${near ? ` style="color:rgb(${o.rgb})"` : ''}>${near ? o.nearLabel : 'quiet'}</span>` +
`<span class="eot-last">last ${fmtT(last)} ET</span>` +
`<span class="eot-n">${hr}× last hr</span>` +
`</div><canvas class="eot-strip"></canvas>`;
requestAnimationFrame(paint);
}
refresh();
setInterval(refresh, 30000);
let rt;
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { lastKey = null; refresh(); }, 250); });
return { refresh };
}