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: ``,
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] ? [`| ${columns[key].label} | ${ columns[key].value ? columns[key].value(node[key]) : node[key] } | `] : [] ).join('
') + '
'+
'
';
}
function getNodePopupHTML(node) {
const userActionUrl = encodeURI(localStorage.getItem('userActionUrl') || '');
const userActionLabel = localStorage.getItem('userActionLabel') || '';
const userActionAnchor = userActionUrl ? `
${userActionLabel}
` : '';
return `
${getTable(node)}
`;
}
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')