diff --git a/public/app.js b/public/app.js
index baddab39a..1cc473bf8 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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.
diff --git a/public/index.html b/public/index.html
index 13af68e7c..67ae15620 100644
--- a/public/index.html
+++ b/public/index.html
@@ -133,6 +133,7 @@
Home
Packets
Map
+ Regions
Live
Channels
Nodes
@@ -212,6 +213,7 @@
+
diff --git a/public/regions-map.js b/public/regions-map.js
new file mode 100644
index 000000000..469d32ef3
--- /dev/null
+++ b/public/regions-map.js
@@ -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);
diff --git a/public/style.css b/public/style.css
index 500850d5c..024250e8d 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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; }
diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json
index e5b4c78d0..82fcac402 100644
--- a/scripts/e2e-shards.json
+++ b/scripts/e2e-shards.json
@@ -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": [
diff --git a/test-all.sh b/test-all.sh
index 1f510f1aa..a2f120d82 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -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
diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js
index 79fbbfb4f..33a815183 100644
--- a/test-e2e-playwright.js
+++ b/test-e2e-playwright.js
@@ -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 () => {
diff --git a/test-nav-priority-1391-e2e.js b/test-nav-priority-1391-e2e.js
index 546ec5880..aeef19fbf 100644
--- a/test-nav-priority-1391-e2e.js
+++ b/test-nav-priority-1391-e2e.js
@@ -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).
@@ -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
@@ -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(),
diff --git a/test-regions-map-e2e.js b/test-regions-map-e2e.js
new file mode 100644
index 000000000..da2df88e1
--- /dev/null
+++ b/test-regions-map-e2e.js
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+/* Regions map page (#376): escaping, theme switching, teardown.
+ * Mutation guards:
+ * - bindPopup(html string) / innerHTML of node name -> XSS test fails
+ * - removing the data-theme MutationObserver -> theme test fails
+ * - removing observer.disconnect() in destroy() -> teardown test fails
+ */
+'use strict';
+const assert = require('node:assert');
+const { chromium } = require('playwright');
+
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+const EVIL = '
';
+
+function nodes() {
+ const mk = (key, name, lat, lon) => ({ public_key: key, name, role: 'repeater', lat, lon,
+ transported_scopes_recent: ['#dk'], last_relayed: '2026-10-08T00:00:00Z' });
+ return [mk('aa11', EVIL, 55, 10), mk('bb22', 'b', 55, 11), mk('cc33', 'c', 56, 10)];
+}
+
+async function main() {
+ let browser;
+ try {
+ browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined,
+ args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });
+ } catch (err) {
+ if (process.env.CHROMIUM_REQUIRE === '1') { console.error('FAIL — Chromium required: ' + err.message); process.exit(1); }
+ console.log('SKIP (Chromium unavailable)'); process.exit(0);
+ }
+ const ctx = await browser.newContext();
+ const page = await ctx.newPage();
+ page.setDefaultTimeout(15000);
+ const errors = [];
+ page.on('pageerror', e => errors.push(String(e)));
+ await page.route('**/api/nodes?*', r => r.fulfill({ status: 200, contentType: 'application/json',
+ body: JSON.stringify({ nodes: nodes(), counts: { repeater: 3 }, total: 3 }) }));
+ let failures = 0;
+ const test = async (name, fn) => {
+ try { await fn(); console.log(' ✓ ' + name); } catch (e) { failures++; console.log(' ✗ ' + name + ': ' + e.message); }
+ };
+ // Track MutationObservers watching data-theme so teardown can be asserted.
+ await page.addInitScript(() => {
+ window.__themeObservers = [];
+ const Orig = window.MutationObserver;
+ window.MutationObserver = class extends Orig {
+ observe(target, opts) {
+ if (target === document.documentElement && opts && opts.attributeFilter && opts.attributeFilter.includes('data-theme')) {
+ const rec = { live: true, page: /regions-map\.js/.test(new Error().stack) ? 'regions' : 'other' };
+ window.__themeObservers.push(rec); this.__rec = rec;
+ }
+ return super.observe(target, opts);
+ }
+ disconnect() { if (this.__rec) this.__rec.live = false; return super.disconnect(); }
+ };
+ });
+ const open = async (theme) => {
+ await page.addInitScript(t => { try { localStorage.setItem('meshcore-theme', t); } catch (e) {} }, theme);
+ await page.goto(`${BASE}/#/regions?scope=%23dk`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => document.querySelector('.regions-status')?.textContent.includes('3 positioned'));
+ };
+
+ await open('dark');
+
+ await test('node name is escaped in the popup', async () => {
+ await page.evaluate(() => { document.querySelectorAll('.regions-leaflet path.leaflet-interactive').forEach(p => p.dispatchEvent(new MouseEvent('click', { bubbles: true }))); });
+ await page.waitForSelector('.leaflet-popup-content');
+ assert.strictEqual(await page.evaluate(() => window.__xss), undefined, 'injected handler ran');
+ assert.strictEqual(await page.locator('.leaflet-popup-content img').count(), 0, 'name parsed as HTML');
+ });
+
+ await test('page keeps the observed-routing disclaimer', async () => {
+ const t = await page.locator('.regions-note').textContent();
+ assert(/not a radio coverage measurement/.test(t), t);
+ });
+
+ await test('switching theme re-resolves the tile layer', async () => {
+ const dark = await page.evaluate(() => window.MC_getTileSpec('dark'));
+ const light = await page.evaluate(() => window.MC_getTileSpec('light'));
+ await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
+ await page.waitForFunction(() => {
+ const f = document.querySelector('.regions-leaflet .leaflet-tile-pane').style.filter;
+ return !f;
+ }, null, { timeout: 3000 }).catch(() => {});
+ const state = await page.evaluate(() => ({
+ filter: document.querySelector('.regions-leaflet .leaflet-tile-pane').style.filter,
+ src: (document.querySelector('.regions-leaflet .leaflet-tile') || {}).src || '',
+ }));
+ if (dark.invertFilter) assert.strictEqual(state.filter, '', 'dark invert filter left on light theme');
+ if (dark.url !== light.url) {
+ const host = new URL(light.url.replace('{s}', 'a')).host;
+ await page.waitForFunction(h => [...document.querySelectorAll('.regions-leaflet .leaflet-tile')].some(i => i.src.includes(h)), host, { timeout: 5000 });
+ }
+ });
+
+ await test('destroy() tears down the map and the theme observer', async () => {
+ await page.evaluate(() => { location.hash = '#/nodes'; });
+ await page.waitForFunction(() => !document.querySelector('.regions-leaflet'));
+ assert.strictEqual(await page.locator('.leaflet-container').count(), 0, 'leaflet container left behind');
+ errors.length = 0;
+ await page.evaluate(() => { document.documentElement.setAttribute('data-theme', 'dark'); });
+ await page.waitForTimeout(200);
+ assert.deepStrictEqual(errors, [], 'theme change after destroy raised errors');
+ const live = await page.evaluate(() => window.__themeObservers.filter(o => o.live && o.page === 'regions').length);
+ assert.strictEqual(live, 0, 'regions theme observer still connected');
+ });
+
+ await browser.close();
+ if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
+ console.log('regions map e2e passed');
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/test-regions-map.js b/test-regions-map.js
new file mode 100644
index 000000000..77295cb7d
--- /dev/null
+++ b/test-regions-map.js
@@ -0,0 +1,30 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const regions = require('./public/regions-map.js');
+
+const sample = [
+ { public_key: 'a', name: 'A', role: 'repeater', lat: 55, lon: 10, transported_scopes_recent: ['#dk'] },
+ { public_key: 'b', name: 'B', role: 'room', lat: 55, lon: 11, transported_scopes_recent: ['#dk'] },
+ { public_key: 'c', name: 'C', role: 'repeater', lat: 56, lon: 10, transported_scopes_recent: ['#dk'] },
+ { public_key: 'd', name: 'D', role: 'companion', lat: 57, lon: 10, transported_scopes_recent: ['#dk'] },
+ { public_key: 'e', name: 'E', role: 'repeater', lat: 0, lon: 0, transported_scopes_recent: ['#dk'] },
+ { public_key: 'f', name: 'F', role: 'repeater', lat: 55, lon: 10, transported_scopes: ['#old'] },
+];
+
+assert.deepEqual(regions.collectScopePoints(sample).map(p => p.publicKey), ['a', 'b', 'c']);
+assert.deepEqual(regions.listScopes(sample), ['#dk']);
+assert.equal(regions.validScope('#DK$'), true);
+assert.equal(regions.validScope('#ø'), true);
+assert.equal(regions.validScope('#bad:scope'), false);
+assert.equal(regions.validScope('#bad ({ ...sample[0], public_key: 'p' + i, transported_scopes_recent: ['#r' + i] }));
+assert.equal(regions.scopeCatalog(manyScopes).truncated, true);
+assert.equal(regions.scopeCatalog(manyScopes).scopes.length, 128);
+assert.deepEqual(regions.convexHull([[10, 55], [11, 55], [10, 56], [10.2, 55.2]]), [[10, 55], [11, 55], [10, 56]]);
+assert.deepEqual(regions.convexHull([[10, 55], [11, 55]]), []);
+assert.equal(regions.parseSelectedScope('#/regions?scope=%23dk'), '#dk');
+assert.equal(regions.parseSelectedScope('#/regions?scope=%3Cbad%3E'), '');
+assert.equal(regions.scopeHash('#dk'), '#/regions?scope=%23dk');
+
+console.log('regions map unit tests passed');