Files
agessaman 6c8151389b Enhance MeshGraph edge promotion logic and update BotDataViewer API
- Updated the MeshGraph class to prevent promoting a 1-byte edge to a 3-byte edge when the existing 1-byte edge lacks public keys, ensuring accurate observation attribution.
- Added a new test case to verify the behavior of edge promotion under specific conditions.
- Modified the BotDataViewer API to return the prefix length dynamically based on the edges, improving data consistency and user experience in the web viewer.
- Enhanced the mesh.html template to support displaying prefix byte counts, providing clearer information on node connections.
2026-03-09 09:43:49 -07:00

2435 lines
114 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>
</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>
<!-- 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 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 = {{ prefix_hex_chars|default(2) }};
// 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'
},
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';
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';
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);
}
// 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 = io();
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 %}