mirror of
https://github.com/meshcore-dev/map.meshcore.dev.git
synced 2026-04-20 22:13:50 +00:00
loader, node nick colors, node emoji icons, fresh indicator
This commit is contained in:
parent
21103bf4fa
commit
06dfdd0469
5 changed files with 511 additions and 171 deletions
|
|
@ -22,6 +22,21 @@ body:has(dialog[open])::after {
|
|||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
body:has(#app .loading)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1049;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
backdrop-filter: grayscale(1) blur(2px);
|
||||
background-image: url('../img/loader.svg');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 64px;
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
z-index: 1050 !important;
|
||||
}
|
||||
|
|
@ -165,21 +180,17 @@ table.node-info tr td:last-child {
|
|||
.search-text b {
|
||||
background-color: #ee0;
|
||||
}
|
||||
button.manual-add {
|
||||
.manual-add {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: block;
|
||||
padding: 12px !important;
|
||||
bottom: 55px;
|
||||
right: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
button.manual-add::after {
|
||||
font-size: 40px;
|
||||
content: '+';
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.leaflet-bottom {
|
||||
position: fixed !important;
|
||||
|
|
@ -204,3 +215,33 @@ button.manual-add::after {
|
|||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.update-recent {
|
||||
filter: saturate(5) hue-rotate(260deg)
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.update-stale {
|
||||
filter: saturate(28) hue-rotate(228deg)
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.update-old {
|
||||
filter: saturate(5) hue-rotate(165deg);
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.update-extinct {
|
||||
filter: grayscale(100) contrast(16);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
time {
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
}
|
||||
5
img/loader.svg
Normal file
5
img/loader.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z">
|
||||
<animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
106
index.html
106
index.html
|
|
@ -13,38 +13,11 @@
|
|||
<title>MeshCore Node Map</title>
|
||||
</head>
|
||||
<body class="light">
|
||||
|
||||
<div class="container">
|
||||
<div id="map"></div>
|
||||
<div id="app" v-if="app.nodes" v-cloak>
|
||||
<dialog class="add-dialog background no-round" ref="dialogAddNode">
|
||||
<h6>Add node / Replace node</h6>
|
||||
<div>
|
||||
<div class="tabs">
|
||||
<a class="active" data-ui="#add-self" tabindex="0">Your Node</a>
|
||||
<a data-ui="#add-contact" tabindex="0">From Contacts</a>
|
||||
</div>
|
||||
<div class="page active" id="add-self">
|
||||
<img src="./img/button_self.jpg">
|
||||
<img src="./img/share_self.jpg">
|
||||
</div>
|
||||
<div class="page" id="add-contact">
|
||||
<img src="./img/button_contact.jpg">
|
||||
<img src="./img/share_contact.jpg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="small-space"></div>
|
||||
<div>
|
||||
Please paste your meshcore link.<br>
|
||||
If you use link with same public key as node already on the map, it will get replaced.
|
||||
</div>
|
||||
<div class="field border"><input placeholder="meshcore:// link" v-model="app.link"></div>
|
||||
<nav class="right-align">
|
||||
<button class="transparent link" @click="dialogAddNode.close()">Cancel</button>
|
||||
<button class="transparent link" @click="addNode">Submit</button>
|
||||
</nav>
|
||||
</dialog>
|
||||
<button class="manual-add square round light-blue extra" title="add node from meshcore:// link" @click="dialogAddNode.showModal()"><i class="extra">add</i></button>
|
||||
<div id="app" v-cloak>
|
||||
<div class="loading" v-if="app.loading"></div>
|
||||
<a class="manual-add square round light-blue extra" title="Add node to map" target="_blank" href="https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md#512-q-how-do-i-add-a-node-to-the-meshcore-map"><i class="extra">add</i></a>
|
||||
<div class="stats">
|
||||
<span title="Stats">
|
||||
<i>monitoring</i>
|
||||
|
|
@ -57,33 +30,58 @@
|
|||
</a>
|
||||
</div>
|
||||
<form class="search no-margin" action="javascript:;">
|
||||
<menu class="left no-wrap" id="node-filter" data-ui="#node-filter">
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="1" v-model="app.nodeFilter"><span>Clients</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="2" v-model="app.nodeFilter"><span>Repeaters</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="3" v-model="app.nodeFilter"><span>Room Servers</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="4" v-model="app.nodeFilter"><span>Sensors</span>
|
||||
</label>
|
||||
</li>
|
||||
<!--li v-if="app.presets.length > 0">
|
||||
<label class="select">
|
||||
<select v-model="app.presetIndex">
|
||||
<option v-for="(preset, i) of app.presets" :value="i">{{ preset.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</li-->
|
||||
<li class="padding">
|
||||
<div class="field label prefix fill small">
|
||||
<i>today</i>
|
||||
<input type="date" v-model="app.fromDate" placeholder=" ">
|
||||
<label>Last updated</label>
|
||||
</div>
|
||||
</li>
|
||||
<li style="flex-direction:column;gap:0">
|
||||
<span>Clustering zoom level</span>
|
||||
<label class="slider tiny">
|
||||
<input min="10" max="17" type="range" v-model="app.clusteringZoom">
|
||||
<span></span>
|
||||
</label>
|
||||
</li>
|
||||
<li v-if="filtersActive">
|
||||
<button class="small max" @click="clearFilters">Clear filters</button>
|
||||
</li>
|
||||
</menu>
|
||||
<div class="field border no-margin">
|
||||
<input type="text" class="background" list="nodes" v-model="app.search" placeholder="Search Nodes">
|
||||
<!--button class="filter circle transparent" data-ui="#node-filter">
|
||||
<input type="text" class="background" list="nodes" v-model="app.search" placeholder="Search Nodes" v-if="app.nodes">
|
||||
<button type="button" class="filter" :class="{ circle: !filtersActive, transparent: !filtersActive, 'error-container': filtersActive }" data-ui="#node-filter">
|
||||
<i>filter_alt</i>
|
||||
<span v-if="filtersActive">Filters active</span>
|
||||
</button>
|
||||
<menu class="left no-wrap" id="node-filter" data-ui="#node-filter">
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="1" v-model="app.nodeFilter"><span>Clients</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="2" v-model="app.nodeFilter"><span>Repeaters</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="3" v-model="app.nodeFilter"><span>Room Servers</span>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" value="3" v-model="app.nodeFilter"><span>Sensors</span>
|
||||
</label>
|
||||
</li>
|
||||
</menu-->
|
||||
</div>
|
||||
<article class="search-results no-margin no-padding" v-if="searchResults?.length > 0">
|
||||
<ul class="list no-space border">
|
||||
|
|
|
|||
485
src/map.js
485
src/map.js
|
|
@ -1,17 +1,8 @@
|
|||
import { createApp, reactive, ref, computed, onMounted } from '../lib/vue.esm-browser.js';
|
||||
|
||||
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 keyOrder = ['adv_name', 'type', 'link', 'inserted_date', 'updated_date', 'public_key', 'coords', 'params' ]
|
||||
const humanLabel = {
|
||||
coords: 'Coordinates',
|
||||
adv_name: 'Name',
|
||||
inserted_date: 'Inserted',
|
||||
updated_date: 'Last updated',
|
||||
public_key: 'Public key',
|
||||
type: 'Node type',
|
||||
params: 'Radio params',
|
||||
link: 'Meshcore link',
|
||||
};
|
||||
|
||||
let presets = [];
|
||||
|
||||
const types = {
|
||||
'1': 'Client',
|
||||
|
|
@ -20,25 +11,133 @@ const types = {
|
|||
'4': 'Sensor'
|
||||
};
|
||||
|
||||
const humanValue = {
|
||||
inserted_date(val) {
|
||||
return new Date(val).toLocaleString();
|
||||
const updateStatusDesc = {
|
||||
'none': 'manualy added',
|
||||
'recent': 'updated recently',
|
||||
'stale': 'updated while ago',
|
||||
'old': 'not updated',
|
||||
'extinct': 'will be deleted soon'
|
||||
};
|
||||
|
||||
const radioParamDesc = {
|
||||
'bw': {
|
||||
label: 'Bandwidth',
|
||||
unit: 'kHz'
|
||||
},
|
||||
updated_date(val) {
|
||||
return new Date(val).toLocaleString();
|
||||
'freq': {
|
||||
label: 'Frequency',
|
||||
unit: 'MHz',
|
||||
},
|
||||
coords(val) {
|
||||
return `<a target="_blank" href="https://google.com/maps/place/${val.replace(' ', '')}">${val}</a>`;
|
||||
'sf': {
|
||||
label: 'Spreading factor',
|
||||
unit: '',
|
||||
},
|
||||
type(val) {
|
||||
return types[val];
|
||||
'cr': {
|
||||
label: 'Coding rate',
|
||||
unit: '',
|
||||
},
|
||||
link(val) {
|
||||
return `<a href="javascript:navigator.clipboard.writeText('${val}')">Copy to clipboard</a>`
|
||||
};
|
||||
|
||||
const columnOrder = ['adv_name', 'type', 'status', 'link', 'inserted_date', 'updated_date', 'public_key', 'coords', 'preset', 'params' ];
|
||||
const columns = {
|
||||
coords: {
|
||||
label: 'Coordinates',
|
||||
value: (val) => `<a target="_blank" href="https://google.com/maps/place/${val.replace(' ', '')}">${val}</a>`
|
||||
},
|
||||
params(val) {
|
||||
return Object.entries(val).map(([key, val]) => `${key}=${val}`).join(', ')
|
||||
adv_name: {
|
||||
label: 'Name',
|
||||
value: (val) => escape(val)
|
||||
},
|
||||
status: {
|
||||
label: 'Update status',
|
||||
value: (val) => updateStatusDesc[val]
|
||||
},
|
||||
inserted_date: {
|
||||
label: 'Inserted',
|
||||
value: (val) => {
|
||||
const dt = new Date(val);
|
||||
return `<time datetime="${val}" title="${dt.toLocaleString()}">${timeAgo(dt.getTime())}</time>`
|
||||
}
|
||||
},
|
||||
updated_date: {
|
||||
label: 'Updated',
|
||||
value: (val) => {
|
||||
const dt = new Date(val);
|
||||
return `<time datetime="${val}" title="${dt.toLocaleString()}">${timeAgo(dt.getTime())}</time>`
|
||||
}
|
||||
},
|
||||
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('<br>')
|
||||
)
|
||||
},
|
||||
link: {
|
||||
label: 'Meshcore link',
|
||||
value: (val) => `<a href="javascript:navigator.clipboard.writeText('${val}')">Copy to clipboard</a>`
|
||||
},
|
||||
};
|
||||
|
||||
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)};`)
|
||||
}
|
||||
|
||||
function getSvgIconUrl(text, color) {
|
||||
const svg = `
|
||||
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg" >
|
||||
<style>
|
||||
text { font: bold 150pt sans-serif; fill: #fff; }
|
||||
</style>
|
||||
<ellipse cx="50%" cy="50%" rx="50%" ry="50%" fill="${color}"/>
|
||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle">${text}</text>
|
||||
</svg>`;
|
||||
|
||||
return L.icon({
|
||||
iconUrl: URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })),
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [17, 17],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
}
|
||||
|
||||
function clearLocationHash () {
|
||||
|
|
@ -47,10 +146,59 @@ function clearLocationHash () {
|
|||
|
||||
function getTable(node) {
|
||||
return '<table class="node-info"><tbody>'+
|
||||
'<tr>' + keyOrder.flatMap(key => node[key] ? [`<td><b>${humanLabel[key]}</b></td><td>${ humanValue[key] ? humanValue[key](node[key]) : node[key] }</td>`] : [] ).join('</tr><tr>') + '</tr>'+
|
||||
'<tr>' + columnOrder.flatMap(key => node[key] ? [`<td><b>${columns[key].label}</b></td><td>${ columns[key].value ? columns[key].value(node[key]) : node[key] }</td>`] : [] ).join('</tr><tr>') + '</tr>'+
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function getNodePopupHTML(node) {
|
||||
const userActionUrl = encodeURI(localStorage.getItem('userActionUrl') || '');
|
||||
const userActionLabel = localStorage.getItem('userActionLabel') || '';
|
||||
const userActionAnchor = userActionUrl ? `
|
||||
<a target="_blank" href="https://${userActionUrl}?nodes=${node.public_key}">${userActionLabel}</a>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
${getTable(node)}
|
||||
<div class="user-actions">
|
||||
<a href="${getDeletionMailUrl(node)}" target="_blank">Request node deletion</a>
|
||||
${userActionAnchor}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
@ -58,17 +206,27 @@ window.isNewerThan = (date, days) => {
|
|||
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: <please insert meshcore:// link here>\n'
|
||||
);
|
||||
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, <a target="_blank" href="https://www.paypal.com/donate/?business=DREHF5HM265ES&no_recurring=0&item_name=If+you+enjoy+my+work%2C+you+can+support+me+here%3A¤cy_code=EUR">
|
||||
App: recrof, <a target="_blank" href="https://github.com/sponsors/recrof?frequency=one-time&sponsor=recrof">
|
||||
<strong>support my work</strong></a> |
|
||||
<a target="_blank" href="${deletionMailUrl.toString().replaceAll('+', '%20')}"><strong>Node deletion request</strong></a>
|
||||
<a target="_blank" href="${getDeletionMailUrl()}"><strong>Node deletion request</strong></a>
|
||||
`;
|
||||
|
||||
const baseMapSelected = localStorage.getItem('baseMapSelected') || 'OpenStreetMap';
|
||||
|
|
@ -83,11 +241,11 @@ const baseMaps = {
|
|||
}),
|
||||
};
|
||||
|
||||
let initCoords = { lat: 7, lon: 25, zoom: 3 };
|
||||
let params = { lat: 7, lon: 25, zoom: 3 };
|
||||
|
||||
const urlParams = Object.fromEntries(new URLSearchParams(location.search));
|
||||
if(!(isNaN(urlParams.lat) || isNaN(urlParams.lon) || isNaN(urlParams.zoom))) {
|
||||
initCoords = urlParams
|
||||
if(Number(urlParams.lat) && Number(urlParams.lon) && Number(urlParams.zoom)) {
|
||||
params = urlParams
|
||||
}
|
||||
|
||||
const map = window.leafletMap = leaflet.map('map', {
|
||||
|
|
@ -98,7 +256,7 @@ const map = window.leafletMap = leaflet.map('map', {
|
|||
],
|
||||
layers: baseMaps[baseMapSelected],
|
||||
zoomControl: false
|
||||
}).setView([initCoords.lat, initCoords.lon], initCoords.zoom);
|
||||
}).setView([params.lat, params.lon], params.zoom);
|
||||
|
||||
map.on('baselayerchange', function(ev) {
|
||||
localStorage.setItem('baseMapSelected', ev.name);
|
||||
|
|
@ -107,21 +265,154 @@ map.on('baselayerchange', function(ev) {
|
|||
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],
|
||||
})]));
|
||||
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 dialogAddNode = ref();
|
||||
const app = window.app = reactive({
|
||||
nodes: null,
|
||||
nodes: [],
|
||||
nodesByType: {},
|
||||
filteredNodes: [],
|
||||
search: '',
|
||||
link: '',
|
||||
nodeFilter: [1, 2, 3, 4]
|
||||
nodeFilter: [],
|
||||
fromDate: '',
|
||||
clusteringZoom: 12,
|
||||
urlParams,
|
||||
presets,
|
||||
presetIndex: 0,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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, `<b>${highlightString}</b>`);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
app.nodeFilter = [1, 2, 3, 4];
|
||||
app.fromDate = '2025-03-01';
|
||||
app.cluster = 12;
|
||||
app.presetIndex = 0;
|
||||
}
|
||||
|
||||
function getDaysEpochMsec(days) {
|
||||
return days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function getNodeUpdateStatus(node) {
|
||||
if(node.source !== 'uploader') return 'none';
|
||||
const updateEpoch = new Date(node.updated_date).getTime();
|
||||
if(updateEpoch < Date.now() - getDaysEpochMsec(20)) return 'extinct';
|
||||
else if(updateEpoch < Date.now() - getDaysEpochMsec(10)) return 'old';
|
||||
else if(updateEpoch < Date.now() - getDaysEpochMsec(5)) return 'stale';
|
||||
|
||||
return 'recent';
|
||||
}
|
||||
|
||||
async function downloadNodes() {
|
||||
try {
|
||||
app.loading = true;
|
||||
const nodesReq = await fetch(apiUrl);
|
||||
app.nodes = await nodesReq.json();
|
||||
|
||||
getPresets().then((presets) => {
|
||||
app.presets = presets;
|
||||
});
|
||||
|
||||
for(const node of app.nodes) {
|
||||
const updateStatus = getNodeUpdateStatus(node);
|
||||
let icon = icons[updateStatus][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.status = updateStatus;
|
||||
node.preset = node.params;
|
||||
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: () => getNodePopupHTML(node) });
|
||||
marker.bindPopup(popup);
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
alert('There was an error loading map nodes:', e);
|
||||
}
|
||||
finally {
|
||||
app.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
|
|
@ -145,88 +436,60 @@ createApp({
|
|||
|
||||
const searchResults = computed(() => {
|
||||
if(!app.search) { return [] }
|
||||
const nodes = app.filteredNodes.length > 0 ? app.filteredNodes : app.nodes;
|
||||
|
||||
return app.nodes.filter(
|
||||
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);
|
||||
})
|
||||
|
||||
async function refreshMap(refresh, noDownload) {
|
||||
if(!noDownload) {
|
||||
const nodesReq = await fetch(apiUrl);
|
||||
app.nodes = await nodesReq.json();
|
||||
}
|
||||
const markers = L.markerClusterGroup();
|
||||
for(const node of app.nodes) {
|
||||
const marker = L.marker([node.adv_lat, node.adv_lon], { icon: icons[node.type.toString()], title: node.adv_name });
|
||||
node.marker = marker;
|
||||
node.coords = `${node.adv_lat.toFixed(4)}, ${node.adv_lon.toFixed(4)}`;
|
||||
node.lastAdvertDate = new Date(node.last_advert);
|
||||
const popup = L.popup({ minWidth: 350, maxWidth: 350, content: getTable(node) });
|
||||
marker.bindPopup(popup);
|
||||
markers.addLayer(marker);
|
||||
}
|
||||
if(refresh) {
|
||||
map.eachLayer(layer => layer.clearLayers());
|
||||
}
|
||||
map.addLayer(markers);
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
async function addNode() {
|
||||
if(!(app.link && app.link.startsWith('meshcore://'))) {
|
||||
alert('Please paste valid meshcore link.');
|
||||
return;
|
||||
};
|
||||
let markerClusterGroup = L.markerClusterGroup({
|
||||
disableClusteringAtZoom: app.clusteringZoom
|
||||
});
|
||||
|
||||
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, `<b>${highlightString}</b>`);
|
||||
}
|
||||
|
||||
refreshMap();
|
||||
watch(
|
||||
() => app.urlParams,
|
||||
() => {
|
||||
history.replaceState({}, '', `/?${new URLSearchParams(app.urlParams)}`);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
map.on('moveend', function(e) {
|
||||
const pos = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
history.replaceState({}, '', `/?lat=${pos.lat.toFixed(4)}&lon=${pos.lng.toFixed(4)}&zoom=${zoom}`);
|
||||
app.urlParams.zoom = zoom;
|
||||
app.urlParams.lat = pos.lat.toFixed(4);
|
||||
app.urlParams.lon = pos.lng.toFixed(4);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if(location.hash === '#add-new-node') {
|
||||
dialogAddNode.value.showModal();
|
||||
dialogAddNode.value.addEventListener("close", () => clearLocationHash());
|
||||
}
|
||||
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();
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
window.refreshMap = refreshMap;
|
||||
|
||||
return {
|
||||
app, refreshMap, addNode,
|
||||
stats, searchResults,
|
||||
showNode, dialogAddNode, highlightString
|
||||
app, refreshMap,
|
||||
stats, searchResults, filtersActive,
|
||||
showNode, highlightString,
|
||||
clearFilters
|
||||
}
|
||||
},
|
||||
}).mount('#app')
|
||||
33
src/node-utils.js
Normal file
33
src/node-utils.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
function fnv1aHash(str) {
|
||||
let hash = 0x811c9dc5n;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = BigInt.asIntN(32, hash ^ BigInt(str.charCodeAt(i)));
|
||||
hash = BigInt.asIntN(32, hash * 0x01000193n);
|
||||
}
|
||||
|
||||
return Number(hash & 0xFFFFFFFFn);
|
||||
}
|
||||
|
||||
export function getColourForName(name, saturation = 60, lightness = 50) {
|
||||
const hash = fnv1aHash(name);
|
||||
|
||||
return `hsl(${hash % 360}deg, ${saturation}%, ${lightness}%)`;
|
||||
}
|
||||
|
||||
export function getNameIconLabel(name) {
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = name.match(/\p{Emoji_Presentation}/u);
|
||||
if (!match) {
|
||||
name = name.trim();
|
||||
const segments = name.split(' ');
|
||||
if (segments.length == 1) {
|
||||
return name.charAt(0);
|
||||
}
|
||||
return `${segments.at(0)[0]}${segments.at(-1)[0]}`;
|
||||
}
|
||||
|
||||
return match[0];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue