(function () { "use strict"; // === Map Setup === var map = L.map("map").setView([52.37, 9.73], 6); L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); // === State === var markerLayer = L.layerGroup().addTo(map); var routeLayer = null; var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change var isRouteMode = false; var isPinMode = false; var pinMarker = null; var pinCircle = null; var pinLatLng = null; var debounceTimer = null; var controller = null; // === DOM === var band2m = document.getElementById("band-2m"); var band70cm = document.getElementById("band-70cm"); var netBm = document.getElementById("net-bm"); var netDmrplus = document.getElementById("net-dmrplus"); var netTgif = document.getElementById("net-tgif"); var netOther = document.getElementById("net-other"); var showHotspots = document.getElementById("show-hotspots"); var showInactive = document.getElementById("show-inactive"); var countEl = document.getElementById("count"); var fromInput = document.getElementById("route-from"); var toInput = document.getElementById("route-to"); var routeBtn = document.getElementById("route-btn"); var clearBtn = document.getElementById("clear-btn"); var pinControlsEl = document.getElementById("pin-controls"); var pinRadiusInput = document.getElementById("pin-radius"); var pinClearBtn = document.getElementById("pin-clear"); var pinListEl = document.getElementById("pin-list"); clearBtn.style.display = "none"; // === Autocomplete === function setupAutocomplete(input, listEl) { var acTimer = null; var acController = null; var activeIdx = -1; function closeList() { listEl.classList.remove("open"); listEl.innerHTML = ""; activeIdx = -1; } function selectItem(displayName) { input.value = displayName; closeList(); } function updateActive() { var items = listEl.querySelectorAll("li"); items.forEach(function (li, i) { li.classList.toggle("active", i === activeIdx); }); if (activeIdx >= 0 && items[activeIdx]) { items[activeIdx].scrollIntoView({ block: "nearest" }); } } input.addEventListener("input", function () { clearTimeout(acTimer); if (acController) acController.abort(); var q = input.value.trim(); if (q.length < 3) { closeList(); return; } acTimer = setTimeout(function () { acController = new AbortController(); fetch( "https://nominatim.openstreetmap.org/search?" + new URLSearchParams({ q: q, format: "json", limit: "5", addressdetails: "0", "accept-language": "de", }), { signal: acController.signal } ) .then(function (r) { return r.json(); }) .then(function (results) { listEl.innerHTML = ""; activeIdx = -1; if (!results.length) { closeList(); return; } results.forEach(function (item) { var li = document.createElement("li"); var parts = item.display_name.split(", "); var main = parts.slice(0, 2).join(", "); var sub = parts.slice(2).join(", "); li.innerHTML = '' + escapeHtml(main) + "" + (sub ? '
' + escapeHtml(sub) + "" : ""); li.addEventListener("mousedown", function (e) { e.preventDefault(); selectItem(item.display_name); }); listEl.appendChild(li); }); listEl.classList.add("open"); }) .catch(function (err) { if (err.name !== "AbortError") closeList(); }); }, 300); }); input.addEventListener("keydown", function (e) { if (!listEl.classList.contains("open")) return; var items = listEl.querySelectorAll("li"); if (e.key === "ArrowDown") { e.preventDefault(); activeIdx = Math.min(activeIdx + 1, items.length - 1); updateActive(); } else if (e.key === "ArrowUp") { e.preventDefault(); activeIdx = Math.max(activeIdx - 1, 0); updateActive(); } else if (e.key === "Enter" && activeIdx >= 0) { e.preventDefault(); e.stopPropagation(); var parts = items[activeIdx].querySelector(".ac-main").textContent; var sub = items[activeIdx].querySelector(".ac-sub"); selectItem(parts + (sub ? ", " + sub.textContent : "")); } else if (e.key === "Escape") { closeList(); } }); input.addEventListener("blur", function () { setTimeout(closeList, 150); }); } setupAutocomplete(fromInput, document.getElementById("ac-from")); setupAutocomplete(toInput, document.getElementById("ac-to")); // === Coordinates display === var coordsEl = document.getElementById("coords"); function toMaidenhead(lat, lng) { lng = lng + 180; lat = lat + 90; var loc = ""; loc += String.fromCharCode(65 + Math.floor(lng / 20)); loc += String.fromCharCode(65 + Math.floor(lat / 10)); lng = (lng % 20); lat = (lat % 10); loc += Math.floor(lng / 2); loc += Math.floor(lat); lng = (lng % 2) * 60; lat = (lat % 1) * 60; loc += String.fromCharCode(97 + Math.floor(lng / 5)); loc += String.fromCharCode(97 + Math.floor(lat / 2.5)); return loc; } map.on("mousemove", function (e) { var lat = e.latlng.lat; var lng = e.latlng.lng; var grid = toMaidenhead(lat, lng); coordsEl.innerHTML = lat.toFixed(5) + ", " + lng.toFixed(5) + ' ' + grid + ""; }); map.on("mouseout", function () { coordsEl.innerHTML = ""; }); // === Utilities === function getSelectedBand() { var has2m = band2m.checked; var has70cm = band70cm.checked; if (has2m && has70cm) return "all"; if (has2m) return "2m"; if (has70cm) return "70cm"; return "all"; } function getSelectedNetworks() { var nets = []; if (netBm.checked) nets.push("BM"); if (netDmrplus.checked) nets.push("DMR+"); if (netTgif.checked) nets.push("TGIF"); if (netOther.checked) nets.push("Other"); if (nets.length === 4) return "all"; if (nets.length === 0) return "none"; return nets.join(","); } function escapeHtml(str) { if (!str) return ""; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function showCount(count) { countEl.textContent = count + " repeater" + (count !== 1 ? "s" : ""); } function showStatus(msg) { countEl.textContent = msg; } // === Popup === function buildPopup(r) { var bandClass = r.band === "2m" ? "band-2m" : "band-70cm"; var html = '
'; html += '

' + escapeHtml(r.callsign) + ' ' + escapeHtml(r.band) + "

"; html += ""; html += ""; if (r.freq_rx) html += ""; if (r.freq_offset) html += ""; html += ""; var loc = escapeHtml(r.city); if (r.state) loc += ", " + escapeHtml(r.state); if (r.country) loc += ", " + escapeHtml(r.country); html += ""; if (r.network) html += ""; if (r.trustee) html += ""; if (r.ts_linked) html += ""; html += ""; if (r.bm_status_text) html += ""; if (r.last_seen) html += ""; if (r.hardware) html += ""; if (r.pep) html += ""; if (r.agl) html += ""; if (r.inactive) html += ''; html += "
TX" + r.freq_tx.toFixed(4) + " MHz
RX" + r.freq_rx.toFixed(4) + " MHz
Offset" + escapeHtml(r.freq_offset) + " MHz
CC" + r.color_code + "
Location" + loc + "
Network" + escapeHtml(r.network) + "
Trustee" + escapeHtml(r.trustee) + "
Timeslots" + escapeHtml(r.ts_linked) + "
Status" + escapeHtml(r.status) + "
BM Status" + escapeHtml(r.bm_status_text) + "
Last seen" + escapeHtml(r.last_seen.replace("T", " ").substring(0, 19)) + "
Hardware" + escapeHtml(r.hardware) + "
Power" + r.pep + " W
Antenna" + r.agl + " m AGL
Inactive
"; return html; } // === Display markers === function displayRepeaters(repeaters) { markerLayer.clearLayers(); repeaters.forEach(function (r) { var color = r.band === "2m" ? "#2196F3" : "#D32F2F"; var marker = L.circleMarker([r.lat, r.lng], { radius: 6, fillColor: color, color: "#fff", weight: 1, fillOpacity: r.inactive ? 0.35 : 0.85, }); marker.bindPopup(buildPopup(r), { maxWidth: 280 }); markerLayer.addLayer(marker); }); } // === Viewport mode === function fetchRepeaters() { if (isRouteMode || isPinMode) return; if (getSelectedNetworks() === "none") { markerLayer.clearLayers(); showCount(0); return; } if (controller) controller.abort(); controller = new AbortController(); var bounds = map.getBounds(); var params = new URLSearchParams({ minLat: bounds.getSouth(), maxLat: bounds.getNorth(), minLng: bounds.getWest(), maxLng: bounds.getEast(), band: getSelectedBand(), network: getSelectedNetworks(), hotspots: showHotspots.checked ? "1" : "0", inactive: showInactive.checked ? "1" : "0", }); fetch("/api/repeaters?" + params, { signal: controller.signal }) .then(function (resp) { if (!resp.ok) throw new Error("HTTP " + resp.status); return resp.json(); }) .then(function (data) { displayRepeaters(data.repeaters); console.log("Showing " + data.count + " repeaters"); showCount(data.count); }) .catch(function (err) { if (err.name === "AbortError") return; console.error("Fetch error:", err); showStatus("Error"); }); } // === Route mode === function geocode(address) { return fetch( "https://nominatim.openstreetmap.org/search?" + new URLSearchParams({ q: address, format: "json", limit: "1", "accept-language": "de", }) ) .then(function (resp) { return resp.json(); }) .then(function (data) { if (!data.length) throw new Error("Not found: " + address); return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon), }; }); } function getRoute(from, to) { return fetch( "https://router.project-osrm.org/route/v1/driving/" + from.lng + "," + from.lat + ";" + to.lng + "," + to.lat + "?overview=full&geometries=geojson" ) .then(function (resp) { return resp.json(); }) .then(function (data) { if (!data.routes || !data.routes.length) throw new Error("No route found"); // coordinates are [lng, lat], convert to [lat, lng] return data.routes[0].geometry.coordinates.map(function (c) { return [c[1], c[0]]; }); }); } function fetchRouteRepeaters() { if (!routePoints) return; if (getSelectedNetworks() === "none") { markerLayer.clearLayers(); showCount(0); return; } showStatus("Loading..."); return fetch("/api/repeaters/route", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ points: routePoints, band: getSelectedBand(), corridor: 10, network: getSelectedNetworks() === "all" ? [] : getSelectedNetworks().split(","), hotspots: showHotspots.checked, inactive: showInactive.checked, }), }) .then(function (resp) { if (!resp.ok) throw new Error("HTTP " + resp.status); return resp.json(); }) .then(function (data) { displayRepeaters(data.repeaters); console.log("Showing " + data.count + " repeaters along route"); showCount(data.count); }) .catch(function (err) { console.error("Route fetch error:", err); showStatus("Error"); }); } function findRoute() { var fromAddr = fromInput.value.trim(); var toAddr = toInput.value.trim(); if (!fromAddr || !toAddr) return; if (isPinMode) clearPin(); routeBtn.disabled = true; showStatus("Geocoding..."); geocode(fromAddr) .then(function (from) { return geocode(toAddr).then(function (to) { return { from: from, to: to }; }); }) .then(function (endpoints) { showStatus("Routing..."); return getRoute(endpoints.from, endpoints.to); }) .then(function (latLngs) { // Draw route on map if (routeLayer) map.removeLayer(routeLayer); routeLayer = L.polyline(latLngs, { color: "#4CAF50", weight: 4, opacity: 0.8, }).addTo(map); map.fitBounds(routeLayer.getBounds(), { padding: [50, 50] }); // Switch to route mode routePoints = latLngs; isRouteMode = true; clearBtn.style.display = ""; return fetchRouteRepeaters(); }) .catch(function (err) { console.error("Route error:", err); showStatus(err.message); }) .then(function () { routeBtn.disabled = false; }); } function clearRoute() { if (routeLayer) { map.removeLayer(routeLayer); routeLayer = null; } routePoints = null; isRouteMode = false; clearBtn.style.display = "none"; fromInput.value = ""; toInput.value = ""; fetchRepeaters(); } // === Pin mode === function fetchPinRepeaters() { if (!pinLatLng) return; if (getSelectedNetworks() === "none") { markerLayer.clearLayers(); pinListEl.innerHTML = ""; showCount(0); return; } showStatus("Loading..."); var params = new URLSearchParams({ lat: pinLatLng.lat, lng: pinLatLng.lng, radius: pinRadiusInput.value, band: getSelectedBand(), network: getSelectedNetworks(), hotspots: showHotspots.checked ? "1" : "0", inactive: showInactive.checked ? "1" : "0", }); fetch("/api/repeaters/radius?" + params) .then(function (resp) { if (!resp.ok) throw new Error("HTTP " + resp.status); return resp.json(); }) .then(function (data) { displayRepeaters(data.repeaters); showCount(data.count); renderPinList(data.repeaters); }) .catch(function (err) { console.error("Pin fetch error:", err); showStatus("Error"); }); } function renderPinList(repeaters) { pinListEl.innerHTML = ""; repeaters.forEach(function (r) { var item = document.createElement("div"); item.className = "pin-list-item"; var bandColor = r.band === "2m" ? "#1976D2" : "#D32F2F"; item.innerHTML = '' + escapeHtml(r.callsign) + "" + '' + r.freq_tx.toFixed(4) + "" + '' + r.distance + " km"; item.addEventListener("click", function (e) { if (e.target.closest("a")) return; map.setView([r.lat, r.lng], 14); markerLayer.eachLayer(function (layer) { if (layer.getLatLng && layer.getLatLng().lat === r.lat && layer.getLatLng().lng === r.lng) { layer.openPopup(); } }); }); pinListEl.appendChild(item); }); } function placePin(latlng) { // Clear route mode if active if (isRouteMode) clearRoute(); isPinMode = true; pinLatLng = latlng; if (pinMarker) map.removeLayer(pinMarker); if (pinCircle) map.removeLayer(pinCircle); pinMarker = L.marker(latlng).addTo(map); pinCircle = L.circle(latlng, { radius: pinRadiusInput.value * 1000, color: "#4CAF50", weight: 2, fillOpacity: 0.06, }).addTo(map); pinControlsEl.style.display = ""; fetchPinRepeaters(); } function clearPin() { if (pinMarker) { map.removeLayer(pinMarker); pinMarker = null; } if (pinCircle) { map.removeLayer(pinCircle); pinCircle = null; } pinLatLng = null; isPinMode = false; pinControlsEl.style.display = "none"; pinListEl.innerHTML = ""; fetchRepeaters(); } // === Events === map.on("moveend", function () { clearTimeout(debounceTimer); debounceTimer = setTimeout(fetchRepeaters, 150); }); map.on("click", function (e) { placePin(e.latlng); }); function refetchActive() { if (isPinMode) fetchPinRepeaters(); else if (isRouteMode) fetchRouteRepeaters(); else fetchRepeaters(); } band2m.addEventListener("change", refetchActive); band70cm.addEventListener("change", refetchActive); [netBm, netDmrplus, netTgif, netOther].forEach(function (cb) { cb.addEventListener("change", refetchActive); }); showHotspots.addEventListener("change", refetchActive); showInactive.addEventListener("change", refetchActive); pinClearBtn.addEventListener("click", clearPin); pinRadiusInput.addEventListener("change", function () { if (!pinLatLng) return; if (pinCircle) pinCircle.setRadius(pinRadiusInput.value * 1000); fetchPinRepeaters(); }); routeBtn.addEventListener("click", findRoute); clearBtn.addEventListener("click", clearRoute); toInput.addEventListener("keydown", function (e) { if (e.key === "Enter") findRoute(); }); fromInput.addEventListener("keydown", function (e) { if (e.key === "Enter") toInput.focus(); }); // === Init === fetchRepeaters(); })();