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');