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 %}

Node Map

- Loading... +
+ Loading... + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+
@@ -31,9 +85,33 @@
-
-

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.

+ +
+ Legend: +
+
+ Chat +
+
+
+ Repeater +
+
+
+ Room +
+
+
+ Other +
+
+
+ Infrastructure +
+
+ +
+

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);