Files
meshcore-bot/modules/web_viewer/templates/mesh.html
T
agessaman bcb3f48f02 fix(web_viewer): restore mesh export and clean scheduler imports
Prevent exportView runtime failure by restoring the download anchor setup and move radio param imports to module scope so Ruff passes on the PR changes.

Made-with: Cursor
2026-04-25 21:01:14 -07:00

2586 lines
119 KiB
HTML

{% extends "base.html" %}
{% block title %}Mesh Graph - MeshCore Bot Data Viewer{% endblock %}
{% block content %}
<style>
/* Make the mesh graph page use full height - scoped to this page only */
/* Fixed height so flex children (e.g. visualization-row) get remaining space; navbar + title + stats ~220px */
#mesh-graph-container {
display: flex;
flex-direction: column;
height: calc(100vh - 110px);
}
#map-view, #graph-view {
flex: 1;
min-height: 0;
}
.visualization-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Add bottom margin to match side margins - apply to the row containing the visualization */
.visualization-row {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
margin-bottom: 1rem; /* Match Bootstrap's container-fluid horizontal padding (15px) */
}
.visualization-row > .col-12 {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
</style>
<div id="mesh-graph-container" class="d-flex flex-column">
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-project-diagram"></i> Mesh Graph
</h1>
</div>
</div>
<!-- Statistics Panel - Compact -->
<div class="row mb-3 g-2">
<div class="col">
<div class="card">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-sitemap me-2"></i>
<div>
<div class="small text-muted">Nodes</div>
<div id="stat-node-count" class="text-primary fw-bold" style="font-size: 1.1rem; line-height: 1.2;">0</div>
<div class="small text-muted">Total Repeaters</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-link me-2"></i>
<div>
<div class="small text-muted">Edges</div>
<div id="stat-edge-count" class="text-info fw-bold" style="font-size: 1.1rem; line-height: 1.2;">0</div>
<div class="small text-muted">Total Connections</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-chart-line me-2"></i>
<div>
<div class="small text-muted">Observations</div>
<div id="stat-avg-obs" class="text-success fw-bold" style="font-size: 1.1rem; line-height: 1.2;">0</div>
<div class="small text-muted">Avg per Edge</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-ruler me-2"></i>
<div>
<div class="small text-muted">Distance</div>
<div id="stat-avg-dist" class="text-warning fw-bold" style="font-size: 1.1rem; line-height: 1.2;">0</div>
<div class="small text-muted">Avg km</div>
</div>
</div>
</div>
</div>
</div>
<div class="col">
<div class="card">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center w-100">
<i class="fas fa-sliders-h me-2"></i>
<div class="flex-grow-1">
<div class="small text-muted">Controls</div>
<div class="d-flex align-items-center gap-1 mt-1 flex-wrap">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm active" id="btn-view-map" onclick="switchView('map')">
<i class="fas fa-map"></i> Map
</button>
<button type="button" class="btn btn-outline-primary btn-sm" id="btn-view-graph" onclick="switchView('graph')">
<i class="fas fa-project-diagram"></i> Graph
</button>
</div>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#filterPanel">
<i class="fas fa-filter"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="refreshData()">
<i class="fas fa-sync-alt"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="exportView()">
<i class="fas fa-download"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" id="btn-fullscreen" onclick="toggleFullscreen()">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Panel -->
<div class="row mb-3 collapse" id="filterPanel">
<div class="col-12">
<div class="card">
<div class="card-header">
<i class="fas fa-filter"></i> Filters
</div>
<div class="card-body">
<div class="row">
<div class="col-md-2">
<label for="filter-min-obs" class="form-label">Min Observations</label>
<input type="range" class="form-range" id="filter-min-obs" min="1" max="50" value="1" oninput="updateFilterLabel('filter-min-obs', 'label-min-obs'); saveFilters();">
<small id="label-min-obs" class="text-muted">1</small>
</div>
<div class="col-md-2">
<label for="filter-edge-days" class="form-label">Edge Timeframe</label>
<select class="form-select" id="filter-edge-days" onchange="applyFilters(); saveFilters();">
<option value="">All Time</option>
<option value="1">Last 24 Hours</option>
<option value="2">Last 48 Hours</option>
<option value="3" selected>Last 72 Hours</option>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
</select>
</div>
<div class="col-md-2">
<label for="filter-node-days" class="form-label">Node Timeframe</label>
<select class="form-select" id="filter-node-days" onchange="applyFilters(); saveFilters();">
<option value="">All Time</option>
<option value="1">Last 24 Hours</option>
<option value="2">Last 48 Hours</option>
<option value="3" selected>Last 72 Hours</option>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
</select>
</div>
<div class="col-md-2">
<label for="filter-starred" class="form-label">&nbsp;</label>
<div class="d-flex align-items-center" style="min-height: 38px;">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="filter-starred" onchange="applyFilters(); saveFilters();">
<label class="form-check-label" for="filter-starred">Show Only Starred</label>
</div>
</div>
</div>
<div class="col-md-4">
<label for="filter-search" class="form-label">Search Node</label>
<input type="text" class="form-control" id="filter-search" placeholder="Name, prefix, or path (e.g., 7e,01,86)..." oninput="handleSearchInput();">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Visualization Area -->
<div class="row visualization-row" style="flex: 1; min-height: 0;">
<div class="col-12 h-100">
<div class="card h-100 visualization-card">
<div class="card-body p-0 h-100" style="position: relative;">
<!-- Map View -->
<div id="map-view" style="height: 100%; width: 100%;"></div>
<!-- Graph View -->
<div id="graph-view" style="height: 100%; width: 100%; display: none;"></div>
<!-- Connection Legend -->
<div id="connection-legend" class="connection-legend">
<strong>Connection Types:</strong>
<div class="connection-legend-item">
<span class="connection-legend-color" style="background-color: #10b981;"></span>
<span>Incoming</span>
</div>
<div class="connection-legend-item">
<span class="connection-legend-color" style="background-color: #3b82f6;"></span>
<span>Outgoing</span>
</div>
<div class="connection-legend-item">
<span class="connection-legend-color" style="background-color: #9333ea;"></span>
<span>Bidirectional</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Node Details Modal -->
<div class="modal fade" id="nodeDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="nodeDetailsTitle">Node Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="nodeDetailsBody">
<!-- Content populated by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- vis-network CSS -->
<link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/styles/vis-network.min.css" />
<style>
#map-view, #graph-view {
min-height: 600px;
}
.leaflet-popup-content {
margin: 13px 19px;
}
/* Prevent selection rectangle/box when clicking on map elements */
.leaflet-container {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.leaflet-container * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Prevent Leaflet popup from showing bounding box */
.leaflet-popup-pane {
pointer-events: auto;
}
/* Hide any selection outlines and bounding boxes */
.leaflet-interactive:focus {
outline: none;
}
.leaflet-popup {
/* Ensure popup doesn't trigger auto-pan visual indicators */
}
/* Hide Leaflet's auto-pan padding/bounding box visual */
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.node-marker-repeater {
background-color: #0d6efd;
}
.node-marker-roomserver {
background-color: #198754;
}
.node-marker-starred {
border: 3px solid #ffc107;
}
.mesh-node-popup .leaflet-popup-content {
margin: 10px 15px;
}
.mesh-node-popup .leaflet-popup-content table {
border-collapse: collapse;
}
.mesh-node-popup .leaflet-popup-content code {
background-color: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.mesh-node-popup .prefix-byte {
display: inline-block;
background-color: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 3px;
padding: 2px 5px;
margin-right: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.mesh-node-popup .prefix-byte:last-child {
margin-right: 0;
}
/* Legend for connection highlighting */
.connection-legend {
position: absolute;
bottom: 30px;
right: 10px;
background: var(--bs-body-bg, rgba(255, 255, 255, 0.95));
color: var(--bs-body-color, #212529);
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
border: 1px solid var(--bs-border-color, rgba(0,0,0,0.1));
z-index: 1000;
font-size: 0.85em;
display: none;
}
.connection-legend.active {
display: block;
}
.connection-legend strong {
color: var(--bs-body-color, #212529);
font-weight: 600;
}
.connection-legend-item {
display: flex;
align-items: center;
margin: 5px 0;
color: var(--bs-body-color, #212529);
}
.connection-legend-color {
width: 20px;
height: 3px;
margin-right: 8px;
display: inline-block;
}
</style>
<!-- Full screen styles -->
<style>
.fullscreen-mode {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 9999 !important;
background: white;
}
.fullscreen-mode .leaflet-control-container {
z-index: 10000;
}
body.fullscreen-active {
overflow: hidden;
}
.fullscreen-mode #map-view {
height: 100vh !important;
width: 100vw !important;
}
/* Hide other elements in fullscreen mode */
.fullscreen-mode ~ .row,
.fullscreen-mode ~ .visualization-row {
display: none !important;
}
/* Show exit fullscreen button in fullscreen mode */
.fullscreen-mode .fullscreen-exit-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 10001;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.fullscreen-mode .fullscreen-exit-btn:hover {
background: rgba(255, 255, 255, 1);
}
[data-theme="dark"] .fullscreen-mode {
background: var(--bg-color);
}
[data-theme="dark"] .fullscreen-mode .fullscreen-exit-btn {
background: rgba(45, 45, 45, 0.9);
border-color: #555;
color: var(--text-color);
}
[data-theme="dark"] .fullscreen-mode .fullscreen-exit-btn:hover {
background: rgba(45, 45, 45, 1);
}
</style>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- vis-network JS -->
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
<script>
// Global state
let currentView = 'map';
let map = null;
let isMapFullscreen = false;
let graphNetwork = null;
let allNodes = [];
let allEdges = [];
let filteredNodes = [];
let filteredEdges = [];
let nodeMap = {}; // prefix -> node data (all nodes, for path resolution)
let nodeMapByKey = {}; // public_key -> node data (all nodes, for path resolution)
let filteredNodeMap = {}; // prefix -> node (filtered set only, for edge drawing)
let filteredNodeMapByKey = {}; // public_key -> node (filtered set only, for edge drawing)
let edgeMap = {}; // "from-to" -> edge data
let mapViewState = null; // Store map center and zoom for preservation
let highlightedNode = null; // Currently highlighted node prefix
let highlightedNodeObject = null; // The actual node object that's highlighted
let highlightedEdges = new Map(); // Map of edge keys to their original styles
let allEdgeStyles = new Map(); // Map of all edge keys to their original styles (for dimming)
let allNodeStyles = new Map(); // Map of node identifiers to their original styles (for shrinking)
let nodeMarkers = new Map(); // Map of node identifier to marker for easy access
let nodeMarkersByPrefix = new Map(); // Map of prefix to array of markers (for multiple nodes with same prefix)
let edgeLines = new Map(); // Map of "from-to" to polyline for easy access
let edgeToNodes = new Map(); // Map of edge key to {fromNode, toNode} for accurate matching
let nodeModal = null; // Bootstrap modal instance for node details
let isInitialMapLoad = true; // Track if this is the first time rendering the map with nodes
let highlightedPath = null; // Currently highlighted path data
let pathHighlightTimeout = null; // Debounce timer for path resolution
const PREFIX_HEX_CHARS = parseInt('{{ prefix_hex_chars|default(2) }}', 10);
// Graph prefix length from edges API (2, 4, or 6 hex chars); used for node prefix display and node fetch.
let graphPrefixHexChars = PREFIX_HEX_CHARS;
// Format prefix for display: add byte count when multi-byte (2 or 3 bytes)
function formatPrefixWithBytes(prefix) {
if (!prefix || typeof prefix !== 'string') return '';
const hexLen = prefix.length;
const bytes = hexLen / 2;
if (bytes === 1) return prefix;
return `${prefix} (${bytes} bytes)`;
}
// Format prefix as up to 3 space-separated bytes (e.g. 86 0C AB); numBytes = how many bytes to show.
function formatPrefixAsByteChunks(hexStr, numBytes) {
if (!hexStr || typeof hexStr !== 'string' || numBytes < 1) return '';
const raw = hexStr.replace(/\s/g, '');
if (raw.length < 2) return raw ? raw.slice(0, 2).toUpperCase() : '';
const parts = [];
for (let i = 0; i < numBytes && i * 2 < raw.length; i++) {
const pair = raw.slice(i * 2, i * 2 + 2);
if (pair.length === 2) parts.push(pair.toUpperCase());
}
return parts.join(' ');
}
// Same as formatPrefixAsByteChunks but returns HTML with each byte in its own box (span.prefix-byte).
function formatPrefixAsByteChunksHTML(hexStr, numBytes) {
const raw = formatPrefixAsByteChunks(hexStr, numBytes);
if (!raw) return '';
return raw.split(' ').map(byte => '<span class="prefix-byte">' + escapeHtml(byte) + '</span>').join(' ');
}
// Helper function to create unique node identifier
function getNodeId(node) {
return `${node.prefix}-${node.latitude.toFixed(6)}-${node.longitude.toFixed(6)}`;
}
// Resolve an edge prefix (2, 4, or 6 hex chars) to a node when node.prefix may be longer or equal.
function findNodeByEdgePrefix(prefix) {
if (!prefix) return null;
return filteredNodeMap[prefix] || filteredNodes.find(n => n.prefix.startsWith(prefix) || prefix.startsWith(n.prefix)) || null;
}
// Detect if input is a hex path (2+ hex values)
function detectPathInput(input) {
if (!input || input.trim().length === 0) return false;
// Pull from template if you can, otherwise default.
// If you already have this elsewhere on the page, reuse it.
const prefixHexChars = PREFIX_HEX_CHARS;
// Normalize input: remove commas, spaces, colons
const normalized = input.replace(/[,\s:]/g, '');
// Check if it's a continuous hex string (e.g., "8601a5" or "8601A58F02")
// If it's all hex and has at least 2 tokens, treat as path.
// Optional: require it to align on token boundaries to reduce false positives.
const minChars = prefixHexChars * 2; // 2+ tokens
if (new RegExp(`^[0-9a-fA-F]{${minChars},}$`).test(normalized)) {
// If you want to be slightly stricter (still minimal), uncomment:
// if (normalized.length % prefixHexChars === 0) return true;
return true;
}
// Also check for space-separated hex values
const hexPattern = new RegExp(`\\b[0-9a-fA-F]{${prefixHexChars}}\\b`, 'g');
const matches = input.match(hexPattern);
// If we have 2+ hex values, treat as path
return matches && matches.length >= 2;
}
// Resolve path via API
async function resolvePath(pathInput) {
try {
const response = await fetch('/api/mesh/resolve-path', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ path: pathInput })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to resolve path');
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error resolving path:', error);
return { valid: false, error: error.message };
}
}
// Handle search input - route to path resolution or normal filtering
function handleSearchInput() {
const searchInput = document.getElementById('filter-search');
const searchTerm = searchInput.value.trim();
// Clear any existing path highlight timeout
if (pathHighlightTimeout) {
clearTimeout(pathHighlightTimeout);
pathHighlightTimeout = null;
}
// If search is empty, clear path highlights and apply normal filters
if (!searchTerm) {
clearPathHighlight();
applyFilters();
saveFilters();
return;
}
// Check if input is a path
if (detectPathInput(searchTerm)) {
// Clear previous path highlight when starting new search
clearPathHighlight();
// Debounce path resolution (wait 500ms after user stops typing)
// Use longer delay when deleting to avoid flicker when path becomes invalid
const isDeleting = searchTerm.length < (highlightedPath ? highlightedPath.node_ids.join('').length : 999);
const delay = isDeleting ? 800 : 500; // Longer delay when deleting
pathHighlightTimeout = setTimeout(async () => {
// Normalize path input: handle continuous hex strings (e.g., "8601a5")
let normalizedPath = searchTerm.trim();
const cleanPath = normalizedPath.replace(/[,\s:]/g, '');
if (/^[0-9a-fA-F]{4,}$/.test(cleanPath)) {
// Continuous hex string - add spaces between pairs for API
normalizedPath = cleanPath.match(/.{1,2}/g).join(' ');
}
const resolved = await resolvePath(normalizedPath);
if (resolved.valid && resolved.repeaters && resolved.repeaters.length > 0) {
// Ensure map is rendered first (applyFilters will render if needed)
// But don't filter out nodes/edges - we want to show the path on the full map
if (currentView === 'map') {
// Map should already be rendered, just highlight the path
highlightPath(resolved);
} else {
// If in graph view, switch to map view first
switchView('map');
// Wait for map to render, then highlight
setTimeout(() => highlightPath(resolved), 100);
}
} else {
// Path resolution failed, fall back to normal search
applyFilters();
saveFilters();
}
}, delay);
} else {
// Not a path - use normal filtering
// Add delay when deleting to avoid flicker
const isDeleting = searchTerm.length < (document.getElementById('filter-search').getAttribute('data-last-length') || 999);
const delay = isDeleting ? 300 : 0;
// Store current length for next comparison
document.getElementById('filter-search').setAttribute('data-last-length', searchTerm.length);
if (delay > 0) {
pathHighlightTimeout = setTimeout(() => {
clearPathHighlight();
applyFilters();
saveFilters();
}, delay);
} else {
clearPathHighlight();
applyFilters();
saveFilters();
}
}
}
// Highlight a resolved path on the map
// immediate: if true, apply styles synchronously (for use after renderMap to prevent flicker)
function highlightPath(resolvedPath, immediate = false) {
if (!resolvedPath || !resolvedPath.valid || !resolvedPath.repeaters) {
console.warn('Invalid path data:', resolvedPath);
return;
}
// Check if map is available and has edges/nodes
if (!map || edgeLines.size === 0) {
console.warn('Map not ready or no edges rendered. Edge count:', edgeLines.size);
// Try to render the map first
if (currentView === 'map') {
applyFilters();
// Try again after rendering
setTimeout(() => highlightPath(resolvedPath, immediate), 200);
}
return;
}
// Store path data first
highlightedPath = resolvedPath;
// Clear any existing node connection highlights (but don't clear the map)
// Just reset the highlighted node state, don't call clearHighlights() which might affect rendering
if (highlightedNode) {
highlightedNode = null;
highlightedNodeObject = null;
}
// Get all found repeaters with locations
const pathRepeaters = resolvedPath.repeaters.filter(r => r.found && r.latitude && r.longitude);
if (pathRepeaters.length === 0) {
console.warn('No repeaters with locations found in path. Path data:', resolvedPath);
// Don't return - still show the map, just don't highlight anything
return;
}
console.log(`Highlighting path with ${pathRepeaters.length} repeaters:`, pathRepeaters.map(r => r.node_id));
// Highlight edges between consecutive nodes in the path
// Need to match the edgeKey format used when storing edges: `${edge.from_prefix}-${edge.to_prefix}`
const pathEdgeKeys = new Set();
for (let i = 0; i < pathRepeaters.length - 1; i++) {
const fromRepeater = pathRepeaters[i];
const toRepeater = pathRepeaters[i + 1];
// Find the edge - try public key matching first, then prefix
let edge = null;
// Try public key matching first (most accurate)
if (fromRepeater.public_key && toRepeater.public_key) {
edge = filteredEdges.find(e =>
e.from_public_key === fromRepeater.public_key &&
e.to_public_key === toRepeater.public_key
);
}
// Fall back to prefix matching (case-insensitive)
const fromPrefix = fromRepeater.node_id.toUpperCase();
const toPrefix = toRepeater.node_id.toUpperCase();
if (!edge) {
edge = filteredEdges.find(e =>
e.from_prefix.toUpperCase() === fromPrefix &&
e.to_prefix.toUpperCase() === toPrefix
);
}
// Also check reverse direction (bidirectional edges)
if (!edge) {
edge = filteredEdges.find(e =>
e.from_prefix.toUpperCase() === toPrefix &&
e.to_prefix.toUpperCase() === fromPrefix
);
}
if (edge) {
// Use the same edgeKey format as when storing: `${edge.from_prefix}-${edge.to_prefix}`
// Always use the edge's actual from/to prefixes (as stored in the database)
// Normalize to lowercase to match how edges are stored
const edgeKey = `${edge.from_prefix.toLowerCase()}-${edge.to_prefix.toLowerCase()}`;
pathEdgeKeys.add(edgeKey);
console.log(`Found edge for path segment ${fromRepeater.node_id}->${toRepeater.node_id}: ${edgeKey} (edge stored as ${edge.from_prefix}->${edge.to_prefix})`);
} else {
console.warn(`No edge found for path segment ${fromRepeater.node_id}->${toRepeater.node_id}`);
// Log available edges for debugging
const availableEdges = filteredEdges.filter(e => {
const eFrom = e.from_prefix.toUpperCase();
const eTo = e.to_prefix.toUpperCase();
return (eFrom === fromPrefix || eTo === fromPrefix || eFrom === toPrefix || eTo === toPrefix);
});
if (availableEdges.length > 0) {
console.log(`Available edges with these prefixes:`, availableEdges.map(e => `${e.from_prefix}->${e.to_prefix}`));
} else {
console.log(`No edges found with prefixes ${fromPrefix} or ${toPrefix} in filteredEdges (total: ${filteredEdges.length})`);
}
}
}
console.log(`Path edge keys to highlight:`, Array.from(pathEdgeKeys));
// First, store original styles for ALL edges if not already stored
edgeLines.forEach((line, edgeKey) => {
if (!allEdgeStyles.has(edgeKey)) {
allEdgeStyles.set(edgeKey, {
color: line.options.color,
weight: line.options.weight,
opacity: line.options.opacity
});
}
});
console.log(`Total edges on map: ${edgeLines.size}, Path edges to highlight: ${pathEdgeKeys.size}`);
// Normalize path edge keys to lowercase for matching
const normalizedPathEdgeKeys = new Set();
pathEdgeKeys.forEach(key => normalizedPathEdgeKeys.add(key.toLowerCase()));
// Apply edge styles - immediately if requested (to prevent flicker after renderMap),
// otherwise use requestAnimationFrame to batch updates
const applyEdgeStyles = () => {
edgeLines.forEach((line, edgeKey) => {
// Normalize edgeKey for comparison
const normalizedKey = edgeKey.toLowerCase();
if (normalizedPathEdgeKeys.has(normalizedKey)) {
// Apply path highlight style
line.setStyle({
color: '#fbbf24', // Bright yellow/orange
weight: 6, // Thicker
opacity: 1.0 // Fully opaque
});
} else {
// Dim non-path edges - now we know originalStyle exists since we stored it above
const originalStyle = allEdgeStyles.get(edgeKey);
if (originalStyle) {
line.setStyle({
color: '#848484',
weight: Math.max(1, originalStyle.weight * 0.5),
opacity: 0.2
});
}
}
});
};
if (immediate) {
// Apply immediately to prevent flicker when called from renderMap()
applyEdgeStyles();
} else {
// Use requestAnimationFrame to batch all style updates in a single frame
requestAnimationFrame(applyEdgeStyles);
}
// Highlight path nodes
pathRepeaters.forEach((repeater, index) => {
// Find the node in our node map
let node = null;
// Try to find by public key first (most accurate)
if (repeater.public_key && nodeMapByKey && nodeMapByKey[repeater.public_key]) {
node = nodeMapByKey[repeater.public_key];
} else if (repeater.latitude && repeater.longitude) {
// Fall back to prefix and location matching (for cases where public key not available)
const matchingNodes = filteredNodes.filter(n =>
n.prefix === repeater.node_id &&
n.latitude && n.longitude &&
Math.abs(n.latitude - repeater.latitude) < 0.001 &&
Math.abs(n.longitude - repeater.longitude) < 0.001
);
if (matchingNodes.length > 0) {
node = matchingNodes[0];
}
} else {
// Last resort: just match by prefix (less accurate but better than nothing)
node = nodeMap[repeater.node_id];
}
if (node) {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (marker) {
const highlightSize = getNodeSize(node) + 5;
const highlightBorderWeight = getBorderWeight(highlightSize, node.is_starred);
marker.setStyle({
radius: highlightSize,
weight: highlightBorderWeight,
color: '#f59e0b' // Amber border
});
}
}
});
// Save filters (but don't trigger applyFilters to avoid clearing highlights)
saveFilters();
}
// Clear path highlighting
function clearPathHighlight() {
if (!highlightedPath) return;
highlightedPath = null;
// Restore all edge styles
edgeLines.forEach((line, edgeKey) => {
const originalStyle = allEdgeStyles.get(edgeKey);
if (originalStyle) {
line.setStyle(originalStyle);
}
});
// Restore all node styles
filteredNodes.forEach(node => {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (marker) {
const originalStyle = allNodeStyles.get(nodeId);
if (originalStyle) {
// Restore original size and style
marker.setStyle(originalStyle);
} else {
// Fallback: restore based on node properties
const normalSize = getNodeSize(node);
const normalBorderWeight = getBorderWeight(normalSize, node.is_starred);
marker.setStyle({
radius: normalSize,
weight: normalBorderWeight,
color: node.is_starred ? '#ffc107' : '#ffffff',
fillOpacity: 0.8
});
}
}
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', async function() {
// Initialize modal instances once
const nodeModalElement = document.getElementById('nodeDetailsModal');
if (nodeModalElement) {
nodeModal = new bootstrap.Modal(nodeModalElement, {
backdrop: true,
keyboard: true,
focus: true
});
}
// Load saved filter settings
loadFilters();
setupSocketIO();
// Load stats first to set initial filter value (but don't override saved filters)
await loadStats();
// Then load data (which will apply filters)
await loadData();
// Open graph tab if URL has #graph (e.g. /mesh#graph)
if (window.location.hash === '#graph') {
switchView('graph');
}
// Keep view in sync with URL hash (back/forward, direct link)
window.addEventListener('hashchange', function() {
if (window.location.hash === '#graph' && currentView !== 'graph') {
switchView('graph');
} else if (window.location.hash !== '#graph' && currentView === 'graph') {
switchView('map');
}
});
});
// Load statistics
async function loadStats() {
try {
const response = await fetch('/api/mesh/stats');
const stats = await response.json();
document.getElementById('stat-node-count').textContent = stats.node_count || 0;
document.getElementById('stat-edge-count').textContent = stats.total_edges || 0;
document.getElementById('stat-avg-obs').textContent = stats.avg_observations || 0;
document.getElementById('stat-avg-dist').textContent = stats.avg_distance ? stats.avg_distance + ' km' : 'N/A';
// Set initial Min Observations filter to floor of average observations
// Only if no saved filter value exists
const savedFilters = localStorage.getItem('meshGraphFilters');
if (!savedFilters && stats.avg_observations && stats.avg_observations > 0) {
const minObsValue = Math.floor(stats.avg_observations);
const minObsSlider = document.getElementById('filter-min-obs');
const minObsLabel = document.getElementById('label-min-obs');
if (minObsSlider && !minObsSlider.value || minObsSlider.value === '1') {
minObsSlider.value = minObsValue;
// Update label directly without triggering applyFilters (data not loaded yet)
if (minObsLabel) {
minObsLabel.textContent = minObsValue;
}
}
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Load nodes and edges
// Options: { skipRender: true } - refresh allNodes/allEdges but do not call applyFilters().
// Used by socket handlers so graph view is not re-rendered on live updates (avoids chaotic re-stabilization).
async function loadData(options) {
const skipRender = options && options.skipRender === true;
try {
// Load edges first so we can pass prefix_hex_chars to nodes (keeps node prefix in sync with edge prefixes)
const edgesResponse = await fetch('/api/mesh/edges');
const edgesData = await edgesResponse.json();
allEdges = edgesData.edges || [];
graphPrefixHexChars = (edgesData.prefix_hex_chars && [2, 4, 6].includes(edgesData.prefix_hex_chars))
? edgesData.prefix_hex_chars
: PREFIX_HEX_CHARS;
// Load nodes with graph prefix length so node.prefix matches edge prefixes (e.g. 2-byte)
const nodesResponse = await fetch('/api/mesh/nodes?prefix_hex_chars=' + graphPrefixHexChars);
const nodesData = await nodesResponse.json();
allNodes = nodesData.nodes || [];
// Build node maps
nodeMap = {};
nodeMapByKey = {};
allNodes.forEach(node => {
nodeMap[node.prefix] = node; // Last node with this prefix wins (for backward compatibility)
if (node.public_key) {
nodeMapByKey[node.public_key] = node; // Map by public key for accurate lookups
}
});
// Build edge map
edgeMap = {};
allEdges.forEach(edge => {
const key = `${edge.from_prefix}-${edge.to_prefix}`;
edgeMap[key] = edge;
});
if (!skipRender) {
// Apply filters (will use the min observations value set by loadStats)
// Note: applyFilters() will preserve and re-apply path highlights via renderMap()
applyFilters();
}
} catch (error) {
console.error('Error loading data:', error);
throw error; // Re-throw so callers can handle it
}
}
// Save filter settings to localStorage
function saveFilters() {
try {
const filters = {
minObs: document.getElementById('filter-min-obs').value,
edgeDays: document.getElementById('filter-edge-days').value,
nodeDays: document.getElementById('filter-node-days').value,
starredOnly: document.getElementById('filter-starred').checked
// Note: searchTerm is NOT saved - it should not persist across reloads
};
localStorage.setItem('meshGraphFilters', JSON.stringify(filters));
} catch (error) {
console.debug('Error saving filters:', error);
}
}
// Load filter settings from localStorage
function loadFilters() {
try {
const saved = localStorage.getItem('meshGraphFilters');
if (saved) {
const filters = JSON.parse(saved);
if (filters.minObs !== undefined) {
document.getElementById('filter-min-obs').value = filters.minObs;
updateFilterLabel('filter-min-obs', 'label-min-obs');
}
if (filters.edgeDays !== undefined) {
document.getElementById('filter-edge-days').value = filters.edgeDays;
}
if (filters.nodeDays !== undefined) {
document.getElementById('filter-node-days').value = filters.nodeDays;
}
if (filters.starredOnly !== undefined) {
document.getElementById('filter-starred').checked = filters.starredOnly;
}
// Note: searchTerm is NOT loaded - it should reset on page reload
}
} catch (error) {
console.debug('Error loading filters:', error);
}
}
// Apply filters
function applyFilters() {
// Preserve highlighted node before filtering/re-rendering
const preservedHighlightedNode = highlightedNodeObject;
// Preserve path highlight if active (will be re-applied in renderMap if still valid)
const preservedPath = highlightedPath;
const minObs = parseInt(document.getElementById('filter-min-obs').value) || 1;
const edgeDays = document.getElementById('filter-edge-days').value;
const nodeDays = document.getElementById('filter-node-days').value;
const starredOnly = document.getElementById('filter-starred').checked;
const searchTerm = document.getElementById('filter-search').value.toLowerCase();
// Filter edges
filteredEdges = allEdges.filter(edge => {
if (edge.observation_count < minObs) return false;
if (edgeDays && edge.last_seen) {
const edgeDate = new Date(edge.last_seen);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - parseInt(edgeDays));
if (edgeDate < cutoffDate) return false;
}
return true;
});
// Filter nodes by timeframe first
let candidateNodes = allNodes;
if (nodeDays) {
candidateNodes = allNodes.filter(node => {
const lastSeen = node.last_heard || node.last_advert_timestamp;
if (!lastSeen) return false;
const nodeDate = new Date(lastSeen);
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - parseInt(nodeDays));
return nodeDate >= cutoffDate;
});
}
// Filter nodes: include all repeaters with location that pass filters (not only those in edge set),
// so e0 nodes with no edges still appear on the map (with 0 connections).
filteredNodes = candidateNodes.filter(node => {
if (starredOnly && !node.is_starred) return false;
// Only apply text search if it's not a path (path resolution is handled separately)
if (searchTerm && !detectPathInput(searchTerm)) {
const nameMatch = node.name.toLowerCase().includes(searchTerm);
const prefixMatch = node.prefix.toLowerCase().includes(searchTerm);
if (!nameMatch && !prefixMatch) return false;
}
return true;
});
// Build filtered node maps for O(1) edge endpoint resolution (only draw edges when both endpoints are in filtered set)
filteredNodeMap = {};
filteredNodeMapByKey = {};
filteredNodes.forEach(node => {
filteredNodeMap[node.prefix] = node;
if (node.public_key) filteredNodeMapByKey[node.public_key] = node;
});
// Re-render current view
if (currentView === 'map') {
renderMap();
// Highlights are re-applied inside renderMap() if preservedHighlightedNode or preservedPath exists
} else {
renderGraph();
// Re-apply highlights in graph view if we had a highlighted node (preservedHighlightedNode is the node object)
if (preservedHighlightedNode && filteredNodes.some(n => getNodeId(n) === getNodeId(preservedHighlightedNode))) {
requestAnimationFrame(() => {
highlightNodeConnectionsGraph(preservedHighlightedNode);
});
}
}
}
// Update filter label
function updateFilterLabel(inputId, labelId) {
const value = document.getElementById(inputId).value;
document.getElementById(labelId).textContent = value;
applyFilters();
}
// Switch between views
function switchView(view) {
currentView = view;
// Update URL hash so /mesh#graph links open directly to graph tab
if (view === 'graph') {
if (window.location.hash !== '#graph') {
history.replaceState(null, '', window.location.pathname + '#graph');
}
} else {
if (window.location.hash) {
history.replaceState(null, '', window.location.pathname);
}
}
if (view === 'map') {
document.getElementById('btn-view-map').classList.add('active');
document.getElementById('btn-view-map').classList.remove('btn-outline-primary');
document.getElementById('btn-view-map').classList.add('btn-primary');
document.getElementById('btn-view-graph').classList.remove('active');
document.getElementById('btn-view-graph').classList.remove('btn-primary');
document.getElementById('btn-view-graph').classList.add('btn-outline-primary');
document.getElementById('map-view').style.display = 'block';
document.getElementById('graph-view').style.display = 'none';
document.getElementById('btn-fullscreen').style.display = 'inline-block';
renderMap();
} else {
document.getElementById('btn-view-graph').classList.add('active');
document.getElementById('btn-view-graph').classList.remove('btn-outline-primary');
document.getElementById('btn-view-graph').classList.add('btn-primary');
document.getElementById('btn-view-map').classList.remove('active');
document.getElementById('btn-view-map').classList.remove('btn-primary');
document.getElementById('btn-view-map').classList.add('btn-outline-primary');
document.getElementById('map-view').style.display = 'none';
document.getElementById('graph-view').style.display = 'block';
document.getElementById('btn-fullscreen').style.display = 'none';
renderGraph();
}
}
// Render map view
function renderMap() {
// Preserve highlighted node and path before clearing
const preservedHighlightedNode = highlightedNodeObject;
const preservedPath = highlightedPath;
// Check if this is initial load before saving state
const isInitialLoad = isInitialMapLoad && mapViewState === null;
// Initialize map if it doesn't exist
if (!map) {
// Check if container is already initialized (Leaflet stores map instance in _leaflet_id)
const mapContainer = document.getElementById('map-view');
if (mapContainer && mapContainer._leaflet_id) {
// Container already has a map instance - we need to clear it first
// Try to get and destroy the existing map instance
try {
// Leaflet stores map instances - try to access it
const existingMap = L.Map.prototype.get ? L.Map.prototype.get(mapContainer._leaflet_id) : null;
if (existingMap && existingMap.destroy) {
existingMap.destroy();
}
} catch (e) {
// If we can't destroy it, just clear the container
console.debug('Could not destroy existing map instance, clearing container');
}
// Clear Leaflet's internal tracking
delete mapContainer._leaflet_id;
if (mapContainer._leaflet) delete mapContainer._leaflet;
// Clear any Leaflet classes and content
mapContainer.className = mapContainer.className
.replace(/\s*leaflet-container\s*/g, ' ')
.replace(/\s*leaflet-touch\s*/g, ' ')
.replace(/\s*leaflet-retina\s*/g, ' ')
.trim();
mapContainer.innerHTML = '';
mapContainer.style.cssText = ''; // Clear any inline styles Leaflet added
}
// First time initialization
try {
map = L.map('map-view', {
zoomControl: true,
preferCanvas: false,
zoomAnimation: true, // Enable smooth zooming
zoomAnimationThreshold: 4, // Only animate if zoom difference is >= 4
fadeAnimation: true, // Enable fade animation for markers
markerZoomAnimation: true // Enable zoom animation for markers
}).setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Disable popup auto-pan globally to prevent bounding boxes
L.Popup.prototype.options.autoPan = false;
mapViewState = null; // No previous state to preserve
isInitialMapLoad = true; // This is the initial map creation
// Clear highlights when clicking on map (not on a marker)
map.on('click', (e) => {
// Only clear if clicking directly on the map, not on a marker or popup
const target = e.originalEvent.target;
if (target && target.classList.contains('leaflet-container')) {
clearHighlights();
}
});
// Preserve highlights when zooming/panning
map.on('zoomend', () => {
if (highlightedNodeObject) {
// Re-apply highlights after zoom using the actual node object
highlightNodeConnectionsForNode(highlightedNodeObject);
}
// Also re-apply path highlights if active
if (highlightedPath) {
highlightPath(highlightedPath);
}
});
map.on('moveend', () => {
if (highlightedNodeObject) {
// Re-apply highlights after pan using the actual node object
highlightNodeConnectionsForNode(highlightedNodeObject);
}
// Also re-apply path highlights if active
if (highlightedPath) {
highlightPath(highlightedPath);
}
});
} catch (e) {
console.error('Error creating map:', e);
// If creation fails, clear container and retry after a brief delay
const container = document.getElementById('map-view');
if (container) {
container.innerHTML = '';
container.className = '';
delete container._leaflet_id;
if (container._leaflet) delete container._leaflet;
}
// Retry after a brief delay
setTimeout(() => {
if (!map) {
try {
map = L.map('map-view', {
zoomControl: true,
preferCanvas: false
}).setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Re-render after successful creation
renderMap();
} catch (retryError) {
console.error('Error retrying map creation:', retryError);
}
}
}, 100);
return; // Exit early, will retry
}
}
// Save current map view state before clearing (if not initial load)
if (map && !isInitialLoad) {
const center = map.getCenter();
const zoom = map.getZoom();
mapViewState = {
center: [center.lat, center.lng],
zoom: zoom
};
}
// Clear existing markers and lines more thoroughly
// First, remove all layers that are markers or polylines
if (map) {
const layersToRemove = [];
map.eachLayer(layer => {
// Check for all marker types (L.Marker, L.CircleMarker) and polylines
if (layer instanceof L.Marker ||
layer instanceof L.CircleMarker ||
layer instanceof L.Polyline ||
(layer.options && (layer.options.radius !== undefined))) { // Catch any circle markers
layersToRemove.push(layer);
}
});
layersToRemove.forEach(layer => {
map.removeLayer(layer);
// Explicitly unbind popups and tooltips to clear any cached state
if (layer.unbindPopup) layer.unbindPopup();
if (layer.unbindTooltip) layer.unbindTooltip();
});
}
// Clear node and edge maps
nodeMarkers.clear();
nodeMarkersByPrefix.clear();
edgeLines.clear();
edgeToNodes.clear();
allEdgeStyles.clear();
allNodeStyles.clear();
highlightedEdges.clear();
// Note: Don't clear highlightedPath here - it will be re-applied after rendering if preservedPath exists
if (filteredNodes.length === 0) {
// Restore view state even if no nodes
if (mapViewState) {
map.setView(mapViewState.center, mapViewState.zoom);
}
return;
}
const bounds = [];
// Add edges FIRST (so they appear below nodes)
filteredEdges.forEach(edge => {
// Resolve endpoints from filtered set only (so we don't draw edges to nodes outside node timeframe)
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
if (!fromNode || !toNode) return;
const color = getEdgeColor(edge);
const weight = Math.max(1, Math.log10(edge.observation_count) * 2);
const line = L.polyline(
[[fromNode.latitude, fromNode.longitude], [toNode.latitude, toNode.longitude]],
{
color: color,
weight: weight,
opacity: getEdgeOpacity(edge),
pane: 'overlayPane' // Use overlayPane for edges (lower z-index)
}
);
// Simple hover tooltip for quick info
line.bindTooltip(`
${formatPrefixWithBytes(edge.from_prefix)}${formatPrefixWithBytes(edge.to_prefix)}<br>
Observations: ${edge.observation_count}<br>
Distance: ${edge.geographic_distance ? edge.geographic_distance.toFixed(1) + ' km' : 'N/A'}<br>
Last Seen: ${edge.last_seen ? new Date(edge.last_seen).toLocaleString() : 'Never'}
`);
// Create information-rich popup tooltip (similar to nodes) for click
const fromPrefixDisplay = formatPrefixWithBytes(edge.from_prefix);
const toPrefixDisplay = formatPrefixWithBytes(edge.to_prefix);
const edgeTooltipContent = `
<div style="min-width: 250px;">
<strong style="font-size: 1.1em;">${fromPrefixDisplay}${toPrefixDisplay}</strong><br>
<hr style="margin: 5px 0; border-color: #666;">
<table style="width: 100%; font-size: 0.9em;">
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>From:</strong></td>
<td style="padding: 2px 0;">${fromNode ? escapeHtml(fromNode.name) : escapeHtml(edge.from_prefix)}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>To:</strong></td>
<td style="padding: 2px 0;">${toNode ? escapeHtml(toNode.name) : escapeHtml(edge.to_prefix)}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Observations:</strong></td>
<td style="padding: 2px 0;">${edge.observation_count}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Distance:</strong></td>
<td style="padding: 2px 0;">${edge.geographic_distance ? edge.geographic_distance.toFixed(2) + ' km' : 'N/A'}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Avg Hop Position:</strong></td>
<td style="padding: 2px 0;">${edge.avg_hop_position ? edge.avg_hop_position.toFixed(2) : 'N/A'}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>First Seen:</strong></td>
<td style="padding: 2px 0;">${edge.first_seen ? new Date(edge.first_seen).toLocaleString() : 'Unknown'}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Last Seen:</strong></td>
<td style="padding: 2px 0;">${edge.last_seen ? new Date(edge.last_seen).toLocaleString() : 'Unknown'}</td>
</tr>
${edge.from_public_key ? `<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>From Public Key:</strong></td>
<td style="padding: 2px 0;"><code style="font-size: 0.85em;">${formatPublicKey(edge.from_public_key)}</code></td>
</tr>` : ''}
${edge.to_public_key ? `<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>To Public Key:</strong></td>
<td style="padding: 2px 0;"><code style="font-size: 0.85em;">${formatPublicKey(edge.to_public_key)}</code></td>
</tr>` : ''}
</table>
</div>
`;
line.bindPopup(edgeTooltipContent, {
maxWidth: 300,
className: 'mesh-edge-popup',
autoPan: false, // Prevent bounding box/auto-pan when popup opens
autoPanPadding: [0, 0], // No padding for auto-pan
closeOnClick: false // Don't close on map click
});
// Open popup on click and hide hover tooltip
line.on('click', (e) => {
// Prevent default behavior that might cause selection box
if (e.originalEvent) {
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
}
// Close hover tooltip if it's open
line.closeTooltip();
// Open popup at the exact click location (e.latlng is the clicked point on the line)
// Use openPopup with the latlng and explicitly disable autoPan
const popup = line.getPopup();
if (popup) {
popup.setLatLng(e.latlng);
popup.options.autoPan = false;
popup.openOn(map);
} else {
// Fallback: use openPopup with options
line.openPopup(e.latlng, {
autoPan: false
});
}
});
line.addTo(map);
// Store edge line for highlighting, along with the actual node objects it connects
// Normalize to lowercase for consistent matching
const edgeKey = `${edge.from_prefix.toLowerCase()}-${edge.to_prefix.toLowerCase()}`;
edgeLines.set(edgeKey, line);
// Store the actual node objects this edge connects (not just prefixes)
edgeToNodes.set(edgeKey, { fromNode: fromNode, toNode: toNode });
});
// Add markers for nodes AFTER edges (so they appear on top and are easier to click)
filteredNodes.forEach(node => {
// Calculate node size based on current filtered edges (this changes when filters change)
const nodeSize = getNodeSize(node);
// Calculate border weight that scales with node size
const borderWeight = getBorderWeight(nodeSize, node.is_starred);
// Create marker with calculated size and border weight
const marker = L.circleMarker([node.latitude, node.longitude], {
radius: nodeSize,
fillColor: node.role === 'roomserver' ? '#198754' : '#0d6efd',
color: node.is_starred ? '#ffc107' : '#fff',
weight: borderWeight,
opacity: 1,
fillOpacity: 0.8,
pane: 'markerPane' // Use markerPane for nodes (higher z-index than overlayPane)
});
// Create information-rich tooltip with all node details
// Match edges to this node by public_key or prefix (including prefix prefix-match: node "a58296" matches edge "a582")
const connections = filteredEdges.filter(e => {
const fromMatch = (e.from_public_key && e.from_public_key === node.public_key) || e.from_prefix === node.prefix || (node.prefix && e.from_prefix && (node.prefix.startsWith(e.from_prefix) || e.from_prefix.startsWith(node.prefix)));
const toMatch = (e.to_public_key && e.to_public_key === node.public_key) || e.to_prefix === node.prefix || (node.prefix && e.to_prefix && (node.prefix.startsWith(e.to_prefix) || e.to_prefix.startsWith(node.prefix)));
return fromMatch || toMatch;
});
const incoming = connections.filter(e => (e.to_public_key && e.to_public_key === node.public_key) || e.to_prefix === node.prefix || (node.prefix && e.to_prefix && (node.prefix.startsWith(e.to_prefix) || e.to_prefix.startsWith(node.prefix)))).length;
const outgoing = connections.filter(e => (e.from_public_key && e.from_public_key === node.public_key) || e.from_prefix === node.prefix || (node.prefix && e.from_prefix && (node.prefix.startsWith(e.from_prefix) || e.from_prefix.startsWith(node.prefix)))).length;
// Infer prefix byte width only from edges we know are this node (public_key match, or exact prefix match). Avoid prefix prefix-match so we don't count edges from other nodes that share a shorter prefix (e.g. another "a7" node).
const connectionsForByteWidth = filteredEdges.filter(e =>
(e.from_public_key && e.from_public_key === node.public_key) || (e.to_public_key && e.to_public_key === node.public_key) || e.from_prefix === node.prefix || e.to_prefix === node.prefix
);
let numBytes = 1;
for (const e of connectionsForByteWidth) {
const n = Math.max((e.from_prefix && e.from_prefix.length) || 0, (e.to_prefix && e.to_prefix.length) || 0) / 2;
if (n > numBytes) numBytes = n;
}
if (connectionsForByteWidth.length === 0) {
numBytes = 1;
}
numBytes = Math.min(3, Math.max(1, numBytes));
const tooltipContent = `
<div style="min-width: 250px;">
<strong style="font-size: 1.1em;">${escapeHtml(node.name)}</strong><br>
<hr style="margin: 5px 0; border-color: #666;">
<table style="width: 100%; font-size: 0.9em;">
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Prefix:</strong></td>
<td style="padding: 2px 0;">${formatPrefixAsByteChunksHTML(node.public_key || node.prefix || '', numBytes)}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Public Key:</strong></td>
<td style="padding: 2px 0;"><code style="font-size: 0.85em;">${formatPublicKey(node.public_key)}</code></td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Role:</strong></td>
<td style="padding: 2px 0; text-transform: capitalize;">${escapeHtml(node.role)}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Location:</strong></td>
<td style="padding: 2px 0;">${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Starred:</strong></td>
<td style="padding: 2px 0;">${node.is_starred ? '<i class="fas fa-star" style="color: #ffc107;"></i> Yes' : 'No'}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Last Heard:</strong></td>
<td style="padding: 2px 0;">${node.last_heard ? new Date(node.last_heard).toLocaleString() : 'Never'}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Last Advert:</strong></td>
<td style="padding: 2px 0;">${node.last_advert_timestamp ? new Date(node.last_advert_timestamp).toLocaleString() : 'Never'}</td>
</tr>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Connections:</strong></td>
<td style="padding: 2px 0;">${connections.length} total (${incoming} incoming, ${outgoing} outgoing)</td>
</tr>
</table>
</div>
`;
marker.bindPopup(tooltipContent, {
maxWidth: 300,
className: 'mesh-node-popup',
autoPan: false // Prevent bounding box/auto-pan when popup opens
});
// Add click handler to highlight connections - pass the actual node object
marker.on('click', (e) => {
// Clear path highlights when clicking a node
if (highlightedPath) {
clearPathHighlight();
}
highlightNodeConnectionsForNode(node);
// Close hover tooltip if it's open (though nodes don't have hover tooltips, this is for consistency)
marker.closeTooltip();
// Open popup on click
marker.openPopup();
});
// Add marker to markerPane (higher z-index than overlayPane where edges are)
marker.addTo(map);
// Store marker with unique node identifier
const nodeId = getNodeId(node);
nodeMarkers.set(nodeId, marker);
// Also store by prefix for backward compatibility (array of markers)
if (!nodeMarkersByPrefix.has(node.prefix)) {
nodeMarkersByPrefix.set(node.prefix, []);
}
nodeMarkersByPrefix.get(node.prefix).push({ marker: marker, node: node });
bounds.push([node.latitude, node.longitude]);
});
// Clear highlight tracking (but preserve the node prefix to re-apply)
// Note: preservedHighlightedNode was already declared at the start of renderMap()
highlightedEdges.clear();
allEdgeStyles.clear();
// Temporarily clear highlightedNode to prevent issues during render
// We'll restore it after rendering if preservedHighlightedNode exists
// Note: preservedHighlightedNode was already declared at the start of renderMap()
highlightedNode = null;
// Fit to bounds on initial load, otherwise restore previous view state
if (isInitialLoad && bounds.length > 0) {
// First time rendering with nodes - fit to bounds
map.fitBounds(bounds, { padding: [50, 50] });
isInitialMapLoad = false; // Mark that initial load is complete
} else if (mapViewState) {
// Subsequent renders - restore previous view state
map.setView(mapViewState.center, mapViewState.zoom);
} else if (bounds.length > 0) {
// Fallback: fit to bounds if no saved state
map.fitBounds(bounds, { padding: [50, 50] });
}
// Re-apply highlights immediately after rendering if we had a highlighted node
// Use double requestAnimationFrame to ensure all layers are fully rendered
if (preservedHighlightedNode) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
highlightNodeConnectionsForNode(preservedHighlightedNode);
});
});
}
// Re-apply path highlights if they were active
if (preservedPath) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
highlightPath(preservedPath);
});
});
}
}
// Render graph view
function renderGraph() {
const container = document.getElementById('graph-view');
// Prepare vis-network data - use unique node IDs to handle prefix collisions
// Remove duplicates by using a Map keyed by unique node ID
const nodeMapById = new Map();
filteredNodes.forEach(node => {
const nodeId = getNodeId(node); // Use unique ID (prefix-lat-lon) instead of just prefix
// Only add if we haven't seen this unique ID before
if (!nodeMapById.has(nodeId)) {
// Use shorter label to reduce overlap, show full name in tooltip
// For very long names, use just the prefix or a very short version
let shortLabel = node.name;
if (node.name.length > 25) {
// For very long names, prefer showing prefix or first part
shortLabel = node.prefix.toUpperCase() + ': ' + (node.name.length > 15 ? node.name.substring(0, 15) + '...' : node.name);
} else if (node.name.length > 20) {
shortLabel = node.name.substring(0, 20) + '...';
}
nodeMapById.set(nodeId, {
id: nodeId,
label: shortLabel,
title: `${node.name} (${node.prefix})`, // Full name in tooltip
prefix: node.prefix, // Store prefix for reference
color: {
background: node.role === 'roomserver' ? '#198754' : '#0d6efd',
border: node.is_starred ? '#ffc107' : '#fff',
highlight: {
background: node.role === 'roomserver' ? '#10b981' : '#f59e0b',
border: '#d97706'
},
hover: {
background: node.role === 'roomserver' ? '#10b981' : '#f59e0b',
border: '#d97706'
}
},
size: getNodeSize(node),
borderWidth: getBorderWeight(getNodeSize(node), node.is_starred),
font: { size: 12 }
});
}
});
const nodes = Array.from(nodeMapById.values());
// Remove duplicate edges by using a Set keyed by edge ID
const edgeMapById = new Map();
filteredEdges.forEach(edge => {
// Resolve endpoints from filtered set only (so we don't draw edges to nodes outside node timeframe)
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
if (!fromNode || !toNode) return;
// Use unique node IDs for from/to to handle prefix collisions
const fromNodeId = getNodeId(fromNode);
const toNodeId = getNodeId(toNode);
const edgeId = `${fromNodeId}-${toNodeId}`;
// Only add if we haven't seen this edge ID before
if (!edgeMapById.has(edgeId)) {
edgeMapById.set(edgeId, {
id: edgeId, // Use unique IDs for edge ID
from: fromNodeId, // Use unique node ID
to: toNodeId, // Use unique node ID
width: Math.max(1, Math.log10(edge.observation_count) * 2),
color: {
color: getEdgeColor(edge),
opacity: getEdgeOpacity(edge)
},
arrows: 'to',
title: `${edge.from_prefix}${edge.to_prefix} (${edge.observation_count} obs)`
});
}
});
const edges = Array.from(edgeMapById.values());
const data = { nodes: nodes, edges: edges };
const options = {
nodes: {
shape: 'dot',
font: {
size: 13,
color: '#ffffff',
strokeWidth: 2,
strokeColor: '#000000',
face: 'Arial',
align: 'center'
},
labelHighlightBold: true,
// Reduce label overlap by adjusting label distance
margin: 8,
// Global fallback so hover/highlight are never default gray (#848484)
color: {
highlight: { border: '#d97706', background: '#f59e0b' },
hover: { border: '#d97706', background: '#f59e0b' }
},
chosen: {
node: function(values, id, selected, hovering) {
if (hovering || selected) {
values.color = {
background: '#f59e0b',
border: '#d97706',
highlight: { background: '#f59e0b', border: '#d97706' },
hover: { background: '#f59e0b', border: '#d97706' }
};
values.font = { size: 15, color: '#fef3c7', strokeWidth: 3, strokeColor: '#92400e' };
values.size = values.size * 1.2; // Slightly enlarge on hover/select
}
}
}
},
edges: {
smooth: {
type: 'continuous',
roundness: 0.5
}
// No global highlight/hover so our direction colors (blue/green/purple) show when a node is selected
},
physics: {
enabled: true,
solver: 'forceAtlas2Based',
stabilization: {
iterations: 600,
fit: true
},
forceAtlas2Based: {
gravitationalConstant: -1200,
centralGravity: 0.005,
springLength: 220,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 1.0
}
},
interaction: {
hover: true,
tooltipDelay: 200,
zoomView: true,
dragView: true,
dragNodes: false,
selectConnectedEdges: false
},
layout: {
improvedLayout: false, // Disabled due to positioning issues with large graphs
hierarchical: {
enabled: false
}
},
configure: {
enabled: false
}
};
// Instead of destroying and recreating, update the existing network to prevent flashing
if (graphNetwork) {
// Update data and re-enable physics so the new graph runs stabilization again
graphNetwork.setData(data);
const updateOptions = {
nodes: options.nodes,
edges: options.edges,
physics: options.physics,
interaction: options.interaction,
layout: options.layout
};
graphNetwork.setOptions(updateOptions);
} else {
// Only create new network if it doesn't exist
graphNetwork = new vis.Network(container, data, options);
// Freeze layout after stabilization so the graph settles instead of drifting
graphNetwork.on('stabilizationIterationsDone', () => {
graphNetwork.setOptions({ physics: false });
});
// Apply direction colors to edges connected to a node (for hover and click)
function applyEdgeHighlightForNodeId(focusNodeId) {
if (!graphNetwork || !focusNodeId) return;
const edgeIdsToHighlight = [];
const edgeStyles = {};
const bidirectionalEdgeIds = new Set();
filteredEdges.forEach(edge => {
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
if (!fromNode || !toNode) return;
const fromNodeId = getNodeId(fromNode);
const toNodeId = getNodeId(toNode);
const edgeId = `${fromNodeId}-${toNodeId}`;
if (fromNodeId === focusNodeId || toNodeId === focusNodeId) {
edgeIdsToHighlight.push(edgeId);
const reverseEdgeId = `${toNodeId}-${fromNodeId}`;
if (filteredEdges.some(e => {
let revFromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
let revToNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
if (!revFromNode || !revToNode) return false;
return getNodeId(revFromNode) === toNodeId && getNodeId(revToNode) === fromNodeId;
})) {
bidirectionalEdgeIds.add(edgeId);
bidirectionalEdgeIds.add(reverseEdgeId);
}
let highlightColor;
if (bidirectionalEdgeIds.has(edgeId)) {
highlightColor = '#9333ea';
} else if (fromNodeId === focusNodeId) {
highlightColor = '#3b82f6';
} else {
highlightColor = '#10b981';
}
const baseWidth = Math.max(1, Math.log10(edge.observation_count) * 2);
edgeStyles[edgeId] = {
color: {
color: highlightColor,
opacity: 1.0,
highlight: highlightColor,
hover: highlightColor
},
width: Math.max(3, baseWidth + 2)
};
}
});
const currentEdges = graphNetwork.body.data.edges.get();
const edgeUpdates = currentEdges.map(edge => {
const edgeId = edge.id;
if (edgeIdsToHighlight.includes(edgeId)) {
return { id: edgeId, ...edgeStyles[edgeId] };
}
return {
id: edgeId,
color: { color: '#848484', opacity: 0.2 },
width: 1
};
});
graphNetwork.body.data.edges.update(edgeUpdates);
}
// Restore edges after hover ends: re-apply selected node state or default edge colors
function restoreGraphEdgesAfterHover() {
if (!graphNetwork || currentView !== 'graph') return;
if (highlightedNodeObject) {
highlightNodeConnectionsGraph(highlightedNodeObject);
} else {
const currentEdges = graphNetwork.body.data.edges.get();
const edgeUpdates = [];
for (const edge of currentEdges) {
const edgeId = edge.id;
const originalEdge = filteredEdges.find(e => {
let fromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
let toNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
if (!fromNode || !toNode) return false;
return `${getNodeId(fromNode)}-${getNodeId(toNode)}` === edgeId;
});
if (originalEdge) {
edgeUpdates.push({
id: edgeId,
color: { color: getEdgeColor(originalEdge), opacity: getEdgeOpacity(originalEdge) },
width: Math.max(1, Math.log10(originalEdge.observation_count) * 2)
});
}
}
if (edgeUpdates.length) graphNetwork.body.data.edges.update(edgeUpdates);
}
}
// Event handlers - only set up once when network is first created
graphNetwork.on('hoverNode', (params) => {
if (params.node) applyEdgeHighlightForNodeId(params.node);
});
graphNetwork.on('blurNode', () => {
restoreGraphEdgesAfterHover();
});
graphNetwork.on('click', (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const node = filteredNodes.find(n => getNodeId(n) === nodeId);
if (node) {
// Click on the currently highlighted node = clear (fixes two-click-to-clear)
if (highlightedNodeObject && getNodeId(highlightedNodeObject) === nodeId) {
clearHighlights();
return;
}
highlightNodeConnectionsGraph(node);
showNodeDetails(node);
}
} else if (params.edges.length > 0) {
const edgeId = params.edges[0]; // This is now fromNodeId-toNodeId format
// Find edge by matching the unique node IDs
const edge = filteredEdges.find(e => {
let fromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
let toNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
if (!fromNode || !toNode) return false;
const expectedEdgeId = `${getNodeId(fromNode)}-${getNodeId(toNode)}`;
return expectedEdgeId === edgeId;
});
// Edge details now shown via popup tooltip, not modal
} else {
// Clicked on empty space - clear highlights
clearHighlights();
}
});
}
}
// Highlight node connections on map - new version that takes the actual node object
function highlightNodeConnectionsForNode(clickedNode) {
// Clear previous highlights
clearHighlights();
if (!clickedNode) return;
highlightedNode = clickedNode.prefix; // Store prefix for backward compatibility
highlightedNodeObject = clickedNode; // Store the actual node object
// Show legend
const legend = document.getElementById('connection-legend');
if (legend) {
legend.classList.add('active');
}
// Store original styles for all edges (for dimming non-highlighted ones)
allEdgeStyles.clear();
edgeLines.forEach((line, edgeKey) => {
allEdgeStyles.set(edgeKey, {
color: line.options.color,
weight: line.options.weight,
opacity: line.options.opacity
});
});
// Store original styles for all nodes (for shrinking non-highlighted ones)
allNodeStyles.clear();
filteredNodes.forEach(node => {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (marker) {
allNodeStyles.set(nodeId, {
radius: marker.options.radius,
weight: marker.options.weight,
color: marker.options.color,
fillOpacity: marker.options.fillOpacity
});
}
});
// Create a unique identifier for the clicked node using prefix + location
const clickedNodeId = getNodeId(clickedNode);
// Find all edges connected to this specific node by checking actual node objects
// Use the edgeToNodes map to match edges to the actual clicked node, not just prefix
const highlightedEdgeKeys = new Set();
edgeToNodes.forEach((nodes, edgeKey) => {
// Check if this edge connects to the clicked node by comparing node identifiers
if (nodes.fromNode && nodes.toNode && clickedNode) {
// Create identifiers for the edge's nodes
const fromNodeId = getNodeId(nodes.fromNode);
const toNodeId = getNodeId(nodes.toNode);
// Match if either end of the edge matches the clicked node
if (fromNodeId === clickedNodeId || toNodeId === clickedNodeId) {
highlightedEdgeKeys.add(edgeKey);
}
}
});
// Also get the edge objects for direction determination
const connectedEdges = [];
highlightedEdgeKeys.forEach(edgeKey => {
const edge = filteredEdges.find(e =>
`${e.from_prefix}-${e.to_prefix}` === edgeKey
);
if (edge) {
connectedEdges.push(edge);
}
});
// Check for bidirectional edges
const bidirectionalEdges = new Set();
connectedEdges.forEach(edge => {
const reverseKey = `${edge.to_prefix}-${edge.from_prefix}`;
if (filteredEdges.some(e =>
e.from_prefix === edge.to_prefix && e.to_prefix === edge.from_prefix
)) {
bidirectionalEdges.add(`${edge.from_prefix}-${edge.to_prefix}`);
bidirectionalEdges.add(reverseKey);
}
});
// Highlight connected edges and dim all others
edgeLines.forEach((line, edgeKey) => {
if (highlightedEdgeKeys.has(edgeKey)) {
// This is a connected edge - highlight it
const edge = filteredEdges.find(e =>
`${e.from_prefix}-${e.to_prefix}` === edgeKey
);
if (!edge) return;
// Determine edge direction and color by checking actual node objects
const edgeNodes = edgeToNodes.get(edgeKey);
let highlightColor;
if (bidirectionalEdges.has(edgeKey) || bidirectionalEdges.has(`${edge.to_prefix}-${edge.from_prefix}`)) {
// Bidirectional - purple
highlightColor = '#9333ea';
} else if (edgeNodes && edgeNodes.fromNode) {
const fromNodeId = getNodeId(edgeNodes.fromNode);
if (fromNodeId === clickedNodeId) {
// Outgoing - blue (from node matches clicked node)
highlightColor = '#3b82f6';
} else {
// Incoming - green (to node matches clicked node)
highlightColor = '#10b981';
}
} else {
// Fallback to prefix matching if node objects not available
highlightColor = edge.from_prefix === clickedNode.prefix ? '#3b82f6' : '#10b981';
}
// Store original style
const originalStyle = allEdgeStyles.get(edgeKey);
if (originalStyle) {
highlightedEdges.set(edgeKey, originalStyle);
}
// Apply highlight
line.setStyle({
color: highlightColor,
weight: Math.max(originalStyle?.weight || 2, 4), // Make highlighted edges thicker
opacity: 1.0
});
} else {
// This is not a connected edge - dim it
const originalStyle = allEdgeStyles.get(edgeKey);
if (originalStyle) {
line.setStyle({
color: '#848484', // Gray color for dimmed edges
weight: Math.max(1, originalStyle.weight * 0.5), // Thinner
opacity: 0.2 // Much lower opacity
});
}
}
});
// Build a set of connected node IDs (clicked node + all neighbors)
const connectedNodeIds = new Set([clickedNodeId]);
edgeToNodes.forEach((nodes, edgeKey) => {
if (highlightedEdgeKeys.has(edgeKey)) {
// This edge is connected to the clicked node, so both endpoints are connected
if (nodes.fromNode) {
connectedNodeIds.add(getNodeId(nodes.fromNode));
}
if (nodes.toNode) {
connectedNodeIds.add(getNodeId(nodes.toNode));
}
}
});
// Highlight the clicked node, keep connected nodes at regular size, shrink all others
filteredNodes.forEach(node => {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (!marker) return;
if (nodeId === clickedNodeId) {
// Highlight the clicked node - make it larger
const highlightSize = getNodeSize(node) + 3;
const highlightBorderWeight = getBorderWeight(highlightSize, node.is_starred);
marker.setStyle({
radius: highlightSize,
weight: highlightBorderWeight,
color: '#ffc107',
fillOpacity: 1.0
});
} else if (connectedNodeIds.has(nodeId)) {
// Keep connected nodes at regular size
const originalStyle = allNodeStyles.get(nodeId);
if (originalStyle) {
marker.setStyle(originalStyle);
} else {
// Fallback: restore based on node properties
const nodeSize = getNodeSize(node);
const borderWeight = getBorderWeight(nodeSize, node.is_starred);
marker.setStyle({
radius: nodeSize,
weight: borderWeight,
color: node.is_starred ? '#ffc107' : '#fff',
fillOpacity: 0.8
});
}
} else {
// Shrink all other nodes significantly for visibility
const originalStyle = allNodeStyles.get(nodeId);
if (originalStyle) {
const shrunkSize = Math.max(2, originalStyle.radius * 0.3); // Shrink to 30% of original size, minimum 2px
const shrunkWeight = Math.max(0.5, originalStyle.weight * 0.3);
marker.setStyle({
radius: shrunkSize,
weight: shrunkWeight,
color: originalStyle.color,
fillOpacity: 0.4 // Reduce opacity for dimmed nodes
});
}
}
});
}
// Backward compatibility wrapper - converts prefix to node object
function highlightNodeConnections(nodePrefix) {
// Resolve from filtered set so we only highlight nodes that are currently visible
const node = filteredNodeMap[nodePrefix];
if (node) {
highlightNodeConnectionsForNode(node);
}
}
// Highlight node connections in graph view.
// nodeOrIdOrPrefix: node object (preferred), unique node id string (getNodeId), or prefix string (fallback).
function highlightNodeConnectionsGraph(nodeOrIdOrPrefix) {
if (!graphNetwork || nodeOrIdOrPrefix == null) return;
let clickedNode = null;
if (typeof nodeOrIdOrPrefix === 'object' && nodeOrIdOrPrefix !== null && 'prefix' in nodeOrIdOrPrefix && 'latitude' in nodeOrIdOrPrefix) {
clickedNode = nodeOrIdOrPrefix;
} else if (typeof nodeOrIdOrPrefix === 'string') {
const byId = filteredNodes.find(n => getNodeId(n) === nodeOrIdOrPrefix);
if (byId) {
clickedNode = byId;
} else {
const nodesWithPrefix = filteredNodes.filter(n => n.prefix === nodeOrIdOrPrefix);
if (nodesWithPrefix.length > 0) clickedNode = nodesWithPrefix[0];
}
}
if (!clickedNode) return;
const clickedNodeId = getNodeId(clickedNode);
// Don't call clearHighlights() so switching to another node works (direct state transition)
highlightedNode = clickedNode.prefix;
highlightedNodeObject = clickedNode;
// Show legend
const legend = document.getElementById('connection-legend');
if (legend) {
legend.classList.add('active');
}
// Find all edges connected to this specific node (using unique IDs)
const edgeIdsToHighlight = [];
const edgeStyles = {};
const bidirectionalEdgeIds = new Set();
const connectedNodeIds = new Set([clickedNodeId]);
filteredEdges.forEach(edge => {
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
if (!fromNode || !toNode) return;
const fromNodeId = getNodeId(fromNode);
const toNodeId = getNodeId(toNode);
const edgeId = `${fromNodeId}-${toNodeId}`;
// Check if this edge connects to the clicked node
if (fromNodeId === clickedNodeId || toNodeId === clickedNodeId) {
edgeIdsToHighlight.push(edgeId);
connectedNodeIds.add(fromNodeId);
connectedNodeIds.add(toNodeId);
// Check for bidirectional
const reverseEdgeId = `${toNodeId}-${fromNodeId}`;
if (filteredEdges.some(e => {
let revFromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
let revToNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
if (!revFromNode || !revToNode) return false;
return getNodeId(revFromNode) === toNodeId && getNodeId(revToNode) === fromNodeId;
})) {
bidirectionalEdgeIds.add(edgeId);
bidirectionalEdgeIds.add(reverseEdgeId);
}
// Use connection direction colors: blue outgoing, green incoming, purple bidirectional
let highlightColor;
if (bidirectionalEdgeIds.has(edgeId)) {
highlightColor = '#9333ea'; // Purple for bidirectional
} else if (fromNodeId === clickedNodeId) {
highlightColor = '#3b82f6'; // Blue for outgoing
} else {
highlightColor = '#10b981'; // Green for incoming
}
const baseWidth = Math.max(1, Math.log10(edge.observation_count) * 2);
edgeStyles[edgeId] = {
color: {
color: highlightColor,
opacity: 1.0,
highlight: highlightColor,
hover: highlightColor
},
width: Math.max(3, baseWidth + 2)
};
}
});
// Batch all edge updates in one call to avoid per-item redraws (was ~18s for large graphs)
const currentEdges = graphNetwork.body.data.edges.get();
const edgeUpdates = currentEdges.map(edge => {
const edgeId = edge.id;
if (edgeIdsToHighlight.includes(edgeId)) {
return { id: edgeId, ...edgeStyles[edgeId] };
}
return {
id: edgeId,
color: { color: '#848484', opacity: 0.2 },
width: 1
};
});
graphNetwork.body.data.edges.update(edgeUpdates);
graphNetwork.selectNodes([clickedNodeId]);
// Update all nodes in one batch: one update per graph node so labels aren't overwritten by duplicates
const nodeHighlightColor = clickedNode.role === 'roomserver' ? '#10b981' : '#f59e0b';
const graphNodeIds = graphNetwork.body.data.nodes.getIds();
const nodeUpdates = graphNodeIds.map(nid => {
const n = filteredNodes.find(fn => getNodeId(fn) === nid);
if (!n) return { id: nid, label: '', title: '' };
const isClicked = nid === clickedNodeId;
const isLinked = connectedNodeIds.has(nid);
const bg = n.role === 'roomserver' ? '#198754' : '#0d6efd';
const border = n.is_starred ? '#ffc107' : '#fff';
const hlBg = n.role === 'roomserver' ? '#10b981' : '#f59e0b';
const label = isLinked
? (n.name.length > 22 ? n.name.substring(0, 22) + '... (' + n.prefix + ')' : n.name + ' (' + n.prefix + ')')
: '';
const title = n.name + ' (' + n.prefix + ')';
const update = {
id: nid,
label: label,
title: title,
color: isClicked
? { border: '#d97706', background: nodeHighlightColor, highlight: { border: '#d97706', background: nodeHighlightColor }, hover: { border: '#d97706', background: nodeHighlightColor } }
: { background: bg, border: border, highlight: { background: hlBg, border: '#d97706' }, hover: { background: hlBg, border: '#d97706' } },
borderWidth: isClicked ? 4 : getBorderWeight(getNodeSize(n), n.is_starred),
size: isClicked ? getNodeSize(clickedNode) + 2 : getNodeSize(n)
};
// Force label to render for linked nodes (vis-network can skip if font not set on update)
if (isLinked) {
update.font = { size: 13, color: '#ffffff', strokeWidth: 2, strokeColor: '#000000' };
}
return update;
});
graphNetwork.body.data.nodes.update(nodeUpdates);
}
// Clear all highlights
function clearHighlights() {
// Hide legend
const legend = document.getElementById('connection-legend');
if (legend) {
legend.classList.remove('active');
}
// Clear path highlights if active
if (highlightedPath) {
clearPathHighlight();
}
// Clear map view highlights - restore all node styles
filteredNodes.forEach(node => {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (marker) {
const originalStyle = allNodeStyles.get(nodeId);
if (originalStyle) {
// Restore original size and style
marker.setStyle(originalStyle);
} else {
// Fallback: restore based on node properties
const nodeSize = getNodeSize(node);
const borderWeight = getBorderWeight(nodeSize, node.is_starred);
marker.setStyle({
radius: nodeSize,
weight: borderWeight,
color: node.is_starred ? '#ffc107' : '#fff',
fillOpacity: 0.8
});
}
}
});
// Restore original edge styles for all edges
allEdgeStyles.forEach((originalStyle, edgeKey) => {
const line = edgeLines.get(edgeKey);
if (line) {
line.setStyle(originalStyle);
}
});
highlightedEdges.clear();
allEdgeStyles.clear();
allNodeStyles.clear();
highlightedNode = null;
highlightedNodeObject = null;
// Clear graph view highlights
if (graphNetwork && currentView === 'graph') {
graphNetwork.unselectAll();
// Batch edge and node restores in single update() calls to avoid per-item redraws
const currentEdges = graphNetwork.body.data.edges.get();
const edgeUpdates = [];
for (const edge of currentEdges) {
const edgeId = edge.id;
const originalEdge = filteredEdges.find(e => {
let fromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
let toNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
if (!fromNode || !toNode) return false;
const expectedEdgeId = `${getNodeId(fromNode)}-${getNodeId(toNode)}`;
return expectedEdgeId === edgeId;
});
if (originalEdge) {
edgeUpdates.push({
id: edgeId,
color: { color: getEdgeColor(originalEdge), opacity: getEdgeOpacity(originalEdge) },
width: Math.max(1, Math.log10(originalEdge.observation_count) * 2)
});
}
}
if (edgeUpdates.length) graphNetwork.body.data.edges.update(edgeUpdates);
const graphNodeIds = graphNetwork.body.data.nodes.getIds();
const nodeUpdates = graphNodeIds.map(nodeId => {
const node = filteredNodes.find(fn => getNodeId(fn) === nodeId);
if (!node) return { id: nodeId, label: '', font: { size: 12 } };
const nodeSize = getNodeSize(node);
const borderWidth = getBorderWeight(nodeSize, node.is_starred);
const bg = node.role === 'roomserver' ? '#198754' : '#0d6efd';
const border = node.is_starred ? '#ffc107' : '#fff';
const hlBg = node.role === 'roomserver' ? '#10b981' : '#f59e0b';
return {
id: nodeId,
label: getDefaultNodeLabel(node),
title: node.name + ' (' + node.prefix + ')',
font: { size: 12 },
color: {
background: bg,
border: border,
highlight: { background: hlBg, border: '#d97706' },
hover: { background: hlBg, border: '#d97706' }
},
borderWidth: borderWidth,
size: nodeSize
};
});
graphNetwork.body.data.nodes.update(nodeUpdates);
}
}
// Helper functions
function getNodeSize(node) {
// Calculate degree (number of connections) using current filteredEdges
// This ensures node size updates when filters change
const degree = filteredEdges.filter(e =>
e.from_prefix === node.prefix || e.to_prefix === node.prefix
).length;
// Base size 6, scale with degree, cap at 16
return Math.max(6, Math.min(16, 6 + degree * 0.5));
}
// Helper function to calculate border weight based on node size
function getBorderWeight(nodeSize, isStarred) {
// Scale border weight proportionally with node size
// For small nodes (4px), use ~0.8px border; for large nodes (12px), use ~2.4px border
const baseWeight = Math.max(0.8, nodeSize * 0.2);
return isStarred ? baseWeight * 1.5 : baseWeight;
}
// Default label for a node (matches renderGraph shortLabel logic) for restore on clear
function getDefaultNodeLabel(node) {
if (node.name.length > 25) {
return node.prefix.toUpperCase() + ': ' + (node.name.length > 15 ? node.name.substring(0, 15) + '...' : node.name);
}
if (node.name.length > 20) {
return node.name.substring(0, 20) + '...';
}
return node.name;
}
function getEdgeColor(edge) {
if (!edge.last_seen) return '#6c757d'; // Gray for unknown
const lastSeen = new Date(edge.last_seen);
const now = new Date();
const daysAgo = (now - lastSeen) / (1000 * 60 * 60 * 24);
if (daysAgo < 1) return '#198754'; // Green (recent)
if (daysAgo < 7) return '#ffc107'; // Yellow
if (daysAgo < 30) return '#fd7e14'; // Orange
return '#dc3545'; // Red (old)
}
function getEdgeOpacity(edge) {
if (!edge.last_seen) return 0.3;
const lastSeen = new Date(edge.last_seen);
const now = new Date();
const daysAgo = (now - lastSeen) / (1000 * 60 * 60 * 24);
if (daysAgo < 1) return 0.9;
if (daysAgo < 7) return 0.7;
if (daysAgo < 30) return 0.5;
return 0.3;
}
function showNodeDetails(node) {
const connections = filteredEdges.filter(e =>
e.from_prefix === node.prefix || e.to_prefix === node.prefix
);
const incoming = connections.filter(e => e.to_prefix === node.prefix).length;
const outgoing = connections.filter(e => e.from_prefix === node.prefix).length;
document.getElementById('nodeDetailsTitle').textContent = node.name;
document.getElementById('nodeDetailsBody').innerHTML = `
<table class="table">
<tr><th>Prefix</th><td>${escapeHtml(node.prefix)}</td></tr>
<tr><th>Public Key</th><td><code>${formatPublicKey(node.public_key)}</code></td></tr>
<tr><th>Role</th><td>${escapeHtml(node.role)}</td></tr>
<tr><th>Location</th><td>${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}</td></tr>
<tr><th>Starred</th><td>${node.is_starred ? '<i class="fas fa-star text-warning"></i> Yes' : 'No'}</td></tr>
<tr><th>Last Heard</th><td>${node.last_heard || 'Never'}</td></tr>
<tr><th>Last Advert</th><td>${node.last_advert_timestamp || 'Never'}</td></tr>
<tr><th>Connections</th><td>${connections.length} total (${incoming} incoming, ${outgoing} outgoing)</td></tr>
</table>
`;
// Reuse modal instance or create new one
if (!nodeModal) {
const modalElement = document.getElementById('nodeDetailsModal');
nodeModal = new bootstrap.Modal(modalElement, {
backdrop: true,
keyboard: true,
focus: true
});
}
nodeModal.show();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatPublicKey(publicKey) {
if (!publicKey || publicKey.length < 32) {
return escapeHtml(publicKey || '');
}
// First 8 bytes = 16 hex characters, last 8 bytes = 16 hex characters
const first8Bytes = publicKey.substring(0, 16);
const last8Bytes = publicKey.substring(publicKey.length - 16);
return escapeHtml(`${first8Bytes}...${last8Bytes}`);
}
function refreshData() {
loadStats();
loadData();
}
function exportView() {
// Simple export - could be enhanced to export as image
const data = {
nodes: filteredNodes,
edges: filteredEdges,
exported: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mesh-graph-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function toggleFullscreen() {
const mapContainer = document.getElementById('map-view');
const btn = document.getElementById('btn-fullscreen');
const icon = btn.querySelector('i');
if (!isMapFullscreen) {
// Enter fullscreen
isMapFullscreen = true;
// Use native fullscreen API where available (not on iOS Safari)
if (mapContainer.requestFullscreen) {
mapContainer.requestFullscreen().catch(() => {});
} else if (mapContainer.webkitRequestFullscreen) {
mapContainer.webkitRequestFullscreen();
} else if (mapContainer.msRequestFullscreen) {
mapContainer.msRequestFullscreen();
}
// CSS-based fullscreen (works on all platforms including mobile)
mapContainer.classList.add('fullscreen-mode');
document.body.classList.add('fullscreen-active');
icon.className = 'fas fa-compress';
// Add exit button overlay
const exitBtn = document.createElement('button');
exitBtn.className = 'fullscreen-exit-btn';
exitBtn.innerHTML = '<i class="fas fa-times"></i> Exit Fullscreen';
exitBtn.onclick = toggleFullscreen;
mapContainer.appendChild(exitBtn);
setTimeout(() => { if (map) map.invalidateSize(); }, 100);
} else {
// Exit fullscreen
isMapFullscreen = false;
if (document.exitFullscreen && document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else if (document.webkitExitFullscreen && document.webkitFullscreenElement) {
document.webkitExitFullscreen();
} else if (document.msExitFullscreen && document.msFullscreenElement) {
document.msExitFullscreen();
}
mapContainer.classList.remove('fullscreen-mode');
document.body.classList.remove('fullscreen-active');
icon.className = 'fas fa-expand';
const exitBtn = mapContainer.querySelector('.fullscreen-exit-btn');
if (exitBtn) exitBtn.remove();
setTimeout(() => { if (map) map.invalidateSize(); }, 100);
}
}
// Sync state when the native fullscreen API exits (e.g. ESC key on desktop)
function onNativeFullscreenExit() {
const nativeFullscreen = document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement;
if (!nativeFullscreen && isMapFullscreen) {
isMapFullscreen = false;
const mapContainer = document.getElementById('map-view');
const btn = document.getElementById('btn-fullscreen');
const icon = btn.querySelector('i');
mapContainer.classList.remove('fullscreen-mode');
document.body.classList.remove('fullscreen-active');
icon.className = 'fas fa-expand';
const exitBtn = mapContainer.querySelector('.fullscreen-exit-btn');
if (exitBtn) exitBtn.remove();
setTimeout(() => { if (map) map.invalidateSize(); }, 100);
}
}
document.addEventListener('fullscreenchange', onNativeFullscreenExit);
document.addEventListener('webkitfullscreenchange', onNativeFullscreenExit);
document.addEventListener('msfullscreenchange', onNativeFullscreenExit);
// Socket.IO setup for real-time updates
// Live updates are applied only when the map view is active. When the graph view is active,
// we refresh data in the background but do not re-render the graph, to avoid the chaotic
// re-stabilization layout (nodes clumping, edges tangling) that occurs when vis-network
// runs physics again. User can refresh or switch to map and back to see updated graph.
function setupSocketIO() {
const socket = window.connectionManager.socket;
socket.emit('subscribe_mesh');
function onMeshUpdate(data, label) {
console.log(label, data);
loadStats();
loadData({ skipRender: true }).then(() => {
if (currentView === 'map') {
applyFilters();
}
});
}
socket.on('mesh_edge_added', (data) => onMeshUpdate(data, 'New edge added:'));
socket.on('mesh_edge_updated', (data) => onMeshUpdate(data, 'Edge updated:'));
socket.on('mesh_node_added', (data) => onMeshUpdate(data, 'New node added:'));
}
</script>
</div>
{% endblock %}