mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-06-29 06:21:42 +02:00
165 lines
4.7 KiB
HTML
165 lines
4.7 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Mesh Nodes Population Heatmap</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<style>
|
|
body { margin: 0; background: #000; }
|
|
#map { height: 100vh; width: 100%; }
|
|
|
|
#legend {
|
|
position: absolute; bottom: 10px; right: 10px;
|
|
background: rgba(0,0,0,0.8);
|
|
color: white; padding: 10px 14px;
|
|
font-family: monospace; font-size: 13px;
|
|
border-radius: 5px; z-index: 1000;
|
|
box-shadow: 0 0 10px rgba(0,0,0,0.6);
|
|
}
|
|
.legend-item { display: flex; align-items: center; margin-bottom: 5px; }
|
|
.legend-color { width: 18px; height: 18px; margin-right: 6px; border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
<div id="legend"></div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet.heat/dist/leaflet-heat.js"></script>
|
|
<script>
|
|
const map = L.map("map");
|
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 19,
|
|
attribution: "© OpenStreetMap"
|
|
}).addTo(map);
|
|
|
|
let heatLayer = null;
|
|
let nodeCoords = [];
|
|
let hoverTooltip = L.tooltip({
|
|
permanent: false,
|
|
direction: "top",
|
|
className: "node-tooltip"
|
|
});
|
|
|
|
// --- Legend ---
|
|
const legend = document.getElementById("legend");
|
|
const legendItems = [
|
|
{ color: "#0000ff", label: "Low" },
|
|
{ color: "#8000ff", label: "Moderate" },
|
|
{ color: "#00ffff", label: "Elevated" },
|
|
{ color: "#00ff00", label: "High" },
|
|
{ color: "#ffff00", label: "Very High" },
|
|
{ color: "#ff0000", label: "Congested?" }
|
|
];
|
|
legendItems.forEach(item => {
|
|
const div = document.createElement("div");
|
|
div.className = "legend-item";
|
|
const colorBox = document.createElement("div");
|
|
colorBox.className = "legend-color";
|
|
colorBox.style.background = item.color;
|
|
const label = document.createElement("span");
|
|
label.textContent = item.label;
|
|
div.appendChild(colorBox);
|
|
div.appendChild(label);
|
|
legend.appendChild(div);
|
|
});
|
|
|
|
// --- Load nodes and create heatmap ---
|
|
async function loadNodes() {
|
|
try {
|
|
const res = await fetch("/api/nodes?days_active=3");
|
|
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
|
const data = await res.json();
|
|
const nodes = data.nodes || [];
|
|
|
|
nodeCoords = [];
|
|
const heatPoints = [];
|
|
nodes.forEach(node => {
|
|
const lat = node.last_lat / 1e7;
|
|
const lng = node.last_long / 1e7;
|
|
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
|
|
nodeCoords.push([lat, lng]);
|
|
heatPoints.push([lat, lng, 1.0]); // equal weight per node
|
|
}
|
|
});
|
|
|
|
if (heatLayer) map.removeLayer(heatLayer);
|
|
heatLayer = L.heatLayer(heatPoints, {
|
|
radius: 18, // smaller circles
|
|
blur: 10, // slightly tighter glow
|
|
maxZoom: 15,
|
|
minOpacity: 0.4,
|
|
gradient: {
|
|
0.0: "#0000ff", // deep blue
|
|
0.2: "#8000ff", // purple
|
|
0.4: "#00ffff", // cyan
|
|
0.6: "#00ff00", // green
|
|
0.8: "#ffff00", // yellow
|
|
0.9: "#ff8000", // orange
|
|
1.0: "#ff0000" // red
|
|
}
|
|
}).addTo(map);
|
|
|
|
await setMapBoundsFromConfig();
|
|
} catch (err) {
|
|
console.error("Failed to load nodes:", err);
|
|
}
|
|
}
|
|
|
|
// --- Map bounds ---
|
|
async function setMapBoundsFromConfig() {
|
|
try {
|
|
const res = await fetch("/api/config");
|
|
const config = await res.json();
|
|
const topLat = parseFloat(config.site.map_top_left_lat);
|
|
const topLon = parseFloat(config.site.map_top_left_lon);
|
|
const bottomLat = parseFloat(config.site.map_bottom_right_lat);
|
|
const bottomLon = parseFloat(config.site.map_bottom_right_lon);
|
|
|
|
if ([topLat, topLon, bottomLat, bottomLon].some(v => isNaN(v))) {
|
|
throw new Error("Map bounds contain NaN");
|
|
}
|
|
|
|
map.fitBounds([[topLat, topLon], [bottomLat, bottomLon]]);
|
|
} catch (err) {
|
|
console.error("Failed to load map bounds from config:", err);
|
|
map.setView([37.77, -122.42], 9);
|
|
}
|
|
}
|
|
|
|
// --- Count nearby nodes ---
|
|
function countNearbyNodes(latlng, radiusMeters) {
|
|
let count = 0;
|
|
const latR = radiusMeters / 111320; // meters per degree lat
|
|
const lngR = radiusMeters / (111320 * Math.cos(latlng.lat * Math.PI / 180));
|
|
|
|
for (const [lat, lng] of nodeCoords) {
|
|
if (Math.abs(lat - latlng.lat) <= latR && Math.abs(lng - latlng.lng) <= lngR)
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
// --- Tooltip on hover ---
|
|
map.on("mousemove", e => {
|
|
if (!nodeCoords.length) return;
|
|
|
|
const zoom = map.getZoom();
|
|
const radiusMeters = 2000 / Math.pow(2, zoom - 10); // dynamic nearness by zoom
|
|
const count = countNearbyNodes(e.latlng, radiusMeters);
|
|
|
|
if (count > 0) {
|
|
hoverTooltip
|
|
.setLatLng(e.latlng)
|
|
.setContent(`${count} nodes nearby (${radiusMeters.toFixed(0)}m radius)`)
|
|
.addTo(map);
|
|
} else {
|
|
map.closeTooltip(hoverTooltip);
|
|
}
|
|
});
|
|
|
|
loadNodes();
|
|
</script>
|
|
</body>
|
|
</html>
|