// 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 = `