Wideband autonomous SDR analysis engine forked from sdr-visual-suite
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.

435 wiersze
13KB

  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. let latest = null;
  17. let zoom = 1.0;
  18. let pan = 0.0;
  19. let isDragging = false;
  20. let dragStartX = 0;
  21. let dragStartPan = 0;
  22. let timelineDirty = true;
  23. let detailDirty = false;
  24. const events = [];
  25. const eventsById = new Map();
  26. let lastEventEndMs = 0;
  27. let eventsFetchInFlight = false;
  28. let timelineRects = [];
  29. let selectedEventId = null;
  30. function resize() {
  31. const dpr = window.devicePixelRatio || 1;
  32. const rect1 = spectrumCanvas.getBoundingClientRect();
  33. spectrumCanvas.width = rect1.width * dpr;
  34. spectrumCanvas.height = rect1.height * dpr;
  35. const rect2 = waterfallCanvas.getBoundingClientRect();
  36. waterfallCanvas.width = rect2.width * dpr;
  37. waterfallCanvas.height = rect2.height * dpr;
  38. const rect3 = timelineCanvas.getBoundingClientRect();
  39. timelineCanvas.width = rect3.width * dpr;
  40. timelineCanvas.height = rect3.height * dpr;
  41. const rect4 = detailSpectrogram.getBoundingClientRect();
  42. detailSpectrogram.width = rect4.width * dpr;
  43. detailSpectrogram.height = rect4.height * dpr;
  44. timelineDirty = true;
  45. detailDirty = true;
  46. }
  47. window.addEventListener('resize', resize);
  48. resize();
  49. function colorMap(v) {
  50. // v in [0..1]
  51. const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6))));
  52. const g = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 1.1))));
  53. const b = Math.min(255, Math.max(0, Math.floor(180 * Math.pow(1 - v, 1.2))));
  54. return [r, g, b];
  55. }
  56. function snrColor(snr) {
  57. const norm = Math.max(0, Math.min(1, (snr + 5) / 30));
  58. const [r, g, b] = colorMap(norm);
  59. return `rgb(${r}, ${g}, ${b})`;
  60. }
  61. function renderSpectrum() {
  62. if (!latest) return;
  63. const ctx = spectrumCanvas.getContext('2d');
  64. const w = spectrumCanvas.width;
  65. const h = spectrumCanvas.height;
  66. ctx.clearRect(0, 0, w, h);
  67. // Grid
  68. ctx.strokeStyle = '#13263b';
  69. ctx.lineWidth = 1;
  70. for (let i = 1; i < 10; i++) {
  71. const y = (h / 10) * i;
  72. ctx.beginPath();
  73. ctx.moveTo(0, y);
  74. ctx.lineTo(w, y);
  75. ctx.stroke();
  76. }
  77. const { spectrum_db, sample_rate, center_hz } = latest;
  78. const n = spectrum_db.length;
  79. const span = sample_rate / zoom;
  80. const startHz = center_hz - span / 2 + pan * span;
  81. const endHz = center_hz + span / 2 + pan * span;
  82. const minDb = -120;
  83. const maxDb = 0;
  84. ctx.strokeStyle = '#48d1b8';
  85. ctx.lineWidth = 2;
  86. ctx.beginPath();
  87. for (let i = 0; i < n; i++) {
  88. const freq = center_hz + (i - n / 2) * (sample_rate / n);
  89. if (freq < startHz || freq > endHz) continue;
  90. const x = ((freq - startHz) / (endHz - startHz)) * w;
  91. const v = spectrum_db[i];
  92. const y = h - ((v - minDb) / (maxDb - minDb)) * h;
  93. if (i === 0) ctx.moveTo(x, y);
  94. else ctx.lineTo(x, y);
  95. }
  96. ctx.stroke();
  97. // Signals overlay
  98. ctx.strokeStyle = '#ffb454';
  99. ctx.lineWidth = 2;
  100. if (latest.signals) {
  101. for (const s of latest.signals) {
  102. const left = s.center_hz - s.bw_hz / 2;
  103. const right = s.center_hz + s.bw_hz / 2;
  104. if (right < startHz || left > endHz) continue;
  105. const x1 = ((left - startHz) / (endHz - startHz)) * w;
  106. const x2 = ((right - startHz) / (endHz - startHz)) * w;
  107. ctx.beginPath();
  108. ctx.moveTo(x1, h - 4);
  109. ctx.lineTo(x2, h - 4);
  110. ctx.stroke();
  111. }
  112. }
  113. metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz`;
  114. }
  115. function renderWaterfall() {
  116. if (!latest) return;
  117. const ctx = waterfallCanvas.getContext('2d');
  118. const w = waterfallCanvas.width;
  119. const h = waterfallCanvas.height;
  120. // Scroll down
  121. const image = ctx.getImageData(0, 0, w, h);
  122. ctx.putImageData(image, 0, 1);
  123. const { spectrum_db, sample_rate, center_hz } = latest;
  124. const n = spectrum_db.length;
  125. const span = sample_rate / zoom;
  126. const startHz = center_hz - span / 2 + pan * span;
  127. const endHz = center_hz + span / 2 + pan * span;
  128. const minDb = -120;
  129. const maxDb = 0;
  130. const row = ctx.createImageData(w, 1);
  131. for (let x = 0; x < w; x++) {
  132. const freq = startHz + (x / (w - 1)) * (endHz - startHz);
  133. const bin = Math.floor((freq - (center_hz - sample_rate / 2)) / (sample_rate / n));
  134. if (bin >= 0 && bin < n) {
  135. const v = spectrum_db[bin];
  136. const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
  137. const [r, g, b] = colorMap(norm);
  138. row.data[x * 4 + 0] = r;
  139. row.data[x * 4 + 1] = g;
  140. row.data[x * 4 + 2] = b;
  141. row.data[x * 4 + 3] = 255;
  142. } else {
  143. row.data[x * 4 + 3] = 255;
  144. }
  145. }
  146. ctx.putImageData(row, 0, 0);
  147. }
  148. function renderTimeline() {
  149. const ctx = timelineCanvas.getContext('2d');
  150. const w = timelineCanvas.width;
  151. const h = timelineCanvas.height;
  152. ctx.clearRect(0, 0, w, h);
  153. if (events.length === 0) {
  154. timelineRangeEl.textContent = 'No events yet';
  155. return;
  156. }
  157. const now = Date.now();
  158. const windowMs = 5 * 60 * 1000;
  159. const endMs = now;
  160. const startMs = endMs - windowMs;
  161. let minHz = Infinity;
  162. let maxHz = -Infinity;
  163. if (latest) {
  164. minHz = latest.center_hz - latest.sample_rate / 2;
  165. maxHz = latest.center_hz + latest.sample_rate / 2;
  166. } else {
  167. for (const ev of events) {
  168. minHz = Math.min(minHz, ev.center_hz - ev.bandwidth_hz / 2);
  169. maxHz = Math.max(maxHz, ev.center_hz + ev.bandwidth_hz / 2);
  170. }
  171. }
  172. if (!isFinite(minHz) || !isFinite(maxHz) || minHz === maxHz) {
  173. minHz = 0;
  174. maxHz = 1;
  175. }
  176. ctx.strokeStyle = '#13263b';
  177. ctx.lineWidth = 1;
  178. for (let i = 1; i < 6; i++) {
  179. const y = (h / 6) * i;
  180. ctx.beginPath();
  181. ctx.moveTo(0, y);
  182. ctx.lineTo(w, y);
  183. ctx.stroke();
  184. }
  185. timelineRects = [];
  186. for (const ev of events) {
  187. if (ev.end_ms < startMs || ev.start_ms > endMs) continue;
  188. const x1 = ((Math.max(ev.start_ms, startMs) - startMs) / (endMs - startMs)) * w;
  189. const x2 = ((Math.min(ev.end_ms, endMs) - startMs) / (endMs - startMs)) * w;
  190. const bw = Math.max(ev.bandwidth_hz, 1);
  191. const topHz = ev.center_hz + bw / 2;
  192. const bottomHz = ev.center_hz - bw / 2;
  193. const y1 = ((maxHz - topHz) / (maxHz - minHz)) * h;
  194. const y2 = ((maxHz - bottomHz) / (maxHz - minHz)) * h;
  195. const rectH = Math.max(2, y2 - y1);
  196. ctx.fillStyle = snrColor(ev.snr_db || 0);
  197. ctx.fillRect(x1, y1, Math.max(2, x2 - x1), rectH);
  198. const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: rectH, id: ev.id };
  199. timelineRects.push(rect);
  200. }
  201. if (selectedEventId) {
  202. const hit = timelineRects.find((r) => r.id === selectedEventId);
  203. if (hit) {
  204. ctx.strokeStyle = '#ffffff';
  205. ctx.lineWidth = 2;
  206. ctx.strokeRect(hit.x - 1, hit.y - 1, hit.w + 2, hit.h + 2);
  207. }
  208. }
  209. const startLabel = new Date(startMs).toLocaleTimeString();
  210. const endLabel = new Date(endMs).toLocaleTimeString();
  211. timelineRangeEl.textContent = `${startLabel} - ${endLabel}`;
  212. }
  213. function renderDetailSpectrogram(ev) {
  214. const ctx = detailSpectrogram.getContext('2d');
  215. const w = detailSpectrogram.width;
  216. const h = detailSpectrogram.height;
  217. ctx.clearRect(0, 0, w, h);
  218. if (!latest || !ev) return;
  219. const span = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 3, latest.sample_rate / 8));
  220. const startHz = ev.center_hz - span / 2;
  221. const endHz = ev.center_hz + span / 2;
  222. const { spectrum_db, sample_rate, center_hz } = latest;
  223. const n = spectrum_db.length;
  224. const minDb = -120;
  225. const maxDb = 0;
  226. const row = ctx.createImageData(w, 1);
  227. for (let x = 0; x < w; x++) {
  228. const freq = startHz + (x / (w - 1)) * (endHz - startHz);
  229. const bin = Math.floor((freq - (center_hz - sample_rate / 2)) / (sample_rate / n));
  230. if (bin >= 0 && bin < n) {
  231. const v = spectrum_db[bin];
  232. const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb)));
  233. const [r, g, b] = colorMap(norm);
  234. row.data[x * 4 + 0] = r;
  235. row.data[x * 4 + 1] = g;
  236. row.data[x * 4 + 2] = b;
  237. row.data[x * 4 + 3] = 255;
  238. } else {
  239. row.data[x * 4 + 3] = 255;
  240. }
  241. }
  242. for (let y = 0; y < h; y++) {
  243. ctx.putImageData(row, 0, y);
  244. }
  245. }
  246. function tick() {
  247. renderSpectrum();
  248. renderWaterfall();
  249. if (timelineDirty) {
  250. renderTimeline();
  251. timelineDirty = false;
  252. }
  253. if (detailDirty && drawerEl.classList.contains('open')) {
  254. const ev = eventsById.get(selectedEventId);
  255. renderDetailSpectrogram(ev);
  256. detailDirty = false;
  257. }
  258. requestAnimationFrame(tick);
  259. }
  260. function connect() {
  261. const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  262. const ws = new WebSocket(`${proto}://${location.host}/ws`);
  263. ws.onopen = () => {
  264. statusEl.textContent = 'Connected';
  265. };
  266. ws.onmessage = (ev) => {
  267. latest = JSON.parse(ev.data);
  268. detailDirty = true;
  269. timelineDirty = true;
  270. };
  271. ws.onclose = () => {
  272. statusEl.textContent = 'Disconnected - retrying...';
  273. setTimeout(connect, 1000);
  274. };
  275. ws.onerror = () => {
  276. ws.close();
  277. };
  278. }
  279. spectrumCanvas.addEventListener('wheel', (ev) => {
  280. ev.preventDefault();
  281. const delta = Math.sign(ev.deltaY);
  282. zoom = Math.max(0.5, Math.min(10, zoom * (delta > 0 ? 1.1 : 0.9)));
  283. });
  284. spectrumCanvas.addEventListener('mousedown', (ev) => {
  285. isDragging = true;
  286. dragStartX = ev.clientX;
  287. dragStartPan = pan;
  288. });
  289. window.addEventListener('mouseup', () => { isDragging = false; });
  290. window.addEventListener('mousemove', (ev) => {
  291. if (!isDragging) return;
  292. const dx = ev.clientX - dragStartX;
  293. pan = dragStartPan - dx / spectrumCanvas.clientWidth;
  294. pan = Math.max(-0.5, Math.min(0.5, pan));
  295. });
  296. function normalizeEvent(ev) {
  297. const startMs = new Date(ev.start).getTime();
  298. const endMs = new Date(ev.end).getTime();
  299. return {
  300. ...ev,
  301. start_ms: startMs,
  302. end_ms: endMs,
  303. duration_ms: Math.max(0, endMs - startMs),
  304. };
  305. }
  306. function upsertEvents(list, replace) {
  307. if (replace) {
  308. events.length = 0;
  309. eventsById.clear();
  310. }
  311. for (const raw of list) {
  312. if (eventsById.has(raw.id)) continue;
  313. const ev = normalizeEvent(raw);
  314. eventsById.set(ev.id, ev);
  315. events.push(ev);
  316. }
  317. events.sort((a, b) => a.end_ms - b.end_ms);
  318. const maxEvents = 1500;
  319. if (events.length > maxEvents) {
  320. const drop = events.length - maxEvents;
  321. for (let i = 0; i < drop; i++) {
  322. eventsById.delete(events[i].id);
  323. }
  324. events.splice(0, drop);
  325. }
  326. if (events.length > 0) {
  327. lastEventEndMs = events[events.length - 1].end_ms;
  328. }
  329. timelineDirty = true;
  330. }
  331. async function fetchEvents(initial) {
  332. if (eventsFetchInFlight) return;
  333. eventsFetchInFlight = true;
  334. try {
  335. let url = '/api/events?limit=1000';
  336. if (!initial && lastEventEndMs > 0) {
  337. url = `/api/events?since=${lastEventEndMs - 1}`;
  338. }
  339. const res = await fetch(url);
  340. if (!res.ok) return;
  341. const data = await res.json();
  342. if (Array.isArray(data)) {
  343. upsertEvents(data, initial);
  344. }
  345. } finally {
  346. eventsFetchInFlight = false;
  347. }
  348. }
  349. function openDrawer(ev) {
  350. if (!ev) return;
  351. selectedEventId = ev.id;
  352. detailCenterEl.textContent = `${(ev.center_hz / 1e6).toFixed(6)} MHz`;
  353. detailBwEl.textContent = `${(ev.bandwidth_hz / 1e3).toFixed(2)} kHz`;
  354. detailStartEl.textContent = new Date(ev.start_ms).toLocaleString();
  355. detailEndEl.textContent = new Date(ev.end_ms).toLocaleString();
  356. detailSnrEl.textContent = `${(ev.snr_db || 0).toFixed(1)} dB`;
  357. detailDurEl.textContent = `${(ev.duration_ms / 1000).toFixed(2)} s`;
  358. drawerEl.classList.add('open');
  359. drawerEl.setAttribute('aria-hidden', 'false');
  360. resize();
  361. detailDirty = true;
  362. timelineDirty = true;
  363. }
  364. function closeDrawer() {
  365. drawerEl.classList.remove('open');
  366. drawerEl.setAttribute('aria-hidden', 'true');
  367. selectedEventId = null;
  368. timelineDirty = true;
  369. }
  370. drawerCloseBtn.addEventListener('click', closeDrawer);
  371. timelineCanvas.addEventListener('click', (ev) => {
  372. const rect = timelineCanvas.getBoundingClientRect();
  373. const scaleX = timelineCanvas.width / rect.width;
  374. const scaleY = timelineCanvas.height / rect.height;
  375. const x = (ev.clientX - rect.left) * scaleX;
  376. const y = (ev.clientY - rect.top) * scaleY;
  377. for (let i = timelineRects.length - 1; i >= 0; i--) {
  378. const r = timelineRects[i];
  379. if (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h) {
  380. const hit = eventsById.get(r.id);
  381. openDrawer(hit);
  382. return;
  383. }
  384. }
  385. });
  386. connect();
  387. requestAnimationFrame(tick);
  388. fetchEvents(true);
  389. setInterval(() => fetchEvents(false), 2000);