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>
This commit is contained in:
istrain
2026-07-24 23:19:16 -04:00
commit 559caead36
41 changed files with 6740 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
// 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 };
}