Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,7 @@ function navigate() {

const app = document.getElementById('app');
// Pages with fixed-height containers (maps, virtual-scroll, split-panels)
const fixedPages = { packets: 1, nodes: 1, map: 1, live: 1, channels: 1, 'audio-lab': 1 };
const fixedPages = { packets: 1, nodes: 1, map: 1, regions: 1, live: 1, channels: 1, 'audio-lab': 1 };
app.classList.toggle('app-fixed', basePage in fixedPages);

// Issue #1369: ?embed=1 chrome suppression for cross-domain iframe embeds.
Expand Down
2 changes: 2 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
<a href="#/home" class="nav-link" data-route="home" data-priority="high">Home</a>
<a href="#/packets" class="nav-link" data-route="packets" data-priority="high">Packets</a>
<a href="#/map" class="nav-link" data-route="map" data-priority="high">Map</a>
<a href="#/regions" class="nav-link" data-route="regions">Regions</a>
<a href="#/live" class="nav-link" data-route="live" data-priority="high"><svg class="ph-icon" aria-hidden="true" focusable="false"><use href="/icons/phosphor-sprite.svg#ph-broadcast"></use></svg> Live</a>
<a href="#/channels" class="nav-link" data-route="channels">Channels</a>
<a href="#/nodes" class="nav-link" data-route="nodes" data-priority="high">Nodes</a>
Expand Down Expand Up @@ -212,6 +213,7 @@
<script src="packets.js?v=__BUST__"></script>
<script src="geo-filter-overlay.js?v=__BUST__"></script>
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="regions-map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="route-render.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="route-view-utils.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="route-view.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
Expand Down
216 changes: 216 additions & 0 deletions public/regions-map.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* CoreScope Regions: an observed-relay footprint, not an RF coverage map. */
'use strict';

(function (root) {
const MAX_POINTS = 5000;
const MAX_SCOPES = 128;
let leafletMap = null;
let themeObserver = null;
let generation = 0;

function validPosition(node) {
const lat = Number(node.lat), lon = Number(node.lon);
return Number.isFinite(lat) && Number.isFinite(lon) &&
lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 &&
(lat !== 0 || lon !== 0);
}

// MeshCore RegionMap::is_name_char byte rules, matching the admin scope
// approval validator. Keep the exact OTA spelling and case.
function validScope(name) {
if (typeof name !== 'string' || !name.startsWith('#')) return false;
const bytes = new TextEncoder().encode(name);
if (bytes.length < 2 || bytes.length > 30 || new TextDecoder().decode(bytes) !== name) return false;
for (let i = 1; i < bytes.length; i++) {
const c = bytes[i];
if (!(c === 45 || c === 36 || c === 35 || (c >= 48 && c <= 57) || (c >= 65 && c !== 127))) return false;
}
return true;
}

function collectScopePoints(nodes) {
if (!Array.isArray(nodes)) return [];
const seen = new Set();
const out = [];
for (const node of nodes) {
if (out.length >= MAX_POINTS) break;
if (!node || (node.role !== 'repeater' && node.role !== 'room') ||
!validPosition(node) || !Array.isArray(node.transported_scopes_recent)) continue;
const key = String(node.public_key || '');
if (!key || seen.has(key)) continue;
const scopes = node.transported_scopes_recent.filter(validScope);
if (!scopes.length) continue;
seen.add(key);
out.push({ publicKey: key, name: String(node.name || key.slice(0, 8)),
lat: Number(node.lat), lon: Number(node.lon), scopes,
lastRelayed: typeof node.last_relayed === 'string' ? node.last_relayed : '' });
}
return out;
}

function scopeCatalog(nodes) {
const scopes = new Set();
for (const point of collectScopePoints(nodes)) {
for (const scope of point.scopes) {
scopes.add(scope);
if (scopes.size > MAX_SCOPES) return { scopes: Array.from(scopes).slice(0, MAX_SCOPES).sort((a, b) => a.localeCompare(b)), truncated: true };
}
}
return { scopes: Array.from(scopes).sort((a, b) => a.localeCompare(b)), truncated: false };
}

function listScopes(nodes) {
return scopeCatalog(nodes).scopes;
}

function convexHull(points) {
if (!Array.isArray(points)) return [];
const sorted = points.filter(p => Array.isArray(p) && p.length === 2 && Number.isFinite(p[0]) && Number.isFinite(p[1]))
.map(p => [p[0], p[1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const unique = sorted.filter((p, i) => i === 0 || p[0] !== sorted[i - 1][0] || p[1] !== sorted[i - 1][1]);
if (unique.length < 3) return [];
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
const lower = [], upper = [];
for (const p of unique) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
for (let i = unique.length - 1; i >= 0; i--) {
const p = unique[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
upper.push(p);
}
lower.pop(); upper.pop();
const hull = lower.concat(upper);
return hull.length >= 3 ? hull : [];
}

function parseSelectedScope(hash) {
const query = String(hash || '').split('?')[1] || '';
const scope = new URLSearchParams(query).get('scope') || '';
return validScope(scope) ? scope : '';
}

function scopeHash(scope) {
return scope ? '#/regions?scope=' + encodeURIComponent(scope) : '#/regions';
}

function element(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}

function renderPoints(layer, map, points, scope, status, truncated, scopesTruncated) {
layer.clearLayers();
const selected = scope ? points.filter(p => p.scopes.includes(scope)) : points;
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || 'steelblue';
for (const point of selected) {
const marker = L.circleMarker([point.lat, point.lon], { radius: 6, weight: 2, color: accent, fillOpacity: 0.75 });
const popup = element('div');
popup.appendChild(element('strong', '', point.name));
popup.appendChild(element('div', '', point.scopes.join(', ')));
if (point.lastRelayed) popup.appendChild(element('div', '', 'Last observed relay: ' + point.lastRelayed));
const link = element('a', '', 'Node page');
link.href = '#/nodes/' + encodeURIComponent(point.publicKey);
popup.appendChild(link);
marker.bindPopup(popup);
layer.addLayer(marker);
}
if (scope && selected.length >= 3) {
const hull = convexHull(selected.map(p => [p.lon, p.lat]));
if (hull.length >= 3) layer.addLayer(L.polygon(hull.map(p => [p[1], p[0]]), {
color: accent, fillOpacity: 0.10, weight: 2, dashArray: '6 5', interactive: false,
}));
}
status.textContent = selected.length ? `${selected.length} positioned repeaters with observed scoped relay traffic in the configured relay-activity window${scope ? ' for ' + scope : ''}. Dashed outline is a convex hull of node positions, not measured RF coverage.`
: 'No positioned repeaters with recent observed relay traffic for this selection. This does not imply there is no coverage.';
if (truncated) status.textContent += ' Node fetch hit its 5,000-row safety limit; this view may be incomplete.';
if (scopesTruncated) status.textContent += ' Scope selector shows only the first 128 observed names; other scopes are omitted from the selector.';
if (selected.length) {
const bounds = L.latLngBounds(selected.map(p => [p.lat, p.lon]));
map.fitBounds(bounds.pad(0.15), { maxZoom: 10 });
}
}

async function init(container) {
destroy();
const token = ++generation;
const shell = element('section', 'regions-page');
shell.appendChild(element('h1', '', 'Regions'));
shell.appendChild(element('p', 'regions-note', 'Inferred from positioned repeaters that recently relayed scoped packets. This is observed routing, not a radio coverage measurement or administrative region boundary.'));
const controls = element('div', 'regions-controls');
const label = element('label', '', 'Scope ');
const select = element('select');
select.setAttribute('aria-label', 'Region scope');
label.appendChild(select);
controls.appendChild(label);
shell.appendChild(controls);
const status = element('p', 'regions-status', 'Loading observed relay data…');
status.setAttribute('role', 'status');
shell.appendChild(status);
const mapEl = element('div', 'regions-leaflet');
mapEl.id = 'regionsLeaflet';
mapEl.setAttribute('aria-label', 'Observed region relay map');
shell.appendChild(mapEl);
container.replaceChildren(shell);
if (typeof L === 'undefined') {
status.textContent = 'Map library unavailable.';
return;
}
leafletMap = L.map(mapEl).setView([55.7, 10.5], 6);
const applyTiles = function () {
if (tileLayer) leafletMap.removeLayer(tileLayer);
const theme = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
const tile = typeof root.MC_getTileSpec === 'function' ? root.MC_getTileSpec(theme) : null;
tileLayer = L.tileLayer(tile && tile.url ? tile.url : 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: tile && tile.maxZoom ? tile.maxZoom : 19,
attribution: tile && tile.attribution ? tile.attribution : '© OpenStreetMap contributors',
}).addTo(leafletMap);
leafletMap.getPane('tilePane').style.filter = tile && tile.invertFilter ? tile.invertFilter : '';
};
let tileLayer = null;
applyTiles();
themeObserver = new MutationObserver(applyTiles);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const layer = L.layerGroup().addTo(leafletMap);
try {
const data = await fetchAllNodes('', { ttl: 60000, pageSize: 500, safetyCap: 5000 });
if (token !== generation || !leafletMap) return;
const points = collectScopePoints(data.nodes);
const catalog = scopeCatalog(data.nodes);
const scopes = catalog.scopes;
const truncated = data.total >= 5000;
select.appendChild(new Option('All observed scopes', ''));
for (const scope of scopes) select.appendChild(new Option(scope, scope));
let selected = parseSelectedScope(location.hash);
if (selected && !scopes.includes(selected)) {
const hasSelectedData = points.some(p => p.scopes.includes(selected));
select.appendChild(new Option((hasSelectedData ? 'Selected (outside list): ' : 'No recent data: ') + selected, selected));
}
select.value = selected;
renderPoints(layer, leafletMap, points, selected, status, truncated, catalog.truncated);
select.addEventListener('change', () => {
selected = select.value;
history.replaceState(null, '', scopeHash(selected));
renderPoints(layer, leafletMap, points, selected, status, truncated, catalog.truncated);
});
setTimeout(() => { if (token === generation && leafletMap) leafletMap.invalidateSize(); }, 0);
} catch (err) {
if (token === generation) status.textContent = 'Could not load observed relay data. Please try again.';
}
}

function destroy() {
generation++;
if (themeObserver) themeObserver.disconnect();
themeObserver = null;
if (leafletMap) leafletMap.remove();
leafletMap = null;
}

const internals = { validScope, collectScopePoints, listScopes, scopeCatalog, convexHull, parseSelectedScope, scopeHash };
if (typeof module !== 'undefined' && module.exports) module.exports = internals;
if (typeof root.registerPage === 'function') root.registerPage('regions', { init, destroy });
})(typeof window === 'undefined' ? globalThis : window);
8 changes: 8 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -5835,3 +5835,11 @@ body.embed #app.app-fixed {
.privacy-page ul { padding-left: 1.3em; }
.privacy-page li { margin: 6px 0; }
.privacy-page a { color: var(--link-color); }
/* Regions map: an observed-relay footprint, deliberately distinct from RF coverage. */
.regions-page { display: flex; flex-direction: column; height: 100%; min-height: 420px; padding: 16px; gap: 8px; }
.regions-page h1, .regions-page p { margin: 0; }
.regions-note { color: var(--text-muted); max-width: 85ch; }
.regions-controls { display: flex; gap: 12px; align-items: center; }
.regions-controls select { background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); padding: 6px; border-radius: 6px; }
.regions-status { color: var(--text-muted); min-height: 2.4em; }
.regions-leaflet { flex: 1; min-height: 280px; border: 1px solid var(--border); border-radius: 8px; }
13 changes: 13 additions & 0 deletions scripts/e2e-shards.json
Original file line number Diff line number Diff line change
Expand Up @@ -2301,6 +2301,19 @@
1.713,
1.626
]
},
{
"file": "test-regions-map-e2e.js",
"shard": 3,
"env": {
"CHROMIUM_REQUIRE": "1",
"BASE_URL": "$BASE_URL"
},
"output": "e2e",
"meanSeconds": 5,
"secondsByExecution": [
5
]
}
],
"exclusions": [
Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ run test-issue-1470-card-bg-contrast.js
run test-issue-1646-compare-polish.js
run test-perf-disk-io-1120.js
run test-table-sort.js
run test-regions-map.js

# test-all.sh self-test (#174)
run test-test-all.js
Expand Down
20 changes: 20 additions & 0 deletions test-e2e-playwright.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,26 @@ async function run() {
assert(nav, 'Nav bar not found');
});

