diff --git a/public/index.html b/public/index.html index 8694464f..7b6a7a57 100644 --- a/public/index.html +++ b/public/index.html @@ -154,6 +154,7 @@ + diff --git a/public/route-view-utils.js b/public/route-view-utils.js new file mode 100644 index 00000000..31cbc92a --- /dev/null +++ b/public/route-view-utils.js @@ -0,0 +1,151 @@ +/* route-view-utils.js โ€” pure helpers extracted from route-view.js (#1424). + * + * Pure refactor: zero behavior change. Loaded BEFORE route-view.js in any + * HTML that needs the route view. The IIFE in route-view.js unpacks + * window.MC_ROUTE_UTILS at the top. + * + * Helpers exported: + * escapeHtml(s) + * buildPacketContextBlock(pktCtx) + * buildSnrSparkline(snrTrend) + * + * `spiderFanFor` is NOT extracted โ€” it consumes Leaflet LatLng / map types + * (mapRef.latLngToLayerPoint, mk.getLatLng, mk.setLatLng, L.point) and is + * not pure. Left in place inside route-view.js (see comment there). + */ +(function () { + 'use strict'; + + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; + }); + } + + // packet-context block (one stable layout, type chip + 3-5 + // facts). pktCtx shape: + // { type: 'ADVERT'|'TXT_MSG'|'GRP_TXT'|'TRACE'|other, + // decoded: , + // payloadType: , + // srcResolvedName, destResolvedName, observedHops, observationCount } + function buildPacketContextBlock(pktCtx) { + if (!pktCtx || !pktCtx.type) return ''; + var t = pktCtx.type; + var d = pktCtx.decoded || {}; + var glyph, label, factsHtml = ''; + switch (t) { + case 'ADVERT': + glyph = '๐Ÿ“ก'; label = 'ADVERT'; + var name = d.adName || d.name || (d.pubKey ? d.pubKey.slice(0, 8) : '?'); + var role = (d.flags && (d.flags.repeater ? 'repeater' : d.flags.room ? 'room' : d.flags.sensor ? 'sensor' : d.flags.chat ? 'companion' : 'unknown')) || 'unknown'; + // no fabricated fields. Battery isn't decoded into the advert + // JSON โ€” adverts carry lat/lon + name + flags, not battery. If a + // future advert version exposes it, re-add then. + var sig = (d.signatureValid === true) ? 'โœ“' : (d.signatureValid === false ? 'โœ—' : null); + var line1 = '' + escapeHtml(name) + ' ยท ' + escapeHtml(role); + if (sig) line1 += ' ยท sig ' + sig; + // Self-reported GPS if present + if (d.lat != null && d.lon != null) { + line1 += ' ยท ' + d.lat.toFixed(3) + ', ' + d.lon.toFixed(3); + } + var pkPrefix = d.pubKey ? d.pubKey.slice(0, 12) + 'โ€ฆ' : ''; + factsHtml = '
' + line1 + '
'; + if (pkPrefix) factsHtml += '
' + escapeHtml(pkPrefix) + '
'; + break; + case 'PATH': + glyph = '๐Ÿ”€'; label = 'PATH'; + var psrc = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : '?'); + var pdst = pktCtx.destResolvedName || (d.destHash ? 'unknown (hash ' + d.destHash + ')' : '?'); + factsHtml = '
' + escapeHtml(psrc) + ' โ†’ ' + escapeHtml(pdst) + '
'; + break; + case 'TXT_MSG': + case 'REQ': + case 'RESPONSE': + case 'ANON_REQ': + var typeGlyphs = { 'TXT_MSG': 'โœ‰', 'REQ': '๐Ÿ”’', 'RESPONSE': '๐Ÿ”“', 'ANON_REQ': '๐Ÿ”’' }; + var typeLabels = { 'TXT_MSG': 'DM', 'REQ': 'REQUEST', 'RESPONSE': 'RESPONSE', 'ANON_REQ': 'ANON REQ' }; + glyph = typeGlyphs[t] || 'ยท'; + label = typeLabels[t] || t; + var src = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : (t === 'ANON_REQ' ? 'anon' : '?')); + var dst = pktCtx.destResolvedName || (d.destHash ? 'unknown (hash ' + d.destHash + ')' : '?'); + factsHtml = '
' + escapeHtml(src) + ' โ†’ ' + escapeHtml(dst) + '
'; + factsHtml += '
๐Ÿ”’ encrypted
'; + break; + case 'GRP_TXT': + case 'CHAN': + glyph = '#'; label = 'CHANNEL MSG'; + var chName = pktCtx.channelName || d.channel || (d.channelHashHex ? 'channel 0x' + d.channelHashHex : 'channel ?'); + var contentText = pktCtx.decryptedText || d.text || d.plainText || null; + var encStatus = contentText ? '๐Ÿ”“ decrypted' : (d.decryptionStatus === 'decrypted' ? '๐Ÿ”“ decrypted' : '๐Ÿ”’ no key'); + factsHtml = '
' + escapeHtml(chName) + '
'; + factsHtml += '
' + encStatus + '
'; + if (contentText) { + var preview = contentText.slice(0, 80); + if (contentText.length > 80) preview += 'โ€ฆ'; + factsHtml += '
"' + escapeHtml(preview) + '"
'; + } + var senderName = pktCtx.srcResolvedName || d.sender || (d.srcHash ? 'sender 0x' + d.srcHash : null); + if (senderName) factsHtml += '
from ' + escapeHtml(senderName) + '
'; + break; + case 'TRACE': + glyph = 'โŒ–'; label = 'TRACE'; + var officialHops = (d.routeTaken && d.routeTaken.length) || (d.route && d.route.length) || null; + var observed = (pktCtx.observedHops != null) ? pktCtx.observedHops : null; + if (officialHops != null && observed != null) { + factsHtml = '
Official: ' + officialHops + ' hops ยท Observed: ' + observed + '
'; + } else if (officialHops != null) { + factsHtml = '
Official route: ' + officialHops + ' hops
'; + } + if (pktCtx.issuedBy) factsHtml += '
issued by ' + escapeHtml(pktCtx.issuedBy) + '
'; + break; + default: + glyph = 'ยท'; label = (t || 'OTHER').toUpperCase(); + if (pktCtx.payloadSize != null) { + factsHtml = '
' + pktCtx.payloadSize + ' bytes
'; + } + break; + } + return '
' + + '
' + glyph + ' ' + escapeHtml(label) + '
' + + '
' + factsHtml + '
' + + '
'; + } + + function buildSnrSparkline(snrTrend) { + if (!snrTrend || !snrTrend.length) return 'no SNR data'; + var pts = snrTrend.filter(function (p) { return p && p.snr != null; }); + if (!pts.length) return 'no SNR data'; + var W = 200, H = 28; + var snrs = pts.map(function (p) { return p.snr; }); + var minS = Math.min.apply(null, snrs), maxS = Math.max.apply(null, snrs); + if (maxS === minS) { minS -= 1; maxS += 1; } + // n<3 is not a sparkline โ€” a 2-point polyline implies a + // trend across time it can't represent. Show DOTS only (no connecting line) + // when there are fewer than 3 observations. + var showLine = pts.length >= 3; + var poly = pts.map(function (p, i) { + var x = (i / (pts.length - 1 || 1)) * W; + var y = H - 2 - ((p.snr - minS) / (maxS - minS)) * (H - 4); + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + var svg = ''; + if (showLine) { + svg += ''; + } + // Dots always (data points themselves are the truth) + pts.forEach(function (p, i) { + var x = (i / (pts.length - 1 || 1)) * W; + var y = H - 2 - ((p.snr - minS) / (maxS - minS)) * (H - 4); + svg += ''; + }); + svg += ''; + return svg + + '' + pts.length + ' obs ยท ' + minS.toFixed(1) + '..' + maxS.toFixed(1) + ' dB'; + } + + window.MC_ROUTE_UTILS = { + escapeHtml: escapeHtml, + buildPacketContextBlock: buildPacketContextBlock, + buildSnrSparkline: buildSnrSparkline + }; +})(); diff --git a/public/route-view.js b/public/route-view.js index 94c8eef8..329e71b9 100644 --- a/public/route-view.js +++ b/public/route-view.js @@ -72,132 +72,14 @@ return Math.round(d/86400000) + 'd ago'; } - function escapeHtml(s) { - return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { - return ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]; - }); - } - - // packet-context block (one stable layout, type chip + 3-5 - // facts). pktCtx shape: - // { type: 'ADVERT'|'TXT_MSG'|'GRP_TXT'|'TRACE'|other, - // decoded: , - // payloadType: , - // srcResolvedName, destResolvedName, observedHops, observationCount } - function buildPacketContextBlock(pktCtx) { - if (!pktCtx || !pktCtx.type) return ''; - var t = pktCtx.type; - var d = pktCtx.decoded || {}; - var glyph, label, factsHtml = ''; - switch (t) { - case 'ADVERT': - glyph = '๐Ÿ“ก'; label = 'ADVERT'; - var name = d.adName || d.name || (d.pubKey ? d.pubKey.slice(0, 8) : '?'); - var role = (d.flags && (d.flags.repeater ? 'repeater' : d.flags.room ? 'room' : d.flags.sensor ? 'sensor' : d.flags.chat ? 'companion' : 'unknown')) || 'unknown'; - // no fabricated fields. Battery isn't decoded into the advert - // JSON โ€” adverts carry lat/lon + name + flags, not battery. If a - // future advert version exposes it, re-add then. - var sig = (d.signatureValid === true) ? 'โœ“' : (d.signatureValid === false ? 'โœ—' : null); - var line1 = '' + escapeHtml(name) + ' ยท ' + escapeHtml(role); - if (sig) line1 += ' ยท sig ' + sig; - // Self-reported GPS if present - if (d.lat != null && d.lon != null) { - line1 += ' ยท ' + d.lat.toFixed(3) + ', ' + d.lon.toFixed(3); - } - var pkPrefix = d.pubKey ? d.pubKey.slice(0, 12) + 'โ€ฆ' : ''; - factsHtml = '
' + line1 + '
'; - if (pkPrefix) factsHtml += '
' + escapeHtml(pkPrefix) + '
'; - break; - case 'PATH': - glyph = '๐Ÿ”€'; label = 'PATH'; - var psrc = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : '?'); - var pdst = pktCtx.destResolvedName || (d.destHash ? 'unknown (hash ' + d.destHash + ')' : '?'); - factsHtml = '
' + escapeHtml(psrc) + ' โ†’ ' + escapeHtml(pdst) + '
'; - break; - case 'TXT_MSG': - case 'REQ': - case 'RESPONSE': - case 'ANON_REQ': - var typeGlyphs = { 'TXT_MSG': 'โœ‰', 'REQ': '๐Ÿ”’', 'RESPONSE': '๐Ÿ”“', 'ANON_REQ': '๐Ÿ”’' }; - var typeLabels = { 'TXT_MSG': 'DM', 'REQ': 'REQUEST', 'RESPONSE': 'RESPONSE', 'ANON_REQ': 'ANON REQ' }; - glyph = typeGlyphs[t] || 'ยท'; - label = typeLabels[t] || t; - var src = pktCtx.srcResolvedName || (d.srcHash ? 'unknown (hash ' + d.srcHash + ')' : (t === 'ANON_REQ' ? 'anon' : '?')); - var dst = pktCtx.destResolvedName || (d.destHash ? 'unknown (hash ' + d.destHash + ')' : '?'); - factsHtml = '
' + escapeHtml(src) + ' โ†’ ' + escapeHtml(dst) + '
'; - factsHtml += '
๐Ÿ”’ encrypted
'; - break; - case 'GRP_TXT': - case 'CHAN': - glyph = '#'; label = 'CHANNEL MSG'; - var chName = pktCtx.channelName || d.channel || (d.channelHashHex ? 'channel 0x' + d.channelHashHex : 'channel ?'); - var contentText = pktCtx.decryptedText || d.text || d.plainText || null; - var encStatus = contentText ? '๐Ÿ”“ decrypted' : (d.decryptionStatus === 'decrypted' ? '๐Ÿ”“ decrypted' : '๐Ÿ”’ no key'); - factsHtml = '
' + escapeHtml(chName) + '
'; - factsHtml += '
' + encStatus + '
'; - if (contentText) { - var preview = contentText.slice(0, 80); - if (contentText.length > 80) preview += 'โ€ฆ'; - factsHtml += '
"' + escapeHtml(preview) + '"
'; - } - var senderName = pktCtx.srcResolvedName || d.sender || (d.srcHash ? 'sender 0x' + d.srcHash : null); - if (senderName) factsHtml += '
from ' + escapeHtml(senderName) + '
'; - break; - case 'TRACE': - glyph = 'โŒ–'; label = 'TRACE'; - var officialHops = (d.routeTaken && d.routeTaken.length) || (d.route && d.route.length) || null; - var observed = (pktCtx.observedHops != null) ? pktCtx.observedHops : null; - if (officialHops != null && observed != null) { - factsHtml = '
Official: ' + officialHops + ' hops ยท Observed: ' + observed + '
'; - } else if (officialHops != null) { - factsHtml = '
Official route: ' + officialHops + ' hops
'; - } - if (pktCtx.issuedBy) factsHtml += '
issued by ' + escapeHtml(pktCtx.issuedBy) + '
'; - break; - default: - glyph = 'ยท'; label = (t || 'OTHER').toUpperCase(); - if (pktCtx.payloadSize != null) { - factsHtml = '
' + pktCtx.payloadSize + ' bytes
'; - } - break; - } - return '
' + - '
' + glyph + ' ' + escapeHtml(label) + '
' + - '
' + factsHtml + '
' + - '
'; - } - - function buildSnrSparkline(snrTrend) { - if (!snrTrend || !snrTrend.length) return 'no SNR data'; - var pts = snrTrend.filter(function (p) { return p && p.snr != null; }); - if (!pts.length) return 'no SNR data'; - var W = 200, H = 28; - var snrs = pts.map(function (p) { return p.snr; }); - var minS = Math.min.apply(null, snrs), maxS = Math.max.apply(null, snrs); - if (maxS === minS) { minS -= 1; maxS += 1; } - // n<3 is not a sparkline โ€” a 2-point polyline implies a - // trend across time it can't represent. Show DOTS only (no connecting line) - // when there are fewer than 3 observations. - var showLine = pts.length >= 3; - var poly = pts.map(function (p, i) { - var x = (i / (pts.length - 1 || 1)) * W; - var y = H - 2 - ((p.snr - minS) / (maxS - minS)) * (H - 4); - return x.toFixed(1) + ',' + y.toFixed(1); - }).join(' '); - var svg = ''; - if (showLine) { - svg += ''; - } - // Dots always (data points themselves are the truth) - pts.forEach(function (p, i) { - var x = (i / (pts.length - 1 || 1)) * W; - var y = H - 2 - ((p.snr - minS) / (maxS - minS)) * (H - 4); - svg += ''; - }); - svg += ''; - return svg + - '' + pts.length + ' obs ยท ' + minS.toFixed(1) + '..' + maxS.toFixed(1) + ' dB'; - } + // #1424 โ€” pure helpers (escapeHtml, buildPacketContextBlock, + // buildSnrSparkline) extracted into public/route-view-utils.js. Loader + // for that file MUST run before route-view.js (see index.html). Local + // refs keep the call sites in this file unchanged. + var _MC_RT_U = window.MC_ROUTE_UTILS || {}; + var escapeHtml = _MC_RT_U.escapeHtml; + var buildPacketContextBlock = _MC_RT_U.buildPacketContextBlock; + var buildSnrSparkline = _MC_RT_U.buildSnrSparkline; // Polish review (carmack #1423): bound _detailCache (was unbounded plain // object; every distinct pubkey ever clicked was retained for the tab's @@ -1369,6 +1251,9 @@ // Spider-fan: after Leaflet projects, group any markers within // 25px of each other and offset them on an arc around their centroid. // Draw a hairline from each offset marker back to the centroid. + // #1424: NOT extracted into route-view-utils.js โ€” this consumes Leaflet + // types (mapRef.latLngToLayerPoint, mk.getLatLng / setLatLng, L.point) + // and mutates marker objects, so it isn't pure. function spiderFanFor(markerArray, positionArray) { if (!mapRef || !mapRef.latLngToLayerPoint) return; var pts = markerArray.map(function (mk, i) {