Fix owner portal login; add tile worker and cross-network improvements

Owner portal fixes:
- Fix session node ID case mismatch: decryptOwnerSession was lowercasing
  node IDs but /owner/live uppercased the ?nodeId= param, so the find()
  never matched — users always got "Node is not owned by this session"
- Revert sameSite strict → lax (broke sessions after March 18 deploy)
- Remove LOWER() wrappers from all owner live SQL (node IDs are uppercase)
- Add Fireball's second node (TS17 Yagi North) to owner_account_nodes

Tile worker:
- Server-side PNG tile rendering via @napi-rs/canvas
- Tile snapshot cache, Redis-backed tile cache, tile worker service
- /api/tiles/nodes/:z/:x/:y.png endpoint with rate limiter
- UK tile TTL logic

Cross-network connectivity:
- Add 7-day hourly history series to /cross-network-connectivity response
- Cache result for 60s to avoid repeated join-heavy query
- Fix query to use literal interval instead of interpolated variable

Other:
- Remove LOWER() from packet/node SQL throughout (IDs stored uppercase)
- Various performance and cleanup improvements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
BenandClaude Sonnet 4.6 committed 2026-03-19 15:55:47 +00:00
1 parent 5d0281b922
commit acd71b0913
27 files changed
+5682 -371

No files matched your search

