From cf4e82503aad4cf509c9bd71e7d75e0b0d46d9d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Dec 2025 18:29:43 +0000 Subject: [PATCH 1/3] Add filters to map page for node type, infrastructure, and owner - Enhanced /map/data endpoint to include node role tag and member ownership - Added client-side filtering for node type (chat, repeater, room) - Added toggle to filter for infrastructure nodes only (role: infra) - Added dropdown filter for member owner (nodes linked via public_key) - Color-coded markers by node type with gold border for infrastructure - Added legend showing marker types - Dynamic count display showing total vs filtered nodes --- src/meshcore_hub/web/routes/map.py | 41 +++- src/meshcore_hub/web/templates/map.html | 297 ++++++++++++++++++++---- 2 files changed, 290 insertions(+), 48 deletions(-) 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); From fe744c7c0cb710e533700e1fb2292d8381b80f59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Dec 2025 18:34:24 +0000 Subject: [PATCH 2/3] Fix map markers to use inline styles and center on nodes - Use inline styles for marker colors instead of CSS classes for reliable rendering - Center map on node locations when data is first loaded - Refactor filter logic to separate recentering behavior - Update legend to use inline styles --- src/meshcore_hub/web/templates/map.html | 74 +++++++++++++------------ 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/meshcore_hub/web/templates/map.html b/src/meshcore_hub/web/templates/map.html index 127a97a..445a5f7 100644 --- a/src/meshcore_hub/web/templates/map.html +++ b/src/meshcore_hub/web/templates/map.html @@ -16,22 +16,6 @@ .leaflet-popup-tip { background: oklch(var(--b1)); } - .marker-chat { - background-color: oklch(var(--p)); - } - .marker-repeater { - background-color: oklch(var(--s)); - } - .marker-room { - background-color: oklch(var(--a)); - } - .marker-default { - background-color: oklch(var(--n)); - } - .marker-infra { - border: 3px solid gold !important; - box-shadow: 0 0 8px gold, 0 2px 4px rgba(0,0,0,0.3) !important; - } {% endblock %} @@ -89,23 +73,23 @@
Legend:
-
+
Chat
-
+
Repeater
-
+
Room
-
+
Other
-
+
Infrastructure
@@ -131,23 +115,23 @@ 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; + // Get marker color based on node type + function getMarkerColor(node) { + if (node.adv_type === 'chat') return 'oklch(var(--p))'; + if (node.adv_type === 'repeater') return 'oklch(var(--s))'; + if (node.adv_type === 'room') return 'oklch(var(--a))'; + return 'oklch(var(--n))'; } // Create marker icon for a node function createNodeIcon(node) { - const markerClass = getMarkerClass(node); + const color = getMarkerColor(node); + const infraStyle = node.is_infra + ? 'border: 3px solid gold; box-shadow: 0 0 8px gold, 0 2px 4px rgba(0,0,0,0.3);' + : 'border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);'; return L.divIcon({ className: 'custom-div-icon', - html: `
`, + html: `
`, iconSize: [12, 12], iconAnchor: [6, 6] }); @@ -191,8 +175,8 @@ markers = []; } - // Apply filters and update map - function applyFilters() { + // Core filter logic - returns filtered nodes and updates markers + function applyFiltersCore() { const typeFilter = document.getElementById('filter-type').value; const ownerFilter = document.getElementById('filter-owner').value; const infraOnly = document.getElementById('filter-infra').checked; @@ -236,6 +220,13 @@ filteredEl.classList.remove('hidden'); } + return filteredNodes; + } + + // Apply filters and recenter map on filtered nodes + function applyFilters() { + const filteredNodes = applyFiltersCore(); + // Fit bounds if we have filtered nodes if (filteredNodes.length > 0) { const bounds = L.latLngBounds(filteredNodes.map(n => [n.lat, n.lon])); @@ -245,6 +236,11 @@ } } + // Apply filters without recentering (for initial load after manual center) + function applyFiltersNoRecenter() { + applyFiltersCore(); + } + // Populate owner filter dropdown function populateOwnerFilter() { const select = document.getElementById('filter-owner'); @@ -299,8 +295,14 @@ // Populate owner filter populateOwnerFilter(); - // Initial display - applyFilters(); + // Initial display - center map on nodes if available + if (allNodes.length > 0) { + const bounds = L.latLngBounds(allNodes.map(n => [n.lat, n.lon])); + map.fitBounds(bounds, { padding: [50, 50] }); + } + + // Apply filters (won't re-center since we just did above) + applyFiltersNoRecenter(); }) .catch(error => { console.error('Error loading map data:', error); From bb0b9f05ec4ffd60e066c83ec12e3ffa9e0f3a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Dec 2025 18:37:50 +0000 Subject: [PATCH 3/3] Add debug info to map data endpoint for troubleshooting - Return total_nodes, nodes_with_coords, and error in response - Display meaningful messages when no nodes or no coordinates found - Log API errors and node counts for debugging --- src/meshcore_hub/web/routes/map.py | 22 ++++++++++++++++++++++ src/meshcore_hub/web/templates/map.html | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/meshcore_hub/web/routes/map.py b/src/meshcore_hub/web/routes/map.py index 91197e9..b93691c 100644 --- a/src/meshcore_hub/web/routes/map.py +++ b/src/meshcore_hub/web/routes/map.py @@ -31,6 +31,9 @@ async def map_data(request: Request) -> JSONResponse: nodes_with_location: list[dict[str, Any]] = [] members_list: list[dict[str, Any]] = [] members_by_key: dict[str, dict[str, Any]] = {} + error: str | None = None + total_nodes = 0 + nodes_with_coords = 0 try: # Fetch all members to build lookup by public_key @@ -48,6 +51,10 @@ async def map_data(request: Request) -> JSONResponse: members_list.append(member_info) if member.get("public_key"): members_by_key[member["public_key"]] = member_info + else: + logger.warning( + f"Failed to fetch members: status {members_response.status_code}" + ) # Fetch all nodes from API response = await request.app.state.http_client.get( @@ -56,6 +63,7 @@ async def map_data(request: Request) -> JSONResponse: if response.status_code == 200: data = response.json() nodes = data.get("items", []) + total_nodes = len(nodes) # Filter nodes with location tags for node in nodes: @@ -83,6 +91,7 @@ async def map_data(request: Request) -> JSONResponse: role = tag.get("value") if lat is not None and lon is not None: + nodes_with_coords += 1 # Use friendly_name, then node name, then public key prefix display_name = ( friendly_name @@ -107,13 +116,21 @@ async def map_data(request: Request) -> JSONResponse: "owner": owner, } ) + else: + error = f"API returned status {response.status_code}" + logger.warning(f"Failed to fetch nodes: {error}") except Exception as e: + error = str(e) logger.warning(f"Failed to fetch nodes for map: {e}") # Get network center location network_location = request.app.state.network_location + logger.info( + f"Map data: {total_nodes} total nodes, " f"{nodes_with_coords} with coordinates" + ) + return JSONResponse( { "nodes": nodes_with_location, @@ -122,5 +139,10 @@ async def map_data(request: Request) -> JSONResponse: "lat": network_location[0], "lon": network_location[1], }, + "debug": { + "total_nodes": total_nodes, + "nodes_with_coords": nodes_with_coords, + "error": error, + }, } ) diff --git a/src/meshcore_hub/web/templates/map.html b/src/meshcore_hub/web/templates/map.html index 445a5f7..994df55 100644 --- a/src/meshcore_hub/web/templates/map.html +++ b/src/meshcore_hub/web/templates/map.html @@ -292,6 +292,25 @@ allMembers = data.members || []; mapCenter = data.center; + // Log debug info + const debug = data.debug || {}; + console.log('Map data loaded:', debug); + + if (debug.error) { + document.getElementById('node-count').textContent = `Error: ${debug.error}`; + return; + } + + if (debug.total_nodes === 0) { + document.getElementById('node-count').textContent = 'No nodes in database'; + return; + } + + if (debug.nodes_with_coords === 0) { + document.getElementById('node-count').textContent = `${debug.total_nodes} nodes (none have coordinates)`; + return; + } + // Populate owner filter populateOwnerFilter();