import { createApp, reactive, ref, computed, watch, onMounted, toRaw } from '../lib/vue.esm-browser.js'; import * as ntools from './node-utils.js'; const apiUrl = 'https://map.meshcore.dev/api/v1/nodes'; const types = { '1': 'Client', '2': 'Repeater', '3': 'Room Server', '4': 'Sensor' }; const columnOrder = ['adv_name', 'type', 'link', 'inserted_date', 'updated_date', 'public_key', 'coords', 'params' ]; const columns = { coords: { label: 'Coordinates', value: (val) => `${val}` }, adv_name: { label: 'Name' }, inserted_date: { label: 'Inserted date', value: (val) => new Date(val).toLocaleString() }, updated_date: { label: 'Updated date', value: (val) => new Date(val).toLocaleString() }, public_key: { label: 'Public key' }, type: { label: 'Node type', value: (val) => types[val] }, params: { label: 'Radio params', value: (val) => Object.entries(val).map(([key, val]) => `${key}=${val}`).join(', ') }, link: { label: 'Meshcore link', value: (val) => `Copy to clipboard` }, }; function getSvgIconUrl(text, color) { const svg = ` ${text} `; return L.icon({ iconUrl: URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })), iconSize: [32, 32], iconAnchor: [17, 17], popupAnchor: [0, -16], }); } 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] }
'; } window.isNewerThan = (date, days) => { const daysMs = 1000 * 3600 * 24 * days; const dateMs = new Date(date).getTime(); return dateMs > Date.now() - daysMs; } 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 from MeshCore Map database\n'+ 'MeshCore link: \n' ); 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([1, 2, 3, 4].map(id => [id, L.icon({ iconUrl: `img/node_types/${id}.svg`, iconSize: [32, 32], iconAnchor: [17, 17], popupAnchor: [0, -16], })])); createApp({ setup() { const dialogAddNode = ref(); const app = window.app = reactive({ nodes: [], nodesByType: {}, filteredNodes: [], search: '', link: '', nodeFilter: [], fromDate: '', clusteringZoom: 12, urlParams }); 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 }); } for(const node of nodes) { markerClusterGroup.addLayer(toRaw(node.marker)); } map.addLayer(markerClusterGroup); } async function addNode() { if(!(app.link && app.link.startsWith('meshcore://'))) { alert('Please paste valid meshcore link.'); return; }; const res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ links: [ app.link ], radio: {} }) }); const reply = await res.json(); alert(reply.message || reply.error); clearLocationHash(); location.reload(); } function showNode(node) { 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.fromDate = '2025-03-01'; app.cluster = 12; } async function downloadNodes() { const nodesReq = await fetch(apiUrl); app.nodes = await nodesReq.json(); for(const node of app.nodes) { let icon = icons[node.type.toString()]; (app.nodesByType[node.type] ??= []).push(node); if(node.type === 1) { const label = ntools.getNameIconLabel(node.adv_name); const color = ntools.getColourForName(node.adv_name); icon = getSvgIconUrl(label, color); } const marker = node.marker = L.marker( [node.adv_lat, node.adv_lon], { icon, title: node.adv_name } ); node.coords = `${node.adv_lat.toFixed(4)}, ${node.adv_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); const popup = L.popup({ minWidth: 350, maxWidth: 350, content: getTable(node) }); marker.bindPopup(popup); } } clearFilters(); const filtersActive = computed(() => app.filteredNodes.length && app.nodes.length !== app.filteredNodes.length); watch( [ () => app.nodeFilter, () => app.fromDate, ], () => { const fromDate = new Date(app.fromDate); app.filteredNodes = app.nodeFilter .flatMap(type => app.nodesByType[type]) .filter(node => node && (node.updatedDate ? node.updatedDate > fromDate : node.insertDate > fromDate)); console.log('refresh', app.nodeFilter, app.filteredNodes.length); app.urlParams.nodes = app.nodeFilter.join(','); app.urlParams.date = app.fromDate; 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) return []; const result = []; result.push(` total: ${nodes.length} | person${nodes.filter(n => n.type === 1).length} | cell_tower${nodes.filter(n => n.type === 2).length} | forum${nodes.filter(n => n.type === 3).length} `); result.push(`24h: ${app.nodes.filter(n => isNewerThan(n.inserted_date, 1)).length}`); result.push(`7d: ${app.nodes.filter(n => isNewerThan(n.inserted_date, 7)).length}`); result.push(`30d: ${app.nodes.filter(n => isNewerThan(n.inserted_date, 30)).length}`); return result; }); const searchResults = computed(() => { if(!app.search) { return [] } return app.filteredNodes.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); }); let markerClusterGroup = L.markerClusterGroup({ disableClusteringAtZoom: app.clusteringZoom }); watch( () => app.urlParams, () => { history.replaceState({}, '', `/?${new URLSearchParams(app.urlParams)}`); }, { deep: true } ); 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.date) { app.fromDate = urlParams.date } if(urlParams.cluster) { app.clusteringZoom = urlParams.cluster; } refreshMap(); }) if(location.hash === '#add-new-node') { dialogAddNode.value.showModal(); dialogAddNode.value.addEventListener('close', () => clearLocationHash()); } }) window.refreshMap = refreshMap; return { app, refreshMap, addNode, stats, searchResults, filtersActive, showNode, dialogAddNode, highlightString, clearFilters } }, }).mount('#app')