Add Visualize LH Heatmap

This commit is contained in:
Marcus Kida 2026-02-10 00:29:10 +01:00
parent 2f49848496
commit 1e30134886
4 changed files with 179 additions and 0 deletions

View file

@ -10,6 +10,7 @@
}).addTo(map); }).addTo(map);
// === State === // === State ===
var heatmapGlowLayer = L.layerGroup().addTo(map);
var markerLayer = L.layerGroup().addTo(map); var markerLayer = L.layerGroup().addTo(map);
var routeLayer = null; var routeLayer = null;
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
@ -20,6 +21,12 @@
var pinLatLng = null; var pinLatLng = null;
var debounceTimer = null; var debounceTimer = null;
var controller = null; var controller = null;
var heatmapSocket = null;
var heatmapCounts = {};
var heatmapMarkerMap = {};
var heatmapGlowMarkers = {};
var heatmapMaxCount = 1;
var heatmapDecayTimer = null;
// === DOM === // === DOM ===
var band2m = document.getElementById("band-2m"); var band2m = document.getElementById("band-2m");
@ -30,6 +37,7 @@
var netOther = document.getElementById("net-other"); var netOther = document.getElementById("net-other");
var showHotspots = document.getElementById("show-hotspots"); var showHotspots = document.getElementById("show-hotspots");
var showInactive = document.getElementById("show-inactive"); var showInactive = document.getElementById("show-inactive");
var showHeatmap = document.getElementById("show-heatmap");
var countEl = document.getElementById("count"); var countEl = document.getElementById("count");
var fromInput = document.getElementById("route-from"); var fromInput = document.getElementById("route-from");
var toInput = document.getElementById("route-to"); var toInput = document.getElementById("route-to");
@ -278,6 +286,9 @@
// === Display markers === // === Display markers ===
function displayRepeaters(repeaters) { function displayRepeaters(repeaters) {
markerLayer.clearLayers(); markerLayer.clearLayers();
heatmapMarkerMap = {};
heatmapGlowLayer.clearLayers();
heatmapGlowMarkers = {};
repeaters.forEach(function (r) { repeaters.forEach(function (r) {
var color = r.band === "2m" ? "#2196F3" : "#D32F2F"; var color = r.band === "2m" ? "#2196F3" : "#D32F2F";
var marker = L.circleMarker([r.lat, r.lng], { var marker = L.circleMarker([r.lat, r.lng], {
@ -289,6 +300,10 @@
}); });
marker.bindPopup(buildPopup(r), { maxWidth: 280 }); marker.bindPopup(buildPopup(r), { maxWidth: 280 });
markerLayer.addLayer(marker); markerLayer.addLayer(marker);
heatmapMarkerMap[r.id] = marker;
if (showHeatmap.checked && heatmapCounts[r.id]) {
applyHeatmapGlow(r.id, r.lat, r.lng);
}
}); });
} }
@ -567,6 +582,122 @@
fetchRepeaters(); fetchRepeaters();
} }
// === Heatmap ===
function heatColor(intensity) {
var r, g;
if (intensity < 0.5) {
r = Math.round(255 * (intensity * 2));
g = 255;
} else {
r = 255;
g = Math.round(255 * (1 - (intensity - 0.5) * 2));
}
return "rgb(" + r + "," + g + ",0)";
}
function applyHeatmapGlow(repeaterId, lat, lng) {
var count = heatmapCounts[repeaterId] || 0;
if (count === 0) return;
var intensity = Math.min(count / Math.max(heatmapMaxCount, 1), 1.0);
var glowRadius = 8 + intensity * 16;
var color = heatColor(intensity);
if (heatmapGlowMarkers[repeaterId]) {
heatmapGlowLayer.removeLayer(heatmapGlowMarkers[repeaterId]);
}
var glow = L.circleMarker([lat, lng], {
radius: glowRadius,
fillColor: color,
color: color,
weight: 2,
fillOpacity: 0.25 + intensity * 0.2,
opacity: 0.6 + intensity * 0.4,
className: "heatmap-glow",
interactive: false,
});
heatmapGlowLayer.addLayer(glow);
heatmapGlowMarkers[repeaterId] = glow;
}
function clearHeatmapVisuals() {
heatmapGlowLayer.clearLayers();
heatmapGlowMarkers = {};
}
function refreshAllGlows() {
clearHeatmapVisuals();
for (var id in heatmapCounts) {
if (heatmapMarkerMap[id]) {
var latlng = heatmapMarkerMap[id].getLatLng();
applyHeatmapGlow(parseInt(id), latlng.lat, latlng.lng);
}
}
}
function connectHeatmap() {
if (heatmapSocket) return;
heatmapSocket = io("https://api.brandmeister.network", {
path: "/lh/socket.io",
transports: ["websocket"],
});
heatmapSocket.on("connect", function () {
console.log("Heatmap: connected to BrandMeister LH");
});
heatmapSocket.on("disconnect", function () {
console.log("Heatmap: disconnected from BrandMeister LH");
});
heatmapSocket.on("mqtt", function (data) {
try {
var payload = typeof data.payload === "string"
? JSON.parse(data.payload)
: data.payload;
if (payload.Event !== "Session-Stop") return;
var contextId = payload.ContextID;
if (!contextId || !heatmapMarkerMap[contextId]) return;
heatmapCounts[contextId] = (heatmapCounts[contextId] || 0) + 1;
if (heatmapCounts[contextId] > heatmapMaxCount) {
heatmapMaxCount = heatmapCounts[contextId];
refreshAllGlows();
} else {
var latlng = heatmapMarkerMap[contextId].getLatLng();
applyHeatmapGlow(contextId, latlng.lat, latlng.lng);
}
} catch (e) { /* ignore malformed payloads */ }
});
heatmapDecayTimer = setInterval(function () {
var changed = false;
for (var id in heatmapCounts) {
heatmapCounts[id] = Math.floor(heatmapCounts[id] * 0.8);
if (heatmapCounts[id] === 0) {
delete heatmapCounts[id];
}
changed = true;
}
if (changed) {
heatmapMaxCount = 1;
for (var id2 in heatmapCounts) {
if (heatmapCounts[id2] > heatmapMaxCount) {
heatmapMaxCount = heatmapCounts[id2];
}
}
refreshAllGlows();
}
}, 60000);
}
function disconnectHeatmap() {
if (heatmapSocket) {
heatmapSocket.disconnect();
heatmapSocket = null;
}
if (heatmapDecayTimer) {
clearInterval(heatmapDecayTimer);
heatmapDecayTimer = null;
}
heatmapCounts = {};
heatmapMaxCount = 1;
clearHeatmapVisuals();
}
// === Events === // === Events ===
map.on("moveend", function () { map.on("moveend", function () {
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
@ -593,6 +724,11 @@
showHotspots.addEventListener("change", refetchActive); showHotspots.addEventListener("change", refetchActive);
showInactive.addEventListener("change", refetchActive); showInactive.addEventListener("change", refetchActive);
showHeatmap.addEventListener("change", function () {
if (showHeatmap.checked) connectHeatmap();
else disconnectHeatmap();
});
pinClearBtn.addEventListener("click", clearPin); pinClearBtn.addEventListener("click", clearPin);
pinRadiusInput.addEventListener("change", function () { pinRadiusInput.addEventListener("change", function () {

View file

@ -26,6 +26,10 @@
<label class="hotspot-label"><input type="checkbox" id="show-hotspots"> Don't hide personal hotspots</label> <label class="hotspot-label"><input type="checkbox" id="show-hotspots"> Don't hide personal hotspots</label>
<label class="hotspot-label"><input type="checkbox" id="show-inactive"> Show inactive repeaters</label> <label class="hotspot-label"><input type="checkbox" id="show-inactive"> Show inactive repeaters</label>
</details> </details>
<details class="experimental">
<summary>Experimental</summary>
<label class="hotspot-label"><input type="checkbox" id="show-heatmap"> Visualize Last Heard Heatmap</label>
</details>
<div class="controls-row route-row autocomplete-wrap"> <div class="controls-row route-row autocomplete-wrap">
<input type="text" id="route-from" placeholder="From address" autocomplete="off" /> <input type="text" id="route-from" placeholder="From address" autocomplete="off" />
<ul class="autocomplete-list" id="ac-from"></ul> <ul class="autocomplete-list" id="ac-from"></ul>
@ -58,6 +62,7 @@
</div> </div>
</div> </div>
<script src="lib/leaflet.js"></script> <script src="lib/leaflet.js"></script>
<script src="lib/socket.io.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

7
static/lib/socket.io.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -201,6 +201,27 @@ html, body {
cursor: pointer; cursor: pointer;
} }
.experimental {
margin-top: 4px;
font-size: 11px;
color: var(--text-fainter);
}
.experimental summary {
cursor: pointer;
user-select: none;
font-size: 10px;
letter-spacing: 0.03em;
}
.experimental summary:hover {
color: var(--text-faint);
}
.experimental[open] {
padding-bottom: 2px;
}
.count { .count {
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 12px;
@ -488,6 +509,16 @@ html, body {
background: #D32F2F; background: #D32F2F;
} }
@keyframes heatmap-pulse {
0% { opacity: 0.8; }
50% { opacity: 0.3; }
100% { opacity: 0.8; }
}
.heatmap-glow {
animation: heatmap-pulse 2s ease-in-out infinite;
}
@media (max-width: 768px) { @media (max-width: 768px) {
#controls { #controls {
top: 10px; top: 10px;