1/* DCC_CONTINUOUS_FT8_DECODER */ 2(() => { 3 "use strict"; 4 5 const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; 6 let requesting = false; 7 let latestData = null; 8 let lastMapSignature = ""; 9 let lastMappedRoster = []; 10 let lastMapUpdateAt = 0; 11 let mapViewMode = "auto"; 12 let demoView = null; 13 let hideWorked = false; 14 let hideCq = false; 15 let clearFrequencySelected = false; 16 let clearFrequencyRequesting = false; 17 let clearFrequencyButton = null; 18 let lastLayoutSignature = ""; 19 const mapSignalFilters = { 20 strong: true, 21 workable: true, 22 weak: true 23 }; 24 25 const MAP_WIDTH = 1000; 26 const MAP_HEIGHT = 500; 27 const MAP_RETENTION_MS = 120000; 28 29 // Tell the radio-status overlay that this script is the sole owner of the 30 // live map. Without this flag the two refresh loops alternate the label 31 // between the grid count and "WAITING FOR GRIDS" several times per second. 32 window.dccLiveDecoderOwnsMap = true; 33 34 function bindDemoSelection(element, station) { 35 if (window.dccDemoMode !== true) return; 36 element.dataset.demoCall = station.call || ""; 37 element.addEventListener("click", () => window.dispatchEvent( 38 new CustomEvent("dcc-demo-select", {detail: station}) 39 )); 40 element.addEventListener("dblclick", () => window.dispatchEvent( 41 new CustomEvent("dcc-demo-answer-cq", {detail: station}) 42 )); 43 } 44 45 function enableDemoNavigation(map, svg) { 46 const controls = document.createElement("div"); 47 controls.className = "dcc-demo-map-navigation"; 48 const current = () => { 49 const [x, y, width, height] = svg.getAttribute("viewBox").split(/\s+/).map(Number); 50 return {x, y, width, height}; 51 }; 52 const apply = view => { 53 demoView = {...view, x: Math.max(0, Math.min(MAP_WIDTH - view.width, view.x)), 54 y: Math.max(0, Math.min(MAP_HEIGHT - view.height, view.y))}; 55 lastMapSignature = ""; 56 renderMap(latestData?.roster, latestData?.status); 57 }; 58 for (const [label, factor, title] of [["+", .75, "Zoom in"], ["â", 1 / .75, "Zoom out"]]) { 59 const button = document.createElement("button"); 60 button.type = "button"; button.textContent = label; button.setAttribute("aria-label", title); 61 button.addEventListener("click", () => { 62 const view = current(); 63 const scale = Math.max(150 / view.width, Math.min(MAP_WIDTH / view.width, MAP_HEIGHT / view.height, factor)); 64 const width = view.width * scale, height = view.height * scale; 65 apply({x: view.x + (view.width - width) / 2, y: view.y + (view.height - height) / 2, width, height}); 66 }); 67 controls.appendChild(button); 68 } 69 map.appendChild(controls); 70 let drag; 71 map.addEventListener("pointerdown", event => { 72 if (event.button !== 0 || event.target.closest("button, input, a")) return; 73 drag = {x: event.clientX, y: event.clientY, view: current(), bounds: map.getBoundingClientRect()}; 74 map.setPointerCapture(event.pointerId); 75 }); 76 map.addEventListener("pointermove", event => { 77 if (!drag) return; 78 apply({...drag.view, 79 x: drag.view.x - (event.clientX - drag.x) / drag.bounds.width * drag.view.width, 80 y: drag.view.y - (event.clientY - drag.y) / drag.bounds.height * drag.view.height}); 81 }); 82 for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) map.addEventListener(event, () => { drag = null; }); 83 } 84 85 function decoderSummary(status) { 86 const mode = status?.mode || "Digital"; 87 if (status?.demo) return `DEMO ${status.mode || "FT8"} ⢠${status.last_message_count || 0} decoded ⢠cycle ${status.cycles_completed || 0}`; 88 if (status?.state === "engine_unavailable") return `${status.mode || "Decoder"}: ${status.last_error || "engine setup needed"}`; 89 if (!status || !status.enabled) return `${mode} decoder disabled`; 90 91 const count = Number(status.last_message_count || 0); 92 93 if (status.state === "error") return `${mode} decoder error`; 94 if (status.state === "audio_disconnected") { 95 return `${mode} decoder waiting for audio`; 96 } 97 if (status.state === "synchronizing") { 98 return `${mode} decoder synchronizing`; 99 } 100 if (status.state === "decoding") return `${mode} decoding cycle`; 101 if (status.state === "receiving") return `${mode} receiving ⢠${status.total_messages || 0} messages`; 102 if (status.cycles_completed > 0) { 103 return `${mode} live ⢠${count} last cycle`; 104 } 105 106 return `${mode} collecting first cycle`; 107 } 108 109 function formatDt(value) { 110 if (value == null) return "--"; 111 const number = Number(value); 112 if (!Number.isFinite(number)) return "--"; 113 return number.toFixed(1); 114 } 115 116 function createDecodeRow(row) { 117 const button = document.createElement("button"); 118 button.className = "decode-row dcc-live-decode-row"; 119 button.dataset.dccLive = "true"; 120 button.dataset.digitalMode = row.mode || "FT8"; 121 bindDemoSelection(button, row); 122 button.title = window.dccDemoMode === true ? `Simulated ${row.mode || "FT8"} decode â click for station details` : `Live receive-only ${row.mode || "FT8"} decode`; 123 button.classList.add(`dcc-row-signal-${signalLevel(row.db)}`); 124 if (window.dccDemoMode && row.active_contact) button.classList.add("dcc-qso-row"); 125 else if (row.is_cq) button.classList.add("dcc-cq-row"); 126 127 const values = [ 128 row.utc || "------", 129 row.db != null && Number.isFinite(Number(row.db)) ? String(row.db) : "--", 130 formatDt(row.dt), 131 row.audio_hz != null && Number.isFinite(Number(row.audio_hz)) 132 ? String(row.audio_hz) 133 : "--", 134 row.message || "" 135 ]; 136
137 values.forEach((value, index) => { 138 const cell = document.createElement("span"); 139 cell.textContent = value; 140 141 if (index === 1) { 142 cell.className = `dcc-db-${signalLevel(row.db)}`; 143 } 144 145 button.appendChild(cell); 146 }); 147 148 return button; 149 } 150 151 function renderDecodePanel(panel, rows, emptyText) { 152 if (!panel) return; 153 154 panel.classList.add("dcc-live-decode-panel"); 155 const body = panel.querySelector(".decode-body"); 156 if (!body) return; 157 158 // Keep unchanged demo rows mounted so an RX selection does not interrupt 159 // the second click of a CQ double-click in Band Activity. 160 if (window.dccDemoMode) { 161 const signature = JSON.stringify([rows, rows?.length ? "" : emptyText]); 162 if (body.dccDemoSignature === signature) return; 163 body.dccDemoSignature = signature; 164 } 165 166 body.querySelectorAll("[data-dcc-live]").forEach( 167 element => element.remove() 168 ); 169 170 const selected = Array.isArray(rows) ? rows.slice(0, window.dccDemoMode ? 80 : 40) : []; 171 172 if (!selected.length) { 173 const empty = document.createElement("div"); 174 empty.className = "dcc-empty-decodes"; 175 empty.dataset.dccLive = "true"; 176 empty.textContent = emptyText; 177 body.appendChild(empty); 178 return; 179 } 180 181 let lastPeriod; 182 selected.forEach(row => { 183 const period = row.cycle_utc ?? row.period_index ?? (Number.isFinite(row.received_at) ? Math.floor((row.received_at - 13.2) / 15) : row.utc); 184 if (period !== lastPeriod) { 185 const separator = document.createElement("div"); 186 separator.className = "dcc-decode-period"; separator.dataset.dccLive = "true"; 187 separator.textContent = `â ${row.utc || "------"} UTC · ${row.mode || "FT8"} ${["JS8","PSK31","RTTY"].includes(row.mode) ? "receive text" : "receive period"} â`; 188 body.appendChild(separator); lastPeriod = period; 189 } 190 body.appendChild(createDecodeRow(row)); 191 }); 192 } 193 194 function statusClass(status) { 195 if (status === "CQ") return "status-grid"; 196 return "status-worked"; 197 } 198 199 function hasDistance(value) { 200 return value !== null 201 && value !== undefined 202 && value !== "" 203 && Number.isFinite(Number(value)); 204 } 205 206 function createRosterRow(station) { 207 const button = document.createElement("button"); 208 button.className = "roster-row dcc-live-roster-row"; 209 button.dataset.dccLive = "true"; 210 bindDemoSelection(button, station); 211 button.title = station.message || "Live decoded station"; 212 button.classList.add(`dcc-row-signal-${signalLevel(station.db)}`); 213 if (window.dccDemoMode && station.active_contact) button.classList.add("dcc-qso-row"); 214 else if (station.status === "CQ") button.classList.add("dcc-cq-row"); 215 if (station.worked) button.classList.add("dcc-worked-row"); 216 217 const distance = hasDistance(station.distance_miles) 218 ? `${Number(station.distance_miles).toLocaleString()} mi` 219 : "--"; 220 221 const values = [ 222 station.call || "--", 223 station.grid || "--", 224 Number.isFinite(Number(station.db)) ? String(station.db) : "--", 225 distance, 226 station.status || "Decoded", 227 station.seen || "Now" 228 ]; 229 230 values.forEach((value, index) => { 231 const cell = document.createElement(index === 0 ? "strong" : "span"); 232 cell.textContent = value; 233 234 if (index === 2) { 235 cell.className = `dcc-db-${signalLevel(station.db)}`; 236 } 237 if (index === 4) cell.className = statusClass(value); 238 239 button.appendChild(cell); 240 }); 241 242 return button; 243 } 244 245 function renderRoster(roster) { 246 const panel = document.querySelector(".roster-panel"); 247 const table = panel?.querySelector(".roster-table"); 248 if (!panel || !table) return; 249 250 panel.classList.add("dcc-live-roster-panel"); 251 table.querySelectorAll("[data-dcc-live]").forEach( 252 element => element.remove() 253 ); 254 255 const selected = Array.isArray(roster) ? roster.slice(0, 50) : []; 256 selected.forEach(station => { 257 table.appendChild(createRosterRow(station)); 258 });
259 260 if (!selected.length) { 261 const empty = document.createElement("div"); 262 empty.className = "dcc-empty-roster"; 263 empty.dataset.dccLive = "true"; 264 empty.textContent = "Waiting for decoded callsigns"; 265 table.appendChild(empty); 266 } 267 268 const count = panel.querySelector(".roster-title em"); 269 if (count) count.textContent = String(selected.length); 270 } 271 272 function preference(name, fallback = false) { 273 try { 274 const value = window.localStorage.getItem(name); 275 return value === null ? fallback : value === "true"; 276 } catch { 277 return fallback; 278 } 279 } 280 281 function savePreference(name, value) { 282 try { 283 window.localStorage.setItem(name, String(Boolean(value))); 284 } catch { 285 // Private-browsing storage failures should not stop the live display. 286 } 287 } 288 289 function createCheckboxLabel(text, className, checked) { 290 const label = document.createElement("label"); 291 label.className = className; 292 const input = document.createElement("input"); 293 input.type = "checkbox"; 294 input.checked = checked; 295 label.append(input, document.createTextNode(text)); 296 return {label, input}; 297 } 298 299 function ensureLogControls() { 300 const panel = document.querySelector(".roster-panel"); 301 const controls = panel?.querySelector(".roster-title > div");
302 if (!panel || !controls || controls.dataset.dccLogControls === "true") { 303 return; 304 } 305 controls.dataset.dccLogControls = "true"; 306 307 const oldWorked = Array.from(controls.querySelectorAll("label")) 308 .find(label => label.textContent.trim().toLowerCase() === "worked"); 309 if (oldWorked) oldWorked.style.display = "none"; 310 311 hideWorked = preference("dcc-hide-worked", false); 312 hideCq = preference("dcc-hide-cq", false); 313 const hideCqControl = createCheckboxLabel( 314 "Hide CQ", 315 "dcc-hide-cq-control", 316 hideCq 317 ); 318 hideCqControl.input.addEventListener("change", () => { 319 hideCq = hideCqControl.input.checked; 320 savePreference("dcc-hide-cq", hideCq); 321 lastMappedRoster = []; 322 lastMapSignature = ""; 323 render(latestData); 324 }); 325 326 const hideControl = createCheckboxLabel( 327 "Hide worked", 328 "dcc-hide-worked-control", 329 hideWorked 330 ); 331 hideControl.input.addEventListener("change", () => { 332 hideWorked = hideControl.input.checked; 333 savePreference("dcc-hide-worked", hideWorked); 334 lastMappedRoster = []; 335 lastMapSignature = ""; 336 render(latestData); 337 }); 338 339 const input = document.createElement("input"); 340 input.type = "file"; 341 input.accept = ".adi,.adif,text/plain"; 342 input.className = "dcc-adif-file-input"; 343 344 const importButton = document.createElement("button"); 345 importButton.type = "button"; 346 importButton.className = "dcc-adif-button"; 347 importButton.textContent = "Import .adi"; 348 importButton.addEventListener("click", () => input.click()); 349 350 input.addEventListener("change", async () => { 351 const file = input.files?.[0]; 352 if (!file) return; 353 const originalText = importButton.textContent; 354 importButton.disabled = true; 355 importButton.textContent = "Importingâ¦"; 356 try { 357 const response = await fetch("/api/v1/log/adif/import", { 358 method: "POST", 359 headers: {"Content-Type": "text/plain; charset=utf-8"}, 360 body: await file.text() 361 }); 362 const result = await response.json(); 363 if (!response.ok) { 364 throw new Error(result.detail || "ADIF import failed"); 365 } 366 importButton.textContent = `Added ${result.imported}`; 367 await fetchDecodes(); 368 } catch (error) { 369 importButton.textContent = "Import failed"; 370 importButton.title = error.message || "ADIF import failed"; 371 } finally { 372 input.value = ""; 373 importButton.disabled = false; 374 window.setTimeout(() => { 375 importButton.textContent = originalText; 376 }, 3500); 377 } 378 }); 379 380 const exportLink = document.createElement("a"); 381 exportLink.className = "dcc-adif-button"; 382 exportLink.href = "/api/v1/log/adif/export"; 383 exportLink.download = "digital-command-center-log.adi"; 384 exportLink.textContent = "Export .adi"; 385 386 const filterButton = Array.from(controls.querySelectorAll("button")) 387 .find(button => button.textContent.trim().toLowerCase().startsWith("filter")); 388 controls.insertBefore(hideCqControl.label, filterButton || null); 389 controls.insertBefore(hideControl.label, filterButton || null); 390 controls.insertBefore(importButton, filterButton || null); 391 controls.insertBefore(exportLink, filterButton || null); 392 panel.appendChild(input); 393 } 394 395 function gridToLatLon(grid) { 396 const value = String(grid || "").trim().toUpperCase(); 397 if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(value)) return null; 398 399 let longitude = (value.charCodeAt(0) - 65) * 20 - 180; 400 let latitude = (value.charCodeAt(1) - 65) * 10 - 90; 401 longitude += Number(value[2]) * 2; 402 latitude += Number(value[3]); 403 404 if (value.length === 6) { 405 longitude += (value.charCodeAt(4) - 65) / 12 + 1 / 24; 406 latitude += (value.charCodeAt(5) - 65) / 24 + 1 / 48; 407 } else { 408 longitude += 1; 409 latitude += 0.5; 410 } 411 412 return {latitude, longitude}; 413 } 414 415 function worldMapPosition(grid) { 416 const location = gridToLatLon(grid); 417 if (!location) return null; 418 419 return { 420 x: (location.longitude + 180) / 360 * MAP_WIDTH, 421 y: (90 - location.latitude) / 180 * MAP_HEIGHT 422 }; 423 } 424 425 function signalLevel(db) { 426 const level = Number(db); 427 if (level >= -10) return "strong"; 428 if (level >= -18) return "workable"; 429 return "weak"; 430 } 431 432 function signalOpacity(seconds) { 433 const age = Number(seconds); 434 if (!Number.isFinite(age) || age <= 60) return 1; 435 return Math.max(0.28, 1 - (age - 60) / 1740 * 0.72); 436 } 437 438 function automaticViewBox(points) { 439 if (window.dccDemoMode === true && demoView) return demoView; 440 if (mapViewMode === "world" || !points.length) { 441 return {x: 0, y: 0, width: MAP_WIDTH, height: MAP_HEIGHT}; 442 } 443 444 const xs = points.map(point => point.x);
445 const ys = points.map(point => point.y); 446 const minimumX = Math.min(...xs); 447 const maximumX = Math.max(...xs); 448 const minimumY = Math.min(...ys); 449 const maximumY = Math.max(...ys); 450 const width = Math.min( 451 MAP_WIDTH, 452 Math.max(300, (maximumX - minimumX) * 1.65 + 100) 453 ); 454 const height = Math.min( 455 MAP_HEIGHT, 456 Math.max(190, (maximumY - minimumY) * 1.8 + 80) 457 ); 458 const centerX = (minimumX + maximumX) / 2; 459 const centerY = (minimumY + maximumY) / 2; 460 461 return { 462 x: Math.max(0, Math.min(MAP_WIDTH - width, centerX - width / 2)), 463 y: Math.max(0, Math.min(MAP_HEIGHT - height, centerY - height / 2)), 464 width, 465 height 466 }; 467 } 468 469 function svgElement(name, attributes = {}) { 470 const element = document.createElementNS(SVG_NAMESPACE, name); 471 Object.entries(attributes).forEach(([key, value]) => { 472 element.setAttribute(key, String(value)); 473 }); 474 return element; 475 } 476 477 function bindMapControls(map) { 478 if (map.dataset.dccControlsBound === "true") return; 479 map.dataset.dccControlsBound = "true"; 480 481 const header = map.closest(".map-tabs")?.querySelector(".intel-header"); 482 const checkboxes = Array.from( 483 header?.querySelectorAll('input[type="checkbox"]') || [] 484 ); 485 const [gridControl, signalControl] = checkboxes; 486 487 if (gridControl) { 488 const updateGrid = () => { 489 map.classList.toggle("dcc-hide-map-grid", !gridControl.checked); 490 }; 491 gridControl.addEventListener("change", updateGrid); 492 updateGrid(); 493 } 494 495 if (signalControl) { 496 const updateSignals = () => { 497 map.classList.toggle("dcc-hide-map-signals", !signalControl.checked); 498 }; 499 signalControl.addEventListener("change", updateSignals); 500 updateSignals(); 501 } 502 } 503 504 function updateTxAudioDisplay(audioHz, selected = clearFrequencySelected) { 505 const frequency = Math.max(300, Math.min(3000, Number(audioHz) || 1500)); 506 clearFrequencySelected = Boolean(selected); 507 window.dccSelectedTxAudioHz = Math.round(frequency); 508 const labels = Array.from( 509 document.querySelectorAll(".sequence-options > span") 510 ); 511 const label = labels.find(item => /TX\s+\d+\s*Hz/i.test(item.textContent)); 512 if (label) { 513 label.textContent = clearFrequencySelected 514 ? `TX ${window.dccSelectedTxAudioHz} Hz ⢠CLEAR` 515 : `TX ${window.dccSelectedTxAudioHz} Hz`; 516 } 517 518 const waterfall = document.querySelector(".waterfall"); 519 if (!waterfall) return; 520 let marker = waterfall.querySelector(".dcc-clear-frequency-marker"); 521 if (!marker) { 522 marker = document.createElement("i"); 523 marker.className = "dcc-clear-frequency-marker"; 524 marker.title = "Selected clear FT8 transmit audio frequency"; 525 waterfall.appendChild(marker); 526 } 527 marker.hidden = !clearFrequencySelected; 528 marker.style.left = `${(window.dccSelectedTxAudioHz - 100) / 3400 * 100}%`; 529 } 530 531 function resetClearFrequency() { 532 clearFrequencySelected = false; 533 updateTxAudioDisplay(1500, false); 534 if (clearFrequencyButton) { 535 clearFrequencyButton.textContent = "Find clear"; 536 clearFrequencyButton.title = "Find and hold one clear FT8 audio frequency"; 537 } 538 } 539 540 async function findClearFrequency() { 541 if (clearFrequencyRequesting) return; 542 clearFrequencyRequesting = true; 543 const button = clearFrequencyButton; 544 if (button) { 545 button.disabled = true; 546 button.textContent = "Searchingâ¦"; 547 } 548 try { 549 const response = await fetch("/api/v1/audio/clear-frequency", { 550 cache: "no-store" 551 }); 552 if (!response.ok) throw new Error("Clear-frequency API unavailable"); 553 const result = await response.json(); 554 const audioHz = result.recommendation?.audio_hz; 555 if (result.connected && Number.isFinite(Number(audioHz))) { 556 updateTxAudioDisplay(audioHz, true); 557 if (button) { 558 button.textContent = `Clear ${window.dccSelectedTxAudioHz} Hz`; 559 button.title = "Press again to search for another clear frequency"; 560 } 561 } else if (button) { 562 button.textContent = "No audio"; 563 } 564 } catch { 565 if (button) button.textContent = "Try again"; 566 } finally { 567 clearFrequencyRequesting = false; 568 if (button) button.disabled = false; 569 } 570 } 571 572 function ensureMapFilterControls(map) { 573 const header = map.closest(".map-tabs")?.querySelector(".intel-header"); 574 const controls = header?.querySelector(":scope > div:last-child"); 575 if (!controls || controls.dataset.dccMapFilters === "true") return; 576 controls.dataset.dccMapFilters = "true"; 577 578 const group = document.createElement("span");
579 group.className = "dcc-map-level-controls"; 580 [ 581 ["strong", "Strong"], 582 ["workable", "Workable"], 583 ["weak", "Weak"] 584 ].forEach(([level, text]) => { 585 const control = createCheckboxLabel( 586 text, 587 `dcc-map-level-filter dcc-filter-${level}`, 588 preference(`dcc-map-${level}`, true) 589 ); 590 mapSignalFilters[level] = control.input.checked; 591 control.input.addEventListener("change", () => { 592 mapSignalFilters[level] = control.input.checked; 593 savePreference(`dcc-map-${level}`, control.input.checked); 594 lastMappedRoster = []; 595 lastMapSignature = ""; 596 renderMap(latestData?.roster, latestData?.status); 597 }); 598 group.appendChild(control.label); 599 }); 600 601 clearFrequencyButton = document.createElement("button"); 602 clearFrequencyButton.type = "button"; 603 clearFrequencyButton.className = "dcc-find-clear-button"; 604 clearFrequencyButton.textContent = "Find clear"; 605 clearFrequencyButton.title = "Find and hold one clear FT8 audio frequency"; 606 clearFrequencyButton.addEventListener("click", findClearFrequency); 607 if (window.dccDemoMode === true) { 608 clearFrequencyButton.disabled = true; 609 clearFrequencyButton.title = "Available with live receive audio; disabled in the demo"; 610 } 611 group.appendChild(clearFrequencyButton); 612 controls.appendChild(group); 613 updateTxAudioDisplay(window.dccSelectedTxAudioHz || 1500, false); 614 } 615 616 function ensureWorldMap(map) { 617 let svg = map.querySelector(":scope > svg.dcc-world-map"); 618 if (!svg) { 619 svg = svgElement("svg", { 620 class: "dcc-world-map",
621 viewBox: `0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`, 622 preserveAspectRatio: "none", 623 role: "img", 624 "aria-label": "Live FT8 Maidenhead grid map" 625 }); 626 627 const baseMap = svgElement("image", { 628 href: "./world-map.svg?v=0.3.0", 629 x: 0, 630 y: 0, 631 width: MAP_WIDTH, 632 height: MAP_HEIGHT, 633 preserveAspectRatio: "none" 634 }); 635 baseMap.classList.add("dcc-map-base"); 636 svg.appendChild(baseMap); 637 638 const gridLayer = svgElement("g", {class: "dcc-map-grid-layer"}); 639 for (let longitude = 0; longitude <= MAP_WIDTH; longitude += MAP_WIDTH / 24) { 640 gridLayer.appendChild(svgElement("line", { 641 x1: longitude, 642 y1: 0, 643 x2: longitude, 644 y2: MAP_HEIGHT 645 })); 646 } 647 for (let latitude = 0; latitude <= MAP_HEIGHT; latitude += MAP_HEIGHT / 12) { 648 gridLayer.appendChild(svgElement("line", { 649 x1: 0, 650 y1: latitude, 651 x2: MAP_WIDTH, 652 y2: latitude 653 })); 654 } 655 svg.appendChild(gridLayer); 656 svg.appendChild(svgElement("g", {class: "dcc-map-route-layer"})); 657 map.prepend(svg); 658 659 const viewToggle = document.createElement("button"); 660 viewToggle.type = "button"; 661 viewToggle.className = "dcc-map-view-toggle"; 662 viewToggle.textContent = "AUTO"; 663 viewToggle.title = "Switch between automatic regional view and full world"; 664 viewToggle.addEventListener("click", () => { 665 demoView = null; 666 mapViewMode = mapViewMode === "auto" ? "world" : "auto"; 667 viewToggle.textContent = mapViewMode.toUpperCase(); 668 lastMapSignature = ""; 669 renderMap(latestData?.roster, latestData?.status); 670 }); 671 map.appendChild(viewToggle); 672 if (window.dccDemoMode === true) enableDemoNavigation(map, svg); 673 } 674
675 bindMapControls(map); 676 ensureMapFilterControls(map); 677 return svg; 678 } 679 680 function markerPosition(element, position, view) { 681 element.style.left = `${(position.x - view.x) / view.width * 100}%`; 682 element.style.top = `${(position.y - view.y) / view.height * 100}%`; 683 } 684 685 function createHomeMarker(homeCall, homeGrid, position, view) { 686 const marker = document.createElement("div"); 687 marker.className = "dcc-world-marker dcc-home-marker"; 688 marker.dataset.dccLiveMap = "true"; 689 marker.innerHTML = "<i></i><strong></strong><small></small>"; 690 marker.querySelector("strong").textContent = homeCall || "HOME"; 691 marker.querySelector("small").textContent = homeGrid; 692 markerPosition(marker, position, view); 693 return marker; 694 } 695 696 function createStationMarker(station, position, view, labelOffset) { 697 const marker = document.createElement("button"); 698 bindDemoSelection(marker, station); 699 const level = signalLevel(station.db); 700 marker.type = "button"; 701 marker.className = `dcc-world-marker dcc-station-marker dcc-signal-${level}`; 702 marker.dataset.dccLiveMap = "true"; 703 marker.style.opacity = String(signalOpacity(station.seen_seconds)); 704 marker.innerHTML = "<i></i><strong></strong><small></small>"; 705 marker.querySelector("strong").textContent = station.call || "--"; 706 marker.querySelector("small").textContent = station.grid || "--"; 707 marker.style.setProperty("--dcc-label-shift-x", `${labelOffset.x}px`); 708 marker.style.setProperty("--dcc-label-shift-y", `${labelOffset.y}px`); 709 marker.title = `${station.call} ${station.grid} ${station.db} dB`; 710 markerPosition(marker, position, view); 711 marker.addEventListener("click", () => { 712 const label = document.querySelector(".signal-map .dcc-demo-label"); 713 const distance = hasDistance(station.distance_miles) 714 ? `${Number(station.distance_miles).toLocaleString()} MI` 715 : "DISTANCE --"; 716 if (label) { 717 label.textContent = `${station.call} ⢠${station.grid} ⢠${station.db} dB ⢠${distance}`; 718 } 719 }); 720 return marker; 721 } 722 723 function labelOffsets(mapped, homePosition, view, map) { 724 const width = Math.max(600, map.clientWidth || 900); 725 const height = Math.max(150, map.clientHeight || 240); 726 const project = position => ({ 727 x: (position.x - view.x) / view.width * width, 728 y: (position.y - view.y) / view.height * height 729 }); 730 const candidates = [ 731 {x: 0, y: 0}, 732 {x: -44, y: -24}, 733 {x: 44, y: -24}, 734 {x: -52, y: 22}, 735 {x: 52, y: 22}, 736 {x: 0, y: 38}, 737 {x: -70, y: 0}, 738 {x: 70, y: 0} 739 ]; 740 const occupied = [project(homePosition)]; 741 742 return mapped.map(item => { 743 const base = project(item.position); 744 const offset = candidates.find(candidate => { 745 const x = base.x + candidate.x; 746 const y = base.y + candidate.y; 747 return !occupied.some(point => 748 Math.abs(point.x - x) < 74 && Math.abs(point.y - y) < 28 749 ); 750 }) || candidates[candidates.length - 1]; 751 occupied.push({x: base.x + offset.x, y: base.y + offset.y}); 752 return {...item, labelOffset: offset}; 753 }); 754 } 755 756 function createRoute(home, destination, station) { 757 const midpointX = (home.x + destination.x) / 2; 758 const distance = Math.abs(destination.x - home.x); 759 const midpointY = Math.max( 760 5, 761 (home.y + destination.y) / 2 - Math.max(18, Math.min(90, distance * 0.18)) 762 ); 763 const path = svgElement("path", { 764 d: `M ${home.x} ${home.y} Q ${midpointX} ${midpointY} ${destination.x} ${destination.y}`, 765 class: `dcc-map-route dcc-signal-${signalLevel(station.db)}` 766 }); 767 path.dataset.dccLiveMap = "true"; 768 path.style.opacity = String(signalOpacity(station.seen_seconds)); 769 return path; 770 } 771 772 function renderMap(roster, status) { 773 const map = document.querySelector(".signal-map"); 774 if (!map) return; 775 776 map.classList.add("dcc-live-signal-map"); 777 const svg = ensureWorldMap(map); 778 const originalGrid = map.querySelector(".home-node small")?.textContent || ""; 779 const originalCall = map.querySelector(".home-node")?.textContent 780 .replace(originalGrid, "") 781 .trim(); 782 const homeGrid = String(status?.station_grid || originalGrid) 783 .trim() 784 .toUpperCase(); 785 const homeCall = String(status?.station_call || originalCall || "HOME") 786 .trim() 787 .toUpperCase(); 788 const homePosition = worldMapPosition(homeGrid); 789 if (!homePosition) return; 790 791 let mapped = (Array.isArray(roster) ? roster : []) 792 .filter(station => Number(station.seen_seconds || 0) <= 1800) 793 .filter(station => !hideWorked || !station.worked) 794 .filter(station => !hideCq || station.status !== "CQ") 795 .filter(station => mapSignalFilters[signalLevel(station.db)]) 796 .map(station => ({ 797 station, 798 position: worldMapPosition(station.grid) 799 })) 800 .filter(item => item.position) 801 .slice(0, 12); 802 803 if (mapped.length) { 804 lastMappedRoster = mapped; 805 lastMapUpdateAt = Date.now(); 806 } else if ( 807 lastMappedRoster.length 808 && Date.now() - lastMapUpdateAt <= MAP_RETENTION_MS 809 ) { 810 mapped = lastMappedRoster; 811 } else { 812 lastMappedRoster = []; 813 } 814 815 const view = automaticViewBox([ 816 homePosition, 817 ...mapped.map(item => item.position) 818 ]); 819 mapped = labelOffsets(mapped, homePosition, view, map); 820 const signature = JSON.stringify({ 821 mode: mapViewMode, 822 hideWorked, 823 hideCq, 824 filters: mapSignalFilters, 825 homeCall, 826 homeGrid, 827 view, 828 stations: mapped.map(({station, position}) => ({ 829 call: station.call || "", 830 grid: station.grid || "", 831 db: station.db, 832 age: Math.floor(Number(station.seen_seconds || 0) / 60), 833 x: position.x, 834 y: position.y 835 })) 836 });
837 const existingPaths = svg.querySelectorAll( 838 ".dcc-map-route-layer > [data-dcc-live-map]" 839 ).length; 840 const existingMarkers = map.querySelectorAll( 841 ":scope > .dcc-world-marker[data-dcc-live-map]" 842 ).length; 843 844 const label = map.querySelector(".dcc-demo-label"); 845 const mapPrefix = status?.demo ? "DEMO MAP" : "LIVE MAP"; 846 const mapSummary = mapped.length 847 ? `${mapPrefix} ⢠${mapViewMode.toUpperCase()} ⢠${mapped.length} GRID${mapped.length === 1 ? "" : "S"}` 848 : `${mapPrefix} ⢠${mapViewMode.toUpperCase()} ⢠WAITING FOR GRIDS`; 849 850 if ( 851 signature === lastMapSignature 852 && existingPaths === mapped.length 853 && existingMarkers === mapped.length + 1 854 ) { 855 if (label && !label.textContent.includes(" dB ⢠")) { 856 label.textContent = mapSummary; 857 } 858 return; 859 } 860 861 svg.setAttribute( 862 "viewBox", 863 `${view.x} ${view.y} ${view.width} ${view.height}` 864 ); 865 svg.querySelectorAll("[data-dcc-live-map]").forEach( 866 element => element.remove() 867 ); 868 map.querySelectorAll(":scope > [data-dcc-live-map]").forEach( 869 element => element.remove() 870 ); 871 872 const routeLayer = svg.querySelector(".dcc-map-route-layer"); 873 mapped.forEach(({station, position, labelOffset}) => { 874 routeLayer.appendChild(createRoute(homePosition, position, station)); 875 map.appendChild(createStationMarker(station, position, view, labelOffset)); 876 }); 877 map.appendChild(createHomeMarker(homeCall, homeGrid, homePosition, view)); 878 879 if (label) label.textContent = mapSummary; 880 const viewToggle = map.querySelector(".dcc-map-view-toggle"); 881 if (viewToggle) viewToggle.textContent = demoView ? "MANUAL" : mapViewMode.toUpperCase(); 882 lastMapSignature = signature; 883 } 884
885 function renderStatus(status) { 886 const summary = decoderSummary(status); 887 window.dccDecoderSummary = summary; 888 889 const items = document.querySelectorAll(".receive-status > span"); 890 if (items[3]) items[3].textContent = summary; 891 892 const waterfallLabel = document.querySelector( 893 ".waterfall .dcc-demo-label" 894 ); 895 if (waterfallLabel) waterfallLabel.textContent = "LIVE FT8 DECODER"; 896 897 document.body.classList.toggle( 898 "dcc-decoder-error", 899 status?.state === "error" 900 ); 901 } 902 903 function render(data) { 904 if (!data) return; 905 document.body.dataset.dccDigitalMode = data.status?.mode || "FT8"; 906 if (window.dccDemoMode) hideWorked = window.dccDemoStation?.hideWorked === true; 907 908 const panels = Array.from(document.querySelectorAll(".decode-panel")); 909 const bandPanel = panels.find(panel => 910 panel.querySelector(".section-title")?.textContent 911 .trim() 912 .toUpperCase() 913 .includes("BAND ACTIVITY") 914 ); 915 const rxPanel = panels.find(panel => 916 panel.querySelector(".section-title")?.textContent 917 .trim() 918 .toUpperCase() 919 .includes("RX FREQUENCY") 920 ); 921 922 const visibleDecodes = (Array.isArray(data.decodes) ? data.decodes : []) 923 .filter(row => !window.dccDemoMode || !hideWorked || !row.worked) 924 .filter(row => !hideCq || !row.is_cq); 925 const visibleLatest = (Array.isArray(data.latest_cycle) ? data.latest_cycle : []) 926 .filter(row => !window.dccDemoMode || !hideWorked || !row.worked) 927 .filter(row => !hideCq || !row.is_cq); 928 929 renderDecodePanel( 930 bandPanel, 931 visibleDecodes, 932 data.status?.last_error || `Waiting for ${data.status?.mode || "FT8"} messages` 933 ); 934 renderDecodePanel( 935 rxPanel, 936 visibleLatest, 937 window.dccDemoMode ? `No messages near RX ${window.dccDemoStation?.rxHz || 0} Hz yet (±50 Hz). Select a station or move the green marker.` : `No messages near RX ${data.status?.rx_hz || 1500} Hz yet` 938 ); 939 if (window.dccDemoMode !== true) ensureLogControls(); 940 const visibleRoster = (Array.isArray(data.roster) ? data.roster : []) 941 .filter(station => !hideWorked || !station.worked) 942 .filter(station => !hideCq || station.status !== "CQ"); 943 renderRoster(visibleRoster); 944 renderMap(data.roster, data.status); 945 renderStatus(data.status); 946 947 const layoutSignature = JSON.stringify({ 948 bandRows: Math.min(8, visibleDecodes.length), 949 rxRows: Math.min(8, visibleLatest.length), 950 rosterRows: Math.min(8, visibleRoster.length) 951 }); 952 if (layoutSignature !== lastLayoutSignature) { 953 lastLayoutSignature = layoutSignature; 954 window.dispatchEvent(new window.Event("dcc-layout-change")); 955 } 956 } 957 958 async function fetchDecodes() { 959 if (requesting) return; 960 requesting = true; 961 962 try { 963 const response = await fetch("/api/v1/decodes?limit=80", { 964 cache: "no-store" 965 }); 966 if (!response.ok) throw new Error("Decoder API unavailable"); 967 968 latestData = await response.json(); 969 render(latestData); 970 } catch { 971 window.dccDecoderSummary = "FT8 decoder API disconnected"; 972 renderStatus({enabled: true, state: "error"}); 973 } finally { 974 requesting = false; 975 } 976 } 977 978 // Reuse the real map in the demo without starting API polling or log controls. 979 if (window.dccDemoMode === true) { 980 window.dccRenderDemoDecodes = data => { 981 latestData = data; 982 render(data); 983 }; 984 window.dccRenderDemoMap = (roster, status) => { 985 latestData = {roster, status: {...status, demo: true}}; 986 renderMap(latestData.roster, latestData.status); 987 }; 988 return; 989 } 990 991 fetchDecodes(); 992 window.addEventListener("dcc-band-change", resetClearFrequency); 993 setInterval(fetchDecodes, 1000); 994 setInterval(() => render(latestData), 400); 995})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.