+291
View File
@@ -9,6 +9,8 @@
"version": "1.0.0",
"dependencies": {
"@michaelhart/meshcore-decoder": "^0.2.7",
"@napi-rs/canvas": "^0.1.58",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.4.1",
@@ -18,6 +20,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.17.6",
@@ -70,6 +73,240 @@
"meshcore-decoder": "dist/cli.js"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.97",
"@napi-rs/canvas-darwin-arm64": "0.1.97",
"@napi-rs/canvas-darwin-x64": "0.1.97",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.97",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@noble/ed25519": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz",
@@ -88,6 +325,16 @@
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -440,6 +687,42 @@
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
@@ -1245,6 +1528,14 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+181 -41
View File
@@ -3,7 +3,11 @@ import { rateLimit } from 'express-rate-limit';
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
import { isIP } from 'node:net';
import mqtt from 'mqtt';
import { Redis } from 'ioredis';
import { getNodes, getNodeHistory, getNodeAdverts, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js';
import { renderNodeTile } from '../tiles/renderer.js';
import { getTileSnapshotNodes } from '../tiles/snapshot.js';
import { isUkTile, UK_TILE_TTL_MS } from '../tiles/worker.js';
import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
import { getWorkerHealthOverview } from '../health/status.js';
import { resolveRequestNetwork } from '../http/requestScope.js';
@@ -22,11 +26,13 @@ const INFERRED_NODES_CACHE_TTL_MS = 60_000; // 7-day packet scan, changes slowl
const PATH_HISTORY_CACHE_TTL_MS = 60_000; // history cache is rebuilt by worker, not real-time
const COVERAGE_CACHE_TTL_MS = 30_000; // geometry changes only on coverage rebuild
const CHARTS_CACHE_TTL_MS = 30 * 60_000; // 30 min — background refresh keeps it warm
const CROSS_NETWORK_CACHE_TTL_MS = 60_000; // dashboard polls every minute; avoid re-running the join-heavy query
const statsCache = new Map<string, { ts: number; data: unknown }>();
const inferredNodesCache = new Map<string, { ts: number; data: unknown }>();
const pathHistoryCache = new Map<string, { ts: number; data: unknown }>();
const coverageCache = new Map<string, { ts: number; data: unknown }>();
const chartsCache = new Map<string, { ts: number; data: unknown }>();
const crossNetworkCache = new Map<string, { ts: number; data: unknown }>();
const chartsInflight = new Map<string, Promise<unknown>>();
const OWNER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const MQTT_USERNAME_MAX_LEN = 128;
@@ -80,6 +86,19 @@ const STATS_CHARTS_LIMITER = rateLimit({
legacyHeaders: false,
message: { error: 'Too many stats chart requests, slow down' },
});
const TILE_CACHE_TTL_MS = 30_000; // on-demand non-UK tiles
const TILE_CACHE_TTL_UK_MS = UK_TILE_TTL_MS; // on-demand UK tiles (matches worker TTL)
const TILE_LIMITER = rateLimit({ windowMs: 60_000, max: 600, standardHeaders: true, legacyHeaders: false });
let tileRedis: Redis | null = null;
function getTileRedis(): Redis {
if (tileRedis) return tileRedis;
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
tileRedis = new Redis(redisUrl);
tileRedis.on('error', (e: Error) => console.error('[redis/tiles] error', e.message));
return tileRedis;
}
const PROHIBITED_NODE_MARKER = '🚫';
const HIDDEN_NODE_MASK_RADIUS_MILES = 1;
@@ -119,8 +138,8 @@ function decryptOwnerSession(token: string): OwnerSession | null {
const parsed = JSON.parse(decoded) as Partial<OwnerSession>;
if (!Array.isArray(parsed.nodeIds) || typeof parsed.exp !== 'number') return null;
const nodeIds = parsed.nodeIds
.map((value) => String(value).trim().toLowerCase())
.filter((value) => /^[0-9a-f]{64}$/.test(value));
.map((value) => String(value).trim().toUpperCase())
.filter((value) => /^[0-9A-F]{64}$/.test(value));
if (nodeIds.length < 1) return null;
const mqttUsername = typeof parsed.mqttUsername === 'string' ? parsed.mqttUsername.trim() : undefined;
return { nodeIds, exp: parsed.exp, mqttUsername: mqttUsername || undefined };
@@ -340,7 +359,7 @@ async function buildOwnerDashboard(nodeIds: string[]) {
}>(
`SELECT node_id, name, network, last_seen, advert_count, lat, lon, iata
FROM nodes
WHERE LOWER(node_id) = ANY($1::text[])
WHERE node_id = ANY($1::text[])
ORDER BY last_seen DESC NULLS LAST`,
[nodeIds],
),
@@ -349,14 +368,14 @@ async function buildOwnerDashboard(nodeIds: string[]) {
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h,
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '7 days')::int AS packets_7d
FROM packets
WHERE LOWER(src_node_id) = ANY($1::text[])`,
WHERE src_node_id = ANY($1::text[])`,
[nodeIds],
),
query<{ packets_24h: number }>(
`SELECT
COUNT(*) FILTER (WHERE time > NOW() - INTERVAL '24 hours')::int AS packets_24h
FROM packets
WHERE LOWER(rx_node_id) = ANY($1::text[])`,
WHERE rx_node_id = ANY($1::text[])`,
[nodeIds],
),
]);
@@ -409,8 +428,8 @@ type NetworkFilters = {
};
function normalizeObserverQuery(value: unknown): string | undefined {
const observer = String(value ?? '').trim().toLowerCase();
return observer && /^[0-9a-f]{64}$/.test(observer) ? observer : undefined;
const observer = String(value ?? '').trim().toUpperCase();
return observer && /^[0-9A-F]{64}$/.test(observer) ? observer : undefined;
}
function normalizeIp(value: string | undefined): string {
@@ -468,9 +487,9 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
if (networkParam) packetConditions.push(`network = ${networkParam}`);
else {
packetConditions.push(`network IS DISTINCT FROM 'test'`);
packetConditions.push(`LOWER(COALESCE(rx_node_id, '')) NOT IN (SELECT LOWER(node_id) FROM nodes WHERE network = 'test')`);
packetConditions.push(`COALESCE(rx_node_id, '') NOT IN (SELECT node_id FROM nodes WHERE network = 'test')`);
}
if (observerParam) packetConditions.push(`LOWER(rx_node_id) = LOWER(${observerParam})`);
if (observerParam) packetConditions.push(`rx_node_id = ${observerParam}`);
const nodeConditions = (alias?: string) => {
const prefix = alias ? `${alias}.` : '';
@@ -480,13 +499,13 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
if (observerParam) {
conditions.push(
`(
LOWER(${prefix}node_id) = LOWER(${observerParam})
${prefix}node_id = ${observerParam}
OR EXISTS (
SELECT 1
FROM packets p
WHERE LOWER(p.rx_node_id) = LOWER(${observerParam})
WHERE p.rx_node_id = ${observerParam}
${networkParam ? `AND p.network = ${networkParam}` : ''}
AND LOWER(p.src_node_id) = LOWER(${prefix}node_id)
AND p.src_node_id = ${prefix}node_id
)
)`,
);
@@ -506,9 +525,9 @@ function networkFilters(network?: string, observer?: string): NetworkFilters {
} else {
conditions.push(`${prefix}network IS DISTINCT FROM 'test'`);
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
conditions.push(`LOWER(COALESCE(${prefix}rx_node_id, '')) NOT IN (SELECT LOWER(node_id) FROM nodes WHERE network = 'test')`);
conditions.push(`COALESCE(${prefix}rx_node_id, '') NOT IN (SELECT node_id FROM nodes WHERE network = 'test')`);
}
if (observerParam) conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${observerParam})`);
if (observerParam) conditions.push(`${prefix}rx_node_id = ${observerParam}`);
return conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '';
},
nodes: nodeConditions().length > 0 ? `AND ${nodeConditions().join(' AND ')}` : '',
@@ -1651,7 +1670,7 @@ router.post('/owner/login', OWNER_LOGIN_LIMITER, async (req, res) => {
res.cookie(OWNER_COOKIE_NAME, token, {
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict',
sameSite: 'lax',
path: '/',
maxAge: OWNER_SESSION_TTL_MS,
});
@@ -1698,9 +1717,9 @@ router.get('/owner/live', async (req, res) => {
return;
}
const requestedNodeId = String(req.query['nodeId'] ?? '').trim().toLowerCase();
const requestedNodeId = String(req.query['nodeId'] ?? '').trim().toUpperCase();
const selectedNodeId = requestedNodeId
? ownedNodeIds.find((id) => id.toLowerCase() === requestedNodeId)
? ownedNodeIds.find((id) => id === requestedNodeId)
: ownedNodeIds[0];
if (!selectedNodeId) {
res.status(403).json({ error: 'Node is not owned by this session' });
@@ -1735,7 +1754,7 @@ router.get('/owner/live', async (req, res) => {
}>(
`SELECT node_id, name, network, iata, advert_count, last_seen, lat, lon, role
FROM nodes
WHERE LOWER(node_id) = LOWER($1)
WHERE node_id = $1
LIMIT 1`,
[selectedNodeId],
),
@@ -1760,10 +1779,10 @@ router.get('/owner/live', async (req, res) => {
MAX(p.time)::text AS last_seen
FROM packets p
LEFT JOIN nodes n ON n.node_id = p.src_node_id
WHERE LOWER(p.rx_node_id) = LOWER($1)
WHERE p.rx_node_id = $1
AND p.hop_count = 0
AND p.src_node_id IS NOT NULL
AND LOWER(p.src_node_id) <> LOWER($1)
AND p.src_node_id <> $1
AND p.time > NOW() - INTERVAL '24 hours'
GROUP BY p.src_node_id, n.name, n.network, n.iata, n.lat, n.lon
ORDER BY packets_24h DESC
@@ -1806,7 +1825,7 @@ router.get('/owner/live', async (req, res) => {
ORDER BY p.time DESC
) AS rn
FROM packets p
WHERE LOWER(p.rx_node_id) = LOWER($1)
WHERE p.rx_node_id = $1
)
SELECT
r.time::text AS time,
@@ -1855,10 +1874,10 @@ router.get('/owner/live', async (req, res) => {
MAX(p.time)::text AS last_seen,
MIN(p.hop_count) AS best_hops
FROM packets p
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
WHERE LOWER(p.src_node_id) = LOWER($1)
LEFT JOIN nodes n ON n.node_id = p.rx_node_id
WHERE p.src_node_id = $1
AND p.rx_node_id IS NOT NULL
AND LOWER(p.rx_node_id) <> LOWER($1)
AND p.rx_node_id <> $1
AND p.time > NOW() - INTERVAL '7 days'
GROUP BY p.rx_node_id, n.name, n.network, n.iata, n.lat, n.lon
ORDER BY packets_24h DESC, packets_7d DESC, last_seen DESC
@@ -1878,20 +1897,20 @@ router.get('/owner/live', async (req, res) => {
last_observed: string | null;
}>(
`SELECT
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END AS peer_node_id,
CASE WHEN nl.node_a_id = $1 THEN nl.node_b_id ELSE nl.node_a_id END AS peer_node_id,
peer.name AS peer_name,
peer.network AS peer_network,
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_a_to_b ELSE nl.count_b_to_a END AS owner_to_peer,
CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.count_b_to_a ELSE nl.count_a_to_b END AS peer_to_owner,
CASE WHEN nl.node_a_id = $1 THEN nl.count_a_to_b ELSE nl.count_b_to_a END AS owner_to_peer,
CASE WHEN nl.node_a_id = $1 THEN nl.count_b_to_a ELSE nl.count_a_to_b END AS peer_to_owner,
nl.observed_count,
nl.itm_path_loss_db,
nl.itm_viable,
nl.force_viable,
nl.last_observed::text AS last_observed
FROM node_links nl
JOIN nodes peer ON LOWER(peer.node_id) = LOWER(CASE WHEN LOWER(nl.node_a_id) = LOWER($1) THEN nl.node_b_id ELSE nl.node_a_id END)
WHERE (LOWER(nl.node_a_id) = LOWER($1)
OR LOWER(nl.node_b_id) = LOWER($1))
JOIN nodes peer ON peer.node_id = CASE WHEN nl.node_a_id = $1 THEN nl.node_b_id ELSE nl.node_a_id END
WHERE (nl.node_a_id = $1
OR nl.node_b_id = $1)
AND (
nl.force_viable = true
OR nl.itm_viable = true
@@ -1913,7 +1932,7 @@ router.get('/owner/live', async (req, res) => {
time_bucket('1 hour', time)::text AS bucket,
COUNT(DISTINCT packet_hash)::int AS adverts
FROM packets
WHERE LOWER(src_node_id) = LOWER($1)
WHERE src_node_id = $1
AND packet_type = 4
AND time > NOW() - INTERVAL '24 hours'
GROUP BY bucket
@@ -1953,7 +1972,7 @@ router.get('/owner/live', async (req, res) => {
ELSE NULL
END AS tx_publish_calls
FROM node_status_samples
WHERE LOWER(node_id) = LOWER($1)
WHERE node_id = $1
AND time > NOW() - INTERVAL '24 hours'
ORDER BY time ASC`,
[selectedNodeId],
@@ -2296,7 +2315,7 @@ async function computeChartsData(network: string | undefined, observer: string |
// top repeated first-2-hex prefix collisions across known repeaters
query(`
WITH prefix_counts AS (
SELECT SUBSTRING(LOWER(n.node_id) FROM 1 FOR 2) AS prefix, COUNT(*)::int AS node_count
SELECT LEFT(n.node_id, 2) AS prefix, COUNT(*)::int AS node_count
FROM nodes n
WHERE n.node_id ~ '^[0-9A-Fa-f]{64}$'
AND (n.name IS NULL OR n.name NOT LIKE '%🚫%')
@@ -2327,11 +2346,11 @@ async function computeChartsData(network: string | undefined, observer: string |
COALESCE(NULLIF(TRIM(UPPER(n.iata)), ''), 'UNK') AS iata,
COUNT(DISTINCT p.packet_hash) FILTER (WHERE p.time > NOW() - INTERVAL '24 hours') AS packets_24h,
COUNT(DISTINCT p.packet_hash) AS packets_7d,
COUNT(DISTINCT LOWER(p.rx_node_id)) FILTER (WHERE n.last_seen > NOW() - INTERVAL '1 minute') AS active_observers,
COUNT(DISTINCT LOWER(p.rx_node_id)) AS observers,
COUNT(DISTINCT p.rx_node_id) FILTER (WHERE n.last_seen > NOW() - INTERVAL '1 minute') AS active_observers,
COUNT(DISTINCT p.rx_node_id) AS observers,
MAX(p.time)::text AS last_packet_at
FROM packets p
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
LEFT JOIN nodes n ON n.node_id = p.rx_node_id
WHERE p.time > NOW() - INTERVAL '7 days'
AND p.rx_node_id IS NOT NULL
AND p.rx_node_id <> ''
@@ -2347,7 +2366,7 @@ async function computeChartsData(network: string | undefined, observer: string |
time_bucket('1 day', p.time) AS day,
COUNT(DISTINCT p.packet_hash) AS count
FROM packets p
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(p.rx_node_id)
LEFT JOIN nodes n ON n.node_id = p.rx_node_id
WHERE p.time > NOW() - INTERVAL '7 days'
AND p.rx_node_id IS NOT NULL
AND p.rx_node_id <> ''
@@ -2450,7 +2469,7 @@ async function computeChartsData(network: string | undefined, observer: string |
) AS decoded_nodes
FROM multibyte m
JOIN hop_matches hm ON hm.packet_hash = m.packet_hash
LEFT JOIN nodes n ON LOWER(n.node_id) = LOWER(hm.matched_node_id)
LEFT JOIN nodes n ON n.node_id = hm.matched_node_id
GROUP BY m.packet_hash
),
latest_multibyte AS (
@@ -2699,7 +2718,21 @@ router.get('/observer-activity', EXPENSIVE_LIMITER, async (req, res) => {
// Outbound: MME observers see it with fewer hops, non-MME also received it within 120s
// Inbound: non-MME observers see it with fewer hops, MME also received it within 120s
router.get('/cross-network-connectivity', EXPENSIVE_LIMITER, async (_req, res) => {
const result = await query<{ last_inbound: string | null; last_outbound: string | null }>(`
const cacheKey = 'teesside';
const cached = crossNetworkCache.get(cacheKey);
if (cached && Date.now() - cached.ts < CROSS_NETWORK_CACHE_TTL_MS) {
res.json(cached.data);
return;
}
let result: Awaited<ReturnType<typeof query<{ last_inbound: string | null; last_outbound: string | null }>>>;
let historyResult: Awaited<ReturnType<typeof query<{
bucket: string;
inbound_count: string;
outbound_count: string;
}>>>;
try {
result = await query<{ last_inbound: string | null; last_outbound: string | null }>(`
WITH mme_observers AS (
SELECT DISTINCT rx_node_id AS node_id
FROM packets
@@ -2734,18 +2767,89 @@ router.get('/cross-network-connectivity', EXPENSIVE_LIMITER, async (_req, res) =
FROM cross_heard
WHERE mme_min_hops != other_min_hops
`);
historyResult = await query<{
bucket: string;
inbound_count: string;
outbound_count: string;
}>(`
WITH mme_observers AS (
SELECT DISTINCT rx_node_id AS node_id
FROM packets
WHERE time > NOW() - INTERVAL '24 hours'
AND network = 'teesside'
AND rx_node_id IS NOT NULL
),
cross_heard AS (
SELECT
p.packet_hash,
MIN(p.hop_count) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) AS mme_min_hops,
MIN(p.hop_count) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) AS other_min_hops,
MIN(p.time) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) AS mme_first_seen,
MIN(p.time) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) AS other_first_seen
FROM packets p
WHERE p.time > NOW() - INTERVAL '7 days'
AND p.hop_count IS NOT NULL
AND p.rx_node_id IS NOT NULL
AND p.packet_hash IS NOT NULL
GROUP BY p.packet_hash
HAVING
MIN(p.hop_count) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) IS NOT NULL
AND MIN(p.hop_count) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers)) IS NOT NULL
AND ABS(EXTRACT(EPOCH FROM (
MIN(p.time) FILTER (WHERE p.rx_node_id IN (SELECT node_id FROM mme_observers)) -
MIN(p.time) FILTER (WHERE p.rx_node_id NOT IN (SELECT node_id FROM mme_observers))
))) <= 120
),
classified AS (
SELECT
date_trunc('hour', mme_first_seen) AS bucket,
CASE WHEN other_min_hops < mme_min_hops THEN 1 ELSE 0 END AS inbound_count,
CASE WHEN mme_min_hops < other_min_hops THEN 1 ELSE 0 END AS outbound_count
FROM cross_heard
WHERE mme_min_hops != other_min_hops
AND mme_first_seen IS NOT NULL
),
buckets AS (
SELECT generate_series(
date_trunc('hour', NOW() - INTERVAL '7 days'),
date_trunc('hour', NOW()),
INTERVAL '1 hour'
) AS bucket
)
SELECT
to_char(b.bucket, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS bucket,
COALESCE(SUM(c.inbound_count), 0)::text AS inbound_count,
COALESCE(SUM(c.outbound_count), 0)::text AS outbound_count
FROM buckets b
LEFT JOIN classified c ON c.bucket = b.bucket
GROUP BY b.bucket
ORDER BY b.bucket
`);
} catch (err) {
console.error('[api] GET /cross-network-connectivity', (err as Error).message);
res.status(500).end();
return;
}
const lastInbound = result.rows[0]?.last_inbound ?? null;
const lastOutbound = result.rows[0]?.last_outbound ?? null;
res.json({
const responseData = {
inbound: !!lastInbound,
outbound: !!lastOutbound,
lastInbound,
lastOutbound,
windowHours: 2,
historyWindowHours: 7 * 24,
history: historyResult.rows.map((row) => ({
bucket: row.bucket,
inboundCount: Number(row.inbound_count ?? 0),
outboundCount: Number(row.outbound_count ?? 0),
})),
checkedAt: new Date().toISOString(),
});
};
crossNetworkCache.set(cacheKey, { ts: Date.now(), data: responseData });
res.json(responseData);
});
// GET /api/radio-history?target=<nodeName>&limit=<n> — proxies radio bot POST /history
@@ -2767,6 +2871,42 @@ router.get('/radio-history', async (req, res) => {
}
});
// GET /api/tiles/nodes/:z/:x/:y.png — server-side node tile rendering
router.get('/tiles/nodes/:z/:x/:y.png', TILE_LIMITER, async (req: Request, res: Response) => {
const network = resolveRequestNetwork(req.query['network'], req.headers);
const z = parseInt(req.params.z!, 10);
const x = parseInt(req.params.x!, 10);
const y = parseInt((req.params.y ?? '').replace('.png', ''), 10);
if (isNaN(z) || isNaN(x) || isNaN(y) || z < 0 || z > 18) { res.status(400).end(); return; }
const cacheKey = `tile:nodes:${network ?? 'all'}:${z}:${x}:${y}`;
const redis = getTileRedis();
const cached = await redis.getBuffer(cacheKey).catch(() => null);
if (cached) {
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=30');
res.send(cached);
return;
}
try {
// 'all' means no network filter — pass undefined so buildNodeScopeClause
// falls back to "IS DISTINCT FROM 'test'" rather than network = 'all'.
const nodeNetwork = network === 'all' ? undefined : network;
const snapshotNodes = await getTileSnapshotNodes(nodeNetwork);
const nodes = snapshotNodes.length > 0 ? snapshotNodes : await getNodes(nodeNetwork);
const png = await renderNodeTile(z, x, y, nodes);
const ttl = isUkTile(z, x, y) ? TILE_CACHE_TTL_UK_MS : TILE_CACHE_TTL_MS;
await redis.set(cacheKey, png, 'PX', ttl).catch(() => {});
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=30');
res.send(png);
} catch (err) {
console.error('[api] GET /tiles/nodes', (err as Error).message);
res.status(500).end();
}
});
// GET /api/radio-stats — proxies radio bot GET /state (port 3011)
router.get('/radio-stats', async (_req, res) => {
const radioBotUrl = process.env['RADIO_BOT_URL'] ?? 'http://meshcore-radio-bot:3011';
+17 -9
View File
@@ -67,7 +67,7 @@ function buildPacketScopeClause(
conditions.push(`split_part(${prefix}topic, '/', 1) <> 'meshcore-test'`);
}
if (placeholders.observerParam) {
conditions.push(`LOWER(${prefix}rx_node_id) = LOWER(${placeholders.observerParam})`);
conditions.push(`${prefix}rx_node_id = ${placeholders.observerParam}`);
}
return conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : '';
}
@@ -87,10 +87,9 @@ function buildNodeScopeClause(
${placeholders.networkParam} = 'teesside'
AND EXISTS (
SELECT 1
FROM packets p_scope
WHERE LOWER(p_scope.src_node_id) = LOWER(${prefix}node_id)
AND p_scope.network = 'teesside'
AND split_part(p_scope.topic, '/', 1) <> 'meshcore-test'
FROM node_network_sightings s
WHERE s.node_id = ${prefix}node_id
AND s.network = 'teesside'
)
)
)`,
@@ -101,13 +100,13 @@ function buildNodeScopeClause(
if (placeholders.observerParam) {
const observerNodeScope = [
`LOWER(${prefix}node_id) = LOWER(${placeholders.observerParam})`,
`${prefix}node_id = ${placeholders.observerParam}`,
`EXISTS (
SELECT 1
FROM packets p
WHERE LOWER(p.rx_node_id) = LOWER(${placeholders.observerParam})`,
WHERE p.rx_node_id = ${placeholders.observerParam}`,
placeholders.networkParam ? `AND p.network = ${placeholders.networkParam}` : '',
`AND LOWER(p.src_node_id) = LOWER(${prefix}node_id)
`AND p.src_node_id = ${prefix}node_id
)`,
].filter(Boolean).join(' ');
conditions.push(`(${observerNodeScope})`);
@@ -260,6 +259,7 @@ export async function insertPacket(p: {
const storedPayload = p.payload
? (p.summary ? { ...p.payload, _summary: p.summary } : p.payload)
: (p.summary ? { _summary: p.summary } : null);
const network = p.network ?? 'teesside';
await pool.query(
`INSERT INTO packets
(time, packet_hash, rx_node_id, src_node_id, topic, packet_type, route_type,
@@ -268,8 +268,16 @@ export async function insertPacket(p: {
[p.packetHash, p.rxNodeId, p.srcNodeId, p.topic, p.packetType,
p.routeType, p.hopCount, p.rssi, p.snr,
storedPayload ? JSON.stringify(storedPayload) : null, p.rawHex, p.advertCount ?? null,
p.pathHashes ?? null, inferredPathHashSizeBytes, p.network ?? 'teesside']
p.pathHashes ?? null, inferredPathHashSizeBytes, network]
);
if (p.srcNodeId && network !== 'test') {
pool.query(
`INSERT INTO node_network_sightings (node_id, network, last_seen_at)
VALUES ($1, $2, NOW())
ON CONFLICT (node_id, network) DO UPDATE SET last_seen_at = NOW()`,
[p.srcNodeId, network]
).catch(() => {}); // fire-and-forget, non-critical
}
}
export async function insertNodeStatusSample(sample: {
+9 -9
View File
@@ -89,13 +89,13 @@ export async function getOwnerNodeIdsForUsername(mqttUsername: string): Promise<
const normalized = mqttUsername.trim();
if (!normalized) return [];
const res = await ownerPool.query<{ node_id: string }>(
`SELECT DISTINCT LOWER(oan.node_id) AS node_id
`SELECT DISTINCT oan.node_id
FROM owner_account_nodes oan
JOIN owner_accounts oa ON oa.mqtt_username = oan.mqtt_username
WHERE oa.is_active = true
AND oan.mqtt_username = $1
AND oan.node_id ~ '^[0-9a-fA-F]{64}$'
ORDER BY LOWER(oan.node_id) ASC`,
AND oan.node_id ~ '^[0-9A-F]{64}$'
ORDER BY oan.node_id ASC`,
[normalized],
);
return res.rows.map((row) => row.node_id);
@@ -115,8 +115,8 @@ export async function ensureOwnerAccount(mqttUsername: string): Promise<void> {
export async function addOwnerNodeForUsername(mqttUsername: string, nodeId: string): Promise<void> {
const normalizedUsername = mqttUsername.trim();
const normalizedNodeId = nodeId.trim().toLowerCase();
if (!normalizedUsername || !/^[0-9a-f]{64}$/.test(normalizedNodeId)) return;
const normalizedNodeId = nodeId.trim().toUpperCase();
if (!normalizedUsername || !/^[0-9A-F]{64}$/.test(normalizedNodeId)) return;
await ensureOwnerAccount(normalizedUsername);
await ownerPool.query(
`INSERT INTO owner_account_nodes (mqtt_username, node_id)
@@ -128,8 +128,8 @@ export async function addOwnerNodeForUsername(mqttUsername: string, nodeId: stri
export async function upsertMqttNodeLogin(mqttUsername: string, nodeId: string): Promise<void> {
const normalizedUsername = mqttUsername.trim();
const normalizedNodeId = nodeId.trim().toLowerCase();
if (!normalizedUsername || !/^[0-9a-f]{64}$/.test(normalizedNodeId)) return;
const normalizedNodeId = nodeId.trim().toUpperCase();
if (!normalizedUsername || !/^[0-9A-F]{64}$/.test(normalizedNodeId)) return;
await ownerPool.query(
`INSERT INTO mqtt_node_logins (mqtt_username, node_id, last_connected_at)
VALUES ($1, $2, NOW())
@@ -153,9 +153,9 @@ export async function getBestNodeForMqttUsername(mqttUsername: string): Promise<
export async function getMappedOwnerNodeIds(): Promise<string[]> {
const res = await ownerPool.query<{ node_id: string }>(
`SELECT DISTINCT LOWER(node_id) AS node_id
`SELECT DISTINCT node_id
FROM owner_account_nodes
WHERE node_id ~ '^[0-9a-fA-F]{64}$'`,
WHERE node_id ~ '^[0-9A-F]{64}$'`,
);
return res.rows.map((row) => row.node_id);
}
+11
View File
@@ -20,6 +20,17 @@ CREATE TABLE IF NOT EXISTS nodes (
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tracks which networks each node has been heard on (maintained by insertPacket).
-- Used for cross-network node visibility (e.g. showing ukmesh nodes on teesside site).
-- Replaces expensive correlated EXISTS subquery against the packets hypertable.
CREATE TABLE IF NOT EXISTS node_network_sightings (
node_id TEXT NOT NULL,
network TEXT NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (node_id, network)
);
CREATE TABLE IF NOT EXISTS planned_nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_pubkey TEXT NOT NULL,
+26
View File
@@ -111,6 +111,7 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
const [
viewshedDepth,
linkDepth,
tileState,
viewshedRecent,
linkRecent,
viewshedLast,
@@ -122,6 +123,7 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
] = await Promise.all([
r.llen('meshcore:viewshed_jobs'),
r.llen('meshcore:link_jobs'),
r.hgetall('meshcore:tile_worker:state'),
query<{ count: string }>(`SELECT COUNT(*) AS count FROM node_coverage WHERE calculated_at > NOW() - INTERVAL '1 hour'`),
query<{ count: string }>(`SELECT COUNT(*) AS count FROM node_links WHERE itm_computed_at > NOW() - INTERVAL '1 hour'`),
query<{ ts: string | null }>(`SELECT MAX(calculated_at)::text AS ts FROM node_coverage`),
@@ -157,6 +159,19 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
const learningRecent = learningLast ? (Date.now() - Date.parse(learningLast)) <= 60 * 60_000 : false;
const backfillLinks = Number(backfillState.rows[0]?.links ?? 0);
const backfillLast = backfillState.rows[0]?.last_observed ?? null;
const tileStatus = tileState.status || 'idle';
const tileDone = Number(tileState.done_tiles ?? 0);
const tileTotal = Number(tileState.total_tiles ?? 0);
const tileRemaining = tileStatus === 'running'
? Math.max(0, Number.isFinite(Number(tileState.remaining_tiles)) ? Number(tileState.remaining_tiles) : tileTotal - tileDone)
: 0;
const tileLastActivity = tileState.updated_at || tileState.last_pass_finished_at || tileState.started_at || null;
const tileLastPassFinished = tileState.last_pass_finished_at ? Date.parse(tileState.last_pass_finished_at) : Number.NaN;
const tileProcessed1h = (
Number.isFinite(tileLastPassFinished) && (Date.now() - tileLastPassFinished) <= 60 * 60_000
)
? Number(tileState.last_pass_tiles ?? 0)
: (tileStatus === 'running' ? tileDone : 0);
return [
{
worker_name: 'viewshed-worker',
@@ -180,6 +195,17 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
mem_used_pct: memPct,
disk_used_pct: diskPct,
},
{
worker_name: 'tile-worker',
status: tileStatus,
queue_depth: tileRemaining,
processed_1h: tileProcessed1h,
last_activity_at: tileLastActivity,
cpu_load_1m: load,
cpu_usage_pct: stats.cpu.usage_pct,
mem_used_pct: memPct,
disk_used_pct: diskPct,
},
{
worker_name: 'path-learning',
status: learningRecent ? 'running' : 'idle',
+4
View File
@@ -11,6 +11,7 @@ import { startMqttConnectionMonitor } from './mqtt/connectionMonitor.js';
import { initWebSocketServer, broadcastPacket, broadcastNodeUpdate, broadcastNodeUpsert } from './ws/server.js';
import apiRoutes from './api/routes.js';
import { isViewshedEligibleCoordinate, queueViewshedJob, queueLinkJob } from './queue/publisher.js';
import { rebuildTileSnapshotFromDb, upsertTileSnapshotNode } from './tiles/snapshot.js';
const ALLOWED_ORIGINS = (process.env['ALLOWED_ORIGINS'] ?? '')
.split(',')
@@ -25,6 +26,8 @@ async function main() {
// 1. Initialise DB schema + retention policy
await initDb();
await initOwnerAuthDb();
const tileSnapshotCount = await rebuildTileSnapshotFromDb();
console.log(`[app] tile snapshot initialised (${tileSnapshotCount} nodes)`);
// Queue viewshed jobs for any node with a position but no coverage yet
// (catches nodes that existed before the worker was added)
@@ -66,6 +69,7 @@ async function main() {
onNodeSeen((nodeId, meta) => broadcastNodeUpdate(nodeId, meta));
onNodeUpsert((node) => {
broadcastNodeUpsert(node);
void upsertTileSnapshotNode(node).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
// Queue a viewshed job only for visible repeaters (role=2 or unknown)
const isHidden = typeof node.name === 'string' && node.name.includes('🚫');
const isNonRepeater = typeof node.role === 'number' && node.role !== 2;
+43 -12
View File
@@ -7,6 +7,7 @@ import type {
import { insertNodeStatusSample, insertPacket, upsertNode, incrementAdvertCount, query } from '../db/index.js';
import { invalidateResolveCache, setResolveCache } from '../path-beta/resolveCache.js';
import { resolvePool } from '../path-beta/resolvePool.js';
import { upsertTileSnapshotNode } from '../tiles/snapshot.js';
import type { LivePacket } from '../types/index.js';
import { decodePacketCompat } from './decodePacket.js';
@@ -461,7 +462,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
const firmware = json['firmware_version'] as string | undefined;
const nodeId = originId ?? observerKey;
void upsertNode(nodeId, {
upsertNode(nodeId, {
name: origin,
iata,
publicKey: originId,
@@ -469,12 +470,21 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
firmwareVersion: (firmware && firmware !== 'unknown') ? firmware : undefined,
network,
allowTestOverride: network === 'test' && nodeId === observerKey,
});
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
void upsertTileSnapshotNode({
node_id: nodeId,
name: origin,
iata,
public_key: originId,
network,
last_seen: new Date().toISOString(),
is_online: true,
}).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
const telemetry = extractStatusTelemetry(json, {
allowRawStatsOnly: network === 'test',
});
if (telemetry) {
void insertNodeStatusSample({
insertNodeStatusSample({
nodeId,
network,
batteryMv: telemetry.batteryMv,
@@ -484,7 +494,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
channelUtilization: telemetry.channelUtilization,
airUtilTx: telemetry.airUtilTx,
stats: telemetry.stats,
});
}).catch((err: Error) => console.error('[mqtt] insertNodeStatusSample error:', err.message));
}
emitNode(nodeId, { network, observerId: observerKey });
return;
@@ -555,7 +565,7 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
const nodeId = senderKey ?? observerKey;
if (network !== 'test') {
void upsertNode(nodeId, {
upsertNode(nodeId, {
name: appData?.['name'] as string | undefined,
lat: loc?.['latitude'],
lon: loc?.['longitude'],
@@ -563,10 +573,10 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
iata,
publicKey: senderKey,
network,
});
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
if (decodedHash && tryCountAdvert(decodedHash)) {
void incrementAdvertCount(nodeId);
incrementAdvertCount(nodeId).catch((err: Error) => console.error('[mqtt] incrementAdvertCount error:', err.message));
}
emitNodeUpsert({
@@ -583,6 +593,18 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
is_online: true,
advert_count: advertCount,
});
void upsertTileSnapshotNode({
node_id: nodeId,
name: appData?.['name'] as string | undefined,
lat: loc?.['latitude'],
lon: loc?.['longitude'],
role: appData?.['deviceRole'] as number | undefined,
iata,
network,
public_key: senderKey,
last_seen: new Date().toISOString(),
is_online: true,
}).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
}
innerPayload = inner;
@@ -635,22 +657,22 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
return;
}
void upsertNode(observerKey, {
upsertNode(observerKey, {
iata,
network,
allowTestOverride: network === 'test',
});
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
emitNode(observerKey, { network, observerId: observerKey });
if (useTxAdvertFallback && originId) {
await upsertNode(originId, {
upsertNode(originId, {
name: origin,
iata,
publicKey: originId,
network,
});
}).catch((err: Error) => console.error('[mqtt] upsertNode error:', err.message));
if (tryCountAdvert(finalHash)) {
void incrementAdvertCount(originId);
incrementAdvertCount(originId).catch((err: Error) => console.error('[mqtt] incrementAdvertCount error:', err.message));
}
emitNodeUpsert({
node_id: originId,
@@ -663,6 +685,15 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
is_online: true,
advert_count: advertCount,
});
void upsertTileSnapshotNode({
node_id: originId,
name: origin,
iata,
network,
public_key: originId,
last_seen: new Date().toISOString(),
is_online: true,
}).catch((err: Error) => console.error('[tile-snapshot] upsert failed:', err.message));
}
{
+8 -4
View File
@@ -26,8 +26,12 @@ async function processLine(line: string): Promise<void> {
if (!m) return;
const [, nodePrefix, mqttUsername] = m;
if (!nodePrefix || !mqttUsername || mqttUsername === 'backend') return;
const nodeId = await resolveNodeId(nodePrefix);
if (nodeId) await upsertMqttNodeLogin(mqttUsername, nodeId);
try {
const nodeId = await resolveNodeId(nodePrefix);
if (nodeId) await upsertMqttNodeLogin(mqttUsername, nodeId);
} catch (err) {
console.error('[conn-monitor] processLine error:', (err as Error).message);
}
}
async function scanRange(start: number, end: number): Promise<void> {
@@ -76,7 +80,7 @@ export function startMqttConnectionMonitor(): void {
}
}
void init();
setInterval(() => void poll(), POLL_INTERVAL_MS);
init().catch((err: Error) => console.error('[conn-monitor] init error:', err.message));
setInterval(() => poll().catch((err: Error) => console.error('[conn-monitor] poll error:', err.message)), POLL_INTERVAL_MS);
console.log('[conn-monitor] started, monitoring', LOG_PATH);
}
+1 -1
View File
@@ -1343,7 +1343,7 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
FROM packets
WHERE packet_hash = $1
AND ($2 = 'all' OR network = $2)
${observer ? 'AND LOWER(rx_node_id) = LOWER($3)' : ''}
${observer ? 'AND rx_node_id = $3' : ''}
ORDER BY COALESCE(cardinality(path_hashes), 0) DESC,
CASE WHEN path_hash_size_bytes IS NOT NULL THEN 1 ELSE 0 END DESC,
CASE WHEN src_node_id IS NOT NULL THEN 1 ELSE 0 END DESC,
+373
View File
@@ -0,0 +1,373 @@
/**
* Server-side node tile renderer.
* Generates 256×256 RGBA PNG tiles showing node dots using only Node built-ins
* (no native canvas/skia dependency — uses zlib for PNG deflate).
*/
import { deflate } from 'node:zlib';
import { promisify } from 'node:util';
const deflateAsync = promisify(deflate);
const TILE_SIZE = 256;
const BUFFER = 4;
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const PROHIBITED_MARKER = '\u{1F6AB}'; // 🚫
// ── Privacy masking (FNV-1a hash, port of frontend/src/utils/pathing.ts) ─────
function hashSeed(input: string): number {
let hash = 2166136261;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function stablePointWithinMiles(lat: number, lon: number, seed: string): [number, number] {
const radiusKm = 1.609344; // 1 mile
const distanceUnit = hashSeed(`${seed}:distance`) / 0xffffffff;
const bearingUnit = hashSeed(`${seed}:bearing`) / 0xffffffff;
const distanceKm = Math.sqrt(distanceUnit) * radiusKm;
const bearing = bearingUnit * Math.PI * 2;
const latRad = lat * (Math.PI / 180);
const dLat = (distanceKm / 111) * Math.cos(bearing);
const lonScale = Math.max(0.01, Math.cos(latRad));
const dLon = (distanceKm / (111 * lonScale)) * Math.sin(bearing);
return [lat + dLat, lon + dLon];
}
// ── Tile math ─────────────────────────────────────────────────────────────────
function tileBounds(z: number, x: number, y: number) {
const pow2 = Math.pow(2, z);
const lonW = (x / pow2) * 360 - 180;
const lonE = ((x + 1) / pow2) * 360 - 180;
const nN = Math.PI - (2 * Math.PI * y) / pow2;
const nS = Math.PI - (2 * Math.PI * (y + 1)) / pow2;
const latN = Math.atan(Math.sinh(nN)) * (180 / Math.PI);
const latS = Math.atan(Math.sinh(nS)) * (180 / Math.PI);
return { lonW, lonE, latN, latS };
}
function latToMercY(lat: number): number {
const latRad = lat * (Math.PI / 180);
return Math.log(Math.tan(Math.PI / 4 + latRad / 2));
}
// ── Pure-JS PNG encoder ───────────────────────────────────────────────────────
function crc32(buf: Uint8Array): number {
const table = crc32Table();
let crc = 0xffffffff;
for (const b of buf) {
crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff]!;
}
return (crc ^ 0xffffffff) >>> 0;
}
let _crcTable: Uint32Array | null = null;
function crc32Table(): Uint32Array {
if (_crcTable) return _crcTable;
_crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
_crcTable[n] = c;
}
return _crcTable;
}
function pngChunk(type: string, data: Uint8Array): Uint8Array {
const typeBytes = new TextEncoder().encode(type);
const out = new Uint8Array(4 + 4 + data.length + 4);
const view = new DataView(out.buffer);
view.setUint32(0, data.length);
out.set(typeBytes, 4);
out.set(data, 8);
const crcData = new Uint8Array(4 + data.length);
crcData.set(typeBytes);
crcData.set(data, 4);
view.setUint32(8 + data.length, crc32(crcData));
return out;
}
async function encodePng(pixels: Uint8Array, width: number, height: number): Promise<Buffer> {
// PNG signature
const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
// IHDR
const ihdr = new Uint8Array(13);
const ihdrView = new DataView(ihdr.buffer);
ihdrView.setUint32(0, width);
ihdrView.setUint32(4, height);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
// bytes 10-12: compression, filter, interlace = 0
// Raw scanlines with filter byte 0 prepended to each row
const stride = width * 4;
const filtered = new Uint8Array(height * (stride + 1));
for (let y = 0; y < height; y++) {
filtered[y * (stride + 1)] = 0; // filter type: None
filtered.set(pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
const compressed = await deflateAsync(Buffer.from(filtered.buffer));
const chunks = [
sig,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength)),
pngChunk('IEND', new Uint8Array(0)),
];
const total = chunks.reduce((sum, c) => sum + c.length, 0);
const out = Buffer.allocUnsafe(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
// ── Software rasteriser ───────────────────────────────────────────────────────
function blendPixel(pixels: Uint8Array, px: number, py: number, r: number, g: number, b: number, a: number) {
const xi = Math.round(px);
const yi = Math.round(py);
if (xi < 0 || xi >= TILE_SIZE || yi < 0 || yi >= TILE_SIZE) return;
const idx = (yi * TILE_SIZE + xi) * 4;
// Alpha-composite over existing pixel
const srcA = a / 255;
const dstA = pixels[idx + 3]! / 255;
const outA = srcA + dstA * (1 - srcA);
if (outA < 1e-6) return;
pixels[idx]! = Math.round((r * srcA + pixels[idx]! * dstA * (1 - srcA)) / outA);
pixels[idx + 1]! = Math.round((g * srcA + pixels[idx + 1]! * dstA * (1 - srcA)) / outA);
pixels[idx + 2]! = Math.round((b * srcA + pixels[idx + 2]! * dstA * (1 - srcA)) / outA);
pixels[idx + 3]! = Math.round(outA * 255);
}
/** Filled anti-aliased circle using radial distance */
function drawDot(pixels: Uint8Array, cx: number, cy: number, radius: number, r: number, g: number, b: number, a: number) {
const r0 = radius;
const x0 = Math.floor(cx - r0 - 1);
const x1 = Math.ceil(cx + r0 + 1);
const y0 = Math.floor(cy - r0 - 1);
const y1 = Math.ceil(cy + r0 + 1);
for (let py = y0; py <= y1; py++) {
for (let px = x0; px <= x1; px++) {
const dist = Math.hypot(px - cx, py - cy);
// Soft edge: full alpha inside, fades over 1px at edge
const alpha = Math.max(0, Math.min(1, r0 + 0.5 - dist));
if (alpha > 0) {
blendPixel(pixels, px, py, r, g, b, Math.round(a * alpha));
}
}
}
}
/** Dashed circle ring */
function drawDashedCircle(
pixels: Uint8Array, cx: number, cy: number, radius: number,
r: number, g: number, b: number, a: number,
dashLen = 4, gapLen = 6, lineWidth = 1.4,
) {
const circumference = 2 * Math.PI * radius;
const steps = Math.ceil(circumference * 3); // oversample for smooth curve
const halfW = lineWidth / 2;
for (let i = 0; i < steps; i++) {
const angle = (2 * Math.PI * i) / steps;
// Arc length position for dash/gap pattern
const arcLen = (angle / (2 * Math.PI)) * circumference;
const period = dashLen + gapLen;
const phase = arcLen % period;
if (phase > dashLen) continue; // in gap
const px = cx + Math.cos(angle) * radius;
const py = cy + Math.sin(angle) * radius;
drawDot(pixels, px, py, halfW, r, g, b, a);
}
}
// ── Node type ─────────────────────────────────────────────────────────────────
export type NodeRow = {
node_id: string;
name: string | null;
lat: number | null;
lon: number | null;
role: number | null;
last_seen: string;
is_online: boolean;
};
function isValidCoord(lat: number | null, lon: number | null): lat is number {
if (typeof lat !== 'number' || typeof lon !== 'number') return false;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
if (Math.abs(lat) < 5 && Math.abs(lon) < 5) return false;
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
}
// RGBA components at 0.9 alpha (230/255) — mirrors App.tsx gpuNodes colour logic
const COLORS = {
stale: [255, 68, 68, 178] as [number, number, number, number], // red, 0.7 alpha
offline: [100, 100, 100, 230] as [number, number, number, number],
companion: [251, 146, 60, 230] as [number, number, number, number],
room: [168, 85, 247, 230] as [number, number, number, number],
repeater: [ 34, 211, 238, 230] as [number, number, number, number],
} as const;
function nodeColor(node: NodeRow, now: number): [number, number, number, number] {
if (now - new Date(node.last_seen).getTime() > SEVEN_DAYS_MS) return COLORS.stale;
if (!node.is_online) return COLORS.offline;
if (node.role === 1) return COLORS.companion;
if (node.role === 3) return COLORS.room;
return COLORS.repeater;
}
// ── Empty tile (pre-computed once, reused for every tile with no nodes) ───────
// Lazily computed so module load stays fast.
let _emptyTile: Promise<Buffer> | null = null;
function emptyTile(): Promise<Buffer> {
if (_emptyTile) return _emptyTile;
_emptyTile = encodePng(new Uint8Array(TILE_SIZE * TILE_SIZE * 4), TILE_SIZE, TILE_SIZE);
return _emptyTile;
}
// ── Tile index (bucket nodes by tile coordinate per zoom, computed once) ──────
export type TileIndex = {
/** z → Map<"x:y", nodes that fall in that tile> */
byZoom: Map<number, Map<string, NodeRow[]>>;
/** Privacy nodes — included in every tile render (very rare, usually <5) */
prohibited: NodeRow[];
};
function nodeTileXY(lat: number, lon: number, z: number): [number, number] {
const pow2 = Math.pow(2, z);
const x = Math.floor((lon + 180) / 360 * pow2);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * pow2);
return [x, y];
}
/**
* Pre-bucket valid, non-stale nodes by tile coordinate for every zoom in
* [zMin, zMax]. Call once per network, then pass the result to
* renderTileFromIndex for fast per-tile rendering.
*/
export function buildTileIndex(nodes: NodeRow[], zMin: number, zMax: number): TileIndex {
const byZoom = new Map<number, Map<string, NodeRow[]>>();
const prohibited: NodeRow[] = [];
const now = Date.now();
// Initialise zoom maps
for (let z = zMin; z <= zMax; z++) byZoom.set(z, new Map());
for (const node of nodes) {
if (!isValidCoord(node.lat, node.lon)) continue;
if (now - new Date(node.last_seen).getTime() > FOURTEEN_DAYS_MS) continue;
if (node.name?.includes(PROHIBITED_MARKER)) {
prohibited.push(node);
continue;
}
for (let z = zMin; z <= zMax; z++) {
const [x, y] = nodeTileXY(node.lat, node.lon!, z);
const key = `${x}:${y}`;
const zMap = byZoom.get(z)!;
const bucket = zMap.get(key);
if (bucket) bucket.push(node);
else zMap.set(key, [node]);
}
}
return { byZoom, prohibited };
}
/**
* Render a tile using a pre-built index. Returns the cached empty PNG
* immediately when there are no nodes to draw.
*/
export function renderTileFromIndex(z: number, x: number, y: number, index: TileIndex): Promise<Buffer> {
const tileNodes = index.byZoom.get(z)?.get(`${x}:${y}`);
if (!tileNodes?.length && !index.prohibited.length) return emptyTile();
const combined = tileNodes ? [...tileNodes, ...index.prohibited] : index.prohibited;
return renderNodeTile(z, x, y, combined);
}
// ── Main export ───────────────────────────────────────────────────────────────
export async function renderNodeTile(z: number, x: number, y: number, nodes: NodeRow[]): Promise<Buffer> {
const { lonW, lonE, latN, latS } = tileBounds(z, x, y);
const lonRange = lonE - lonW;
const mercN = latToMercY(latN);
const mercS = latToMercY(latS);
const mercRange = mercN - mercS;
const pow2z = Math.pow(2, z);
const now = Date.now();
// Transparent pixel buffer: TILE_SIZE × TILE_SIZE × RGBA
const pixels = new Uint8Array(TILE_SIZE * TILE_SIZE * 4); // all zeros = transparent
const DOT_R = 3.5;
for (const node of nodes) {
if (!isValidCoord(node.lat, node.lon)) continue;
if (now - new Date(node.last_seen).getTime() > FOURTEEN_DAYS_MS) continue;
const isProhibited = Boolean(node.name?.includes(PROHIBITED_MARKER));
let dotLat = node.lat;
let dotLon = node.lon!;
let circleLat: number | null = null;
let circleLon: number | null = null;
if (isProhibited) {
const center = stablePointWithinMiles(node.lat, node.lon!, node.node_id);
const activityKey = node.last_seen ?? 'unknown';
const point = stablePointWithinMiles(center[0], center[1], `${node.node_id}|${activityKey}`);
circleLat = center[0];
circleLon = center[1];
dotLat = point[0];
dotLon = point[1];
}
// Compute pixel position for the dot
const mercY = latToMercY(dotLat);
const px = (dotLon - lonW) / lonRange * TILE_SIZE;
const py = (mercN - mercY) / mercRange * TILE_SIZE;
const [r, g, b, a] = nodeColor(node, now);
// Draw dashed circle for privacy nodes
if (isProhibited && circleLat !== null && circleLon !== null) {
const cMercY = latToMercY(circleLat);
const cpx = (circleLon - lonW) / lonRange * TILE_SIZE;
const cpy = (mercN - cMercY) / mercRange * TILE_SIZE;
const metersPerPixel = 156543.03 * Math.cos(circleLat * Math.PI / 180) / pow2z;
const radiusPx = 1609.344 / metersPerPixel;
if (radiusPx >= 4) {
drawDashedCircle(pixels, cpx, cpy, radiusPx, 245, 158, 11, 140);
}
}
// Skip dot if outside tile (with buffer)
if (px < -BUFFER || px > TILE_SIZE + BUFFER || py < -BUFFER || py > TILE_SIZE + BUFFER) continue;
drawDot(pixels, px, py, DOT_R, r, g, b, a);
// Stroke ring: slightly darker
drawDot(pixels, px, py, DOT_R + 0.8, Math.round(r * 0.65), Math.round(g * 0.65), Math.round(b * 0.65), Math.round(a * 0.85));
}
return encodePng(pixels, TILE_SIZE, TILE_SIZE);
}
+164
View File
@@ -0,0 +1,164 @@
import { Redis } from 'ioredis';
import { query } from '../db/index.js';
import type { NodeRow } from './renderer.js';
const TILE_SNAPSHOT_NODE_KEY_PREFIX = 'meshcore:tile_snapshot:node:';
const TILE_SNAPSHOT_IDS_ALL_KEY = 'meshcore:tile_snapshot:ids:all';
const TILE_SNAPSHOT_IDS_NETWORK_PREFIX = 'meshcore:tile_snapshot:ids:network:';
const TILE_SNAPSHOT_META_KEY = 'meshcore:tile_snapshot:meta';
type SnapshotNode = NodeRow & { network?: string | null };
let redisClient: Redis | null = null;
function redis(): Redis {
if (!redisClient) {
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
redisClient = new Redis(redisUrl);
redisClient.on('error', (err) => console.error('[tile-snapshot] redis error', err.message));
}
return redisClient;
}
function nodeKey(nodeId: string): string {
return `${TILE_SNAPSHOT_NODE_KEY_PREFIX}${nodeId}`;
}
function networkIdsKey(network: string): string {
return `${TILE_SNAPSHOT_IDS_NETWORK_PREFIX}${network}`;
}
function toNullableString(value: unknown): string | null {
if (value == null) return null;
const text = String(value).trim();
return text === '' ? null : text;
}
function toNullableNumber(value: unknown): number | null {
if (value == null || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function toBoolean(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 't') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'f') return false;
}
return fallback;
}
function normalizeSnapshotNode(value: Partial<SnapshotNode> & { node_id?: unknown; last_seen?: unknown }): SnapshotNode | null {
const nodeId = toNullableString(value.node_id);
const lastSeen = toNullableString(value.last_seen);
if (!nodeId || !lastSeen) return null;
return {
node_id: nodeId,
name: toNullableString(value.name),
lat: toNullableNumber(value.lat),
lon: toNullableNumber(value.lon),
role: toNullableNumber(value.role),
last_seen: lastSeen,
is_online: toBoolean(value.is_online, true),
network: toNullableString(value.network),
};
}
function parseSnapshotNode(raw: string | null): SnapshotNode | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<SnapshotNode>;
return normalizeSnapshotNode(parsed);
} catch {
return null;
}
}
export async function getTileSnapshotNodes(network?: string): Promise<NodeRow[]> {
const r = redis();
const ids = await r.smembers(network ? networkIdsKey(network) : TILE_SNAPSHOT_IDS_ALL_KEY);
if (ids.length < 1) return [];
const rows = await r.mget(ids.map((id) => nodeKey(id)));
return rows
.map(parseSnapshotNode)
.filter((node): node is SnapshotNode => Boolean(node))
.sort((a, b) => Date.parse(b.last_seen) - Date.parse(a.last_seen));
}
export async function rebuildTileSnapshotFromDb(): Promise<number> {
const res = await query<SnapshotNode>(
`SELECT node_id, name, lat, lon, role, last_seen::text AS last_seen, is_online, network
FROM nodes
WHERE network IS DISTINCT FROM 'test'`,
);
const r = redis();
const allIds = new Set<string>();
const seenNetworks = new Set<string>();
for (const row of res.rows) {
const network = toNullableString(row.network);
if (network) seenNetworks.add(network);
}
const pipe = r.pipeline();
pipe.del(TILE_SNAPSHOT_IDS_ALL_KEY);
for (const network of ['teesside', 'ukmesh', ...seenNetworks]) {
pipe.del(networkIdsKey(network));
}
for (const row of res.rows) {
const node = normalizeSnapshotNode(row);
if (!node) continue;
const serialized = JSON.stringify(node);
pipe.set(nodeKey(node.node_id), serialized);
pipe.sadd(TILE_SNAPSHOT_IDS_ALL_KEY, node.node_id);
allIds.add(node.node_id);
if (node.network) {
pipe.sadd(networkIdsKey(node.network), node.node_id);
}
}
pipe.hset(TILE_SNAPSHOT_META_KEY, {
refreshed_at: new Date().toISOString(),
node_count: String(allIds.size),
});
await pipe.exec();
return allIds.size;
}
export async function upsertTileSnapshotNode(node: Record<string, unknown>): Promise<void> {
const nodeId = toNullableString(node['node_id']);
if (!nodeId) return;
const r = redis();
const existing = parseSnapshotNode(await r.get(nodeKey(nodeId)));
const mergedInput: Partial<SnapshotNode> & { node_id: string; last_seen: string; is_online: boolean } = {
node_id: nodeId,
name: toNullableString(node['name']) ?? existing?.name ?? null,
lat: toNullableNumber(node['lat']) ?? existing?.lat ?? null,
lon: toNullableNumber(node['lon']) ?? existing?.lon ?? null,
role: toNullableNumber(node['role']) ?? existing?.role ?? null,
last_seen: toNullableString(node['last_seen']) ?? existing?.last_seen ?? new Date().toISOString(),
is_online: node['is_online'] != null ? toBoolean(node['is_online'], true) : (existing?.is_online ?? true),
network: toNullableString(node['network']) ?? existing?.network ?? null,
};
const merged = normalizeSnapshotNode(mergedInput);
if (!merged) return;
const pipe = r.pipeline();
if (existing?.network && existing.network !== merged.network) {
pipe.srem(networkIdsKey(existing.network), nodeId);
}
if (merged.network === 'test') {
pipe.srem(TILE_SNAPSHOT_IDS_ALL_KEY, nodeId);
if (existing?.network) pipe.srem(networkIdsKey(existing.network), nodeId);
pipe.del(nodeKey(nodeId));
} else {
pipe.set(nodeKey(nodeId), JSON.stringify(merged));
pipe.sadd(TILE_SNAPSHOT_IDS_ALL_KEY, nodeId);
if (merged.network) pipe.sadd(networkIdsKey(merged.network), nodeId);
pipe.hset(TILE_SNAPSHOT_META_KEY, 'updated_at', new Date().toISOString());
}
await pipe.exec();
}
+305
View File
@@ -0,0 +1,305 @@
/**
* Tile pre-rendering worker.
*
* On startup and every hour, renders all node tiles covering the UK
* (z=5–13) for each active network and writes them to Redis with a
* 90-minute TTL. Everything outside the UK bbox continues to render
* on-demand via the tile API route.
*
* Yields to the event loop every batch flush so tile compression
* does not monopolise the worker event loop.
* is never starved.
*/
import { Redis } from 'ioredis';
import { buildTileIndex, renderTileFromIndex } from './renderer.js';
import { getTileSnapshotNodes } from './snapshot.js';
// UK bounding box — same as the viewshed eligibility check in index.ts
const UK_LAT_MIN = 49.5;
const UK_LAT_MAX = 61.5;
const UK_LON_MIN = -8.5;
const UK_LON_MAX = 2.5;
export const UK_ZOOM_MIN = 5;
export const UK_ZOOM_MAX = 13;
// Tile TTL: 90 min. Worker runs every 60 min, so tiles never expire
// between passes. On-demand route also uses this TTL for UK tiles.
export const UK_TILE_TTL_MS = 90 * 60 * 1000;
const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const BATCH_SIZE = 50; // tiles per pipeline flush + event-loop yield
const TILE_WORKER_STATE_KEY = 'meshcore:tile_worker:state';
const TILE_WORKER_RESUME_MAX_AGE_MS = 2 * 60 * 60 * 1000;
// Networks to pre-render. Each entry is [db_network, cache_key_scope].
// 'all' scope uses undefined for getNodes() (returns all non-test nodes).
const NETWORKS: Array<{ db: string | undefined; scope: string }> = [
{ db: undefined, scope: 'all' },
{ db: 'teesside', scope: 'teesside' },
];
type TileCursor = {
scopeIndex: number;
z: number;
x: number;
y: number;
};
function firstCursor(): TileCursor {
const firstScopeIndex = 0;
const { xMin, yMin } = ukTileRange(UK_ZOOM_MIN);
return {
scopeIndex: firstScopeIndex,
z: UK_ZOOM_MIN,
x: xMin,
y: yMin,
};
}
function parseInteger(value: string | undefined): number | null {
if (value == null || value === '') return null;
const num = Number(value);
return Number.isInteger(num) ? num : null;
}
function parseResumeCursor(state: Record<string, string>): TileCursor | null {
if (state['status'] !== 'running') return null;
const updatedAt = state['updated_at'] ? Date.parse(state['updated_at']) : Number.NaN;
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt > TILE_WORKER_RESUME_MAX_AGE_MS) return null;
const scopeIndex = parseInteger(state['next_scope_index']);
const z = parseInteger(state['next_zoom']);
const x = parseInteger(state['next_x']);
const y = parseInteger(state['next_y']);
if (scopeIndex == null || z == null || x == null || y == null) return null;
if (scopeIndex < 0 || scopeIndex >= NETWORKS.length) return null;
if (z < UK_ZOOM_MIN || z > UK_ZOOM_MAX) return null;
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
if (x < xMin || x > xMax || y < yMin || y > yMax) return null;
return { scopeIndex, z, x, y };
}
function nextCursor(cursor: TileCursor): TileCursor | null {
const { scopeIndex, z, x, y } = cursor;
const range = ukTileRange(z);
if (y < range.yMax) {
return { scopeIndex, z, x, y: y + 1 };
}
if (x < range.xMax) {
return { scopeIndex, z, x: x + 1, y: range.yMin };
}
if (z < UK_ZOOM_MAX) {
const nextZ = z + 1;
const nextRange = ukTileRange(nextZ);
return { scopeIndex, z: nextZ, x: nextRange.xMin, y: nextRange.yMin };
}
if (scopeIndex + 1 < NETWORKS.length) {
const nextScopeIndex = scopeIndex + 1;
const nextRange = ukTileRange(UK_ZOOM_MIN);
return { scopeIndex: nextScopeIndex, z: UK_ZOOM_MIN, x: nextRange.xMin, y: nextRange.yMin };
}
return null;
}
function cursorMatches(scopeIndex: number, z: number, x: number, y: number, cursor: TileCursor): boolean {
return cursor.scopeIndex === scopeIndex && cursor.z === z && cursor.x === x && cursor.y === y;
}
export function isUkTile(z: number, x: number, y: number): boolean {
const pow2 = Math.pow(2, z);
const lonW = (x / pow2) * 360 - 180;
const lonE = ((x + 1) / pow2) * 360 - 180;
if (lonE < UK_LON_MIN || lonW > UK_LON_MAX) return false;
const nN = Math.PI - (2 * Math.PI * y) / pow2;
const nS = Math.PI - (2 * Math.PI * (y + 1)) / pow2;
const latN = Math.atan(Math.sinh(nN)) * (180 / Math.PI);
const latS = Math.atan(Math.sinh(nS)) * (180 / Math.PI);
return latN >= UK_LAT_MIN && latS <= UK_LAT_MAX;
}
function ukTileRange(z: number) {
const pow2 = Math.pow(2, z);
const xMin = Math.floor((UK_LON_MIN + 180) / 360 * pow2);
const xMax = Math.floor((UK_LON_MAX + 180) / 360 * pow2);
function mercY(lat: number) {
const r = lat * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * pow2);
}
return { xMin, xMax, yMin: mercY(UK_LAT_MAX), yMax: mercY(UK_LAT_MIN) };
}
function totalUkTiles(): number {
let n = 0;
for (let z = UK_ZOOM_MIN; z <= UK_ZOOM_MAX; z++) {
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
n += (xMax - xMin + 1) * (yMax - yMin + 1);
}
return n;
}
async function renderPass(redis: Redis): Promise<void> {
const tileCount = totalUkTiles();
const start = Date.now();
const totalTilesAllScopes = tileCount * NETWORKS.length;
const previousState = await redis.hgetall(TILE_WORKER_STATE_KEY);
const resumeCursor = parseResumeCursor(previousState);
const resumed = Boolean(resumeCursor);
const startCursor = resumeCursor ?? firstCursor();
await redis.hset(TILE_WORKER_STATE_KEY, {
status: 'running',
updated_at: new Date().toISOString(),
started_at: resumed ? (previousState['started_at'] || new Date(start).toISOString()) : new Date(start).toISOString(),
total_tiles: String(totalTilesAllScopes),
done_tiles: resumed ? String(Number(previousState['done_tiles'] ?? 0)) : '0',
remaining_tiles: resumed
? String(Math.max(0, totalTilesAllScopes - Number(previousState['done_tiles'] ?? 0)))
: String(totalTilesAllScopes),
scope: resumed ? String(previousState['scope'] ?? '') : '',
zoom: resumed ? String(previousState['zoom'] ?? '') : '',
next_scope_index: String(startCursor.scopeIndex),
next_zoom: String(startCursor.z),
next_x: String(startCursor.x),
next_y: String(startCursor.y),
resumed_from_checkpoint: resumed ? '1' : '0',
last_error: '',
});
let globalDone = resumed ? Number(previousState['done_tiles'] ?? 0) : 0;
if (resumed) {
console.log(
`[tile-worker] resuming pass from scope=${NETWORKS[startCursor.scopeIndex]?.scope ?? startCursor.scopeIndex} z=${startCursor.z} x=${startCursor.x} y=${startCursor.y} done=${globalDone.toLocaleString()}/${totalTilesAllScopes.toLocaleString()}`,
);
}
for (let scopeIndex = startCursor.scopeIndex; scopeIndex < NETWORKS.length; scopeIndex++) {
const { db, scope } = NETWORKS[scopeIndex]!;
const nodes = await getTileSnapshotNodes(db);
// Build spatial index once per network — buckets each node into the
// exact tile(s) it falls in at every zoom level. Avoids iterating
// all nodes for every tile.
const index = buildTileIndex(nodes, UK_ZOOM_MIN, UK_ZOOM_MAX);
console.log(`[tile-worker] ${scope} — ${nodes.length} nodes indexed (${index.prohibited.length} prohibited)`);
let done = 0;
const resumingThisScope = startCursor.scopeIndex === scopeIndex;
// Pipeline batches writes to Redis — 100 SET commands per round-trip
// instead of one per tile. We flush and yield to the event loop every
// BATCH_SIZE tiles so HTTP/WS serving is never starved.
let pipe = redis.pipeline();
let pipeCount = 0;
let pendingNextCursor: TileCursor | null = resumingThisScope ? startCursor : null;
const flush = async () => {
if (pipeCount === 0) return;
await pipe.exec();
pipe = redis.pipeline();
pipeCount = 0;
await redis.hset(TILE_WORKER_STATE_KEY, {
status: 'running',
updated_at: new Date().toISOString(),
done_tiles: String(globalDone),
remaining_tiles: String(Math.max(0, totalTilesAllScopes - globalDone)),
scope,
zoom: pendingNextCursor ? String(pendingNextCursor.z) : String(UK_ZOOM_MAX),
next_scope_index: pendingNextCursor ? String(pendingNextCursor.scopeIndex) : String(NETWORKS.length),
next_zoom: pendingNextCursor ? String(pendingNextCursor.z) : '',
next_x: pendingNextCursor ? String(pendingNextCursor.x) : '',
next_y: pendingNextCursor ? String(pendingNextCursor.y) : '',
});
await new Promise<void>((resolve) => setImmediate(resolve));
};
const startZoom = resumingThisScope ? startCursor.z : UK_ZOOM_MIN;
for (let z = startZoom; z <= UK_ZOOM_MAX; z++) {
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
const startX = resumingThisScope && z === startCursor.z ? startCursor.x : xMin;
for (let x = startX; x <= xMax; x++) {
const startY = resumingThisScope && z === startCursor.z && x === startCursor.x ? startCursor.y : yMin;
for (let y = startY; y <= yMax; y++) {
const key = `tile:nodes:${scope}:${z}:${x}:${y}`;
// Empty tiles return the cached 334-byte buffer instantly (no deflate).
const png = await renderTileFromIndex(z, x, y, index);
pipe.set(key, png, 'PX', UK_TILE_TTL_MS);
done++;
globalDone++;
pipeCount++;
pendingNextCursor = nextCursor({ scopeIndex, z, x, y });
if (pipeCount >= BATCH_SIZE) await flush();
}
}
await flush(); // flush remainder at end of each zoom level
await redis.hset(TILE_WORKER_STATE_KEY, {
status: 'running',
updated_at: new Date().toISOString(),
done_tiles: String(globalDone),
remaining_tiles: String(Math.max(0, totalTilesAllScopes - globalDone)),
scope,
zoom: String(z),
next_scope_index: pendingNextCursor ? String(pendingNextCursor.scopeIndex) : String(NETWORKS.length),
next_zoom: pendingNextCursor ? String(pendingNextCursor.z) : '',
next_x: pendingNextCursor ? String(pendingNextCursor.x) : '',
next_y: pendingNextCursor ? String(pendingNextCursor.y) : '',
});
const pct = Math.round(done / tileCount * 100);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`[tile-worker] ${scope} z=${z} — ${done.toLocaleString()}/${tileCount.toLocaleString()} tiles (${pct}%) — ${elapsed}s elapsed`);
}
await flush();
const totalElapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`[tile-worker] ${scope} complete — ${done.toLocaleString()} tiles in ${totalElapsed}s`);
}
await redis.hset(TILE_WORKER_STATE_KEY, {
status: 'idle',
updated_at: new Date().toISOString(),
done_tiles: String(totalTilesAllScopes),
remaining_tiles: '0',
last_pass_finished_at: new Date().toISOString(),
last_pass_tiles: String(totalTilesAllScopes),
next_scope_index: '',
next_zoom: '',
next_x: '',
next_y: '',
});
}
export function startTileWorker(): void {
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
const redis = new Redis(redisUrl);
redis.on('error', (e: Error) => console.error('[tile-worker] redis error', e.message));
let running = false;
const run = () => {
if (running) {
console.log('[tile-worker] skipping pass start — previous pass still running');
return;
}
running = true;
console.log(`[tile-worker] starting pass — ${totalUkTiles()} UK tiles × ${NETWORKS.length} networks`);
void renderPass(redis)
.catch(async (err: Error) => {
await redis.hset(TILE_WORKER_STATE_KEY, {
status: 'error',
updated_at: new Date().toISOString(),
last_error: err.message,
}).catch(() => {});
console.error('[tile-worker] pass failed:', err.message);
})
.finally(() => {
running = false;
});
};
// First pass after 5 s (let server finish starting up), then every hour.
setTimeout(run, 5_000);
setInterval(run, REFRESH_INTERVAL_MS);
}
+3
View File
@@ -1,6 +1,7 @@
import 'node:process';
import { initDb } from '../db/index.js';
import { captureWorkerHealthSnapshot } from '../health/status.js';
import { startTileWorker } from '../tiles/worker.js';
const SNAPSHOT_INTERVAL_MS = 60 * 1000;
@@ -19,6 +20,8 @@ async function main() {
setInterval(() => {
void captureOnce('scheduled');
}, SNAPSHOT_INTERVAL_MS);
startTileWorker();
}
main().catch((err) => {
+13
View File
@@ -0,0 +1,13 @@
import 'node:process';
import { initDb } from '../db/index.js';
import { startTileWorker } from '../tiles/worker.js';
async function main() {
await initDb();
startTileWorker();
}
main().catch((err) => {
console.error('[tile-worker] fatal startup error:', err);
process.exit(1);
});
+1 -1
View File
@@ -297,7 +297,7 @@ export function initWebSocketServer(httpServer: Server): WebSocketServer {
const scheduleFlush = () => {
if (flushTimeout !== null) return;
flushTimeout = setTimeout(flushMessageQueue, 50);
flushTimeout = setTimeout(flushMessageQueue, 16);
};
// Fan-out Redis messages to all connected WS clients - now batched
+23
View File
@@ -122,6 +122,29 @@ services:
redis:
condition: service_healthy
tile-worker:
build:
context: .
dockerfile: Dockerfile.backend
restart: unless-stopped
command: ["node", "dist/workers/tile.js"]
cpus: 1.0
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
DATABASE_APPLICATION_NAME: meshcore-tile-worker
DATABASE_POOL_MAX: 2
DATABASE_STATEMENT_TIMEOUT_MS: 10000
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
NODE_ENV: production
depends_on:
timescaledb:
condition: service_healthy
redis:
condition: service_healthy
backend:
condition: service_started
link-backfill-worker:
build:
context: .
+403 -17
View File
@@ -16,7 +16,9 @@
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1"
"react-leaflet": "^4.2.1",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.0"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
@@ -261,6 +263,14 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -352,21 +362,20 @@
}
},
"node_modules/@deck.gl/geo-layers": {
"version": "9.2.10",
"resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.10.tgz",
"integrity": "sha512-Rt271ANfwZbGQIH83yxvbxaguBK6QQ3OrdxT05dN0B2NxsBwzaFcnB1UTB0Fna5+SVGBwZAMUr0FzunG/V5ljA==",
"license": "MIT",
"version": "9.2.11",
"resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.11.tgz",
"integrity": "sha512-Mr3yvKyZMPmQ3ho0hSqcJu1p7a881RqQaq/dRaPs2VP56UAkfk1e10zxXnrZ9/Dmo2MR5PH0j8tkOoGR3zKbfA==",
"dependencies": {
"@loaders.gl/3d-tiles": "^4.3.4",
"@loaders.gl/gis": "^4.3.4",
"@loaders.gl/loader-utils": "^4.3.4",
"@loaders.gl/mvt": "^4.3.4",
"@loaders.gl/schema": "^4.3.4",
"@loaders.gl/terrain": "^4.3.4",
"@loaders.gl/tiles": "^4.3.4",
"@loaders.gl/wms": "^4.3.4",
"@luma.gl/gltf": "^9.2.6",
"@luma.gl/shadertools": "^9.2.6",
"@loaders.gl/3d-tiles": "~4.3.4",
"@loaders.gl/gis": "~4.3.4",
"@loaders.gl/loader-utils": "~4.3.4",
"@loaders.gl/mvt": "~4.3.4",
"@loaders.gl/schema": "~4.3.4",
"@loaders.gl/terrain": "~4.3.4",
"@loaders.gl/tiles": "~4.3.4",
"@loaders.gl/wms": "~4.3.4",
"@luma.gl/gltf": "~9.2.6",
"@luma.gl/shadertools": "~9.2.6",
"@math.gl/core": "^4.1.0",
"@math.gl/culling": "^4.1.0",
"@math.gl/web-mercator": "^4.1.0",
@@ -380,7 +389,7 @@
"@deck.gl/extensions": "~9.2.0",
"@deck.gl/layers": "~9.2.0",
"@deck.gl/mesh-layers": "~9.2.0",
"@loaders.gl/core": "^4.3.4",
"@loaders.gl/core": "~4.3.4",
"@luma.gl/core": "~9.2.6",
"@luma.gl/engine": "~9.2.6"
}
@@ -1873,6 +1882,60 @@
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"license": "MIT"
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2101,6 +2164,14 @@
"node": "*"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -2108,6 +2179,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-assert": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz",
@@ -2140,9 +2223,118 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2161,6 +2353,11 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/deep-strict-equal": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz",
@@ -2173,6 +2370,15 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/draco3d": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
@@ -2241,6 +2447,19 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-xml-parser": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz",
@@ -2351,6 +2570,14 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
@@ -2434,6 +2661,11 @@
"immediate": "~3.0.5"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/long": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz",
@@ -2528,6 +2760,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -2610,6 +2850,21 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
@@ -2641,6 +2896,11 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
@@ -2665,6 +2925,71 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -2680,6 +3005,36 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
@@ -2765,6 +3120,11 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
@@ -2836,6 +3196,11 @@
"texture-compressor": "bin/texture-compressor.js"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2893,6 +3258,27 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+2 -58
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Map as LeafletMap } from 'leaflet';
import { MapView } from './components/Map/MapView.js';
import type { DeckViewState, GPUNodeData } from './components/Map/DeckGLOverlay.js';
import type { DeckViewState } from './components/Map/DeckGLOverlay.js';
import { DeckGLOverlay } from './components/Map/DeckGLOverlay.js';
import { FilterPanel, type Filters } from './components/FilterPanel/FilterPanel.js';
import { PacketFeed } from './components/PacketFeed.js';
@@ -17,7 +17,7 @@ import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js';
import { useAppMessageHandler } from './hooks/useAppMessageHandler.js';
import { getCurrentSite } from './config/site.js';
import { uncachedEndpoint, withScopeParams } from './utils/api.js';
import { buildHiddenCoordMask, resolvePathNodeIds, hasCoords, maskNodePoint } from './utils/pathing.js';
import { buildHiddenCoordMask, resolvePathNodeIds, hasCoords } from './utils/pathing.js';
type PacketHistorySegment = {
positions: [[number, number], [number, number]];
@@ -60,7 +60,6 @@ export const App: React.FC = () => {
const [isPageVisible, setIsPageVisible] = useState(
() => (typeof document === 'undefined' ? true : document.visibilityState === 'visible'),
);
const [prefixFocusActive, setPrefixFocusActive] = useState(false);
const clashRestoreRef = useRef<{ coverage: boolean; clientNodes: boolean } | null>(null);
const prevHexClashesRef = useRef<boolean>(DEFAULT_FILTERS.hexClashes);
@@ -153,58 +152,6 @@ export const App: React.FC = () => {
return result;
}, [pinnedPacketSnapshot, filters.betaPaths, packets, nodes]);
// GPU node data — computed here so DeckGLOverlay can render dots without Leaflet SVG elements.
// Colors mirror the markerColor() logic in NodeMarker.tsx (hex-clash colours are never needed
// here because showGpuNodes=false during clash mode).
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const gpuNodes = useMemo<GPUNodeData[]>(() => {
const now = Date.now();
const result: GPUNodeData[] = [];
const addNode = (node: MeshNode, isInferredVariant: boolean) => {
if (!hasCoords(node)) return;
if (now - new Date(node.last_seen).getTime() > FOURTEEN_DAYS_MS) return;
if (pathNodeIds && !pathNodeIds.has(node.node_id.toLowerCase())) return;
const isStale = now - new Date(node.last_seen).getTime() > SEVEN_DAYS_MS;
const variant = (isInferredVariant || node.is_inferred)
? 'inferred'
: (node.role === 1 ? 'companion' : node.role === 3 ? 'room' : 'repeater');
const masked = maskNodePoint(node, hiddenCoordMask);
const maskedLat = masked?.[0] ?? node.lat!;
const maskedLon = masked?.[1] ?? node.lon!;
const alpha = 178; // 0.7 * 255
let color: [number, number, number, number];
if (isStale) color = [255, 68, 68, alpha];
else if (!node.is_online) color = [102, 102, 102, alpha];
else if (variant === 'companion') color = [255, 152, 0, alpha];
else if (variant === 'room') color = [206, 147, 216, alpha];
else if (variant === 'inferred') color = [109, 220, 122, 230];
else color = [ 0, 196, 255, alpha]; // repeater
result.push({ id: node.node_id, position: [maskedLon, maskedLat], color, radius: 3.5 });
};
for (const node of nodes.values()) {
const role = node.role;
if (role !== undefined && role !== 2 && role !== 1 && role !== 3) continue;
if ((role === 1 || role === 3) && !filters.clientNodes) continue;
addNode(node, false);
}
for (const node of inferredNodes) {
addNode(node, true);
}
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, inferredNodes, hiddenCoordMask, pathNodeIds, filters.clientNodes]);
const handlePrefixFocusActiveChange = useCallback((active: boolean) => {
setPrefixFocusActive(active);
}, []);
useEffect(() => {
if ('serviceWorker' in navigator) {
@@ -415,7 +362,6 @@ export const App: React.FC = () => {
hiddenCoordMask={hiddenCoordMask}
pathNodeIds={pathNodeIds}
onMapReady={setMap}
onPrefixFocusActiveChange={handlePrefixFocusActiveChange}
/>
<DeckGLOverlay
arcs={arcs}
@@ -427,8 +373,6 @@ export const App: React.FC = () => {
betaCompletionPaths={betaCompletionPaths}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
pathFadingOut={pathFadingOut}
gpuNodes={gpuNodes}
showGpuNodes={!filters.hexClashes && !prefixFocusActive}
viewState={deckViewState}
hiddenCoordMask={hiddenCoordMask}
/>
+1 -38
View File
@@ -10,7 +10,7 @@
*/
import React, { useMemo } from 'react';
import DeckGL from '@deck.gl/react';
import { ArcLayer, LineLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
import { ArcLayer, LineLayer, PathLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions';
import type { PathStyleExtensionProps } from '@deck.gl/extensions';
import type { PacketArc } from '../../hooks/useNodes.js';
@@ -38,13 +38,6 @@ type HistorySegmentWithColor = HistorySegment & {
width: number;
};
export interface GPUNodeData {
id: string;
position: [number, number]; // [lon, lat] — deck.gl / GeoJSON convention
color: [number, number, number, number]; // RGBA fill
radius: number; // pixels
}
interface Props {
// Live arc trails
arcs: PacketArc[];
@@ -62,10 +55,6 @@ interface Props {
/** When true, opacity transitions to 0 (deck.gl handles the interpolation). */
pathFadingOut: boolean;
// GPU-rendered node markers (repeaters, companions, room servers, inferred)
gpuNodes: GPUNodeData[];
showGpuNodes: boolean;
viewState: DeckViewState;
hiddenCoordMask: Map<string, HiddenMaskGeometry>;
}
@@ -92,35 +81,12 @@ function useDeckLayers(
betaCompletionPaths: [number, number][][],
showBetaPaths: boolean,
pathFadingOut: boolean,
gpuNodes: GPUNodeData[],
showGpuNodes: boolean,
hiddenCoordMask: Map<string, HiddenMaskGeometry>,
) {
return useMemo(() => {
const now = Date.now();
const layers = [];
// ── Node markers (replaces Leaflet CircleMarker SVG elements) ────────────
if (showGpuNodes && gpuNodes.length > 0) {
layers.push(
new ScatterplotLayer<GPUNodeData>({
id: 'node-markers',
data: gpuNodes,
getPosition: (d) => d.position,
getFillColor: (d) => d.color,
getLineColor: (d) => [d.color[0], d.color[1], d.color[2], 200] as [number, number, number, number],
getRadius: (d) => d.radius,
radiusUnits: 'pixels',
radiusMinPixels: 2,
stroked: true,
lineWidthUnits: 'pixels',
lineWidthMinPixels: 1,
getLineWidth: 1,
pickable: false,
}),
);
}
// ── Arc trails ───────────────────────────────────────────────────────────
if (showArcs && arcs.length > 0) {
const visible = arcs.filter((a) => now - a.ts < ARC_TTL_MS);
@@ -253,7 +219,6 @@ function useDeckLayers(
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
gpuNodes, showGpuNodes,
hiddenCoordMask,
]);
}
@@ -263,7 +228,6 @@ export const DeckGLOverlay: React.FC<Props> = React.memo(({
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
gpuNodes, showGpuNodes,
viewState, hiddenCoordMask,
}) => {
const layers = useDeckLayers(
@@ -271,7 +235,6 @@ export const DeckGLOverlay: React.FC<Props> = React.memo(({
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
gpuNodes, showGpuNodes,
hiddenCoordMask,
);
+26
View File
@@ -286,6 +286,14 @@ export const MapView = React.memo(({
};
}, [map]);
const [tilePending, setTilePending] = useState(0);
const tilePendingRef = useRef(0);
const tileHandlers = useMemo(() => ({
tileloadstart: () => { tilePendingRef.current += 1; setTilePending(tilePendingRef.current); },
tileload: () => { tilePendingRef.current = Math.max(0, tilePendingRef.current - 1); setTilePending(tilePendingRef.current); },
tileerror: () => { tilePendingRef.current = Math.max(0, tilePendingRef.current - 1); setTilePending(tilePendingRef.current); },
}), []);
const [deckViewState, setDeckViewState] = useState<DeckViewState>({
longitude: DEFAULT_CENTER[1], latitude: DEFAULT_CENTER[0], zoom: DEFAULT_ZOOM, pitch: 0, bearing: 0,
});
@@ -600,6 +608,12 @@ export const MapView = React.memo(({
return (
<div className="map-area">
<NodeSearch nodes={nodes} map={map} />
{gpuRendered && tilePending > 0 && (
<div className="tile-loading-badge">
<span className="tile-loading-badge__spinner" />
{tilePending} tile{tilePending !== 1 ? 's' : ''} rendering
</div>
)}
<MapContainer
ref={setMap}
center={DEFAULT_CENTER}
@@ -618,6 +632,18 @@ export const MapView = React.memo(({
updateWhenIdle={false}
/>
{/* Server-rendered node dot tiles */}
{gpuRendered && (
<TileLayer
url="/api/tiles/nodes/{z}/{x}/{y}.png"
tileSize={256}
zIndex={400}
updateWhenIdle={false}
keepBuffer={2}
eventHandlers={tileHandlers}
/>
)}
{/* Sync Leaflet map position to deck.gl */}
<LeafletDeckSyncer onViewStateChange={handleViewStateChange} />
+5 -19
View File
@@ -25,7 +25,7 @@ interface Props {
const VISIBLE_ROWS = 8;
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObserverCount = 0, onPacketClick, pinnedPacketId }) => {
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacketClick, pinnedPacketId }) => {
const visible = useMemo(
() => packets.filter((p) => p.packetType === 4 || p.packetType === 5).slice(0, VISIBLE_ROWS).reverse(),
[packets],
@@ -57,8 +57,6 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
? (TYPE_LABELS[p.packetType] ?? `T${p.packetType}`)
: '???';
const observerIata = p.rxNodeId ? nodes.get(p.rxNodeId)?.iata : undefined;
const allObserversHeard = mqttObserverCount > 1 && p.observerIds.length >= mqttObserverCount;
const rawContent = p.summary;
const content = rawContent?.includes('🚫') ? '[redacted]' : rawContent;
const display = content && content.length > 28
@@ -69,16 +67,6 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
? (p.advertCount === 1 ? 'NEW' : `${p.advertCount}`)
: undefined;
// Resolve observer IDs to names for display
const MAX_OBSERVER_NAMES = 3;
const observerNames = p.observerIds.map((id) => {
const node = nodes.get(id);
return node?.name ?? node?.iata ?? id.slice(0, 6);
});
const observerDisplay = observerNames.length > MAX_OBSERVER_NAMES
? [...observerNames.slice(0, MAX_OBSERVER_NAMES), `+${observerNames.length - MAX_OBSERVER_NAMES}`]
: observerNames;
const isPinned = pinnedPacketId === p.id;
return (
@@ -90,10 +78,8 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)}
>
{(observerIata || allObserversHeard) && (
<span className={`packet-item__iata${allObserversHeard ? ' packet-item__iata--all' : ''}`}>
{allObserversHeard ? 'ALL' : observerIata}
</span>
{observerIata && (
<span className="packet-item__iata">{observerIata}</span>
)}
{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && (
<span className="packet-item__path-bytes">{p.pathHashSizeBytes}</span>
@@ -109,8 +95,8 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, mqttObs
<span className="packet-item__hops">↑{p.hopCount}</span>
)}
<span className="packet-item__counts">
{observerDisplay.length > 0 && (
<span className="count count--rx">{observerDisplay.join(' · ')}</span>
{p.observerIds.length > 0 && (
<span className="count count--rx">{p.observerIds.length}rx</span>
)}
{p.txCount > 0 && <span className="count count--tx">{p.txCount}tx</span>}
</span>
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -196,7 +196,7 @@ export function useAppMessageHandler({
}, [handlePacket, handleNodeUpdate, handleNodeUpsert, handleCoverageUpdate, applyLinkUpdate, onPacketObserved]);
// Throttle flush — batches bursts from the WebSocket into single React renders
const BATCH_INTERVAL_MS = 100;
const BATCH_INTERVAL_MS = 16;
const scheduleFlush = useCallback(() => {
if (rafRef.current !== null) return;
rafRef.current = window.setTimeout(() => {
+2 -2
View File
@@ -3,12 +3,12 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { App } from './App.js';
import { Layout } from './pages/Layout.js';
import { HomePage } from './pages/HomePage.js';
import { InstallPage } from './pages/InstallPage.js';
import { OpenSourcePage } from './pages/OpenSourcePage.js';
import { StatsPage } from './pages/StatsPage.js';
import { PacketsPage } from './pages/PacketsPage.js';
import { OwnerPortalPage } from './pages/OwnerPortalPage.js';
import { TeessieDashboard } from './pages/teesside/TeessieDashboard.js';
import { UKLayout } from './pages/ukmesh/UKLayout.js';
import { UKHomePage } from './pages/ukmesh/UKHomePage.js';
import { UKInstallPage } from './pages/ukmesh/UKInstallPage.js';
@@ -62,8 +62,8 @@ ReactDOM.createRoot(root).render(
) : (
<BrowserRouter>
<Routes>
<Route index element={<TeessieDashboard />} />
<Route element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="about" element={<Navigate to="/" replace />} />
<Route path="install" element={<InstallPage />} />
<Route path="mqtt" element={<Navigate to="/install" replace />} />
-159
View File
@@ -1,159 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { LiveStatsSection } from '../components/LiveStatsSection.js';
import { getCurrentSite } from '../config/site.js';
export const HomePage: React.FC = () => {
const site = getCurrentSite();
return (
<>
<section className="site-home">
<div className="site-content site-home__grid">
<div className="site-home__intro">
<h1 className="site-home__title">Teesside Mesh Network</h1>
<p className="site-home__body">
A regional MeshCore deployment for Teesside and the wider North East. The website documents the
network, the live map shows what the observer hears, and the MQTT path lets repeater owners feed
packet data into the shared view.
</p>
<div className="site-home__actions">
<a href={site.appUrl} className="site-btn site-btn--primary">Open live map</a>
<Link to="/install" className="site-btn site-btn--ghost">Install MeshCore</Link>
<Link to="/stats" className="site-btn site-btn--ghost">Network stats</Link>
</div>
</div>
<section className="site-home__panel">
<h2>Network overview</h2>
<div className="site-home__meta">
<div className="site-home__meta-row">
<span>Coverage</span>
<strong>Teesside and North East England</strong>
</div>
<div className="site-home__meta-row">
<span>Band</span>
<strong>LoRa 868 MHz</strong>
</div>
<div className="site-home__meta-row">
<span>Channel</span>
<strong>Public</strong>
</div>
<div className="site-home__meta-row">
<span>Observer ingest</span>
<strong>MQTT via UK Mesh broker</strong>
</div>
</div>
<p>
Teesside views are filtered from the shared ingest path. Observers using IATA <strong>MME</strong>{' '}
show up here and in the wider UK stack.
</p>
</section>
</div>
</section>
<LiveStatsSection network={site.network} />
<section className="site-section">
<div className="site-content">
<div className="site-section__head">
<h2>What MeshCore is</h2>
<p>
MeshCore is open-source firmware for LoRa radios. Each node can relay packets, which is what lets
traffic move across repeaters long after it has left the original transmitter. This site tracks the
Teesside observer view, node positions, relay behaviour, and the public data that sits behind the live map.
</p>
</div>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-section__head">
<h2>Use the network</h2>
<p>Everything on the public site should answer one of three questions: what MeshCore is, how to join, and how to contribute useful coverage.</p>
</div>
<div className="site-home__cards">
<div className="site-home__card">
<h3>Get on the air</h3>
<p>
A handheld node is the fastest way in. Flash the firmware in a browser, pair it to your phone,
set the UK profile, and use the default Public channel.
</p>
<Link to="/install">Open the install guide</Link>
</div>
<div className="site-home__card">
<h3>Feed the dashboards</h3>
<p>
Repeater owners can run `meshcoretomqtt` on a Pi or other Linux host, publish packets over MQTT,
and contribute live telemetry back into the map and stats pages.
</p>
<Link to="/install">Observer setup</Link>
</div>
<div className="site-home__card">
<h3>Inspect live traffic</h3>
<p>
The map shows repeater locations, live packets, path predictions, coverage modelling, and the
stats pages that sit behind the public network view.
</p>
<a href={site.appUrl}>Open the live map</a>
</div>
</div>
</div>
</section>
<section className="site-section site-section--dark">
<div className="site-content">
<div className="site-section__head">
<h2>Radio profile</h2>
<p>These are the settings used on the Teesside side of the network.</p>
</div>
<div className="site-home__specs">
<div className="site-home__spec-row">
<span>Profile</span>
<strong>EU/UK Narrow</strong>
</div>
<div className="site-home__spec-row">
<span>Frequency</span>
<strong>869.618 MHz</strong>
</div>
<div className="site-home__spec-row">
<span>Bandwidth</span>
<strong>62.5 kHz</strong>
</div>
<div className="site-home__spec-row">
<span>Spreading factor</span>
<strong>SF8</strong>
</div>
<div className="site-home__spec-row">
<span>Coding rate</span>
<strong>CR8</strong>
</div>
</div>
</div>
</section>
<section className="site-section">
<div className="site-content">
<div className="site-home__join">
<div>
<h2>Join the conversation</h2>
<p>
The network is community-run. If you want help with hardware, range testing, observer setup, or
repeater placement, the Discord is where that happens.
</p>
</div>
<div className="site-home__join-actions">
<a href="https://discord.gg/bSuST8xvet" target="_blank" rel="noopener noreferrer" className="site-btn site-btn--primary">
Join Discord
</a>
<Link to="/open-source" className="site-btn site-btn--ghost">View source</Link>
</div>
</div>
</div>
</section>
</>
);
};
+34
View File
@@ -162,6 +162,40 @@ html, body, #root {
height: 100%;
}
.tile-loading-badge {
position: absolute;
bottom: 14px;
right: 14px;
z-index: 1000;
display: flex;
align-items: center;
gap: 6px;
background: rgba(15, 15, 20, 0.82);
border: 1px solid rgba(34, 211, 238, 0.25);
border-radius: 6px;
padding: 5px 10px;
font-size: 11px;
color: var(--text-muted);
backdrop-filter: blur(4px);
pointer-events: none;
white-space: nowrap;
}
.tile-loading-badge__spinner {
display: inline-block;
width: 8px;
height: 8px;
border: 1.5px solid rgba(34, 211, 238, 0.3);
border-top-color: rgba(34, 211, 238, 0.9);
border-radius: 50%;
animation: tile-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes tile-spin {
to { transform: rotate(360deg); }
}
.leaflet-container {
width: 100%;
height: 100%;