mirror of
https://github.com/meshcore-dev/map.meshcore.dev.git
synced 2026-04-20 22:13:50 +00:00
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:
parent
21103bf4fa
commit
9cb65ce40c
3 changed files with 146 additions and 26 deletions
21
index.html
21
index.html
|
|
@ -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">
|
||||
|
|
|
|||
118
src/map.js
118
src/map.js
|
|
@ -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
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