Improve map UI and add QR code to node detail page

Map improvements:
- Change non-infra nodes from emojis to subtle blue circles
- Add "Show Chat Nodes" checkbox (hidden by default)
- Fix z-index for hovered marker labels
- Increase zoom on mobile devices
- Simplify legend to show Infrastructure and Node icons

Node detail page:
- Add QR code for meshcore:// contact protocol
- Move activity (first/last seen) to title row
- QR code positioned under public key with white background
- Protocol: meshcore://contact/add?name=<name>&public_key=<key>&type=<n>
- Type mapping: chat=1, repeater=2, room=3, sensor=4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-02-07 20:51:25 +00:00
parent ceaef9178a
commit 3d7ed53df3
2 changed files with 135 additions and 91 deletions
+27 -32
View File
@@ -76,6 +76,12 @@
<!-- Populated dynamically -->
</select>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">Show Chat Nodes</span>
<input type="checkbox" id="show-chat" class="checkbox checkbox-sm">
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2 py-1">
<span class="label-text">Show Labels</span>
@@ -101,20 +107,8 @@
<span>Infrastructure</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">💬</span>
<span>Chat</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">📡</span>
<span>Repeater</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">🪧</span>
<span>Room</span>
</div>
<div class="flex items-center gap-1">
<span class="text-lg">📍</span>
<span>Other</span>
<div style="width: 10px; height: 10px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%;"></div>
<span>Node</span>
</div>
</div>
@@ -146,6 +140,10 @@
// Maximum radius (km) from anchor point for bounds calculation
const MAX_BOUNDS_RADIUS_KM = 20;
// Padding for fitBounds - more padding on mobile for tighter zoom
const isMobile = window.innerWidth < 768;
const BOUNDS_PADDING = isMobile ? [150, 150] : [100, 100];
// Calculate distance between two points in km (Haversine formula)
function getDistanceKm(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in km
@@ -185,15 +183,6 @@
// formatRelativeTime is provided by /static/js/utils.js
// Get emoji marker based on node type
function getNodeEmoji(node) {
const type = normalizeType(node.adv_type);
if (type === 'chat') return '💬';
if (type === 'repeater') return '📡';
if (type === 'room') return '🪧';
return '📍';
}
// Get display name for node type
function getTypeDisplay(node) {
const type = normalizeType(node.adv_type);
@@ -209,13 +198,12 @@
const relativeTime = formatRelativeTime(node.last_seen);
const timeDisplay = relativeTime ? ` (${relativeTime})` : '';
// Use logo for infrastructure nodes, emoji for others
// Use logo for infrastructure nodes, blue circle for others
let iconHtml;
if (node.is_infra) {
iconHtml = `<img src="${logoUrl}" alt="Infra" style="width: 24px; height: 24px; filter: drop-shadow(0 0 2px #1a237e) drop-shadow(0 0 4px #1a237e) drop-shadow(0 1px 2px rgba(0,0,0,0.7));">`;
} else {
const emoji = getNodeEmoji(node);
iconHtml = `<span style="font-size: 24px; text-shadow: 0 0 3px #1a237e, 0 0 6px #1a237e, 0 1px 2px rgba(0,0,0,0.7);">${emoji}</span>`;
iconHtml = `<div style="width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; box-shadow: 0 0 4px rgba(59,130,246,0.6), 0 1px 2px rgba(0,0,0,0.5);"></div>`;
}
return L.divIcon({
@@ -246,10 +234,10 @@
const typeDisplay = getTypeDisplay(node);
// Use logo for infrastructure nodes, emoji for others
// Use logo for infrastructure nodes, blue circle for others
const iconHtml = node.is_infra
? `<img src="${logoUrl}" alt="Infra" style="width: 20px; height: 20px; display: inline-block; vertical-align: middle;">`
: getNodeEmoji(node);
: `<span style="display: inline-block; width: 12px; height: 12px; background: #3b82f6; border: 2px solid #1e40af; border-radius: 50%; vertical-align: middle;"></span>`;
return `
<div class="p-2">
@@ -278,14 +266,19 @@
const categoryFilter = document.getElementById('filter-category').value;
const typeFilter = document.getElementById('filter-type').value;
const memberFilter = document.getElementById('filter-member').value;
const showChat = document.getElementById('show-chat').checked;
// Filter nodes
const filteredNodes = allNodes.filter(node => {
// Hide chat/companion nodes unless checkbox is checked
const nodeType = normalizeType(node.adv_type);
if (!showChat && nodeType === 'chat') return false;
// Category filter (infrastructure only)
if (categoryFilter === 'infra' && !node.is_infra) return false;
// Type filter (case-insensitive)
if (typeFilter && normalizeType(node.adv_type) !== typeFilter) return false;
if (typeFilter && nodeType !== typeFilter) return false;
// Member filter - match node's member_id tag to selected member_id
if (memberFilter) {
@@ -340,7 +333,7 @@
}
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [100, 100] });
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (mapCenter.lat !== 0 || mapCenter.lon !== 0) {
map.setView([mapCenter.lat, mapCenter.lon], 10);
}
@@ -376,6 +369,7 @@
document.getElementById('filter-category').value = '';
document.getElementById('filter-type').value = '';
document.getElementById('filter-member').value = '';
document.getElementById('show-chat').checked = false;
document.getElementById('show-labels').checked = false;
updateLabelVisibility();
applyFilters();
@@ -396,6 +390,7 @@
document.getElementById('filter-category').addEventListener('change', applyFilters);
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-member').addEventListener('change', applyFilters);
document.getElementById('show-chat').addEventListener('change', applyFilters);
document.getElementById('show-labels').addEventListener('change', updateLabelVisibility);
document.getElementById('clear-filters').addEventListener('click', clearFilters);
@@ -435,14 +430,14 @@
const infraNodes = allNodes.filter(n => n.is_infra);
if (infraNodes.length > 0) {
const bounds = L.latLngBounds(infraNodes.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [100, 100] });
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
} else if (allNodes.length > 0) {
// Use radius filter to exclude outliers
const anchor = getAnchorPoint(allNodes);
const nearbyNodes = getNodesWithinRadius(allNodes, anchor.lat, anchor.lon, MAX_BOUNDS_RADIUS_KM);
const nodesToFit = nearbyNodes.length > 0 ? nearbyNodes : allNodes;
const bounds = L.latLngBounds(nodesToFit.map(n => [n.lat, n.lon]));
map.fitBounds(bounds, { padding: [100, 100] });
map.fitBounds(bounds, { padding: BOUNDS_PADDING });
}
// Apply filters (won't re-center since we just did above)
+108 -59
View File
@@ -3,6 +3,7 @@
{% block title %}{{ network_name }} - Node Details{% endblock %}
{% block extra_head %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<style>
#node-map {
height: 300px;
@@ -56,36 +57,28 @@
<!-- Node Info Card -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h1 class="card-title text-2xl">
{% if node.adv_type %}
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
<!-- Title Row with Activity -->
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<h1 class="card-title text-2xl">
{% if node.adv_type %}
{% if node.adv_type|lower == 'chat' %}
<span title="Chat">💬</span>
{% elif node.adv_type|lower == 'repeater' %}
<span title="Repeater">📡</span>
{% elif node.adv_type|lower == 'room' %}
<span title="Room">🪧</span>
{% else %}
<span title="{{ node.adv_type }}">📍</span>
{% endif %}
{% endif %}
{% endif %}
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
</div>
<div>
<h3 class="font-semibold opacity-70 mb-2">Activity</h3>
<div class="space-y-1 text-sm">
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
</div>
{{ ns.tag_name or node.name or 'Unnamed Node' }}
</h1>
<div class="text-sm text-right">
<p><span class="opacity-70">First seen:</span> {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}</p>
<p><span class="opacity-70">Last seen:</span> {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}</p>
</div>
</div>
<!-- Tags and Map Grid -->
{% set ns_map = namespace(lat=none, lon=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}
@@ -95,42 +88,17 @@
{% endif %}
{% endfor %}
<div class="grid grid-cols-1 {% if ns_map.lat and ns_map.lon %}lg:grid-cols-2{% endif %} gap-6 mt-6">
<!-- Tags -->
{% if node.tags or (admin_enabled and is_authenticated) %}
<!-- Public Key + QR Code and Map Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<!-- Public Key and QR Code -->
<div>
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
{% if node.tags %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3 class="font-semibold opacity-70 mb-2">Public Key</h3>
<code class="text-sm bg-base-200 p-2 rounded block break-all">{{ node.public_key }}</code>
<div class="mt-4">
<div id="qr-code" class="inline-block bg-white p-3 rounded"></div>
<p class="text-xs opacity-50 mt-2">Scan to add as contact</p>
</div>
{% else %}
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
{% endif %}
{% if admin_enabled and is_authenticated %}
<div class="mt-3">
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
</div>
{% endif %}
</div>
{% endif %}
<!-- Location Map -->
{% if ns_map.lat and ns_map.lon %}
@@ -143,6 +111,42 @@
</div>
{% endif %}
</div>
<!-- Tags Section -->
{% if node.tags or (admin_enabled and is_authenticated) %}
<div class="mt-6">
<h3 class="font-semibold opacity-70 mb-2">Tags</h3>
{% if node.tags %}
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for tag in node.tags %}
<tr>
<td class="font-mono">{{ tag.key }}</td>
<td>{{ tag.value }}</td>
<td class="opacity-70">{{ tag.value_type or 'string' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-sm opacity-70 mb-2">No tags defined.</p>
{% endif %}
{% if admin_enabled and is_authenticated %}
<div class="mt-3">
<a href="/a/node-tags?public_key={{ node.public_key }}" class="btn btn-sm btn-outline">{% if node.tags %}Edit Tags{% else %}Add Tags{% endif %}</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
@@ -267,6 +271,51 @@
{% block extra_scripts %}
{% if node %}
{% set ns_qr = namespace(tag_name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'name' %}
{% set ns_qr.tag_name = tag.value %}
{% endif %}
{% endfor %}
<script>
// Generate QR code for adding contact
(function() {
const nodeName = {{ (ns_qr.tag_name or node.name or 'Node') | tojson }};
const publicKey = {{ node.public_key | tojson }};
const advType = {{ (node.adv_type or '') | tojson }};
// Map adv_type to numeric type for meshcore:// protocol
const typeMap = {
'chat': 1,
'repeater': 2,
'room': 3,
'sensor': 4
};
const typeNum = typeMap[advType.toLowerCase()] || 1;
// Build meshcore:// URL
const meshcoreUrl = `meshcore://contact/add?name=${encodeURIComponent(nodeName)}&public_key=${publicKey}&type=${typeNum}`;
// Generate QR code
const qrContainer = document.getElementById('qr-code');
if (qrContainer && typeof QRCode !== 'undefined') {
try {
new QRCode(qrContainer, {
text: meshcoreUrl,
width: 256,
height: 256,
colorDark: '#000000',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.L
});
} catch (error) {
console.error('QR code generation failed:', error);
qrContainer.innerHTML = '<p class="text-sm opacity-50">QR code unavailable</p>';
}
}
})();
</script>
{% set ns_map = namespace(lat=none, lon=none, name=none) %}
{% for tag in node.tags or [] %}
{% if tag.key == 'lat' %}