From b6f3b2d8645e96f99d837dd762026a247ca79fc1 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 8 Feb 2026 23:16:13 +0000 Subject: [PATCH] Redesign node detail page with hero map header - Add hero panel with non-interactive map background when GPS coords exist - Fix coordinate detection: check node model fields before falling back to tags - Move node name to standard page header above hero panel - QR code displayed in hero panel (right side, 140px) - Map pans to show node at 1/3 horizontal position (avoiding QR overlap) - Replace Telemetry section with Tags card in grid layout - Consolidate First Seen, Last Seen, Location into single row - Add configurable offset support to map-node.js (offsetX, offsetY) - Add configurable size support to qrcode-init.js --- src/meshcore_hub/web/static/css/app.css | 16 ++ src/meshcore_hub/web/static/js/map-node.js | 114 ++++++--- src/meshcore_hub/web/static/js/qrcode-init.js | 6 +- .../web/templates/node_detail.html | 227 ++++++++---------- 4 files changed, 204 insertions(+), 159 deletions(-) diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index af89427..edb0e0f 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -347,3 +347,19 @@ footer { .leaflet-marker-icon:hover { z-index: 10000 !important; } + +/* ========================================================================== + Node Header Hero Map Background + ========================================================================== */ + +#header-map { + height: 100%; + width: 100%; + z-index: 0; +} + +/* Ensure Leaflet elements stay within the map layer */ +#header-map .leaflet-pane, +#header-map .leaflet-control { + z-index: auto !important; +} diff --git a/src/meshcore_hub/web/static/js/map-node.js b/src/meshcore_hub/web/static/js/map-node.js index 3a55cf3..9104d53 100644 --- a/src/meshcore_hub/web/static/js/map-node.js +++ b/src/meshcore_hub/web/static/js/map-node.js @@ -10,6 +10,12 @@ * - name: Node display name (required) * - type: Node adv_type (optional) * - publicKey: Node public key (optional, for linking) + * - elementId: Map container element ID (default: 'node-map') + * - interactive: Enable map interactions (default: true) + * - zoom: Initial zoom level (default: 15) + * - showMarker: Show node marker (default: true) + * - offsetX: Horizontal position of node (0-1, default: 0.5 = center) + * - offsetY: Vertical position of node (0-1, default: 0.5 = center) */ (function() { @@ -26,54 +32,90 @@ var nodeLon = config.lon; var nodeName = config.name || 'Unnamed Node'; var nodeType = config.type || ''; + var elementId = config.elementId || 'node-map'; + var interactive = config.interactive !== false; // Default true + var zoomLevel = config.zoom || 15; + var showMarker = config.showMarker !== false; // Default true + var offsetX = typeof config.offsetX === 'number' ? config.offsetX : 0.5; // 0-1, default center + var offsetY = typeof config.offsetY === 'number' ? config.offsetY : 0.5; // 0-1, default center // Check if map container exists - var mapContainer = document.getElementById('node-map'); + var mapContainer = document.getElementById(elementId); if (!mapContainer) { return; } + // Build map options + var mapOptions = {}; + + // Disable interactions if non-interactive + if (!interactive) { + mapOptions.dragging = false; + mapOptions.touchZoom = false; + mapOptions.scrollWheelZoom = false; + mapOptions.doubleClickZoom = false; + mapOptions.boxZoom = false; + mapOptions.keyboard = false; + mapOptions.zoomControl = false; + mapOptions.attributionControl = false; + } + // Initialize map centered on the node's location - var map = L.map('node-map').setView([nodeLat, nodeLon], 15); + var map = L.map(elementId, mapOptions).setView([nodeLat, nodeLon], zoomLevel); + + // Apply offset to position node at specified location instead of center + // offsetX/Y of 0.5 = center (no pan), 0.33 = 1/3 from left/top + if (offsetX !== 0.5 || offsetY !== 0.5) { + var containerWidth = mapContainer.offsetWidth; + var containerHeight = mapContainer.offsetHeight; + // Pan amount: how far to move the map so node appears at offset position + // Positive X = pan right (node moves left), Positive Y = pan down (node moves up) + var panX = (0.5 - offsetX) * containerWidth; + var panY = (0.5 - offsetY) * containerHeight; + map.panBy([panX, panY], { animate: false }); + } // Add tile layer L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map); - /** - * Get emoji marker based on node type - */ - function getNodeEmoji(type) { - var normalizedType = type ? type.toLowerCase() : null; - if (normalizedType === 'chat') return '💬'; - if (normalizedType === 'repeater') return '📡'; - if (normalizedType === 'room') return '🪧'; - return '📍'; + // Only add marker if showMarker is true + if (showMarker) { + /** + * Get emoji marker based on node type + */ + function getNodeEmoji(type) { + var normalizedType = type ? type.toLowerCase() : null; + if (normalizedType === 'chat') return '💬'; + if (normalizedType === 'repeater') return '📡'; + if (normalizedType === 'room') return '🪧'; + return '📍'; + } + + // Create marker icon (just the emoji, no label) + var emoji = getNodeEmoji(nodeType); + var icon = L.divIcon({ + className: 'custom-div-icon', + html: '' + emoji + '', + iconSize: [32, 32], + iconAnchor: [16, 16] + }); + + // Add marker + var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map); + + // Only add popup if map is interactive + if (interactive) { + var typeHtml = nodeType ? '

Type: ' + nodeType + '

' : ''; + var popupContent = '
' + + '

' + emoji + ' ' + nodeName + '

' + + '
' + + typeHtml + + '

Coordinates: ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '

' + + '
' + + '
'; + marker.bindPopup(popupContent); + } } - - // Create marker icon (just the emoji, no label) - var emoji = getNodeEmoji(nodeType); - var icon = L.divIcon({ - className: 'custom-div-icon', - html: '' + emoji + '', - iconSize: [32, 32], - iconAnchor: [16, 16] - }); - - // Add marker - var marker = L.marker([nodeLat, nodeLon], { icon: icon }).addTo(map); - - // Build popup content - var typeHtml = nodeType ? '

Type: ' + nodeType + '

' : ''; - var popupContent = '
' + - '

' + emoji + ' ' + nodeName + '

' + - '
' + - typeHtml + - '

Coordinates: ' + nodeLat.toFixed(4) + ', ' + nodeLon.toFixed(4) + '

' + - '
' + - '
'; - - // Add popup (shown on click, not by default) - marker.bindPopup(popupContent); })(); diff --git a/src/meshcore_hub/web/static/js/qrcode-init.js b/src/meshcore_hub/web/static/js/qrcode-init.js index 2c47cb4..6ae8759 100644 --- a/src/meshcore_hub/web/static/js/qrcode-init.js +++ b/src/meshcore_hub/web/static/js/qrcode-init.js @@ -9,6 +9,7 @@ * - publicKey: 64-char hex public key (required) * - advType: Node advertisement type (optional) * - containerId: ID of container element (default: 'qr-code') + * - size: QR code size in pixels (default: 128) */ (function() { @@ -25,6 +26,7 @@ var publicKey = config.publicKey; var advType = config.advType || ''; var containerId = config.containerId || 'qr-code'; + var size = config.size || 128; // Map adv_type to numeric type for meshcore:// protocol var typeMap = { @@ -46,8 +48,8 @@ try { new QRCode(qrContainer, { text: meshcoreUrl, - width: 256, - height: 256, + width: size, + height: size, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.L diff --git a/src/meshcore_hub/web/templates/node_detail.html b/src/meshcore_hub/web/templates/node_detail.html index a1673e9..e015649 100644 --- a/src/meshcore_hub/web/templates/node_detail.html +++ b/src/meshcore_hub/web/templates/node_detail.html @@ -34,105 +34,93 @@ {% endif %} {% if node %} +{# Get display name from tag or node.name #} {% set ns = namespace(tag_name=none) %} {% for tag in node.tags or [] %} {% if tag.key == 'name' %} {% set ns.tag_name = tag.value %} {% endif %} {% endfor %} - +{% set display_name = ns.tag_name or node.name or 'Unnamed Node' %} + +{# Get coordinates from node model first, then fall back to tags (bug fix) #} +{% set ns_coords = namespace(lat=node.lat, lon=node.lon) %} +{% if not ns_coords.lat or not ns_coords.lon %} + {% for tag in node.tags or [] %} + {% if tag.key == 'lat' and not ns_coords.lat %} + {% set ns_coords.lat = tag.value|float %} + {% elif tag.key == 'lon' and not ns_coords.lon %} + {% set ns_coords.lon = tag.value|float %} + {% endif %} + {% endfor %} +{% endif %} +{% set has_coords = ns_coords.lat is not none and ns_coords.lon is not none %} + +{# Node type emoji #} +{% set type_emoji = '📍' %} +{% if node.adv_type %} + {% if node.adv_type|lower == 'chat' %} + {% set type_emoji = '💬' %} + {% elif node.adv_type|lower == 'repeater' %} + {% set type_emoji = '📡' %} + {% elif node.adv_type|lower == 'room' %} + {% set type_emoji = '🪧' %} + {% endif %} +{% endif %} + + +

+ {{ type_emoji }} + {{ display_name }} +

+ + +{% if has_coords %} +
+ +
+ + +
+
+
+
+{% else %} + +
+
+
+

Scan to add as contact

+
+
+{% endif %} + +
- -
-

- {% if node.adv_type %} - {% if node.adv_type|lower == 'chat' %} - 💬 - {% elif node.adv_type|lower == 'repeater' %} - 📡 - {% elif node.adv_type|lower == 'room' %} - 🪧 - {% else %} - 📍 - {% endif %} - {% endif %} - {{ ns.tag_name or node.name or 'Unnamed Node' }} -

-
-

First seen: {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}

-

Last seen: {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}

-
+ +
+

Public Key

+ {{ node.public_key }}
- {% set ns_map = namespace(lat=none, lon=none) %} - {% for tag in node.tags or [] %} - {% if tag.key == 'lat' %} - {% set ns_map.lat = tag.value %} - {% elif tag.key == 'lon' %} - {% set ns_map.lon = tag.value %} - {% endif %} - {% endfor %} - - -
- + +
-

Public Key

- {{ node.public_key }} -
-
-

Scan to add as contact

-
+ First seen: + {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}
- - - {% if ns_map.lat and ns_map.lon %}
-

Location

-
-
-

Coordinates: {{ ns_map.lat }}, {{ ns_map.lon }}

-
+ Last seen: + {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }} +
+ {% if has_coords %} +
+ Location: + {{ ns_coords.lat }}, {{ ns_coords.lon }}
{% endif %}
- - - {% if node.tags or (admin_enabled and is_authenticated) %} -
-

