Files
agessaman 1fb0dbb72b chore(web_viewer): drop debug console logging from templates
Removes 26 console.log calls, including contacts.html dumping whole data
structures and sample contact records on every render. Variables and
callback parameters that existed only to feed those logs go with them
(deviceTypes, anyNewDevices, an availableEdges debug block, and a
realtime status handler whose body was nothing but a log), so no new
no-unused-vars warnings are introduced — the count drops 60 to 59.

console.warn and console.error are kept. One console.log survives in
realtime.html: the decoder key-count line is a one-time startup message
with real diagnostic value, and removing it would mean deleting two
counters and their increments inside a loop.

Also normalizes the mesh page's user-visible "Neighbours" strings to
match the project's American spelling. The option value stays
"neighbors", so evidence-mode filtering is untouched.
2026-08-07 13:44:31 -07:00

3552 lines
156 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 + header row ~100px */
#mesh-graph-container {
display: flex;
flex-direction: column;
height: calc(100vh - 78px);
}
/* Reclaim every vertical pixel for the visualization: drop the footer and the
container's top gap on this page only (deliberate convention break) */
body:has(#mesh-graph-container) footer {
display: none;
}
body:has(#mesh-graph-container) .container-fluid.mt-4 {
margin-top: 0.5rem !important;
}
/* Compact single-row header: title + stat chips + controls share the line to
maximize the visualization viewport (deliberately breaks the usual card grid) */
.mesh-title {
font-size: 1.3rem;
font-weight: 700;
white-space: nowrap;
}
.mesh-stat-chip {
display: flex;
align-items: center;
gap: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 3px 10px;
}
.mesh-stat-chip > i {
color: var(--text-muted);
font-size: 0.8rem;
}
.mesh-stat-text {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.mesh-stat-value {
font-weight: 700;
font-size: 0.95rem;
}
.mesh-stat-label {
font-size: 0.66rem;
color: var(--text-muted);
white-space: nowrap;
}
.mesh-view-switch .btn,
.mesh-tool-group .btn {
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
}
.mesh-view-switch .btn:hover,
.mesh-tool-group .btn:hover {
background: var(--bg-tertiary);
color: var(--text-color);
}
.mesh-view-switch .btn.active {
background: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.mesh-tool-group .btn.active {
background: #21918c; /* viridis teal: matches the heat ramp it enables */
border-color: #21918c;
color: #fff;
}
@media (max-width: 991.98px) {
.mesh-btn-text {
display: none; /* icon-only view switch on small screens */
}
.mesh-stat-label {
font-size: 0.62rem;
}
.mesh-stat-chip {
padding: 2px 8px;
}
}
/* Mobile: the header and filter panel stack tall, so a fixed-height flex
container starves the visualization (and strands its absolutely-positioned
legends). Let the page scroll and give the visualization its own height. */
@media (max-width: 767.98px) {
#mesh-graph-container {
height: auto;
}
.visualization-row {
flex: none;
}
.visualization-card .card-body {
height: calc(100vh - 160px) !important;
}
#map-view, #graph-view {
min-height: 0;
}
}
#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: 0.5rem;
}
.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">
<!-- Single-row header: title, stat chips, controls (wraps on narrow screens) -->
<div class="mesh-header d-flex align-items-center flex-wrap gap-2 mb-2 mt-2">
<h1 class="mesh-title mb-0 me-1">
<i class="fas fa-project-diagram"></i> Mesh Graph
</h1>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="mesh-stat-chip" title="Total repeaters">
<i class="fas fa-sitemap"></i>
<div class="mesh-stat-text">
<span id="stat-node-count" class="mesh-stat-value text-primary">0</span>
<span class="mesh-stat-label">Repeaters</span>
</div>
</div>
<div class="mesh-stat-chip" title="Total connections">
<i class="fas fa-link"></i>
<div class="mesh-stat-text">
<span id="stat-edge-count" class="mesh-stat-value text-info">0</span>
<span class="mesh-stat-label" id="stat-edge-detail">Edges</span>
</div>
</div>
<div class="mesh-stat-chip" title="Average observations per edge">
<i class="fas fa-chart-line"></i>
<div class="mesh-stat-text">
<span id="stat-avg-obs" class="mesh-stat-value text-success">0</span>
<span class="mesh-stat-label">Avg Obs/Edge</span>
</div>
</div>
<div class="mesh-stat-chip" title="Average edge distance">
<i class="fas fa-ruler"></i>
<div class="mesh-stat-text">
<span id="stat-avg-dist" class="mesh-stat-value text-warning">0</span>
<span class="mesh-stat-label">Avg km</span>
</div>
</div>
</div>
<div class="ms-auto d-flex align-items-center flex-wrap gap-2">
<div class="btn-group mesh-view-switch" role="group" aria-label="View">
<button type="button" class="btn btn-sm active" id="btn-view-map" title="Map view">
<i class="fas fa-map"></i><span class="mesh-btn-text"> Map</span>
</button>
<button type="button" class="btn btn-sm" id="btn-view-graph" title="Graph view">
<i class="fas fa-project-diagram"></i><span class="mesh-btn-text"> Graph</span>
</button>
</div>
<div class="btn-group mesh-tool-group" role="group" aria-label="Tools">
<button class="btn btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#filterPanel" title="Filters">
<i class="fas fa-filter"></i>
</button>
<button class="btn btn-sm" type="button" id="btn-heat" title="Hot connections: color edges by traffic (viridis)">
<i class="fas fa-fire"></i>
</button>
<button class="btn btn-sm" type="button" id="btn-mesh-theme" title="Toggle map dark mode (independent of site theme)">
<i class="fas fa-moon" id="mesh-theme-icon"></i>
</button>
<button class="btn btn-sm" type="button" id="btn-mesh-refresh" title="Refresh data">
<i class="fas fa-sync-alt"></i>
</button>
<button class="btn btn-sm" type="button" id="btn-mesh-export" title="Export JSON">
<i class="fas fa-download"></i>
</button>
<button class="btn btn-sm" type="button" id="btn-fullscreen" title="Fullscreen">
<i class="fas fa-expand"></i>
</button>
</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="0" max="100" value="0">
<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">
<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">
<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-evidence" class="form-label">Evidence</label>
<select class="form-select" id="filter-evidence" title="Multi-byte only: build the graph solely from unique multi-byte path observations, ignoring ambiguous single-byte evidence. Neighbors only: only links this node confirmed directly by zero-hop discovery, with measured SNR.">
<option value="all">All Evidence</option>
<option value="multibyte" selected>Multi-byte Only</option>
<option value="neighbors">Neighbors Only</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">
<label class="form-check-label" for="filter-starred">Show Only Starred</label>
</div>
</div>
</div>
<div class="col-md-2">
<label for="filter-search" class="form-label">Search Node</label>
<input type="text" class="form-control" id="filter-search" placeholder="Name, prefix, or path...">
</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>
<hr style="margin: 4px 0;">
<div class="connection-legend-item">
<span style="display: inline-block; width: 18px; border-top: 4px solid currentColor; vertical-align: middle;"></span>
<span>Confirmed neighbor (zero-hop)</span>
</div>
<div class="connection-legend-item">
<span style="display: inline-block; width: 18px; border-top: 2px solid currentColor; vertical-align: middle;"></span>
<span>Multi-byte evidence</span>
</div>
<div class="connection-legend-item">
<span style="display: inline-block; width: 18px; border-top: 2px dashed currentColor; vertical-align: middle;"></span>
<span>Single-byte (uncertain)</span>
</div>
</div>
<!-- Hot-connections (viridis) scale, shown when heat coloring is active -->
<div id="heat-legend" class="heat-legend">
<div class="heat-legend-title">Observations</div>
<div class="heat-legend-bar"></div>
<div class="heat-legend-labels">
<span id="heat-legend-min">1</span>
<span id="heat-legend-max">1</span>
</div>
<div class="heat-legend-note">dots: repeater totals across paths</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.
Colored by the mesh display theme (.mesh-dark), not the site theme, because
it floats over the map/graph whose darkness is toggled independently. */
.connection-legend {
position: absolute;
bottom: 30px;
right: 10px;
background: rgba(255, 255, 255, 0.95);
color: #212529;
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
border: 1px solid rgba(0,0,0,0.1);
z-index: 1000;
font-size: 0.85em;
display: none;
}
.connection-legend.active {
display: block;
}
.connection-legend strong {
color: inherit;
font-weight: 600;
}
.connection-legend-item {
display: flex;
align-items: center;
margin: 5px 0;
color: inherit;
}
.connection-legend-color {
width: 20px;
height: 3px;
margin-right: 8px;
display: inline-block;
}
.mesh-dark .connection-legend {
background: rgba(45, 45, 45, 0.95);
color: #e9ecef;
border-color: #495057;
}
/* Hot-connections (viridis) scale legend */
.heat-legend {
position: absolute;
bottom: 30px;
left: 10px;
background: rgba(255, 255, 255, 0.95);
color: #212529;
padding: 8px 10px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
border: 1px solid rgba(0,0,0,0.1);
z-index: 1000;
font-size: 0.75em;
display: none;
min-width: 140px;
}
.heat-legend.active {
display: block;
}
.heat-legend-title {
font-weight: 600;
margin-bottom: 4px;
}
.heat-legend-bar {
height: 8px;
border-radius: 4px;
background: linear-gradient(90deg, #440154, #3b528b, #21918c, #5ec962, #fde725);
}
.heat-legend-labels {
display: flex;
justify-content: space-between;
margin-top: 2px;
color: #6c757d;
}
.heat-legend-note {
margin-top: 2px;
font-size: 0.9em;
color: #6c757d;
}
.mesh-dark .heat-legend-note {
color: #adb5bd;
}
.mesh-dark .heat-legend {
background: rgba(45, 45, 45, 0.95);
color: #e9ecef;
border-color: #495057;
}
/* Dark mode compresses the cold (near-black) end of the ramp out of the scale */
.mesh-dark .heat-legend-bar {
background: linear-gradient(90deg, #414487, #2c728e, #22a884, #7ad151, #fde725);
}
.mesh-dark .heat-legend-labels {
color: #adb5bd;
}
/* Mesh display dark theme: Leaflet chrome + graph canvas.
Applied via .mesh-dark on #mesh-graph-container, independent of [data-theme]. */
#graph-view {
background: #ffffff;
}
.mesh-dark #graph-view {
background: #1a1a1a;
}
.mesh-dark .leaflet-container {
background: #1a1a1a;
}
.mesh-dark .leaflet-popup-content-wrapper,
.mesh-dark .leaflet-popup-tip {
background: #2d2d2d;
color: #e9ecef;
}
.mesh-dark .leaflet-control-zoom a {
background: #2d2d2d;
color: #e9ecef;
border-color: #495057;
}
.mesh-dark .leaflet-control-zoom a:hover {
background: #3d3d3d;
}
.mesh-dark .leaflet-control-attribution {
background: rgba(26, 26, 26, 0.7);
color: #adb5bd;
}
.mesh-dark .leaflet-control-attribution a {
color: #6ea8fe;
}
.mesh-dark .mesh-node-popup .leaflet-popup-content code,
.mesh-dark .mesh-edge-popup .leaflet-popup-content code {
background-color: rgba(255, 255, 255, 0.12);
color: #e685b5; /* Bootstrap's code pink is unreadable on the dark popup */
}
.mesh-dark .mesh-node-popup .prefix-byte {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
</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 nonce="{{ g.csp_nonce }}">
// 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
let meshLoadInFlight = null;
let pendingMeshLoad = null;
let statsLoadInFlight = null;
let meshLiveRefreshTimer = null;
let meshLiveRefreshPending = false;
let meshLiveRefreshNeedsNodes = false;
let meshLiveRefreshRunning = false;
let meshLiveRefreshRetryCount = 0;
let meshLiveRefreshActiveReloadsNodes = false;
const PREFIX_HEX_CHARS = parseInt('{{ prefix_hex_chars|default(2) }}', 10);
const MESH_LIVE_REFRESH_MS = 30000;
const MESH_LIVE_REFRESH_RETRY_MS = 6000;
const MESH_LIVE_REFRESH_MAX_RETRIES = 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;
}
// Prefix-compatible equality across resolutions (2, 4, or 6 hex chars):
// equal, or one is a prefix of the other. Needed wherever edges and nodes
// may carry different resolutions (e.g. 4-char edge vs 6-char node prefix).
function prefixesCompatible(a, b) {
if (!a || !b) return false;
return a === b || a.startsWith(b) || b.startsWith(a);
}
function edgeTouchesNode(edge, node) {
return prefixesCompatible(edge.from_prefix, node.prefix) || prefixesCompatible(edge.to_prefix, node.prefix);
}
// Great-circle distance fallback for edges without a stored distance
// (multi-byte derived edges carry none; compute from resolved node coords)
function haversineKm(lat1, lon1, lat2, lon2) {
const toRad = deg => deg * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function getEdgeDistance(edge, fromNode, toNode) {
if (edge.geographic_distance !== null && edge.geographic_distance !== undefined) {
return edge.geographic_distance;
}
if (fromNode && toNode) {
return haversineKm(fromNode.latitude, fromNode.longitude, toNode.latitude, toNode.longitude);
}
return null;
}
function isSingleByteEdge(edge) {
return edge.evidence === 'singlebyte';
}
// A zero-hop discovery response is a direct RF measurement between two full
// public keys — the strongest evidence class, so it gets a heavier line.
function isNeighborEdge(edge) {
return edge.evidence === 'neighbors';
}
function formatSnr(value) {
return (typeof value === 'number' && isFinite(value)) ? `${value.toFixed(1)} dB` : null;
}
function getEvidenceLabel(edge) {
if (edge.evidence === 'neighbors') {
const snr = formatSnr(edge.snr);
return snr ? `Confirmed neighbor (avg SNR ${snr})` : 'Confirmed neighbor (zero-hop)';
}
if (edge.evidence === 'multibyte') {
return edge.path_count
? `Multi-byte (${edge.path_count} unique path${edge.path_count === 1 ? '' : 's'})`
: 'Multi-byte confirmed';
}
if (edge.evidence === 'singlebyte') {
return 'Single-byte (identity uncertain)';
}
return null;
}
// --- Mesh display theme (dark basemap/graph) ---
// Follows the site theme until the user explicitly toggles it here, after which
// the override is remembered independently of the site theme.
let meshThemeOverride = null; // 'dark' | 'light' | null = follow site theme
function getSiteTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function getMeshTheme() {
return meshThemeOverride || getSiteTheme();
}
const TILE_LAYERS = {
light: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap contributors'
},
dark: {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© OpenStreetMap contributors © CARTO'
}
};
let baseTileLayer = null;
function setBaseTileLayer() {
if (!map) return;
const cfg = TILE_LAYERS[getMeshTheme()];
if (baseTileLayer) {
map.removeLayer(baseTileLayer);
}
baseTileLayer = L.tileLayer(cfg.url, { attribution: cfg.attribution }).addTo(map);
}
function applyMeshTheme() {
const dark = getMeshTheme() === 'dark';
document.getElementById('mesh-graph-container').classList.toggle('mesh-dark', dark);
const icon = document.getElementById('mesh-theme-icon');
if (icon) {
icon.className = dark ? 'fas fa-sun' : 'fas fa-moon';
}
setBaseTileLayer();
}
function toggleMeshTheme() {
meshThemeOverride = getMeshTheme() === 'dark' ? 'light' : 'dark';
try {
localStorage.setItem('meshGraphTheme', meshThemeOverride);
} catch (error) {
console.debug('Error saving mesh theme:', error);
}
applyMeshTheme();
// Heat colors are theme-dependent (dark compresses the cold end), so re-render
if (edgeColorMode === 'heat') {
applyFilters();
}
}
function initMeshTheme() {
try {
const saved = localStorage.getItem('meshGraphTheme');
if (saved === 'dark' || saved === 'light') {
meshThemeOverride = saved;
}
} catch (error) {
console.debug('Error loading mesh theme:', error);
}
applyMeshTheme();
// Track site theme changes (navbar toggle) while un-overridden
new MutationObserver(() => {
if (!meshThemeOverride) {
applyMeshTheme();
if (edgeColorMode === 'heat') {
applyFilters();
}
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
// --- Hot connections: viridis coloring by observation count ---
let edgeColorMode = 'age'; // 'age' (recency, default) | 'heat' (viridis by observations)
// log-domain normalization range, recomputed from filteredEdges in applyFilters
let heatObsRange = { min: 1, max: 1 };
// Viridis control points (matplotlib), piecewise-linearly interpolated
const VIRIDIS_STOPS = [
[0x44, 0x01, 0x54], [0x47, 0x2d, 0x7b], [0x3b, 0x52, 0x8b],
[0x2c, 0x72, 0x8e], [0x21, 0x91, 0x8c], [0x28, 0xae, 0x80],
[0x5e, 0xc9, 0x62], [0xad, 0xdc, 0x30], [0xfd, 0xe7, 0x25]
];
function viridisColor(t) {
const clamped = Math.min(1, Math.max(0, t));
const scaled = clamped * (VIRIDIS_STOPS.length - 1);
const i = Math.min(VIRIDIS_STOPS.length - 2, Math.floor(scaled));
const f = scaled - i;
const [r1, g1, b1] = VIRIDIS_STOPS[i];
const [r2, g2, b2] = VIRIDIS_STOPS[i + 1];
const r = Math.round(r1 + (r2 - r1) * f);
const g = Math.round(g1 + (g2 - g1) * f);
const b = Math.round(b1 + (b2 - b1) * f);
return `rgb(${r},${g},${b})`;
}
// Normalized heat 0..1 for an edge: log-scaled because observation counts are heavy-tailed
function heatT(edge) {
const obs = Math.max(1, edge.observation_count || 1);
const lo = Math.log(heatObsRange.min);
const hi = Math.log(heatObsRange.max);
if (hi <= lo) return 1;
return Math.min(1, Math.max(0, (Math.log(obs) - lo) / (hi - lo)));
}
// On a dark basemap the cold end of viridis (near-black purple) is invisible,
// so compress the ramp to start at the still-visible blue-purple
function viridisThemed(t) {
return getMeshTheme() === 'dark' ? viridisColor(0.25 + 0.75 * t) : viridisColor(t);
}
function heatColor(edge) {
return viridisThemed(heatT(edge));
}
// Resolve an edge's endpoints from the filtered set, the same way the renderers do
function resolveEdgeEndpoints(edge) {
const fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key])
? filteredNodeMapByKey[edge.from_public_key]
: (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
const toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key])
? filteredNodeMapByKey[edge.to_public_key]
: (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
return { fromNode, toNode };
}
// Per-node aggregates over the filtered edges, recomputed in applyFilters:
// degree drives dot size, observation totals drive heat-mode node color.
let nodeHeatTotals = new Map(); // nodeId -> sum of observation counts of touching edges
let nodeDegrees = new Map(); // nodeId -> number of touching edges
let nodeHeatRange = { min: 1, max: 1 };
function computeNodeStats() {
nodeHeatTotals = new Map();
nodeDegrees = new Map();
filteredEdges.forEach(edge => {
const { fromNode, toNode } = resolveEdgeEndpoints(edge);
if (!fromNode || !toNode) return;
const obs = Math.max(1, edge.observation_count || 1);
[fromNode, toNode].forEach(n => {
const id = getNodeId(n);
nodeHeatTotals.set(id, (nodeHeatTotals.get(id) || 0) + obs);
nodeDegrees.set(id, (nodeDegrees.get(id) || 0) + 1);
});
});
let lo = Infinity, hi = 0;
nodeHeatTotals.forEach(v => { if (v < lo) lo = v; if (v > hi) hi = v; });
nodeHeatRange = hi ? { min: lo, max: hi } : { min: 1, max: 1 };
}
// Normalized node heat 0..1: how often this repeater shows up in observed paths
function nodeHeatT(node) {
const total = nodeHeatTotals.get(getNodeId(node)) || 0;
if (!total) return 0;
const lo = Math.log(nodeHeatRange.min);
const hi = Math.log(nodeHeatRange.max);
if (hi <= lo) return 1;
return Math.min(1, Math.max(0, (Math.log(total) - lo) / (hi - lo)));
}
function getNodeFillColor(node) {
if (edgeColorMode === 'heat') {
return viridisThemed(nodeHeatT(node));
}
return node.role === 'roomserver' ? '#198754' : '#0d6efd';
}
// --- Initial map framing: home mesh component ---
// Nodes occasionally carry wrong GPS, and fitting the view to every node can
// stretch the initial frame across continents. Instead, frame the component
// reachable from the bot's location over provenance-trusted evidence edges
// only; wrong-GPS phantoms are rarely connected to it.
let botLocation = null; // {latitude, longitude} from /api/mesh/stats
function getHomeComponentBounds() {
if (!botLocation) return null;
// Every evidence mode tags its edges, so this works in "All Evidence" too.
// Confirmed neighbors count as trusted (they are measured, not inferred),
// and are the only tagged edges present in "Neighbors Only" mode.
const mbEdges = filteredEdges.filter(
e => e.evidence === 'multibyte' || e.evidence === 'neighbors'
);
if (!mbEdges.length) return null;
const adj = new Map(); // nodeId -> Set of neighbor nodeIds
const nodesById = new Map();
mbEdges.forEach(edge => {
const { fromNode, toNode } = resolveEdgeEndpoints(edge);
if (!fromNode || !toNode) return;
const a = getNodeId(fromNode);
const b = getNodeId(toNode);
nodesById.set(a, fromNode);
nodesById.set(b, toNode);
if (!adj.has(a)) adj.set(a, new Set());
if (!adj.has(b)) adj.set(b, new Set());
adj.get(a).add(b);
adj.get(b).add(a);
});
// Seeds: multibyte-connected repeaters within 30 km of the bot,
// falling back to the single nearest one
const seeds = [];
let nearestId = null;
let nearestKm = Infinity;
nodesById.forEach((node, id) => {
const d = haversineKm(botLocation.latitude, botLocation.longitude, node.latitude, node.longitude);
if (d <= 30) seeds.push(id);
if (d < nearestKm) {
nearestKm = d;
nearestId = id;
}
});
if (!seeds.length && nearestId) seeds.push(nearestId);
const visited = new Set(seeds);
const queue = [...seeds];
while (queue.length) {
const neighbors = adj.get(queue.pop());
if (!neighbors) continue;
neighbors.forEach(n => {
if (!visited.has(n)) {
visited.add(n);
queue.push(n);
}
});
}
if (visited.size < 3) return null; // too small to be the home mesh; use default framing
const bounds = [];
visited.forEach(id => {
const n = nodesById.get(id);
bounds.push([n.latitude, n.longitude]);
});
return bounds;
}
function updateHeatLegend() {
const legend = document.getElementById('heat-legend');
if (!legend) return;
legend.classList.toggle('active', edgeColorMode === 'heat');
if (edgeColorMode === 'heat') {
document.getElementById('heat-legend-min').textContent = heatObsRange.min.toLocaleString();
document.getElementById('heat-legend-max').textContent = heatObsRange.max.toLocaleString();
}
}
function syncHeatButton() {
const btn = document.getElementById('btn-heat');
if (btn) {
btn.classList.toggle('active', edgeColorMode === 'heat');
}
}
function toggleHeatMode() {
edgeColorMode = edgeColorMode === 'heat' ? 'age' : 'heat';
syncHeatButton();
applyFilters();
saveFilters();
}
// 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;
}
// 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);
} else {
console.warn(`No edge found for path segment ${fromRepeater.node_id}->${toRepeater.node_id}`);
}
}
// 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
});
}
});
// 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 = getMapNodeSize(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 = getMapNodeSize(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() {
document.getElementById('btn-view-map').addEventListener('click', () => switchView('map'));
document.getElementById('btn-view-graph').addEventListener('click', () => switchView('graph'));
document.getElementById('btn-heat').addEventListener('click', toggleHeatMode);
document.getElementById('btn-mesh-theme').addEventListener('click', toggleMeshTheme);
document.getElementById('btn-mesh-refresh').addEventListener('click', refreshData);
document.getElementById('btn-mesh-export').addEventListener('click', exportView);
document.getElementById('btn-fullscreen').addEventListener('click', toggleFullscreen);
document.getElementById('filter-min-obs').addEventListener('input', onMinObsInput);
document.getElementById('filter-edge-days').addEventListener('change', () => {
saveFilters();
loadData({ reloadNodes: false })
.catch(error => console.error('Error reloading edge window:', error));
});
document.getElementById('filter-node-days').addEventListener('change', () => {
saveFilters();
loadData({ reloadEdges: false })
.catch(error => console.error('Error reloading node window:', error));
});
document.getElementById('filter-starred').addEventListener('change', () => {
applyFilters();
saveFilters();
});
document.getElementById('filter-evidence').addEventListener('change', onEvidenceChange);
document.getElementById('filter-search').addEventListener('input', handleSearchInput);
// 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();
syncHeatButton();
// Apply mesh display theme (dark basemap/graph) before the map is created
initMeshTheme();
setupSocketIO();
// Fetch stats and graph data concurrently; render once both are in so the
// initial map framing can use the bot location from stats
await Promise.all([loadStats(), loadData({ skipRender: true })]);
applyFilters();
// 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');
}
});
});
function isValidMeshNode(node) {
return Boolean(
node
&& typeof node === 'object'
&& typeof node.public_key === 'string'
&& node.public_key.length > 0
&& typeof node.prefix === 'string'
&& node.prefix.length > 0
&& typeof node.name === 'string'
&& Number.isFinite(node.latitude)
&& Number.isFinite(node.longitude)
);
}
async function fetchMeshJson(url, requiredArrayKey = null) {
const response = await fetch(url);
let data;
try {
data = await response.json();
} catch (error) {
throw new Error(`Invalid JSON response from ${url}`);
}
if (!response.ok || !data || typeof data !== 'object' || data.error) {
const detail = data && data.error ? `: ${data.error}` : '';
throw new Error(`Mesh request failed (${response.status})${detail}`);
}
if (requiredArrayKey && !Array.isArray(data[requiredArrayKey])) {
throw new Error(
`Mesh response from ${url} is missing ${requiredArrayKey}`
);
}
if (
requiredArrayKey === 'edges'
&& !data.edges.every(edge =>
edge
&& typeof edge === 'object'
&& typeof edge.from_prefix === 'string'
&& typeof edge.to_prefix === 'string'
)
) {
throw new Error(`Mesh response from ${url} contains invalid edges`);
}
if (
requiredArrayKey === 'nodes'
&& !data.nodes.every(isValidMeshNode)
) {
throw new Error(`Mesh response from ${url} contains invalid nodes`);
}
return data;
}
// Load statistics
function loadStats() {
if (statsLoadInFlight) return statsLoadInFlight;
statsLoadInFlight = (async () => {
try {
const stats = await fetchMeshJson('/api/mesh/stats');
document.getElementById('stat-node-count').textContent = stats.node_count || 0;
document.getElementById('stat-edge-count').textContent = stats.total_edges || 0;
if (stats.multibyte_edges !== undefined) {
document.getElementById('stat-edge-detail').textContent =
`${stats.multibyte_edges} multi-byte confirmed`;
}
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';
if (stats.bot_location) {
botLocation = stats.bot_location; // used to frame the initial map view
}
} catch (error) {
console.error('Error loading stats:', error);
}
})().finally(() => {
statsLoadInFlight = null;
});
return statsLoadInFlight;
}
// --- Min Observations slider ---
// Observation counts are heavy-tailed (median ~single digits, max in the tens of
// thousands), so the slider position maps logarithmically onto a threshold of
// 1..minObsScaleMax. minObsValue (the threshold) is the source of truth: it is
// what gets persisted, and the position is re-derived from it whenever the data
// (and therefore the scale) changes.
let minObsValue = null; // null until restored from storage or defaulted from data
let minObsScaleMax = 100;
function minObsPositionToThreshold(pos) {
return Math.max(1, Math.round(Math.pow(minObsScaleMax, pos / 100)));
}
function minObsThresholdToPosition(threshold) {
const clamped = Math.min(Math.max(threshold, 1), minObsScaleMax);
return Math.round(100 * Math.log(clamped) / Math.log(minObsScaleMax));
}
// Recompute the slider scale from the loaded edges; default the threshold to the
// median observation count on first visit (the old default used the average,
// which sits above the 75th percentile on heavy-tailed data and hid most edges).
function syncMinObsSlider() {
const counts = allEdges.map(e => e.observation_count || 0).sort((a, b) => a - b);
const maxCount = counts.length ? counts[counts.length - 1] : 0;
minObsScaleMax = Math.max(10, maxCount);
if (minObsValue === null) {
minObsValue = counts.length ? Math.max(1, counts[Math.floor(counts.length / 2)]) : 1;
}
document.getElementById('filter-min-obs').value = minObsThresholdToPosition(minObsValue);
}
function updateMinObsLabel() {
const label = document.getElementById('label-min-obs');
if (label) {
label.textContent = `≥ ${minObsValue || 1} obs · ${filteredEdges.length.toLocaleString()}/${allEdges.length.toLocaleString()} edges`;
}
}
function onMinObsInput() {
const pos = parseInt(document.getElementById('filter-min-obs').value, 10) || 0;
minObsValue = minObsPositionToThreshold(pos);
applyFilters();
saveFilters();
}
function normalizeMeshLoadOptions(options) {
return {
skipRender: Boolean(options && options.skipRender === true),
reloadEdges: !options || options.reloadEdges !== false,
reloadNodes: !options || options.reloadNodes !== false,
forceRefresh: Boolean(options && options.forceRefresh === true)
};
}
function mergeMeshLoadOptions(existing, incoming) {
if (!existing) return incoming;
return {
// Render when any queued caller needs a render.
skipRender: existing.skipRender && incoming.skipRender,
reloadEdges: existing.reloadEdges || incoming.reloadEdges,
reloadNodes: existing.reloadNodes || incoming.reloadNodes,
forceRefresh: existing.forceRefresh || incoming.forceRefresh
};
}
// Load nodes and edges. Calls are serialized and requests that arrive while
// one is active are merged into one trailing load.
function loadData(options) {
pendingMeshLoad = mergeMeshLoadOptions(
pendingMeshLoad,
normalizeMeshLoadOptions(options)
);
if (!meshLoadInFlight) {
meshLoadInFlight = drainMeshLoads().finally(() => {
meshLoadInFlight = null;
});
}
return meshLoadInFlight;
}
async function drainMeshLoads() {
let lastError = null;
while (pendingMeshLoad) {
const options = pendingMeshLoad;
pendingMeshLoad = null;
try {
await performMeshLoad(options);
lastError = null;
} catch (error) {
// Continue with work queued while the failed request was active.
lastError = error;
}
}
if (lastError) {
throw lastError;
}
}
async function performMeshLoad(options) {
const skipRender = options.skipRender;
const reloadEdges = options.reloadEdges;
const reloadNodes = options.reloadNodes;
const forceRefresh = options.forceRefresh;
try {
const evidence = document.getElementById('filter-evidence').value;
const edgeDays = document.getElementById('filter-edge-days').value;
const nodeDays = document.getElementById('filter-node-days').value;
const edgeParams = new URLSearchParams();
if (evidence === 'multibyte' || evidence === 'neighbors') edgeParams.set('evidence', evidence);
if (edgeDays) edgeParams.set('days', edgeDays);
if (forceRefresh) edgeParams.set('refresh', '1');
const nodeParams = new URLSearchParams();
if (nodeDays) nodeParams.set('days', nodeDays);
const edgeQuery = edgeParams.toString();
const nodeQuery = nodeParams.toString();
const edgesUrl = edgeQuery
? `/api/mesh/edges?${edgeQuery}`
: '/api/mesh/edges';
const nodesUrl = nodeQuery
? `/api/mesh/nodes?${nodeQuery}`
: '/api/mesh/nodes';
// Fetch edges and nodes concurrently; node prefixes are recomputed locally
// below once the edge prefix length is known, so the nodes request does
// not have to wait for the edges response
const [edgesData, nodesData] = await Promise.all([
reloadEdges ? fetchMeshJson(edgesUrl, 'edges') : Promise.resolve(null),
reloadNodes ? fetchMeshJson(nodesUrl, 'nodes') : Promise.resolve(null)
]);
const nextEdges = edgesData ? edgesData.edges : allEdges;
const nextPrefixHexChars = edgesData
? ((edgesData.prefix_hex_chars && [2, 4, 6].includes(edgesData.prefix_hex_chars))
? edgesData.prefix_hex_chars
: PREFIX_HEX_CHARS)
: graphPrefixHexChars;
const sourceNodes = nodesData ? nodesData.nodes : allNodes;
if (!nextEdges.every(edge =>
edge
&& typeof edge === 'object'
&& typeof edge.from_prefix === 'string'
&& typeof edge.to_prefix === 'string'
)) {
throw new Error(`Mesh response from ${edgesUrl} contains invalid edges`);
}
if (!sourceNodes.every(isValidMeshNode)) {
throw new Error(`Mesh response from ${nodesUrl} contains invalid nodes`);
}
// Prepare a complete candidate graph before replacing any current
// state, so malformed responses cannot blank a working display.
const nextNodes = sourceNodes.map(node => {
const nextNode = { ...node };
if (nextNode.public_key) {
nextNode.prefix = nextNode.public_key
.substring(0, nextPrefixHexChars)
.toLowerCase();
}
return nextNode;
});
const nextNodeMap = {};
const nextNodeMapByKey = {};
nextNodes.forEach(node => {
nextNodeMap[node.prefix] = node;
if (node.public_key) {
nextNodeMapByKey[node.public_key] = node;
}
});
const nextEdgeMap = {};
nextEdges.forEach(edge => {
const key = `${edge.from_prefix}-${edge.to_prefix}`;
nextEdgeMap[key] = edge;
});
allEdges = nextEdges;
allNodes = nextNodes;
graphPrefixHexChars = nextPrefixHexChars;
nodeMap = nextNodeMap;
nodeMapByKey = nextNodeMapByKey;
edgeMap = nextEdgeMap;
// Rescale the min-observations slider to the new edge distribution
if (reloadEdges) {
syncMinObsSlider();
}
if (!skipRender) {
// 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: minObsValue, // threshold, not slider position
edgeDays: document.getElementById('filter-edge-days').value,
nodeDays: document.getElementById('filter-node-days').value,
starredOnly: document.getElementById('filter-starred').checked,
evidence: document.getElementById('filter-evidence').value,
colorMode: edgeColorMode
// 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 && filters.minObs !== null) {
const parsed = parseInt(filters.minObs, 10);
if (!isNaN(parsed) && parsed >= 1) {
// Threshold only; slider position is derived in syncMinObsSlider
// once the data (and therefore the log scale) is loaded
minObsValue = parsed;
}
}
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;
}
if (['all', 'multibyte', 'neighbors'].includes(filters.evidence)) {
document.getElementById('filter-evidence').value = filters.evidence;
}
if (filters.colorMode === 'age' || filters.colorMode === 'heat') {
edgeColorMode = filters.colorMode;
}
// 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 = minObsValue || 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;
});
// Per-node degree and observation totals for sizing and heat coloring
computeNodeStats();
// Live feedback: threshold plus how many edges survive the current filters
updateMinObsLabel();
// Normalize heat colors over the currently visible edges (log domain).
// Loop rather than Math.min(...arr): tens of thousands of edges would
// exceed the engine's argument-count limit.
let heatMin = Infinity, heatMax = 0;
filteredEdges.forEach(e => {
const obs = Math.max(1, e.observation_count || 1);
if (obs < heatMin) heatMin = obs;
if (obs > heatMax) heatMax = obs;
});
heatObsRange = heatMax ? { min: heatMin, max: heatMax } : { min: 1, max: 1 };
updateHeatLegend();
// 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);
});
}
}
}
// Evidence mode is a server-side parameter, so changing it re-fetches data
// (loadData applies the current filters and re-renders when done)
function onEvidenceChange() {
saveFilters();
loadData({ reloadNodes: false })
.catch(error => console.error('Error reloading for evidence mode:', error));
}
// 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);
}
}
document.getElementById('btn-view-map').classList.toggle('active', view === 'map');
document.getElementById('btn-view-graph').classList.toggle('active', view !== 'map');
if (view === 'map') {
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('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);
setBaseTileLayer(); // theme-aware basemap (light OSM / dark CARTO)
// 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', () => {
// Rescale dots/lines for the new zoom before highlights repaint on top
updateMapSizesForZoom();
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);
setBaseTileLayer();
// 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 = getMapEdgeWeight(edge);
const edgeDistance = getEdgeDistance(edge, fromNode, toNode);
const evidenceLabel = getEvidenceLabel(edge);
const line = L.polyline(
[[fromNode.latitude, fromNode.longitude], [toNode.latitude, toNode.longitude]],
{
color: color,
weight: weight,
opacity: getEdgeOpacity(edge),
dashArray: isSingleByteEdge(edge) ? '6 6' : null, // Dash ambiguous single-byte evidence
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>
${evidenceLabel ? 'Evidence: ' + evidenceLabel + '<br>' : ''}
Distance: ${edgeDistance ? edgeDistance.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>
${evidenceLabel ? `<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Evidence:</strong></td>
<td style="padding: 2px 0;">${evidenceLabel}</td>
</tr>` : ''}
${isNeighborEdge(edge) && formatSnr(edge.best_snr) ? `<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Best SNR:</strong></td>
<td style="padding: 2px 0;">${formatSnr(edge.best_snr)}${formatSnr(edge.last_snr) ? ` (last ${formatSnr(edge.last_snr)})` : ''}</td>
</tr>` : ''}
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Distance:</strong></td>
<td style="padding: 2px 0;">${edgeDistance ? edgeDistance.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 node objects and edge data (edge needed for zoom re-weighting)
edgeToNodes.set(edgeKey, { fromNode: fromNode, toNode: toNode, edge: edge });
});
// Add markers for nodes AFTER edges (so they appear on top and are easier to click)
filteredNodes.forEach(node => {
// Zoom-scaled size based on current filtered edges (changes with filters/zoom)
const nodeSize = getMapNodeSize(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: getNodeFillColor(node),
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>
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Path Observations:</strong></td>
<td style="padding: 2px 0;">${(nodeHeatTotals.get(getNodeId(node)) || 0).toLocaleString()}</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.
// Prefer the home mesh component (multi-byte connectivity from the bot's
// location) so wrong-GPS outliers don't stretch the frame.
if (isInitialLoad && bounds.length > 0) {
// Refresh Leaflet's cached container size first: if the map was created
// while the container had no layout yet, fitBounds would compute max zoom
map.invalidateSize();
map.fitBounds(getHomeComponentBounds() || 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.invalidateSize();
map.fitBounds(getHomeComponentBounds() || 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: `${escapeHtml(node.name)} (${escapeHtml(node.prefix)})`, // Full name in tooltip (rendered as HTML by vis-network)
prefix: node.prefix, // Store prefix for reference
color: {
background: getNodeFillColor(node),
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)) {
const evidenceLabel = getEvidenceLabel(edge);
edgeMapById.set(edgeId, {
id: edgeId, // Use unique IDs for edge ID
from: fromNodeId, // Use unique node ID
to: toNodeId, // Use unique node ID
width: getEdgeWeight(edge),
color: {
color: getEdgeColor(edge),
opacity: getEdgeOpacity(edge)
},
dashes: isSingleByteEdge(edge), // Dash ambiguous single-byte evidence
arrows: 'to',
title: `${edge.from_prefix}${edge.to_prefix} (${edge.observation_count} obs)`
+ (evidenceLabel ? ` — ${evidenceLabel}` : '')
});
}
});
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 = getEdgeWeight(edge);
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: getEdgeWeight(originalEdge)
});
}
}
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 = getMapNodeSize(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 = getMapNodeSize(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 = getEdgeWeight(edge);
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 = getNodeFillColor(n);
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 = escapeHtml(n.name) + ' (' + escapeHtml(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 = getMapNodeSize(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: getEdgeWeight(originalEdge)
});
}
}
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 = getNodeFillColor(node);
const border = node.is_starred ? '#ffc107' : '#fff';
const hlBg = node.role === 'roomserver' ? '#10b981' : '#f59e0b';
return {
id: nodeId,
label: getDefaultNodeLabel(node),
title: escapeHtml(node.name) + ' (' + escapeHtml(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) {
// Degree comes from the per-render aggregates (computeNodeStats), which
// resolve endpoints the same way edges are drawn. Base 5, cap 13: large
// enough to click, small enough that dense clusters stay readable.
const degree = nodeDegrees.get(getNodeId(node)) || 0;
return Math.max(5, Math.min(13, 5 + degree * 0.4));
}
// Map dots and lines shrink as you zoom out so dense areas stay readable;
// full size from zoom 12 in, floor of 55% at zoom 7 and out.
function mapZoomScale() {
if (!map) return 1;
const z = map.getZoom();
if (z >= 12) return 1;
if (z <= 7) return 0.55;
return 0.55 + (z - 7) * 0.09;
}
function getMapNodeSize(node) {
return Math.max(3, getNodeSize(node) * mapZoomScale());
}
// Shared edge width: log-scaled, capped so heavy edges don't blanket dense areas
function getEdgeWeight(edge) {
const weight = Math.max(1, Math.min(5, Math.log10(Math.max(1, edge.observation_count || 1)) * 1.5));
// Confirmed zero-hop links are the strongest evidence class, but they
// accumulate observations slowly (one per cycle, cycles are >=12h apart).
// Without a floor an inferred edge seen thousands of times would always
// outweigh a directly measured one, which inverts the confidence order.
return isNeighborEdge(edge) ? Math.max(weight, 3) : weight;
}
function getMapEdgeWeight(edge) {
return Math.max(0.75, getEdgeWeight(edge) * mapZoomScale());
}
// Re-apply zoom-scaled sizes to existing map layers (called on zoomend, before
// any active highlight is re-applied on top). Keeps the saved original styles
// in sync so highlight restores don't resurrect stale sizes.
function updateMapSizesForZoom() {
if (!map || currentView !== 'map') return;
filteredNodes.forEach(node => {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (!marker) return;
const size = getMapNodeSize(node);
const weight = getBorderWeight(size, node.is_starred);
marker.setStyle({ radius: size, weight: weight });
const saved = allNodeStyles.get(nodeId);
if (saved) {
saved.radius = size;
saved.weight = weight;
}
});
edgeToNodes.forEach((entry, edgeKey) => {
if (!entry.edge) return;
const line = edgeLines.get(edgeKey);
if (!line) return;
const weight = getMapEdgeWeight(entry.edge);
line.setStyle({ weight: weight });
const saved = allEdgeStyles.get(edgeKey);
if (saved) {
saved.weight = weight;
}
});
}
// 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 (edgeColorMode === 'heat') return heatColor(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) {
// Heat mode: hot edges pop, cold edges recede (recency shown by color mode 'age')
if (edgeColorMode === 'heat') return 0.45 + 0.5 * heatT(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) {
// Prefix-compatible matching across resolutions (see edgeTouchesNode)
const connections = filteredEdges.filter(e => edgeTouchesNode(e, node));
const incoming = connections.filter(e => prefixesCompatible(e.to_prefix, node.prefix)).length;
const outgoing = connections.filter(e => prefixesCompatible(e.from_prefix, node.prefix)).length;
document.getElementById('nodeDetailsTitle').textContent = node.name;
const table = document.createElement('table');
table.className = 'table';
const addRow = (label, value, code = false) => {
const row = document.createElement('tr');
const heading = document.createElement('th');
heading.textContent = label;
const cell = document.createElement('td');
const valueElement = document.createElement(code ? 'code' : 'span');
valueElement.textContent = String(value ?? '');
cell.appendChild(valueElement);
row.append(heading, cell);
table.appendChild(row);
};
addRow('Prefix', node.prefix);
addRow('Public Key', formatPublicKeyText(node.public_key), true);
addRow('Role', node.role);
addRow('Location', `${Number(node.latitude).toFixed(6)}, ${Number(node.longitude).toFixed(6)}`);
addRow('Starred', node.is_starred ? 'Yes' : 'No');
addRow('Last Heard', node.last_heard || 'Never');
addRow('Last Advert', node.last_advert_timestamp || 'Never');
addRow('Connections', `${connections.length} total (${incoming} incoming, ${outgoing} outgoing)`);
document.getElementById('nodeDetailsBody').replaceChildren(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) {
return escapeHtml(formatPublicKeyText(publicKey));
}
function formatPublicKeyText(publicKey) {
if (!publicKey || publicKey.length < 32) {
return String(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 `${first8Bytes}...${last8Bytes}`;
}
async function refreshData() {
const absorbedPending = meshLiveRefreshPending
|| meshLiveRefreshTimer !== null;
const absorbedNeedsNodes = meshLiveRefreshNeedsNodes;
if (meshLiveRefreshTimer) {
clearTimeout(meshLiveRefreshTimer);
meshLiveRefreshTimer = null;
}
meshLiveRefreshPending = false;
meshLiveRefreshNeedsNodes = false;
meshLiveRefreshRetryCount = 0;
const activeScheduledRefresh = meshLiveRefreshRunning;
const activeReloadsNodes = meshLiveRefreshActiveReloadsNodes;
try {
if (activeScheduledRefresh && meshLoadInFlight) {
await Promise.all([loadStats(), meshLoadInFlight]);
if (activeReloadsNodes) {
applyFilters();
} else {
// The active forced edge refresh is sufficient; fetch only
// the nodes it omitted and render from the combined state.
await loadData({
reloadEdges: false,
reloadNodes: true
});
}
} else {
await Promise.all([
loadStats(),
loadData({ forceRefresh: true })
]);
}
} catch (error) {
// A manual refresh may have absorbed a pending live update. Restore
// it and let the bounded scheduled-retry policy recover.
scheduleMeshLiveRefresh(true, MESH_LIVE_REFRESH_RETRY_MS);
throw error;
}
if (absorbedPending && activeScheduledRefresh) {
// Events arriving during an active database snapshot may not be
// represented by it, so retain that trailing refresh.
scheduleMeshLiveRefresh(absorbedNeedsNodes);
}
}
function scheduleMeshLiveRefresh(
reloadNodes,
delayMs = MESH_LIVE_REFRESH_MS
) {
meshLiveRefreshPending = true;
meshLiveRefreshNeedsNodes = meshLiveRefreshNeedsNodes || Boolean(reloadNodes);
if (document.hidden || meshLiveRefreshTimer || meshLiveRefreshRunning) return;
meshLiveRefreshTimer = setTimeout(() => {
meshLiveRefreshTimer = null;
runScheduledMeshRefresh();
}, delayMs);
}
async function runScheduledMeshRefresh() {
if (
document.hidden
|| meshLiveRefreshRunning
|| !meshLiveRefreshPending
) {
return;
}
const reloadNodes = meshLiveRefreshNeedsNodes;
meshLiveRefreshPending = false;
meshLiveRefreshNeedsNodes = false;
meshLiveRefreshRunning = true;
meshLiveRefreshActiveReloadsNodes = reloadNodes;
let retryDelayMs = MESH_LIVE_REFRESH_MS;
try {
await Promise.all([
loadStats(),
loadData({
reloadNodes,
skipRender: true,
forceRefresh: true
})
]);
if (currentView === 'map') {
applyFilters();
}
meshLiveRefreshRetryCount = 0;
} catch (error) {
console.error('Error refreshing live mesh data:', error);
if (meshLiveRefreshRetryCount < MESH_LIVE_REFRESH_MAX_RETRIES) {
meshLiveRefreshRetryCount++;
meshLiveRefreshPending = true;
meshLiveRefreshNeedsNodes =
meshLiveRefreshNeedsNodes || reloadNodes;
retryDelayMs = MESH_LIVE_REFRESH_RETRY_MS;
} else {
meshLiveRefreshRetryCount = 0;
}
} finally {
meshLiveRefreshRunning = false;
meshLiveRefreshActiveReloadsNodes = false;
if (meshLiveRefreshPending) {
scheduleMeshLiveRefresh(false, retryDelayMs);
}
}
}
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.addEventListener('click', 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,
// events are coalesced into a bounded background refresh and do not re-render
// the graph, avoiding both request storms and chaotic physics re-stabilization.
document.addEventListener('visibilitychange', () => {
if (!document.hidden && meshLiveRefreshPending) {
if (meshLiveRefreshTimer) {
clearTimeout(meshLiveRefreshTimer);
meshLiveRefreshTimer = null;
}
runScheduledMeshRefresh();
}
});
function setupSocketIO() {
const socket = window.connectionManager.socket;
socket.emit('subscribe_mesh');
function onMeshUpdate(reloadNodes) {
scheduleMeshLiveRefresh(reloadNodes);
}
socket.on('mesh_edge_added', () => onMeshUpdate(false));
socket.on('mesh_edge_updated', () => onMeshUpdate(false));
socket.on('mesh_node_added', () => onMeshUpdate(true));
}
</script>
</div>
{% endblock %}