await test('Regions map deep link renders observed repeaters without claiming RF coverage', async () => {
const repeater = (key, lat, lon) => ({ public_key: key, name: key, role: 'repeater', lat, lon,
transported_scopes_recent: ['#dk'], last_relayed: '2026-10-08T00:00:00Z' });
const nodes = [repeater('a', 55, 10), repeater('b', 55, 11), repeater('c', 56, 10)];
await page.route('**/api/nodes?*', route => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify({ nodes, counts: { repeater: 3 }, total: 3 }) }));
try {
await page.goto(`${BASE}/#/regions?scope=%23dk`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.regions-status');
await page.waitForFunction(() => document.querySelector('.regions-status')?.textContent.includes('3 positioned repeaters'));
assert(await page.locator('.regions-leaflet .leaflet-interactive').count() >= 3, 'repeater markers missing');
assert((await page.locator('.regions-status').textContent()).includes('not measured RF coverage'), 'coverage disclaimer missing');
assert(await page.locator('.regions-controls select').inputValue() === '#dk', 'scope deep link not selected');
await page.locator('.regions-controls select').selectOption('');
assert((await page.evaluate(() => location.hash)) === '#/regions', 'scope change not reflected in URL');
} finally {
await page.unroute('**/api/nodes?*');
}
});