Tags

- {% if node.tags %} -
- - - - - - - - - - {% for tag in node.tags %} - - - - - - {% endfor %} - -
KeyValueType
{{ tag.key }}{{ tag.value }}{{ tag.value_type or 'string' }}
-
- {% else %} -

No tags defined.

- {% endif %} - {% if admin_enabled and is_authenticated %} - - {% endif %} -
- {% endif %}
@@ -193,52 +181,38 @@
- +
-

Recent Telemetry

- {% if telemetry %} +

Tags

+ {% if node.tags %}
- - - + + + - {% for tel in telemetry %} + {% for tag in node.tags %} - - - + + + {% endfor %}
TimeDataReceived ByKeyValueType
{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }} - {% if tel.parsed_data %} - {{ tel.parsed_data | tojson }} - {% else %} - - - {% endif %} - - {% if tel.received_by %} - - {% if tel.receiver_tag_name or tel.receiver_name %} -
{{ tel.receiver_tag_name or tel.receiver_name }}
-
{{ tel.received_by[:16] }}...
- {% else %} - {{ tel.received_by[:16] }}... - {% endif %} -
- {% else %} - - - {% endif %} -
{{ tag.key }}{{ tag.value }}{{ tag.value_type or 'string' }}
{% else %} -

No telemetry recorded.

