added filter by frequency, optimizations, bugfixes

This commit is contained in:
Rastislav Vysoky 2026-03-27 18:12:00 +01:00
parent b85c616511
commit 1e5f13a80a
3 changed files with 181 additions and 70 deletions

View file

@ -232,6 +232,11 @@ table.node-info tr td:last-child {
opacity: 0.6; opacity: 0.6;
} }
.svg-node-icon {
background: none !important;
border: none !important;
}
time { time {
cursor: help; cursor: help;
text-decoration: underline; text-decoration: underline;

View file

@ -29,8 +29,8 @@
</a> </a>
</a> </a>
</div> </div>
<form class="search no-margin" action="javascript:;"> <form class="search no-margin" data-ui action="javascript:;">
<menu class="left no-wrap" id="node-filter" data-ui="#node-filter"> <menu class="left no-wrap" id="node-filter">
<li> <li>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" value="1" v-model="app.nodeFilter"><span>Clients</span> <input type="checkbox" value="1" v-model="app.nodeFilter"><span>Clients</span>
@ -79,6 +79,13 @@
<span></span> <span></span>
</label> </label>
</li> </li>
<li v-if="app.availableFreqs.length > 0" style="flex-direction:column;gap:4px">
<span>Frequency</span>
<label class="checkbox" v-for="freq in app.availableFreqs" :key="freq">
<input type="checkbox" :value="freq" v-model="app.freqFilter">
<span>{{ freq }} MHz</span>
</label>
</li>
<li v-if="filtersActive"> <li v-if="filtersActive">
<button class="small max" @click="clearFilters">Clear filters</button> <button class="small max" @click="clearFilters">Clear filters</button>
</li> </li>

View file

@ -1,13 +1,13 @@
import { unpack } from 'https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/+esm'; import { unpack } from 'https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/+esm';
import { createApp, reactive, ref, computed, watch, onMounted, toRaw } from '../lib/vue.esm-browser.js'; import { createApp, reactive, ref, computed, watch, onMounted, markRaw, shallowRef } from '../lib/vue.esm-browser.js';
import * as ntools from './node-utils.js'; import * as ntools from './node-utils.js';
const apiUrl = 'https://map.meshcore.dev/api/v1/nodes?binary=1&short=1'; const apiUrl = 'https://map.meshcore.dev/api/v1/nodes?binary=1&short=1';
function uint8ArrayToHex(uint8arr) { function uint8ArrayToHex(uint8arr) {
const hexOctets = new Array(uint8arr.length); // is even faster (preallocates necessary array size), then use hexOctets[i] instead of .push() const hexOctets = new Array(uint8arr.length);
for (let i = 0; i < uint8arr.length; ++i) for (let i = 0; i < uint8arr.length; ++i)
hexOctets.push(ntools.byteToHex[uint8arr[i]]); hexOctets[i] = ntools.byteToHex[uint8arr[i]];
return hexOctets.join(''); return hexOctets.join('');
} }
@ -163,22 +163,21 @@ function escape(html) {
return html.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`) return html.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
} }
function getSvgIconUrl(text, color) { const svgIconCache = new Map();
const svg = ` function getSvgIcon(text, color) {
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg" > const cacheKey = text + '|' + color;
<style> let icon = svgIconCache.get(cacheKey);
text { font: bold 150pt sans-serif; fill: #fff; } if (icon) return icon;
</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({ icon = L.divIcon({
iconUrl: URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })), html: `<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><ellipse cx="256" cy="256" rx="256" ry="256" fill="${color}"/><text x="256" y="256" dominant-baseline="central" text-anchor="middle" fill="#fff" font-size="150" font-weight="bold" font-family="sans-serif">${text}</text></svg>`,
className: 'svg-node-icon',
iconSize: [32, 32], iconSize: [32, 32],
iconAnchor: [17, 17], iconAnchor: [17, 17],
popupAnchor: [0, -16], popupAnchor: [0, -16],
}); });
svgIconCache.set(cacheKey, icon);
return icon;
} }
function clearLocationHash () { function clearLocationHash () {
@ -282,12 +281,9 @@ const baseMaps = {
}), }),
}; };
let params = { lat: 7, lon: 25, zoom: 3 };
let params = { lat: 7, lon: 25, zoom: 3 }; let params = { lat: 7, lon: 25, zoom: 3 };
const urlParams = Object.fromEntries(new URLSearchParams(location.search)); const urlParams = Object.fromEntries(new URLSearchParams(location.search));
if(Number(urlParams.lat) && Number(urlParams.lon) && Number(urlParams.zoom)) {
params = urlParams
if(Number(urlParams.lat) && Number(urlParams.lon) && Number(urlParams.zoom)) { if(Number(urlParams.lat) && Number(urlParams.lon) && Number(urlParams.zoom)) {
params = urlParams params = urlParams
} }
@ -324,15 +320,11 @@ const icons = Object.fromEntries(['none', 'recent', 'stale', 'old', 'extinct'].m
createApp({ createApp({
setup() { setup() {
const app = window.app = reactive({ const app = window.app = reactive({
nodes: [],
nodesByType: {},
filteredNodes: [],
nodes: [],
nodesByType: {},
filteredNodes: [],
search: '', search: '',
link: '', link: '',
nodeFilter: [], nodeFilter: [],
freqFilter: [],
availableFreqs: [],
fromDate: '', fromDate: '',
fromInsertDate: '', fromInsertDate: '',
clusteringZoom: 12, clusteringZoom: 12,
@ -342,6 +334,35 @@ createApp({
loading: false, loading: false,
}); });
// Keep large arrays outside Vue's deep reactivity
const nodesRef = shallowRef([]);
const nodesByTypeRef = shallowRef({});
const filteredNodesRef = shallowRef([]);
// Proxy access so templates/watchers can use app.nodes etc.
Object.defineProperty(app, 'nodes', {
get: () => nodesRef.value,
set: (v) => { nodesRef.value = v; },
});
Object.defineProperty(app, 'nodesByType', {
get: () => nodesByTypeRef.value,
set: (v) => { nodesByTypeRef.value = v; },
});
Object.defineProperty(app, 'filteredNodes', {
get: () => filteredNodesRef.value,
set: (v) => { filteredNodesRef.value = v; },
});
function attachClusterClickHandler(group) {
group.on('click', function(e) {
const marker = e.layer;
if(marker) {
ensurePopup(marker);
marker.openPopup();
}
});
}
async function refreshMap({ clusteringZoom = 0 } = {}) { async function refreshMap({ clusteringZoom = 0 } = {}) {
markerClusterGroup.clearLayers(); markerClusterGroup.clearLayers();
const nodes = app.filteredNodes.length > 0 ? app.filteredNodes : app.nodes; const nodes = app.filteredNodes.length > 0 ? app.filteredNodes : app.nodes;
@ -350,18 +371,23 @@ createApp({
if(clusteringZoom) { if(clusteringZoom) {
markerClusterGroup = L.markerClusterGroup({ markerClusterGroup = L.markerClusterGroup({
disableClusteringAtZoom: clusteringZoom disableClusteringAtZoom: clusteringZoom,
chunkedLoading: true,
}); });
attachClusterClickHandler(markerClusterGroup);
} }
for(const node of nodes) { const markers = new Array(nodes.length);
markerClusterGroup.addLayer(toRaw(node.marker)); for(let i = 0; i < nodes.length; i++) {
markers[i] = nodes[i].marker;
} }
markerClusterGroup.addLayers(markers);
map.addLayer(markerClusterGroup); map.addLayer(markerClusterGroup);
} }
function showNode(node) { function showNode(node) {
ensurePopup(node.marker);
node.marker.openPopup(); node.marker.openPopup();
map.flyTo(node.marker.getLatLng(), 19); map.flyTo(node.marker.getLatLng(), 19);
app.search = ''; app.search = '';
@ -376,6 +402,7 @@ createApp({
function clearFilters() { function clearFilters() {
app.nodeFilter = [1, 2, 3, 4]; app.nodeFilter = [1, 2, 3, 4];
app.freqFilter = [];
app.fromDate = '2025-03-01'; app.fromDate = '2025-03-01';
app.fromInsertDate = '2025-03-01'; app.fromInsertDate = '2025-03-01';
app.cluster = 12; app.cluster = 12;
@ -386,13 +413,17 @@ createApp({
return days * 24 * 60 * 60 * 1000; return days * 24 * 60 * 60 * 1000;
} }
const _now = Date.now();
const _extinct = _now - getDaysEpochMsec(20);
const _old = _now - getDaysEpochMsec(10);
const _stale = _now - getDaysEpochMsec(5);
function getNodeUpdateStatus(node) { function getNodeUpdateStatus(node) {
if(node.source[0] !== 'u') return 'none'; if(node.source[0] !== 'u') return 'none';
const updateEpoch = new Date(node.updated_date).getTime(); const updateEpoch = new Date(node.updated_date).getTime();
if(updateEpoch < Date.now() - getDaysEpochMsec(20)) return 'extinct'; if(updateEpoch < _extinct) return 'extinct';
else if(updateEpoch < Date.now() - getDaysEpochMsec(10)) return 'old'; if(updateEpoch < _old) return 'old';
else if(updateEpoch < Date.now() - getDaysEpochMsec(5)) return 'stale'; if(updateEpoch < _stale) return 'stale';
return 'recent'; return 'recent';
} }
@ -411,39 +442,58 @@ createApp({
app.loading = true; app.loading = true;
const nodesReq = await fetch(apiUrl); const nodesReq = await fetch(apiUrl);
const nodesBlob = await nodesReq.blob(); const nodesBlob = await nodesReq.blob();
app.nodes = unpack(await nodesBlob.arrayBuffer()); const nodes = unpack(await nodesBlob.arrayBuffer());
getPresets().then((presets) => { getPresets().then((presets) => {
app.presets = presets; app.presets = presets;
}); });
for(const node of app.nodes) { const byType = {};
inflateNode(node); const freqSet = new Set();
const updateStatus = getNodeUpdateStatus(node); const CHUNK_SIZE = 2000;
let icon = icons[updateStatus][node.type.toString()]; for(let offset = 0; offset < nodes.length; offset += CHUNK_SIZE) {
const end = Math.min(offset + CHUNK_SIZE, nodes.length);
(app.nodesByType[node.type] ??= []).push(node); // yield to browser between chunks so UI stays responsive
if(offset > 0) {
if(node.type === 1) { await new Promise(r => setTimeout(r, 0));
const label = ntools.getNameIconLabel(node.adv_name);
const color = ntools.getColourForName(node.adv_name);
icon = getSvgIconUrl(label, color);
} }
const marker = node.marker = L.marker( for(let i = offset; i < end; i++) {
[node.lat, node.lon], { icon, title: node.adv_name } const node = nodes[i];
); inflateNode(node);
const updateStatus = getNodeUpdateStatus(node);
node.status = updateStatus; let icon = icons[updateStatus][node.type.toString()];
node.preset = node.params;
node.coords = `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`; (byType[node.type] ??= []).push(node);
node.lastAdvertDate = new Date(node.last_advert);
node.insertDate = new Date(node.inserted_date); if(node.type === 1) {
node.updatedDate = node.updated_date && new Date(node.updated_date); const label = ntools.getNameIconLabel(node.adv_name);
const popup = L.popup({ minWidth: 350, maxWidth: 350, content: () => getNodePopupHTML(node) }); const color = ntools.getColourForName(node.adv_name);
marker.bindPopup(popup); icon = getSvgIcon(label, color);
}
const marker = node.marker = markRaw(L.marker(
[node.lat, node.lon], { icon, title: node.adv_name }
));
node.status = updateStatus;
node.preset = node.params;
node.coords = `${node.lat.toFixed(4)}, ${node.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);
markerToNode.set(marker, node);
if(node.params?.freq) freqSet.add(Math.floor(node.params.freq));
}
} }
nodesByTypeRef.value = byType;
nodesRef.value = nodes;
app.availableFreqs = [...freqSet].sort((a, b) => a - b);
} }
catch(e) { catch(e) {
alert('There was an error loading map nodes:', e); alert('There was an error loading map nodes:', e);
@ -461,18 +511,33 @@ createApp({
watch( watch(
[ [
() => app.nodeFilter, () => app.nodeFilter,
() => app.freqFilter,
() => app.fromDate, () => app.fromDate,
() => app.fromInsertDate, () => app.fromInsertDate,
], ],
() => { () => {
const fromDate = new Date(app.fromDate); const fromDate = new Date(app.fromDate);
const fromInsertDate = new Date(app.fromInsertDate); const fromInsertDate = new Date(app.fromInsertDate);
app.filteredNodes = app.nodeFilter const byType = nodesByTypeRef.value;
.flatMap(type => app.nodesByType[type]) const hasFreqFilter = app.freqFilter.length > 0;
.filter(node => node && (node.updatedDate ? node.updatedDate > fromDate : node.insertDate > fromDate)) const freqSet = hasFreqFilter ? new Set(app.freqFilter) : null;
.filter(node => node && (node.insertDate > fromInsertDate));
console.log('refresh', app.nodeFilter, app.filteredNodes.length); const result = [];
for(const type of app.nodeFilter) {
const typeNodes = byType[type];
if(!typeNodes) continue;
for(let i = 0; i < typeNodes.length; i++) {
const node = typeNodes[i];
if(node.updatedDate ? node.updatedDate <= fromDate : node.insertDate <= fromDate) continue;
if(node.insertDate <= fromInsertDate) continue;
if(hasFreqFilter && !(node.params?.freq && freqSet.has(Math.floor(node.params.freq)))) continue;
result.push(node);
}
}
filteredNodesRef.value = result;
console.log('refresh', app.nodeFilter, result.length);
app.urlParams.nodes = app.nodeFilter.join(','); app.urlParams.nodes = app.nodeFilter.join(',');
app.urlParams.freq = app.freqFilter.join(',');
app.urlParams.date = app.fromDate; app.urlParams.date = app.fromDate;
app.urlParams.dateInsert = app.fromInsertDate; app.urlParams.dateInsert = app.fromInsertDate;
refreshMap({ download: false }); refreshMap({ download: false });
@ -487,18 +552,33 @@ createApp({
const stats = computed(() => { const stats = computed(() => {
const nodes = app.nodes; const nodes = app.nodes;
if(!nodes) return []; if(!nodes || !nodes.length) return [];
const byType = app.nodesByType;
const now = Date.now();
const msPerDay = 86400000;
const t1 = now - msPerDay;
const t7 = now - 7 * msPerDay;
const t30 = now - 30 * msPerDay;
let c1 = 0, c7 = 0, c30 = 0;
for(let i = 0; i < nodes.length; i++) {
const insertMs = nodes[i].insertDate.getTime();
if(insertMs > t1) c1++;
if(insertMs > t7) c7++;
if(insertMs > t30) c30++;
}
const result = []; const result = [];
result.push(` result.push(`
<span>total: <b>${nodes.length}</b></span>&nbsp;| <span>total: <b>${nodes.length}</b></span>&nbsp;|
<i class="node-type pointer-help" title="Total client nodes">person</i><b>${nodes.filter(n => n.type === 1).length}</b>&nbsp;| <i class="node-type pointer-help" title="Total client nodes">person</i><b>${(byType[1] || []).length}</b>&nbsp;|
<i class="node-type pointer-help" title="Total repeater nodes">cell_tower</i><b>${nodes.filter(n => n.type === 2).length}</b>&nbsp;| <i class="node-type pointer-help" title="Total repeater nodes">cell_tower</i><b>${(byType[2] || []).length}</b>&nbsp;|
<i class="node-type pointer-help" title="Total room server nodes">forum</i><b>${nodes.filter(n => n.type === 3).length}</b> <i class="node-type pointer-help" title="Total room server nodes">forum</i><b>${(byType[3] || []).length}</b>
`); `);
result.push(`<span class="pointer-help" title="Nodes added in last 24 hours">24h: <b>${app.nodes.filter(n => isNewerThan(n.inserted_date, 1)).length}</b></span>`); result.push(`<span class="pointer-help" title="Nodes added in last 24 hours">24h: <b>${c1}</b></span>`);
result.push(`<span class="pointer-help" title="Nodes added in last 7 days">7d: <b>${app.nodes.filter(n => isNewerThan(n.inserted_date, 7)).length}</b></span>`); result.push(`<span class="pointer-help" title="Nodes added in last 7 days">7d: <b>${c7}</b></span>`);
result.push(`<span class="pointer-help" title="Nodes added in last 30 days">30d: <b>${app.nodes.filter(n => isNewerThan(n.inserted_date, 30)).length}</b></span>`); result.push(`<span class="pointer-help" title="Nodes added in last 30 days">30d: <b>${c30}</b></span>`);
return result; return result;
}); });
@ -517,9 +597,26 @@ createApp({
}); });
let markerClusterGroup = L.markerClusterGroup({ let markerClusterGroup = L.markerClusterGroup({
disableClusteringAtZoom: app.clusteringZoom disableClusteringAtZoom: app.clusteringZoom,
chunkedLoading: true,
}); });
// Map markers back to nodes for lazy popup binding
const markerToNode = new WeakMap();
// Lazy popup binding — only create popup when a marker is clicked
function ensurePopup(marker) {
if(!marker._popupBound) {
const node = markerToNode.get(marker);
if(node) {
marker.bindPopup(
markRaw(L.popup({ minWidth: 350, maxWidth: 350, content: () => getNodePopupHTML(node) }))
);
marker._popupBound = true;
}
}
}
watch( watch(
() => app.urlParams, () => app.urlParams,
() => { () => {
@ -528,15 +625,14 @@ createApp({
{ deep: true } { deep: true }
); );
attachClusterClickHandler(markerClusterGroup);
map.on('moveend', function(e) { map.on('moveend', function(e) {
const pos = map.getCenter(); const pos = map.getCenter();
const zoom = map.getZoom(); const zoom = map.getZoom();
app.urlParams.zoom = zoom; app.urlParams.zoom = zoom;
app.urlParams.lat = pos.lat.toFixed(4); app.urlParams.lat = pos.lat.toFixed(4);
app.urlParams.lon = pos.lng.toFixed(4); app.urlParams.lon = pos.lng.toFixed(4);
app.urlParams.zoom = zoom;
app.urlParams.lat = pos.lat.toFixed(4);
app.urlParams.lon = pos.lng.toFixed(4);
}); });
onMounted(() => { onMounted(() => {
@ -544,6 +640,9 @@ createApp({
if(urlParams.nodes) { if(urlParams.nodes) {
app.nodeFilter = urlParams.nodes.split(','); app.nodeFilter = urlParams.nodes.split(',');
} }
if(urlParams.freq) {
app.freqFilter = urlParams.freq.split(',').map(Number);
}
if(urlParams.date) { if(urlParams.date) {
app.fromDate = urlParams.date app.fromDate = urlParams.date
} }