import { unpack } from 'https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/+esm'; import { createApp, reactive, ref, computed, watch, onMounted, markRaw, shallowRef } from '../lib/vue.esm-browser.js'; import * as ntools from './node-utils.js'; const apiUrl = 'https://map.meshcore.dev/api/v1/nodes?binary=1&short=1'; function uint8ArrayToHex(uint8arr) { const hexOctets = new Array(uint8arr.length); for (let i = 0; i < uint8arr.length; ++i) hexOctets[i] = ntools.byteToHex[uint8arr[i]]; return hexOctets.join(''); } let presets = []; const nodeKeys = { pk: { key: 'public_key', convert(val) { return uint8ArrayToHex(val) } }, t: { key: 'type', }, n: { key:'adv_name', }, la: { key:'last_advert', }, id: { key:'inserted_date', }, ud: { key:'updated_date', }, p: { key:'params', }, l: { key:'link', }, s: { key:'source', }, }; const types = { '1': 'Client', '2': 'Repeater', '3': 'Room Server', '4': 'Sensor' }; const updateStatusDesc = { 'none': 'manually added', 'recent': 'updated recently', 'stale': 'updated while ago', 'old': 'not updated', 'extinct': 'will be deleted soon' }; const radioParamDesc = { 'bw': { label: 'Bandwidth', unit: 'kHz' }, 'freq': { label: 'Frequency', unit: 'MHz', }, 'sf': { label: 'Spreading factor', unit: '', }, 'cr': { label: 'Coding rate', unit: '', }, }; const columnOrder = ['adv_name', 'public_key', 'type', 'status', 'link', 'inserted_date', 'updated_date', 'coords', 'preset', 'params' ]; const columns = { coords: { label: 'Coordinates', value: (val) => `${val}` }, adv_name: { label: 'Name', value: (val) => escape(val) }, status: { label: 'Freshness', value: (val) => updateStatusDesc[val] }, inserted_date: { label: 'Inserted', value: (val) => { const dt = new Date(val); return `` } }, updated_date: { label: 'Updated', value: (val) => { const dt = new Date(val); return `` } }, public_key: { label: 'Public key', }, type: { label: 'Type', value: (val) => types[val] }, preset: { label: 'Radio preset', value: (val) => { const preset = findPreset(val) || {}; console.log({ val, preset }); return preset?.params?.freq ? preset.name : 'Custom' } }, params: { label: 'Radio params', value: (val) => (Object.entries(val).map(([key, val]) => { const paramKey = radioParamDesc[key]; return escape(`${paramKey.label}: ${val}${paramKey.unit}`) }).join('
') ) }, link: { label: 'Meshcore link', value: (uint8arr) => `Copy to clipboard` }, }; function timeAgo(msec) { const seconds = Math.floor((Date.now() - msec) / 1000); const units = [ { name: 'year', limit: 31536000 }, { name: 'month', limit: 2592000 }, { name: 'day', limit: 86400 }, { name: 'hour', limit: 3600 }, { name: 'minute', limit: 60 }, { name: 'second', limit: 1 } ]; for (const unit of units) { const count = Math.floor(seconds / unit.limit); if (count >= 1) { return `${count} ${unit.name}${count > 1 ? 's' : ''} ago`; } } return 'just now'; } function escape(html) { return html.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`) } const svgIconCache = new Map(); function getSvgIcon(text, color) { const cacheKey = text + '|' + color; let icon = svgIconCache.get(cacheKey); if (icon) return icon; icon = L.divIcon({ html: `${text}`, className: 'svg-node-icon', iconSize: [32, 32], iconAnchor: [17, 17], popupAnchor: [0, -16], }); svgIconCache.set(cacheKey, icon); return icon; } function clearLocationHash () { history.pushState('', document.title, location.pathname + location.search); } function getTable(node) { return ''+ '' + columnOrder.flatMap(key => node[key] ? [``] : [] ).join('') + ''+ '
${columns[key].label}${ columns[key].value ? columns[key].value(node[key]) : node[key] }
'; } function getNodePopupHTML(node) { const userActionUrl = encodeURI(localStorage.getItem('userActionUrl') || ''); const userActionLabel = localStorage.getItem('userActionLabel') || ''; const userActionAnchor = userActionUrl ? ` ${userActionLabel} ` : ''; return ` ${getTable(node)}
Request node deletion ${userActionAnchor}
`; } async function getPresets() { if(presets.length) return presets; const res = await fetch('https://api.meshcore.nz/api/v1/config'); const presetsApi = (await res.json()).config.suggested_radio_settings.entries; presets = presetsApi.map(p => ({ name: p.title, desc: p.description, params: { freq: p.frequency, bw: p.bandwidth, sf: p.spreading_factor, cr: p.coding_rate } })); presets.unshift({ name: 'All presets', params: {} }); return presets; } function findPreset(params) { return presets.find(p => params.sf == p.params.sf && params.freq == p.params.freq && params.bw == p.params.bw ) ?? {} } window.isNewerThan = (date, days) => { const daysMs = 1000 * 3600 * 24 * days; const dateMs = new Date(date).getTime(); return dateMs > Date.now() - daysMs; } function getDeletionMailUrl(node) { const deletionMailUrl = new URL('mailto:recrof@gmail.com'); deletionMailUrl.searchParams.append('subject', 'MeshCore Map node deletion request'); deletionMailUrl.searchParams.append('body', [ 'Please delete my node(s) from MeshCore Map database', 'MeshCore link(s) or Public key(s):', '', node ? node.public_key : '', '', '*** IMPORTANT ***', 'if you have multiple nodes to delete, put them into single email, delimited by newline. public key is enough, you don\'t need to add name or screenshot of the node.', ].join('\n') ); return deletionMailUrl.toString().replaceAll('+', '%20').replaceAll('\n', '%0A'); } const appAttribution = ` App: recrof, support my work | Node deletion request `; const baseMapSelected = localStorage.getItem('baseMapSelected') || 'OpenStreetMap'; const baseMaps = { 'OpenStreetMap': L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: `Tiles: © OpenStreetMap | ${appAttribution}` }), 'Esri Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { maxZoom: 18, attribution: `Tiles: © Esri | Sources: Esri, DigitalGlobe, GeoEye, i-cubed, USDA FSA, USGS, AEX, Getmapping, Aerogrid, IGN, IGP, swisstopo, GIS Users | ${appAttribution}`, }), }; let params = { lat: 7, lon: 25, zoom: 3 }; const urlParams = Object.fromEntries(new URLSearchParams(location.search)); if(Number(urlParams.lat) && Number(urlParams.lon) && Number(urlParams.zoom)) { params = urlParams } // console.log(params); const map = window.leafletMap = leaflet.map('map', { minZoom: 2, maxBounds: [ [-90, -180], // top left [90, 200], // bottom right ], layers: baseMaps[baseMapSelected], zoomControl: false }).setView([params.lat, params.lon], params.zoom); map.on('baselayerchange', function(ev) { localStorage.setItem('baseMapSelected', ev.name); }); L.control.layers(baseMaps, null, { position: 'bottomleft' }).addTo(map); // map.zoomControl.setPosition('bottomleft'); const icons = Object.fromEntries(['none', 'recent', 'stale', 'old', 'extinct'].map(color => [color, Object.fromEntries([1, 2, 3, 4].map(id => [id, L.icon({ iconUrl: `img/node_types/${id}.svg`, iconSize: [32, 32], iconAnchor: [17, 17], popupAnchor: [0, -16], className: `update-${color}` })])) ])); createApp({ setup() { const app = window.app = reactive({ search: '', link: '', nodeFilter: [], freqFilter: [], availableFreqs: [], fromDate: '', fromInsertDate: '', clusteringZoom: 12, urlParams, presets, presetIndex: 0, loading: false, }); // Keep large arrays outside Vue's deep reactivity const nodesRef = shallowRef([]); const nodesByTypeRef = shallowRef({}); const filteredNodesRef = shallowRef([]); // Proxy access so templates/watchers can use app.nodes etc. Object.defineProperty(app, 'nodes', { get: () => nodesRef.value, set: (v) => { nodesRef.value = v; }, }); Object.defineProperty(app, 'nodesByType', { get: () => nodesByTypeRef.value, set: (v) => { nodesByTypeRef.value = v; }, }); Object.defineProperty(app, 'filteredNodes', { get: () => filteredNodesRef.value, set: (v) => { filteredNodesRef.value = v; }, }); function attachClusterClickHandler(group) { group.on('click', function(e) { const marker = e.layer; if(marker) { ensurePopup(marker); marker.openPopup(); } }); } async function refreshMap({ clusteringZoom = 0 } = {}) { markerClusterGroup.clearLayers(); const nodes = app.filteredNodes.length > 0 ? app.filteredNodes : app.nodes; map.removeLayer(markerClusterGroup); if(clusteringZoom) { markerClusterGroup = L.markerClusterGroup({ disableClusteringAtZoom: clusteringZoom, chunkedLoading: true, }); attachClusterClickHandler(markerClusterGroup); } const markers = new Array(nodes.length); for(let i = 0; i < nodes.length; i++) { markers[i] = nodes[i].marker; } markerClusterGroup.addLayers(markers); map.addLayer(markerClusterGroup); } function showNode(node) { ensurePopup(node.marker); node.marker.openPopup(); map.flyTo(node.marker.getLatLng(), 19); app.search = ''; } function highlightString(source, toHighlight) { const escapedSource = source.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); const matchIndex = source.toLowerCase().indexOf(toHighlight.toLowerCase()); const highlightString = matchIndex >= 0 ? source.substring(matchIndex, matchIndex + toHighlight.length) : toHighlight; return escapedSource.replace(highlightString, `${highlightString}`); } function clearFilters() { app.nodeFilter = [1, 2, 3, 4]; app.freqFilter = []; app.fromDate = '2025-03-01'; app.fromInsertDate = '2025-03-01'; app.cluster = 12; app.presetIndex = 0; } function getDaysEpochMsec(days) { return days * 24 * 60 * 60 * 1000; } const _now = Date.now(); const _extinct = _now - getDaysEpochMsec(20); const _old = _now - getDaysEpochMsec(10); const _stale = _now - getDaysEpochMsec(5); function getNodeUpdateStatus(node) { if(node.source[0] !== 'u') return 'none'; const updateEpoch = new Date(node.updated_date).getTime(); if(updateEpoch < _extinct) return 'extinct'; if(updateEpoch < _old) return 'old'; if(updateEpoch < _stale) return 'stale'; return 'recent'; } function inflateNode(node) { for(const key of Object.keys(node)) { if(!nodeKeys[key]) continue; const convertFn = nodeKeys[key].convert; node[nodeKeys[key].key] = typeof convertFn === 'function' ? convertFn(node[key]) : node[key]; delete node[key] } } async function downloadNodes() { try { app.loading = true; const nodesReq = await fetch(apiUrl); const nodesBlob = await nodesReq.blob(); const nodes = unpack(await nodesBlob.arrayBuffer()); getPresets().then((presets) => { app.presets = presets; }); const byType = {}; const freqSet = new Set(); const CHUNK_SIZE = 2000; for(let offset = 0; offset < nodes.length; offset += CHUNK_SIZE) { const end = Math.min(offset + CHUNK_SIZE, nodes.length); // yield to browser between chunks so UI stays responsive if(offset > 0) { await new Promise(r => setTimeout(r, 0)); } for(let i = offset; i < end; i++) { const node = nodes[i]; inflateNode(node); const updateStatus = getNodeUpdateStatus(node); let icon = icons[updateStatus][node.type.toString()]; (byType[node.type] ??= []).push(node); if(node.type === 1) { const label = ntools.getNameIconLabel(node.adv_name); const color = ntools.getColourForName(node.adv_name); icon = getSvgIcon(label, color); } const marker = node.marker = markRaw(L.marker( [node.lat, node.lon], { icon, title: node.adv_name } )); node.status = updateStatus; node.preset = node.params; node.coords = `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`; node.lastAdvertDate = new Date(node.last_advert); node.insertDate = new Date(node.inserted_date); node.updatedDate = node.updated_date && new Date(node.updated_date); markerToNode.set(marker, node); if(node.params?.freq) freqSet.add(Math.floor(node.params.freq)); } } nodesByTypeRef.value = byType; nodesRef.value = nodes; app.availableFreqs = [...freqSet].sort((a, b) => a - b); } catch(e) { alert('There was an error loading map nodes:', e); console.log(e); } finally { app.loading = false; } } clearFilters(); const filtersActive = computed(() => app.filteredNodes.length && app.nodes.length !== app.filteredNodes.length); watch( [ () => app.nodeFilter, () => app.freqFilter, () => app.fromDate, () => app.fromInsertDate, ], () => { const fromDate = new Date(app.fromDate); const fromInsertDate = new Date(app.fromInsertDate); const byType = nodesByTypeRef.value; const hasFreqFilter = app.freqFilter.length > 0; const freqSet = hasFreqFilter ? new Set(app.freqFilter) : null; const result = []; for(const type of app.nodeFilter) { const typeNodes = byType[type]; if(!typeNodes) continue; for(let i = 0; i < typeNodes.length; i++) { const node = typeNodes[i]; if(node.updatedDate ? node.updatedDate <= fromDate : node.insertDate <= fromDate) continue; if(node.insertDate <= fromInsertDate) continue; if(hasFreqFilter && !(node.params?.freq && freqSet.has(Math.floor(node.params.freq)))) continue; result.push(node); } } filteredNodesRef.value = result; console.log('refresh', app.nodeFilter, result.length); app.urlParams.nodes = app.nodeFilter.join(','); app.urlParams.freq = app.freqFilter.join(','); app.urlParams.date = app.fromDate; app.urlParams.dateInsert = app.fromInsertDate; refreshMap({ download: false }); } ); watch(() => app.clusteringZoom, () => { app.urlParams.cluster = app.clusteringZoom; refreshMap({ download: false, clusteringZoom: app.clusteringZoom }); }); const stats = computed(() => { const nodes = app.nodes; if(!nodes || !nodes.length) return []; const byType = app.nodesByType; const now = Date.now(); const msPerDay = 86400000; const t1 = now - msPerDay; const t7 = now - 7 * msPerDay; const t30 = now - 30 * msPerDay; let c1 = 0, c7 = 0, c30 = 0; for(let i = 0; i < nodes.length; i++) { const insertMs = nodes[i].insertDate.getTime(); if(insertMs > t1) c1++; if(insertMs > t7) c7++; if(insertMs > t30) c30++; } const result = []; result.push(` total: ${nodes.length} | person${(byType[1] || []).length} | cell_tower${(byType[2] || []).length} | forum${(byType[3] || []).length} `); result.push(`24h: ${c1}`); result.push(`7d: ${c7}`); result.push(`30d: ${c30}`); return result; }); const searchResults = computed(() => { if(!app.search) { return [] } const nodes = app.filteredNodes.length > 0 ? app.filteredNodes : app.nodes; const results = nodes.filter( node => node.adv_name.toLowerCase().includes(app.search.toLowerCase()) || node.public_key.startsWith(app.search) ).toSorted( (a, b) => a.adv_name.localeCompare(b.adv_name) ).slice(0, 20); return results; }); let markerClusterGroup = L.markerClusterGroup({ disableClusteringAtZoom: app.clusteringZoom, chunkedLoading: true, }); // Map markers back to nodes for lazy popup binding const markerToNode = new WeakMap(); // Lazy popup binding — only create popup when a marker is clicked function ensurePopup(marker) { if(!marker._popupBound) { const node = markerToNode.get(marker); if(node) { marker.bindPopup( markRaw(L.popup({ minWidth: 350, maxWidth: 350, content: () => getNodePopupHTML(node) })) ); marker._popupBound = true; } } } watch( () => app.urlParams, () => { history.replaceState({}, '', `/?${new URLSearchParams(app.urlParams)}`); }, { deep: true } ); attachClusterClickHandler(markerClusterGroup); map.on('moveend', function(e) { const pos = map.getCenter(); const zoom = map.getZoom(); app.urlParams.zoom = zoom; app.urlParams.lat = pos.lat.toFixed(4); app.urlParams.lon = pos.lng.toFixed(4); }); onMounted(() => { downloadNodes().then(() => { if(urlParams.nodes) { app.nodeFilter = urlParams.nodes.split(','); } if(urlParams.freq) { app.freqFilter = urlParams.freq.split(',').map(Number); } if(urlParams.date) { app.fromDate = urlParams.date } if(urlParams.dateInsert) { app.fromInsertDate = urlParams.dateInsert } if(urlParams.cluster) { app.clusteringZoom = urlParams.cluster; } refreshMap(); }) }) window.refreshMap = refreshMap; return { app, refreshMap, stats, searchResults, filtersActive, showNode, highlightString, clearFilters } }, }).mount('#app')