loader, node nick colors, node emoji icons, fresh indicator

This commit is contained in:
recrof 2026-01-18 16:04:46 +01:00
parent 21103bf4fa
commit 06dfdd0469
5 changed files with 511 additions and 171 deletions

View file

@ -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
View 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

View file

@ -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">

View file

@ -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&currency_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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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
View 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];
}