+

No tags defined.

+ {% endif %} + {% if admin_enabled and is_authenticated %} + {% endif %}
@@ -265,28 +239,39 @@ window.qrCodeConfig = { name: {{ (ns_qr.tag_name or node.name or 'Node') | tojson }}, publicKey: {{ node.public_key | tojson }}, - advType: {{ (node.adv_type or '') | tojson }} + advType: {{ (node.adv_type or '') | tojson }}, + size: 140 }; -{% set ns_map = namespace(lat=none, lon=none, name=none) %} +{# Get coordinates from node model first, then fall back to tags #} +{% set ns_map = namespace(lat=node.lat, lon=node.lon, name=none) %} +{% if not ns_map.lat or not ns_map.lon %} + {% for tag in node.tags or [] %} + {% if tag.key == 'lat' and not ns_map.lat %} + {% set ns_map.lat = tag.value|float %} + {% elif tag.key == 'lon' and not ns_map.lon %} + {% set ns_map.lon = tag.value|float %} + {% endif %} + {% endfor %} +{% endif %} {% for tag in node.tags or [] %} - {% if tag.key == 'lat' %} - {% set ns_map.lat = tag.value %} - {% elif tag.key == 'lon' %} - {% set ns_map.lon = tag.value %} - {% elif tag.key == 'name' %} + {% if tag.key == 'name' %} {% set ns_map.name = tag.value %} {% endif %} {% endfor %} {% if ns_map.lat and ns_map.lon %}