// Path Inspector — prefix candidate scoring with map overlay (issue #944). // IIFE; exports window.PathInspector for testability. (function () { 'use strict'; var container = null; var currentResults = null; function init(app) { container = app; var params = new URLSearchParams(location.hash.split('?')[1] || ''); var prefixParam = params.get('prefixes') || ''; container.innerHTML = '
' + '

Path Inspector

' + '

Enter comma or space-separated hex prefixes (1-3 bytes each, e.g. 2C,A1,F4 or 2C A1 F4).

' + '
' + '' + '' + '
' + '
' + '
' + '
'; var input = document.getElementById('path-inspector-input'); var btn = document.getElementById('path-inspector-submit'); btn.addEventListener('click', function () { submit(input.value); }); input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(input.value); }); // Auto-run if prefixes in URL. if (prefixParam) submit(prefixParam); } function destroy() { container = null; currentResults = null; } function parsePrefixes(raw) { // Accept comma or space separated. var parts = raw.trim().split(/[\s,]+/).filter(function (s) { return s.length > 0; }); return parts.map(function (p) { return p.toLowerCase(); }); } function validatePrefixes(prefixes) { if (prefixes.length === 0) return 'Enter at least one prefix.'; if (prefixes.length > 64) return 'Too many prefixes (max 64).'; var hexRe = /^[0-9a-f]+$/; var byteLen = -1; for (var i = 0; i < prefixes.length; i++) { var p = prefixes[i]; if (!hexRe.test(p)) return 'Invalid hex: ' + p; if (p.length % 2 !== 0) return 'Odd-length prefix: ' + p; var bl = p.length / 2; if (bl > 3) return 'Prefix too long (max 3 bytes): ' + p; if (byteLen === -1) byteLen = bl; else if (bl !== byteLen) return 'Mixed prefix lengths not allowed.'; } return null; } function submit(raw) { var errDiv = document.getElementById('path-inspector-error'); var resultsDiv = document.getElementById('path-inspector-results'); errDiv.textContent = ''; resultsDiv.innerHTML = ''; var prefixes = parsePrefixes(raw); var err = validatePrefixes(prefixes); if (err) { errDiv.textContent = err; return; } // Update URL. var base = '#/tools/path-inspector'; if (location.hash.indexOf(base) === 0) { history.replaceState(null, '', base + '?prefixes=' + prefixes.join(',')); } resultsDiv.innerHTML = '

Loading...

'; fetch('/api/paths/inspect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefixes: prefixes }) }) .then(function (r) { if (r.status === 503) return r.json().then(function (d) { throw new Error('Service warming up, retry in a few seconds.'); }); if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Request failed'); }); return r.json(); }) .then(function (data) { currentResults = data; renderResults(data, resultsDiv); }) .catch(function (e) { resultsDiv.innerHTML = ''; errDiv.textContent = e.message; }); } function renderResults(data, div) { if (!data.candidates || data.candidates.length === 0) { div.innerHTML = '

No candidates found. The prefixes may not match any known path-eligible nodes.

'; return; } var html = '' + '' + ''; for (var i = 0; i < data.candidates.length; i++) { var c = data.candidates[i]; var rowClass = c.speculative ? 'speculative-row' : ''; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; // Per-hop evidence (collapsed). html += ''; } html += '
#ScorePathAction
' + (i + 1) + '' + c.score.toFixed(3) + (c.speculative ? ' ' : '') + '' + escapeHtml(c.names.join(' → ')) + '
'; html += '
Beam width: ' + data.stats.beamWidth + ' | Expansions: ' + data.stats.expansionsRun + ' | Elapsed: ' + data.stats.elapsedMs + 'ms
'; div.innerHTML = html; // Wire up Show on Map buttons. div.querySelectorAll('button[data-idx]').forEach(function (btn) { btn.addEventListener('click', function () { var idx = parseInt(btn.dataset.idx); showOnMap(data.candidates[idx]); }); }); // Wire up row expand for evidence. div.querySelectorAll('.path-inspector-table tbody tr:not(.evidence-row)').forEach(function (row) { row.style.cursor = 'pointer'; row.addEventListener('click', function (e) { if (e.target.tagName === 'BUTTON') return; var idx = row.querySelector('button[data-idx]'); if (!idx) return; var evidenceRow = div.querySelector('tr[data-evidence="' + idx.dataset.idx + '"]'); if (evidenceRow) evidenceRow.classList.toggle('collapsed'); }); }); } function showOnMap(candidate) { // Store pending route for map init to pick up. window._pendingPathInspectorRoute = candidate; // Switch to map page if not there; map init will draw the route. if (location.hash.indexOf('#/map') !== 0) { location.hash = '#/map'; } else { // Already on map — draw directly. delete window._pendingPathInspectorRoute; if (window.routeLayer) window.routeLayer.clearLayers(); // Pass FULL path as hopKeys (not slice(1)) — drawPacketRoute resolves // each entry against nodes[] for plotting. The 2nd arg is the origin // OBJECT (with pubkey/lat/lon/name); pass null since the origin is // already the first hop in the path itself, and drawPacketRoute draws // a marker for every resolved hop. if (window.drawPacketRoute) window.drawPacketRoute(candidate.path, null); } } function escapeAttr(s) { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } window.PathInspector = { init: init, destroy: destroy, parsePrefixes: parsePrefixes, validatePrefixes: validatePrefixes }; if (typeof registerPage === 'function') registerPage('path-inspector', { init: init, destroy: destroy }); })();