// #1137 follow-up: Aldrich webfont must actually load so the navbar logo SVG
// renders in the intended typeface (not the silent monospace fallback).
await test('#1137 Aldrich webfont is loaded for navbar logo SVG', async () => {
Expand Down
7 changes: 4 additions & 3 deletions test-nav-priority-1391-e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
* Acceptance (from issue #1396 / #1400):
* - .nav-links must never render at a negative top offset (y >= 0).
* - In the ≤1100px force-collapse band on /#/channels, More must contain
* exactly the 5 non-active non-high routes; channels stays inline.
* exactly the non-active non-high routes; channels stays inline.
*
* Mutation guard: removing the "pin active inline" rule in applyNavPriority
* must make this test fail (active link gets overflowed at 1080px on /#/perf).
Expand All @@ -48,7 +48,8 @@ const HIGH_PRIORITY_HREFS = ['#/home', '#/packets', '#/map', '#/live', '#/nodes'
// "active pill is non-high" branch where the bug surfaces.
// #1396: extended to include /#/channels — operator screenshot at ~1024px
// showed the entire inline strip EMPTY and More containing only "Tools".
const NON_HIGH_ROUTES = ['#/perf', '#/audio-lab', '#/analytics', '#/observers', '#/channels'];
// Regions is another non-high-priority route and must obey the same contract.
const NON_HIGH_ROUTES = ['#/perf', '#/audio-lab', '#/analytics', '#/observers', '#/channels', '#/regions'];

// Operator screenshot was ~1080px. Cover the narrow-desktop CSS branch
// (≤1100) AND the measurement-loop branch (>1100) — bug reproduces in
Expand Down Expand Up @@ -197,7 +198,7 @@ async function main() {
// EXACTLY the non-active non-high routes so the channels link (when
// active) stays inline and is not orphaned in the dropdown.
if (w <= 1100) {
const ALL_NON_HIGH = ['#/channels', '#/tools', '#/observers', '#/analytics', '#/perf', '#/audio-lab'];
const ALL_NON_HIGH = ['#/regions', '#/channels', '#/tools', '#/observers', '#/analytics', '#/perf', '#/audio-lab'];
const expectedMore = ALL_NON_HIGH.filter(h => h !== expectedActive).sort();
assert.deepStrictEqual(
[...data.moreItems].sort(),
Expand Down
Loading
Loading