mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 23:00:41 +00:00
Removes 26 console.log calls, including contacts.html dumping whole data structures and sample contact records on every render. Variables and callback parameters that existed only to feed those logs go with them (deviceTypes, anyNewDevices, an availableEdges debug block, and a realtime status handler whose body was nothing but a log), so no new no-unused-vars warnings are introduced — the count drops 60 to 59. console.warn and console.error are kept. One console.log survives in realtime.html: the decoder key-count line is a one-time startup message with real diagnostic value, and removing it would mean deleting two counters and their increments inside a loop. Also normalizes the mesh page's user-visible "Neighbours" strings to match the project's American spelling. The option value stays "neighbors", so evidence-mode filtering is untouched.
3552 lines
156 KiB
HTML
3552 lines
156 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Mesh Graph - MeshCore Bot Data Viewer{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* Make the mesh graph page use full height - scoped to this page only */
|
|
/* Fixed height so flex children (e.g. visualization-row) get remaining space; navbar + header row ~100px */
|
|
#mesh-graph-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - 78px);
|
|
}
|
|
|
|
/* Reclaim every vertical pixel for the visualization: drop the footer and the
|
|
container's top gap on this page only (deliberate convention break) */
|
|
body:has(#mesh-graph-container) footer {
|
|
display: none;
|
|
}
|
|
body:has(#mesh-graph-container) .container-fluid.mt-4 {
|
|
margin-top: 0.5rem !important;
|
|
}
|
|
|
|
/* Compact single-row header: title + stat chips + controls share the line to
|
|
maximize the visualization viewport (deliberately breaks the usual card grid) */
|
|
.mesh-title {
|
|
font-size: 1.3rem;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
}
|
|
.mesh-stat-chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 3px 10px;
|
|
}
|
|
.mesh-stat-chip > i {
|
|
color: var(--text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
.mesh-stat-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
line-height: 1.15;
|
|
}
|
|
.mesh-stat-value {
|
|
font-weight: 700;
|
|
font-size: 0.95rem;
|
|
}
|
|
.mesh-stat-label {
|
|
font-size: 0.66rem;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
}
|
|
.mesh-view-switch .btn,
|
|
.mesh-tool-group .btn {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-color);
|
|
}
|
|
.mesh-view-switch .btn:hover,
|
|
.mesh-tool-group .btn:hover {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-color);
|
|
}
|
|
.mesh-view-switch .btn.active {
|
|
background: #0d6efd;
|
|
border-color: #0d6efd;
|
|
color: #fff;
|
|
}
|
|
.mesh-tool-group .btn.active {
|
|
background: #21918c; /* viridis teal: matches the heat ramp it enables */
|
|
border-color: #21918c;
|
|
color: #fff;
|
|
}
|
|
@media (max-width: 991.98px) {
|
|
.mesh-btn-text {
|
|
display: none; /* icon-only view switch on small screens */
|
|
}
|
|
.mesh-stat-label {
|
|
font-size: 0.62rem;
|
|
}
|
|
.mesh-stat-chip {
|
|
padding: 2px 8px;
|
|
}
|
|
}
|
|
|
|
/* Mobile: the header and filter panel stack tall, so a fixed-height flex
|
|
container starves the visualization (and strands its absolutely-positioned
|
|
legends). Let the page scroll and give the visualization its own height. */
|
|
@media (max-width: 767.98px) {
|
|
#mesh-graph-container {
|
|
height: auto;
|
|
}
|
|
.visualization-row {
|
|
flex: none;
|
|
}
|
|
.visualization-card .card-body {
|
|
height: calc(100vh - 160px) !important;
|
|
}
|
|
#map-view, #graph-view {
|
|
min-height: 0;
|
|
}
|
|
}
|
|
|
|
#map-view, #graph-view {
|
|
flex: 1;
|
|
min-height: 0;
|
|
}
|
|
|
|
.visualization-card {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
/* Add bottom margin to match side margins - apply to the row containing the visualization */
|
|
.visualization-row {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.visualization-row > .col-12 {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
flex: 1;
|
|
}
|
|
</style>
|
|
|
|
<div id="mesh-graph-container" class="d-flex flex-column">
|
|
<!-- Single-row header: title, stat chips, controls (wraps on narrow screens) -->
|
|
<div class="mesh-header d-flex align-items-center flex-wrap gap-2 mb-2 mt-2">
|
|
<h1 class="mesh-title mb-0 me-1">
|
|
<i class="fas fa-project-diagram"></i> Mesh Graph
|
|
</h1>
|
|
<div class="d-flex align-items-center flex-wrap gap-2">
|
|
<div class="mesh-stat-chip" title="Total repeaters">
|
|
<i class="fas fa-sitemap"></i>
|
|
<div class="mesh-stat-text">
|
|
<span id="stat-node-count" class="mesh-stat-value text-primary">0</span>
|
|
<span class="mesh-stat-label">Repeaters</span>
|
|
</div>
|
|
</div>
|
|
<div class="mesh-stat-chip" title="Total connections">
|
|
<i class="fas fa-link"></i>
|
|
<div class="mesh-stat-text">
|
|
<span id="stat-edge-count" class="mesh-stat-value text-info">0</span>
|
|
<span class="mesh-stat-label" id="stat-edge-detail">Edges</span>
|
|
</div>
|
|
</div>
|
|
<div class="mesh-stat-chip" title="Average observations per edge">
|
|
<i class="fas fa-chart-line"></i>
|
|
<div class="mesh-stat-text">
|
|
<span id="stat-avg-obs" class="mesh-stat-value text-success">0</span>
|
|
<span class="mesh-stat-label">Avg Obs/Edge</span>
|
|
</div>
|
|
</div>
|
|
<div class="mesh-stat-chip" title="Average edge distance">
|
|
<i class="fas fa-ruler"></i>
|
|
<div class="mesh-stat-text">
|
|
<span id="stat-avg-dist" class="mesh-stat-value text-warning">0</span>
|
|
<span class="mesh-stat-label">Avg km</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="ms-auto d-flex align-items-center flex-wrap gap-2">
|
|
<div class="btn-group mesh-view-switch" role="group" aria-label="View">
|
|
<button type="button" class="btn btn-sm active" id="btn-view-map" title="Map view">
|
|
<i class="fas fa-map"></i><span class="mesh-btn-text"> Map</span>
|
|
</button>
|
|
<button type="button" class="btn btn-sm" id="btn-view-graph" title="Graph view">
|
|
<i class="fas fa-project-diagram"></i><span class="mesh-btn-text"> Graph</span>
|
|
</button>
|
|
</div>
|
|
<div class="btn-group mesh-tool-group" role="group" aria-label="Tools">
|
|
<button class="btn btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#filterPanel" title="Filters">
|
|
<i class="fas fa-filter"></i>
|
|
</button>
|
|
<button class="btn btn-sm" type="button" id="btn-heat" title="Hot connections: color edges by traffic (viridis)">
|
|
<i class="fas fa-fire"></i>
|
|
</button>
|
|
<button class="btn btn-sm" type="button" id="btn-mesh-theme" title="Toggle map dark mode (independent of site theme)">
|
|
<i class="fas fa-moon" id="mesh-theme-icon"></i>
|
|
</button>
|
|
<button class="btn btn-sm" type="button" id="btn-mesh-refresh" title="Refresh data">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
<button class="btn btn-sm" type="button" id="btn-mesh-export" title="Export JSON">
|
|
<i class="fas fa-download"></i>
|
|
</button>
|
|
<button class="btn btn-sm" type="button" id="btn-fullscreen" title="Fullscreen">
|
|
<i class="fas fa-expand"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filter Panel -->
|
|
<div class="row mb-3 collapse" id="filterPanel">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<i class="fas fa-filter"></i> Filters
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-2">
|
|
<label for="filter-min-obs" class="form-label">Min Observations</label>
|
|
<input type="range" class="form-range" id="filter-min-obs" min="0" max="100" value="0">
|
|
<small id="label-min-obs" class="text-muted">1</small>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="filter-edge-days" class="form-label">Edge Timeframe</label>
|
|
<select class="form-select" id="filter-edge-days">
|
|
<option value="">All Time</option>
|
|
<option value="1">Last 24 Hours</option>
|
|
<option value="2">Last 48 Hours</option>
|
|
<option value="3" selected>Last 72 Hours</option>
|
|
<option value="7">Last 7 Days</option>
|
|
<option value="30">Last 30 Days</option>
|
|
<option value="90">Last 90 Days</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="filter-node-days" class="form-label">Node Timeframe</label>
|
|
<select class="form-select" id="filter-node-days">
|
|
<option value="">All Time</option>
|
|
<option value="1">Last 24 Hours</option>
|
|
<option value="2">Last 48 Hours</option>
|
|
<option value="3" selected>Last 72 Hours</option>
|
|
<option value="7">Last 7 Days</option>
|
|
<option value="30">Last 30 Days</option>
|
|
<option value="90">Last 90 Days</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="filter-evidence" class="form-label">Evidence</label>
|
|
<select class="form-select" id="filter-evidence" title="Multi-byte only: build the graph solely from unique multi-byte path observations, ignoring ambiguous single-byte evidence. Neighbors only: only links this node confirmed directly by zero-hop discovery, with measured SNR.">
|
|
<option value="all">All Evidence</option>
|
|
<option value="multibyte" selected>Multi-byte Only</option>
|
|
<option value="neighbors">Neighbors Only</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="filter-starred" class="form-label"> </label>
|
|
<div class="d-flex align-items-center" style="min-height: 38px;">
|
|
<div class="form-check mb-0">
|
|
<input type="checkbox" class="form-check-input" id="filter-starred">
|
|
<label class="form-check-label" for="filter-starred">Show Only Starred</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label for="filter-search" class="form-label">Search Node</label>
|
|
<input type="text" class="form-control" id="filter-search" placeholder="Name, prefix, or path...">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Visualization Area -->
|
|
<div class="row visualization-row" style="flex: 1; min-height: 0;">
|
|
<div class="col-12 h-100">
|
|
<div class="card h-100 visualization-card">
|
|
<div class="card-body p-0 h-100" style="position: relative;">
|
|
<!-- Map View -->
|
|
<div id="map-view" style="height: 100%; width: 100%;"></div>
|
|
|
|
<!-- Graph View -->
|
|
<div id="graph-view" style="height: 100%; width: 100%; display: none;"></div>
|
|
|
|
<!-- Connection Legend -->
|
|
<div id="connection-legend" class="connection-legend">
|
|
<strong>Connection Types:</strong>
|
|
<div class="connection-legend-item">
|
|
<span class="connection-legend-color" style="background-color: #10b981;"></span>
|
|
<span>Incoming</span>
|
|
</div>
|
|
<div class="connection-legend-item">
|
|
<span class="connection-legend-color" style="background-color: #3b82f6;"></span>
|
|
<span>Outgoing</span>
|
|
</div>
|
|
<div class="connection-legend-item">
|
|
<span class="connection-legend-color" style="background-color: #9333ea;"></span>
|
|
<span>Bidirectional</span>
|
|
</div>
|
|
<hr style="margin: 4px 0;">
|
|
<div class="connection-legend-item">
|
|
<span style="display: inline-block; width: 18px; border-top: 4px solid currentColor; vertical-align: middle;"></span>
|
|
<span>Confirmed neighbor (zero-hop)</span>
|
|
</div>
|
|
<div class="connection-legend-item">
|
|
<span style="display: inline-block; width: 18px; border-top: 2px solid currentColor; vertical-align: middle;"></span>
|
|
<span>Multi-byte evidence</span>
|
|
</div>
|
|
<div class="connection-legend-item">
|
|
<span style="display: inline-block; width: 18px; border-top: 2px dashed currentColor; vertical-align: middle;"></span>
|
|
<span>Single-byte (uncertain)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hot-connections (viridis) scale, shown when heat coloring is active -->
|
|
<div id="heat-legend" class="heat-legend">
|
|
<div class="heat-legend-title">Observations</div>
|
|
<div class="heat-legend-bar"></div>
|
|
<div class="heat-legend-labels">
|
|
<span id="heat-legend-min">1</span>
|
|
<span id="heat-legend-max">1</span>
|
|
</div>
|
|
<div class="heat-legend-note">dots: repeater totals across paths</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Node Details Modal -->
|
|
<div class="modal fade" id="nodeDetailsModal" tabindex="-1">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="nodeDetailsTitle">Node Details</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" id="nodeDetailsBody">
|
|
<!-- Content populated by JavaScript -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<!-- vis-network CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/vis-network@9.1.9/styles/vis-network.min.css" />
|
|
|
|
<style>
|
|
#map-view, #graph-view {
|
|
min-height: 600px;
|
|
}
|
|
.leaflet-popup-content {
|
|
margin: 13px 19px;
|
|
}
|
|
/* Prevent selection rectangle/box when clicking on map elements */
|
|
.leaflet-container {
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
.leaflet-container * {
|
|
-webkit-user-select: none;
|
|
-moz-user-select: none;
|
|
-ms-user-select: none;
|
|
user-select: none;
|
|
}
|
|
/* Prevent Leaflet popup from showing bounding box */
|
|
.leaflet-popup-pane {
|
|
pointer-events: auto;
|
|
}
|
|
/* Hide any selection outlines and bounding boxes */
|
|
.leaflet-interactive:focus {
|
|
outline: none;
|
|
}
|
|
.leaflet-popup {
|
|
/* Ensure popup doesn't trigger auto-pan visual indicators */
|
|
}
|
|
/* Hide Leaflet's auto-pan padding/bounding box visual */
|
|
.leaflet-container.leaflet-touch-zoom {
|
|
-ms-touch-action: pan-x pan-y;
|
|
touch-action: pan-x pan-y;
|
|
}
|
|
.node-marker-repeater {
|
|
background-color: #0d6efd;
|
|
}
|
|
.node-marker-roomserver {
|
|
background-color: #198754;
|
|
}
|
|
.node-marker-starred {
|
|
border: 3px solid #ffc107;
|
|
}
|
|
.mesh-node-popup .leaflet-popup-content {
|
|
margin: 10px 15px;
|
|
}
|
|
.mesh-node-popup .leaflet-popup-content table {
|
|
border-collapse: collapse;
|
|
}
|
|
.mesh-node-popup .leaflet-popup-content code {
|
|
background-color: rgba(0, 0, 0, 0.1);
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
.mesh-node-popup .prefix-byte {
|
|
display: inline-block;
|
|
background-color: rgba(0, 0, 0, 0.08);
|
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
border-radius: 3px;
|
|
padding: 2px 5px;
|
|
margin-right: 4px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
.mesh-node-popup .prefix-byte:last-child {
|
|
margin-right: 0;
|
|
}
|
|
/* Legend for connection highlighting.
|
|
Colored by the mesh display theme (.mesh-dark), not the site theme, because
|
|
it floats over the map/graph whose darkness is toggled independently. */
|
|
.connection-legend {
|
|
position: absolute;
|
|
bottom: 30px;
|
|
right: 10px;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
color: #212529;
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
z-index: 1000;
|
|
font-size: 0.85em;
|
|
display: none;
|
|
}
|
|
.connection-legend.active {
|
|
display: block;
|
|
}
|
|
.connection-legend strong {
|
|
color: inherit;
|
|
font-weight: 600;
|
|
}
|
|
.connection-legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 5px 0;
|
|
color: inherit;
|
|
}
|
|
.connection-legend-color {
|
|
width: 20px;
|
|
height: 3px;
|
|
margin-right: 8px;
|
|
display: inline-block;
|
|
}
|
|
.mesh-dark .connection-legend {
|
|
background: rgba(45, 45, 45, 0.95);
|
|
color: #e9ecef;
|
|
border-color: #495057;
|
|
}
|
|
|
|
/* Hot-connections (viridis) scale legend */
|
|
.heat-legend {
|
|
position: absolute;
|
|
bottom: 30px;
|
|
left: 10px;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
color: #212529;
|
|
padding: 8px 10px;
|
|
border-radius: 5px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
border: 1px solid rgba(0,0,0,0.1);
|
|
z-index: 1000;
|
|
font-size: 0.75em;
|
|
display: none;
|
|
min-width: 140px;
|
|
}
|
|
.heat-legend.active {
|
|
display: block;
|
|
}
|
|
.heat-legend-title {
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
}
|
|
.heat-legend-bar {
|
|
height: 8px;
|
|
border-radius: 4px;
|
|
background: linear-gradient(90deg, #440154, #3b528b, #21918c, #5ec962, #fde725);
|
|
}
|
|
.heat-legend-labels {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-top: 2px;
|
|
color: #6c757d;
|
|
}
|
|
.heat-legend-note {
|
|
margin-top: 2px;
|
|
font-size: 0.9em;
|
|
color: #6c757d;
|
|
}
|
|
.mesh-dark .heat-legend-note {
|
|
color: #adb5bd;
|
|
}
|
|
.mesh-dark .heat-legend {
|
|
background: rgba(45, 45, 45, 0.95);
|
|
color: #e9ecef;
|
|
border-color: #495057;
|
|
}
|
|
/* Dark mode compresses the cold (near-black) end of the ramp out of the scale */
|
|
.mesh-dark .heat-legend-bar {
|
|
background: linear-gradient(90deg, #414487, #2c728e, #22a884, #7ad151, #fde725);
|
|
}
|
|
.mesh-dark .heat-legend-labels {
|
|
color: #adb5bd;
|
|
}
|
|
|
|
/* Mesh display dark theme: Leaflet chrome + graph canvas.
|
|
Applied via .mesh-dark on #mesh-graph-container, independent of [data-theme]. */
|
|
#graph-view {
|
|
background: #ffffff;
|
|
}
|
|
.mesh-dark #graph-view {
|
|
background: #1a1a1a;
|
|
}
|
|
.mesh-dark .leaflet-container {
|
|
background: #1a1a1a;
|
|
}
|
|
.mesh-dark .leaflet-popup-content-wrapper,
|
|
.mesh-dark .leaflet-popup-tip {
|
|
background: #2d2d2d;
|
|
color: #e9ecef;
|
|
}
|
|
.mesh-dark .leaflet-control-zoom a {
|
|
background: #2d2d2d;
|
|
color: #e9ecef;
|
|
border-color: #495057;
|
|
}
|
|
.mesh-dark .leaflet-control-zoom a:hover {
|
|
background: #3d3d3d;
|
|
}
|
|
.mesh-dark .leaflet-control-attribution {
|
|
background: rgba(26, 26, 26, 0.7);
|
|
color: #adb5bd;
|
|
}
|
|
.mesh-dark .leaflet-control-attribution a {
|
|
color: #6ea8fe;
|
|
}
|
|
.mesh-dark .mesh-node-popup .leaflet-popup-content code,
|
|
.mesh-dark .mesh-edge-popup .leaflet-popup-content code {
|
|
background-color: rgba(255, 255, 255, 0.12);
|
|
color: #e685b5; /* Bootstrap's code pink is unreadable on the dark popup */
|
|
}
|
|
.mesh-dark .mesh-node-popup .prefix-byte {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
}
|
|
</style>
|
|
|
|
<!-- Full screen styles -->
|
|
<style>
|
|
.fullscreen-mode {
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
width: 100vw !important;
|
|
height: 100vh !important;
|
|
z-index: 9999 !important;
|
|
background: white;
|
|
}
|
|
|
|
.fullscreen-mode .leaflet-control-container {
|
|
z-index: 10000;
|
|
}
|
|
|
|
body.fullscreen-active {
|
|
overflow: hidden;
|
|
}
|
|
|
|
.fullscreen-mode #map-view {
|
|
height: 100vh !important;
|
|
width: 100vw !important;
|
|
}
|
|
|
|
/* Hide other elements in fullscreen mode */
|
|
.fullscreen-mode ~ .row,
|
|
.fullscreen-mode ~ .visualization-row {
|
|
display: none !important;
|
|
}
|
|
|
|
/* Show exit fullscreen button in fullscreen mode */
|
|
.fullscreen-mode .fullscreen-exit-btn {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 10001;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border: 1px solid #ccc;
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.fullscreen-mode .fullscreen-exit-btn:hover {
|
|
background: rgba(255, 255, 255, 1);
|
|
}
|
|
|
|
[data-theme="dark"] .fullscreen-mode {
|
|
background: var(--bg-color);
|
|
}
|
|
|
|
[data-theme="dark"] .fullscreen-mode .fullscreen-exit-btn {
|
|
background: rgba(45, 45, 45, 0.9);
|
|
border-color: #555;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
[data-theme="dark"] .fullscreen-mode .fullscreen-exit-btn:hover {
|
|
background: rgba(45, 45, 45, 1);
|
|
}
|
|
</style>
|
|
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<!-- vis-network JS -->
|
|
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
|
|
|
<script nonce="{{ g.csp_nonce }}">
|
|
// Global state
|
|
let currentView = 'map';
|
|
let map = null;
|
|
let isMapFullscreen = false;
|
|
let graphNetwork = null;
|
|
let allNodes = [];
|
|
let allEdges = [];
|
|
let filteredNodes = [];
|
|
let filteredEdges = [];
|
|
let nodeMap = {}; // prefix -> node data (all nodes, for path resolution)
|
|
let nodeMapByKey = {}; // public_key -> node data (all nodes, for path resolution)
|
|
let filteredNodeMap = {}; // prefix -> node (filtered set only, for edge drawing)
|
|
let filteredNodeMapByKey = {}; // public_key -> node (filtered set only, for edge drawing)
|
|
let edgeMap = {}; // "from-to" -> edge data
|
|
let mapViewState = null; // Store map center and zoom for preservation
|
|
let highlightedNode = null; // Currently highlighted node prefix
|
|
let highlightedNodeObject = null; // The actual node object that's highlighted
|
|
let highlightedEdges = new Map(); // Map of edge keys to their original styles
|
|
let allEdgeStyles = new Map(); // Map of all edge keys to their original styles (for dimming)
|
|
let allNodeStyles = new Map(); // Map of node identifiers to their original styles (for shrinking)
|
|
let nodeMarkers = new Map(); // Map of node identifier to marker for easy access
|
|
let nodeMarkersByPrefix = new Map(); // Map of prefix to array of markers (for multiple nodes with same prefix)
|
|
let edgeLines = new Map(); // Map of "from-to" to polyline for easy access
|
|
let edgeToNodes = new Map(); // Map of edge key to {fromNode, toNode} for accurate matching
|
|
let nodeModal = null; // Bootstrap modal instance for node details
|
|
let isInitialMapLoad = true; // Track if this is the first time rendering the map with nodes
|
|
let highlightedPath = null; // Currently highlighted path data
|
|
let pathHighlightTimeout = null; // Debounce timer for path resolution
|
|
let meshLoadInFlight = null;
|
|
let pendingMeshLoad = null;
|
|
let statsLoadInFlight = null;
|
|
let meshLiveRefreshTimer = null;
|
|
let meshLiveRefreshPending = false;
|
|
let meshLiveRefreshNeedsNodes = false;
|
|
let meshLiveRefreshRunning = false;
|
|
let meshLiveRefreshRetryCount = 0;
|
|
let meshLiveRefreshActiveReloadsNodes = false;
|
|
|
|
const PREFIX_HEX_CHARS = parseInt('{{ prefix_hex_chars|default(2) }}', 10);
|
|
const MESH_LIVE_REFRESH_MS = 30000;
|
|
const MESH_LIVE_REFRESH_RETRY_MS = 6000;
|
|
const MESH_LIVE_REFRESH_MAX_RETRIES = 2;
|
|
// Graph prefix length from edges API (2, 4, or 6 hex chars); used for node prefix display and node fetch.
|
|
let graphPrefixHexChars = PREFIX_HEX_CHARS;
|
|
// Format prefix for display: add byte count when multi-byte (2 or 3 bytes)
|
|
function formatPrefixWithBytes(prefix) {
|
|
if (!prefix || typeof prefix !== 'string') return '';
|
|
const hexLen = prefix.length;
|
|
const bytes = hexLen / 2;
|
|
if (bytes === 1) return prefix;
|
|
return `${prefix} (${bytes} bytes)`;
|
|
}
|
|
|
|
// Format prefix as up to 3 space-separated bytes (e.g. 86 0C AB); numBytes = how many bytes to show.
|
|
function formatPrefixAsByteChunks(hexStr, numBytes) {
|
|
if (!hexStr || typeof hexStr !== 'string' || numBytes < 1) return '';
|
|
const raw = hexStr.replace(/\s/g, '');
|
|
if (raw.length < 2) return raw ? raw.slice(0, 2).toUpperCase() : '';
|
|
const parts = [];
|
|
for (let i = 0; i < numBytes && i * 2 < raw.length; i++) {
|
|
const pair = raw.slice(i * 2, i * 2 + 2);
|
|
if (pair.length === 2) parts.push(pair.toUpperCase());
|
|
}
|
|
return parts.join(' ');
|
|
}
|
|
|
|
// Same as formatPrefixAsByteChunks but returns HTML with each byte in its own box (span.prefix-byte).
|
|
function formatPrefixAsByteChunksHTML(hexStr, numBytes) {
|
|
const raw = formatPrefixAsByteChunks(hexStr, numBytes);
|
|
if (!raw) return '';
|
|
return raw.split(' ').map(byte => '<span class="prefix-byte">' + escapeHtml(byte) + '</span>').join(' ');
|
|
}
|
|
|
|
// Helper function to create unique node identifier
|
|
function getNodeId(node) {
|
|
return `${node.prefix}-${node.latitude.toFixed(6)}-${node.longitude.toFixed(6)}`;
|
|
}
|
|
|
|
// Resolve an edge prefix (2, 4, or 6 hex chars) to a node when node.prefix may be longer or equal.
|
|
function findNodeByEdgePrefix(prefix) {
|
|
if (!prefix) return null;
|
|
return filteredNodeMap[prefix] || filteredNodes.find(n => n.prefix.startsWith(prefix) || prefix.startsWith(n.prefix)) || null;
|
|
}
|
|
|
|
// Prefix-compatible equality across resolutions (2, 4, or 6 hex chars):
|
|
// equal, or one is a prefix of the other. Needed wherever edges and nodes
|
|
// may carry different resolutions (e.g. 4-char edge vs 6-char node prefix).
|
|
function prefixesCompatible(a, b) {
|
|
if (!a || !b) return false;
|
|
return a === b || a.startsWith(b) || b.startsWith(a);
|
|
}
|
|
|
|
function edgeTouchesNode(edge, node) {
|
|
return prefixesCompatible(edge.from_prefix, node.prefix) || prefixesCompatible(edge.to_prefix, node.prefix);
|
|
}
|
|
|
|
// Great-circle distance fallback for edges without a stored distance
|
|
// (multi-byte derived edges carry none; compute from resolved node coords)
|
|
function haversineKm(lat1, lon1, lat2, lon2) {
|
|
const toRad = deg => deg * Math.PI / 180;
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLon = toRad(lon2 - lon1);
|
|
const a = Math.sin(dLat / 2) ** 2 +
|
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
function getEdgeDistance(edge, fromNode, toNode) {
|
|
if (edge.geographic_distance !== null && edge.geographic_distance !== undefined) {
|
|
return edge.geographic_distance;
|
|
}
|
|
if (fromNode && toNode) {
|
|
return haversineKm(fromNode.latitude, fromNode.longitude, toNode.latitude, toNode.longitude);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isSingleByteEdge(edge) {
|
|
return edge.evidence === 'singlebyte';
|
|
}
|
|
|
|
// A zero-hop discovery response is a direct RF measurement between two full
|
|
// public keys — the strongest evidence class, so it gets a heavier line.
|
|
function isNeighborEdge(edge) {
|
|
return edge.evidence === 'neighbors';
|
|
}
|
|
|
|
function formatSnr(value) {
|
|
return (typeof value === 'number' && isFinite(value)) ? `${value.toFixed(1)} dB` : null;
|
|
}
|
|
|
|
function getEvidenceLabel(edge) {
|
|
if (edge.evidence === 'neighbors') {
|
|
const snr = formatSnr(edge.snr);
|
|
return snr ? `Confirmed neighbor (avg SNR ${snr})` : 'Confirmed neighbor (zero-hop)';
|
|
}
|
|
if (edge.evidence === 'multibyte') {
|
|
return edge.path_count
|
|
? `Multi-byte (${edge.path_count} unique path${edge.path_count === 1 ? '' : 's'})`
|
|
: 'Multi-byte confirmed';
|
|
}
|
|
if (edge.evidence === 'singlebyte') {
|
|
return 'Single-byte (identity uncertain)';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Mesh display theme (dark basemap/graph) ---
|
|
// Follows the site theme until the user explicitly toggles it here, after which
|
|
// the override is remembered independently of the site theme.
|
|
let meshThemeOverride = null; // 'dark' | 'light' | null = follow site theme
|
|
|
|
function getSiteTheme() {
|
|
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
}
|
|
|
|
function getMeshTheme() {
|
|
return meshThemeOverride || getSiteTheme();
|
|
}
|
|
|
|
const TILE_LAYERS = {
|
|
light: {
|
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
attribution: '© OpenStreetMap contributors'
|
|
},
|
|
dark: {
|
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
attribution: '© OpenStreetMap contributors © CARTO'
|
|
}
|
|
};
|
|
let baseTileLayer = null;
|
|
|
|
function setBaseTileLayer() {
|
|
if (!map) return;
|
|
const cfg = TILE_LAYERS[getMeshTheme()];
|
|
if (baseTileLayer) {
|
|
map.removeLayer(baseTileLayer);
|
|
}
|
|
baseTileLayer = L.tileLayer(cfg.url, { attribution: cfg.attribution }).addTo(map);
|
|
}
|
|
|
|
function applyMeshTheme() {
|
|
const dark = getMeshTheme() === 'dark';
|
|
document.getElementById('mesh-graph-container').classList.toggle('mesh-dark', dark);
|
|
const icon = document.getElementById('mesh-theme-icon');
|
|
if (icon) {
|
|
icon.className = dark ? 'fas fa-sun' : 'fas fa-moon';
|
|
}
|
|
setBaseTileLayer();
|
|
}
|
|
|
|
function toggleMeshTheme() {
|
|
meshThemeOverride = getMeshTheme() === 'dark' ? 'light' : 'dark';
|
|
try {
|
|
localStorage.setItem('meshGraphTheme', meshThemeOverride);
|
|
} catch (error) {
|
|
console.debug('Error saving mesh theme:', error);
|
|
}
|
|
applyMeshTheme();
|
|
// Heat colors are theme-dependent (dark compresses the cold end), so re-render
|
|
if (edgeColorMode === 'heat') {
|
|
applyFilters();
|
|
}
|
|
}
|
|
|
|
function initMeshTheme() {
|
|
try {
|
|
const saved = localStorage.getItem('meshGraphTheme');
|
|
if (saved === 'dark' || saved === 'light') {
|
|
meshThemeOverride = saved;
|
|
}
|
|
} catch (error) {
|
|
console.debug('Error loading mesh theme:', error);
|
|
}
|
|
applyMeshTheme();
|
|
// Track site theme changes (navbar toggle) while un-overridden
|
|
new MutationObserver(() => {
|
|
if (!meshThemeOverride) {
|
|
applyMeshTheme();
|
|
if (edgeColorMode === 'heat') {
|
|
applyFilters();
|
|
}
|
|
}
|
|
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
}
|
|
|
|
// --- Hot connections: viridis coloring by observation count ---
|
|
let edgeColorMode = 'age'; // 'age' (recency, default) | 'heat' (viridis by observations)
|
|
// log-domain normalization range, recomputed from filteredEdges in applyFilters
|
|
let heatObsRange = { min: 1, max: 1 };
|
|
|
|
// Viridis control points (matplotlib), piecewise-linearly interpolated
|
|
const VIRIDIS_STOPS = [
|
|
[0x44, 0x01, 0x54], [0x47, 0x2d, 0x7b], [0x3b, 0x52, 0x8b],
|
|
[0x2c, 0x72, 0x8e], [0x21, 0x91, 0x8c], [0x28, 0xae, 0x80],
|
|
[0x5e, 0xc9, 0x62], [0xad, 0xdc, 0x30], [0xfd, 0xe7, 0x25]
|
|
];
|
|
|
|
function viridisColor(t) {
|
|
const clamped = Math.min(1, Math.max(0, t));
|
|
const scaled = clamped * (VIRIDIS_STOPS.length - 1);
|
|
const i = Math.min(VIRIDIS_STOPS.length - 2, Math.floor(scaled));
|
|
const f = scaled - i;
|
|
const [r1, g1, b1] = VIRIDIS_STOPS[i];
|
|
const [r2, g2, b2] = VIRIDIS_STOPS[i + 1];
|
|
const r = Math.round(r1 + (r2 - r1) * f);
|
|
const g = Math.round(g1 + (g2 - g1) * f);
|
|
const b = Math.round(b1 + (b2 - b1) * f);
|
|
return `rgb(${r},${g},${b})`;
|
|
}
|
|
|
|
// Normalized heat 0..1 for an edge: log-scaled because observation counts are heavy-tailed
|
|
function heatT(edge) {
|
|
const obs = Math.max(1, edge.observation_count || 1);
|
|
const lo = Math.log(heatObsRange.min);
|
|
const hi = Math.log(heatObsRange.max);
|
|
if (hi <= lo) return 1;
|
|
return Math.min(1, Math.max(0, (Math.log(obs) - lo) / (hi - lo)));
|
|
}
|
|
|
|
// On a dark basemap the cold end of viridis (near-black purple) is invisible,
|
|
// so compress the ramp to start at the still-visible blue-purple
|
|
function viridisThemed(t) {
|
|
return getMeshTheme() === 'dark' ? viridisColor(0.25 + 0.75 * t) : viridisColor(t);
|
|
}
|
|
|
|
function heatColor(edge) {
|
|
return viridisThemed(heatT(edge));
|
|
}
|
|
|
|
// Resolve an edge's endpoints from the filtered set, the same way the renderers do
|
|
function resolveEdgeEndpoints(edge) {
|
|
const fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key])
|
|
? filteredNodeMapByKey[edge.from_public_key]
|
|
: (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
|
|
const toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key])
|
|
? filteredNodeMapByKey[edge.to_public_key]
|
|
: (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
|
|
return { fromNode, toNode };
|
|
}
|
|
|
|
// Per-node aggregates over the filtered edges, recomputed in applyFilters:
|
|
// degree drives dot size, observation totals drive heat-mode node color.
|
|
let nodeHeatTotals = new Map(); // nodeId -> sum of observation counts of touching edges
|
|
let nodeDegrees = new Map(); // nodeId -> number of touching edges
|
|
let nodeHeatRange = { min: 1, max: 1 };
|
|
|
|
function computeNodeStats() {
|
|
nodeHeatTotals = new Map();
|
|
nodeDegrees = new Map();
|
|
filteredEdges.forEach(edge => {
|
|
const { fromNode, toNode } = resolveEdgeEndpoints(edge);
|
|
if (!fromNode || !toNode) return;
|
|
const obs = Math.max(1, edge.observation_count || 1);
|
|
[fromNode, toNode].forEach(n => {
|
|
const id = getNodeId(n);
|
|
nodeHeatTotals.set(id, (nodeHeatTotals.get(id) || 0) + obs);
|
|
nodeDegrees.set(id, (nodeDegrees.get(id) || 0) + 1);
|
|
});
|
|
});
|
|
let lo = Infinity, hi = 0;
|
|
nodeHeatTotals.forEach(v => { if (v < lo) lo = v; if (v > hi) hi = v; });
|
|
nodeHeatRange = hi ? { min: lo, max: hi } : { min: 1, max: 1 };
|
|
}
|
|
|
|
// Normalized node heat 0..1: how often this repeater shows up in observed paths
|
|
function nodeHeatT(node) {
|
|
const total = nodeHeatTotals.get(getNodeId(node)) || 0;
|
|
if (!total) return 0;
|
|
const lo = Math.log(nodeHeatRange.min);
|
|
const hi = Math.log(nodeHeatRange.max);
|
|
if (hi <= lo) return 1;
|
|
return Math.min(1, Math.max(0, (Math.log(total) - lo) / (hi - lo)));
|
|
}
|
|
|
|
function getNodeFillColor(node) {
|
|
if (edgeColorMode === 'heat') {
|
|
return viridisThemed(nodeHeatT(node));
|
|
}
|
|
return node.role === 'roomserver' ? '#198754' : '#0d6efd';
|
|
}
|
|
|
|
// --- Initial map framing: home mesh component ---
|
|
// Nodes occasionally carry wrong GPS, and fitting the view to every node can
|
|
// stretch the initial frame across continents. Instead, frame the component
|
|
// reachable from the bot's location over provenance-trusted evidence edges
|
|
// only; wrong-GPS phantoms are rarely connected to it.
|
|
let botLocation = null; // {latitude, longitude} from /api/mesh/stats
|
|
|
|
function getHomeComponentBounds() {
|
|
if (!botLocation) return null;
|
|
// Every evidence mode tags its edges, so this works in "All Evidence" too.
|
|
// Confirmed neighbors count as trusted (they are measured, not inferred),
|
|
// and are the only tagged edges present in "Neighbors Only" mode.
|
|
const mbEdges = filteredEdges.filter(
|
|
e => e.evidence === 'multibyte' || e.evidence === 'neighbors'
|
|
);
|
|
if (!mbEdges.length) return null;
|
|
|
|
const adj = new Map(); // nodeId -> Set of neighbor nodeIds
|
|
const nodesById = new Map();
|
|
mbEdges.forEach(edge => {
|
|
const { fromNode, toNode } = resolveEdgeEndpoints(edge);
|
|
if (!fromNode || !toNode) return;
|
|
const a = getNodeId(fromNode);
|
|
const b = getNodeId(toNode);
|
|
nodesById.set(a, fromNode);
|
|
nodesById.set(b, toNode);
|
|
if (!adj.has(a)) adj.set(a, new Set());
|
|
if (!adj.has(b)) adj.set(b, new Set());
|
|
adj.get(a).add(b);
|
|
adj.get(b).add(a);
|
|
});
|
|
|
|
// Seeds: multibyte-connected repeaters within 30 km of the bot,
|
|
// falling back to the single nearest one
|
|
const seeds = [];
|
|
let nearestId = null;
|
|
let nearestKm = Infinity;
|
|
nodesById.forEach((node, id) => {
|
|
const d = haversineKm(botLocation.latitude, botLocation.longitude, node.latitude, node.longitude);
|
|
if (d <= 30) seeds.push(id);
|
|
if (d < nearestKm) {
|
|
nearestKm = d;
|
|
nearestId = id;
|
|
}
|
|
});
|
|
if (!seeds.length && nearestId) seeds.push(nearestId);
|
|
|
|
const visited = new Set(seeds);
|
|
const queue = [...seeds];
|
|
while (queue.length) {
|
|
const neighbors = adj.get(queue.pop());
|
|
if (!neighbors) continue;
|
|
neighbors.forEach(n => {
|
|
if (!visited.has(n)) {
|
|
visited.add(n);
|
|
queue.push(n);
|
|
}
|
|
});
|
|
}
|
|
if (visited.size < 3) return null; // too small to be the home mesh; use default framing
|
|
|
|
const bounds = [];
|
|
visited.forEach(id => {
|
|
const n = nodesById.get(id);
|
|
bounds.push([n.latitude, n.longitude]);
|
|
});
|
|
return bounds;
|
|
}
|
|
|
|
function updateHeatLegend() {
|
|
const legend = document.getElementById('heat-legend');
|
|
if (!legend) return;
|
|
legend.classList.toggle('active', edgeColorMode === 'heat');
|
|
if (edgeColorMode === 'heat') {
|
|
document.getElementById('heat-legend-min').textContent = heatObsRange.min.toLocaleString();
|
|
document.getElementById('heat-legend-max').textContent = heatObsRange.max.toLocaleString();
|
|
}
|
|
}
|
|
|
|
function syncHeatButton() {
|
|
const btn = document.getElementById('btn-heat');
|
|
if (btn) {
|
|
btn.classList.toggle('active', edgeColorMode === 'heat');
|
|
}
|
|
}
|
|
|
|
function toggleHeatMode() {
|
|
edgeColorMode = edgeColorMode === 'heat' ? 'age' : 'heat';
|
|
syncHeatButton();
|
|
applyFilters();
|
|
saveFilters();
|
|
}
|
|
|
|
// Detect if input is a hex path (2+ hex values)
|
|
function detectPathInput(input) {
|
|
if (!input || input.trim().length === 0) return false;
|
|
|
|
// Pull from template if you can, otherwise default.
|
|
// If you already have this elsewhere on the page, reuse it.
|
|
const prefixHexChars = PREFIX_HEX_CHARS;
|
|
|
|
// Normalize input: remove commas, spaces, colons
|
|
const normalized = input.replace(/[,\s:]/g, '');
|
|
|
|
// Check if it's a continuous hex string (e.g., "8601a5" or "8601A58F02")
|
|
// If it's all hex and has at least 2 tokens, treat as path.
|
|
// Optional: require it to align on token boundaries to reduce false positives.
|
|
const minChars = prefixHexChars * 2; // 2+ tokens
|
|
if (new RegExp(`^[0-9a-fA-F]{${minChars},}$`).test(normalized)) {
|
|
// If you want to be slightly stricter (still minimal), uncomment:
|
|
// if (normalized.length % prefixHexChars === 0) return true;
|
|
return true;
|
|
}
|
|
|
|
// Also check for space-separated hex values
|
|
const hexPattern = new RegExp(`\\b[0-9a-fA-F]{${prefixHexChars}}\\b`, 'g');
|
|
const matches = input.match(hexPattern);
|
|
|
|
// If we have 2+ hex values, treat as path
|
|
return matches && matches.length >= 2;
|
|
}
|
|
|
|
// Resolve path via API
|
|
async function resolvePath(pathInput) {
|
|
try {
|
|
const response = await fetch('/api/mesh/resolve-path', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: JSON.stringify({ path: pathInput })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to resolve path');
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Error resolving path:', error);
|
|
return { valid: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// Handle search input - route to path resolution or normal filtering
|
|
function handleSearchInput() {
|
|
const searchInput = document.getElementById('filter-search');
|
|
const searchTerm = searchInput.value.trim();
|
|
|
|
// Clear any existing path highlight timeout
|
|
if (pathHighlightTimeout) {
|
|
clearTimeout(pathHighlightTimeout);
|
|
pathHighlightTimeout = null;
|
|
}
|
|
|
|
// If search is empty, clear path highlights and apply normal filters
|
|
if (!searchTerm) {
|
|
clearPathHighlight();
|
|
applyFilters();
|
|
saveFilters();
|
|
return;
|
|
}
|
|
|
|
// Check if input is a path
|
|
if (detectPathInput(searchTerm)) {
|
|
// Clear previous path highlight when starting new search
|
|
clearPathHighlight();
|
|
|
|
// Debounce path resolution (wait 500ms after user stops typing)
|
|
// Use longer delay when deleting to avoid flicker when path becomes invalid
|
|
const isDeleting = searchTerm.length < (highlightedPath ? highlightedPath.node_ids.join('').length : 999);
|
|
const delay = isDeleting ? 800 : 500; // Longer delay when deleting
|
|
|
|
pathHighlightTimeout = setTimeout(async () => {
|
|
// Normalize path input: handle continuous hex strings (e.g., "8601a5")
|
|
let normalizedPath = searchTerm.trim();
|
|
const cleanPath = normalizedPath.replace(/[,\s:]/g, '');
|
|
if (/^[0-9a-fA-F]{4,}$/.test(cleanPath)) {
|
|
// Continuous hex string - add spaces between pairs for API
|
|
normalizedPath = cleanPath.match(/.{1,2}/g).join(' ');
|
|
}
|
|
const resolved = await resolvePath(normalizedPath);
|
|
if (resolved.valid && resolved.repeaters && resolved.repeaters.length > 0) {
|
|
// Ensure map is rendered first (applyFilters will render if needed)
|
|
// But don't filter out nodes/edges - we want to show the path on the full map
|
|
if (currentView === 'map') {
|
|
// Map should already be rendered, just highlight the path
|
|
highlightPath(resolved);
|
|
} else {
|
|
// If in graph view, switch to map view first
|
|
switchView('map');
|
|
// Wait for map to render, then highlight
|
|
setTimeout(() => highlightPath(resolved), 100);
|
|
}
|
|
} else {
|
|
// Path resolution failed, fall back to normal search
|
|
applyFilters();
|
|
saveFilters();
|
|
}
|
|
}, delay);
|
|
} else {
|
|
// Not a path - use normal filtering
|
|
// Add delay when deleting to avoid flicker
|
|
const isDeleting = searchTerm.length < (document.getElementById('filter-search').getAttribute('data-last-length') || 999);
|
|
const delay = isDeleting ? 300 : 0;
|
|
|
|
// Store current length for next comparison
|
|
document.getElementById('filter-search').setAttribute('data-last-length', searchTerm.length);
|
|
|
|
if (delay > 0) {
|
|
pathHighlightTimeout = setTimeout(() => {
|
|
clearPathHighlight();
|
|
applyFilters();
|
|
saveFilters();
|
|
}, delay);
|
|
} else {
|
|
clearPathHighlight();
|
|
applyFilters();
|
|
saveFilters();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Highlight a resolved path on the map
|
|
// immediate: if true, apply styles synchronously (for use after renderMap to prevent flicker)
|
|
function highlightPath(resolvedPath, immediate = false) {
|
|
if (!resolvedPath || !resolvedPath.valid || !resolvedPath.repeaters) {
|
|
console.warn('Invalid path data:', resolvedPath);
|
|
return;
|
|
}
|
|
|
|
// Check if map is available and has edges/nodes
|
|
if (!map || edgeLines.size === 0) {
|
|
console.warn('Map not ready or no edges rendered. Edge count:', edgeLines.size);
|
|
// Try to render the map first
|
|
if (currentView === 'map') {
|
|
applyFilters();
|
|
// Try again after rendering
|
|
setTimeout(() => highlightPath(resolvedPath, immediate), 200);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Store path data first
|
|
highlightedPath = resolvedPath;
|
|
|
|
// Clear any existing node connection highlights (but don't clear the map)
|
|
// Just reset the highlighted node state, don't call clearHighlights() which might affect rendering
|
|
if (highlightedNode) {
|
|
highlightedNode = null;
|
|
highlightedNodeObject = null;
|
|
}
|
|
|
|
// Get all found repeaters with locations
|
|
const pathRepeaters = resolvedPath.repeaters.filter(r => r.found && r.latitude && r.longitude);
|
|
|
|
if (pathRepeaters.length === 0) {
|
|
console.warn('No repeaters with locations found in path. Path data:', resolvedPath);
|
|
// Don't return - still show the map, just don't highlight anything
|
|
return;
|
|
}
|
|
|
|
// Highlight edges between consecutive nodes in the path
|
|
// Need to match the edgeKey format used when storing edges: `${edge.from_prefix}-${edge.to_prefix}`
|
|
const pathEdgeKeys = new Set();
|
|
for (let i = 0; i < pathRepeaters.length - 1; i++) {
|
|
const fromRepeater = pathRepeaters[i];
|
|
const toRepeater = pathRepeaters[i + 1];
|
|
|
|
// Find the edge - try public key matching first, then prefix
|
|
let edge = null;
|
|
|
|
// Try public key matching first (most accurate)
|
|
if (fromRepeater.public_key && toRepeater.public_key) {
|
|
edge = filteredEdges.find(e =>
|
|
e.from_public_key === fromRepeater.public_key &&
|
|
e.to_public_key === toRepeater.public_key
|
|
);
|
|
}
|
|
|
|
// Fall back to prefix matching (case-insensitive)
|
|
const fromPrefix = fromRepeater.node_id.toUpperCase();
|
|
const toPrefix = toRepeater.node_id.toUpperCase();
|
|
|
|
if (!edge) {
|
|
edge = filteredEdges.find(e =>
|
|
e.from_prefix.toUpperCase() === fromPrefix &&
|
|
e.to_prefix.toUpperCase() === toPrefix
|
|
);
|
|
}
|
|
|
|
// Also check reverse direction (bidirectional edges)
|
|
if (!edge) {
|
|
edge = filteredEdges.find(e =>
|
|
e.from_prefix.toUpperCase() === toPrefix &&
|
|
e.to_prefix.toUpperCase() === fromPrefix
|
|
);
|
|
}
|
|
|
|
if (edge) {
|
|
// Use the same edgeKey format as when storing: `${edge.from_prefix}-${edge.to_prefix}`
|
|
// Always use the edge's actual from/to prefixes (as stored in the database)
|
|
// Normalize to lowercase to match how edges are stored
|
|
const edgeKey = `${edge.from_prefix.toLowerCase()}-${edge.to_prefix.toLowerCase()}`;
|
|
pathEdgeKeys.add(edgeKey);
|
|
} else {
|
|
console.warn(`No edge found for path segment ${fromRepeater.node_id}->${toRepeater.node_id}`);
|
|
}
|
|
}
|
|
|
|
// First, store original styles for ALL edges if not already stored
|
|
edgeLines.forEach((line, edgeKey) => {
|
|
if (!allEdgeStyles.has(edgeKey)) {
|
|
allEdgeStyles.set(edgeKey, {
|
|
color: line.options.color,
|
|
weight: line.options.weight,
|
|
opacity: line.options.opacity
|
|
});
|
|
}
|
|
});
|
|
|
|
// Normalize path edge keys to lowercase for matching
|
|
const normalizedPathEdgeKeys = new Set();
|
|
pathEdgeKeys.forEach(key => normalizedPathEdgeKeys.add(key.toLowerCase()));
|
|
|
|
// Apply edge styles - immediately if requested (to prevent flicker after renderMap),
|
|
// otherwise use requestAnimationFrame to batch updates
|
|
const applyEdgeStyles = () => {
|
|
edgeLines.forEach((line, edgeKey) => {
|
|
// Normalize edgeKey for comparison
|
|
const normalizedKey = edgeKey.toLowerCase();
|
|
if (normalizedPathEdgeKeys.has(normalizedKey)) {
|
|
// Apply path highlight style
|
|
line.setStyle({
|
|
color: '#fbbf24', // Bright yellow/orange
|
|
weight: 6, // Thicker
|
|
opacity: 1.0 // Fully opaque
|
|
});
|
|
} else {
|
|
// Dim non-path edges - now we know originalStyle exists since we stored it above
|
|
const originalStyle = allEdgeStyles.get(edgeKey);
|
|
if (originalStyle) {
|
|
line.setStyle({
|
|
color: '#848484',
|
|
weight: Math.max(1, originalStyle.weight * 0.5),
|
|
opacity: 0.2
|
|
});
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
if (immediate) {
|
|
// Apply immediately to prevent flicker when called from renderMap()
|
|
applyEdgeStyles();
|
|
} else {
|
|
// Use requestAnimationFrame to batch all style updates in a single frame
|
|
requestAnimationFrame(applyEdgeStyles);
|
|
}
|
|
|
|
// Highlight path nodes
|
|
pathRepeaters.forEach((repeater, index) => {
|
|
// Find the node in our node map
|
|
let node = null;
|
|
|
|
// Try to find by public key first (most accurate)
|
|
if (repeater.public_key && nodeMapByKey && nodeMapByKey[repeater.public_key]) {
|
|
node = nodeMapByKey[repeater.public_key];
|
|
} else if (repeater.latitude && repeater.longitude) {
|
|
// Fall back to prefix and location matching (for cases where public key not available)
|
|
const matchingNodes = filteredNodes.filter(n =>
|
|
n.prefix === repeater.node_id &&
|
|
n.latitude && n.longitude &&
|
|
Math.abs(n.latitude - repeater.latitude) < 0.001 &&
|
|
Math.abs(n.longitude - repeater.longitude) < 0.001
|
|
);
|
|
if (matchingNodes.length > 0) {
|
|
node = matchingNodes[0];
|
|
}
|
|
} else {
|
|
// Last resort: just match by prefix (less accurate but better than nothing)
|
|
node = nodeMap[repeater.node_id];
|
|
}
|
|
|
|
if (node) {
|
|
const nodeId = getNodeId(node);
|
|
const marker = nodeMarkers.get(nodeId);
|
|
if (marker) {
|
|
const highlightSize = getMapNodeSize(node) + 5;
|
|
const highlightBorderWeight = getBorderWeight(highlightSize, node.is_starred);
|
|
marker.setStyle({
|
|
radius: highlightSize,
|
|
weight: highlightBorderWeight,
|
|
color: '#f59e0b' // Amber border
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Save filters (but don't trigger applyFilters to avoid clearing highlights)
|
|
saveFilters();
|
|
}
|
|
|
|
// Clear path highlighting
|
|
function clearPathHighlight() {
|
|
if (!highlightedPath) return;
|
|
|
|
highlightedPath = null;
|
|
|
|
// Restore all edge styles
|
|
edgeLines.forEach((line, edgeKey) => {
|
|
const originalStyle = allEdgeStyles.get(edgeKey);
|
|
if (originalStyle) {
|
|
line.setStyle(originalStyle);
|
|
}
|
|
});
|
|
|
|
// Restore all node styles
|
|
filteredNodes.forEach(node => {
|
|
const nodeId = getNodeId(node);
|
|
const marker = nodeMarkers.get(nodeId);
|
|
if (marker) {
|
|
const originalStyle = allNodeStyles.get(nodeId);
|
|
if (originalStyle) {
|
|
// Restore original size and style
|
|
marker.setStyle(originalStyle);
|
|
} else {
|
|
// Fallback: restore based on node properties
|
|
const normalSize = getMapNodeSize(node);
|
|
const normalBorderWeight = getBorderWeight(normalSize, node.is_starred);
|
|
marker.setStyle({
|
|
radius: normalSize,
|
|
weight: normalBorderWeight,
|
|
color: node.is_starred ? '#ffc107' : '#ffffff',
|
|
fillOpacity: 0.8
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', async function() {
|
|
document.getElementById('btn-view-map').addEventListener('click', () => switchView('map'));
|
|
document.getElementById('btn-view-graph').addEventListener('click', () => switchView('graph'));
|
|
document.getElementById('btn-heat').addEventListener('click', toggleHeatMode);
|
|
document.getElementById('btn-mesh-theme').addEventListener('click', toggleMeshTheme);
|
|
document.getElementById('btn-mesh-refresh').addEventListener('click', refreshData);
|
|
document.getElementById('btn-mesh-export').addEventListener('click', exportView);
|
|
document.getElementById('btn-fullscreen').addEventListener('click', toggleFullscreen);
|
|
document.getElementById('filter-min-obs').addEventListener('input', onMinObsInput);
|
|
document.getElementById('filter-edge-days').addEventListener('change', () => {
|
|
saveFilters();
|
|
loadData({ reloadNodes: false })
|
|
.catch(error => console.error('Error reloading edge window:', error));
|
|
});
|
|
document.getElementById('filter-node-days').addEventListener('change', () => {
|
|
saveFilters();
|
|
loadData({ reloadEdges: false })
|
|
.catch(error => console.error('Error reloading node window:', error));
|
|
});
|
|
document.getElementById('filter-starred').addEventListener('change', () => {
|
|
applyFilters();
|
|
saveFilters();
|
|
});
|
|
document.getElementById('filter-evidence').addEventListener('change', onEvidenceChange);
|
|
document.getElementById('filter-search').addEventListener('input', handleSearchInput);
|
|
|
|
// Initialize modal instances once
|
|
const nodeModalElement = document.getElementById('nodeDetailsModal');
|
|
if (nodeModalElement) {
|
|
nodeModal = new bootstrap.Modal(nodeModalElement, {
|
|
backdrop: true,
|
|
keyboard: true,
|
|
focus: true
|
|
});
|
|
}
|
|
|
|
// Load saved filter settings
|
|
loadFilters();
|
|
syncHeatButton();
|
|
|
|
// Apply mesh display theme (dark basemap/graph) before the map is created
|
|
initMeshTheme();
|
|
|
|
setupSocketIO();
|
|
// Fetch stats and graph data concurrently; render once both are in so the
|
|
// initial map framing can use the bot location from stats
|
|
await Promise.all([loadStats(), loadData({ skipRender: true })]);
|
|
applyFilters();
|
|
// Open graph tab if URL has #graph (e.g. /mesh#graph)
|
|
if (window.location.hash === '#graph') {
|
|
switchView('graph');
|
|
}
|
|
// Keep view in sync with URL hash (back/forward, direct link)
|
|
window.addEventListener('hashchange', function() {
|
|
if (window.location.hash === '#graph' && currentView !== 'graph') {
|
|
switchView('graph');
|
|
} else if (window.location.hash !== '#graph' && currentView === 'graph') {
|
|
switchView('map');
|
|
}
|
|
});
|
|
});
|
|
|
|
function isValidMeshNode(node) {
|
|
return Boolean(
|
|
node
|
|
&& typeof node === 'object'
|
|
&& typeof node.public_key === 'string'
|
|
&& node.public_key.length > 0
|
|
&& typeof node.prefix === 'string'
|
|
&& node.prefix.length > 0
|
|
&& typeof node.name === 'string'
|
|
&& Number.isFinite(node.latitude)
|
|
&& Number.isFinite(node.longitude)
|
|
);
|
|
}
|
|
|
|
async function fetchMeshJson(url, requiredArrayKey = null) {
|
|
const response = await fetch(url);
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON response from ${url}`);
|
|
}
|
|
if (!response.ok || !data || typeof data !== 'object' || data.error) {
|
|
const detail = data && data.error ? `: ${data.error}` : '';
|
|
throw new Error(`Mesh request failed (${response.status})${detail}`);
|
|
}
|
|
if (requiredArrayKey && !Array.isArray(data[requiredArrayKey])) {
|
|
throw new Error(
|
|
`Mesh response from ${url} is missing ${requiredArrayKey}`
|
|
);
|
|
}
|
|
if (
|
|
requiredArrayKey === 'edges'
|
|
&& !data.edges.every(edge =>
|
|
edge
|
|
&& typeof edge === 'object'
|
|
&& typeof edge.from_prefix === 'string'
|
|
&& typeof edge.to_prefix === 'string'
|
|
)
|
|
) {
|
|
throw new Error(`Mesh response from ${url} contains invalid edges`);
|
|
}
|
|
if (
|
|
requiredArrayKey === 'nodes'
|
|
&& !data.nodes.every(isValidMeshNode)
|
|
) {
|
|
throw new Error(`Mesh response from ${url} contains invalid nodes`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// Load statistics
|
|
function loadStats() {
|
|
if (statsLoadInFlight) return statsLoadInFlight;
|
|
statsLoadInFlight = (async () => {
|
|
try {
|
|
const stats = await fetchMeshJson('/api/mesh/stats');
|
|
|
|
document.getElementById('stat-node-count').textContent = stats.node_count || 0;
|
|
document.getElementById('stat-edge-count').textContent = stats.total_edges || 0;
|
|
if (stats.multibyte_edges !== undefined) {
|
|
document.getElementById('stat-edge-detail').textContent =
|
|
`${stats.multibyte_edges} multi-byte confirmed`;
|
|
}
|
|
document.getElementById('stat-avg-obs').textContent = stats.avg_observations || 0;
|
|
document.getElementById('stat-avg-dist').textContent = stats.avg_distance ? stats.avg_distance + ' km' : 'N/A';
|
|
if (stats.bot_location) {
|
|
botLocation = stats.bot_location; // used to frame the initial map view
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
})().finally(() => {
|
|
statsLoadInFlight = null;
|
|
});
|
|
return statsLoadInFlight;
|
|
}
|
|
|
|
// --- Min Observations slider ---
|
|
// Observation counts are heavy-tailed (median ~single digits, max in the tens of
|
|
// thousands), so the slider position maps logarithmically onto a threshold of
|
|
// 1..minObsScaleMax. minObsValue (the threshold) is the source of truth: it is
|
|
// what gets persisted, and the position is re-derived from it whenever the data
|
|
// (and therefore the scale) changes.
|
|
let minObsValue = null; // null until restored from storage or defaulted from data
|
|
let minObsScaleMax = 100;
|
|
|
|
function minObsPositionToThreshold(pos) {
|
|
return Math.max(1, Math.round(Math.pow(minObsScaleMax, pos / 100)));
|
|
}
|
|
|
|
function minObsThresholdToPosition(threshold) {
|
|
const clamped = Math.min(Math.max(threshold, 1), minObsScaleMax);
|
|
return Math.round(100 * Math.log(clamped) / Math.log(minObsScaleMax));
|
|
}
|
|
|
|
// Recompute the slider scale from the loaded edges; default the threshold to the
|
|
// median observation count on first visit (the old default used the average,
|
|
// which sits above the 75th percentile on heavy-tailed data and hid most edges).
|
|
function syncMinObsSlider() {
|
|
const counts = allEdges.map(e => e.observation_count || 0).sort((a, b) => a - b);
|
|
const maxCount = counts.length ? counts[counts.length - 1] : 0;
|
|
minObsScaleMax = Math.max(10, maxCount);
|
|
if (minObsValue === null) {
|
|
minObsValue = counts.length ? Math.max(1, counts[Math.floor(counts.length / 2)]) : 1;
|
|
}
|
|
document.getElementById('filter-min-obs').value = minObsThresholdToPosition(minObsValue);
|
|
}
|
|
|
|
function updateMinObsLabel() {
|
|
const label = document.getElementById('label-min-obs');
|
|
if (label) {
|
|
label.textContent = `≥ ${minObsValue || 1} obs · ${filteredEdges.length.toLocaleString()}/${allEdges.length.toLocaleString()} edges`;
|
|
}
|
|
}
|
|
|
|
function onMinObsInput() {
|
|
const pos = parseInt(document.getElementById('filter-min-obs').value, 10) || 0;
|
|
minObsValue = minObsPositionToThreshold(pos);
|
|
applyFilters();
|
|
saveFilters();
|
|
}
|
|
|
|
function normalizeMeshLoadOptions(options) {
|
|
return {
|
|
skipRender: Boolean(options && options.skipRender === true),
|
|
reloadEdges: !options || options.reloadEdges !== false,
|
|
reloadNodes: !options || options.reloadNodes !== false,
|
|
forceRefresh: Boolean(options && options.forceRefresh === true)
|
|
};
|
|
}
|
|
|
|
function mergeMeshLoadOptions(existing, incoming) {
|
|
if (!existing) return incoming;
|
|
return {
|
|
// Render when any queued caller needs a render.
|
|
skipRender: existing.skipRender && incoming.skipRender,
|
|
reloadEdges: existing.reloadEdges || incoming.reloadEdges,
|
|
reloadNodes: existing.reloadNodes || incoming.reloadNodes,
|
|
forceRefresh: existing.forceRefresh || incoming.forceRefresh
|
|
};
|
|
}
|
|
|
|
// Load nodes and edges. Calls are serialized and requests that arrive while
|
|
// one is active are merged into one trailing load.
|
|
function loadData(options) {
|
|
pendingMeshLoad = mergeMeshLoadOptions(
|
|
pendingMeshLoad,
|
|
normalizeMeshLoadOptions(options)
|
|
);
|
|
if (!meshLoadInFlight) {
|
|
meshLoadInFlight = drainMeshLoads().finally(() => {
|
|
meshLoadInFlight = null;
|
|
});
|
|
}
|
|
return meshLoadInFlight;
|
|
}
|
|
|
|
async function drainMeshLoads() {
|
|
let lastError = null;
|
|
while (pendingMeshLoad) {
|
|
const options = pendingMeshLoad;
|
|
pendingMeshLoad = null;
|
|
try {
|
|
await performMeshLoad(options);
|
|
lastError = null;
|
|
} catch (error) {
|
|
// Continue with work queued while the failed request was active.
|
|
lastError = error;
|
|
}
|
|
}
|
|
if (lastError) {
|
|
throw lastError;
|
|
}
|
|
}
|
|
|
|
async function performMeshLoad(options) {
|
|
const skipRender = options.skipRender;
|
|
const reloadEdges = options.reloadEdges;
|
|
const reloadNodes = options.reloadNodes;
|
|
const forceRefresh = options.forceRefresh;
|
|
|
|
try {
|
|
const evidence = document.getElementById('filter-evidence').value;
|
|
const edgeDays = document.getElementById('filter-edge-days').value;
|
|
const nodeDays = document.getElementById('filter-node-days').value;
|
|
const edgeParams = new URLSearchParams();
|
|
if (evidence === 'multibyte' || evidence === 'neighbors') edgeParams.set('evidence', evidence);
|
|
if (edgeDays) edgeParams.set('days', edgeDays);
|
|
if (forceRefresh) edgeParams.set('refresh', '1');
|
|
const nodeParams = new URLSearchParams();
|
|
if (nodeDays) nodeParams.set('days', nodeDays);
|
|
const edgeQuery = edgeParams.toString();
|
|
const nodeQuery = nodeParams.toString();
|
|
const edgesUrl = edgeQuery
|
|
? `/api/mesh/edges?${edgeQuery}`
|
|
: '/api/mesh/edges';
|
|
const nodesUrl = nodeQuery
|
|
? `/api/mesh/nodes?${nodeQuery}`
|
|
: '/api/mesh/nodes';
|
|
// Fetch edges and nodes concurrently; node prefixes are recomputed locally
|
|
// below once the edge prefix length is known, so the nodes request does
|
|
// not have to wait for the edges response
|
|
const [edgesData, nodesData] = await Promise.all([
|
|
reloadEdges ? fetchMeshJson(edgesUrl, 'edges') : Promise.resolve(null),
|
|
reloadNodes ? fetchMeshJson(nodesUrl, 'nodes') : Promise.resolve(null)
|
|
]);
|
|
const nextEdges = edgesData ? edgesData.edges : allEdges;
|
|
const nextPrefixHexChars = edgesData
|
|
? ((edgesData.prefix_hex_chars && [2, 4, 6].includes(edgesData.prefix_hex_chars))
|
|
? edgesData.prefix_hex_chars
|
|
: PREFIX_HEX_CHARS)
|
|
: graphPrefixHexChars;
|
|
const sourceNodes = nodesData ? nodesData.nodes : allNodes;
|
|
if (!nextEdges.every(edge =>
|
|
edge
|
|
&& typeof edge === 'object'
|
|
&& typeof edge.from_prefix === 'string'
|
|
&& typeof edge.to_prefix === 'string'
|
|
)) {
|
|
throw new Error(`Mesh response from ${edgesUrl} contains invalid edges`);
|
|
}
|
|
if (!sourceNodes.every(isValidMeshNode)) {
|
|
throw new Error(`Mesh response from ${nodesUrl} contains invalid nodes`);
|
|
}
|
|
|
|
// Prepare a complete candidate graph before replacing any current
|
|
// state, so malformed responses cannot blank a working display.
|
|
const nextNodes = sourceNodes.map(node => {
|
|
const nextNode = { ...node };
|
|
if (nextNode.public_key) {
|
|
nextNode.prefix = nextNode.public_key
|
|
.substring(0, nextPrefixHexChars)
|
|
.toLowerCase();
|
|
}
|
|
return nextNode;
|
|
});
|
|
const nextNodeMap = {};
|
|
const nextNodeMapByKey = {};
|
|
nextNodes.forEach(node => {
|
|
nextNodeMap[node.prefix] = node;
|
|
if (node.public_key) {
|
|
nextNodeMapByKey[node.public_key] = node;
|
|
}
|
|
});
|
|
const nextEdgeMap = {};
|
|
nextEdges.forEach(edge => {
|
|
const key = `${edge.from_prefix}-${edge.to_prefix}`;
|
|
nextEdgeMap[key] = edge;
|
|
});
|
|
|
|
allEdges = nextEdges;
|
|
allNodes = nextNodes;
|
|
graphPrefixHexChars = nextPrefixHexChars;
|
|
nodeMap = nextNodeMap;
|
|
nodeMapByKey = nextNodeMapByKey;
|
|
edgeMap = nextEdgeMap;
|
|
|
|
// Rescale the min-observations slider to the new edge distribution
|
|
if (reloadEdges) {
|
|
syncMinObsSlider();
|
|
}
|
|
|
|
if (!skipRender) {
|
|
// Note: applyFilters() will preserve and re-apply path highlights via renderMap()
|
|
applyFilters();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading data:', error);
|
|
throw error; // Re-throw so callers can handle it
|
|
}
|
|
}
|
|
|
|
// Save filter settings to localStorage
|
|
function saveFilters() {
|
|
try {
|
|
const filters = {
|
|
minObs: minObsValue, // threshold, not slider position
|
|
edgeDays: document.getElementById('filter-edge-days').value,
|
|
nodeDays: document.getElementById('filter-node-days').value,
|
|
starredOnly: document.getElementById('filter-starred').checked,
|
|
evidence: document.getElementById('filter-evidence').value,
|
|
colorMode: edgeColorMode
|
|
// Note: searchTerm is NOT saved - it should not persist across reloads
|
|
};
|
|
localStorage.setItem('meshGraphFilters', JSON.stringify(filters));
|
|
} catch (error) {
|
|
console.debug('Error saving filters:', error);
|
|
}
|
|
}
|
|
|
|
// Load filter settings from localStorage
|
|
function loadFilters() {
|
|
try {
|
|
const saved = localStorage.getItem('meshGraphFilters');
|
|
if (saved) {
|
|
const filters = JSON.parse(saved);
|
|
if (filters.minObs !== undefined && filters.minObs !== null) {
|
|
const parsed = parseInt(filters.minObs, 10);
|
|
if (!isNaN(parsed) && parsed >= 1) {
|
|
// Threshold only; slider position is derived in syncMinObsSlider
|
|
// once the data (and therefore the log scale) is loaded
|
|
minObsValue = parsed;
|
|
}
|
|
}
|
|
if (filters.edgeDays !== undefined) {
|
|
document.getElementById('filter-edge-days').value = filters.edgeDays;
|
|
}
|
|
if (filters.nodeDays !== undefined) {
|
|
document.getElementById('filter-node-days').value = filters.nodeDays;
|
|
}
|
|
if (filters.starredOnly !== undefined) {
|
|
document.getElementById('filter-starred').checked = filters.starredOnly;
|
|
}
|
|
if (['all', 'multibyte', 'neighbors'].includes(filters.evidence)) {
|
|
document.getElementById('filter-evidence').value = filters.evidence;
|
|
}
|
|
if (filters.colorMode === 'age' || filters.colorMode === 'heat') {
|
|
edgeColorMode = filters.colorMode;
|
|
}
|
|
// Note: searchTerm is NOT loaded - it should reset on page reload
|
|
}
|
|
} catch (error) {
|
|
console.debug('Error loading filters:', error);
|
|
}
|
|
}
|
|
|
|
// Apply filters
|
|
function applyFilters() {
|
|
// Preserve highlighted node before filtering/re-rendering
|
|
const preservedHighlightedNode = highlightedNodeObject;
|
|
// Preserve path highlight if active (will be re-applied in renderMap if still valid)
|
|
const preservedPath = highlightedPath;
|
|
|
|
const minObs = minObsValue || 1;
|
|
const edgeDays = document.getElementById('filter-edge-days').value;
|
|
const nodeDays = document.getElementById('filter-node-days').value;
|
|
const starredOnly = document.getElementById('filter-starred').checked;
|
|
const searchTerm = document.getElementById('filter-search').value.toLowerCase();
|
|
|
|
// Filter edges
|
|
filteredEdges = allEdges.filter(edge => {
|
|
if (edge.observation_count < minObs) return false;
|
|
if (edgeDays && edge.last_seen) {
|
|
const edgeDate = new Date(edge.last_seen);
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - parseInt(edgeDays));
|
|
if (edgeDate < cutoffDate) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Filter nodes by timeframe first
|
|
let candidateNodes = allNodes;
|
|
if (nodeDays) {
|
|
candidateNodes = allNodes.filter(node => {
|
|
const lastSeen = node.last_heard || node.last_advert_timestamp;
|
|
if (!lastSeen) return false;
|
|
const nodeDate = new Date(lastSeen);
|
|
const cutoffDate = new Date();
|
|
cutoffDate.setDate(cutoffDate.getDate() - parseInt(nodeDays));
|
|
return nodeDate >= cutoffDate;
|
|
});
|
|
}
|
|
|
|
// Filter nodes: include all repeaters with location that pass filters (not only those in edge set),
|
|
// so e0 nodes with no edges still appear on the map (with 0 connections).
|
|
filteredNodes = candidateNodes.filter(node => {
|
|
if (starredOnly && !node.is_starred) return false;
|
|
// Only apply text search if it's not a path (path resolution is handled separately)
|
|
if (searchTerm && !detectPathInput(searchTerm)) {
|
|
const nameMatch = node.name.toLowerCase().includes(searchTerm);
|
|
const prefixMatch = node.prefix.toLowerCase().includes(searchTerm);
|
|
if (!nameMatch && !prefixMatch) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Build filtered node maps for O(1) edge endpoint resolution (only draw edges when both endpoints are in filtered set)
|
|
filteredNodeMap = {};
|
|
filteredNodeMapByKey = {};
|
|
filteredNodes.forEach(node => {
|
|
filteredNodeMap[node.prefix] = node;
|
|
if (node.public_key) filteredNodeMapByKey[node.public_key] = node;
|
|
});
|
|
|
|
// Per-node degree and observation totals for sizing and heat coloring
|
|
computeNodeStats();
|
|
|
|
// Live feedback: threshold plus how many edges survive the current filters
|
|
updateMinObsLabel();
|
|
|
|
// Normalize heat colors over the currently visible edges (log domain).
|
|
// Loop rather than Math.min(...arr): tens of thousands of edges would
|
|
// exceed the engine's argument-count limit.
|
|
let heatMin = Infinity, heatMax = 0;
|
|
filteredEdges.forEach(e => {
|
|
const obs = Math.max(1, e.observation_count || 1);
|
|
if (obs < heatMin) heatMin = obs;
|
|
if (obs > heatMax) heatMax = obs;
|
|
});
|
|
heatObsRange = heatMax ? { min: heatMin, max: heatMax } : { min: 1, max: 1 };
|
|
updateHeatLegend();
|
|
|
|
// Re-render current view
|
|
if (currentView === 'map') {
|
|
renderMap();
|
|
// Highlights are re-applied inside renderMap() if preservedHighlightedNode or preservedPath exists
|
|
} else {
|
|
renderGraph();
|
|
// Re-apply highlights in graph view if we had a highlighted node (preservedHighlightedNode is the node object)
|
|
if (preservedHighlightedNode && filteredNodes.some(n => getNodeId(n) === getNodeId(preservedHighlightedNode))) {
|
|
requestAnimationFrame(() => {
|
|
highlightNodeConnectionsGraph(preservedHighlightedNode);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Evidence mode is a server-side parameter, so changing it re-fetches data
|
|
// (loadData applies the current filters and re-renders when done)
|
|
function onEvidenceChange() {
|
|
saveFilters();
|
|
loadData({ reloadNodes: false })
|
|
.catch(error => console.error('Error reloading for evidence mode:', error));
|
|
}
|
|
|
|
// Switch between views
|
|
function switchView(view) {
|
|
currentView = view;
|
|
// Update URL hash so /mesh#graph links open directly to graph tab
|
|
if (view === 'graph') {
|
|
if (window.location.hash !== '#graph') {
|
|
history.replaceState(null, '', window.location.pathname + '#graph');
|
|
}
|
|
} else {
|
|
if (window.location.hash) {
|
|
history.replaceState(null, '', window.location.pathname);
|
|
}
|
|
}
|
|
|
|
document.getElementById('btn-view-map').classList.toggle('active', view === 'map');
|
|
document.getElementById('btn-view-graph').classList.toggle('active', view !== 'map');
|
|
|
|
if (view === 'map') {
|
|
document.getElementById('map-view').style.display = 'block';
|
|
document.getElementById('graph-view').style.display = 'none';
|
|
document.getElementById('btn-fullscreen').style.display = 'inline-block';
|
|
renderMap();
|
|
} else {
|
|
document.getElementById('map-view').style.display = 'none';
|
|
document.getElementById('graph-view').style.display = 'block';
|
|
document.getElementById('btn-fullscreen').style.display = 'none';
|
|
renderGraph();
|
|
}
|
|
}
|
|
|
|
// Render map view
|
|
function renderMap() {
|
|
// Preserve highlighted node and path before clearing
|
|
const preservedHighlightedNode = highlightedNodeObject;
|
|
const preservedPath = highlightedPath;
|
|
|
|
// Check if this is initial load before saving state
|
|
const isInitialLoad = isInitialMapLoad && mapViewState === null;
|
|
|
|
// Initialize map if it doesn't exist
|
|
if (!map) {
|
|
// Check if container is already initialized (Leaflet stores map instance in _leaflet_id)
|
|
const mapContainer = document.getElementById('map-view');
|
|
if (mapContainer && mapContainer._leaflet_id) {
|
|
// Container already has a map instance - we need to clear it first
|
|
// Try to get and destroy the existing map instance
|
|
try {
|
|
// Leaflet stores map instances - try to access it
|
|
const existingMap = L.Map.prototype.get ? L.Map.prototype.get(mapContainer._leaflet_id) : null;
|
|
if (existingMap && existingMap.destroy) {
|
|
existingMap.destroy();
|
|
}
|
|
} catch (e) {
|
|
// If we can't destroy it, just clear the container
|
|
console.debug('Could not destroy existing map instance, clearing container');
|
|
}
|
|
|
|
// Clear Leaflet's internal tracking
|
|
delete mapContainer._leaflet_id;
|
|
if (mapContainer._leaflet) delete mapContainer._leaflet;
|
|
|
|
// Clear any Leaflet classes and content
|
|
mapContainer.className = mapContainer.className
|
|
.replace(/\s*leaflet-container\s*/g, ' ')
|
|
.replace(/\s*leaflet-touch\s*/g, ' ')
|
|
.replace(/\s*leaflet-retina\s*/g, ' ')
|
|
.trim();
|
|
mapContainer.innerHTML = '';
|
|
mapContainer.style.cssText = ''; // Clear any inline styles Leaflet added
|
|
}
|
|
|
|
// First time initialization
|
|
try {
|
|
map = L.map('map-view', {
|
|
zoomControl: true,
|
|
preferCanvas: false,
|
|
zoomAnimation: true, // Enable smooth zooming
|
|
zoomAnimationThreshold: 4, // Only animate if zoom difference is >= 4
|
|
fadeAnimation: true, // Enable fade animation for markers
|
|
markerZoomAnimation: true // Enable zoom animation for markers
|
|
}).setView([0, 0], 2);
|
|
|
|
setBaseTileLayer(); // theme-aware basemap (light OSM / dark CARTO)
|
|
|
|
// Disable popup auto-pan globally to prevent bounding boxes
|
|
L.Popup.prototype.options.autoPan = false;
|
|
mapViewState = null; // No previous state to preserve
|
|
isInitialMapLoad = true; // This is the initial map creation
|
|
|
|
// Clear highlights when clicking on map (not on a marker)
|
|
map.on('click', (e) => {
|
|
// Only clear if clicking directly on the map, not on a marker or popup
|
|
const target = e.originalEvent.target;
|
|
if (target && target.classList.contains('leaflet-container')) {
|
|
clearHighlights();
|
|
}
|
|
});
|
|
|
|
// Preserve highlights when zooming/panning
|
|
map.on('zoomend', () => {
|
|
// Rescale dots/lines for the new zoom before highlights repaint on top
|
|
updateMapSizesForZoom();
|
|
if (highlightedNodeObject) {
|
|
// Re-apply highlights after zoom using the actual node object
|
|
highlightNodeConnectionsForNode(highlightedNodeObject);
|
|
}
|
|
// Also re-apply path highlights if active
|
|
if (highlightedPath) {
|
|
highlightPath(highlightedPath);
|
|
}
|
|
});
|
|
|
|
map.on('moveend', () => {
|
|
if (highlightedNodeObject) {
|
|
// Re-apply highlights after pan using the actual node object
|
|
highlightNodeConnectionsForNode(highlightedNodeObject);
|
|
}
|
|
// Also re-apply path highlights if active
|
|
if (highlightedPath) {
|
|
highlightPath(highlightedPath);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Error creating map:', e);
|
|
// If creation fails, clear container and retry after a brief delay
|
|
const container = document.getElementById('map-view');
|
|
if (container) {
|
|
container.innerHTML = '';
|
|
container.className = '';
|
|
delete container._leaflet_id;
|
|
if (container._leaflet) delete container._leaflet;
|
|
}
|
|
// Retry after a brief delay
|
|
setTimeout(() => {
|
|
if (!map) {
|
|
try {
|
|
map = L.map('map-view', {
|
|
zoomControl: true,
|
|
preferCanvas: false
|
|
}).setView([0, 0], 2);
|
|
setBaseTileLayer();
|
|
// Re-render after successful creation
|
|
renderMap();
|
|
} catch (retryError) {
|
|
console.error('Error retrying map creation:', retryError);
|
|
}
|
|
}
|
|
}, 100);
|
|
return; // Exit early, will retry
|
|
}
|
|
}
|
|
|
|
// Save current map view state before clearing (if not initial load)
|
|
if (map && !isInitialLoad) {
|
|
const center = map.getCenter();
|
|
const zoom = map.getZoom();
|
|
mapViewState = {
|
|
center: [center.lat, center.lng],
|
|
zoom: zoom
|
|
};
|
|
}
|
|
|
|
// Clear existing markers and lines more thoroughly
|
|
// First, remove all layers that are markers or polylines
|
|
if (map) {
|
|
const layersToRemove = [];
|
|
map.eachLayer(layer => {
|
|
// Check for all marker types (L.Marker, L.CircleMarker) and polylines
|
|
if (layer instanceof L.Marker ||
|
|
layer instanceof L.CircleMarker ||
|
|
layer instanceof L.Polyline ||
|
|
(layer.options && (layer.options.radius !== undefined))) { // Catch any circle markers
|
|
layersToRemove.push(layer);
|
|
}
|
|
});
|
|
layersToRemove.forEach(layer => {
|
|
map.removeLayer(layer);
|
|
// Explicitly unbind popups and tooltips to clear any cached state
|
|
if (layer.unbindPopup) layer.unbindPopup();
|
|
if (layer.unbindTooltip) layer.unbindTooltip();
|
|
});
|
|
}
|
|
|
|
// Clear node and edge maps
|
|
nodeMarkers.clear();
|
|
nodeMarkersByPrefix.clear();
|
|
edgeLines.clear();
|
|
edgeToNodes.clear();
|
|
allEdgeStyles.clear();
|
|
allNodeStyles.clear();
|
|
highlightedEdges.clear();
|
|
// Note: Don't clear highlightedPath here - it will be re-applied after rendering if preservedPath exists
|
|
|
|
if (filteredNodes.length === 0) {
|
|
// Restore view state even if no nodes
|
|
if (mapViewState) {
|
|
map.setView(mapViewState.center, mapViewState.zoom);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const bounds = [];
|
|
|
|
// Add edges FIRST (so they appear below nodes)
|
|
filteredEdges.forEach(edge => {
|
|
// Resolve endpoints from filtered set only (so we don't draw edges to nodes outside node timeframe)
|
|
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
|
|
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
|
|
if (!fromNode || !toNode) return;
|
|
|
|
const color = getEdgeColor(edge);
|
|
const weight = getMapEdgeWeight(edge);
|
|
const edgeDistance = getEdgeDistance(edge, fromNode, toNode);
|
|
const evidenceLabel = getEvidenceLabel(edge);
|
|
|
|
const line = L.polyline(
|
|
[[fromNode.latitude, fromNode.longitude], [toNode.latitude, toNode.longitude]],
|
|
{
|
|
color: color,
|
|
weight: weight,
|
|
opacity: getEdgeOpacity(edge),
|
|
dashArray: isSingleByteEdge(edge) ? '6 6' : null, // Dash ambiguous single-byte evidence
|
|
pane: 'overlayPane' // Use overlayPane for edges (lower z-index)
|
|
}
|
|
);
|
|
|
|
// Simple hover tooltip for quick info
|
|
line.bindTooltip(`
|
|
${formatPrefixWithBytes(edge.from_prefix)} → ${formatPrefixWithBytes(edge.to_prefix)}<br>
|
|
Observations: ${edge.observation_count}<br>
|
|
${evidenceLabel ? 'Evidence: ' + evidenceLabel + '<br>' : ''}
|
|
Distance: ${edgeDistance ? edgeDistance.toFixed(1) + ' km' : 'N/A'}<br>
|
|
Last Seen: ${edge.last_seen ? new Date(edge.last_seen).toLocaleString() : 'Never'}
|
|
`);
|
|
|
|
// Create information-rich popup tooltip (similar to nodes) for click
|
|
const fromPrefixDisplay = formatPrefixWithBytes(edge.from_prefix);
|
|
const toPrefixDisplay = formatPrefixWithBytes(edge.to_prefix);
|
|
const edgeTooltipContent = `
|
|
<div style="min-width: 250px;">
|
|
<strong style="font-size: 1.1em;">${fromPrefixDisplay} → ${toPrefixDisplay}</strong><br>
|
|
<hr style="margin: 5px 0; border-color: #666;">
|
|
<table style="width: 100%; font-size: 0.9em;">
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>From:</strong></td>
|
|
<td style="padding: 2px 0;">${fromNode ? escapeHtml(fromNode.name) : escapeHtml(edge.from_prefix)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>To:</strong></td>
|
|
<td style="padding: 2px 0;">${toNode ? escapeHtml(toNode.name) : escapeHtml(edge.to_prefix)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Observations:</strong></td>
|
|
<td style="padding: 2px 0;">${edge.observation_count}</td>
|
|
</tr>
|
|
${evidenceLabel ? `<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Evidence:</strong></td>
|
|
<td style="padding: 2px 0;">${evidenceLabel}</td>
|
|
</tr>` : ''}
|
|
${isNeighborEdge(edge) && formatSnr(edge.best_snr) ? `<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Best SNR:</strong></td>
|
|
<td style="padding: 2px 0;">${formatSnr(edge.best_snr)}${formatSnr(edge.last_snr) ? ` (last ${formatSnr(edge.last_snr)})` : ''}</td>
|
|
</tr>` : ''}
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Distance:</strong></td>
|
|
<td style="padding: 2px 0;">${edgeDistance ? edgeDistance.toFixed(2) + ' km' : 'N/A'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Avg Hop Position:</strong></td>
|
|
<td style="padding: 2px 0;">${edge.avg_hop_position ? edge.avg_hop_position.toFixed(2) : 'N/A'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>First Seen:</strong></td>
|
|
<td style="padding: 2px 0;">${edge.first_seen ? new Date(edge.first_seen).toLocaleString() : 'Unknown'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Last Seen:</strong></td>
|
|
<td style="padding: 2px 0;">${edge.last_seen ? new Date(edge.last_seen).toLocaleString() : 'Unknown'}</td>
|
|
</tr>
|
|
${edge.from_public_key ? `<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>From Public Key:</strong></td>
|
|
<td style="padding: 2px 0;"><code style="font-size: 0.85em;">${formatPublicKey(edge.from_public_key)}</code></td>
|
|
</tr>` : ''}
|
|
${edge.to_public_key ? `<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>To Public Key:</strong></td>
|
|
<td style="padding: 2px 0;"><code style="font-size: 0.85em;">${formatPublicKey(edge.to_public_key)}</code></td>
|
|
</tr>` : ''}
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
line.bindPopup(edgeTooltipContent, {
|
|
maxWidth: 300,
|
|
className: 'mesh-edge-popup',
|
|
autoPan: false, // Prevent bounding box/auto-pan when popup opens
|
|
autoPanPadding: [0, 0], // No padding for auto-pan
|
|
closeOnClick: false // Don't close on map click
|
|
});
|
|
|
|
// Open popup on click and hide hover tooltip
|
|
line.on('click', (e) => {
|
|
// Prevent default behavior that might cause selection box
|
|
if (e.originalEvent) {
|
|
e.originalEvent.preventDefault();
|
|
e.originalEvent.stopPropagation();
|
|
}
|
|
|
|
// Close hover tooltip if it's open
|
|
line.closeTooltip();
|
|
|
|
// Open popup at the exact click location (e.latlng is the clicked point on the line)
|
|
// Use openPopup with the latlng and explicitly disable autoPan
|
|
const popup = line.getPopup();
|
|
if (popup) {
|
|
popup.setLatLng(e.latlng);
|
|
popup.options.autoPan = false;
|
|
popup.openOn(map);
|
|
} else {
|
|
// Fallback: use openPopup with options
|
|
line.openPopup(e.latlng, {
|
|
autoPan: false
|
|
});
|
|
}
|
|
});
|
|
line.addTo(map);
|
|
|
|
// Store edge line for highlighting, along with the actual node objects it connects
|
|
// Normalize to lowercase for consistent matching
|
|
const edgeKey = `${edge.from_prefix.toLowerCase()}-${edge.to_prefix.toLowerCase()}`;
|
|
edgeLines.set(edgeKey, line);
|
|
// Store the node objects and edge data (edge needed for zoom re-weighting)
|
|
edgeToNodes.set(edgeKey, { fromNode: fromNode, toNode: toNode, edge: edge });
|
|
});
|
|
|
|
// Add markers for nodes AFTER edges (so they appear on top and are easier to click)
|
|
filteredNodes.forEach(node => {
|
|
// Zoom-scaled size based on current filtered edges (changes with filters/zoom)
|
|
const nodeSize = getMapNodeSize(node);
|
|
// Calculate border weight that scales with node size
|
|
const borderWeight = getBorderWeight(nodeSize, node.is_starred);
|
|
|
|
// Create marker with calculated size and border weight
|
|
const marker = L.circleMarker([node.latitude, node.longitude], {
|
|
radius: nodeSize,
|
|
fillColor: getNodeFillColor(node),
|
|
color: node.is_starred ? '#ffc107' : '#fff',
|
|
weight: borderWeight,
|
|
opacity: 1,
|
|
fillOpacity: 0.8,
|
|
pane: 'markerPane' // Use markerPane for nodes (higher z-index than overlayPane)
|
|
});
|
|
|
|
// Create information-rich tooltip with all node details
|
|
// Match edges to this node by public_key or prefix (including prefix prefix-match: node "a58296" matches edge "a582")
|
|
const connections = filteredEdges.filter(e => {
|
|
const fromMatch = (e.from_public_key && e.from_public_key === node.public_key) || e.from_prefix === node.prefix || (node.prefix && e.from_prefix && (node.prefix.startsWith(e.from_prefix) || e.from_prefix.startsWith(node.prefix)));
|
|
const toMatch = (e.to_public_key && e.to_public_key === node.public_key) || e.to_prefix === node.prefix || (node.prefix && e.to_prefix && (node.prefix.startsWith(e.to_prefix) || e.to_prefix.startsWith(node.prefix)));
|
|
return fromMatch || toMatch;
|
|
});
|
|
const incoming = connections.filter(e => (e.to_public_key && e.to_public_key === node.public_key) || e.to_prefix === node.prefix || (node.prefix && e.to_prefix && (node.prefix.startsWith(e.to_prefix) || e.to_prefix.startsWith(node.prefix)))).length;
|
|
const outgoing = connections.filter(e => (e.from_public_key && e.from_public_key === node.public_key) || e.from_prefix === node.prefix || (node.prefix && e.from_prefix && (node.prefix.startsWith(e.from_prefix) || e.from_prefix.startsWith(node.prefix)))).length;
|
|
// Infer prefix byte width only from edges we know are this node (public_key match, or exact prefix match). Avoid prefix prefix-match so we don't count edges from other nodes that share a shorter prefix (e.g. another "a7" node).
|
|
const connectionsForByteWidth = filteredEdges.filter(e =>
|
|
(e.from_public_key && e.from_public_key === node.public_key) || (e.to_public_key && e.to_public_key === node.public_key) || e.from_prefix === node.prefix || e.to_prefix === node.prefix
|
|
);
|
|
let numBytes = 1;
|
|
for (const e of connectionsForByteWidth) {
|
|
const n = Math.max((e.from_prefix && e.from_prefix.length) || 0, (e.to_prefix && e.to_prefix.length) || 0) / 2;
|
|
if (n > numBytes) numBytes = n;
|
|
}
|
|
if (connectionsForByteWidth.length === 0) {
|
|
numBytes = 1;
|
|
}
|
|
numBytes = Math.min(3, Math.max(1, numBytes));
|
|
|
|
const tooltipContent = `
|
|
<div style="min-width: 250px;">
|
|
<strong style="font-size: 1.1em;">${escapeHtml(node.name)}</strong><br>
|
|
<hr style="margin: 5px 0; border-color: #666;">
|
|
<table style="width: 100%; font-size: 0.9em;">
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Prefix:</strong></td>
|
|
<td style="padding: 2px 0;">${formatPrefixAsByteChunksHTML(node.public_key || node.prefix || '', numBytes)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Public Key:</strong></td>
|
|
<td style="padding: 2px 0;"><code style="font-size: 0.85em;">${formatPublicKey(node.public_key)}</code></td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Role:</strong></td>
|
|
<td style="padding: 2px 0; text-transform: capitalize;">${escapeHtml(node.role)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Location:</strong></td>
|
|
<td style="padding: 2px 0;">${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Starred:</strong></td>
|
|
<td style="padding: 2px 0;">${node.is_starred ? '<i class="fas fa-star" style="color: #ffc107;"></i> Yes' : 'No'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Last Heard:</strong></td>
|
|
<td style="padding: 2px 0;">${node.last_heard ? new Date(node.last_heard).toLocaleString() : 'Never'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Last Advert:</strong></td>
|
|
<td style="padding: 2px 0;">${node.last_advert_timestamp ? new Date(node.last_advert_timestamp).toLocaleString() : 'Never'}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Connections:</strong></td>
|
|
<td style="padding: 2px 0;">${connections.length} total (${incoming} incoming, ${outgoing} outgoing)</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Path Observations:</strong></td>
|
|
<td style="padding: 2px 0;">${(nodeHeatTotals.get(getNodeId(node)) || 0).toLocaleString()}</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
marker.bindPopup(tooltipContent, {
|
|
maxWidth: 300,
|
|
className: 'mesh-node-popup',
|
|
autoPan: false // Prevent bounding box/auto-pan when popup opens
|
|
});
|
|
|
|
// Add click handler to highlight connections - pass the actual node object
|
|
marker.on('click', (e) => {
|
|
// Clear path highlights when clicking a node
|
|
if (highlightedPath) {
|
|
clearPathHighlight();
|
|
}
|
|
highlightNodeConnectionsForNode(node);
|
|
// Close hover tooltip if it's open (though nodes don't have hover tooltips, this is for consistency)
|
|
marker.closeTooltip();
|
|
// Open popup on click
|
|
marker.openPopup();
|
|
});
|
|
|
|
// Add marker to markerPane (higher z-index than overlayPane where edges are)
|
|
marker.addTo(map);
|
|
// Store marker with unique node identifier
|
|
const nodeId = getNodeId(node);
|
|
nodeMarkers.set(nodeId, marker);
|
|
// Also store by prefix for backward compatibility (array of markers)
|
|
if (!nodeMarkersByPrefix.has(node.prefix)) {
|
|
nodeMarkersByPrefix.set(node.prefix, []);
|
|
}
|
|
nodeMarkersByPrefix.get(node.prefix).push({ marker: marker, node: node });
|
|
bounds.push([node.latitude, node.longitude]);
|
|
});
|
|
|
|
// Clear highlight tracking (but preserve the node prefix to re-apply)
|
|
// Note: preservedHighlightedNode was already declared at the start of renderMap()
|
|
highlightedEdges.clear();
|
|
allEdgeStyles.clear();
|
|
// Temporarily clear highlightedNode to prevent issues during render
|
|
// We'll restore it after rendering if preservedHighlightedNode exists
|
|
// Note: preservedHighlightedNode was already declared at the start of renderMap()
|
|
highlightedNode = null;
|
|
|
|
// Fit to bounds on initial load, otherwise restore previous view state.
|
|
// Prefer the home mesh component (multi-byte connectivity from the bot's
|
|
// location) so wrong-GPS outliers don't stretch the frame.
|
|
if (isInitialLoad && bounds.length > 0) {
|
|
// Refresh Leaflet's cached container size first: if the map was created
|
|
// while the container had no layout yet, fitBounds would compute max zoom
|
|
map.invalidateSize();
|
|
map.fitBounds(getHomeComponentBounds() || bounds, { padding: [50, 50] });
|
|
isInitialMapLoad = false; // Mark that initial load is complete
|
|
} else if (mapViewState) {
|
|
// Subsequent renders - restore previous view state
|
|
map.setView(mapViewState.center, mapViewState.zoom);
|
|
} else if (bounds.length > 0) {
|
|
// Fallback: fit to bounds if no saved state
|
|
map.invalidateSize();
|
|
map.fitBounds(getHomeComponentBounds() || bounds, { padding: [50, 50] });
|
|
}
|
|
|
|
// Re-apply highlights immediately after rendering if we had a highlighted node
|
|
// Use double requestAnimationFrame to ensure all layers are fully rendered
|
|
if (preservedHighlightedNode) {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
highlightNodeConnectionsForNode(preservedHighlightedNode);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Re-apply path highlights if they were active
|
|
if (preservedPath) {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
highlightPath(preservedPath);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Render graph view
|
|
function renderGraph() {
|
|
const container = document.getElementById('graph-view');
|
|
|
|
// Prepare vis-network data - use unique node IDs to handle prefix collisions
|
|
// Remove duplicates by using a Map keyed by unique node ID
|
|
const nodeMapById = new Map();
|
|
filteredNodes.forEach(node => {
|
|
const nodeId = getNodeId(node); // Use unique ID (prefix-lat-lon) instead of just prefix
|
|
// Only add if we haven't seen this unique ID before
|
|
if (!nodeMapById.has(nodeId)) {
|
|
// Use shorter label to reduce overlap, show full name in tooltip
|
|
// For very long names, use just the prefix or a very short version
|
|
let shortLabel = node.name;
|
|
if (node.name.length > 25) {
|
|
// For very long names, prefer showing prefix or first part
|
|
shortLabel = node.prefix.toUpperCase() + ': ' + (node.name.length > 15 ? node.name.substring(0, 15) + '...' : node.name);
|
|
} else if (node.name.length > 20) {
|
|
shortLabel = node.name.substring(0, 20) + '...';
|
|
}
|
|
nodeMapById.set(nodeId, {
|
|
id: nodeId,
|
|
label: shortLabel,
|
|
title: `${escapeHtml(node.name)} (${escapeHtml(node.prefix)})`, // Full name in tooltip (rendered as HTML by vis-network)
|
|
prefix: node.prefix, // Store prefix for reference
|
|
color: {
|
|
background: getNodeFillColor(node),
|
|
border: node.is_starred ? '#ffc107' : '#fff',
|
|
highlight: {
|
|
background: node.role === 'roomserver' ? '#10b981' : '#f59e0b',
|
|
border: '#d97706'
|
|
},
|
|
hover: {
|
|
background: node.role === 'roomserver' ? '#10b981' : '#f59e0b',
|
|
border: '#d97706'
|
|
}
|
|
},
|
|
size: getNodeSize(node),
|
|
borderWidth: getBorderWeight(getNodeSize(node), node.is_starred),
|
|
font: { size: 12 }
|
|
});
|
|
}
|
|
});
|
|
const nodes = Array.from(nodeMapById.values());
|
|
|
|
// Remove duplicate edges by using a Set keyed by edge ID
|
|
const edgeMapById = new Map();
|
|
filteredEdges.forEach(edge => {
|
|
// Resolve endpoints from filtered set only (so we don't draw edges to nodes outside node timeframe)
|
|
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
|
|
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
|
|
if (!fromNode || !toNode) return;
|
|
|
|
// Use unique node IDs for from/to to handle prefix collisions
|
|
const fromNodeId = getNodeId(fromNode);
|
|
const toNodeId = getNodeId(toNode);
|
|
const edgeId = `${fromNodeId}-${toNodeId}`;
|
|
|
|
// Only add if we haven't seen this edge ID before
|
|
if (!edgeMapById.has(edgeId)) {
|
|
const evidenceLabel = getEvidenceLabel(edge);
|
|
edgeMapById.set(edgeId, {
|
|
id: edgeId, // Use unique IDs for edge ID
|
|
from: fromNodeId, // Use unique node ID
|
|
to: toNodeId, // Use unique node ID
|
|
width: getEdgeWeight(edge),
|
|
color: {
|
|
color: getEdgeColor(edge),
|
|
opacity: getEdgeOpacity(edge)
|
|
},
|
|
dashes: isSingleByteEdge(edge), // Dash ambiguous single-byte evidence
|
|
arrows: 'to',
|
|
title: `${edge.from_prefix} → ${edge.to_prefix} (${edge.observation_count} obs)`
|
|
+ (evidenceLabel ? ` — ${evidenceLabel}` : '')
|
|
});
|
|
}
|
|
});
|
|
const edges = Array.from(edgeMapById.values());
|
|
|
|
const data = { nodes: nodes, edges: edges };
|
|
|
|
const options = {
|
|
nodes: {
|
|
shape: 'dot',
|
|
font: {
|
|
size: 13,
|
|
color: '#ffffff',
|
|
strokeWidth: 2,
|
|
strokeColor: '#000000',
|
|
face: 'Arial',
|
|
align: 'center'
|
|
},
|
|
labelHighlightBold: true,
|
|
// Reduce label overlap by adjusting label distance
|
|
margin: 8,
|
|
// Global fallback so hover/highlight are never default gray (#848484)
|
|
color: {
|
|
highlight: { border: '#d97706', background: '#f59e0b' },
|
|
hover: { border: '#d97706', background: '#f59e0b' }
|
|
},
|
|
chosen: {
|
|
node: function(values, id, selected, hovering) {
|
|
if (hovering || selected) {
|
|
values.color = {
|
|
background: '#f59e0b',
|
|
border: '#d97706',
|
|
highlight: { background: '#f59e0b', border: '#d97706' },
|
|
hover: { background: '#f59e0b', border: '#d97706' }
|
|
};
|
|
values.font = { size: 15, color: '#fef3c7', strokeWidth: 3, strokeColor: '#92400e' };
|
|
values.size = values.size * 1.2; // Slightly enlarge on hover/select
|
|
}
|
|
}
|
|
}
|
|
},
|
|
edges: {
|
|
smooth: {
|
|
type: 'continuous',
|
|
roundness: 0.5
|
|
}
|
|
// No global highlight/hover so our direction colors (blue/green/purple) show when a node is selected
|
|
},
|
|
physics: {
|
|
enabled: true,
|
|
solver: 'forceAtlas2Based',
|
|
stabilization: {
|
|
iterations: 600,
|
|
fit: true
|
|
},
|
|
forceAtlas2Based: {
|
|
gravitationalConstant: -1200,
|
|
centralGravity: 0.005,
|
|
springLength: 220,
|
|
springConstant: 0.08,
|
|
damping: 0.4,
|
|
avoidOverlap: 1.0
|
|
}
|
|
},
|
|
interaction: {
|
|
hover: true,
|
|
tooltipDelay: 200,
|
|
zoomView: true,
|
|
dragView: true,
|
|
dragNodes: false,
|
|
selectConnectedEdges: false
|
|
},
|
|
layout: {
|
|
improvedLayout: false, // Disabled due to positioning issues with large graphs
|
|
hierarchical: {
|
|
enabled: false
|
|
}
|
|
},
|
|
configure: {
|
|
enabled: false
|
|
}
|
|
};
|
|
|
|
// Instead of destroying and recreating, update the existing network to prevent flashing
|
|
if (graphNetwork) {
|
|
// Update data and re-enable physics so the new graph runs stabilization again
|
|
graphNetwork.setData(data);
|
|
const updateOptions = {
|
|
nodes: options.nodes,
|
|
edges: options.edges,
|
|
physics: options.physics,
|
|
interaction: options.interaction,
|
|
layout: options.layout
|
|
};
|
|
graphNetwork.setOptions(updateOptions);
|
|
} else {
|
|
// Only create new network if it doesn't exist
|
|
graphNetwork = new vis.Network(container, data, options);
|
|
|
|
// Freeze layout after stabilization so the graph settles instead of drifting
|
|
graphNetwork.on('stabilizationIterationsDone', () => {
|
|
graphNetwork.setOptions({ physics: false });
|
|
});
|
|
|
|
// Apply direction colors to edges connected to a node (for hover and click)
|
|
function applyEdgeHighlightForNodeId(focusNodeId) {
|
|
if (!graphNetwork || !focusNodeId) return;
|
|
const edgeIdsToHighlight = [];
|
|
const edgeStyles = {};
|
|
const bidirectionalEdgeIds = new Set();
|
|
filteredEdges.forEach(edge => {
|
|
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
|
|
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
|
|
if (!fromNode || !toNode) return;
|
|
const fromNodeId = getNodeId(fromNode);
|
|
const toNodeId = getNodeId(toNode);
|
|
const edgeId = `${fromNodeId}-${toNodeId}`;
|
|
if (fromNodeId === focusNodeId || toNodeId === focusNodeId) {
|
|
edgeIdsToHighlight.push(edgeId);
|
|
const reverseEdgeId = `${toNodeId}-${fromNodeId}`;
|
|
if (filteredEdges.some(e => {
|
|
let revFromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
|
|
let revToNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
|
|
if (!revFromNode || !revToNode) return false;
|
|
return getNodeId(revFromNode) === toNodeId && getNodeId(revToNode) === fromNodeId;
|
|
})) {
|
|
bidirectionalEdgeIds.add(edgeId);
|
|
bidirectionalEdgeIds.add(reverseEdgeId);
|
|
}
|
|
let highlightColor;
|
|
if (bidirectionalEdgeIds.has(edgeId)) {
|
|
highlightColor = '#9333ea';
|
|
} else if (fromNodeId === focusNodeId) {
|
|
highlightColor = '#3b82f6';
|
|
} else {
|
|
highlightColor = '#10b981';
|
|
}
|
|
const baseWidth = getEdgeWeight(edge);
|
|
edgeStyles[edgeId] = {
|
|
color: {
|
|
color: highlightColor,
|
|
opacity: 1.0,
|
|
highlight: highlightColor,
|
|
hover: highlightColor
|
|
},
|
|
width: Math.max(3, baseWidth + 2)
|
|
};
|
|
}
|
|
});
|
|
const currentEdges = graphNetwork.body.data.edges.get();
|
|
const edgeUpdates = currentEdges.map(edge => {
|
|
const edgeId = edge.id;
|
|
if (edgeIdsToHighlight.includes(edgeId)) {
|
|
return { id: edgeId, ...edgeStyles[edgeId] };
|
|
}
|
|
return {
|
|
id: edgeId,
|
|
color: { color: '#848484', opacity: 0.2 },
|
|
width: 1
|
|
};
|
|
});
|
|
graphNetwork.body.data.edges.update(edgeUpdates);
|
|
}
|
|
|
|
// Restore edges after hover ends: re-apply selected node state or default edge colors
|
|
function restoreGraphEdgesAfterHover() {
|
|
if (!graphNetwork || currentView !== 'graph') return;
|
|
if (highlightedNodeObject) {
|
|
highlightNodeConnectionsGraph(highlightedNodeObject);
|
|
} else {
|
|
const currentEdges = graphNetwork.body.data.edges.get();
|
|
const edgeUpdates = [];
|
|
for (const edge of currentEdges) {
|
|
const edgeId = edge.id;
|
|
const originalEdge = filteredEdges.find(e => {
|
|
let fromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
|
|
let toNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
|
|
if (!fromNode || !toNode) return false;
|
|
return `${getNodeId(fromNode)}-${getNodeId(toNode)}` === edgeId;
|
|
});
|
|
if (originalEdge) {
|
|
edgeUpdates.push({
|
|
id: edgeId,
|
|
color: { color: getEdgeColor(originalEdge), opacity: getEdgeOpacity(originalEdge) },
|
|
width: getEdgeWeight(originalEdge)
|
|
});
|
|
}
|
|
}
|
|
if (edgeUpdates.length) graphNetwork.body.data.edges.update(edgeUpdates);
|
|
}
|
|
}
|
|
|
|
// Event handlers - only set up once when network is first created
|
|
graphNetwork.on('hoverNode', (params) => {
|
|
if (params.node) applyEdgeHighlightForNodeId(params.node);
|
|
});
|
|
graphNetwork.on('blurNode', () => {
|
|
restoreGraphEdgesAfterHover();
|
|
});
|
|
graphNetwork.on('click', (params) => {
|
|
if (params.nodes.length > 0) {
|
|
const nodeId = params.nodes[0];
|
|
const node = filteredNodes.find(n => getNodeId(n) === nodeId);
|
|
if (node) {
|
|
// Click on the currently highlighted node = clear (fixes two-click-to-clear)
|
|
if (highlightedNodeObject && getNodeId(highlightedNodeObject) === nodeId) {
|
|
clearHighlights();
|
|
return;
|
|
}
|
|
highlightNodeConnectionsGraph(node);
|
|
showNodeDetails(node);
|
|
}
|
|
} else if (params.edges.length > 0) {
|
|
const edgeId = params.edges[0]; // This is now fromNodeId-toNodeId format
|
|
// Find edge by matching the unique node IDs
|
|
const edge = filteredEdges.find(e => {
|
|
let fromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
|
|
let toNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
|
|
if (!fromNode || !toNode) return false;
|
|
const expectedEdgeId = `${getNodeId(fromNode)}-${getNodeId(toNode)}`;
|
|
return expectedEdgeId === edgeId;
|
|
});
|
|
// Edge details now shown via popup tooltip, not modal
|
|
} else {
|
|
// Clicked on empty space - clear highlights
|
|
clearHighlights();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Highlight node connections on map - new version that takes the actual node object
|
|
function highlightNodeConnectionsForNode(clickedNode) {
|
|
// Clear previous highlights
|
|
clearHighlights();
|
|
|
|
if (!clickedNode) return;
|
|
highlightedNode = clickedNode.prefix; // Store prefix for backward compatibility
|
|
highlightedNodeObject = clickedNode; // Store the actual node object
|
|
|
|
// Show legend
|
|
const legend = document.getElementById('connection-legend');
|
|
if (legend) {
|
|
legend.classList.add('active');
|
|
}
|
|
|
|
// Store original styles for all edges (for dimming non-highlighted ones)
|
|
allEdgeStyles.clear();
|
|
edgeLines.forEach((line, edgeKey) => {
|
|
allEdgeStyles.set(edgeKey, {
|
|
color: line.options.color,
|
|
weight: line.options.weight,
|
|
opacity: line.options.opacity
|
|
});
|
|
});
|
|
|
|
// Store original styles for all nodes (for shrinking non-highlighted ones)
|
|
allNodeStyles.clear();
|
|
filteredNodes.forEach(node => {
|
|
const nodeId = getNodeId(node);
|
|
const marker = nodeMarkers.get(nodeId);
|
|
if (marker) {
|
|
allNodeStyles.set(nodeId, {
|
|
radius: marker.options.radius,
|
|
weight: marker.options.weight,
|
|
color: marker.options.color,
|
|
fillOpacity: marker.options.fillOpacity
|
|
});
|
|
}
|
|
});
|
|
|
|
// Create a unique identifier for the clicked node using prefix + location
|
|
const clickedNodeId = getNodeId(clickedNode);
|
|
|
|
// Find all edges connected to this specific node by checking actual node objects
|
|
// Use the edgeToNodes map to match edges to the actual clicked node, not just prefix
|
|
const highlightedEdgeKeys = new Set();
|
|
edgeToNodes.forEach((nodes, edgeKey) => {
|
|
// Check if this edge connects to the clicked node by comparing node identifiers
|
|
if (nodes.fromNode && nodes.toNode && clickedNode) {
|
|
// Create identifiers for the edge's nodes
|
|
const fromNodeId = getNodeId(nodes.fromNode);
|
|
const toNodeId = getNodeId(nodes.toNode);
|
|
|
|
// Match if either end of the edge matches the clicked node
|
|
if (fromNodeId === clickedNodeId || toNodeId === clickedNodeId) {
|
|
highlightedEdgeKeys.add(edgeKey);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Also get the edge objects for direction determination
|
|
const connectedEdges = [];
|
|
highlightedEdgeKeys.forEach(edgeKey => {
|
|
const edge = filteredEdges.find(e =>
|
|
`${e.from_prefix}-${e.to_prefix}` === edgeKey
|
|
);
|
|
if (edge) {
|
|
connectedEdges.push(edge);
|
|
}
|
|
});
|
|
|
|
// Check for bidirectional edges
|
|
const bidirectionalEdges = new Set();
|
|
connectedEdges.forEach(edge => {
|
|
const reverseKey = `${edge.to_prefix}-${edge.from_prefix}`;
|
|
if (filteredEdges.some(e =>
|
|
e.from_prefix === edge.to_prefix && e.to_prefix === edge.from_prefix
|
|
)) {
|
|
bidirectionalEdges.add(`${edge.from_prefix}-${edge.to_prefix}`);
|
|
bidirectionalEdges.add(reverseKey);
|
|
}
|
|
});
|
|
|
|
// Highlight connected edges and dim all others
|
|
edgeLines.forEach((line, edgeKey) => {
|
|
if (highlightedEdgeKeys.has(edgeKey)) {
|
|
// This is a connected edge - highlight it
|
|
const edge = filteredEdges.find(e =>
|
|
`${e.from_prefix}-${e.to_prefix}` === edgeKey
|
|
);
|
|
if (!edge) return;
|
|
|
|
// Determine edge direction and color by checking actual node objects
|
|
const edgeNodes = edgeToNodes.get(edgeKey);
|
|
let highlightColor;
|
|
if (bidirectionalEdges.has(edgeKey) || bidirectionalEdges.has(`${edge.to_prefix}-${edge.from_prefix}`)) {
|
|
// Bidirectional - purple
|
|
highlightColor = '#9333ea';
|
|
} else if (edgeNodes && edgeNodes.fromNode) {
|
|
const fromNodeId = getNodeId(edgeNodes.fromNode);
|
|
if (fromNodeId === clickedNodeId) {
|
|
// Outgoing - blue (from node matches clicked node)
|
|
highlightColor = '#3b82f6';
|
|
} else {
|
|
// Incoming - green (to node matches clicked node)
|
|
highlightColor = '#10b981';
|
|
}
|
|
} else {
|
|
// Fallback to prefix matching if node objects not available
|
|
highlightColor = edge.from_prefix === clickedNode.prefix ? '#3b82f6' : '#10b981';
|
|
}
|
|
|
|
// Store original style
|
|
const originalStyle = allEdgeStyles.get(edgeKey);
|
|
if (originalStyle) {
|
|
highlightedEdges.set(edgeKey, originalStyle);
|
|
}
|
|
|
|
// Apply highlight
|
|
line.setStyle({
|
|
color: highlightColor,
|
|
weight: Math.max(originalStyle?.weight || 2, 4), // Make highlighted edges thicker
|
|
opacity: 1.0
|
|
});
|
|
} else {
|
|
// This is not a connected edge - dim it
|
|
const originalStyle = allEdgeStyles.get(edgeKey);
|
|
if (originalStyle) {
|
|
line.setStyle({
|
|
color: '#848484', // Gray color for dimmed edges
|
|
weight: Math.max(1, originalStyle.weight * 0.5), // Thinner
|
|
opacity: 0.2 // Much lower opacity
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Build a set of connected node IDs (clicked node + all neighbors)
|
|
const connectedNodeIds = new Set([clickedNodeId]);
|
|
edgeToNodes.forEach((nodes, edgeKey) => {
|
|
if (highlightedEdgeKeys.has(edgeKey)) {
|
|
// This edge is connected to the clicked node, so both endpoints are connected
|
|
if (nodes.fromNode) {
|
|
connectedNodeIds.add(getNodeId(nodes.fromNode));
|
|
}
|
|
if (nodes.toNode) {
|
|
connectedNodeIds.add(getNodeId(nodes.toNode));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Highlight the clicked node, keep connected nodes at regular size, shrink all others
|
|
filteredNodes.forEach(node => {
|
|
const nodeId = getNodeId(node);
|
|
const marker = nodeMarkers.get(nodeId);
|
|
if (!marker) return;
|
|
|
|
if (nodeId === clickedNodeId) {
|
|
// Highlight the clicked node - make it larger
|
|
const highlightSize = getMapNodeSize(node) + 3;
|
|
const highlightBorderWeight = getBorderWeight(highlightSize, node.is_starred);
|
|
marker.setStyle({
|
|
radius: highlightSize,
|
|
weight: highlightBorderWeight,
|
|
color: '#ffc107',
|
|
fillOpacity: 1.0
|
|
});
|
|
} else if (connectedNodeIds.has(nodeId)) {
|
|
// Keep connected nodes at regular size
|
|
const originalStyle = allNodeStyles.get(nodeId);
|
|
if (originalStyle) {
|
|
marker.setStyle(originalStyle);
|
|
} else {
|
|
// Fallback: restore based on node properties
|
|
const nodeSize = getMapNodeSize(node);
|
|
const borderWeight = getBorderWeight(nodeSize, node.is_starred);
|
|
marker.setStyle({
|
|
radius: nodeSize,
|
|
weight: borderWeight,
|
|
color: node.is_starred ? '#ffc107' : '#fff',
|
|
fillOpacity: 0.8
|
|
});
|
|
}
|
|
} else {
|
|
// Shrink all other nodes significantly for visibility
|
|
const originalStyle = allNodeStyles.get(nodeId);
|
|
if (originalStyle) {
|
|
const shrunkSize = Math.max(2, originalStyle.radius * 0.3); // Shrink to 30% of original size, minimum 2px
|
|
const shrunkWeight = Math.max(0.5, originalStyle.weight * 0.3);
|
|
marker.setStyle({
|
|
radius: shrunkSize,
|
|
weight: shrunkWeight,
|
|
color: originalStyle.color,
|
|
fillOpacity: 0.4 // Reduce opacity for dimmed nodes
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Backward compatibility wrapper - converts prefix to node object
|
|
function highlightNodeConnections(nodePrefix) {
|
|
// Resolve from filtered set so we only highlight nodes that are currently visible
|
|
const node = filteredNodeMap[nodePrefix];
|
|
if (node) {
|
|
highlightNodeConnectionsForNode(node);
|
|
}
|
|
}
|
|
|
|
// Highlight node connections in graph view.
|
|
// nodeOrIdOrPrefix: node object (preferred), unique node id string (getNodeId), or prefix string (fallback).
|
|
function highlightNodeConnectionsGraph(nodeOrIdOrPrefix) {
|
|
if (!graphNetwork || nodeOrIdOrPrefix == null) return;
|
|
|
|
let clickedNode = null;
|
|
if (typeof nodeOrIdOrPrefix === 'object' && nodeOrIdOrPrefix !== null && 'prefix' in nodeOrIdOrPrefix && 'latitude' in nodeOrIdOrPrefix) {
|
|
clickedNode = nodeOrIdOrPrefix;
|
|
} else if (typeof nodeOrIdOrPrefix === 'string') {
|
|
const byId = filteredNodes.find(n => getNodeId(n) === nodeOrIdOrPrefix);
|
|
if (byId) {
|
|
clickedNode = byId;
|
|
} else {
|
|
const nodesWithPrefix = filteredNodes.filter(n => n.prefix === nodeOrIdOrPrefix);
|
|
if (nodesWithPrefix.length > 0) clickedNode = nodesWithPrefix[0];
|
|
}
|
|
}
|
|
if (!clickedNode) return;
|
|
|
|
const clickedNodeId = getNodeId(clickedNode);
|
|
|
|
// Don't call clearHighlights() so switching to another node works (direct state transition)
|
|
highlightedNode = clickedNode.prefix;
|
|
highlightedNodeObject = clickedNode;
|
|
|
|
// Show legend
|
|
const legend = document.getElementById('connection-legend');
|
|
if (legend) {
|
|
legend.classList.add('active');
|
|
}
|
|
|
|
// Find all edges connected to this specific node (using unique IDs)
|
|
const edgeIdsToHighlight = [];
|
|
const edgeStyles = {};
|
|
const bidirectionalEdgeIds = new Set();
|
|
const connectedNodeIds = new Set([clickedNodeId]);
|
|
|
|
filteredEdges.forEach(edge => {
|
|
let fromNode = (edge.from_public_key && filteredNodeMapByKey[edge.from_public_key]) ? filteredNodeMapByKey[edge.from_public_key] : (filteredNodeMap[edge.from_prefix] || findNodeByEdgePrefix(edge.from_prefix));
|
|
let toNode = (edge.to_public_key && filteredNodeMapByKey[edge.to_public_key]) ? filteredNodeMapByKey[edge.to_public_key] : (filteredNodeMap[edge.to_prefix] || findNodeByEdgePrefix(edge.to_prefix));
|
|
if (!fromNode || !toNode) return;
|
|
|
|
const fromNodeId = getNodeId(fromNode);
|
|
const toNodeId = getNodeId(toNode);
|
|
const edgeId = `${fromNodeId}-${toNodeId}`;
|
|
|
|
// Check if this edge connects to the clicked node
|
|
if (fromNodeId === clickedNodeId || toNodeId === clickedNodeId) {
|
|
edgeIdsToHighlight.push(edgeId);
|
|
connectedNodeIds.add(fromNodeId);
|
|
connectedNodeIds.add(toNodeId);
|
|
|
|
// Check for bidirectional
|
|
const reverseEdgeId = `${toNodeId}-${fromNodeId}`;
|
|
if (filteredEdges.some(e => {
|
|
let revFromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
|
|
let revToNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
|
|
if (!revFromNode || !revToNode) return false;
|
|
return getNodeId(revFromNode) === toNodeId && getNodeId(revToNode) === fromNodeId;
|
|
})) {
|
|
bidirectionalEdgeIds.add(edgeId);
|
|
bidirectionalEdgeIds.add(reverseEdgeId);
|
|
}
|
|
|
|
// Use connection direction colors: blue outgoing, green incoming, purple bidirectional
|
|
let highlightColor;
|
|
if (bidirectionalEdgeIds.has(edgeId)) {
|
|
highlightColor = '#9333ea'; // Purple for bidirectional
|
|
} else if (fromNodeId === clickedNodeId) {
|
|
highlightColor = '#3b82f6'; // Blue for outgoing
|
|
} else {
|
|
highlightColor = '#10b981'; // Green for incoming
|
|
}
|
|
const baseWidth = getEdgeWeight(edge);
|
|
edgeStyles[edgeId] = {
|
|
color: {
|
|
color: highlightColor,
|
|
opacity: 1.0,
|
|
highlight: highlightColor,
|
|
hover: highlightColor
|
|
},
|
|
width: Math.max(3, baseWidth + 2)
|
|
};
|
|
}
|
|
});
|
|
|
|
// Batch all edge updates in one call to avoid per-item redraws (was ~18s for large graphs)
|
|
const currentEdges = graphNetwork.body.data.edges.get();
|
|
const edgeUpdates = currentEdges.map(edge => {
|
|
const edgeId = edge.id;
|
|
if (edgeIdsToHighlight.includes(edgeId)) {
|
|
return { id: edgeId, ...edgeStyles[edgeId] };
|
|
}
|
|
return {
|
|
id: edgeId,
|
|
color: { color: '#848484', opacity: 0.2 },
|
|
width: 1
|
|
};
|
|
});
|
|
graphNetwork.body.data.edges.update(edgeUpdates);
|
|
|
|
graphNetwork.selectNodes([clickedNodeId]);
|
|
|
|
// Update all nodes in one batch: one update per graph node so labels aren't overwritten by duplicates
|
|
const nodeHighlightColor = clickedNode.role === 'roomserver' ? '#10b981' : '#f59e0b';
|
|
const graphNodeIds = graphNetwork.body.data.nodes.getIds();
|
|
const nodeUpdates = graphNodeIds.map(nid => {
|
|
const n = filteredNodes.find(fn => getNodeId(fn) === nid);
|
|
if (!n) return { id: nid, label: '', title: '' };
|
|
const isClicked = nid === clickedNodeId;
|
|
const isLinked = connectedNodeIds.has(nid);
|
|
const bg = getNodeFillColor(n);
|
|
const border = n.is_starred ? '#ffc107' : '#fff';
|
|
const hlBg = n.role === 'roomserver' ? '#10b981' : '#f59e0b';
|
|
const label = isLinked
|
|
? (n.name.length > 22 ? n.name.substring(0, 22) + '... (' + n.prefix + ')' : n.name + ' (' + n.prefix + ')')
|
|
: '';
|
|
const title = escapeHtml(n.name) + ' (' + escapeHtml(n.prefix) + ')';
|
|
const update = {
|
|
id: nid,
|
|
label: label,
|
|
title: title,
|
|
color: isClicked
|
|
? { border: '#d97706', background: nodeHighlightColor, highlight: { border: '#d97706', background: nodeHighlightColor }, hover: { border: '#d97706', background: nodeHighlightColor } }
|
|
: { background: bg, border: border, highlight: { background: hlBg, border: '#d97706' }, hover: { background: hlBg, border: '#d97706' } },
|
|
borderWidth: isClicked ? 4 : getBorderWeight(getNodeSize(n), n.is_starred),
|
|
size: isClicked ? getNodeSize(clickedNode) + 2 : getNodeSize(n)
|
|
};
|
|
// Force label to render for linked nodes (vis-network can skip if font not set on update)
|
|
if (isLinked) {
|
|
update.font = { size: 13, color: '#ffffff', strokeWidth: 2, strokeColor: '#000000' };
|
|
}
|
|
return update;
|
|
});
|
|
graphNetwork.body.data.nodes.update(nodeUpdates);
|
|
}
|
|
|
|
// Clear all highlights
|
|
function clearHighlights() {
|
|
// Hide legend
|
|
const legend = document.getElementById('connection-legend');
|
|
if (legend) {
|
|
legend.classList.remove('active');
|
|
}
|
|
|
|
// Clear path highlights if active
|
|
if (highlightedPath) {
|
|
clearPathHighlight();
|
|
}
|
|
|
|
// Clear map view highlights - restore all node styles
|
|
filteredNodes.forEach(node => {
|
|
const nodeId = getNodeId(node);
|
|
const marker = nodeMarkers.get(nodeId);
|
|
if (marker) {
|
|
const originalStyle = allNodeStyles.get(nodeId);
|
|
if (originalStyle) {
|
|
// Restore original size and style
|
|
marker.setStyle(originalStyle);
|
|
} else {
|
|
// Fallback: restore based on node properties
|
|
const nodeSize = getMapNodeSize(node);
|
|
const borderWeight = getBorderWeight(nodeSize, node.is_starred);
|
|
marker.setStyle({
|
|
radius: nodeSize,
|
|
weight: borderWeight,
|
|
color: node.is_starred ? '#ffc107' : '#fff',
|
|
fillOpacity: 0.8
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Restore original edge styles for all edges
|
|
allEdgeStyles.forEach((originalStyle, edgeKey) => {
|
|
const line = edgeLines.get(edgeKey);
|
|
if (line) {
|
|
line.setStyle(originalStyle);
|
|
}
|
|
});
|
|
|
|
highlightedEdges.clear();
|
|
allEdgeStyles.clear();
|
|
allNodeStyles.clear();
|
|
highlightedNode = null;
|
|
highlightedNodeObject = null;
|
|
|
|
// Clear graph view highlights
|
|
if (graphNetwork && currentView === 'graph') {
|
|
graphNetwork.unselectAll();
|
|
|
|
// Batch edge and node restores in single update() calls to avoid per-item redraws
|
|
const currentEdges = graphNetwork.body.data.edges.get();
|
|
const edgeUpdates = [];
|
|
for (const edge of currentEdges) {
|
|
const edgeId = edge.id;
|
|
const originalEdge = filteredEdges.find(e => {
|
|
let fromNode = (e.from_public_key && filteredNodeMapByKey[e.from_public_key]) ? filteredNodeMapByKey[e.from_public_key] : (filteredNodeMap[e.from_prefix] || findNodeByEdgePrefix(e.from_prefix));
|
|
let toNode = (e.to_public_key && filteredNodeMapByKey[e.to_public_key]) ? filteredNodeMapByKey[e.to_public_key] : (filteredNodeMap[e.to_prefix] || findNodeByEdgePrefix(e.to_prefix));
|
|
if (!fromNode || !toNode) return false;
|
|
const expectedEdgeId = `${getNodeId(fromNode)}-${getNodeId(toNode)}`;
|
|
return expectedEdgeId === edgeId;
|
|
});
|
|
if (originalEdge) {
|
|
edgeUpdates.push({
|
|
id: edgeId,
|
|
color: { color: getEdgeColor(originalEdge), opacity: getEdgeOpacity(originalEdge) },
|
|
width: getEdgeWeight(originalEdge)
|
|
});
|
|
}
|
|
}
|
|
if (edgeUpdates.length) graphNetwork.body.data.edges.update(edgeUpdates);
|
|
|
|
const graphNodeIds = graphNetwork.body.data.nodes.getIds();
|
|
const nodeUpdates = graphNodeIds.map(nodeId => {
|
|
const node = filteredNodes.find(fn => getNodeId(fn) === nodeId);
|
|
if (!node) return { id: nodeId, label: '', font: { size: 12 } };
|
|
const nodeSize = getNodeSize(node);
|
|
const borderWidth = getBorderWeight(nodeSize, node.is_starred);
|
|
const bg = getNodeFillColor(node);
|
|
const border = node.is_starred ? '#ffc107' : '#fff';
|
|
const hlBg = node.role === 'roomserver' ? '#10b981' : '#f59e0b';
|
|
return {
|
|
id: nodeId,
|
|
label: getDefaultNodeLabel(node),
|
|
title: escapeHtml(node.name) + ' (' + escapeHtml(node.prefix) + ')',
|
|
font: { size: 12 },
|
|
color: {
|
|
background: bg,
|
|
border: border,
|
|
highlight: { background: hlBg, border: '#d97706' },
|
|
hover: { background: hlBg, border: '#d97706' }
|
|
},
|
|
borderWidth: borderWidth,
|
|
size: nodeSize
|
|
};
|
|
});
|
|
graphNetwork.body.data.nodes.update(nodeUpdates);
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
function getNodeSize(node) {
|
|
// Degree comes from the per-render aggregates (computeNodeStats), which
|
|
// resolve endpoints the same way edges are drawn. Base 5, cap 13: large
|
|
// enough to click, small enough that dense clusters stay readable.
|
|
const degree = nodeDegrees.get(getNodeId(node)) || 0;
|
|
return Math.max(5, Math.min(13, 5 + degree * 0.4));
|
|
}
|
|
|
|
// Map dots and lines shrink as you zoom out so dense areas stay readable;
|
|
// full size from zoom 12 in, floor of 55% at zoom 7 and out.
|
|
function mapZoomScale() {
|
|
if (!map) return 1;
|
|
const z = map.getZoom();
|
|
if (z >= 12) return 1;
|
|
if (z <= 7) return 0.55;
|
|
return 0.55 + (z - 7) * 0.09;
|
|
}
|
|
|
|
function getMapNodeSize(node) {
|
|
return Math.max(3, getNodeSize(node) * mapZoomScale());
|
|
}
|
|
|
|
// Shared edge width: log-scaled, capped so heavy edges don't blanket dense areas
|
|
function getEdgeWeight(edge) {
|
|
const weight = Math.max(1, Math.min(5, Math.log10(Math.max(1, edge.observation_count || 1)) * 1.5));
|
|
// Confirmed zero-hop links are the strongest evidence class, but they
|
|
// accumulate observations slowly (one per cycle, cycles are >=12h apart).
|
|
// Without a floor an inferred edge seen thousands of times would always
|
|
// outweigh a directly measured one, which inverts the confidence order.
|
|
return isNeighborEdge(edge) ? Math.max(weight, 3) : weight;
|
|
}
|
|
|
|
function getMapEdgeWeight(edge) {
|
|
return Math.max(0.75, getEdgeWeight(edge) * mapZoomScale());
|
|
}
|
|
|
|
// Re-apply zoom-scaled sizes to existing map layers (called on zoomend, before
|
|
// any active highlight is re-applied on top). Keeps the saved original styles
|
|
// in sync so highlight restores don't resurrect stale sizes.
|
|
function updateMapSizesForZoom() {
|
|
if (!map || currentView !== 'map') return;
|
|
filteredNodes.forEach(node => {
|
|
const nodeId = getNodeId(node);
|
|
const marker = nodeMarkers.get(nodeId);
|
|
if (!marker) return;
|
|
const size = getMapNodeSize(node);
|
|
const weight = getBorderWeight(size, node.is_starred);
|
|
marker.setStyle({ radius: size, weight: weight });
|
|
const saved = allNodeStyles.get(nodeId);
|
|
if (saved) {
|
|
saved.radius = size;
|
|
saved.weight = weight;
|
|
}
|
|
});
|
|
edgeToNodes.forEach((entry, edgeKey) => {
|
|
if (!entry.edge) return;
|
|
const line = edgeLines.get(edgeKey);
|
|
if (!line) return;
|
|
const weight = getMapEdgeWeight(entry.edge);
|
|
line.setStyle({ weight: weight });
|
|
const saved = allEdgeStyles.get(edgeKey);
|
|
if (saved) {
|
|
saved.weight = weight;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper function to calculate border weight based on node size
|
|
function getBorderWeight(nodeSize, isStarred) {
|
|
// Scale border weight proportionally with node size
|
|
// For small nodes (4px), use ~0.8px border; for large nodes (12px), use ~2.4px border
|
|
const baseWeight = Math.max(0.8, nodeSize * 0.2);
|
|
return isStarred ? baseWeight * 1.5 : baseWeight;
|
|
}
|
|
|
|
// Default label for a node (matches renderGraph shortLabel logic) for restore on clear
|
|
function getDefaultNodeLabel(node) {
|
|
if (node.name.length > 25) {
|
|
return node.prefix.toUpperCase() + ': ' + (node.name.length > 15 ? node.name.substring(0, 15) + '...' : node.name);
|
|
}
|
|
if (node.name.length > 20) {
|
|
return node.name.substring(0, 20) + '...';
|
|
}
|
|
return node.name;
|
|
}
|
|
|
|
function getEdgeColor(edge) {
|
|
if (edgeColorMode === 'heat') return heatColor(edge);
|
|
|
|
if (!edge.last_seen) return '#6c757d'; // Gray for unknown
|
|
|
|
const lastSeen = new Date(edge.last_seen);
|
|
const now = new Date();
|
|
const daysAgo = (now - lastSeen) / (1000 * 60 * 60 * 24);
|
|
|
|
if (daysAgo < 1) return '#198754'; // Green (recent)
|
|
if (daysAgo < 7) return '#ffc107'; // Yellow
|
|
if (daysAgo < 30) return '#fd7e14'; // Orange
|
|
return '#dc3545'; // Red (old)
|
|
}
|
|
|
|
function getEdgeOpacity(edge) {
|
|
// Heat mode: hot edges pop, cold edges recede (recency shown by color mode 'age')
|
|
if (edgeColorMode === 'heat') return 0.45 + 0.5 * heatT(edge);
|
|
|
|
if (!edge.last_seen) return 0.3;
|
|
|
|
const lastSeen = new Date(edge.last_seen);
|
|
const now = new Date();
|
|
const daysAgo = (now - lastSeen) / (1000 * 60 * 60 * 24);
|
|
|
|
if (daysAgo < 1) return 0.9;
|
|
if (daysAgo < 7) return 0.7;
|
|
if (daysAgo < 30) return 0.5;
|
|
return 0.3;
|
|
}
|
|
|
|
function showNodeDetails(node) {
|
|
// Prefix-compatible matching across resolutions (see edgeTouchesNode)
|
|
const connections = filteredEdges.filter(e => edgeTouchesNode(e, node));
|
|
const incoming = connections.filter(e => prefixesCompatible(e.to_prefix, node.prefix)).length;
|
|
const outgoing = connections.filter(e => prefixesCompatible(e.from_prefix, node.prefix)).length;
|
|
|
|
document.getElementById('nodeDetailsTitle').textContent = node.name;
|
|
const table = document.createElement('table');
|
|
table.className = 'table';
|
|
const addRow = (label, value, code = false) => {
|
|
const row = document.createElement('tr');
|
|
const heading = document.createElement('th');
|
|
heading.textContent = label;
|
|
const cell = document.createElement('td');
|
|
const valueElement = document.createElement(code ? 'code' : 'span');
|
|
valueElement.textContent = String(value ?? '');
|
|
cell.appendChild(valueElement);
|
|
row.append(heading, cell);
|
|
table.appendChild(row);
|
|
};
|
|
addRow('Prefix', node.prefix);
|
|
addRow('Public Key', formatPublicKeyText(node.public_key), true);
|
|
addRow('Role', node.role);
|
|
addRow('Location', `${Number(node.latitude).toFixed(6)}, ${Number(node.longitude).toFixed(6)}`);
|
|
addRow('Starred', node.is_starred ? 'Yes' : 'No');
|
|
addRow('Last Heard', node.last_heard || 'Never');
|
|
addRow('Last Advert', node.last_advert_timestamp || 'Never');
|
|
addRow('Connections', `${connections.length} total (${incoming} incoming, ${outgoing} outgoing)`);
|
|
document.getElementById('nodeDetailsBody').replaceChildren(table);
|
|
|
|
// Reuse modal instance or create new one
|
|
if (!nodeModal) {
|
|
const modalElement = document.getElementById('nodeDetailsModal');
|
|
nodeModal = new bootstrap.Modal(modalElement, {
|
|
backdrop: true,
|
|
keyboard: true,
|
|
focus: true
|
|
});
|
|
}
|
|
nodeModal.show();
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatPublicKey(publicKey) {
|
|
return escapeHtml(formatPublicKeyText(publicKey));
|
|
}
|
|
|
|
function formatPublicKeyText(publicKey) {
|
|
if (!publicKey || publicKey.length < 32) {
|
|
return String(publicKey || '');
|
|
}
|
|
// First 8 bytes = 16 hex characters, last 8 bytes = 16 hex characters
|
|
const first8Bytes = publicKey.substring(0, 16);
|
|
const last8Bytes = publicKey.substring(publicKey.length - 16);
|
|
return `${first8Bytes}...${last8Bytes}`;
|
|
}
|
|
|
|
async function refreshData() {
|
|
const absorbedPending = meshLiveRefreshPending
|
|
|| meshLiveRefreshTimer !== null;
|
|
const absorbedNeedsNodes = meshLiveRefreshNeedsNodes;
|
|
if (meshLiveRefreshTimer) {
|
|
clearTimeout(meshLiveRefreshTimer);
|
|
meshLiveRefreshTimer = null;
|
|
}
|
|
meshLiveRefreshPending = false;
|
|
meshLiveRefreshNeedsNodes = false;
|
|
meshLiveRefreshRetryCount = 0;
|
|
const activeScheduledRefresh = meshLiveRefreshRunning;
|
|
const activeReloadsNodes = meshLiveRefreshActiveReloadsNodes;
|
|
try {
|
|
if (activeScheduledRefresh && meshLoadInFlight) {
|
|
await Promise.all([loadStats(), meshLoadInFlight]);
|
|
if (activeReloadsNodes) {
|
|
applyFilters();
|
|
} else {
|
|
// The active forced edge refresh is sufficient; fetch only
|
|
// the nodes it omitted and render from the combined state.
|
|
await loadData({
|
|
reloadEdges: false,
|
|
reloadNodes: true
|
|
});
|
|
}
|
|
} else {
|
|
await Promise.all([
|
|
loadStats(),
|
|
loadData({ forceRefresh: true })
|
|
]);
|
|
}
|
|
} catch (error) {
|
|
// A manual refresh may have absorbed a pending live update. Restore
|
|
// it and let the bounded scheduled-retry policy recover.
|
|
scheduleMeshLiveRefresh(true, MESH_LIVE_REFRESH_RETRY_MS);
|
|
throw error;
|
|
}
|
|
if (absorbedPending && activeScheduledRefresh) {
|
|
// Events arriving during an active database snapshot may not be
|
|
// represented by it, so retain that trailing refresh.
|
|
scheduleMeshLiveRefresh(absorbedNeedsNodes);
|
|
}
|
|
}
|
|
|
|
function scheduleMeshLiveRefresh(
|
|
reloadNodes,
|
|
delayMs = MESH_LIVE_REFRESH_MS
|
|
) {
|
|
meshLiveRefreshPending = true;
|
|
meshLiveRefreshNeedsNodes = meshLiveRefreshNeedsNodes || Boolean(reloadNodes);
|
|
if (document.hidden || meshLiveRefreshTimer || meshLiveRefreshRunning) return;
|
|
|
|
meshLiveRefreshTimer = setTimeout(() => {
|
|
meshLiveRefreshTimer = null;
|
|
runScheduledMeshRefresh();
|
|
}, delayMs);
|
|
}
|
|
|
|
async function runScheduledMeshRefresh() {
|
|
if (
|
|
document.hidden
|
|
|| meshLiveRefreshRunning
|
|
|| !meshLiveRefreshPending
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const reloadNodes = meshLiveRefreshNeedsNodes;
|
|
meshLiveRefreshPending = false;
|
|
meshLiveRefreshNeedsNodes = false;
|
|
meshLiveRefreshRunning = true;
|
|
meshLiveRefreshActiveReloadsNodes = reloadNodes;
|
|
let retryDelayMs = MESH_LIVE_REFRESH_MS;
|
|
try {
|
|
await Promise.all([
|
|
loadStats(),
|
|
loadData({
|
|
reloadNodes,
|
|
skipRender: true,
|
|
forceRefresh: true
|
|
})
|
|
]);
|
|
if (currentView === 'map') {
|
|
applyFilters();
|
|
}
|
|
meshLiveRefreshRetryCount = 0;
|
|
} catch (error) {
|
|
console.error('Error refreshing live mesh data:', error);
|
|
if (meshLiveRefreshRetryCount < MESH_LIVE_REFRESH_MAX_RETRIES) {
|
|
meshLiveRefreshRetryCount++;
|
|
meshLiveRefreshPending = true;
|
|
meshLiveRefreshNeedsNodes =
|
|
meshLiveRefreshNeedsNodes || reloadNodes;
|
|
retryDelayMs = MESH_LIVE_REFRESH_RETRY_MS;
|
|
} else {
|
|
meshLiveRefreshRetryCount = 0;
|
|
}
|
|
} finally {
|
|
meshLiveRefreshRunning = false;
|
|
meshLiveRefreshActiveReloadsNodes = false;
|
|
if (meshLiveRefreshPending) {
|
|
scheduleMeshLiveRefresh(false, retryDelayMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
function exportView() {
|
|
// Simple export - could be enhanced to export as image
|
|
const data = {
|
|
nodes: filteredNodes,
|
|
edges: filteredEdges,
|
|
exported: new Date().toISOString()
|
|
};
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `mesh-graph-${new Date().toISOString().split('T')[0]}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function toggleFullscreen() {
|
|
const mapContainer = document.getElementById('map-view');
|
|
const btn = document.getElementById('btn-fullscreen');
|
|
const icon = btn.querySelector('i');
|
|
|
|
if (!isMapFullscreen) {
|
|
// Enter fullscreen
|
|
isMapFullscreen = true;
|
|
|
|
// Use native fullscreen API where available (not on iOS Safari)
|
|
if (mapContainer.requestFullscreen) {
|
|
mapContainer.requestFullscreen().catch(() => {});
|
|
} else if (mapContainer.webkitRequestFullscreen) {
|
|
mapContainer.webkitRequestFullscreen();
|
|
} else if (mapContainer.msRequestFullscreen) {
|
|
mapContainer.msRequestFullscreen();
|
|
}
|
|
|
|
// CSS-based fullscreen (works on all platforms including mobile)
|
|
mapContainer.classList.add('fullscreen-mode');
|
|
document.body.classList.add('fullscreen-active');
|
|
|
|
icon.className = 'fas fa-compress';
|
|
|
|
// Add exit button overlay
|
|
const exitBtn = document.createElement('button');
|
|
exitBtn.className = 'fullscreen-exit-btn';
|
|
exitBtn.innerHTML = '<i class="fas fa-times"></i> Exit Fullscreen';
|
|
exitBtn.addEventListener('click', toggleFullscreen);
|
|
mapContainer.appendChild(exitBtn);
|
|
|
|
setTimeout(() => { if (map) map.invalidateSize(); }, 100);
|
|
|
|
} else {
|
|
// Exit fullscreen
|
|
isMapFullscreen = false;
|
|
|
|
if (document.exitFullscreen && document.fullscreenElement) {
|
|
document.exitFullscreen().catch(() => {});
|
|
} else if (document.webkitExitFullscreen && document.webkitFullscreenElement) {
|
|
document.webkitExitFullscreen();
|
|
} else if (document.msExitFullscreen && document.msFullscreenElement) {
|
|
document.msExitFullscreen();
|
|
}
|
|
|
|
mapContainer.classList.remove('fullscreen-mode');
|
|
document.body.classList.remove('fullscreen-active');
|
|
|
|
icon.className = 'fas fa-expand';
|
|
|
|
const exitBtn = mapContainer.querySelector('.fullscreen-exit-btn');
|
|
if (exitBtn) exitBtn.remove();
|
|
|
|
setTimeout(() => { if (map) map.invalidateSize(); }, 100);
|
|
}
|
|
}
|
|
|
|
// Sync state when the native fullscreen API exits (e.g. ESC key on desktop)
|
|
function onNativeFullscreenExit() {
|
|
const nativeFullscreen = document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement;
|
|
if (!nativeFullscreen && isMapFullscreen) {
|
|
isMapFullscreen = false;
|
|
const mapContainer = document.getElementById('map-view');
|
|
const btn = document.getElementById('btn-fullscreen');
|
|
const icon = btn.querySelector('i');
|
|
|
|
mapContainer.classList.remove('fullscreen-mode');
|
|
document.body.classList.remove('fullscreen-active');
|
|
icon.className = 'fas fa-expand';
|
|
|
|
const exitBtn = mapContainer.querySelector('.fullscreen-exit-btn');
|
|
if (exitBtn) exitBtn.remove();
|
|
|
|
setTimeout(() => { if (map) map.invalidateSize(); }, 100);
|
|
}
|
|
}
|
|
document.addEventListener('fullscreenchange', onNativeFullscreenExit);
|
|
document.addEventListener('webkitfullscreenchange', onNativeFullscreenExit);
|
|
document.addEventListener('msfullscreenchange', onNativeFullscreenExit);
|
|
|
|
// Socket.IO setup for real-time updates
|
|
// Live updates are applied only when the map view is active. When the graph view is active,
|
|
// events are coalesced into a bounded background refresh and do not re-render
|
|
// the graph, avoiding both request storms and chaotic physics re-stabilization.
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (!document.hidden && meshLiveRefreshPending) {
|
|
if (meshLiveRefreshTimer) {
|
|
clearTimeout(meshLiveRefreshTimer);
|
|
meshLiveRefreshTimer = null;
|
|
}
|
|
runScheduledMeshRefresh();
|
|
}
|
|
});
|
|
|
|
function setupSocketIO() {
|
|
const socket = window.connectionManager.socket;
|
|
|
|
socket.emit('subscribe_mesh');
|
|
|
|
function onMeshUpdate(reloadNodes) {
|
|
scheduleMeshLiveRefresh(reloadNodes);
|
|
}
|
|
|
|
socket.on('mesh_edge_added', () => onMeshUpdate(false));
|
|
socket.on('mesh_edge_updated', () => onMeshUpdate(false));
|
|
socket.on('mesh_node_added', () => onMeshUpdate(true));
|
|
}
|
|
</script>
|
|
|
|
</div>
|
|
{% endblock %}
|