Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

650 строки
19KB

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