diff --git a/src/meshcore_hub/web/routes/map.py b/src/meshcore_hub/web/routes/map.py
index c5f982e..91197e9 100644
--- a/src/meshcore_hub/web/routes/map.py
+++ b/src/meshcore_hub/web/routes/map.py
@@ -1,6 +1,7 @@
"""Map page route."""
import logging
+from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
@@ -23,10 +24,31 @@ async def map_page(request: Request) -> HTMLResponse:
@router.get("/map/data")
async def map_data(request: Request) -> JSONResponse:
- """Return node location data as JSON for the map."""
- nodes_with_location = []
+ """Return node location data as JSON for the map.
+
+ Includes role tag, member ownership info, and all data needed for filtering.
+ """
+ nodes_with_location: list[dict[str, Any]] = []
+ members_list: list[dict[str, Any]] = []
+ members_by_key: dict[str, dict[str, Any]] = {}
try:
+ # Fetch all members to build lookup by public_key
+ members_response = await request.app.state.http_client.get(
+ "/api/v1/members", params={"limit": 500}
+ )
+ if members_response.status_code == 200:
+ members_data = members_response.json()
+ for member in members_data.get("items", []):
+ member_info = {
+ "id": member.get("id"),
+ "name": member.get("name"),
+ "callsign": member.get("callsign"),
+ }
+ members_list.append(member_info)
+ if member.get("public_key"):
+ members_by_key[member["public_key"]] = member_info
+
# Fetch all nodes from API
response = await request.app.state.http_client.get(
"/api/v1/nodes", params={"limit": 500}
@@ -41,6 +63,8 @@ async def map_data(request: Request) -> JSONResponse:
lat = None
lon = None
friendly_name = None
+ role = None
+
for tag in tags:
key = tag.get("key")
if key == "lat":
@@ -55,6 +79,8 @@ async def map_data(request: Request) -> JSONResponse:
pass
elif key == "friendly_name":
friendly_name = tag.get("value")
+ elif key == "role":
+ role = tag.get("value")
if lat is not None and lon is not None:
# Use friendly_name, then node name, then public key prefix
@@ -63,14 +89,22 @@ async def map_data(request: Request) -> JSONResponse:
or node.get("name")
or node.get("public_key", "")[:12]
)
+ public_key = node.get("public_key")
+
+ # Find owner member if exists
+ owner = members_by_key.get(public_key)
+
nodes_with_location.append(
{
- "public_key": node.get("public_key"),
+ "public_key": public_key,
"name": display_name,
"adv_type": node.get("adv_type"),
"lat": lat,
"lon": lon,
"last_seen": node.get("last_seen"),
+ "role": role,
+ "is_infra": role == "infra",
+ "owner": owner,
}
)
@@ -83,6 +117,7 @@ async def map_data(request: Request) -> JSONResponse:
return JSONResponse(
{
"nodes": nodes_with_location,
+ "members": members_list,
"center": {
"lat": network_location[0],
"lon": network_location[1],
diff --git a/src/meshcore_hub/web/templates/map.html b/src/meshcore_hub/web/templates/map.html
index 0480b14..127a97a 100644
--- a/src/meshcore_hub/web/templates/map.html
+++ b/src/meshcore_hub/web/templates/map.html
@@ -5,7 +5,7 @@
{% block extra_head %}
{% endblock %}
{% block content %}
-
Nodes are placed on the map based on their lat and lon tags.
-
To add a node to the map, set its location tags via the API.
+
+
+
+
+
Nodes are placed on the map based on their lat and lon tags. Infrastructure nodes are tagged with role: infra.
{% endblock %}
@@ -47,53 +125,182 @@
attribution: '©
OpenStreetMap contributors'
}).addTo(map);
- // Custom marker icon
- const nodeIcon = L.divIcon({
- className: 'custom-div-icon',
- html: `
`,
- iconSize: [12, 12],
- iconAnchor: [6, 6]
- });
+ // Store all nodes and markers
+ let allNodes = [];
+ let allMembers = [];
+ let markers = [];
+ let mapCenter = { lat: {{ network_location[0] }}, lon: {{ network_location[1] }} };
+
+ // Get marker color class based on node type
+ function getMarkerClass(node) {
+ let baseClass = 'marker-default';
+ if (node.adv_type === 'chat') baseClass = 'marker-chat';
+ else if (node.adv_type === 'repeater') baseClass = 'marker-repeater';
+ else if (node.adv_type === 'room') baseClass = 'marker-room';
+
+ if (node.is_infra) baseClass += ' marker-infra';
+ return baseClass;
+ }
+
+ // Create marker icon for a node
+ function createNodeIcon(node) {
+ const markerClass = getMarkerClass(node);
+ return L.divIcon({
+ className: 'custom-div-icon',
+ html: `
`,
+ iconSize: [12, 12],
+ iconAnchor: [6, 6]
+ });
+ }
+
+ // Create popup content for a node
+ function createPopupContent(node) {
+ let ownerHtml = '';
+ if (node.owner) {
+ const ownerDisplay = node.owner.callsign
+ ? `${node.owner.name} (${node.owner.callsign})`
+ : node.owner.name;
+ ownerHtml = `
Owner: ${ownerDisplay}
`;
+ }
+
+ let roleHtml = '';
+ if (node.role) {
+ const roleClass = node.is_infra ? 'badge-warning' : 'badge-ghost';
+ roleHtml = `
Role: ${node.role}
`;
+ }
+
+ return `
+
+
${node.name}
+
+
Type: ${node.adv_type || 'Unknown'}
+ ${roleHtml}
+ ${ownerHtml}
+
Key: ${node.public_key.substring(0, 16)}...
+
Location: ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}
+ ${node.last_seen ? `
Last seen: ${node.last_seen.substring(0, 19).replace('T', ' ')}
` : ''}
+
+
View Details
+
+ `;
+ }
+
+ // Clear all markers from map
+ function clearMarkers() {
+ markers.forEach(marker => map.removeLayer(marker));
+ markers = [];
+ }
+
+ // Apply filters and update map
+ function applyFilters() {
+ const typeFilter = document.getElementById('filter-type').value;
+ const ownerFilter = document.getElementById('filter-owner').value;
+ const infraOnly = document.getElementById('filter-infra').checked;
+
+ // Filter nodes
+ const filteredNodes = allNodes.filter(node => {
+ // Type filter
+ if (typeFilter && node.adv_type !== typeFilter) return false;
+
+ // Infrastructure filter
+ if (infraOnly && !node.is_infra) return false;
+
+ // Owner filter
+ if (ownerFilter) {
+ if (!node.owner || node.owner.id !== ownerFilter) return false;
+ }
+
+ return true;
+ });
+
+ // Clear existing markers
+ clearMarkers();
+
+ // Add filtered markers
+ filteredNodes.forEach(node => {
+ const marker = L.marker([node.lat, node.lon], { icon: createNodeIcon(node) }).addTo(map);
+ marker.bindPopup(createPopupContent(node));
+ markers.push(marker);
+ });
+
+ // Update counts
+ const countEl = document.getElementById('node-count');
+ const filteredEl = document.getElementById('filtered-count');
+
+ if (filteredNodes.length === allNodes.length) {
+ countEl.textContent = `${allNodes.length} nodes on map`;
+ filteredEl.classList.add('hidden');
+ } else {
+ countEl.textContent = `${allNodes.length} total`;
+ filteredEl.textContent = `${filteredNodes.length} shown`;
+ filteredEl.classList.remove('hidden');
+ }
+
+ // Fit bounds if we have filtered nodes
+ if (filteredNodes.length > 0) {
+ const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon]));
+ map.fitBounds(bounds, { padding: [50, 50] });
+ } else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
+ map.setView([mapCenter.lat, mapCenter.lon], 10);
+ }
+ }
+
+ // Populate owner filter dropdown
+ function populateOwnerFilter() {
+ const select = document.getElementById('filter-owner');
+
+ // Get unique owners from nodes that have locations
+ const ownersWithNodes = new Set();
+ allNodes.forEach(node => {
+ if (node.owner) {
+ ownersWithNodes.add(node.owner.id);
+ }
+ });
+
+ // Filter members to only those who own nodes on the map
+ const relevantMembers = allMembers.filter(m => ownersWithNodes.has(m.id));
+
+ // Sort by name
+ relevantMembers.sort((a, b) => a.name.localeCompare(b.name));
+
+ // Add options
+ relevantMembers.forEach(member => {
+ const option = document.createElement('option');
+ option.value = member.id;
+ option.textContent = member.callsign
+ ? `${member.name} (${member.callsign})`
+ : member.name;
+ select.appendChild(option);
+ });
+ }
+
+ // Clear all filters
+ function clearFilters() {
+ document.getElementById('filter-type').value = '';
+ document.getElementById('filter-owner').value = '';
+ document.getElementById('filter-infra').checked = false;
+ applyFilters();
+ }
+
+ // Event listeners for filters
+ document.getElementById('filter-type').addEventListener('change', applyFilters);
+ document.getElementById('filter-owner').addEventListener('change', applyFilters);
+ document.getElementById('filter-infra').addEventListener('change', applyFilters);
+ document.getElementById('clear-filters').addEventListener('click', clearFilters);
// Fetch and display nodes
fetch('/map/data')
.then(response => response.json())
.then(data => {
- const nodes = data.nodes;
- const center = data.center;
+ allNodes = data.nodes;
+ allMembers = data.members || [];
+ mapCenter = data.center;
- // Update node count
- document.getElementById('node-count').textContent = `${nodes.length} nodes on map`;
+ // Populate owner filter
+ populateOwnerFilter();
- // Add markers for each node
- nodes.forEach(node => {
- const marker = L.marker([node.lat, node.lon], { icon: nodeIcon }).addTo(map);
-
- // Create popup content
- const popupContent = `
-
-
${node.name}
-
-
Type: ${node.adv_type || 'Unknown'}
-
Key: ${node.public_key.substring(0, 16)}...
-
Location: ${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}
- ${node.last_seen ? `
Last seen: ${node.last_seen.substring(0, 19).replace('T', ' ')}
` : ''}
-
-
View Details
-
- `;
-
- marker.bindPopup(popupContent);
- });
-
- // Fit bounds if we have nodes
- if (nodes.length > 0) {
- const bounds = L.latLngBounds(nodes.map(n => [n.lat, n.lon]));
- map.fitBounds(bounds, { padding: [50, 50] });
- } else if (center.lat !== 0 || center.lon !== 0) {
- // Use network center if no nodes
- map.setView([center.lat, center.lon], 10);
- }
+ // Initial display
+ applyFilters();
})
.catch(error => {
console.error('Error loading map data:', error);