' +
'' +
_renderChannelsShowEncryptedToggle() +
+ _renderHide1ByteHopsToggle() +
_renderTileProviderSelector() +
'';
}
+ // ββ #1633 Hide 1-byte path hops toggle ββ
+ // Writes localStorage["meshcore-hide-1byte-hops"]. Default OFF: key is
+ // removed (not "false") so MC_getHide1ByteHops cleanly returns false.
+ // Fires `mc-hide-1byte-hops-changed`; map.js + packets.js subscribe and
+ // re-render in place (PR #1689 r1 adv #4). Analytics + route-view rebuild
+ // on next navigation β they don't need live wiring because they re-render
+ // on tab activation.
+ function _renderHide1ByteHopsToggle() {
+ var on = false;
+ try { on = localStorage.getItem('meshcore-hide-1byte-hops') === 'true'; } catch (_e) {}
+ return '
Path Display
' +
+ '
1-byte path-hash prefixes (firmware default) collide ~8-way at ~2k relays β many polylines and route-pattern rows they produce are visual noise. Hide them globally without changing what\'s stored.
' +
+ '
' +
+ '' +
+ '' +
+ '
';
+ }
+
// ββ #1454 Show-encrypted-channels toggle ββ
// Writes localStorage["channels-show-encrypted"]. Default OFF: key is
// removed (not set to "false") so the read-gate in channels.js cleanly
@@ -2247,6 +2268,23 @@
});
});
+ // #1633 Hide-1-byte-path-hops checkbox β persists + fires
+ // mc-hide-1byte-hops-changed; consumers re-render or rely on the next
+ // navigation refresh (analytics tab, packets list).
+ container.querySelectorAll('[data-cv2-hide-1byte-hops]').forEach(function (cb) {
+ cb.addEventListener('change', function () {
+ var on = !!cb.checked;
+ if (typeof window.MC_setHide1ByteHops === 'function') {
+ window.MC_setHide1ByteHops(on);
+ } else {
+ try {
+ if (on) localStorage.setItem('meshcore-hide-1byte-hops', 'true');
+ else localStorage.removeItem('meshcore-hide-1byte-hops');
+ } catch (_e) {}
+ }
+ });
+ });
+
// Preset buttons
container.querySelectorAll('.cust-preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
@@ -2725,6 +2763,7 @@
STORAGE_KEY, // 'cs-theme-overrides'
'meshcore-cb-preset', // #1361 CB preset id
'channels-show-encrypted', // #1454 encrypted-channel toggle
+ 'meshcore-hide-1byte-hops', // #1633 hide 1-byte path hops toggle
'mc-dark-tile-provider' // #1430 dark-tile provider pick
];
for (var i = 0; i < CUSTOMIZER_LS_KEYS.length; i++) {
diff --git a/public/hop-filter.js b/public/hop-filter.js
new file mode 100644
index 00000000..47c78560
--- /dev/null
+++ b/public/hop-filter.js
@@ -0,0 +1,92 @@
+/* === CoreScope β hop-filter.js === */
+/* #1633 β Render-time filter that hides 1-byte path hops when the
+ * customize-v2 toggle is ON. Pure render-time; firmware behavior and
+ * wire/store contents are untouched.
+ *
+ * A "hop" here is a hex-string prefix as stored in observations.path_json
+ * (e.g. "AB" = 1-byte, "CDEF" = 2-byte, "ABCDEF" = 3-byte). Byte count
+ * is `floor(hopHex.length / 2)`.
+ *
+ * Wire & store stay intact: every consumer call site reads the toggle
+ * via window.MC_getHide1ByteHops() and filters its rendered/aggregated
+ * view at the boundary (no upstream mutation).
+ */
+'use strict';
+
+(function () {
+ var STORAGE_KEY = 'meshcore-hide-1byte-hops';
+
+ function getHide1ByteHops() {
+ try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
+ catch (_e) { return false; }
+ }
+
+ function setHide1ByteHops(on) {
+ try {
+ if (on) localStorage.setItem(STORAGE_KEY, 'true');
+ else localStorage.removeItem(STORAGE_KEY);
+ } catch (_e) { /* private mode */ }
+ if (typeof window !== 'undefined' && typeof window.CustomEvent === 'function') {
+ window.dispatchEvent(new window.CustomEvent('mc-hide-1byte-hops-changed', {
+ detail: { value: !!on }
+ }));
+ }
+ }
+
+ // bytes of a hop hex token β handles undefined / non-string.
+ function hopByteLen(h) {
+ if (h == null) return 0;
+ var s = String(h);
+ return s.length >> 1;
+ }
+
+ // Render-time predicate. opts may be omitted β if so, falls back to the
+ // current localStorage value. The hop is hidden only when:
+ // - opts.hide1ByteHops === true AND
+ // - the hop hex encodes exactly 1 byte (length === 2)
+ // Anything else (origin/destination payload hops, multi-byte path hops,
+ // null/undefined sentinels) stays visible β those callers already
+ // bypass the filter when they pass undefined/falsey tokens.
+ function isVisibleHop(hop, opts) {
+ var enabled = (opts && typeof opts.hide1ByteHops === 'boolean')
+ ? opts.hide1ByteHops
+ : getHide1ByteHops();
+ if (!enabled) return true;
+ return hopByteLen(hop) !== 1;
+ }
+
+ // Filter a path hop array. Returns a NEW array; never mutates the input
+ // (callers depend on the original path_json staying intact for downstream
+ // consumers like hash-size detection / raw-hex rendering).
+ function filterPathHops(hops, opts) {
+ if (!hops || !hops.length) return hops || [];
+ var enabled = (opts && typeof opts.hide1ByteHops === 'boolean')
+ ? opts.hide1ByteHops
+ : getHide1ByteHops();
+ if (!enabled) return hops;
+ var out = [];
+ for (var i = 0; i < hops.length; i++) {
+ if (hopByteLen(hops[i]) !== 1) out.push(hops[i]);
+ }
+ return out;
+ }
+
+ if (typeof window !== 'undefined') {
+ window.MC_getHide1ByteHops = getHide1ByteHops;
+ window.MC_setHide1ByteHops = setHide1ByteHops;
+ window.MC_isVisibleHop = isVisibleHop;
+ window.MC_filterPathHops = filterPathHops;
+ window.MC_hopByteLen = hopByteLen;
+ }
+
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = {
+ getHide1ByteHops: getHide1ByteHops,
+ setHide1ByteHops: setHide1ByteHops,
+ isVisibleHop: isVisibleHop,
+ filterPathHops: filterPathHops,
+ hopByteLen: hopByteLen,
+ _STORAGE_KEY: STORAGE_KEY
+ };
+ }
+})();
diff --git a/public/index.html b/public/index.html
index 99f413cc..f3d6aab2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -172,6 +172,7 @@
+
diff --git a/public/live.js b/public/live.js
index ea43b8c6..af52cd59 100644
--- a/public/live.js
+++ b/public/live.js
@@ -2541,9 +2541,24 @@
return;
}
const COLLAPSE = 5;
+ // #1689 r1 (adv #3): respect the customizer "hide 1-byte path hops"
+ // toggle in the live-pane "Paths Through" widget. /api/.../paths
+ // returns hops as objects {prefix,pubkey,name}; the byte-length is
+ // derived from the hex prefix.
+ function _filterHopObjs(hopObjs) {
+ if (typeof window === 'undefined' || !window.MC_isVisibleHop) return hopObjs;
+ if (!window.MC_getHide1ByteHops || !window.MC_getHide1ByteHops()) return hopObjs;
+ return (hopObjs || []).filter(function (h) {
+ return window.MC_isVisibleHop(h && h.prefix ? String(h.prefix) : '');
+ });
+ }
function renderPathList(paths) {
return paths.map(p => {
- const chain = p.hops.map(h => {
+ const filteredHops = _filterHopObjs(p.hops || []);
+ if (!filteredHops.length) {
+ return `
β (1-byte filtered) (${p.count}Γ)
`;
+ }
+ const chain = filteredHops.map(h => {
const isThis = h.pubkey === n.public_key || (h.prefix && n.public_key.toLowerCase().startsWith(h.prefix.toLowerCase()));
const name = escapeHtml(h.name || h.prefix);
if (isThis) return `${name}`;
@@ -2680,6 +2695,13 @@
function packetInvolvesFilterNode(pkt, filterKeys) {
if (!filterKeys.length) return true;
+ // #1689 r2 MAJOR: node-filter search semantics MUST be independent of
+ // the customizer's hide-1-byte-hops display preference. Letting the
+ // display toggle change search results silently fails the principle
+ // of least astonishment (operator searches for a node, hides 1-byte
+ // hops for chart readability, suddenly matches disappear). Chip
+ // rendering filters hops separately via MC_filterPathHops at the
+ // render boundary β see feedHops below.
const hops = (pkt.decoded?.path?.hops) || [];
for (const hop of hops) {
const h = (hop.id || hop.public_key || hop).toString().toLowerCase();
@@ -3187,6 +3209,20 @@
}
if (fpHops.length > feedHops.length) feedHops = fpHops;
}
+ // #1689 r1 (adv #3): apply the customizer "hide 1-byte path hops"
+ // toggle to the feed-item hop count + chip rendering. feedHops here
+ // is an array of raw hex tokens (from path_json) or hop-objects (from
+ // decoded.path.hops) β MC_filterPathHops only knows about hex strings
+ // so we branch on shape.
+ if (typeof window !== 'undefined' && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()) {
+ if (feedHops.length && typeof feedHops[0] === 'string') {
+ feedHops = window.MC_filterPathHops(feedHops);
+ } else if (feedHops.length && window.MC_isVisibleHop) {
+ feedHops = feedHops.filter(function (h) {
+ return window.MC_isVisibleHop(h && h.prefix ? String(h.prefix) : (h && h.id ? String(h.id) : ''));
+ });
+ }
+ }
addFeedItem(icon, typeName, payload, feedHops, color, consolidated);
// Store all observation packets in dedup entry for replay tree
if (consolidated.hash && feedDedup.has(consolidated.hash)) {
diff --git a/public/map.js b/public/map.js
index 1e4e0211..bbb57542 100644
--- a/public/map.js
+++ b/public/map.js
@@ -341,6 +341,26 @@
_syncDarkTiles(dark);
});
+ // #1689 r1 (adv #4): live re-render when the customizer "hide 1-byte
+ // path hops" toggle flips. Before this listener the toggle only took
+ // effect on the next navigation despite an inline comment claiming
+ // "live update". Re-issue the most recent drawPacketRoute(Multi) call
+ // so the polyline + redacted badge update in place.
+ if (typeof window !== 'undefined' && !window.__mc_map_hide1byte_wired) {
+ window.__mc_map_hide1byte_wired = true;
+ window.addEventListener('mc-hide-1byte-hops-changed', function () {
+ try {
+ const last = window.__mc_lastRouteDraw;
+ if (!last) return;
+ if (last.kind === 'multi' && typeof window.drawPacketRouteMulti === 'function') {
+ window.drawPacketRouteMulti(last.paths, last.origin, last.opts);
+ } else if (last.kind === 'single' && typeof window.drawPacketRoute === 'function') {
+ window.drawPacketRoute(last.hopKeys, last.origin, last.opts);
+ }
+ } catch (e) { console.warn('[map] hide-1byte re-render failed', e); }
+ });
+ }
+
// Save position on move
map.on('moveend', () => {
const c = map.getCenter();
@@ -611,6 +631,9 @@
origin = { pubkey: origin };
}
opts = opts || {};
+ // #1689 r1 (adv #4): remember the last route-draw inputs so the
+ // mc-hide-1byte-hops-changed listener can re-render in place.
+ try { window.__mc_lastRouteDraw = { kind: 'single', hopKeys: hopKeys, origin: origin, opts: opts }; } catch (_e) {}
// #1422: use the backend's /api/resolve-hops for proper disambiguation
// (unique_prefix vs multi-byte vs gps_preference vs affinity scoring).
// Falls back to naive nodes.filter() scan if the API is unreachable.
@@ -670,15 +693,20 @@
// renderer can label it "no GPS" instead of "unresolved prefix".
const raw = hopKeys.map(hop => {
const hopLower = String(hop).toLowerCase();
+ // #1633 β tag the original hex token so MC_isVisibleHop can filter
+ // 1-byte hops out of the polyline/sidebar when the customizer toggle
+ // is ON. The hop value stays in place upstream (resolve, fitBounds
+ // ordering) β only the polyline render iterator drops it.
+ const _hopHex = String(hop);
// Try server resolution first
const srv = serverResolved && (serverResolved[hop] || serverResolved[hopLower] || serverResolved[hop.toUpperCase()]);
if (srv && srv.pubkey) {
const c = srv.candidates && srv.candidates[0];
if (c && c.lat != null && c.lon != null && !(c.lat === 0 && c.lon === 0)) {
- return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true };
+ return { lat: c.lat, lon: c.lon, name: srv.name || c.name || hop.slice(0,8), pubkey: srv.pubkey, role: c.role, resolved: true, _hopHex };
}
// Server resolved but node has no usable GPS
- return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true };
+ return { name: srv.name || hop.slice(0,8), pubkey: srv.pubkey, role: (c && c.role) || null, resolved: false, gpsless: true, _hopHex };
}
// Fallback: naive local scan (kept for resilience when API is down).
const allMatches = nodes.filter(n => {
@@ -688,14 +716,14 @@
const withGps = allMatches.filter(n => n.lat != null && n.lon != null && !(n.lat === 0 && n.lon === 0));
if (withGps.length === 1) {
const c = withGps[0];
- return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true };
+ return { lat: c.lat, lon: c.lon, name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: true, _hopHex };
} else if (withGps.length > 1) {
- return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps };
+ return { name: hop.slice(0,8), pubkey: hop, resolved: false, candidates: withGps, _hopHex };
} else if (allMatches.length >= 1) {
const c = allMatches[0];
- return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true };
+ return { name: c.name || hop.slice(0,8), pubkey: c.public_key, role: c.role, resolved: false, gpsless: true, _hopHex };
}
- return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false };
+ return { name: String(hop).slice(0, 8), pubkey: hop, resolved: false, _hopHex };
});
// Disambiguate: pick candidate closest to center of already-resolved hops
@@ -716,7 +744,7 @@
}
}
- const positions = raw.filter(h => h != null);
+ let positions = raw.filter(h => h != null);
// Resolve and prepend origin node
if (origin) {
@@ -762,6 +790,34 @@
// Mark final hop as destination so the renderer applies the dest glyph.
positions[positions.length - 1].isDest = true;
+ // #1633 β render-time 1-byte hop filter (default OFF). Origin / destination
+ // positions are added without _hopHex (they came from the payload, not the
+ // outer path bytes) and therefore always survive. Intermediate hops with a
+ // 1-byte _hopHex are dropped when the customizer toggle is ON.
+ //
+ // #1689 r1 (adv #2): if the filter drops every intermediate hop the result
+ // is a 2-point originβdest polyline that is visually identical to a
+ // "direct delivery, no hops" route. Operators misread that as ground-truth
+ // direct path. To make the redaction LEGIBLE we tag the surviving
+ // positions with `_hopsHiddenCount` so the renderer can draw the polyline
+ // in a different style (dashed/lighter) and add a midpoint badge that
+ // reads "N hops hidden (1-byte)".
+ var hopsHiddenCount = 0;
+ if (window.MC_isVisibleHop && window.MC_getHide1ByteHops && window.MC_getHide1ByteHops()) {
+ var beforeCount = positions.length;
+ var beforeIntermediate = positions.filter(function (p) { return !!p._hopHex; }).length;
+ positions = positions.filter(function (p) {
+ return !p._hopHex || window.MC_isVisibleHop(p._hopHex);
+ });
+ var afterIntermediate = positions.filter(function (p) { return !!p._hopHex; }).length;
+ hopsHiddenCount = beforeIntermediate - afterIntermediate;
+ if (positions.length < beforeCount && positions.length >= 1) {
+ // Re-mark last surviving hop as destination if the original got dropped.
+ positions[positions.length - 1].isDest = true;
+ }
+ if (positions.length < 1) return;
+ }
+
// Hand off to sequence-primary sequence-primary renderer (#1418), falling
// back to the legacy role-aware MeshRoute (#1374), then to the minimal
// polyline (should never run in production).
@@ -770,13 +826,17 @@
timestamp: opts.timestamp || Date.now(),
packetHash: opts.packetHash || null,
observationId: opts.observationId || null,
- packetContext: opts.packetContext || null
+ packetContext: opts.packetContext || null,
+ // #1689 r1 (adv #2): tell the renderer how many intermediate hops
+ // the 1-byte filter dropped so it can style the polyline as redacted.
+ hopsHiddenCount: hopsHiddenCount
});
return;
}
if (window.MeshRoute && typeof window.MeshRoute.render === 'function') {
window.MeshRoute.render(map, routeLayer, positions, {
- timestamp: opts.timestamp || Date.now()
+ timestamp: opts.timestamp || Date.now(),
+ hopsHiddenCount: hopsHiddenCount
});
return;
}
@@ -784,7 +844,10 @@
// ββ Legacy fallback (kept tiny β should never run in production) βββββ
const coords = positions.filter(p => p.lat != null).map(p => [p.lat, p.lon]);
if (coords.length >= 2) {
- L.polyline(coords, { color: '#f59e0b', weight: 3, opacity: 0.8, dashArray: '8 4' }).addTo(routeLayer);
+ L.polyline(coords, {
+ color: '#f59e0b', weight: 3, opacity: hopsHiddenCount > 0 ? 0.55 : 0.8,
+ dashArray: hopsHiddenCount > 0 ? '4 6' : '8 4'
+ }).addTo(routeLayer);
map.fitBounds(L.latLngBounds(coords).pad(0.3));
} else if (coords.length === 1) {
map.setView(coords[0], 13);
@@ -804,6 +867,9 @@
opts = opts || {};
if (typeof origin === 'string') origin = { pubkey: origin };
if (!Array.isArray(paths) || paths.length === 0) return;
+ // #1689 r1 (adv #4): remember last multi-path draw for live re-render
+ // when the hide-1-byte-hops toggle changes.
+ try { window.__mc_lastRouteDraw = { kind: 'multi', paths: paths, origin: origin, opts: opts }; } catch (_e) {}
if (paths.length === 1) {
return drawPacketRoute(paths[0].path || [], origin, opts);
}
diff --git a/public/observer-detail.js b/public/observer-detail.js
index fe77f896..0db88baa 100644
--- a/public/observer-detail.js
+++ b/public/observer-detail.js
@@ -439,7 +439,14 @@ window.ObserverDetailNaiveBanner = {
Time
Type
Hash
SNR
RSSI
Hops
${packets.map(p => {
const decoded = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : (p.decoded_json || {});
- const hops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
+ const rawHops = typeof p.path_json === 'string' ? JSON.parse(p.path_json) : (p.path_json || []);
+ // #1689 r1 (adv #3): honor the customizer "hide 1-byte path hops"
+ // toggle for the hops-count column. Counting raw hops here was
+ // missed by the original PR; operators expect the count to match
+ // what's displayed everywhere else when the toggle is ON.
+ const hops = (typeof window !== 'undefined' && window.MC_filterPathHops)
+ ? window.MC_filterPathHops(rawHops)
+ : rawHops;
const typeName = PAYLOAD_LABELS[p.payload_type] || 'Type ' + p.payload_type;
return `