Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

684 lignes
20KB

  1. const spectrumCanvas = document.getElementById('spectrum');
  2. const waterfallCanvas = document.getElementById('waterfall');
  3. const timelineCanvas = document.getElementById('timeline');
  4. const statusEl = document.getElementById('status');
  5. const metaEl = document.getElementById('meta');
  6. const timelineRangeEl = document.getElementById('timelineRange');
  7. const drawerEl = document.getElementById('eventDrawer');
  8. const drawerCloseBtn = document.getElementById('drawerClose');
  9. const detailCenterEl = document.getElementById('detailCenter');
  10. const detailBwEl = document.getElementById('detailBw');
  11. const detailStartEl = document.getElementById('detailStart');
  12. const detailEndEl = document.getElementById('detailEnd');
  13. const detailSnrEl = document.getElementById('detailSnr');
  14. const detailDurEl = document.getElementById('detailDur');
  15. const detailSpectrogram = document.getElementById('detailSpectrogram');
  16. const configStatusEl = document.getElementById('configStatus');
  17. const centerInput = document.getElementById('centerInput');
  18. const spanInput = document.getElementById('spanInput');
  19. const sampleRateSelect = document.getElementById('sampleRateSelect');
  20. const fftSelect = document.getElementById('fftSelect');
  21. const gainRange = document.getElementById('gainRange');
  22. const gainInput = document.getElementById('gainInput');
  23. const thresholdRange = document.getElementById('thresholdRange');
  24. const thresholdInput = document.getElementById('thresholdInput');
  25. const agcToggle = document.getElementById('agcToggle');
  26. const dcToggle = document.getElementById('dcToggle');
  27. const iqToggle = document.getElementById('iqToggle');
  28. const presetButtons = Array.from(document.querySelectorAll('.preset-btn'));
  29. let latest = null;
  30. let zoom = 1.0;
  31. let pan = 0.0;
  32. let isDragging = false;
  33. let dragStartX = 0;
  34. let dragStartPan = 0;
  35. let timelineDirty = true;
  36. let detailDirty = false;
  37. let currentConfig = null;
  38. let isSyncingConfig = false;
  39. let pendingConfigUpdate = null;
  40. let pendingSettingsUpdate = null;
  41. let configTimer = null;
  42. let settingsTimer = null;
  43. const GAIN_MAX = 60;
  44. const events = [];
  45. const eventsById = new Map();
  46. let lastEventEndMs = 0;
  47. let eventsFetchInFlight = false;
  48. let timelineRects = [];
  49. let selectedEventId = null;
  50. function resize() {
  51. const dpr = window.devicePixelRatio || 1;
  52. const rect1 = spectrumCanvas.getBoundingClientRect();
  53. spectrumCanvas.width = rect1.width * dpr;
  54. spectrumCanvas.height = rect1.height * dpr;
  55. const rect2 = waterfallCanvas.getBoundingClientRect();
  56. waterfallCanvas.width = rect2.width * dpr;
  57. waterfallCanvas.height = rect2.height * dpr;
  58. const rect3 = timelineCanvas.getBoundingClientRect();
  59. timelineCanvas.width = rect3.width * dpr;
  60. timelineCanvas.height = rect3.height * dpr;
  61. const rect4 = detailSpectrogram.getBoundingClientRect();
  62. detailSpectrogram.width = rect4.width * dpr;
  63. detailSpectrogram.height = rect4.height * dpr;
  64. timelineDirty = true;
  65. detailDirty = true;
  66. }
  67. window.addEventListener('resize', resize);
  68. resize();
  69. function setConfigStatus(text) {
  70. if (configStatusEl) {
  71. configStatusEl.textContent = text;
  72. }
  73. }
  74. function toMHz(hz) {
  75. return hz / 1e6;
  76. }
  77. function fromMHz(mhz) {
  78. return mhz * 1e6;
  79. }
  80. function applyConfigToUI(cfg) {
  81. if (!cfg) return;
  82. isSyncingConfig = true;
  83. centerInput.value = toMHz(cfg.center_hz).toFixed(6);
  84. if (sampleRateSelect) {
  85. sampleRateSelect.value = toMHz(cfg.sample_rate).toFixed(3).replace(/\.0+$/, '').replace(/\.$/, '');
  86. }
  87. const spanMHz = toMHz(cfg.sample_rate / zoom);
  88. spanInput.value = spanMHz.toFixed(3);
  89. fftSelect.value = String(cfg.fft_size);
  90. const uiGain = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - cfg.gain_db));
  91. gainRange.value = uiGain;
  92. gainInput.value = uiGain;
  93. thresholdRange.value = cfg.detector.threshold_db;
  94. thresholdInput.value = cfg.detector.threshold_db;
  95. agcToggle.checked = !!cfg.agc;
  96. dcToggle.checked = !!cfg.dc_block;
  97. iqToggle.checked = !!cfg.iq_balance;
  98. isSyncingConfig = false;
  99. }
  100. async function loadConfig() {
  101. try {
  102. const res = await fetch('/api/config');
  103. if (!res.ok) {
  104. setConfigStatus('Failed to load');
  105. return;
  106. }
  107. const data = await res.json();
  108. currentConfig = data;
  109. applyConfigToUI(currentConfig);
  110. setConfigStatus('Synced');
  111. } catch (err) {
  112. setConfigStatus('Offline');
  113. }
  114. }
  115. function queueConfigUpdate(partial) {
  116. if (isSyncingConfig) return;
  117. pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial };
  118. setConfigStatus('Updating...');
  119. if (configTimer) clearTimeout(configTimer);
  120. configTimer = setTimeout(sendConfigUpdate, 200);
  121. }
  122. function queueSettingsUpdate(partial) {
  123. if (isSyncingConfig) return;
  124. pendingSettingsUpdate = { ...(pendingSettingsUpdate || {}), ...partial };
  125. setConfigStatus('Updating...');
  126. if (settingsTimer) clearTimeout(settingsTimer);
  127. settingsTimer = setTimeout(sendSettingsUpdate, 100);
  128. }
  129. async function sendConfigUpdate() {
  130. if (!pendingConfigUpdate) return;
  131. const payload = pendingConfigUpdate;
  132. pendingConfigUpdate = null;
  133. try {
  134. const res = await fetch('/api/config', {
  135. method: 'POST',
  136. headers: { 'Content-Type': 'application/json' },
  137. body: JSON.stringify(payload),
  138. });
  139. if (!res.ok) {
  140. setConfigStatus('Apply failed');
  141. return;
  142. }
  143. const data = await res.json();
  144. currentConfig = data;
  145. applyConfigToUI(currentConfig);
  146. setConfigStatus('Applied');
  147. } catch (err) {
  148. setConfigStatus('Offline');
  149. }
  150. }
  151. async function sendSettingsUpdate() {
  152. if (!pendingSettingsUpdate) return;
  153. const payload = pendingSettingsUpdate;
  154. pendingSettingsUpdate = null;
  155. try {
  156. const res = await fetch('/api/sdr/settings', {
  157. method: 'POST',
  158. headers: { 'Content-Type': 'application/json' },
  159. body: JSON.stringify(payload),
  160. });
  161. if (!res.ok) {
  162. setConfigStatus('Apply failed');
  163. return;
  164. }
  165. const data = await res.json();
  166. currentConfig = data;
  167. applyConfigToUI(currentConfig);
  168. setConfigStatus('Applied');
  169. } catch (err) {
  170. setConfigStatus('Offline');
  171. }
  172. }
  173. function colorMap(v) {
  174. // v in [0..1]
  175. const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6))));
  176. const g = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 1.1))));
  177. const b = Math.min(255, Math.max(0, Math.floor(180 * Math.pow(1 - v, 1.2))));
  178. return [r, g, b];
  179. }
  180. function binForFreq(freq, centerHz, sampleRate, n) {
  181. return Math.floor((freq - (centerHz - sampleRate / 2)) / (sampleRate / n));
  182. }
  183. function maxInBinRange(spectrum, b0, b1) {
  184. const n = spectrum.length;
  185. let start = Math.max(0, Math.min(n - 1, b0));
  186. let end = Math.max(0, Math.min(n - 1, b1));
  187. if (end < start) {
  188. const tmp = start;
  189. start = end;
  190. end = tmp;
  191. }
  192. let max = -1e9;
  193. for (let i = start; i <= end; i++) {
  194. const v = spectrum[i];
  195. if (v > max) max = v;
  196. }
  197. return max;
  198. }
  199. function snrColor(snr) {
  200. const norm = Math.max(0, Math.min(1, (snr + 5) / 30));
  201. const [r, g, b] = colorMap(norm);
  202. return `rgb(${r}, ${g}, ${b})`;
  203. }
  204. function renderSpectrum() {
  205. if (!latest) return;
  206. const ctx = spectrumCanvas.getContext('2d');
  207. const w = spectrumCanvas.width;
  208. const h = spectrumCanvas.height;
  209. ctx.clearRect(0, 0, w, h);
  210. // Grid
  211. ctx.strokeStyle = '#13263b';
  212. ctx.lineWidth = 1;
  213. for (let i = 1; i < 10; i++) {
  214. const y = (h / 10) * i;
  215. ctx.beginPath();
  216. ctx.moveTo(0, y);
  217. ctx.lineTo(w, y);
  218. ctx.stroke();
  219. }
  220. const { spectrum_db, sample_rate, center_hz } = latest;
  221. const n = spectrum_db.length;
  222. const span = sample_rate / zoom;
  223. const startHz = center_hz - span / 2 + pan * span;
  224. const endHz = center_hz + span / 2 + pan * span;
  225. if (!isSyncingConfig && spanInput) {
  226. spanInput.value = (span / 1e6).toFixed(3);
  227. }
  228. const minDb = -120;
  229. const maxDb = 0;
  230. ctx.strokeStyle = '#48d1b8';
  231. ctx.lineWidth = 2;
  232. ctx.beginPath();
  233. for (let x = 0; x < w; x++) {
  234. const f1 = startHz + (x / w) * (endHz - startHz);
  235. const f2 = startHz + ((x + 1) / w) * (endHz - startHz);
  236. const b0 = binForFreq(f1, center_hz, sample_rate, n);
  237. const b1 = binForFreq(f2, center_hz, sample_rate, n);
  238. const v = maxInBinRange(spectrum_db, b0, b1);
  239. const y = h - ((v - minDb) / (maxDb - minDb)) * h;
  240. if (x === 0) ctx.moveTo(x, y);
  241. else ctx.lineTo(x, y);
  242. }
  243. ctx.stroke();
  244. // Signals overlay
  245. ctx.strokeStyle = '#ffb454';
  246. ctx.lineWidth = 2;
  247. if (latest.signals) {
  248. for (const s of latest.signals) {
  249. const left = s.center_hz - s.bw_hz / 2;
  250. const right = s.center_hz + s.bw_hz / 2;
  251. if (right < startHz || left > endHz) continue;
  252. const x1 = ((left - startHz) / (endHz - startHz)) * w;
  253. const x2 = ((right - startHz) / (endHz - startHz)) * w;
  254. ctx.beginPath();
  255. ctx.moveTo(x1, h - 4);
  256. ctx.lineTo(x2, h - 4);
  257. ctx.stroke();
  258. }
  259. }
  260. const binHz = sample_rate / n;
  261. metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin`;
  262. }
  263. function renderWaterfall() {
  264. if (!latest) return;
  265. const ctx = waterfallCanvas.getContext('2d');
  266. const w = waterfallCanvas.width;
  267. const h = waterfallCanvas.height;
  268. // Scroll down
  269. const image = ctx.getImageData(0, 0, w, h);
  270. ctx.putImageData(image, 0, 1);
  271. const { spectrum_db, sample_rate, center_hz } = latest;
  272. const n = spectrum_db.length;
  273. const span = sample_rate / zoom;
  274. const startHz = center_hz - span / 2 + pan * span;
  275. const endHz = center_hz + span / 2 + pan * span;
  276. const minDb = -120;
  277. const maxDb = 0;
  278. const row = ctx.createImageData(w, 1);
  279. for (let x = 0; x < w; x++) {
  280. const f1 = startHz + (x / w) * (endHz - startHz);
  281. const f2 = startHz + ((x + 1) / w) * (endHz - startHz);
  282. const b0 = binForFreq(f1, center_hz, sample_rate, n);
  283. const b1 = binForFreq(f2, center_hz, sample_rate, n);
  284. if (b0 < n && b1 >= 0) {
  285. const v = maxInBinRange(spectrum_db, b0, b1);
  286. const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
  287. const [r, g, b] = colorMap(norm);
  288. row.data[x * 4 + 0] = r;
  289. row.data[x * 4 + 1] = g;
  290. row.data[x * 4 + 2] = b;
  291. row.data[x * 4 + 3] = 255;
  292. } else {
  293. row.data[x * 4 + 3] = 255;
  294. }
  295. }
  296. ctx.putImageData(row, 0, 0);
  297. }
  298. function renderTimeline() {
  299. const ctx = timelineCanvas.getContext('2d');
  300. const w = timelineCanvas.width;
  301. const h = timelineCanvas.height;
  302. ctx.clearRect(0, 0, w, h);
  303. if (events.length === 0) {
  304. timelineRangeEl.textContent = 'No events yet';
  305. return;
  306. }
  307. const now = Date.now();
  308. const windowMs = 5 * 60 * 1000;
  309. const endMs = now;
  310. const startMs = endMs - windowMs;
  311. let minHz = Infinity;
  312. let maxHz = -Infinity;
  313. if (latest) {
  314. minHz = latest.center_hz - latest.sample_rate / 2;
  315. maxHz = latest.center_hz + latest.sample_rate / 2;
  316. } else {
  317. for (const ev of events) {
  318. minHz = Math.min(minHz, ev.center_hz - ev.bandwidth_hz / 2);
  319. maxHz = Math.max(maxHz, ev.center_hz + ev.bandwidth_hz / 2);
  320. }
  321. }
  322. if (!isFinite(minHz) || !isFinite(maxHz) || minHz === maxHz) {
  323. minHz = 0;
  324. maxHz = 1;
  325. }
  326. ctx.strokeStyle = '#13263b';
  327. ctx.lineWidth = 1;
  328. for (let i = 1; i < 6; i++) {
  329. const y = (h / 6) * i;
  330. ctx.beginPath();
  331. ctx.moveTo(0, y);
  332. ctx.lineTo(w, y);
  333. ctx.stroke();
  334. }
  335. timelineRects = [];
  336. for (const ev of events) {
  337. if (ev.end_ms < startMs || ev.start_ms > endMs) continue;
  338. const x1 = ((Math.max(ev.start_ms, startMs) - startMs) / (endMs - startMs)) * w;
  339. const x2 = ((Math.min(ev.end_ms, endMs) - startMs) / (endMs - startMs)) * w;
  340. const bw = Math.max(ev.bandwidth_hz, 1);
  341. const topHz = ev.center_hz + bw / 2;
  342. const bottomHz = ev.center_hz - bw / 2;
  343. const y1 = ((maxHz - topHz) / (maxHz - minHz)) * h;
  344. const y2 = ((maxHz - bottomHz) / (maxHz - minHz)) * h;
  345. const rectH = Math.max(2, y2 - y1);
  346. ctx.fillStyle = snrColor(ev.snr_db || 0);
  347. ctx.fillRect(x1, y1, Math.max(2, x2 - x1), rectH);
  348. const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: rectH, id: ev.id };
  349. timelineRects.push(rect);
  350. }
  351. if (selectedEventId) {
  352. const hit = timelineRects.find((r) => r.id === selectedEventId);
  353. if (hit) {
  354. ctx.strokeStyle = '#ffffff';
  355. ctx.lineWidth = 2;
  356. ctx.strokeRect(hit.x - 1, hit.y - 1, hit.w + 2, hit.h + 2);
  357. }
  358. }
  359. const startLabel = new Date(startMs).toLocaleTimeString();
  360. const endLabel = new Date(endMs).toLocaleTimeString();
  361. timelineRangeEl.textContent = `${startLabel} - ${endLabel}`;
  362. }
  363. function renderDetailSpectrogram(ev) {
  364. const ctx = detailSpectrogram.getContext('2d');
  365. const w = detailSpectrogram.width;
  366. const h = detailSpectrogram.height;
  367. ctx.clearRect(0, 0, w, h);
  368. if (!latest || !ev) return;
  369. const span = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 3, latest.sample_rate / 8));
  370. const startHz = ev.center_hz - span / 2;
  371. const endHz = ev.center_hz + span / 2;
  372. const { spectrum_db, sample_rate, center_hz } = latest;
  373. const n = spectrum_db.length;
  374. const minDb = -120;
  375. const maxDb = 0;
  376. const row = ctx.createImageData(w, 1);
  377. for (let x = 0; x < w; x++) {
  378. const f1 = startHz + (x / w) * (endHz - startHz);
  379. const f2 = startHz + ((x + 1) / w) * (endHz - startHz);
  380. const b0 = binForFreq(f1, center_hz, sample_rate, n);
  381. const b1 = binForFreq(f2, center_hz, sample_rate, n);
  382. if (b0 < n && b1 >= 0) {
  383. const v = maxInBinRange(spectrum_db, b0, b1);
  384. const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
  385. const [r, g, b] = colorMap(norm);
  386. row.data[x * 4 + 0] = r;
  387. row.data[x * 4 + 1] = g;
  388. row.data[x * 4 + 2] = b;
  389. row.data[x * 4 + 3] = 255;
  390. } else {
  391. row.data[x * 4 + 3] = 255;
  392. }
  393. }
  394. for (let y = 0; y < h; y++) {
  395. ctx.putImageData(row, 0, y);
  396. }
  397. }
  398. function tick() {
  399. renderSpectrum();
  400. renderWaterfall();
  401. if (timelineDirty) {
  402. renderTimeline();
  403. timelineDirty = false;
  404. }
  405. if (detailDirty && drawerEl.classList.contains('open')) {
  406. const ev = eventsById.get(selectedEventId);
  407. renderDetailSpectrogram(ev);
  408. detailDirty = false;
  409. }
  410. requestAnimationFrame(tick);
  411. }
  412. function connect() {
  413. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  414. const ws = new WebSocket(`${proto}://${location.host}/ws`);
  415. ws.onopen = () => {
  416. statusEl.textContent = 'Connected';
  417. };
  418. ws.onmessage = (ev) => {
  419. latest = JSON.parse(ev.data);
  420. detailDirty = true;
  421. timelineDirty = true;
  422. };
  423. ws.onclose = () => {
  424. statusEl.textContent = 'Disconnected - retrying...';
  425. setTimeout(connect, 1000);
  426. };
  427. ws.onerror = () => {
  428. ws.close();
  429. };
  430. }
  431. spectrumCanvas.addEventListener('wheel', (ev) => {
  432. ev.preventDefault();
  433. const delta = Math.sign(ev.deltaY);
  434. zoom = Math.max(0.5, Math.min(10, zoom * (delta > 0 ? 1.1 : 0.9)));
  435. });
  436. spectrumCanvas.addEventListener('mousedown', (ev) => {
  437. isDragging = true;
  438. dragStartX = ev.clientX;
  439. dragStartPan = pan;
  440. });
  441. window.addEventListener('mouseup', () => { isDragging = false; });
  442. window.addEventListener('mousemove', (ev) => {
  443. if (!isDragging) return;
  444. const dx = ev.clientX - dragStartX;
  445. pan = dragStartPan - dx / spectrumCanvas.clientWidth;
  446. pan = Math.max(-0.5, Math.min(0.5, pan));
  447. });
  448. centerInput.addEventListener('change', () => {
  449. const mhz = parseFloat(centerInput.value);
  450. if (Number.isFinite(mhz)) {
  451. queueConfigUpdate({ center_hz: fromMHz(mhz) });
  452. }
  453. });
  454. spanInput.addEventListener('change', () => {
  455. const mhz = parseFloat(spanInput.value);
  456. if (!Number.isFinite(mhz) || mhz <= 0) return;
  457. const baseRate = currentConfig ? currentConfig.sample_rate : (latest ? latest.sample_rate : 0);
  458. if (!baseRate) return;
  459. zoom = Math.max(0.25, Math.min(20, baseRate / fromMHz(mhz)));
  460. timelineDirty = true;
  461. });
  462. if (sampleRateSelect) {
  463. sampleRateSelect.addEventListener('change', () => {
  464. const mhz = parseFloat(sampleRateSelect.value);
  465. if (Number.isFinite(mhz) && mhz > 0) {
  466. queueConfigUpdate({ sample_rate: Math.round(fromMHz(mhz)) });
  467. }
  468. });
  469. }
  470. fftSelect.addEventListener('change', () => {
  471. const size = parseInt(fftSelect.value, 10);
  472. if (Number.isFinite(size)) {
  473. queueConfigUpdate({ fft_size: size });
  474. }
  475. });
  476. gainRange.addEventListener('input', () => {
  477. gainInput.value = gainRange.value;
  478. const uiVal = parseFloat(gainRange.value);
  479. if (Number.isFinite(uiVal)) {
  480. const gr = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - uiVal));
  481. queueConfigUpdate({ gain_db: gr });
  482. }
  483. });
  484. gainInput.addEventListener('change', () => {
  485. const v = parseFloat(gainInput.value);
  486. if (Number.isFinite(v)) {
  487. gainRange.value = v;
  488. const gr = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - v));
  489. queueConfigUpdate({ gain_db: gr });
  490. }
  491. });
  492. thresholdRange.addEventListener('input', () => {
  493. thresholdInput.value = thresholdRange.value;
  494. queueConfigUpdate({ detector: { threshold_db: parseFloat(thresholdRange.value) } });
  495. });
  496. thresholdInput.addEventListener('change', () => {
  497. const v = parseFloat(thresholdInput.value);
  498. if (Number.isFinite(v)) {
  499. thresholdRange.value = v;
  500. queueConfigUpdate({ detector: { threshold_db: v } });
  501. }
  502. });
  503. agcToggle.addEventListener('change', () => {
  504. queueSettingsUpdate({ agc: agcToggle.checked });
  505. });
  506. dcToggle.addEventListener('change', () => {
  507. queueSettingsUpdate({ dc_block: dcToggle.checked });
  508. });
  509. iqToggle.addEventListener('change', () => {
  510. queueSettingsUpdate({ iq_balance: iqToggle.checked });
  511. });
  512. for (const btn of presetButtons) {
  513. btn.addEventListener('click', () => {
  514. const mhz = parseFloat(btn.dataset.center);
  515. if (Number.isFinite(mhz)) {
  516. centerInput.value = mhz.toFixed(3);
  517. queueConfigUpdate({ center_hz: fromMHz(mhz) });
  518. }
  519. });
  520. }
  521. function normalizeEvent(ev) {
  522. const startMs = new Date(ev.start).getTime();
  523. const endMs = new Date(ev.end).getTime();
  524. return {
  525. ...ev,
  526. start_ms: startMs,
  527. end_ms: endMs,
  528. duration_ms: Math.max(0, endMs - startMs),
  529. };
  530. }
  531. function upsertEvents(list, replace) {
  532. if (replace) {
  533. events.length = 0;
  534. eventsById.clear();
  535. }
  536. for (const raw of list) {
  537. if (eventsById.has(raw.id)) continue;
  538. const ev = normalizeEvent(raw);
  539. eventsById.set(ev.id, ev);
  540. events.push(ev);
  541. }
  542. events.sort((a, b) => a.end_ms - b.end_ms);
  543. const maxEvents = 1500;
  544. if (events.length > maxEvents) {
  545. const drop = events.length - maxEvents;
  546. for (let i = 0; i < drop; i++) {
  547. eventsById.delete(events[i].id);
  548. }
  549. events.splice(0, drop);
  550. }
  551. if (events.length > 0) {
  552. lastEventEndMs = events[events.length - 1].end_ms;
  553. }
  554. timelineDirty = true;
  555. }
  556. async function fetchEvents(initial) {
  557. if (eventsFetchInFlight) return;
  558. eventsFetchInFlight = true;
  559. try {
  560. let url = '/api/events?limit=1000';
  561. if (!initial && lastEventEndMs > 0) {
  562. url = `/api/events?since=${lastEventEndMs - 1}`;
  563. }
  564. const res = await fetch(url);
  565. if (!res.ok) return;
  566. const data = await res.json();
  567. if (Array.isArray(data)) {
  568. upsertEvents(data, initial);
  569. }
  570. } finally {
  571. eventsFetchInFlight = false;
  572. }
  573. }
  574. function openDrawer(ev) {
  575. if (!ev) return;
  576. selectedEventId = ev.id;
  577. detailCenterEl.textContent = `${(ev.center_hz / 1e6).toFixed(6)} MHz`;
  578. detailBwEl.textContent = `${(ev.bandwidth_hz / 1e3).toFixed(2)} kHz`;
  579. detailStartEl.textContent = new Date(ev.start_ms).toLocaleString();
  580. detailEndEl.textContent = new Date(ev.end_ms).toLocaleString();
  581. detailSnrEl.textContent = `${(ev.snr_db || 0).toFixed(1)} dB`;
  582. detailDurEl.textContent = `${(ev.duration_ms / 1000).toFixed(2)} s`;
  583. drawerEl.classList.add('open');
  584. drawerEl.setAttribute('aria-hidden', 'false');
  585. resize();
  586. detailDirty = true;
  587. timelineDirty = true;
  588. }
  589. function closeDrawer() {
  590. drawerEl.classList.remove('open');
  591. drawerEl.setAttribute('aria-hidden', 'true');
  592. selectedEventId = null;
  593. timelineDirty = true;
  594. }
  595. drawerCloseBtn.addEventListener('click', closeDrawer);
  596. timelineCanvas.addEventListener('click', (ev) => {
  597. const rect = timelineCanvas.getBoundingClientRect();
  598. const scaleX = timelineCanvas.width / rect.width;
  599. const scaleY = timelineCanvas.height / rect.height;
  600. const x = (ev.clientX - rect.left) * scaleX;
  601. const y = (ev.clientY - rect.top) * scaleY;
  602. for (let i = timelineRects.length - 1; i >= 0; i--) {
  603. const r = timelineRects[i];
  604. if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
  605. const hit = eventsById.get(r.id);
  606. openDrawer(hit);
  607. return;
  608. }
  609. }
  610. });
  611. loadConfig();
  612. connect();
  613. requestAnimationFrame(tick);
  614. fetchEvents(true);
  615. setInterval(() => fetchEvents(false), 2000);