mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-29 14:19:53 +00:00
Conflict badge: bigger clickable button with popover pane
⚠3 is now a yellow button (not tiny superscript). Clicking it opens a popover listing all regional candidates with: - Node name (clickable → node detail page) - Distance from observer region center - Truncated pubkey Popover dismisses on outside click. Each candidate is a link to #/nodes/PUBKEY for full details.
This commit is contained in:
@@ -7,11 +7,58 @@ window.HopDisplay = (function() {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// Dismiss any open conflict popover
|
||||
function dismissPopover() {
|
||||
const old = document.querySelector('.hop-conflict-popover');
|
||||
if (old) old.remove();
|
||||
}
|
||||
|
||||
// Global click handler to dismiss popovers
|
||||
let _listenerAttached = false;
|
||||
function ensureGlobalListener() {
|
||||
if (_listenerAttached) return;
|
||||
_listenerAttached = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.hop-conflict-popover') && !e.target.closest('.hop-conflict-btn')) {
|
||||
dismissPopover();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showConflictPopover(btn, h, conflicts, globalFallback) {
|
||||
dismissPopover();
|
||||
ensureGlobalListener();
|
||||
|
||||
const regional = conflicts.filter(c => c.regional);
|
||||
const shown = regional.length > 0 ? regional : conflicts;
|
||||
|
||||
let html = `<div class="hop-conflict-header">${escapeHtml(h)} — ${shown.length} candidate${shown.length > 1 ? 's' : ''}${regional.length > 0 ? ' in region' : ' (global fallback)'}</div>`;
|
||||
html += '<div class="hop-conflict-list">';
|
||||
for (const c of shown) {
|
||||
const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?');
|
||||
const dist = c.distKm != null ? `<span class="hop-conflict-dist">${c.distKm}km</span>` : '';
|
||||
const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : '';
|
||||
html += `<a href="#/nodes/${encodeURIComponent(c.pubkey || '')}" class="hop-conflict-item">
|
||||
<span class="hop-conflict-name">${name}</span>
|
||||
${dist}
|
||||
<span class="hop-conflict-pk">${pk}</span>
|
||||
</a>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
const popover = document.createElement('div');
|
||||
popover.className = 'hop-conflict-popover';
|
||||
popover.innerHTML = html;
|
||||
document.body.appendChild(popover);
|
||||
|
||||
// Position near the button
|
||||
const rect = btn.getBoundingClientRect();
|
||||
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||||
popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a hop prefix as HTML with conflict info.
|
||||
* @param {string} h - hex hop prefix
|
||||
* @param {Object} [entry] - resolved hop entry from HopResolver or hopNameCache
|
||||
* @param {Object} [opts] - { link: true, truncate: 0 }
|
||||
*/
|
||||
function renderHop(h, entry, opts) {
|
||||
opts = opts || {};
|
||||
@@ -22,33 +69,20 @@ window.HopDisplay = (function() {
|
||||
const pubkey = entry.pubkey || h;
|
||||
const ambiguous = entry.ambiguous || false;
|
||||
const conflicts = entry.conflicts || [];
|
||||
const totalGlobal = entry.totalGlobal || conflicts.length;
|
||||
const totalRegional = entry.totalRegional || 0;
|
||||
const globalFallback = entry.globalFallback || false;
|
||||
const unreliable = entry.unreliable || false;
|
||||
const display = opts.hexMode ? h : (name ? escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name) : h);
|
||||
|
||||
// Build tooltip — only show regional candidates (global is noise)
|
||||
// Simple title for the hop link itself
|
||||
let title = h;
|
||||
if (conflicts.length > 0) {
|
||||
const regional = conflicts.filter(c => c.regional);
|
||||
const shown = regional.length > 0 ? regional : conflicts; // fall back to all if no regional
|
||||
const lines = shown.map(c => {
|
||||
let line = c.name || c.pubkey?.slice(0, 12) || '?';
|
||||
if (c.distKm != null) line += ` (${c.distKm}km)`;
|
||||
return line;
|
||||
});
|
||||
const label = regional.length > 0 ? `${regional.length} in region` : `${conflicts.length} global`;
|
||||
title = `${h} — ${label}:\n${lines.join('\n')}`;
|
||||
}
|
||||
if (unreliable) title += '\n✗ Unreliable — too far from neighbors';
|
||||
if (globalFallback) title += '\n⚑ No regional candidates';
|
||||
if (unreliable) title += ' — unreliable';
|
||||
|
||||
// Badge — only count regional conflicts
|
||||
const regionalConflicts = conflicts.filter(c => c.regional);
|
||||
const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0);
|
||||
const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback }));
|
||||
const warnBadge = badgeCount > 1
|
||||
? `<span class="hop-warn" title="${escapeHtml(title)}">⚠${badgeCount}</span>`
|
||||
? ` <button class="hop-conflict-btn" data-conflict='${conflictData}' onclick="event.preventDefault();event.stopPropagation();HopDisplay._showFromBtn(this)" title="${badgeCount} candidates — click for details">⚠${badgeCount}</button>`
|
||||
: '';
|
||||
|
||||
const cls = [
|
||||
@@ -60,16 +94,13 @@ window.HopDisplay = (function() {
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (opts.link !== false) {
|
||||
return `<a class="${cls} hop-link" href="#/nodes/${encodeURIComponent(pubkey)}" title="${escapeHtml(title)}" data-hop-link="true">${display}${warnBadge}</a>`;
|
||||
return `<a class="${cls} hop-link" href="#/nodes/${encodeURIComponent(pubkey)}" title="${escapeHtml(title)}" data-hop-link="true">${display}</a>${warnBadge}`;
|
||||
}
|
||||
return `<span class="${cls}" title="${escapeHtml(title)}">${display}${warnBadge}</span>`;
|
||||
return `<span class="${cls}" title="${escapeHtml(title)}">${display}</span>${warnBadge}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a full path as HTML.
|
||||
* @param {string[]} hops - array of hex prefixes
|
||||
* @param {Object} cache - hop name cache (hop → entry)
|
||||
* @param {Object} [opts] - { link: true, separator: ' → ' }
|
||||
*/
|
||||
function renderPath(hops, cache, opts) {
|
||||
opts = opts || {};
|
||||
@@ -78,5 +109,13 @@ window.HopDisplay = (function() {
|
||||
return hops.filter(Boolean).map(h => renderHop(h, cache[h], opts)).join(sep);
|
||||
}
|
||||
|
||||
return { renderHop, renderPath };
|
||||
// Called from inline onclick
|
||||
function _showFromBtn(btn) {
|
||||
try {
|
||||
const data = JSON.parse(btn.dataset.conflict);
|
||||
showConflictPopover(btn, data.h, data.conflicts, data.globalFallback);
|
||||
} catch (e) { console.error('Conflict popover error:', e); }
|
||||
}
|
||||
|
||||
return { renderHop, renderPath, _showFromBtn };
|
||||
})();
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1774221131">
|
||||
<link rel="stylesheet" href="style.css?v=1774221932">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1774058575">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
@@ -83,7 +83,7 @@
|
||||
<script src="roles.js?v=1774325000"></script>
|
||||
<script src="region-filter.js?v=1774325000"></script>
|
||||
<script src="hop-resolver.js?v=1774221120"></script>
|
||||
<script src="hop-display.js?v=1774221736"></script>
|
||||
<script src="hop-display.js?v=1774221932"></script>
|
||||
<script src="app.js?v=1774126708"></script>
|
||||
<script src="home.js?v=1774042199"></script>
|
||||
<script src="packets.js?v=1774221842"></script>
|
||||
|
||||
@@ -1225,6 +1225,21 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
/* Ambiguous hop indicator */
|
||||
.hop-ambiguous { border-bottom: 1px dashed #f59e0b; }
|
||||
.hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; }
|
||||
.hop-conflict-btn { background: #f59e0b; color: #000; border: none; border-radius: 4px; font-size: 11px;
|
||||
font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; }
|
||||
.hop-conflict-btn:hover { background: #d97706; }
|
||||
.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border);
|
||||
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; }
|
||||
.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted); }
|
||||
.hop-conflict-list { padding: 4px 0; }
|
||||
.hop-conflict-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-decoration: none;
|
||||
color: var(--text); font-size: 13px; border-bottom: 1px solid var(--border); }
|
||||
.hop-conflict-item:last-child { border-bottom: none; }
|
||||
.hop-conflict-item:hover { background: var(--hover-bg); }
|
||||
.hop-conflict-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; }
|
||||
.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }
|
||||
.hop-unreliable { opacity: 0.5; text-decoration: line-through; }
|
||||
.hop-global-fallback { border-bottom: 1px dashed #ef4444; }
|
||||
.hop-current { font-weight: 700 !important; color: var(--accent) !important; }
|
||||
|
||||
Reference in New Issue
Block a user