Files
meshcore-bot/modules/web_viewer/templates/mesh.html
T

3265 lines
145 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">
<option value="all">All Evidence</option>
<option value="multibyte" selected>Multi-byte 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: 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
const PREFIX_HEX_CHARS = parseInt('{{ prefix_hex_chars|default(2) }}', 10);
// Graph prefix length from edges API (2, 4, or 6 hex chars); used for node prefix display and node fetch.
let graphPrefixHexChars = PREFIX_HEX_CHARS;
// Format prefix for display: add byte count when multi-byte (2 or 3 bytes)
function formatPrefixWithBytes(prefix) {
if (!prefix || typeof prefix !== 'string') return '';
const hexLen = prefix.length;
const bytes = hexLen / 2;
if (bytes === 1) return prefix;
return `${prefix} (${bytes} bytes)`;
}
// Format prefix as up to 3 space-separated bytes (e.g. 86 0C AB); numBytes = how many bytes to show.
function formatPrefixAsByteChunks(hexStr, numBytes) {
if (!hexStr || typeof hexStr !== 'string' || numBytes < 1) return '';
const raw = hexStr.replace(/\s/g, '');
if (raw.length < 2) return raw ? raw.slice(0, 2).toUpperCase() : '';
const parts = [];
for (let i = 0; i < numBytes && i * 2 < raw.length; i++) {
const pair = raw.slice(i * 2, i * 2 + 2);
if (pair.length === 2) parts.push(pair.toUpperCase());
}
return parts.join(' ');
}
// Same as formatPrefixAsByteChunks but returns HTML with each byte in its own box (span.prefix-byte).
function formatPrefixAsByteChunksHTML(hexStr, numBytes) {
const raw = formatPrefixAsByteChunks(hexStr, numBytes);
if (!raw) return '';
return raw.split(' ').map(byte => '<span class="prefix-byte">' + escapeHtml(byte) + '</span>').join(' ');
}
// Helper function to create unique node identifier
function getNodeId(node) {
return `${node.prefix}-${node.latitude.toFixed(6)}-${node.longitude.toFixed(6)}`;
}
// Resolve an edge prefix (2, 4, or 6 hex chars) to a node when node.prefix may be longer or equal.
function findNodeByEdgePrefix(prefix) {
if (!prefix) return null;
return filteredNodeMap[prefix] || filteredNodes.find(n => n.prefix.startsWith(prefix) || prefix.startsWith(n.prefix)) || null;
}
// 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';
}
function getEvidenceLabel(edge) {
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 multi-byte (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;
// Both evidence modes tag edges, so this works even in "All Evidence"
const mbEdges = filteredEdges.filter(e => e.evidence === 'multibyte');
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;
}
console.log(`Highlighting path with ${pathRepeaters.length} repeaters:`, pathRepeaters.map(r => r.node_id));
// Highlight edges between consecutive nodes in the path
// Need to match the edgeKey format used when storing edges: `${edge.from_prefix}-${edge.to_prefix}`
const pathEdgeKeys = new Set();
for (let i = 0; i < pathRepeaters.length - 1; i++) {
const fromRepeater = pathRepeaters[i];
const toRepeater = pathRepeaters[i + 1];
// Find the edge - try public key matching first, then prefix
let edge = null;
// Try public key matching first (most accurate)
if (fromRepeater.public_key && toRepeater.public_key) {
edge = filteredEdges.find(e =>
e.from_public_key === fromRepeater.public_key &&
e.to_public_key === toRepeater.public_key
);
}
// Fall back to prefix matching (case-insensitive)
const fromPrefix = fromRepeater.node_id.toUpperCase();
const toPrefix = toRepeater.node_id.toUpperCase();
if (!edge) {
edge = filteredEdges.find(e =>
e.from_prefix.toUpperCase() === fromPrefix &&
e.to_prefix.toUpperCase() === toPrefix
);
}
// Also check reverse direction (bidirectional edges)
if (!edge) {
edge = filteredEdges.find(e =>
e.from_prefix.toUpperCase() === toPrefix &&
e.to_prefix.toUpperCase() === fromPrefix
);
}
if (edge) {
// Use the same edgeKey format as when storing: `${edge.from_prefix}-${edge.to_prefix}`
// Always use the edge's actual from/to prefixes (as stored in the database)
// Normalize to lowercase to match how edges are stored
const edgeKey = `${edge.from_prefix.toLowerCase()}-${edge.to_prefix.toLowerCase()}`;
pathEdgeKeys.add(edgeKey);
console.log(`Found edge for path segment ${fromRepeater.node_id}->${toRepeater.node_id}: ${edgeKey} (edge stored as ${edge.from_prefix}->${edge.to_prefix})`);
} else {
console.warn(`No edge found for path segment ${fromRepeater.node_id}->${toRepeater.node_id}`);
// Log available edges for debugging
const availableEdges = filteredEdges.filter(e => {
const eFrom = e.from_prefix.toUpperCase();
const eTo = e.to_prefix.toUpperCase();
return (eFrom === fromPrefix || eTo === fromPrefix || eFrom === toPrefix || eTo === toPrefix);
});
if (availableEdges.length > 0) {
console.log(`Available edges with these prefixes:`, availableEdges.map(e => `${e.from_prefix}->${e.to_prefix}`));
} else {
console.log(`No edges found with prefixes ${fromPrefix} or ${toPrefix} in filteredEdges (total: ${filteredEdges.length})`);
}
}
}
console.log(`Path edge keys to highlight:`, Array.from(pathEdgeKeys));
// First, store original styles for ALL edges if not already stored
edgeLines.forEach((line, edgeKey) => {
if (!allEdgeStyles.has(edgeKey)) {
allEdgeStyles.set(edgeKey, {
color: line.options.color,
weight: line.options.weight,
opacity: line.options.opacity
});
}
});
console.log(`Total edges on map: ${edgeLines.size}, Path edges to highlight: ${pathEdgeKeys.size}`);
// Normalize path edge keys to lowercase for matching
const normalizedPathEdgeKeys = new Set();
pathEdgeKeys.forEach(key => normalizedPathEdgeKeys.add(key.toLowerCase()));
// Apply edge styles - immediately if requested (to prevent flicker after renderMap),
// otherwise use requestAnimationFrame to batch updates
const applyEdgeStyles = () => {
edgeLines.forEach((line, edgeKey) => {
// Normalize edgeKey for comparison
const normalizedKey = edgeKey.toLowerCase();
if (normalizedPathEdgeKeys.has(normalizedKey)) {
// Apply path highlight style
line.setStyle({
color: '#fbbf24', // Bright yellow/orange
weight: 6, // Thicker
opacity: 1.0 // Fully opaque
});
} else {
// Dim non-path edges - now we know originalStyle exists since we stored it above
const originalStyle = allEdgeStyles.get(edgeKey);
if (originalStyle) {
line.setStyle({
color: '#848484',
weight: Math.max(1, originalStyle.weight * 0.5),
opacity: 0.2
});
}
}
});
};
if (immediate) {
// Apply immediately to prevent flicker when called from renderMap()
applyEdgeStyles();
} else {
// Use requestAnimationFrame to batch all style updates in a single frame
requestAnimationFrame(applyEdgeStyles);
}
// Highlight path nodes
pathRepeaters.forEach((repeater, index) => {
// Find the node in our node map
let node = null;
// Try to find by public key first (most accurate)
if (repeater.public_key && nodeMapByKey && nodeMapByKey[repeater.public_key]) {
node = nodeMapByKey[repeater.public_key];
} else if (repeater.latitude && repeater.longitude) {
// Fall back to prefix and location matching (for cases where public key not available)
const matchingNodes = filteredNodes.filter(n =>
n.prefix === repeater.node_id &&
n.latitude && n.longitude &&
Math.abs(n.latitude - repeater.latitude) < 0.001 &&
Math.abs(n.longitude - repeater.longitude) < 0.001
);
if (matchingNodes.length > 0) {
node = matchingNodes[0];
}
} else {
// Last resort: just match by prefix (less accurate but better than nothing)
node = nodeMap[repeater.node_id];
}
if (node) {
const nodeId = getNodeId(node);
const marker = nodeMarkers.get(nodeId);
if (marker) {
const highlightSize = 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);
['filter-edge-days', 'filter-node-days', 'filter-starred'].forEach(id => {
document.getElementById(id).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');
}
});
});
// Load statistics
async function loadStats() {
try {
const response = await fetch('/api/mesh/stats');
const stats = await response.json();
document.getElementById('stat-node-count').textContent = stats.node_count || 0;
document.getElementById('stat-edge-count').textContent = stats.total_edges || 0;
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);
}
}
// --- 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();
}
// Load nodes and edges
// Options: { skipRender: true } - refresh allNodes/allEdges but do not call applyFilters().
// Used by socket handlers so graph view is not re-rendered on live updates (avoids chaotic re-stabilization).
async function loadData(options) {
const skipRender = options && options.skipRender === true;
try {
const evidence = document.getElementById('filter-evidence').value;
const edgesUrl = evidence === 'multibyte' ? '/api/mesh/edges?evidence=multibyte' : '/api/mesh/edges';
// 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([
fetch(edgesUrl).then(r => r.json()),
fetch('/api/mesh/nodes').then(r => r.json())
]);
allEdges = edgesData.edges || [];
graphPrefixHexChars = (edgesData.prefix_hex_chars && [2, 4, 6].includes(edgesData.prefix_hex_chars))
? edgesData.prefix_hex_chars
: PREFIX_HEX_CHARS;
allNodes = nodesData.nodes || [];
// Keep node.prefix in sync with edge prefixes (e.g. 2-byte graphs)
allNodes.forEach(node => {
if (node.public_key) {
node.prefix = node.public_key.substring(0, graphPrefixHexChars).toLowerCase();
}
});
// Build node maps
nodeMap = {};
nodeMapByKey = {};
allNodes.forEach(node => {
nodeMap[node.prefix] = node; // Last node with this prefix wins (for backward compatibility)
if (node.public_key) {
nodeMapByKey[node.public_key] = node; // Map by public key for accurate lookups
}
});
// Build edge map
edgeMap = {};
allEdges.forEach(edge => {
const key = `${edge.from_prefix}-${edge.to_prefix}`;
edgeMap[key] = edge;
});
// Rescale the min-observations slider to the new edge distribution
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 (filters.evidence === 'all' || filters.evidence === 'multibyte') {
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().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>` : ''}
<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) {
return Math.max(1, Math.min(5, Math.log10(Math.max(1, edge.observation_count || 1)) * 1.5));
}
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}`;
}
function refreshData() {
loadStats();
loadData();
}
function exportView() {
// Simple export - could be enhanced to export as image
const data = {
nodes: filteredNodes,
edges: filteredEdges,
exported: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mesh-graph-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
function toggleFullscreen() {
const mapContainer = document.getElementById('map-view');
const btn = document.getElementById('btn-fullscreen');
const icon = btn.querySelector('i');
if (!isMapFullscreen) {
// Enter fullscreen
isMapFullscreen = true;
// Use native fullscreen API where available (not on iOS Safari)
if (mapContainer.requestFullscreen) {
mapContainer.requestFullscreen().catch(() => {});
} else if (mapContainer.webkitRequestFullscreen) {
mapContainer.webkitRequestFullscreen();
} else if (mapContainer.msRequestFullscreen) {
mapContainer.msRequestFullscreen();
}
// CSS-based fullscreen (works on all platforms including mobile)
mapContainer.classList.add('fullscreen-mode');
document.body.classList.add('fullscreen-active');
icon.className = 'fas fa-compress';
// Add exit button overlay
const exitBtn = document.createElement('button');
exitBtn.className = 'fullscreen-exit-btn';
exitBtn.innerHTML = '<i class="fas fa-times"></i> Exit Fullscreen';
exitBtn.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,
// we refresh data in the background but do not re-render the graph, to avoid the chaotic
// re-stabilization layout (nodes clumping, edges tangling) that occurs when vis-network
// runs physics again. User can refresh or switch to map and back to see updated graph.
function setupSocketIO() {
const socket = window.connectionManager.socket;
socket.emit('subscribe_mesh');
function onMeshUpdate(data, label) {
console.log(label, data);
loadStats();
loadData({ skipRender: true }).then(() => {
if (currentView === 'map') {
applyFilters();
}
});
}
socket.on('mesh_edge_added', (data) => onMeshUpdate(data, 'New edge added:'));
socket.on('mesh_edge_updated', (data) => onMeshUpdate(data, 'Edge updated:'));
socket.on('mesh_node_added', (data) => onMeshUpdate(data, 'New node added:'));
}
</script>
</div>
{% endblock %}