Add Visualize LH Heatmap
This commit is contained in:
parent
2f49848496
commit
1e30134886
4 changed files with 179 additions and 0 deletions
136
static/app.js
136
static/app.js
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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
7
static/lib/socket.io.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue