added filtering by node type and insert/update time; clients now show initials/emojis and node color like in official MeshCore app; added clustering zoom level slider in filter

This commit is contained in:
recrof 2025-07-19 23:15:02 +02:00
parent 21103bf4fa
commit 9cb65ce40c
3 changed files with 146 additions and 26 deletions

View file

@ -59,8 +59,9 @@
<form class="search no-margin" action="javascript:;">
<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">
<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>
@ -80,10 +81,24 @@
</li>
<li>
<label class="checkbox">
<input type="checkbox" value="3" v-model="app.nodeFilter"><span>Sensors</span>
<input type="checkbox" value="4" v-model="app.nodeFilter"><span>Sensors</span>
</label>
</li>
</menu-->
<li class="padding">
<div class="field label prefix fill small">
<i>today</i>
<input type="date" v-model="app.fromDate" placeholder=" ">
<label>Inserted from</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>
</menu>
</div>
<article class="search-results no-margin no-padding" v-if="searchResults?.length > 0">
<ul class="list no-space border">

View file

@ -1,4 +1,5 @@
import { createApp, reactive, ref, computed, onMounted } from '../lib/vue.esm-browser.js';
import { createApp, reactive, ref, computed, watch, onMounted } 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' ]
@ -41,6 +42,24 @@ const humanValue = {
}
}
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 () {
history.pushState('', document.title, location.pathname + location.search);
}
@ -118,12 +137,18 @@ createApp({
setup() {
const dialogAddNode = ref();
const app = window.app = reactive({
nodes: null,
nodes: [],
nodesByType: {},
filteredNodes: [],
search: '',
link: '',
nodeFilter: [1, 2, 3, 4]
nodeFilter: [],
fromDate: '2025-03-01',
clusteringZoom: 12,
});
const filtersActive = computed(() => app.filteredNodes.length && app.nodes.length !== app.filteredNodes.length);
const stats = computed(() => {
const nodes = app.nodes;
@ -146,32 +171,78 @@ createApp({
const searchResults = computed(() => {
if(!app.search) { return [] }
return app.nodes.filter(
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);
})
});
async function refreshMap(refresh, noDownload) {
if(!noDownload) {
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);
refreshMap({ download: false });
}
);
watch(() => app.clusteringZoom, () => {
refreshMap({ download: false, clusteringZoom: app.clusteringZoom });
});
let markerClusterGroup = L.markerClusterGroup({
disableClusteringAtZoom: app.clusteringZoom
});
async function refreshMap({ download = true, clusteringZoom = 0 } = {}) {
if(download) {
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);
}
}
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);
markerClusterGroup.clearLayers();
const nodes = app.filteredNodes.length > 0 ? app.filteredNodes : app.nodes;
map.removeLayer(markerClusterGroup);
if(clusteringZoom) {
markerClusterGroup = L.markerClusterGroup({
disableClusteringAtZoom: clusteringZoom
});
}
if(refresh) {
map.eachLayer(layer => layer.clearLayers());
for(const node of nodes) {
markerClusterGroup.addLayer(node.marker);
}
map.addLayer(markers);
map.addLayer(markerClusterGroup);
}
async function addNode() {
@ -207,25 +278,26 @@ createApp({
return escapedSource.replace(highlightString, `<b>${highlightString}</b>`);
}
refreshMap();
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}`);
});
refreshMap();
onMounted(() => {
app.nodeFilter = ['1', '2', '3', '4'];
if(location.hash === '#add-new-node') {
dialogAddNode.value.showModal();
dialogAddNode.value.addEventListener("close", () => clearLocationHash());
dialogAddNode.value.addEventListener('close', () => clearLocationHash());
}
})
window.refreshMap = refreshMap;
return {
app, refreshMap, addNode,
stats, searchResults,
stats, searchResults, filtersActive,
showNode, dialogAddNode, highlightString
}
},

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];
}