Rewrite map stack and tighten path topology

This commit is contained in:
Ben
2026-03-19 22:42:35 +00:00
parent 8b535b5eec
commit 8db9a71e43
47 changed files with 3344 additions and 3466 deletions
+9 -57
View File
@@ -3,11 +3,7 @@ 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 { getNodes, getNodeHistory, getNodeAdverts, getPathHistoryCache, getRecentPacketEvents, getRecentPackets, query } from '../db/index.js';
import { addOwnerNodeForUsername, getBestNodeForMqttUsername, getOwnerNodeIdsForUsername } from '../db/ownerAuth.js';
import { getWorkerHealthOverview } from '../health/status.js';
import { resolveRequestNetwork } from '../http/requestScope.js';
@@ -86,18 +82,6 @@ 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;
@@ -1195,9 +1179,10 @@ router.get('/nodes/:id/links', async (req, res) => {
CASE WHEN node_a_id = $1 THEN count_b_to_a ELSE count_a_to_b END AS count_peer_to_this
FROM node_links
LEFT JOIN nodes n ON n.node_id = CASE WHEN node_a_id = $1 THEN node_b_id ELSE node_a_id END
WHERE (node_a_id = $1 OR node_b_id = $1) AND (itm_viable = true OR force_viable = true) AND observed_count >= $2
WHERE (node_a_id = $1 OR node_b_id = $1)
AND (itm_viable = true OR force_viable = true)
ORDER BY observed_count DESC`,
[id, MIN_LINK_OBSERVATIONS],
[id],
);
res.json(result.rows);
} catch (err) {
@@ -1918,7 +1903,7 @@ router.get('/owner/live', async (req, res) => {
AND (
nl.force_viable = true
OR nl.itm_viable = true
OR (nl.itm_path_loss_db IS NOT NULL AND nl.itm_path_loss_db <= 137.88)
OR (nl.itm_path_loss_db IS NOT NULL AND nl.itm_path_loss_db <= 145.0)
)
ORDER BY
COALESCE(nl.itm_viable, false) DESC,
@@ -2695,12 +2680,14 @@ router.get('/observer-activity', EXPENSIVE_LIMITER, async (req, res) => {
conditions.push(`n.network = $${params.length}`);
}
const where = conditions.join(' AND ');
const result = await query<{ node_id: string; name: string | null; rx_24h: string; tx_24h: string }>(
const result = await query<{ node_id: string; name: string | null; rx_24h: string; tx_24h: string; last_tx: string | null; last_rx: string | null }>(
`SELECT
n.node_id,
n.name,
COUNT(p.packet_hash) FILTER (WHERE p.rx_node_id = n.node_id) AS rx_24h,
COUNT(p.packet_hash) FILTER (WHERE p.src_node_id = n.node_id) AS tx_24h
COUNT(p.packet_hash) FILTER (WHERE p.src_node_id = n.node_id) AS tx_24h,
MAX(p.time) FILTER (WHERE p.src_node_id = n.node_id)::text AS last_tx,
MAX(p.time) FILTER (WHERE p.rx_node_id = n.node_id)::text AS last_rx
FROM nodes n
JOIN packets p ON (p.rx_node_id = n.node_id OR p.src_node_id = n.node_id)
WHERE ${where}
@@ -2875,41 +2862,6 @@ 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) => {
+9 -9
View File
@@ -558,8 +558,8 @@ export const MIN_LINK_OBSERVATIONS = 5;
/** Returns only confirmed viable link pairs — compact for sending in initial WebSocket state. */
export async function getViableLinkPairs(network?: string, observer?: string): Promise<[string, string][]> {
const scope = buildScopePlaceholders(2, network, observer);
const params: unknown[] = [MIN_LINK_OBSERVATIONS, ...scope.params];
const scope = buildScopePlaceholders(1, network, observer);
const params: unknown[] = [...scope.params];
const res = await pool.query<{ node_a_id: string; node_b_id: string }>(
`SELECT nl.node_a_id, nl.node_b_id
@@ -567,7 +567,6 @@ export async function getViableLinkPairs(network?: string, observer?: string): P
JOIN nodes a ON a.node_id = nl.node_a_id
JOIN nodes b ON b.node_id = nl.node_b_id
WHERE (nl.itm_viable = true OR nl.force_viable = true)
AND nl.observed_count >= $1
${buildNodeScopeClause(scope, 'a')}
${buildNodeScopeClause(scope, 'b')}`,
params,
@@ -579,6 +578,7 @@ export type ViableLinkRow = {
node_a_id: string;
node_b_id: string;
observed_count: number;
multibyte_observed_count: number;
itm_viable: boolean | null;
itm_path_loss_db: number | null;
count_a_to_b: number;
@@ -594,34 +594,35 @@ export async function getViableLinks(network?: string, observer?: string): Promi
if (network && !observer) {
const res = await pool.query<ViableLinkRow>(
`WITH net_nodes AS (
SELECT DISTINCT node_id FROM nodes WHERE network = $2
SELECT DISTINCT node_id FROM nodes WHERE network = $1
)
SELECT
nl.node_a_id,
nl.node_b_id,
nl.observed_count,
nl.multibyte_observed_count,
nl.itm_viable,
nl.itm_path_loss_db,
nl.count_a_to_b,
nl.count_b_to_a
FROM node_links nl
WHERE (nl.itm_viable = true OR nl.force_viable = true)
AND nl.observed_count >= $1
AND nl.node_a_id IN (SELECT node_id FROM net_nodes)
AND nl.node_b_id IN (SELECT node_id FROM net_nodes)`,
[MIN_LINK_OBSERVATIONS, network],
[network],
);
return res.rows;
}
const scope = buildScopePlaceholders(2, network, observer);
const params: unknown[] = [MIN_LINK_OBSERVATIONS, ...scope.params];
const scope = buildScopePlaceholders(1, network, observer);
const params: unknown[] = [...scope.params];
const res = await pool.query<ViableLinkRow>(
`SELECT
nl.node_a_id,
nl.node_b_id,
nl.observed_count,
nl.multibyte_observed_count,
nl.itm_viable,
nl.itm_path_loss_db,
nl.count_a_to_b,
@@ -630,7 +631,6 @@ export async function getViableLinks(network?: string, observer?: string): Promi
JOIN nodes a ON a.node_id = nl.node_a_id
JOIN nodes b ON b.node_id = nl.node_b_id
WHERE (nl.itm_viable = true OR nl.force_viable = true)
AND nl.observed_count >= $1
${buildNodeScopeClause(scope, 'a')}
${buildNodeScopeClause(scope, 'b')}`,
params,
+1
View File
@@ -203,6 +203,7 @@ CREATE INDEX IF NOT EXISTS node_links_b_idx ON node_links(node_b_id);
ALTER TABLE node_links ADD COLUMN IF NOT EXISTS count_a_to_b INTEGER NOT NULL DEFAULT 0;
ALTER TABLE node_links ADD COLUMN IF NOT EXISTS count_b_to_a INTEGER NOT NULL DEFAULT 0;
ALTER TABLE node_links ADD COLUMN IF NOT EXISTS force_viable BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE node_links ADD COLUMN IF NOT EXISTS multibyte_observed_count INTEGER NOT NULL DEFAULT 0;
-- ─── Coverage polygons (one row per node, recalculated on position change) ───
-26
View File
@@ -111,7 +111,6 @@ async function currentWorkers(precomputedStats?: ReturnType<typeof systemStats>)
const [
viewshedDepth,
linkDepth,
tileState,
viewshedRecent,
linkRecent,
viewshedLast,
@@ -123,7 +122,6 @@ 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`),
@@ -159,19 +157,6 @@ 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',
@@ -195,17 +180,6 @@ 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',
+1 -5
View File
@@ -11,7 +11,6 @@ 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(',')
@@ -26,8 +25,6 @@ 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)
@@ -63,13 +60,12 @@ async function main() {
onPacket((packet) => {
broadcastPacket(packet);
if (packet.path?.length && packet.rxNodeId) {
queueLinkJob(packet.rxNodeId, packet.srcNodeId, packet.path, packet.hopCount);
queueLinkJob(packet.rxNodeId, packet.srcNodeId, packet.path, packet.hopCount, packet.pathHashSizeBytes);
}
});
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;
+15 -34
View File
@@ -7,7 +7,6 @@ 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';
@@ -471,15 +470,6 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
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',
});
@@ -593,18 +583,6 @@ 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;
@@ -685,15 +663,6 @@ 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));
}
{
@@ -755,7 +724,13 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
}
export async function backfillHistoricalLinks(
queueFn: (rxNodeId: string, srcNodeId: string | undefined, path: string[], hopCount: number | undefined) => void,
queueFn: (
rxNodeId: string,
srcNodeId: string | undefined,
path: string[],
hopCount: number | undefined,
pathHashSizeBytes: number | undefined,
) => void,
): Promise<void> {
const res = await query<{
rx_node_id: string; src_node_id: string | null; hop_count: number | null; raw_hex: string;
@@ -771,8 +746,14 @@ export async function backfillHistoricalLinks(
for (const row of res.rows) {
try {
const compat = decodePacketCompat(row.raw_hex, keyStore);
if (compat.pathHashes && compat.pathHashes.length > 0) {
queueFn(row.rx_node_id, row.src_node_id ?? undefined, compat.pathHashes, compat.pathHashCount);
if ((compat.pathHashSize ?? 1) > 1 && compat.pathHashes && compat.pathHashes.length > 0) {
queueFn(
row.rx_node_id,
row.src_node_id ?? undefined,
compat.pathHashes,
compat.pathHashCount,
compat.pathHashSize,
);
queued++;
}
} catch {
+1 -1
View File
@@ -2,7 +2,7 @@ export const MAX_BETA_HOPS = 25;
export const BETA_PURPLE_THRESHOLD = 0.45;
export const R_EFF_M = 6_371_000 / (1 - 0.25);
export const PREFIX_AMBIGUITY_FLOOR_KM = 45;
export const WEAK_LINK_PATHLOSS_MAX_DB = 137.88;
export const WEAK_LINK_PATHLOSS_MAX_DB = 145.0;
export const LOOSE_LINK_PATHLOSS_MAX_DB = 146.0;
// Hard block threshold: path loss high enough to indicate a genuine terrain barrier
// (well above the loose threshold — links just over 138dB may still work in practice)
+236 -46
View File
@@ -1,4 +1,4 @@
import { MIN_LINK_OBSERVATIONS, query, touchNodesPredictedOnline } from '../db/index.js';
import { query, touchNodesPredictedOnline } from '../db/index.js';
import {
buildNodePathHashIndex,
countNodesForPathHash,
@@ -103,6 +103,15 @@ function observerHopPrior(candidate: MeshNode, prevNode: MeshNode, hints: Observ
return clamp(weighted / totalWeight, -1, 1);
}
function linkColorPreference(meta: LinkMetrics | undefined): number {
if (!meta) return -0.08;
const pathLoss = meta.itm_path_loss_db;
if (pathLoss == null) return -0.04;
if (pathLoss <= 130) return 0.28; // green
if (pathLoss <= 138) return 0.14; // yellow/amber
return 0.02; // red
}
function confirmedLinkConfidence(
meta: LinkMetrics | undefined,
fromId: string,
@@ -116,15 +125,15 @@ function confirmedLinkConfidence(
let base: number;
if (pathLoss == null) {
base = observed >= 60 ? 0.68 : observed >= 30 ? 0.56 : 0.34;
} else if (pathLoss <= 120) {
base = 0.95;
} else if (pathLoss <= 125) {
base = 0.9;
} else if (pathLoss <= 130) {
base = 0.95;
} else if (pathLoss <= 134) {
base = 0.9;
} else if (pathLoss <= 138) {
base = 0.84;
} else if (pathLoss <= 133) {
} else if (pathLoss <= 142) {
base = 0.78;
} else if (pathLoss <= 135) {
} else if (pathLoss <= 145) {
base = 0.7;
} else {
base = 0.56;
@@ -141,7 +150,8 @@ function confirmedLinkConfidence(
+ Number(prior?.transition ?? 0)
+ Number(prior?.motif ?? 0)
+ Number(prior?.edge ?? 0)
+ Number(prior?.ambiguity ?? 0);
+ Number(prior?.ambiguity ?? 0)
+ linkColorPreference(meta);
return clamp(confidence + priorBoost, 0, 1);
}
@@ -173,9 +183,20 @@ function strongConfirmedFloor(meta: LinkMetrics | undefined): number {
if (!meta) return 0;
const observed = meta.observed_count ?? 0;
const pathLoss = meta.itm_path_loss_db;
if (observed >= 120 && pathLoss != null && pathLoss <= 125) return 0.82;
if (observed >= 70 && pathLoss != null && pathLoss <= 130) return 0.76;
if (observed >= 35 && pathLoss != null && pathLoss <= 133) return 0.70;
if (pathLoss == null) return 0;
if (pathLoss <= 130) {
if (observed >= 120) return 0.95;
if (observed >= 70) return 0.90;
if (observed >= 35) return 0.84;
}
if (pathLoss <= 145) {
if (observed >= 120) return 0.86;
if (observed >= 70) return 0.80;
if (observed >= 35) return 0.72;
}
if (observed >= 120) return 0.68;
if (observed >= 70) return 0.62;
if (observed >= 35) return 0.56;
return 0;
}
@@ -200,28 +221,57 @@ function edgeMetricConfidence(fromId: string, toId: string, linkMetrics: Map<str
const pathLoss = meta.itm_path_loss_db;
let base: number;
if (pathLoss == null) base = observed >= 60 ? 0.72 : observed >= 30 ? 0.62 : 0.45;
else if (observed >= 120 && pathLoss <= 125) base = 0.86;
else if (observed >= 70 && pathLoss <= 130) base = 0.80;
else if (observed >= 35 && pathLoss <= 133) base = 0.74;
else if (pathLoss <= 135) base = Math.min(0.72, 0.48 + Math.log10(1 + observed) * 0.10);
else if (observed >= 120 && pathLoss <= 134) base = 0.86;
else if (observed >= 70 && pathLoss <= 138) base = 0.80;
else if (observed >= 35 && pathLoss <= 142) base = 0.74;
else if (pathLoss <= 145) base = Math.min(0.72, 0.48 + Math.log10(1 + observed) * 0.10);
else base = Math.min(0.58, 0.40 + Math.log10(1 + observed) * 0.08);
if (observed >= 120) return Math.max(base, 0.82);
if (observed >= 70) return Math.max(base, 0.74);
if (observed >= 35) return Math.max(base, 0.62);
if (observed >= 20) return Math.max(base, 0.56);
return base;
if (observed >= 120) return Math.max(base + linkColorPreference(meta), 0.82);
if (observed >= 70) return Math.max(base + linkColorPreference(meta), 0.74);
if (observed >= 35) return Math.max(base + linkColorPreference(meta), 0.62);
if (observed >= 20) return Math.max(base + linkColorPreference(meta), 0.56);
return clamp(base + linkColorPreference(meta), 0, 1);
}
function purpleEdgeAllowed(
fromId: string,
toId: string,
nodesById: Map<string, MeshNode>,
coverageByNode: Map<string, number>,
linkMetrics: Map<string, LinkMetrics>,
): boolean {
const from = nodesById.get(fromId);
const to = nodesById.get(toId);
if (!hasCoords(from) || !hasCoords(to)) return false;
const distance = distKm(from, to);
if (distance > MAX_HOP_KM) return false;
const meta = linkMetrics.get(linkKey(fromId, toId));
if (isImpossibleLink(meta)) return false;
// A strongly-confirmed observed link is accepted as a lenient substitute for
// strict geometric LoS, because it is direct real-world evidence.
if (strongConfirmedFloor(meta) >= 0.70) return true;
if (!hasLoS(from, to)) return false;
return canReach(from, to, coverageByNode) || isWeakOrBetter(meta);
}
function splitResolvedAndAlternatives(
result: { path: [number, number][]; segmentConfidence: number[]; nodeIds: string[] },
threshold: number,
nodesById: Map<string, MeshNode>,
coverageByNode: Map<string, number>,
linkMetrics: Map<string, LinkMetrics>,
): { purplePath: [number, number][] | null; redPath: [number, number][] | null; remainingHops: number } {
const seg = result.segmentConfidence.map((v, i) => {
const fromId = result.nodeIds[i];
const toId = result.nodeIds[i + 1];
if (!fromId || !toId) return v;
if (!purpleEdgeAllowed(fromId, toId, nodesById, coverageByNode, linkMetrics)) return 0;
return Math.max(v, edgeMetricConfidence(fromId, toId, linkMetrics));
});
@@ -247,12 +297,15 @@ function splitResolvedAndAlternatives(
function splitResolvedFromSource(
result: { path: [number, number][]; segmentConfidence: number[]; nodeIds: string[] },
threshold: number,
nodesById: Map<string, MeshNode>,
coverageByNode: Map<string, number>,
linkMetrics: Map<string, LinkMetrics>,
): { purplePath: [number, number][] | null; remainingHops: number } {
const seg = result.segmentConfidence.map((v, i) => {
const fromId = result.nodeIds[i];
const toId = result.nodeIds[i + 1];
if (!fromId || !toId) return v;
if (!purpleEdgeAllowed(fromId, toId, nodesById, coverageByNode, linkMetrics)) return 0;
return Math.max(v, edgeMetricConfidence(fromId, toId, linkMetrics));
});
let keepEdges = 0;
@@ -503,6 +556,37 @@ function buildHashMatchedAnchors(
return anchors;
}
function buildResolvableMultibyteAnchors(
hops: string[],
candidates: MeshNode[],
excludeNodeIds: Set<string>,
): Map<number, MeshNode> {
const anchors = new Map<number, MeshNode>();
if (hops.length === 0 || candidates.length === 0) return anchors;
const normalizedLengths = Array.from(new Set(
hops
.map((hash) => normalizePathHash(hash))
.filter((hash): hash is string => Boolean(hash) && hash.length >= 4)
.map((hash) => hash.length),
));
if (normalizedLengths.length === 0) return anchors;
const eligibleCandidates = candidates.filter(
(n) => !excludeNodeIds.has(n.node_id) && hasCoords(n) && (n.role === null || n.role === 2),
);
if (eligibleCandidates.length === 0) return anchors;
const pathHashIndex = buildNodePathHashIndex(eligibleCandidates, normalizedLengths);
for (let i = 0; i < hops.length; i++) {
const hash = normalizePathHash(hops[i]);
if (!hash || hash.length < 4) continue;
const matches = getNodesForPathHash(pathHashIndex, hash);
if (matches.length === 1) anchors.set(i, matches[0]!);
}
return anchors;
}
function trimObserverTerminalHop(hops: string[], rx: MeshNode | null | undefined): string[] {
if (!rx || rx.role !== 2 || hops.length <= 1) return hops;
const terminal = normalizePathHash(hops[hops.length - 1]);
@@ -512,12 +596,25 @@ function trimObserverTerminalHop(hops: string[], rx: MeshNode | null | undefined
: hops;
}
function matchesObserverPathHash(rx: MeshNode | null | undefined, hash: string | null | undefined): boolean {
if (!rx || !hash) return false;
const normalized = normalizePathHash(hash);
if (!normalized) return false;
return nodePathHash(rx.node_id, normalized) === normalized;
}
function isObserverSelfEchoLoop(rawHops: string[], rx: MeshNode | null | undefined): boolean {
if (!rx || rx.role !== 2 || rawHops.length < 3) return false;
return matchesObserverPathHash(rx, rawHops[0]) && matchesObserverPathHash(rx, rawHops[rawHops.length - 1]);
}
type PreparedPacketObservation = {
packet: PathPacket;
rx: MeshNode | null;
hashes: string[];
rawHops: string[];
hops: string[];
ignoreForPathing: boolean;
};
function preparePacketObservation(packet: PathPacket, rx: MeshNode | null): PreparedPacketObservation {
@@ -529,16 +626,17 @@ function preparePacketObservation(packet: PathPacket, rx: MeshNode | null): Prep
const rawHops = packet.hop_count != null
? validatedHashes.slice(0, Math.max(0, packet.hop_count))
: validatedHashes;
const ignoreForPathing = isObserverSelfEchoLoop(rawHops, rx);
const hops = trimObserverTerminalHop(rawHops, rx);
return { packet, rx, hashes, rawHops, hops };
return { packet, rx, hashes, rawHops, hops, ignoreForPathing };
}
function compareCanonicalObserverObservation(a: PreparedPacketObservation, b: PreparedPacketObservation): number {
return a.hops.length - b.hops.length
|| a.rawHops.length - b.rawHops.length
return b.hops.length - a.hops.length
|| b.rawHops.length - a.rawHops.length
|| Number(Boolean(b.packet.path_hash_size_bytes)) - Number(Boolean(a.packet.path_hash_size_bytes))
|| Number(Boolean(b.packet.src_node_id)) - Number(Boolean(a.packet.src_node_id))
|| Number(a.packet.hop_count ?? Number.MAX_SAFE_INTEGER) - Number(b.packet.hop_count ?? Number.MAX_SAFE_INTEGER);
|| Number(b.packet.hop_count ?? 0) - Number(a.packet.hop_count ?? 0);
}
function comparePreferredResolvedObservation(a: PreparedPacketObservation, b: PreparedPacketObservation): number {
@@ -700,6 +798,8 @@ function resolveBetaPath(
function localPrefixAmbiguityPenalty(candidate: MeshNode, prevNode: MeshNode, pathHash: string): number {
const peers = getNodesForPathHash(pathHashIndex, pathHash);
if (peers.length <= 1) return 0;
if (pathHash.length >= 6 && peers.length <= 2) return 0;
if (pathHash.length >= 4 && peers.length <= 2) return 0.01;
const inRangeKm = Math.max(PREFIX_AMBIGUITY_FLOOR_KM, nodeRange(candidate.node_id, context.coverageByNode), nodeRange(prevNode.node_id, context.coverageByNode));
const candidateDist = distKm(candidate, prevNode);
@@ -728,8 +828,16 @@ function resolveBetaPath(
function hashUniquenessBoost(pathHash: string): number {
const matchCount = getNodesForPathHash(pathHashIndex, pathHash).length;
if (matchCount !== 1) return 0;
if (pathHash.length >= 6) return 0.22; // 3-byte hash, single global match: near-certain
if (pathHash.length >= 4) return 0.12; // 2-byte hash, single match: fairly certain
if (pathHash.length >= 6) return 0.42; // 3-byte hash, single global match: essentially definitive
if (pathHash.length >= 4) return 0.26; // 2-byte hash, single global match: very strong evidence
return 0;
}
function multibyteConfidenceFloor(pathHash: string): number {
const matchCount = getNodesForPathHash(pathHashIndex, pathHash).length;
if (matchCount !== 1) return 0;
if (pathHash.length >= 6) return 0.985;
if (pathHash.length >= 4) return 0.93;
return 0;
}
@@ -813,7 +921,10 @@ function resolveBetaPath(
function sortScore(c: MeshNode): number {
const corridorBonus = inCorridor(c, prevNode, prefix) ? 0.25 : -0.6;
const observerCollisionPenalty = isObserverTerminalCollision(c) ? 3.5 : 0;
return directionalPrior(c) + multiObserverPrior(c) * 1.4 - distKm(c, prevNode) / 50 + corridorBonus - observerCollisionPenalty;
const meta = context.linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
const linkBias = context.linkPairs.has(linkKey(c.node_id, prevNode.node_id)) ? 1.8 : -0.7;
const colorBias = linkColorPreference(meta) * 4.5;
return directionalPrior(c) + multiObserverPrior(c) * 1.4 - distKm(c, prevNode) / 50 + corridorBonus + linkBias + colorBias - observerCollisionPenalty;
}
const usedIds = new Set<string>();
@@ -845,14 +956,16 @@ function resolveBetaPath(
const directionalBoost = clamp(directionalPrior(c), -1, 1) * 0.08;
const observerHopBoost = clamp(multiObserverPrior(c), -1, 1) * OBSERVER_HOP_WEIGHT_CONFIRMED;
const confirmedFloor = strongConfirmedFloor(meta);
const multibyteFloor = multibyteConfidenceFloor(prefix);
const linkedPathBonus = 0.16;
const baseConf = confirmedLinkConfidence(meta, c.node_id, prevNode.node_id, {
prefix: priorBoost + directionalBoost + observerHopBoost + uniquenessBoost,
prefix: priorBoost + directionalBoost + observerHopBoost + uniquenessBoost + linkedPathBonus,
transition: transitionBoost,
motif: motifBoost,
edge: edgeBoost,
ambiguity: -ambiguityPenalty,
});
return { node: c, conf: Math.max(baseConf, confirmedFloor) };
return { node: c, conf: Math.max(baseConf, confirmedFloor, multibyteFloor) };
});
const reachable = all
@@ -872,6 +985,8 @@ function resolveBetaPath(
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 10)
.map((c) => {
const edgeKey = linkKey(c.node_id, prevNode.node_id);
const isLinked = context.linkPairs.has(edgeKey);
usedIds.add(c.node_id);
const distancePenalty = Math.min(0.12, distKm(c, prevNode) / 120);
const prior = distanceElevationPrior(c, prevNode);
@@ -882,11 +997,18 @@ function resolveBetaPath(
const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.28;
const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode, prefix);
const uniquenessBoost = hashUniquenessBoost(prefix);
const multibyteFloor = multibyteConfidenceFloor(prefix);
const directionalBoost = clamp(directionalPrior(c), -1, 1) * 0.1;
const observerHopBoost = clamp(multiObserverPrior(c), -1, 1) * OBSERVER_HOP_WEIGHT_REACHABLE;
const rawConf = Math.max(
multibyteFloor,
0.08,
0.2 + prior * 0.34 + prefixBoost + transitionBoost + motifBoost + edgeBoost + directionalBoost + observerHopBoost + uniquenessBoost - distancePenalty - ambiguityPenalty - (all.length - 1) * 0.01,
);
const nonLinkCap = 0.41;
return {
node: c,
conf: Math.max(0.08, 0.2 + prior * 0.34 + prefixBoost + transitionBoost + motifBoost + edgeBoost + directionalBoost + observerHopBoost + uniquenessBoost - distancePenalty - ambiguityPenalty - (all.length - 1) * 0.01),
conf: isLinked || multibyteFloor >= BETA_PURPLE_THRESHOLD ? rawConf : Math.min(rawConf, nonLinkCap),
};
});
@@ -905,6 +1027,8 @@ function resolveBetaPath(
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 6)
.map((c) => {
const edgeKey = linkKey(c.node_id, prevNode.node_id);
const isLinked = context.linkPairs.has(edgeKey);
const prior = distanceElevationPrior(c, prevNode);
const prefixBoost = prefixPrior(prefix, prevPrefix, c.node_id) * 0.16;
const transitionBoost = transitionPrior(c.node_id, prevNode.node_id) * 0.16;
@@ -912,11 +1036,17 @@ function resolveBetaPath(
const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.18;
const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode, prefix);
const uniquenessBoost = hashUniquenessBoost(prefix);
const multibyteFloor = multibyteConfidenceFloor(prefix);
const directionalBoost = clamp(directionalPrior(c), -1, 1) * 0.08;
const observerHopBoost = clamp(multiObserverPrior(c), -1, 1) * OBSERVER_HOP_WEIGHT_FALLBACK;
const rawConf = Math.max(
multibyteFloor,
Math.max(0.03, 0.04 + prior * 0.2 + prefixBoost + transitionBoost + motifBoost + edgeBoost + directionalBoost + observerHopBoost + uniquenessBoost - ambiguityPenalty) / Math.max(1, all.length),
);
const nonLinkCap = 0.28;
return {
node: c,
conf: Math.max(0.03, 0.04 + prior * 0.2 + prefixBoost + transitionBoost + motifBoost + edgeBoost + directionalBoost + observerHopBoost + uniquenessBoost - ambiguityPenalty) / Math.max(1, all.length),
conf: isLinked || multibyteFloor >= BETA_PURPLE_THRESHOLD ? rawConf : Math.min(rawConf, nonLinkCap),
};
});
@@ -940,10 +1070,11 @@ function resolveBetaPath(
const meta = context.linkMetrics.get(key);
const ambiguityPenalty = localPrefixAmbiguityPenalty(anchor, prevNode, prefix);
const uniquenessBoost = hashUniquenessBoost(prefix);
const multibyteFloor = multibyteConfidenceFloor(prefix);
const rawConf = meta
? confirmedLinkConfidence(meta, anchor.node_id, prevNode.node_id, { ambiguity: -ambiguityPenalty + uniquenessBoost })
: (edgeMetricConfidence(anchor.node_id, prevNode.node_id, context.linkMetrics) || ANCHOR_CONFIDENCE_DEFAULT) - ambiguityPenalty + uniquenessBoost;
const conf = Math.max(0.03, rawConf);
const conf = Math.max(0.03, rawConf, multibyteFloor);
const nextVisited = new Set(visited);
nextVisited.add(anchor.node_id);
const rest = solve(hopIdx - 1, anchor, prevNode.node_id, nextVisited);
@@ -1167,20 +1298,20 @@ async function loadContext(network: string): Promise<BetaResolveContext> {
node_a_id: string;
node_b_id: string;
observed_count: number;
multibyte_observed_count: number;
itm_path_loss_db: number | null;
itm_viable: boolean | null;
force_viable: boolean | null;
count_a_to_b: number | null;
count_b_to_a: number | null;
}>(
`SELECT nl.node_a_id, nl.node_b_id, nl.observed_count, nl.itm_path_loss_db, nl.itm_viable, nl.force_viable, nl.count_a_to_b, nl.count_b_to_a
`SELECT nl.node_a_id, nl.node_b_id, nl.observed_count, nl.multibyte_observed_count, nl.itm_path_loss_db, nl.itm_viable, nl.force_viable, nl.count_a_to_b, nl.count_b_to_a
FROM node_links nl
JOIN nodes a ON a.node_id = nl.node_a_id
JOIN nodes b ON b.node_id = nl.node_b_id
WHERE (nl.itm_viable IS NOT NULL OR nl.force_viable = true)
AND nl.observed_count >= $2
AND ($1 = 'all' OR (a.network = $1 AND b.network = $1))`,
[network, MIN_LINK_OBSERVATIONS],
[network],
),
buildLearningModel(network),
]);
@@ -1200,6 +1331,7 @@ async function loadContext(network: string): Promise<BetaResolveContext> {
if (row.itm_viable === true || row.force_viable === true) linkPairs.add(key);
linkMetrics.set(key, {
observed_count: Number(row.observed_count ?? 0),
multibyte_observed_count: Number(row.multibyte_observed_count ?? 0),
itm_path_loss_db: row.itm_path_loss_db == null ? null : Number(row.itm_path_loss_db),
itm_viable: row.itm_viable ?? null,
count_a_to_b: row.count_a_to_b == null ? null : Number(row.count_a_to_b),
@@ -1369,13 +1501,17 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
const key = row.rx_node_id ?? '__no_observer__';
const rxNode = row.rx_node_id ? (context.nodesById.get(row.rx_node_id) ?? null) : null;
const prepared = preparePacketObservation(row, rxNode);
if (prepared.ignoreForPathing) continue;
const existing = preparedByObserver.get(key);
if (!existing || compareCanonicalObserverObservation(prepared, existing) < 0) {
preparedByObserver.set(key, prepared);
}
}
const packet = Array.from(preparedByObserver.values()).sort(comparePreferredResolvedObservation)[0];
if (!packet) return null;
if (!packet) {
console.log(`[path-beta] hash=${packetHash} network=${network} mode=none reason=all-observations-ignored-self-echo`);
return null;
}
const hiddenCoordMask = buildHiddenCoordMask(context.nodesById);
const applyHiddenMask = (payload: BetaResolvedPayload) => maskResolvedPayload(payload, hiddenCoordMask);
const logPrefix = `[path-beta] hash=${packetHash} network=${network}`;
@@ -1465,8 +1601,28 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
const exactMultibyte = resolveExactMultibyteChain(hops, context);
if (exactMultibyte) {
await recordPredictedOnline(exactMultibyte.nodeIds);
const exactSplit = splitResolvedAndAlternatives(
{
path: exactMultibyte.path,
nodeIds: exactMultibyte.nodeIds,
segmentConfidence: exactMultibyte.nodeIds.slice(0, -1).map(() => 1),
},
BETA_PURPLE_THRESHOLD,
context.nodesById,
context.coverageByNode,
context.linkMetrics,
);
const purpleEdges = Math.max(0, (exactSplit.purplePath?.length ?? 0) - 1);
const redEdges = Math.max(0, (exactSplit.redPath?.length ?? 0) - 1);
const colorMode = purpleEdges > 0 && redEdges > 0
? 'mixed'
: purpleEdges > 0
? 'purple-only'
: redEdges > 0
? 'full-red'
: 'none';
console.log(
`${logPrefix} mode=resolved color=purple-only reason=exact-multibyte-chain conf=1.0000 threshold=${BETA_PURPLE_THRESHOLD.toFixed(2)} hops=${hops.length} purpleEdges=${Math.max(0, exactMultibyte.path.length - 1)} redEdges=0 remaining=0 rx=${packet.packet.rx_node_id ?? 'unknown'} src=${packet.packet.src_node_id ?? 'unknown'}`,
`${logPrefix} mode=resolved color=${colorMode} reason=exact-multibyte-chain conf=1.0000 threshold=${BETA_PURPLE_THRESHOLD.toFixed(2)} hops=${hops.length} purpleEdges=${purpleEdges} redEdges=${redEdges} remaining=${exactSplit.remainingHops} rx=${packet.packet.rx_node_id ?? 'unknown'} src=${packet.packet.src_node_id ?? 'unknown'}`,
);
return applyHiddenMask({
ok: true,
@@ -1474,11 +1630,11 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
mode: 'resolved',
confidence: 1,
permutationCount: 0,
remainingHops: 0,
purplePath: exactMultibyte.path,
remainingHops: exactSplit.remainingHops,
purplePath: exactSplit.purplePath,
extraPurplePaths: [],
redPath: null,
redSegments: [],
redPath: exactSplit.redPath,
redSegments: segmentizePath(exactSplit.redPath),
completionPaths: [],
threshold: BETA_PURPLE_THRESHOLD,
debug: {
@@ -1496,11 +1652,20 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
const excludeFromAnchors = new Set([rx.node_id, ...(src ? [src.node_id] : [])]);
const mqttNodes = Array.from(context.nodesById.values()).filter((n) => n.role === 2 && hasCoords(n));
const hashAnchors = buildHashMatchedAnchors(hops, mqttNodes, excludeFromAnchors);
const multibyteAnchors = buildResolvableMultibyteAnchors(
hops,
Array.from(context.nodesById.values()),
excludeFromAnchors,
);
const anchorNodes = new Map<number, MeshNode>(multibyteAnchors);
for (const [hopIdx, node] of hashAnchors) {
anchorNodes.set(hopIdx, node);
}
let result = resolveBetaPath(hops, hasCoords(src) ? src : null, rx, context, {
forceIncludeSource,
observerHopHints,
anchorNodes: hashAnchors.size > 0 ? hashAnchors : undefined,
anchorNodes: anchorNodes.size > 0 ? anchorNodes : undefined,
});
let solvedHopCount = hops.length;
let solverMode: 'full' | 'suffix-partial' = 'full';
@@ -1525,7 +1690,13 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
if (result) {
await recordPredictedOnline(result.nodeIds);
const split = splitResolvedAndAlternatives(result, BETA_PURPLE_THRESHOLD, context.linkMetrics);
const split = splitResolvedAndAlternatives(
result,
BETA_PURPLE_THRESHOLD,
context.nodesById,
context.coverageByNode,
context.linkMetrics,
);
let purplePath = split.purplePath;
const extraPurplePaths: [number, number][][] = [];
const unresolvedBySolver = Math.max(0, hops.length - solvedHopCount);
@@ -1571,7 +1742,13 @@ export async function resolveBetaPathForPacketHash(packetHash: string, network:
break;
}
if (sourcePartial) {
const sourceSplit = splitResolvedFromSource(sourcePartial, BETA_PURPLE_THRESHOLD, context.linkMetrics);
const sourceSplit = splitResolvedFromSource(
sourcePartial,
BETA_PURPLE_THRESHOLD,
context.nodesById,
context.coverageByNode,
context.linkMetrics,
);
const sourcePurplePath = sourceSplit.purplePath;
if (sourcePurplePath && sourcePurplePath.length >= 2) {
extraPurplePaths.push(sourcePurplePath);
@@ -1775,6 +1952,7 @@ export async function resolveMultiObserverBetaPath(
if (!row.rx_node_id) continue;
const rxNode = context.nodesById.get(row.rx_node_id) ?? null;
const prepared = preparePacketObservation(row, rxNode);
if (prepared.ignoreForPathing) continue;
const existing = byObserver.get(row.rx_node_id);
if (!existing || compareCanonicalObserverObservation(prepared, existing) < 0) {
byObserver.set(row.rx_node_id, prepared);
@@ -2118,7 +2296,13 @@ function buildResolvedPayload(
forceIncludeSource: boolean,
observerHopHints: ObserverHopHint[],
): BetaResolvedPayload {
const split = splitResolvedAndAlternatives(result, BETA_PURPLE_THRESHOLD, context.linkMetrics);
const split = splitResolvedAndAlternatives(
result,
BETA_PURPLE_THRESHOLD,
context.nodesById,
context.coverageByNode,
context.linkMetrics,
);
let purplePath = split.purplePath;
const extraPurplePaths: [number, number][][] = [];
let redPath = attachSrcToPath(split.redPath, purplePath, hasCoords(src) ? src : null, forceIncludeSource);
@@ -2148,7 +2332,13 @@ function buildResolvedPayload(
break;
}
if (sourcePartial) {
const sourceSplit = splitResolvedFromSource(sourcePartial, BETA_PURPLE_THRESHOLD, context.linkMetrics);
const sourceSplit = splitResolvedFromSource(
sourcePartial,
BETA_PURPLE_THRESHOLD,
context.nodesById,
context.coverageByNode,
context.linkMetrics,
);
const sourcePurplePath = sourceSplit.purplePath;
if (sourcePurplePath && sourcePurplePath.length >= 2) {
extraPurplePaths.push(sourcePurplePath);
+1
View File
@@ -11,6 +11,7 @@ export type MeshNode = {
export type LinkMetrics = {
observed_count: number;
multibyte_observed_count: number;
itm_path_loss_db: number | null;
itm_viable: boolean | null;
count_a_to_b: number | null;
+2 -4
View File
@@ -1,4 +1,4 @@
import { MIN_LINK_OBSERVATIONS, query } from '../db/index.js';
import { query } from '../db/index.js';
import {
buildNodePathHashIndex,
getNodesForPathHash,
@@ -202,10 +202,9 @@ async function rebuildNetwork(modelNetwork: string, sourceNetwork: string | unde
const nodeNetworkFilter = sourceNetwork ? 'AND network = $1' : '';
const packetNetworkFilter = sourceNetwork ? 'AND network = $1' : '';
const linkNetworkFilter = sourceNetwork ? 'AND a.network = $1 AND b.network = $1' : '';
const linkObsParam = sourceNetwork ? '$2' : '$1';
const nodeParams: unknown[] = sourceNetwork ? [sourceNetwork] : [];
const packetParams: unknown[] = sourceNetwork ? [sourceNetwork, MAX_TRAINING_PACKETS] : [MAX_TRAINING_PACKETS];
const linkParams: unknown[] = sourceNetwork ? [sourceNetwork, MIN_LINK_OBSERVATIONS] : [MIN_LINK_OBSERVATIONS];
const linkParams: unknown[] = sourceNetwork ? [sourceNetwork] : [];
const nodesResult = await query<LearningNode>(
`SELECT node_id, lat, lon, elevation_m, iata
@@ -227,7 +226,6 @@ async function rebuildNetwork(modelNetwork: string, sourceNetwork: string | unde
JOIN nodes a ON a.node_id = nl.node_a_id
JOIN nodes b ON b.node_id = nl.node_b_id
WHERE (nl.itm_viable = true OR nl.force_viable = true)
AND nl.observed_count >= ${linkObsParam}
${linkNetworkFilter}`,
linkParams,
);
+15 -1
View File
@@ -55,12 +55,26 @@ export function queueLinkJob(
srcNodeId: string | undefined,
pathHashes: string[],
hopCount: number | undefined,
pathHashSizeBytes: number | undefined,
): void {
if (!pathHashes.length) return;
if (!pathHashes.length || (pathHashSizeBytes ?? 1) <= 1) return;
void getPublisher().lpush(LINK_JOB_QUEUE, JSON.stringify({
type: 'observe',
rx_node_id: rxNodeId,
src_node_id: srcNodeId,
path_hashes: pathHashes,
hop_count: hopCount,
path_hash_size_bytes: pathHashSizeBytes,
}));
}
/** Push a physical pair evaluation job for two positioned repeater nodes. */
export function queuePhysicalLinkJob(nodeAId: string, nodeBId: string): void {
if (!nodeAId || !nodeBId || nodeAId === nodeBId) return;
const [aId, bId] = nodeAId < nodeBId ? [nodeAId, nodeBId] : [nodeBId, nodeAId];
void getPublisher().lpush(LINK_JOB_QUEUE, JSON.stringify({
type: 'physical_pair',
node_a_id: aId,
node_b_id: bId,
}));
}
-373
View File
@@ -1,373 +0,0 @@
/**
* 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
@@ -1,164 +0,0 @@
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
@@ -1,305 +0,0 @@
/**
* Tile pre-rendering worker.
*
* On startup and every hour, renders all node tiles covering the UK
* (z=513) 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,7 +1,6 @@
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;
@@ -20,8 +19,6 @@ async function main() {
setInterval(() => {
void captureOnce('scheduled');
}, SNAPSHOT_INTERVAL_MS);
startTileWorker();
}
main().catch((err) => {
+2 -2
View File
@@ -13,8 +13,8 @@ async function main() {
}
console.log('[backfill] node_links empty, starting historical link backfill');
await backfillHistoricalLinks((rxNodeId, srcNodeId, path, hopCount) => {
queueLinkJob(rxNodeId, srcNodeId, path, hopCount);
await backfillHistoricalLinks((rxNodeId, srcNodeId, path, hopCount, pathHashSizeBytes) => {
queueLinkJob(rxNodeId, srcNodeId, path, hopCount, pathHashSizeBytes);
});
}
+117
View File
@@ -0,0 +1,117 @@
import 'node:process';
import { Redis } from 'ioredis';
import { initDb, pool, query } from '../db/index.js';
import { backfillHistoricalLinks } from '../mqtt/client.js';
import { queueLinkJob, queuePhysicalLinkJob, closeQueuePublisher } from '../queue/publisher.js';
const LINK_JOB_QUEUE = 'meshcore:link_jobs';
const DEFAULT_PHYSICAL_RADIUS_KM = 60;
const MIN_PHYSICAL_RADIUS_KM = 20;
const MAX_PHYSICAL_RADIUS_KM = 100;
const PHYSICAL_RADIUS_MARGIN = 1.25;
type PhysicalNodeRow = {
node_id: string;
lat: number;
lon: number;
radius_m: number | null;
};
function distKm(a: PhysicalNodeRow, b: PhysicalNodeRow): number {
const cos = Math.cos(((a.lat + b.lat) / 2) * Math.PI / 180);
const dLat = (a.lat - b.lat) * 111.32;
const dLon = (a.lon - b.lon) * 111.32 * cos;
return Math.sqrt(dLat * dLat + dLon * dLon);
}
function candidateRadiusKm(node: PhysicalNodeRow): number {
const derived = node.radius_m != null ? (node.radius_m / 1000) * PHYSICAL_RADIUS_MARGIN : DEFAULT_PHYSICAL_RADIUS_KM;
return Math.min(MAX_PHYSICAL_RADIUS_KM, Math.max(MIN_PHYSICAL_RADIUS_KM, derived));
}
async function main() {
await initDb();
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
const redis = new Redis(redisUrl);
redis.on('error', (err: Error) => console.error('[link-recompute/redis] error:', err.message));
try {
const before = await query<{ count: string; forced: string }>(
`SELECT
COUNT(*)::text AS count,
COUNT(*) FILTER (WHERE force_viable = true)::text AS forced
FROM node_links`,
);
const row = before.rows[0];
const existingCount = Number(row?.count ?? 0);
const forcedCount = Number(row?.forced ?? 0);
console.log(`[link-recompute] existing node_links=${existingCount} forced_overrides=${forcedCount}`);
const clearedQueue = await redis.del(LINK_JOB_QUEUE).catch(() => 0);
console.log(`[link-recompute] cleared redis queue ${LINK_JOB_QUEUE}: removed=${clearedQueue}`);
await query(
`DELETE FROM node_links`,
);
const afterReset = await query<{ count: string; forced: string }>(
`SELECT
COUNT(*)::text AS count,
COUNT(*) FILTER (WHERE force_viable = true)::text AS forced
FROM node_links`,
);
const resetRow = afterReset.rows[0];
console.log(
`[link-recompute] node_links reset complete remaining_rows=${Number(resetRow?.count ?? 0)} forced_overrides=${Number(resetRow?.forced ?? 0)}`,
);
const nodes = await query<PhysicalNodeRow>(
`SELECT n.node_id, n.lat, n.lon, nc.radius_m
FROM nodes n
LEFT JOIN node_coverage nc ON nc.node_id = n.node_id
WHERE n.lat IS NOT NULL
AND n.lon IS NOT NULL
AND n.lat BETWEEN 49.5 AND 61.5
AND n.lon BETWEEN -8.5 AND 2.5
AND NOT (ABS(n.lat) < 1e-9 AND ABS(n.lon) < 1e-9)
AND (n.name IS NULL OR n.name NOT LIKE '%🚫%')
AND (n.role IS NULL OR n.role = 2)
ORDER BY n.node_id`,
);
let queuedPhysical = 0;
for (let i = 0; i < nodes.rows.length; i += 1) {
const a = nodes.rows[i]!;
const aRadiusKm = candidateRadiusKm(a);
for (let j = i + 1; j < nodes.rows.length; j += 1) {
const b = nodes.rows[j]!;
const maxRadiusKm = Math.max(aRadiusKm, candidateRadiusKm(b));
if (distKm(a, b) > maxRadiusKm) continue;
queuePhysicalLinkJob(a.node_id, b.node_id);
queuedPhysical += 1;
}
}
console.log(`[link-recompute] queued physical pair jobs=${queuedPhysical}`);
await backfillHistoricalLinks((rxNodeId, srcNodeId, path, hopCount, pathHashSizeBytes) => {
queueLinkJob(rxNodeId, srcNodeId, path, hopCount, pathHashSizeBytes);
});
const queuedDepth = await redis.llen(LINK_JOB_QUEUE).catch(() => -1);
console.log(`[link-recompute] historical link rebuild queued depth=${queuedDepth}`);
} finally {
await redis.quit().catch(() => {});
}
}
main()
.catch((err) => {
console.error('[link-recompute] fatal error:', err);
process.exit(1);
})
.finally(async () => {
await closeQueuePublisher();
await pool.end();
process.exit(0);
});
-13
View File
@@ -1,13 +0,0 @@
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 -23
View File
@@ -122,29 +122,6 @@ 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: .
@@ -196,6 +173,7 @@ services:
context: ./viewshed-worker
dockerfile: Dockerfile
restart: unless-stopped
cpus: 2.0
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
+311 -81
View File
@@ -9,19 +9,19 @@
"version": "1.0.0",
"dependencies": {
"@deck.gl/core": "^9.1.0",
"@deck.gl/extensions": "^9.1.0",
"@deck.gl/geo-layers": "^9.1.0",
"@deck.gl/layers": "^9.1.0",
"@deck.gl/react": "^9.1.0",
"leaflet": "^1.9.4",
"@deck.gl/mapbox": "^9.1.0",
"maplibre-gl": "^4.7.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.0"
"recharts": "^2.15.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
@@ -349,7 +349,6 @@
"resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.10.tgz",
"integrity": "sha512-GMKmps67kX2d4nMbEZYDxGDZWmHDQJkFa9YbL/kSbTyt8OpFe9H5zTuqNog3l75F9Fyop/nq7bQYD2pKOUGPBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@luma.gl/constants": "^9.2.6",
"@luma.gl/shadertools": "^9.2.6",
@@ -416,6 +415,21 @@
"@luma.gl/engine": "~9.2.6"
}
},
"node_modules/@deck.gl/mapbox": {
"version": "9.2.11",
"resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.11.tgz",
"integrity": "sha512-5OaFZgjyA4Vq6WjHUdcEdl0Phi8dwj8hSCErej0NetW90mctdbxwMt0gSbqcvWBowwhyj2QAhH0P2FcITjKG/A==",
"dependencies": {
"@luma.gl/constants": "~9.2.6",
"@math.gl/web-mercator": "^4.1.0"
},
"peerDependencies": {
"@deck.gl/core": "~9.2.0",
"@luma.gl/constants": "~9.2.6",
"@luma.gl/core": "~9.2.6",
"@math.gl/web-mercator": "^4.1.0"
}
},
"node_modules/@deck.gl/mesh-layers": {
"version": "9.2.10",
"resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.10.tgz",
@@ -436,32 +450,6 @@
"@luma.gl/shadertools": "~9.2.6"
}
},
"node_modules/@deck.gl/react": {
"version": "9.2.10",
"resolved": "https://registry.npmjs.org/@deck.gl/react/-/react-9.2.10.tgz",
"integrity": "sha512-TBbGFEKxP+nNQ+/n1B8UAmlyYdvbC63zBffKvY77ivFAJexPDWVq5bzuBmx2nqYV7y0s3QhdJpK3P6YYb14GIQ==",
"license": "MIT",
"peerDependencies": {
"@deck.gl/core": "~9.2.0",
"@deck.gl/widgets": "~9.2.0",
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/@deck.gl/widgets": {
"version": "9.2.10",
"resolved": "https://registry.npmjs.org/@deck.gl/widgets/-/widgets-9.2.10.tgz",
"integrity": "sha512-1pvFSTW2SzKJekBo4S7sUKud5w/Ms3y1aw9RqBf9LwquJapQXKSVBB5evzz1k7aUir+qRAFzrkV1poGytxg8CQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"preact": "^10.17.0"
},
"peerDependencies": {
"@deck.gl/core": "~9.2.0",
"@luma.gl/core": "~9.2.6"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1291,6 +1279,26 @@
"@luma.gl/core": "~9.2.0"
}
},
"node_modules/@mapbox/geojson-rewind": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"dependencies": {
"get-stream": "^6.0.1",
"minimist": "^1.2.6"
},
"bin": {
"geojson-rewind": "geojson-rewind"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/martini": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz",
@@ -1309,6 +1317,11 @@
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
},
"node_modules/@mapbox/vector-tile": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
@@ -1318,6 +1331,38 @@
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "20.4.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
"integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^2.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
},
"node_modules/@math.gl/core": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz",
@@ -1398,17 +1443,6 @@
"integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==",
"license": "MIT"
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1949,16 +1983,29 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"node_modules/@types/geojson-vt": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA=="
},
"node_modules/@types/mapbox__vector-tile": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
"dependencies": {
"@types/geojson": "*",
"@types/mapbox__point-geometry": "*",
"@types/pbf": "*"
}
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
@@ -1980,18 +2027,23 @@
"integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==",
"license": "MIT"
},
"node_modules/@types/pbf": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -2008,6 +2060,14 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2509,12 +2569,41 @@
"node": ">=6.9.0"
}
},
"node_modules/geojson-vt": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A=="
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/global-prefix": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
"integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
"dependencies": {
"ini": "^4.1.3",
"kind-of": "^6.0.3",
"which": "^4.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/h3-js": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz",
@@ -2570,6 +2659,14 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
"integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -2596,6 +2693,14 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
"engines": {
"node": ">=18"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2615,6 +2720,11 @@
"node": ">=6"
}
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2640,18 +2750,25 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA=="
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ktx-parse": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz",
"integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==",
"license": "MIT"
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -2710,6 +2827,51 @@
"integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==",
"license": "BSD-2-Clause"
},
"node_modules/maplibre-gl": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/tiny-sdf": "^2.0.6",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/maplibre-gl-style-spec": "^20.3.1",
"@types/geojson": "^7946.0.14",
"@types/geojson-vt": "3.2.5",
"@types/mapbox__point-geometry": "^0.1.4",
"@types/mapbox__vector-tile": "^1.3.4",
"@types/pbf": "^3.0.5",
"@types/supercluster": "^7.1.3",
"earcut": "^3.0.0",
"geojson-vt": "^4.0.2",
"gl-matrix": "^3.4.3",
"global-prefix": "^4.0.0",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^3.3.0",
"potpack": "^2.0.0",
"quickselect": "^3.0.0",
"supercluster": "^8.0.1",
"tinyqueue": "^3.0.0",
"vt-pbf": "^3.1.3"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/maplibre-gl/node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
@@ -2721,6 +2883,14 @@
"is-buffer": "~1.1.6"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mjolnir.js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz",
@@ -2734,6 +2904,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2833,16 +3008,10 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.28.4",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
@@ -2871,6 +3040,11 @@
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -2901,20 +3075,6 @@
"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",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3095,6 +3255,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
@@ -3183,6 +3348,14 @@
],
"license": "MIT"
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/texture-compressor": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz",
@@ -3201,6 +3374,11 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3339,12 +3517,36 @@
}
}
},
"node_modules/vt-pbf": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
"integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
"dependencies": {
"@mapbox/point-geometry": "0.1.0",
"@mapbox/vector-tile": "^1.3.1",
"pbf": "^3.2.1"
}
},
"node_modules/wgsl_reflect": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz",
"integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==",
"license": "MIT"
},
"node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
"dependencies": {
"isexe": "^3.1.1"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^16.13.0 || >=18.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -3358,6 +3560,34 @@
"integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==",
"license": "MIT",
"optional": true
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+5 -5
View File
@@ -10,19 +10,19 @@
},
"dependencies": {
"@deck.gl/core": "^9.1.0",
"@deck.gl/extensions": "^9.1.0",
"@deck.gl/geo-layers": "^9.1.0",
"@deck.gl/layers": "^9.1.0",
"@deck.gl/react": "^9.1.0",
"leaflet": "^1.9.4",
"@deck.gl/mapbox": "^9.1.0",
"maplibre-gl": "^4.7.1",
"polygon-clipping": "^0.15.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.0"
"recharts": "^2.15.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/leaflet": "^1.9.14",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
+53 -195
View File
@@ -1,23 +1,20 @@
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 } from './components/Map/DeckGLOverlay.js';
import { DeckGLOverlay } from './components/Map/DeckGLOverlay.js';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type maplibregl from 'maplibre-gl';
import { MapLibreMap } from './components/Map/MapLibreMap.js';
import { LiveOverlayController } from './components/Map/LiveOverlayController.js';
import { FilterPanel, type Filters } from './components/FilterPanel/FilterPanel.js';
import { PacketFeed } from './components/PacketFeed.js';
import { DisclaimerModal } from './components/app/DisclaimerModal.js';
import { AppTopBar } from './components/app/AppTopBar.js';
import { MobileControls } from './components/app/MobileControls.js';
import { useWebSocket } from './hooks/useWebSocket.js';
import { useNodes, type MeshNode } from './hooks/useNodes.js';
import { useCoverage } from './hooks/useCoverage.js';
import { nodeStore, type MeshNode } from './hooks/useNodes.js';
import { coverageStore, useCoverageLoader } from './hooks/useCoverage.js';
import { useDashboardStats, type DashboardStats } from './hooks/useDashboardStats.js';
import { useLinkState } from './hooks/useLinkState.js';
import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js';
import { linkStateStore } from './hooks/useLinkState.js';
import { useAppMessageHandler } from './hooks/useAppMessageHandler.js';
import { getCurrentSite } from './config/site.js';
import { uncachedEndpoint, withScopeParams } from './utils/api.js';
import { buildHiddenCoordMask, resolvePathNodeIds, hasCoords } from './utils/pathing.js';
type PacketHistorySegment = {
positions: [[number, number], [number, number]];
@@ -26,6 +23,7 @@ type PacketHistorySegment = {
const DEFAULT_FILTERS: Filters = {
livePackets: true,
links: false,
coverage: false,
clientNodes: false,
packetHistory: false,
@@ -50,8 +48,8 @@ export const App: React.FC = () => {
return DEFAULT_FILTERS;
}
});
const [map, setMap] = useState<LeafletMap | null>(null);
const [deckViewState, setDeckViewState] = useState<DeckViewState>({ longitude: -1.23, latitude: 54.57, zoom: 10, pitch: 0, bearing: 0 });
// MapLibre map instance — used by MobileControls/NodeSearch for flyTo
const [mlMap, setMlMap] = useState<maplibregl.Map | null>(null);
const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY));
const [inferredNodes, setInferredNodes] = useState<MeshNode[]>([]);
const [inferredActiveNodeIds, setInferredActiveNodeIds] = useState<Set<string>>(new Set());
@@ -63,95 +61,14 @@ export const App: React.FC = () => {
const clashRestoreRef = useRef<{ coverage: boolean; clientNodes: boolean } | null>(null);
const prevHexClashesRef = useRef<boolean>(DEFAULT_FILTERS.hexClashes);
const {
nodes,
packets,
arcs,
activeNodes,
handleInitialState,
replaceRecentPackets,
handlePacket,
handleNodeUpdate,
handleNodeUpdateBatch,
handleNodeUpsert,
handleNodeUpsertBatch,
} = useNodes();
const networkFilter = site.networkFilter;
const observerFilter = site.observerId;
// Coordinate privacy mask — computed once here and shared with MapView (for node markers /
// clash lines) and DeckGLOverlay (for GPU-rendered path/history layers).
const hiddenCoordMask = useMemo(() => buildHiddenCoordMask(nodes.values()), [nodes]);
const { coverage, handleCoverageUpdate, handleCoverageUpdateBatch } = useCoverage({ network: networkFilter, observer: observerFilter }, filters.coverage);
useCoverageLoader(
{ network: networkFilter, observer: observerFilter },
filters.coverage,
);
const stats = useDashboardStats(fetchedStats);
const {
linkMetrics,
viablePairsArr,
applyInitialViablePairs,
applyInitialViableLinks,
applyLinkUpdate,
applyLinkUpdateBatch,
} = useLinkState();
const {
betaPacketPaths,
betaLowConfidenceSegments,
betaCompletionPaths,
betaPathConfidence,
betaPermutationCount,
betaRemainingHops,
pathFadingOut,
pinnedPacketId,
pinnedPacketSnapshot,
handlePacketPin,
} = usePacketPathOverlay({
packets,
nodes,
filters,
network: networkFilter,
observer: observerFilter,
});
// Compute the set of node IDs involved in the currently displayed path.
// Active when: a packet is pinned, OR the live-path toggle is on (auto-tracks packets[0]).
// Passed to MapView so it can hide unrelated repeaters.
//
// Uses a ref-based stability guard: if the computed Set has identical contents to the
// previous result, the same reference is returned. This prevents MapView from re-rendering
// on every packet arrival when the active path packet hasn't actually changed.
const pathNodeIdsPrevRef = useRef<Set<string> | null>(null);
const pathNodeIds = useMemo<Set<string> | null>(() => {
const activePacket = pinnedPacketSnapshot ?? (filters.betaPaths ? (packets.find((p) => p.packetType === 4 || p.packetType === 5) ?? null) : null);
if (!activePacket) {
if (pathNodeIdsPrevRef.current !== null) pathNodeIdsPrevRef.current = null;
return null;
}
const srcNode = activePacket.srcNodeId ? (nodes.get(activePacket.srcNodeId) ?? null) : null;
const rxNode = activePacket.rxNodeId ? (nodes.get(activePacket.rxNodeId) ?? null) : null;
const srcWithCoords = srcNode && hasCoords(srcNode) ? srcNode as MeshNode & { lat: number; lon: number } : null;
const rxWithCoords = rxNode && hasCoords(rxNode) ? rxNode as MeshNode & { lat: number; lon: number }
: (() => {
for (const id of activePacket.observerIds) {
const n = nodes.get(id);
if (n && hasCoords(n)) return n as MeshNode & { lat: number; lon: number };
}
return null;
})();
const ids = resolvePathNodeIds(activePacket.path ?? [], srcWithCoords, rxWithCoords, nodes);
for (const id of activePacket.observerIds) ids.add(id.toLowerCase());
const result = ids.size > 0 ? ids : null;
// Stabilise reference: return previous set when contents are identical, so MapView's
// propsAreEqual check passes and it doesn't re-render just because packets changed.
const prev = pathNodeIdsPrevRef.current;
if (prev && result && prev.size === result.size && [...result].every((id) => prev.has(id))) {
return prev;
}
pathNodeIdsPrevRef.current = result;
return result;
}, [pinnedPacketSnapshot, filters.betaPaths, packets, nodes]);
useEffect(() => {
if ('serviceWorker' in navigator) {
@@ -170,14 +87,13 @@ export const App: React.FC = () => {
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
}, [filters]);
// Consolidated polling - fetches all data in parallel with a single timer
// Consolidated polling
useEffect(() => {
let cancelled = false;
const syncAllData = async () => {
if (!isPageVisible) return;
// Fetch all data in parallel
const [packetsRes, historyRes, inferredRes, statsRes] = await Promise.allSettled([
fetch(uncachedEndpoint(withScopeParams('/api/packets/recent?limit=12', { network: networkFilter, observer: observerFilter })), { cache: 'no-store' }),
fetch(uncachedEndpoint(withScopeParams('/api/path-beta/history', { network: networkFilter })), { cache: 'no-store' }),
@@ -187,87 +103,60 @@ export const App: React.FC = () => {
if (cancelled) return;
// Process packets
if (packetsRes.status === 'fulfilled' && packetsRes.value.ok) {
const rows = await packetsRes.value.json() as Array<{
time: string;
packet_hash: string;
rx_node_id?: string;
observer_node_ids?: string[] | null;
src_node_id?: string;
packet_type?: number;
hop_count?: number;
summary?: string | null;
payload?: Record<string, unknown>;
advert_count?: number | null;
time: string; packet_hash: string; rx_node_id?: string;
observer_node_ids?: string[] | null; src_node_id?: string;
packet_type?: number; hop_count?: number; summary?: string | null;
payload?: Record<string, unknown>; advert_count?: number | null;
path_hashes?: string[] | null;
}>;
if (!cancelled) replaceRecentPackets(rows);
if (!cancelled) nodeStore.replaceRecentPackets(rows);
}
// Process history
if (historyRes.status === 'fulfilled' && historyRes.value.ok) {
const payload = await historyRes.value.json() as { segments?: PacketHistorySegment[] };
const payload = await historyRes.value.json() as { segments?: PacketHistorySegment[] };
if (!cancelled) setPacketHistorySegments(Array.isArray(payload.segments) ? payload.segments : []);
}
// Process inferred nodes
if (inferredRes.status === 'fulfilled' && inferredRes.value.ok) {
const payload = await inferredRes.value.json() as {
inferredNodes: MeshNode[];
inferredActiveNodeIds: string[];
inferredNodes: MeshNode[]; inferredActiveNodeIds: string[];
};
if (!cancelled) {
setInferredNodes(payload.inferredNodes ?? []);
setInferredActiveNodeIds(new Set((payload.inferredActiveNodeIds ?? []).map((value) => value.toLowerCase())));
setInferredActiveNodeIds(new Set((payload.inferredActiveNodeIds ?? []).map((v) => v.toLowerCase())));
}
}
// Process stats (consolidates the previously separate 30s useDashboardStats poll)
if (statsRes.status === 'fulfilled' && statsRes.value.ok) {
const payload = await statsRes.value.json() as DashboardStats;
if (!cancelled) setFetchedStats(payload);
}
};
void syncAllData();
void syncAllData();
// Single timer: 10s when visible, 60s when hidden (reduced from 4s/30s to lower load)
const pollMs = isPageVisible ? 10000 : 60000;
const timer = window.setInterval(() => { void syncAllData(); }, pollMs);
const pollMs = isPageVisible ? 10000 : 60000;
const timer = window.setInterval(() => { void syncAllData(); }, pollMs);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [isPageVisible, networkFilter, observerFilter, replaceRecentPackets]);
// Removed: redundant secondary inferred-nodes poll (5-min interval).
// The consolidated polling loop above already fetches inferred-nodes every 10s
// and the server caches the result for 60s, so a second timer is pure overhead.
}, [isPageVisible, networkFilter, observerFilter]);
useEffect(() => {
const wasHexClashes = prevHexClashesRef.current;
const isHexClashes = filters.hexClashes;
if (!wasHexClashes && isHexClashes) {
clashRestoreRef.current = {
coverage: filters.coverage,
clientNodes: filters.clientNodes,
};
setFilters((current) => ({
...current,
coverage: false,
clientNodes: false,
}));
clashRestoreRef.current = { coverage: filters.coverage, clientNodes: filters.clientNodes };
setFilters((current) => ({ ...current, coverage: false, clientNodes: false }));
} else if (wasHexClashes && !isHexClashes && clashRestoreRef.current) {
const restore = clashRestoreRef.current;
clashRestoreRef.current = null;
setFilters((current) => ({
...current,
coverage: restore.coverage,
clientNodes: restore.clientNodes,
}));
setFilters((current) => ({ ...current, coverage: restore.coverage, clientNodes: restore.clientNodes }));
}
prevHexClashesRef.current = isHexClashes;
@@ -278,13 +167,7 @@ export const App: React.FC = () => {
void fetch('/api/telemetry/frontend-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind,
message,
stack,
page: window.location.href,
userAgent: navigator.userAgent,
}),
body: JSON.stringify({ kind, message, stack, page: window.location.href, userAgent: navigator.userAgent }),
}).catch(() => {});
};
@@ -310,18 +193,18 @@ export const App: React.FC = () => {
}, []);
const handleMessage = useAppMessageHandler({
handleInitialState,
handlePacket,
handleNodeUpdate,
handleNodeUpdateBatch,
handleNodeUpsert,
handleNodeUpsertBatch,
handleCoverageUpdate,
handleCoverageUpdateBatch,
applyInitialViablePairs,
applyInitialViableLinks,
applyLinkUpdate,
applyLinkUpdateBatch,
handleInitialState: nodeStore.handleInitialState,
handlePacket: nodeStore.handlePacket,
handleNodeUpdate: nodeStore.handleNodeUpdate,
handleNodeUpdateBatch: nodeStore.handleNodeUpdateBatch,
handleNodeUpsert: nodeStore.handleNodeUpsert,
handleNodeUpsertBatch: nodeStore.handleNodeUpsertBatch,
handleCoverageUpdate: coverageStore.handleCoverageUpdate,
handleCoverageUpdateBatch: coverageStore.handleCoverageUpdateBatch,
applyInitialViablePairs: linkStateStore.applyInitialViablePairs,
applyInitialViableLinks: linkStateStore.applyInitialViableLinks,
applyLinkUpdate: linkStateStore.applyLinkUpdate,
applyLinkUpdateBatch: linkStateStore.applyLinkUpdateBatch,
onPacketObserved: () => {
window.dispatchEvent(new Event('meshcore:packet-observed'));
},
@@ -339,62 +222,37 @@ export const App: React.FC = () => {
/>
<MobileControls
map={map}
nodes={nodes}
map={mlMap}
filters={filters}
onFiltersChange={setFilters}
/>
<div className="map-layer">
<MapView
nodes={nodes}
<MapLibreMap
inferredNodes={inferredNodes}
inferredActiveNodeIds={inferredActiveNodeIds}
activeNodes={activeNodes}
coverage={coverage}
onDeckViewStateChange={setDeckViewState}
showLinks={filters.links}
showCoverage={filters.coverage}
showClientNodes={filters.clientNodes}
showHexClashes={filters.hexClashes}
maxHexClashHops={filters.hexClashMaxHops}
viablePairsArr={viablePairsArr}
linkMetrics={linkMetrics}
hiddenCoordMask={hiddenCoordMask}
pathNodeIds={pathNodeIds}
onMapReady={setMap}
onMapReady={setMlMap}
/>
<DeckGLOverlay
arcs={arcs}
showArcs={filters.livePackets}
<LiveOverlayController
map={mlMap}
filters={filters}
network={networkFilter}
observer={observerFilter}
packetHistorySegments={packetHistorySegments}
showPacketHistory={filters.packetHistory}
betaPaths={betaPacketPaths}
betaLowSegments={betaLowConfidenceSegments}
betaCompletionPaths={betaCompletionPaths}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
pathFadingOut={pathFadingOut}
viewState={deckViewState}
hiddenCoordMask={hiddenCoordMask}
/>
</div>
<FilterPanel
filters={filters}
onChange={setFilters}
betaPathConfidence={betaPathConfidence}
betaPermutationCount={betaPermutationCount}
betaRemainingHops={betaRemainingHops}
/>
{filters.livePackets && (
<PacketFeed
packets={packets}
nodes={nodes}
mqttObserverCount={stats.mqttNodes}
onPacketClick={handlePacketPin}
pinnedPacketId={pinnedPacketId}
/>
)}
{filters.livePackets && <PacketFeed />}
{showDisclaimer && <DisclaimerModal onClose={dismissDisclaimer} />}
</div>
@@ -1,7 +1,9 @@
import React from 'react';
import { useOverlayStore } from '../../store/overlayStore.js';
export interface Filters {
livePackets: boolean;
links: boolean;
coverage: boolean;
clientNodes: boolean;
packetHistory: boolean;
@@ -23,9 +25,9 @@ export const LinksLegend: React.FC<{ compact?: boolean; muted?: boolean }> = ({
<div className={`links-legend-inline${compact ? ' links-legend-inline--compact' : ''}${muted ? ' links-legend-inline--muted' : ''}`}>
<div className="links-legend-inline__title">Links Legend</div>
<div className="links-legend-inline__grid">
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#22c55e' }} /> Good (120 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#fbbf24' }} /> Marginal (121-135 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#ef4444' }} /> Weak (&gt;135 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#22c55e' }} /> Good (130 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#fbbf24' }} /> Marginal (131-138 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#ef4444' }} /> Weak (&gt;138 dB)</div>
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#d1d5db' }} /> Unknown (no dB yet)</div>
</div>
</div>
@@ -33,6 +35,7 @@ export const LinksLegend: React.FC<{ compact?: boolean; muted?: boolean }> = ({
export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: string; hollow?: boolean }> = [
{ key: 'livePackets', label: 'Live Feed', color: '#00c4ff' },
{ key: 'links', label: 'Links', color: '#22c55e', hollow: true },
{ key: 'packetHistory', label: 'Paths', color: '#00c4ff', hollow: true },
{ key: 'betaPaths', label: 'Live Path', color: '#a855f7', hollow: true },
{ key: 'hexClashes', label: 'Hex Clashes', color: '#f97316' },
@@ -41,6 +44,12 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri
];
export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, betaPathConfidence, betaPermutationCount, betaRemainingHops }) => {
const liveBetaPathConfidence = useOverlayStore((state) => state.betaPathConfidence);
const liveBetaPermutationCount = useOverlayStore((state) => state.betaPermutationCount);
const liveBetaRemainingHops = useOverlayStore((state) => state.betaRemainingHops);
const resolvedConfidence = betaPathConfidence ?? liveBetaPathConfidence;
const resolvedPermutations = betaPermutationCount ?? liveBetaPermutationCount;
const resolvedRemainingHops = betaRemainingHops ?? liveBetaRemainingHops;
const toggle = (key: keyof Filters) => {
onChange({ ...filters, [key]: !filters[key] });
};
@@ -50,11 +59,11 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
<div className="filter-panel__title">Layers</div>
{filters.betaPaths && (
<div className="filter-beta-note">
Beta Confidence: <strong>{betaPathConfidence == null ? 'N/A' : `${Math.round(betaPathConfidence * 100)}%`}</strong>
Beta Confidence: <strong>{resolvedConfidence == null ? 'N/A' : `${Math.round(resolvedConfidence * 100)}%`}</strong>
<br />
Permutations: <strong>{betaPermutationCount == null ? 'N/A' : betaPermutationCount}</strong>
Permutations: <strong>{resolvedPermutations == null ? 'N/A' : resolvedPermutations}</strong>
<br />
Remaining Hops: <strong>{betaRemainingHops == null ? 'N/A' : betaRemainingHops}</strong>
Remaining Hops: <strong>{resolvedRemainingHops == null ? 'N/A' : resolvedRemainingHops}</strong>
</div>
)}
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
+180 -181
View File
@@ -1,36 +1,27 @@
/**
* DeckGLOverlay single WebGL canvas for all GPU-rendered map overlays.
* DeckGLOverlay all GPU-rendered map overlays via @deck.gl/mapbox.
*
* Consolidates packet arc trails, packet history link-segments, and beta path
* overlays into one DeckGL instance to avoid multiple WebGL contexts and to
* keep all rendering off the SVG/DOM layer.
*
* Replaces PacketArcLayer.tsx and the Leaflet Pane/Polyline overlays that
* previously lived in MapView (packet history, beta paths).
* Uses MapboxOverlay (works with MapLibre GL) to integrate deck.gl layers
* directly into the MapLibre map. No separate WebGL canvas or viewport sync
* needed deck.gl automatically follows the MapLibre viewport.
*/
import React, { useMemo } from 'react';
import DeckGL from '@deck.gl/react';
import React, { useEffect, useRef, useMemo } from 'react';
import { MapboxOverlay } from '@deck.gl/mapbox';
import { ArcLayer, LineLayer, PathLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions';
import type { PathStyleExtensionProps } from '@deck.gl/extensions';
import type { Layer } from '@deck.gl/core';
import type maplibregl from 'maplibre-gl';
import type { PacketArc } from '../../hooks/useNodes.js';
import type { HiddenMaskGeometry } from '../../utils/pathing.js';
import { maskPoint } from '../../utils/pathing.js';
const ARC_TTL_MS = 5_000;
const ARC_TTL_MS = 5_000;
const FADE_DURATION_MS = 1_000;
export interface DeckViewState {
longitude: number;
latitude: number;
zoom: number;
pitch: number;
bearing: number;
}
type HistorySegment = {
positions: [[number, number], [number, number]];
count: number;
count: number;
};
type HistorySegmentWithColor = HistorySegment & {
@@ -39,23 +30,23 @@ type HistorySegmentWithColor = HistorySegment & {
};
interface Props {
map: maplibregl.Map | null;
// Live arc trails
arcs: PacketArc[];
showArcs: boolean;
arcs: PacketArc[];
showArcs: boolean;
// Packet path history (link-segment heat map)
packetHistorySegments: HistorySegment[];
showPacketHistory: boolean;
showPacketHistory: boolean;
// Beta path overlays
betaPaths: [number, number][][];
betaLowSegments: [[number, number], [number, number]][];
betaPaths: [number, number][][];
betaLowSegments: [[number, number], [number, number]][];
betaCompletionPaths: [number, number][][];
showBetaPaths: boolean;
/** When true, opacity transitions to 0 (deck.gl handles the interpolation). */
pathFadingOut: boolean;
showBetaPaths: boolean;
pathFadingOut: boolean;
viewState: DeckViewState;
hiddenCoordMask: Map<string, HiddenMaskGeometry>;
}
@@ -71,7 +62,7 @@ function toXY(
// Shared PathStyleExtension instance for dashed paths — created once outside the component.
const DASH_EXT = [new PathStyleExtension({ dash: true, highPrecisionDash: true })];
function useDeckLayers(
function buildLayers(
arcs: PacketArc[],
showArcs: boolean,
packetHistorySegments: HistorySegment[],
@@ -82,175 +73,183 @@ function useDeckLayers(
showBetaPaths: boolean,
pathFadingOut: boolean,
hiddenCoordMask: Map<string, HiddenMaskGeometry>,
) {
return useMemo(() => {
const now = Date.now();
const layers = [];
): Layer[] {
const now = Date.now();
const layers: Layer[] = [];
// ── Arc trails ───────────────────────────────────────────────────────────
if (showArcs && arcs.length > 0) {
const visible = arcs.filter((a) => now - a.ts < ARC_TTL_MS);
if (visible.length > 0) {
const fade = (ts: number) => Math.max(0, 1 - (now - ts) / ARC_TTL_MS);
layers.push(
new ArcLayer<PacketArc>({
id: 'arc-bloom',
data: visible,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getSourceColor: (d) => [0, 196, 255, Math.round(35 * fade(d.ts))],
getTargetColor: (d) => [0, 196, 255, Math.round(70 * fade(d.ts))],
getWidth: 10,
getHeight: 0.15,
}),
new ArcLayer<PacketArc>({
id: 'arc-core',
data: visible,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getSourceColor: (d) => [120, 220, 255, Math.round(200 * fade(d.ts))],
getTargetColor: (d) => [200, 245, 255, Math.round(255 * fade(d.ts))],
getWidth: 2,
getHeight: 0.15,
}),
);
}
}
// ── Packet history heat map (replaces up to 700 SVG Polylines) ───────────
if (showPacketHistory && packetHistorySegments.length > 0) {
// Pre-compute colour and width once per useMemo update instead of per segment per frame
const historyWithColors: HistorySegmentWithColor[] = packetHistorySegments.map((d) => {
const s = Math.max(1, d.count);
const alpha = Math.min(0.82, 0.12 + Math.log10(s + 1) * 0.32);
return {
...d,
color: [168, 85, 247, Math.round(alpha * 255)] as [number, number, number, number],
width: Math.min(6, 1.2 + Math.log2(s + 1) * 1.05),
};
});
// ── Arc trails ─────────────────────────────────────────────────────────────
if (showArcs && arcs.length > 0) {
const visible = arcs.filter((a) => now - a.ts < ARC_TTL_MS);
if (visible.length > 0) {
const fade = (ts: number) => Math.max(0, 1 - (now - ts) / ARC_TTL_MS);
layers.push(
new LineLayer<HistorySegmentWithColor>({
id: 'packet-history',
data: historyWithColors,
getSourcePosition: (d) => toXY(d.positions[0], hiddenCoordMask),
getTargetPosition: (d) => toXY(d.positions[1], hiddenCoordMask),
getColor: (d) => d.color,
getWidth: (d) => d.width,
new ArcLayer<PacketArc>({
id: 'arc-bloom',
data: visible,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getSourceColor: (d) => [0, 196, 255, Math.round(35 * fade(d.ts))],
getTargetColor: (d) => [0, 196, 255, Math.round(70 * fade(d.ts))],
getWidth: 10,
getHeight: 0.15,
}),
new ArcLayer<PacketArc>({
id: 'arc-core',
data: visible,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getSourceColor: (d) => [120, 220, 255, Math.round(200 * fade(d.ts))],
getTargetColor: (d) => [200, 245, 255, Math.round(255 * fade(d.ts))],
getWidth: 2,
getHeight: 0.15,
}),
);
}
}
// ── Packet history heat map ────────────────────────────────────────────────
if (showPacketHistory && packetHistorySegments.length > 0) {
const historyWithColors: HistorySegmentWithColor[] = packetHistorySegments.map((d) => {
const s = Math.max(1, d.count);
const alpha = Math.min(0.82, 0.12 + Math.log10(s + 1) * 0.32);
return {
...d,
color: [168, 85, 247, Math.round(alpha * 255)] as [number, number, number, number],
width: Math.min(6, 1.2 + Math.log2(s + 1) * 1.05),
};
});
layers.push(
new LineLayer<HistorySegmentWithColor>({
id: 'packet-history',
data: historyWithColors,
getSourcePosition: (d) => toXY(d.positions[0], hiddenCoordMask),
getTargetPosition: (d) => toXY(d.positions[1], hiddenCoordMask),
getColor: (d) => d.color,
getWidth: (d) => d.width,
widthUnits: 'pixels',
widthMinPixels: 1,
pickable: false,
updateTriggers: {
getSourcePosition: hiddenCoordMask,
getTargetPosition: hiddenCoordMask,
},
}),
);
}
// ── Beta path overlays ─────────────────────────────────────────────────────
if (showBetaPaths) {
const targetOpacity = pathFadingOut ? 0 : 1;
const opacityTransition = { duration: pathFadingOut ? FADE_DURATION_MS : 0 };
if (betaLowSegments.length > 0) {
layers.push(
new PathLayer<[[number, number], [number, number]], PathStyleExtensionProps>({
id: 'beta-low-segs',
data: betaLowSegments,
getPath: (d) => [toXY(d[0], hiddenCoordMask), toXY(d[1], hiddenCoordMask)],
getColor: [239, 68, 68, 230],
getWidth: 2.6,
widthUnits: 'pixels',
widthMinPixels: 1,
getDashArray: [6, 9],
opacity: targetOpacity * 0.9,
transitions: { opacity: opacityTransition },
extensions: DASH_EXT,
pickable: false,
updateTriggers: {
getSourcePosition: hiddenCoordMask,
getTargetPosition: hiddenCoordMask,
},
updateTriggers: { getPath: hiddenCoordMask },
}),
);
}
// ── Beta path overlays (replaces Leaflet Pane with SVG Polylines) ────────
if (showBetaPaths) {
// Opacity fades smoothly to 0 when pathFadingOut; deck.gl interpolates the
// uniform between renders so we only need two React state changes (not 60/s rAF).
const targetOpacity = pathFadingOut ? 0 : 1;
const opacityTransition = { duration: pathFadingOut ? FADE_DURATION_MS : 0 };
if (betaLowSegments.length > 0) {
layers.push(
new PathLayer<[[number, number], [number, number]], PathStyleExtensionProps>({
id: 'beta-low-segs',
data: betaLowSegments,
getPath: (d) => [toXY(d[0], hiddenCoordMask), toXY(d[1], hiddenCoordMask)],
getColor: [239, 68, 68, 230],
getWidth: 2.6,
widthUnits: 'pixels',
getDashArray: [6, 9],
opacity: targetOpacity * 0.9,
transitions: { opacity: opacityTransition },
extensions: DASH_EXT,
pickable: false,
updateTriggers: { getPath: hiddenCoordMask },
}),
);
}
if (betaPaths.length > 0) {
layers.push(
new PathLayer<[number, number][], PathStyleExtensionProps>({
id: 'beta-purple',
data: betaPaths,
getPath: (d) => d.map((pt) => toXY(pt, hiddenCoordMask)),
getColor: [168, 85, 247, 255],
getWidth: 2.8,
widthUnits: 'pixels',
getDashArray: [6, 9],
opacity: targetOpacity * 0.75,
transitions: { opacity: opacityTransition },
extensions: DASH_EXT,
pickable: false,
updateTriggers: { getPath: hiddenCoordMask },
}),
);
}
if (betaCompletionPaths.length > 0) {
layers.push(
new PathLayer<[number, number][], PathStyleExtensionProps>({
id: 'beta-completion',
data: betaCompletionPaths,
getPath: (d) => d.map((pt) => toXY(pt, hiddenCoordMask)),
getColor: [239, 68, 68, 255],
getWidth: 1.8,
widthUnits: 'pixels',
getDashArray: [4, 7],
opacity: targetOpacity * 0.74,
transitions: { opacity: opacityTransition },
extensions: DASH_EXT,
pickable: false,
updateTriggers: { getPath: hiddenCoordMask },
}),
);
}
if (betaPaths.length > 0) {
layers.push(
new PathLayer<[number, number][], PathStyleExtensionProps>({
id: 'beta-purple',
data: betaPaths,
getPath: (d) => d.map((pt) => toXY(pt, hiddenCoordMask)),
getColor: [168, 85, 247, 255],
getWidth: 2.8,
widthUnits: 'pixels',
getDashArray: [6, 9],
opacity: targetOpacity * 0.75,
transitions: { opacity: opacityTransition },
extensions: DASH_EXT,
pickable: false,
updateTriggers: { getPath: hiddenCoordMask },
}),
);
}
return layers;
}, [
arcs, showArcs,
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
hiddenCoordMask,
]);
if (betaCompletionPaths.length > 0) {
layers.push(
new PathLayer<[number, number][], PathStyleExtensionProps>({
id: 'beta-completion',
data: betaCompletionPaths,
getPath: (d) => d.map((pt) => toXY(pt, hiddenCoordMask)),
getColor: [239, 68, 68, 255],
getWidth: 1.8,
widthUnits: 'pixels',
getDashArray: [4, 7],
opacity: targetOpacity * 0.74,
transitions: { opacity: opacityTransition },
extensions: DASH_EXT,
pickable: false,
updateTriggers: { getPath: hiddenCoordMask },
}),
);
}
}
return layers;
}
export const DeckGLOverlay: React.FC<Props> = React.memo(({
export const DeckGLOverlay: React.FC<Props> = ({
map,
arcs, showArcs,
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
viewState, hiddenCoordMask,
hiddenCoordMask,
}) => {
const layers = useDeckLayers(
arcs, showArcs,
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
hiddenCoordMask,
const overlayRef = useRef<MapboxOverlay | null>(null);
// Create/destroy the MapboxOverlay when the map instance changes
useEffect(() => {
if (!map) return;
const overlay = new MapboxOverlay({ interleaved: false, layers: [] });
// MapboxOverlay implements IControl — addControl works with MapLibre GL
map.addControl(overlay as unknown as maplibregl.IControl);
overlayRef.current = overlay;
return () => {
map.removeControl(overlay as unknown as maplibregl.IControl);
overlayRef.current = null;
};
}, [map]);
// Recompute layers (useMemo keeps this off the render hot path)
const layers = useMemo(
() => buildLayers(
arcs, showArcs,
packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut,
hiddenCoordMask,
),
[arcs, showArcs, packetHistorySegments, showPacketHistory,
betaPaths, betaLowSegments, betaCompletionPaths,
showBetaPaths, pathFadingOut, hiddenCoordMask],
);
if (layers.length === 0) return null;
// Push updated layers to the overlay imperatively
useEffect(() => {
overlayRef.current?.setProps({ layers });
}, [layers]);
return (
<DeckGL
viewState={viewState}
controller={false}
layers={layers}
style={{
position: 'absolute',
top: '0', left: '0', right: '0', bottom: '0',
pointerEvents: 'none',
zIndex: '400',
}}
/>
);
});
// No DOM output — everything is rendered inside the MapLibre canvas
return null;
};
// Keep DeckViewState export for backward compat (no longer used by App)
export type { Props as DeckGLOverlayProps };
@@ -0,0 +1,147 @@
import React, { useEffect, useMemo } from 'react';
import type maplibregl from 'maplibre-gl';
import { DeckGLOverlay } from './DeckGLOverlay.js';
import { useNodeMap } from '../../hooks/useNodes.js';
import { usePacketPathOverlay } from '../../hooks/usePacketPathOverlay.js';
import type { Filters } from '../FilterPanel/FilterPanel.js';
import { buildHiddenCoordMask, hasCoords } from '../../utils/pathing.js';
import { useOverlayStore } from '../../store/overlayStore.js';
type PacketHistorySegment = {
positions: [[number, number], [number, number]];
count: number;
};
type LiveOverlayControllerProps = {
map: maplibregl.Map | null;
filters: Filters;
network?: string;
observer?: string;
packetHistorySegments: PacketHistorySegment[];
};
export const LiveOverlayController: React.FC<LiveOverlayControllerProps> = ({
map,
filters,
network,
observer,
packetHistorySegments,
}) => {
const nodes = useNodeMap();
const hiddenCoordMask = useMemo(() => buildHiddenCoordMask(nodes.values()), [nodes]);
const setPathNodeIds = useOverlayStore((state) => state.setPathNodeIds);
const setBetaMetrics = useOverlayStore((state) => state.setBetaMetrics);
const {
packetPaths,
betaPacketPaths,
betaLowConfidenceSegments,
betaCompletionPaths,
betaPathConfidence,
betaPermutationCount,
betaRemainingHops,
pathFadingOut,
pinnedPacketId,
activePacketSnapshot,
} = usePacketPathOverlay({
filters,
network,
observer,
});
const renderedPaths = useMemo<[number, number][][]>(() => (
betaPacketPaths.length > 0 ? betaPacketPaths : packetPaths
), [betaPacketPaths, packetPaths]);
const showPathOnly = filters.betaPaths || pinnedPacketId !== null;
const pathPointIndex = useMemo(() => {
const index = new Map<string, Set<string>>();
const pointKey = (lat: number, lon: number) => `${lat.toFixed(5)},${lon.toFixed(5)}`;
for (const node of nodes.values()) {
if (!hasCoords(node)) continue;
const key = pointKey(node.lat, node.lon);
const existing = index.get(key);
if (existing) existing.add(node.node_id.toLowerCase());
else index.set(key, new Set([node.node_id.toLowerCase()]));
}
return index;
}, [nodes]);
const pathNodeIdsPrevRef = React.useRef<Set<string> | null>(null);
const pathNodeIds = useMemo<Set<string> | null>(() => {
if (!showPathOnly) {
if (pathNodeIdsPrevRef.current !== null) pathNodeIdsPrevRef.current = null;
return null;
}
if (!activePacketSnapshot) {
const empty = new Set<string>();
pathNodeIdsPrevRef.current = empty;
return empty;
}
const pointKey = (lat: number, lon: number) => `${lat.toFixed(5)},${lon.toFixed(5)}`;
const ids = new Set<string>();
const addPoint = (point: [number, number] | null | undefined) => {
if (!point) return;
const matches = pathPointIndex.get(pointKey(point[0], point[1]));
if (!matches) return;
for (const id of matches) ids.add(id);
};
for (const path of renderedPaths) {
for (const point of path) addPoint(point);
}
for (const [a, b] of betaLowConfidenceSegments) {
addPoint(a);
addPoint(b);
}
for (const path of betaCompletionPaths) {
for (const point of path) addPoint(point);
}
const result = ids;
const prev = pathNodeIdsPrevRef.current;
if (prev && result && prev.size === result.size && [...result].every((id) => prev.has(id))) {
return prev;
}
pathNodeIdsPrevRef.current = result;
return result;
}, [showPathOnly, activePacketSnapshot, pathPointIndex, renderedPaths, betaLowConfidenceSegments, betaCompletionPaths]);
useEffect(() => {
setPathNodeIds(pathNodeIds);
}, [pathNodeIds, setPathNodeIds]);
useEffect(() => {
setBetaMetrics({
betaPathConfidence,
betaPermutationCount,
betaRemainingHops,
});
}, [betaPathConfidence, betaPermutationCount, betaRemainingHops, setBetaMetrics]);
useEffect(() => () => {
setPathNodeIds(null);
setBetaMetrics({
betaPathConfidence: null,
betaPermutationCount: null,
betaRemainingHops: null,
});
}, [setPathNodeIds, setBetaMetrics]);
return (
<DeckGLOverlay
map={map}
arcs={[]}
showArcs={filters.livePackets}
packetHistorySegments={packetHistorySegments}
showPacketHistory={filters.packetHistory}
betaPaths={renderedPaths}
betaLowSegments={betaLowConfidenceSegments}
betaCompletionPaths={betaCompletionPaths}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
pathFadingOut={pathFadingOut}
hiddenCoordMask={hiddenCoordMask}
/>
);
};
File diff suppressed because it is too large Load Diff
-858
View File
@@ -1,858 +0,0 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { MapContainer, TileLayer, useMap, Pane, Polygon, Polyline, Circle, Popup } from 'react-leaflet';
import type { LatLngExpression, Map as LeafletMap, LeafletMouseEvent } from 'leaflet';
import type { MeshNode } from '../../hooks/useNodes.js';
import type { NodeCoverage } from '../../hooks/useCoverage.js';
import { hasCoords, maskCircleCenter, maskNodePoint, isProhibitedMapNode, HIDDEN_NODE_MASK_RADIUS_METERS } from '../../utils/pathing.js';
import type { HiddenMaskGeometry, LinkMetrics } from '../../utils/pathing.js';
import { NodeMarker } from './NodeMarker.js';
import { NodeSearch } from './NodeSearch.js';
export interface DeckViewState {
longitude: number;
latitude: number;
zoom: number;
pitch: number;
bearing: number;
}
type ViewBounds = {
north: number;
south: number;
east: number;
west: number;
};
interface SyncerProps { onViewStateChange: (vs: DeckViewState) => void; }
// Leaflet→deck.gl sync component
const LeafletDeckSyncer: React.FC<SyncerProps> = ({ onViewStateChange }) => {
const map = useMap();
React.useEffect(() => {
const sync = () => {
const center = map.getCenter();
onViewStateChange({
longitude: center.lng,
latitude: center.lat,
// deck.gl uses 512px tiles (mapbox convention); Leaflet uses 256px.
// One zoom level difference = factor of 2 in scale.
zoom: map.getZoom() - 1,
pitch: 0,
bearing: 0,
});
};
map.on('move', sync);
map.on('zoom', sync);
sync();
return () => { map.off('move', sync); map.off('zoom', sync); };
}, [map, onViewStateChange]);
return null;
};
// ── GPU popup helpers ──────────────────────────────────────────────────────────
interface NodeLink {
peer_id: string; peer_name: string | null; observed_count: number;
itm_path_loss_db: number | null;
count_this_to_peer: number; count_peer_to_this: number;
}
const GPU_ROLE_LABELS: Record<number, string> = {
1: 'Companion Radio', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor',
};
function gpuTimeAgo(iso: string): string {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
function gpuIsRepeater(role: number | undefined): boolean {
return role === undefined || role === 2;
}
// Registers a single map.on('click') listener; uses refs so the handler is never
// re-registered when node data updates — avoiding any click-listener churn.
interface GPUClickHandlerProps {
allNodes: MeshNode[];
hiddenCoordMask: Map<string, HiddenMaskGeometry>;
onNodeClick: (node: MeshNode, lat: number, lon: number) => void;
}
const GPUClickHandler: React.FC<GPUClickHandlerProps> = ({ allNodes, hiddenCoordMask, onNodeClick }) => {
const map = useMap();
const nodesRef = useRef(allNodes);
const maskRef = useRef(hiddenCoordMask);
const cbRef = useRef(onNodeClick);
useEffect(() => { nodesRef.current = allNodes; }, [allNodes]);
useEffect(() => { maskRef.current = hiddenCoordMask; }, [hiddenCoordMask]);
useEffect(() => { cbRef.current = onNodeClick; }, [onNodeClick]);
useEffect(() => {
const handler = (e: LeafletMouseEvent) => {
const clickPt = map.latLngToContainerPoint(e.latlng);
let nearest: MeshNode | null = null;
let nearestDist = 16; // pixel threshold
for (const node of nodesRef.current) {
if (!hasCoords(node)) continue;
const masked = maskNodePoint(node as MeshNode & { lat: number; lon: number }, maskRef.current);
const lat = masked[0];
const lon = masked[1];
const pt = map.latLngToContainerPoint([lat, lon]);
const d = Math.hypot(clickPt.x - pt.x, clickPt.y - pt.y);
if (d < nearestDist) { nearestDist = d; nearest = node; }
}
if (nearest) {
// nearest is guaranteed to have coords (hasCoords check above)
const masked = maskNodePoint(nearest as MeshNode & { lat: number; lon: number }, maskRef.current);
cbRef.current(nearest, masked[0], masked[1]);
}
};
map.on('click', handler);
return () => { map.off('click', handler); };
}, [map]);
return null;
};
// ──────────────────────────────────────────────────────────────────────────────
function ringToLatLng(ring: number[][] | undefined): LatLngExpression[] {
if (!ring) return [];
return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression);
}
function geomToRings(geom: { type: string; coordinates: unknown } | null | undefined): LatLngExpression[][] {
if (!geom) return [];
if (geom.type === 'Polygon') {
const ring = (geom.coordinates as number[][][])[0];
return ring ? [ringToLatLng(ring)] : [];
}
if (geom.type === 'MultiPolygon') {
return (geom.coordinates as number[][][][]).flatMap((poly) => {
const ring = poly[0];
return ring ? [ringToLatLng(ring)] : [];
});
}
return [];
}
// Raw outer rings from each coverage polygon — used for the green coverage display.
// Using raw rings (not a union) with fillRule:'nonzero' means:
// - overlapping viewsheds: winding numbers add (+1 per CCW ring) → always filled ✓
// - no opacity stacking: single SVG <path> element, one fill pass ✓
function useCoverageDisplayRings(coverage: NodeCoverage[]): LatLngExpression[][] {
return useMemo(() => {
const rings: LatLngExpression[][] = [];
for (const c of coverage) {
rings.push(...geomToRings(c.geom));
}
return rings;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [coverage]);
}
interface MapViewProps {
nodes: Map<string, MeshNode>;
inferredNodes: MeshNode[];
inferredActiveNodeIds: Set<string>;
activeNodes: Set<string>;
coverage: NodeCoverage[];
onDeckViewStateChange: (vs: DeckViewState) => void;
showCoverage: boolean;
showClientNodes: boolean;
showHexClashes: boolean;
maxHexClashHops: number;
viablePairsArr: [string, string][];
linkMetrics: Map<string, LinkMetrics>;
hiddenCoordMask: Map<string, HiddenMaskGeometry>;
pathNodeIds: Set<string> | null;
onMapReady?: (m: LeafletMap) => void;
/** Called when prefix-focus mode activates/deactivates, so DeckGLOverlay can hide GPU nodes
* during the focus animation (which relies on Leaflet markers for show/hide transitions). */
onPrefixFocusActiveChange?: (active: boolean) => void;
}
// Default UK centre (Teesside area)
const DEFAULT_CENTER: [number, number] = [54.57, -1.23];
const DEFAULT_ZOOM = 11;
const STALE_MARKER_MS = 7 * 24 * 60 * 60 * 1000;
// Custom comparison — only props that affect Leaflet SVG/marker rendering.
// GPU overlay props (packet history, beta paths) are handled by DeckGLOverlay.
function propsAreEqual(prev: MapViewProps, next: MapViewProps): boolean {
if (prev.nodes !== next.nodes) return false;
if (prev.coverage !== next.coverage) return false;
if (prev.activeNodes !== next.activeNodes) return false;
if (prev.viablePairsArr !== next.viablePairsArr) return false;
if (prev.linkMetrics !== next.linkMetrics) return false;
if (prev.inferredNodes !== next.inferredNodes) return false;
if (prev.inferredActiveNodeIds !== next.inferredActiveNodeIds) return false;
if (prev.hiddenCoordMask !== next.hiddenCoordMask) return false;
if (prev.showCoverage !== next.showCoverage) return false;
if (prev.showClientNodes !== next.showClientNodes) return false;
if (prev.showHexClashes !== next.showHexClashes) return false;
if (prev.maxHexClashHops !== next.maxHexClashHops) return false;
if (prev.pathNodeIds !== next.pathNodeIds) return false;
if (prev.onPrefixFocusActiveChange !== next.onPrefixFocusActiveChange) return false;
return true;
}
export const MapView = React.memo(({
nodes, inferredNodes, inferredActiveNodeIds, activeNodes, coverage, showCoverage, showClientNodes,
showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, hiddenCoordMask, pathNodeIds,
onMapReady, onDeckViewStateChange, onPrefixFocusActiveChange,
}) => {
const [map, setMap] = useState<LeafletMap | null>(null);
const [viewBounds, setViewBounds] = useState<ViewBounds | null>(null);
const [focusedPrefix, setFocusedPrefix] = useState<string | null>(null);
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
const [focusedPrefixNodeIds, setFocusedPrefixNodeIds] = useState<Set<string> | null>(null);
const [focusHidePhase, setFocusHidePhase] = useState<'idle' | 'hide' | 'fade'>('idle');
const hideTimerRef = useRef<number | null>(null);
const fadeTimerRef = useRef<number | null>(null);
// GPU popup — one popup at a time driven by click handler
const [gpuPopupNode, setGpuPopupNode] = useState<MeshNode | null>(null);
const [gpuPopupLat, setGpuPopupLat] = useState<number>(0);
const [gpuPopupLon, setGpuPopupLon] = useState<number>(0);
const [gpuPopupLinks, setGpuPopupLinks] = useState<NodeLink[] | null>(null);
const clearFocusTimers = useCallback(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
if (fadeTimerRef.current !== null) {
window.clearTimeout(fadeTimerRef.current);
fadeTimerRef.current = null;
}
}, []);
useEffect(() => () => clearFocusTimers(), [clearFocusTimers]);
// Fetch neighbour links when GPU popup opens for non-repeater nodes
useEffect(() => {
if (!gpuPopupNode || gpuIsRepeater(gpuPopupNode.role)) {
setGpuPopupLinks(null);
return;
}
setGpuPopupLinks(null);
fetch(`/api/nodes/${gpuPopupNode.node_id}/links`)
.then((r) => r.json())
.then((data: NodeLink[]) => setGpuPopupLinks(data))
.catch(() => setGpuPopupLinks([]));
}, [gpuPopupNode]);
const handleGpuNodeClick = useCallback((node: MeshNode, lat: number, lon: number) => {
setGpuPopupNode(node);
setGpuPopupLat(lat);
setGpuPopupLon(lon);
}, []);
// Notify App.tsx when prefix-focus mode activates/deactivates so it can pause GPU node
// rendering and let Leaflet handle the show/hide transition animation.
useEffect(() => {
onPrefixFocusActiveChange?.(focusHidePhase !== 'idle');
}, [focusHidePhase, onPrefixFocusActiveChange]);
useEffect(() => {
if (map && onMapReady) onMapReady(map);
}, [map, onMapReady]);
useEffect(() => {
if (!map) return;
const syncBounds = () => {
const bounds = map.getBounds().pad(0.2);
setViewBounds({
north: bounds.getNorth(),
south: bounds.getSouth(),
east: bounds.getEast(),
west: bounds.getWest(),
});
};
map.on('moveend', syncBounds);
map.on('zoomend', syncBounds);
syncBounds();
return () => {
map.off('moveend', syncBounds);
map.off('zoomend', syncBounds);
};
}, [map]);
const tileHandlers = useMemo(() => ({}), []);
const [deckViewState, setDeckViewState] = useState<DeckViewState>({
longitude: DEFAULT_CENTER[1], latitude: DEFAULT_CENTER[0], zoom: DEFAULT_ZOOM, pitch: 0, bearing: 0,
});
const [isMobileViewport, setIsMobileViewport] = useState(
() => (typeof window !== 'undefined' ? window.matchMedia('(max-width: 640px)').matches : false),
);
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobileViewport(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// Marching dashes are animated via CSS @keyframes in globals.css — no JS rAF needed.
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
const allNodesWithPos = useMemo(() => Array.from(nodes.values()).filter(
(n) => hasCoords(n)
&& (Date.now() - new Date(n.last_seen).getTime()) < FOURTEEN_DAYS_MS
), [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
const nodesWithPos = useMemo(() => allNodesWithPos.filter((n) => n.role === undefined || n.role === 2), [allNodesWithPos]);
const clientNodesArr = useMemo(() => allNodesWithPos.filter((n) => n.role === 1 || n.role === 3), [allNodesWithPos]);
const repeaterPrefixIds = useMemo(() => {
const prefixMap = new Map<string, string[]>();
for (const n of nodesWithPos) {
const prefix = n.node_id.slice(0, 2).toUpperCase();
const existing = prefixMap.get(prefix);
if (existing) existing.push(n.node_id);
else prefixMap.set(prefix, [n.node_id]);
}
return prefixMap;
}, [nodesWithPos]);
const handleToggleSamePrefix = useCallback((nodeId: string, enabled: boolean) => {
if (!enabled) {
clearFocusTimers();
setFocusedPrefix(null);
setFocusedNodeId(null);
setFocusedPrefixNodeIds(null);
setFocusHidePhase('idle');
return;
}
const prefix = nodeId.slice(0, 2).toUpperCase();
const ids = repeaterPrefixIds.get(prefix) ?? [nodeId];
const idSet = new Set(ids);
clearFocusTimers();
setFocusedPrefix(prefix);
setFocusedNodeId(nodeId);
setFocusedPrefixNodeIds(idSet);
setFocusHidePhase('hide');
hideTimerRef.current = window.setTimeout(() => {
setFocusHidePhase('fade');
fadeTimerRef.current = window.setTimeout(() => {
setFocusedPrefix(null);
setFocusedNodeId(null);
setFocusedPrefixNodeIds(null);
setFocusHidePhase('idle');
}, 1200);
}, 10_000);
}, [clearFocusTimers, repeaterPrefixIds]);
const coverageRings = useCoverageDisplayRings(coverage);
const coverageByNodeId = useMemo(() => {
const m = new Map<string, NodeCoverage>();
for (const c of coverage) m.set(c.node_id, c);
return m;
}, [coverage]);
const distKm = useCallback((a: MeshNode, b: MeshNode) => {
if (!hasCoords(a) || !hasCoords(b)) return Number.POSITIVE_INFINITY;
const midLat = ((a.lat + b.lat) / 2) * (Math.PI / 180);
const dlat = (a.lat - b.lat) * 111;
const dlon = (a.lon - b.lon) * 111 * Math.cos(midLat);
return Math.hypot(dlat, dlon);
}, []);
const nodeRangeKm = useCallback((nodeId: string) => {
const cov = coverageByNodeId.get(nodeId);
if (!cov?.radius_m) return 50;
return Math.min(80, Math.max(50, cov.radius_m / 1000));
}, [coverageByNodeId]);
const pairInReceiveRange = useCallback((a: MeshNode, b: MeshNode) => {
const d = distKm(a, b);
const range = Math.max(nodeRangeKm(a.node_id), nodeRangeKm(b.node_id));
return d <= range;
}, [distKm, nodeRangeKm]);
const clashLinePositions = useCallback((a: MeshNode, b: MeshNode): [number, number][] => {
const d = distKm(a, b);
if (d > 0.02) return [[a.lat!, a.lon!], [b.lat!, b.lon!]];
const off = 0.0018;
return [[a.lat!, a.lon!], [b.lat! + off, b.lon! + off]];
}, [distKm]);
const linkKey = (a: string, b: string) => (a < b ? `${a}:${b}` : `${b}:${a}`);
const inView = useCallback((lat: number, lon: number) => {
if (!viewBounds) return true;
return lat <= viewBounds.north
&& lat >= viewBounds.south
&& lon <= viewBounds.east
&& lon >= viewBounds.west;
}, [viewBounds]);
const lineInView = useCallback((positions: [number, number][]) => {
if (!viewBounds || positions.length < 1) return true;
let minLat = Number.POSITIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
let minLon = Number.POSITIVE_INFINITY;
let maxLon = Number.NEGATIVE_INFINITY;
for (const [lat, lon] of positions) {
if (inView(lat, lon)) return true;
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
}
return !(maxLat < viewBounds.south
|| minLat > viewBounds.north
|| maxLon < viewBounds.west
|| minLon > viewBounds.east);
}, [inView, viewBounds]);
const clashAdjacency = useMemo(() => {
const adj = new Map<string, Set<string>>();
for (const [aId, bId] of viablePairsArr) {
const a = nodes.get(aId);
const b = nodes.get(bId);
if (!hasCoords(a) || !hasCoords(b)) continue;
const key = linkKey(aId, bId);
// Require computed dB (weak or above) for every edge used in clash-hop routing.
const pathLoss = linkMetrics.get(key)?.itm_path_loss_db;
if (pathLoss == null) continue;
if (!pairInReceiveRange(a, b)) continue;
if (!adj.has(aId)) adj.set(aId, new Set());
if (!adj.has(bId)) adj.set(bId, new Set());
adj.get(aId)!.add(bId);
adj.get(bId)!.add(aId);
}
return adj;
}, [viablePairsArr, linkMetrics, nodes, pairInReceiveRange]);
const shortestPathWithinRelayHops = useCallback((fromId: string, toId: string, maxRelayHops: number) => {
if (fromId === toId) return [fromId];
const maxEdges = Math.max(1, Math.floor(maxRelayHops) + 1);
const visited = new Set<string>([fromId]);
const prev = new Map<string, string>();
const queue: Array<{ id: string; edges: number }> = [{ id: fromId, edges: 0 }];
while (queue.length > 0) {
const cur = queue.shift()!;
if (cur.edges >= maxEdges) continue;
for (const next of (clashAdjacency.get(cur.id) ?? [])) {
if (visited.has(next)) continue;
visited.add(next);
prev.set(next, cur.id);
const nextEdges = cur.edges + 1;
if (next === toId) {
const path = [toId];
let p = toId;
while (prev.has(p)) {
p = prev.get(p)!;
path.unshift(p);
}
return path;
}
queue.push({ id: next, edges: nextEdges });
}
}
return null;
}, [clashAdjacency]);
type ClashPath = { key: string; nodeIds: string[]; offenderA: string; offenderB: string };
const clashPaths = useMemo(() => {
const paths: ClashPath[] = [];
for (const [, ids] of repeaterPrefixIds) {
if (ids.length < 2) continue;
for (let i = 0; i < ids.length - 1; i++) {
for (let j = i + 1; j < ids.length; j++) {
const fromId = ids[i]!;
const toId = ids[j]!;
const path = shortestPathWithinRelayHops(fromId, toId, maxHexClashHops);
if (!path || path.length < 2) continue;
paths.push({
key: `clash-${fromId.slice(0, 8)}-${toId.slice(0, 8)}-${path.length}`,
nodeIds: path,
offenderA: fromId,
offenderB: toId,
});
}
}
}
return paths;
}, [repeaterPrefixIds, shortestPathWithinRelayHops, maxHexClashHops]);
const focusedClashPaths = useMemo(() => {
if (!focusedNodeId || !focusedPrefixNodeIds || focusedPrefixNodeIds.size < 2) return [];
const paths: ClashPath[] = [];
for (const targetId of focusedPrefixNodeIds) {
if (targetId === focusedNodeId) continue;
const path = shortestPathWithinRelayHops(focusedNodeId, targetId, maxHexClashHops);
if (!path || path.length < 2) continue;
paths.push({
key: `focus-${focusedNodeId.slice(0, 8)}-${targetId.slice(0, 8)}-${path.length}`,
nodeIds: path,
offenderA: focusedNodeId,
offenderB: targetId,
});
}
return paths;
}, [focusedNodeId, focusedPrefixNodeIds, shortestPathWithinRelayHops, maxHexClashHops]);
const clashPathLines = useMemo(() => {
const chosen = showHexClashes ? clashPaths : focusedClashPaths;
const lines: Array<{ key: string; positions: [number, number][] }> = [];
const edgeKeys = new Set<string>();
for (const path of chosen) {
for (let i = 0; i < path.nodeIds.length - 1; i++) {
const a = nodes.get(path.nodeIds[i]!);
const b = nodes.get(path.nodeIds[i + 1]!);
if (!hasCoords(a) || !hasCoords(b)) continue;
const edgeKey = linkKey(a.node_id, b.node_id);
if (edgeKeys.has(edgeKey)) continue;
edgeKeys.add(edgeKey);
lines.push({ key: `${path.key}-${edgeKey}`, positions: clashLinePositions(a, b) });
}
}
return lines;
}, [showHexClashes, clashPaths, focusedClashPaths, nodes, clashLinePositions]);
const clashOffenderNodeIds = useMemo(() => {
const ids = new Set<string>();
const chosen = showHexClashes ? clashPaths : focusedClashPaths;
for (const path of chosen) {
ids.add(path.offenderA);
ids.add(path.offenderB);
}
return ids;
}, [showHexClashes, clashPaths, focusedClashPaths]);
const clashVisibleNodeIds = useMemo(() => {
const ids = new Set<string>();
const chosen = showHexClashes ? clashPaths : focusedClashPaths;
for (const path of chosen) {
for (const id of path.nodeIds) ids.add(id);
}
return ids;
}, [showHexClashes, clashPaths, focusedClashPaths]);
// Stable callback: without this, an inline arrow in JSX would create a new function reference
// on every render, causing LeafletDeckSyncer's effect to re-run and re-register the 'move'
// event listener every frame during pan — effectively listener churn at 60fps.
const handleViewStateChange = useCallback((vs: DeckViewState) => {
setDeckViewState(vs);
onDeckViewStateChange(vs);
}, [onDeckViewStateChange]);
const clashModeActive = showHexClashes || !!focusedPrefixNodeIds;
const effectiveShowCoverage = showCoverage && !clashModeActive;
// When gpuRendered is true, NodeMarkers are NOT rendered at all — zero React fibers for
// individual nodes. Server-side PNG tiles (/api/tiles/nodes/{z}/{x}/{y}.png) render node dots,
// and GPUClickHandler does nearest-node hit-testing on click. Fall back to full Leaflet markers
// during hex-clash mode (needs clash colours) and prefix-focus animations (show/hide transitions).
const gpuRendered = !clashModeActive && focusHidePhase === 'idle';
const visibleClashPathLines = useMemo(
() => clashPathLines.filter((line) => lineInView(line.positions)),
[clashPathLines, lineInView],
);
const visibleRepeaterNodes = useMemo(
() => nodesWithPos.filter((node) => hasCoords(node) && inView(node.lat, node.lon)),
[nodesWithPos, inView],
);
const visibleClientNodes = useMemo(
() => clientNodesArr.filter((node) => hasCoords(node) && inView(node.lat, node.lon)),
[clientNodesArr, inView],
);
const visibleInferredNodes = useMemo(
() => inferredNodes.filter((node) => hasCoords(node) && inView(node.lat, node.lon)),
[inferredNodes, inView],
);
// All visible nodes fed to the GPU click handler for hit-testing
const allVisibleGpuNodes = useMemo(() => {
const result = [...visibleRepeaterNodes, ...visibleInferredNodes];
if (showClientNodes) result.push(...visibleClientNodes);
return result;
}, [visibleRepeaterNodes, visibleInferredNodes, visibleClientNodes, showClientNodes]);
const markerSize = useMemo(() => {
const leafletZoom = deckViewState.zoom + 1;
let size = 12;
if (leafletZoom <= 6) size = 5;
else if (leafletZoom <= 7) size = 6;
else if (leafletZoom <= 8) size = 7;
else if (leafletZoom <= 9) size = 8;
else if (leafletZoom <= 10) size = 9;
else if (leafletZoom <= 11) size = 10;
else if (leafletZoom <= 12) size = 11;
if (isMobileViewport) size -= 1;
return Math.max(4, size);
}, [deckViewState.zoom, isMobileViewport]);
return (
<div className="map-area">
<NodeSearch nodes={nodes} map={map} />
<MapContainer
ref={setMap}
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
style={{ width: '100%', height: '100%' }}
zoomControl={false}
attributionControl={false}
>
{/* CartoDB Dark Matter tiles */}
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
subdomains="abcd"
maxZoom={19}
keepBuffer={10}
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} />
{/* Coverage raw outer rings from each viewshed, fillRule:'nonzero'.
nonzero means overlapping CCW rings sum winding numbers (+1 each)
so all covered areas fill regardless of how many viewsheds overlap. */}
{effectiveShowCoverage && coverageRings.length > 0 && (
<Pane name="coveragePane" style={{ zIndex: 350 }}>
<Polygon
positions={coverageRings as LatLngExpression[][]}
pathOptions={{
fillColor: '#22c55e',
fillOpacity: 0.18,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
</Pane>
)}
{/* Confirmed link lines — ITM-viable node pairs */}
{clashModeActive && (
<Pane name="hexClashPane" style={{ zIndex: 660 }}>
{visibleClashPathLines.map((line) => (
<Polyline
key={line.key}
positions={line.positions}
pathOptions={{
color: '#f97316',
weight: 2.2,
opacity: 0.9,
}}
interactive={false}
/>
))}
</Pane>
)}
{/* GPU mode: zero NodeMarker fibers
Server-side PNG tiles render all node dots; no Leaflet NodeMarkers exist.
We only add Leaflet elements for nodes that need them:
- Privacy circles for prohibited nodes (visual mask ring)
- A single click handler that opens a popup for the nearest node
- The popup itself */}
{gpuRendered && visibleRepeaterNodes
.filter((n) => isProhibitedMapNode(n) && hasCoords(n))
.map((node) => {
const cp = maskCircleCenter([node.lat!, node.lon!], hiddenCoordMask);
return (
<Circle
key={`priv-${node.node_id}`}
center={[cp?.[0] ?? node.lat!, cp?.[1] ?? node.lon!]}
radius={HIDDEN_NODE_MASK_RADIUS_METERS}
pathOptions={{ color: '#f59e0b', weight: 1.4, opacity: 0.55, fillColor: '#f59e0b', fillOpacity: 0.05, dashArray: '4 6' }}
interactive={false}
/>
);
})}
{gpuRendered && (
<GPUClickHandler
allNodes={allVisibleGpuNodes}
hiddenCoordMask={hiddenCoordMask}
onNodeClick={handleGpuNodeClick}
/>
)}
{gpuRendered && gpuPopupNode && (() => {
const node = gpuPopupNode;
const prohibited = isProhibitedMapNode(node);
const fallbackName = GPU_ROLE_LABELS[node.role ?? 2] ?? 'Unknown Device';
const displayName = prohibited ? `Redacted ${fallbackName}` : (node.name ?? `Unknown ${fallbackName}`);
const ageMs = Date.now() - new Date(node.last_seen).getTime();
const isStale = ageMs > STALE_MARKER_MS;
const statusLabel = isStale ? 'STALE' : node.is_online ? 'ONLINE' : 'OFFLINE';
const statusColor = isStale ? 'var(--danger)' : node.is_online ? 'var(--online)' : 'var(--offline)';
const isRepeater = gpuIsRepeater(node.role);
return (
<Popup
position={[gpuPopupLat, gpuPopupLon]}
eventHandlers={{ remove: () => { setGpuPopupNode(null); setGpuPopupLinks(null); } }}
>
<div className="node-popup">
<div className="node-popup__name">{displayName}</div>
{node.public_key && (
<div className="node-popup__row">
<span>Public key</span>
<span className="node-popup__mono">{node.public_key}</span>
</div>
)}
{!isRepeater && node.role !== undefined && (
<div className="node-popup__row">
<span>Type</span>
<span>{GPU_ROLE_LABELS[node.role] ?? 'Unknown'}</span>
</div>
)}
<div className="node-popup__row">
<span>Status</span>
<span style={{ color: statusColor }}>{statusLabel}</span>
</div>
{node.hardware_model && (
<div className="node-popup__row">
<span>Hardware</span>
<span>{node.hardware_model}</span>
</div>
)}
<div className="node-popup__row">
<span>Last seen</span>
<span>{gpuTimeAgo(node.last_seen)}</span>
</div>
{node.advert_count !== undefined && (
<div className="node-popup__row">
<span>Times seen</span>
<span>{node.advert_count}</span>
</div>
)}
<div className="node-popup__row">
<span>Position</span>
<span>{prohibited ? 'Redacted' : `${gpuPopupLat.toFixed(5)}, ${gpuPopupLon.toFixed(5)}`}</span>
</div>
{prohibited && (
<div className="node-popup__row">
<span>Location</span>
<span>Redacted within 1 mile radius</span>
</div>
)}
{node.elevation_m !== undefined && node.elevation_m !== null && (
<div className="node-popup__row">
<span>Elevation</span>
<span>{Math.round(node.elevation_m)} m ASL</span>
</div>
)}
{!isRepeater && gpuPopupLinks === null && (
<div className="node-popup__neighbours-loading">Loading neighbours</div>
)}
{!isRepeater && gpuPopupLinks !== null && gpuPopupLinks.length > 0 && (
<div className="node-popup__neighbours">
<div className="node-popup__neighbours-title">Confirmed neighbours</div>
{gpuPopupLinks.map((lk) => {
const tx = lk.count_this_to_peer > 0;
const rx = lk.count_peer_to_this > 0;
const arrow = tx && rx ? '↔' : tx ? '→' : '←';
return (
<div key={lk.peer_id} className="node-popup__neighbour-row">
<span className="node-popup__neighbour-name">{arrow} {lk.peer_name ?? lk.peer_id.slice(0, 8)}</span>
<span className="node-popup__neighbour-meta">
{lk.observed_count}× seen
{lk.itm_path_loss_db != null && <> &middot; {Math.round(lk.itm_path_loss_db)} dB</>}
</span>
</div>
);
})}
</div>
)}
</div>
</Popup>
);
})()}
{/* Non-GPU mode: full Leaflet NodeMarkers
Active during hex-clash mode and prefix-focus animations, where we need
per-node Leaflet SVG styling (clash colours, show/hide transitions). */}
{!gpuRendered && visibleRepeaterNodes.map((node) => {
if (!hasCoords(node)) return null;
if (pathNodeIds && !pathNodeIds.has(node.node_id.toLowerCase())) return null;
if (showHexClashes && !clashVisibleNodeIds.has(node.node_id)) return null;
const isFocusVisible = clashVisibleNodeIds.has(node.node_id) || (focusedPrefixNodeIds?.has(node.node_id) ?? false);
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
const isStaleNode = (Date.now() - new Date(node.last_seen).getTime()) > STALE_MARKER_MS;
const displayPosition = maskNodePoint(node, hiddenCoordMask);
const circleCenterPosition = maskCircleCenter([node.lat, node.lon], hiddenCoordMask);
return (
<NodeMarker
key={node.node_id}
node={node}
displayPosition={displayPosition}
circleCenterPosition={circleCenterPosition}
isActive={activeNodes.has(node.node_id)}
isInferred={isStaleNode && inferredActiveNodeIds.has(node.node_id.toLowerCase()) && !activeNodes.has(node.node_id)}
isHighlighted={!!focusedPrefix && node.node_id.slice(0, 2).toUpperCase() === focusedPrefix}
isRestoring={!!focusedPrefixNodeIds && focusHidePhase === 'fade' && !isFocusVisible}
hexClashState={clashModeActive ? (clashOffenderNodeIds.has(node.node_id) ? 'offender' : clashVisibleNodeIds.has(node.node_id) ? 'clear' : undefined) : undefined}
samePrefixRepeaterCount={repeaterPrefixIds.get(node.node_id.slice(0, 2).toUpperCase())?.length ?? 1}
samePrefixActive={!!focusedPrefix && node.node_id.slice(0, 2).toUpperCase() === focusedPrefix}
onToggleSamePrefix={handleToggleSamePrefix}
nodeCoverage={coverageByNodeId.get(node.node_id)}
markerSize={markerSize}
/>
);
})}
{!gpuRendered && !showHexClashes && visibleInferredNodes.map((node) => (
<NodeMarker
key={node.node_id}
node={node}
isActive={false}
isInferred
markerSize={Math.max(4, markerSize - 1)}
/>
))}
{!gpuRendered && showClientNodes && !showHexClashes && visibleClientNodes.map((node) => {
if (!hasCoords(node)) return null;
const isFocusVisible = clashVisibleNodeIds.has(node.node_id);
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
return (
<NodeMarker
key={node.node_id}
node={node}
displayPosition={maskNodePoint(node, hiddenCoordMask)}
circleCenterPosition={maskCircleCenter([node.lat, node.lon], hiddenCoordMask)}
isActive={activeNodes.has(node.node_id)}
isRestoring={!!focusedPrefixNodeIds && focusHidePhase === 'fade' && !isFocusVisible}
nodeCoverage={coverageByNodeId.get(node.node_id)}
markerSize={markerSize}
/>
);
})}
</MapContainer>
</div>
);
}, propsAreEqual);
-355
View File
@@ -1,355 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Circle, CircleMarker, Popup, Polygon, Pane } from 'react-leaflet';
import type { LatLngExpression } from 'leaflet';
import type { MeshNode } from '../../hooks/useNodes.js';
import type { NodeCoverage } from '../../hooks/useCoverage.js';
import { HIDDEN_NODE_MASK_RADIUS_METERS, isProhibitedMapNode, isValidMapCoord } from '../../utils/pathing.js';
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const PREVIEW_TTL_MS = 20_000;
type MarkerVariant = 'repeater' | 'companion' | 'room' | 'inferred';
type HexClashState = 'offender' | 'clear';
// Resolve the SVG stroke/fill colour for a CircleMarker based on node state and role
function markerColor(variant: MarkerVariant, isOnline: boolean, isStale: boolean, hexClashState?: HexClashState): string {
if (hexClashState === 'offender') return '#ef4444';
if (hexClashState === 'clear') return '#22c55e';
if (isStale) return '#ff4444';
if (!isOnline) return '#666';
if (variant === 'companion') return '#ff9800';
if (variant === 'room') return '#ce93d8';
if (variant === 'inferred') return 'rgba(109,220,122,0.9)';
return '#00c4ff'; // repeater default
}
function timeAgo(iso: string): string {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
const ROLE_LABELS: Record<number, string> = {
1: 'Companion Radio',
2: 'Repeater',
3: 'Room Server',
4: 'Sensor',
};
function roleVariant(role: number | undefined): MarkerVariant {
if (role === 1) return 'companion';
if (role === 3) return 'room';
return 'repeater';
}
function isRepeaterNode(role: number | undefined): boolean {
return role === undefined || role === 2;
}
function ringToLatLng(ring: number[][]): LatLngExpression[] {
return ring.map(([lon, lat]) => [lat, lon] as LatLngExpression);
}
function coverageToPolygons(geom: { type: string; coordinates: unknown } | null | undefined): LatLngExpression[][][] {
if (!geom) return [];
if (geom.type === 'Polygon') {
const polygon = geom.coordinates as number[][][];
return [polygon.map((ring) => ringToLatLng(ring))];
}
if (geom.type === 'MultiPolygon') {
const multiPolygon = geom.coordinates as number[][][][];
return multiPolygon.map((polygon) => polygon.map((ring) => ringToLatLng(ring)));
}
return [];
}
interface NodeLink {
peer_id: string; peer_name: string | null; observed_count: number;
itm_path_loss_db: number | null;
count_this_to_peer: number; count_peer_to_this: number;
}
interface Props {
node: MeshNode;
displayPosition?: [number, number];
circleCenterPosition?: [number, number];
isActive: boolean;
isInferred?: boolean;
nodeCoverage?: NodeCoverage;
markerSize?: number;
isHighlighted?: boolean;
isRestoring?: boolean;
samePrefixRepeaterCount?: number;
samePrefixActive?: boolean;
onToggleSamePrefix?: (nodeId: string, enabled: boolean) => void;
hexClashState?: HexClashState;
}
export const NodeMarker: React.FC<Props> = React.memo(({
node,
displayPosition,
circleCenterPosition,
isActive: _isActive,
isInferred = false,
nodeCoverage,
markerSize: _markerSize,
isHighlighted = false,
isRestoring: _isRestoring,
samePrefixRepeaterCount: _samePrefixRepeaterCount,
samePrefixActive: _samePrefixActive,
onToggleSamePrefix: _onToggleSamePrefix,
hexClashState,
}) => {
const [showPreview, setShowPreview] = useState(false);
const [links, setLinks] = useState<NodeLink[] | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
const handleShowCoverage = () => {
setShowPreview(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setShowPreview(false), PREVIEW_TTL_MS);
};
const prohibited = isProhibitedMapNode(node);
const markerLat = displayPosition?.[0] ?? node.lat;
const markerLon = displayPosition?.[1] ?? node.lon;
const circleLat = circleCenterPosition?.[0] ?? markerLat;
const circleLon = circleCenterPosition?.[1] ?? markerLon;
if (!isValidMapCoord(markerLat, markerLon)) return null;
const lat = markerLat as number;
const lon = markerLon as number;
const ageMs = Date.now() - new Date(node.last_seen).getTime();
const isStale = ageMs > SEVEN_DAYS_MS;
const variant = (isInferred || node.is_inferred) ? 'inferred' : roleVariant(node.role);
const fallbackName = ROLE_LABELS[node.role ?? 2] ?? 'Unknown Device';
const displayName = prohibited ? `Redacted ${fallbackName}` : (node.name ?? `Unknown ${fallbackName}`);
const statusLabel = isStale
? 'STALE'
: node.is_online ? 'ONLINE' : 'OFFLINE';
const statusColor = isStale
? 'var(--danger)'
: node.is_online ? 'var(--online)' : 'var(--offline)';
const previewBands = showPreview && nodeCoverage ? {
red: coverageToPolygons(nodeCoverage.strength_geoms?.red ?? nodeCoverage.geom),
amber: coverageToPolygons(nodeCoverage.strength_geoms?.amber),
green: coverageToPolygons(nodeCoverage.strength_geoms?.green),
} : { red: [], amber: [], green: [] };
const isRepeater = isRepeaterNode(node.role);
// Simple popup content for repeaters - just name and coords (respecting privacy)
const repeaterPopupContent = (
<div className="node-popup">
<div className="node-popup__name">{displayName}</div>
{node.public_key && (
<div className="node-popup__row">
<span>Public key</span>
<span className="node-popup__mono">{node.public_key}</span>
</div>
)}
<div className="node-popup__row">
<span>Status</span>
<span style={{ color: statusColor }}>{statusLabel}</span>
</div>
<div className="node-popup__row">
<span>Position</span>
<span>{prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`}</span>
</div>
{prohibited && (
<div className="node-popup__row">
<span>Location</span>
<span>Redacted within 1 mile radius</span>
</div>
)}
</div>
);
const color = markerColor(variant, node.is_online, isStale, hexClashState);
const radius = isHighlighted ? 5 : 3;
return (
<>
<CircleMarker
center={[lat, lon]}
radius={radius}
pathOptions={{ color, fillColor: color, fillOpacity: 0.7, weight: 1 }}
>
<Popup eventHandlers={!isRepeater ? {
add: () => {
if (links !== null) return;
fetch(`/api/nodes/${node.node_id}/links`)
.then((r) => r.json())
.then((data: NodeLink[]) => setLinks(data))
.catch(() => setLinks([]));
},
} : undefined}>
{isRepeater ? repeaterPopupContent : (
<div className="node-popup">
<div className="node-popup__name">{displayName}</div>
{node.public_key && (
<div className="node-popup__row">
<span>Public key</span>
<span className="node-popup__mono">{node.public_key}</span>
</div>
)}
{node.role !== undefined && node.role !== 2 && (
<div className="node-popup__row">
<span>Type</span>
<span>{ROLE_LABELS[node.role] ?? 'Unknown'}</span>
</div>
)}
{(isInferred || node.is_inferred) && (
<>
<div className="node-popup__row">
<span>Type</span>
<span>{node.is_inferred ? 'Inferred repeater' : 'Inferred active'}</span>
</div>
{node.inferred_prefix && (
<div className="node-popup__row">
<span>Prefix</span>
<span>{node.inferred_prefix}</span>
</div>
)}
{(node.inferred_packet_count || node.inferred_observations) && (
<div className="node-popup__row">
<span>Evidence</span>
<span>{node.inferred_packet_count ?? 0} packet(s) / {node.inferred_observations ?? 0} sighting(s)</span>
</div>
)}
{(node.inferred_prev_name || node.inferred_next_name) && (
<div className="node-popup__row">
<span>Between</span>
<span>{node.inferred_prev_name ?? 'unknown'} · {node.inferred_next_name ?? 'unknown'}</span>
</div>
)}
</>
)}
<div className="node-popup__row">
<span>Status</span>
<span style={{ color: statusColor }}>{statusLabel}</span>
</div>
{node.hardware_model && (
<div className="node-popup__row">
<span>Hardware</span>
<span>{node.hardware_model}</span>
</div>
)}
<div className="node-popup__row">
<span>Last seen</span>
<span>{timeAgo(node.last_seen)}</span>
</div>
{node.advert_count !== undefined && (
<div className="node-popup__row">
<span>Times seen</span>
<span>{node.advert_count}</span>
</div>
)}
<div className="node-popup__row">
<span>Position</span>
<span>{prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`}</span>
</div>
{node.elevation_m !== undefined && node.elevation_m !== null && (
<div className="node-popup__row">
<span>Elevation</span>
<span>{Math.round(node.elevation_m)} m ASL</span>
</div>
)}
{nodeCoverage && (
<button
className={`node-popup__coverage-btn${showPreview ? ' node-popup__coverage-btn--active' : ''}`}
onClick={handleShowCoverage}
>
{showPreview ? 'Showing coverage…' : 'Preview coverage'}
</button>
)}
{links === null && <div className="node-popup__neighbours-loading">Loading neighbours</div>}
{links !== null && links.length > 0 && (
<div className="node-popup__neighbours">
<div className="node-popup__neighbours-title">Confirmed neighbours</div>
{links.map((lk) => {
const tx = lk.count_this_to_peer > 0;
const rx = lk.count_peer_to_this > 0;
const arrow = tx && rx ? '↔' : tx ? '→' : '←';
return (
<div key={lk.peer_id} className="node-popup__neighbour-row">
<span className="node-popup__neighbour-name">{arrow} {lk.peer_name ?? lk.peer_id.slice(0, 8)}</span>
<span className="node-popup__neighbour-meta">
{lk.observed_count}× seen
{lk.itm_path_loss_db != null && <> &middot; {Math.round(lk.itm_path_loss_db)} dB</>}
</span>
</div>
);
})}
</div>
)}
</div>
)}
</Popup>
</CircleMarker>
{prohibited && (
<Circle
center={[circleLat as number, circleLon as number]}
radius={HIDDEN_NODE_MASK_RADIUS_METERS}
pathOptions={{
color: '#f59e0b',
weight: 1.4,
opacity: 0.55,
fillColor: '#f59e0b',
fillOpacity: 0.05,
dashArray: '4 6',
}}
interactive={false}
/>
)}
{(previewBands.red.length > 0 || previewBands.amber.length > 0 || previewBands.green.length > 0) && (
<Pane name={`cov-preview-${node.node_id}`} style={{ zIndex: 351 }}>
{previewBands.red.length > 0 && (
<Polygon
positions={previewBands.red as unknown as LatLngExpression[][]}
pathOptions={{
fillColor: '#ef4444',
fillOpacity: 0.12,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
)}
{previewBands.amber.length > 0 && (
<Polygon
positions={previewBands.amber as unknown as LatLngExpression[][]}
pathOptions={{
fillColor: '#f59e0b',
fillOpacity: 0.18,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
)}
{previewBands.green.length > 0 && (
<Polygon
positions={previewBands.green as unknown as LatLngExpression[][]}
pathOptions={{
fillColor: '#22c55e',
fillOpacity: 0.28,
weight: 0,
fillRule: 'nonzero',
}}
interactive={false}
/>
)}
</Pane>
)}
</>
);
});
+9 -7
View File
@@ -1,17 +1,18 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import type { Map as LeafletMap } from 'leaflet';
import type maplibregl from 'maplibre-gl';
import type { MeshNode } from '../../hooks/useNodes.js';
import { useNodeMap } from '../../hooks/useNodes.js';
import { isValidMapCoord } from '../../utils/pathing.js';
interface NodeSearchProps {
nodes: Map<string, MeshNode>;
map: LeafletMap | null;
map: maplibregl.Map | null;
}
export const NodeSearch: React.FC<NodeSearchProps> = ({ nodes, map }) => {
export const NodeSearch: React.FC<NodeSearchProps> = ({ map }) => {
const nodes = useNodeMap();
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const results = useMemo(() => {
if (!query.trim()) return [];
@@ -36,7 +37,8 @@ export const NodeSearch: React.FC<NodeSearchProps> = ({ nodes, map }) => {
}, []);
const select = (node: MeshNode) => {
map?.flyTo([node.lat!, node.lon!], 15);
// MapLibre flyTo: center is [lon, lat]
map?.flyTo({ center: [node.lon!, node.lat!], zoom: 15 });
setQuery('');
setOpen(false);
};
@@ -1,71 +0,0 @@
import React, { useMemo } from 'react';
import DeckGL from '@deck.gl/react';
import { ArcLayer } from '@deck.gl/layers';
import type { PacketArc } from '../../hooks/useNodes.js';
const ARC_TTL = 5000;
interface DeckViewState {
longitude: number;
latitude: number;
zoom: number;
pitch: number;
bearing: number;
}
interface Props {
arcs: PacketArc[];
showArcs: boolean;
viewState: DeckViewState;
}
// Memoize layer creation to avoid rebuilding every frame
function useArcLayers(arcs: PacketArc[], showArcs: boolean): ArcLayer<PacketArc>[] {
return useMemo(() => {
if (!showArcs || arcs.length === 0) return [];
const now = Date.now();
const visible = arcs.filter((a) => now - a.ts < ARC_TTL);
if (visible.length === 0) return [];
const fade = (ts: number) => Math.max(0, 1 - (now - ts) / ARC_TTL);
return [
new ArcLayer<PacketArc>({
id: 'arc-bloom',
data: visible,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getSourceColor: (d) => [0, 196, 255, Math.round(35 * fade(d.ts))],
getTargetColor: (d) => [0, 196, 255, Math.round(70 * fade(d.ts))],
getWidth: 10,
getHeight: 0.15,
}),
new ArcLayer<PacketArc>({
id: 'arc-core',
data: visible,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getSourceColor: (d) => [120, 220, 255, Math.round(200 * fade(d.ts))],
getTargetColor: (d) => [200, 245, 255, Math.round(255 * fade(d.ts))],
getWidth: 2,
getHeight: 0.15,
}),
];
}, [arcs, showArcs]);
}
export const PacketArcLayer: React.FC<Props> = React.memo(({ arcs, showArcs, viewState }) => {
const layers = useArcLayers(arcs, showArcs);
if (layers.length === 0) return null;
return (
<DeckGL
viewState={viewState}
controller={false}
layers={layers}
style={{ position: 'absolute', top: '0', left: '0', right: '0', bottom: '0', pointerEvents: 'none', zIndex: '400' }}
/>
);
});
+9 -12
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { AggregatedPacket, MeshNode } from '../hooks/useNodes.js';
import { useNodeMap, usePackets } from '../hooks/useNodes.js';
import { useOverlayStore } from '../store/overlayStore.js';
const TYPE_LABELS: Record<number, string> = {
0: 'REQ',
@@ -15,17 +16,13 @@ const TYPE_LABELS: Record<number, string> = {
11: 'CTL',
};
interface Props {
packets: AggregatedPacket[];
nodes: Map<string, MeshNode>;
mqttObserverCount?: number;
onPacketClick?: (packet: AggregatedPacket) => void;
pinnedPacketId?: string | null;
}
const VISIBLE_ROWS = 8;
export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacketClick, pinnedPacketId }) => {
export const PacketFeed: React.FC = React.memo(() => {
const packets = usePackets();
const nodes = useNodeMap();
const pinnedPacketId = useOverlayStore((state) => state.pinnedPacketId);
const togglePinnedPacket = useOverlayStore((state) => state.togglePinnedPacket);
const visible = useMemo(
() => packets.filter((p) => p.packetType === 4 || p.packetType === 5).slice(0, VISIBLE_ROWS).reverse(),
[packets],
@@ -73,10 +70,10 @@ export const PacketFeed: React.FC<Props> = React.memo(({ packets, nodes, onPacke
<div
key={p.id}
className={`packet-item packet-item--clickable${isPinned ? ' packet-item--pinned' : ''}${newestVisibleId === p.id ? ' packet-item--new' : ''}`}
onClick={() => onPacketClick?.(p)}
onClick={() => togglePinnedPacket(p)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)}
onKeyDown={(e) => e.key === 'Enter' && togglePinnedPacket(p)}
>
{observerIata && (
<span className="packet-item__iata">{observerIata}</span>
@@ -1,19 +1,16 @@
import React, { useState } from 'react';
import type { Map as LeafletMap } from 'leaflet';
import type maplibregl from 'maplibre-gl';
import { NodeSearch } from '../Map/NodeSearch.js';
import { FILTER_ROWS, type Filters } from '../FilterPanel/FilterPanel.js';
import type { MeshNode } from '../../hooks/useNodes.js';
type MobileControlsProps = {
map: LeafletMap | null;
nodes: Map<string, MeshNode>;
map: maplibregl.Map | null;
filters: Filters;
onFiltersChange: (next: Filters) => void;
};
export const MobileControls: React.FC<MobileControlsProps> = ({
map,
nodes,
filters,
onFiltersChange,
}) => {
@@ -73,7 +70,7 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
)}
</div>
<div className="mobile-search">
<NodeSearch map={map} nodes={nodes} />
<NodeSearch map={map} />
</div>
</div>
);
+12 -1
View File
@@ -193,7 +193,18 @@ export function useAppMessageHandler({
packetObserved: false,
};
rafRef.current = null;
}, [handlePacket, handleNodeUpdate, handleNodeUpsert, handleCoverageUpdate, applyLinkUpdate, onPacketObserved]);
}, [
handlePacket,
handleNodeUpdate,
handleNodeUpdateBatch,
handleNodeUpsert,
handleNodeUpsertBatch,
handleCoverageUpdate,
handleCoverageUpdateBatch,
applyLinkUpdate,
applyLinkUpdateBatch,
onPacketObserved,
]);
// Throttle flush — batches bursts from the WebSocket into single React renders
const BATCH_INTERVAL_MS = 16;
+113 -35
View File
@@ -1,47 +1,125 @@
import { useState, useCallback, useEffect } from 'react';
import { useEffect, useSyncExternalStore } from 'react';
import { withScopeParams, type ApiScope } from '../utils/api.js';
export interface NodeCoverage {
node_id: string;
geom: { type: string; coordinates: unknown };
strength_geoms?: Partial<Record<'green' | 'amber' | 'red', { type: string; coordinates: unknown }>>;
node_id: string;
geom: { type: string; coordinates: unknown };
strength_geoms?: Partial<Record<'green' | 'amber' | 'red', { type: string; coordinates: unknown }>>;
antenna_height_m?: number;
radius_m?: number;
calculated_at?: string;
radius_m?: number;
calculated_at?: string;
}
export function useCoverage(scope: ApiScope = {}, enabled = false) {
const [coverage, setCoverage] = useState<NodeCoverage[]>([]);
type CoverageState = {
coverage: NodeCoverage[];
loadedScopeKey: string | null;
};
// Fetch coverage lazily — only when the user enables the coverage toggle.
// The response is ~26 MB of GeoJSON; fetching it eagerly on every mount
// wastes ~700 ms of load time when coverage is never displayed.
let state: CoverageState = {
coverage: [],
loadedScopeKey: null,
};
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function setState(next: CoverageState): void {
state = next;
emit();
}
function scopeKey(scope: ApiScope = {}): string {
return `${scope.network ?? 'all'}|${scope.observer ?? 'all'}`;
}
function replaceCoverage(coverage: NodeCoverage[], key: string): void {
setState({
coverage,
loadedScopeKey: key,
});
}
function upsertCoverageBatch(
updates: Array<{
node_id: string;
geom: NodeCoverage['geom'];
strength_geoms?: NodeCoverage['strength_geoms'];
}>,
): void {
if (updates.length === 0) return;
const idsToRemove = new Set(updates.map((update) => update.node_id));
const filtered = state.coverage.filter((entry) => !idsToRemove.has(entry.node_id));
const added = updates.map((update) => ({
node_id: update.node_id,
geom: update.geom,
strength_geoms: update.strength_geoms,
}));
setState({
...state,
coverage: [...filtered, ...added],
});
}
function handleCoverageUpdate(update: {
node_id: string;
geom: NodeCoverage['geom'];
strength_geoms?: NodeCoverage['strength_geoms'];
}): void {
upsertCoverageBatch([update]);
}
function handleCoverageUpdateBatch(updates: Array<{
node_id: string;
geom: NodeCoverage['geom'];
strength_geoms?: NodeCoverage['strength_geoms'];
}>): void {
upsertCoverageBatch(updates);
}
function getState(): CoverageState {
return state;
}
export const coverageStore = {
subscribe,
getState,
replaceCoverage,
handleCoverageUpdate,
handleCoverageUpdateBatch,
scopeKey,
};
export function useCoverageData(): NodeCoverage[] {
return useSyncExternalStore(subscribe, () => state.coverage);
}
export function useCoverageLoader(scope: ApiScope = {}, enabled = false): void {
useEffect(() => {
if (!enabled) return;
const key = scopeKey(scope);
if (state.loadedScopeKey === key && state.coverage.length > 0) return;
const controller = new AbortController();
const url = withScopeParams('/api/coverage', scope);
fetch(url)
.then((r) => r.json())
.then((data: NodeCoverage[]) => setCoverage(data))
.catch(() => { /* non-fatal */ });
fetch(url, { signal: controller.signal })
.then((response) => response.json())
.then((coverage: NodeCoverage[]) => {
if (!controller.signal.aborted) replaceCoverage(coverage, key);
})
.catch(() => {
// non-fatal
});
return () => controller.abort();
}, [enabled, scope.network, scope.observer]);
// Called when a coverage_update WS message arrives
const handleCoverageUpdate = useCallback((update: { node_id: string; geom: NodeCoverage['geom']; strength_geoms?: NodeCoverage['strength_geoms'] }) => {
setCoverage((prev) => {
const filtered = prev.filter((c) => c.node_id !== update.node_id);
return [...filtered, { node_id: update.node_id, geom: update.geom, strength_geoms: update.strength_geoms }];
});
}, []);
const handleCoverageUpdateBatch = useCallback((updates: { node_id: string; geom: NodeCoverage['geom']; strength_geoms?: NodeCoverage['strength_geoms'] }[]) => {
if (updates.length === 0) return;
setCoverage((prev) => {
const idsToRemove = new Set(updates.map(u => u.node_id));
const filtered = prev.filter((c) => !idsToRemove.has(c.node_id));
const added = updates.map(u => ({ node_id: u.node_id, geom: u.geom, strength_geoms: u.strength_geoms }));
return [...filtered, ...added];
});
}, []);
return { coverage, handleCoverageUpdate, handleCoverageUpdateBatch };
}
+129 -116
View File
@@ -1,10 +1,11 @@
import { useCallback, useState } from 'react';
import { MIN_LINK_OBSERVATIONS, linkKey, type LinkMetrics } from '../utils/pathing.js';
import { useSyncExternalStore } from 'react';
import { linkKey, type LinkMetrics } from '../utils/pathing.js';
type LinkUpdate = {
node_a_id: string;
node_b_id: string;
observed_count: number;
multibyte_observed_count?: number;
itm_viable: boolean | null;
itm_path_loss_db?: number | null;
count_a_to_b?: number;
@@ -15,134 +16,146 @@ export type ViableLinkSnapshot = {
node_a_id: string;
node_b_id: string;
observed_count: number;
multibyte_observed_count?: number;
itm_viable: boolean | null;
itm_path_loss_db?: number | null;
count_a_to_b?: number;
count_b_to_a?: number;
};
export function useLinkState() {
const [linkPairs, setLinkPairs] = useState<Set<string>>(new Set());
const [linkMetrics, setLinkMetrics] = useState<Map<string, LinkMetrics>>(new Map());
const [viablePairsArr, setViablePairsArr] = useState<[string, string][]>([]);
type LinkState = {
linkPairs: Set<string>;
linkMetrics: Map<string, LinkMetrics>;
viablePairsArr: [string, string][];
};
const applyInitialViablePairs = useCallback((viablePairs?: [string, string][]) => {
if (!viablePairs) return;
let state: LinkState = {
linkPairs: new Set(),
linkMetrics: new Map(),
viablePairsArr: [],
};
setLinkPairs(new Set(viablePairs.map(([a, b]) => linkKey(a, b))));
setLinkMetrics(() => {
const metrics = new Map<string, LinkMetrics>();
for (const [a, b] of viablePairs) {
metrics.set(linkKey(a, b), {
observed_count: MIN_LINK_OBSERVATIONS,
itm_viable: true,
});
}
return metrics;
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function setState(next: LinkState): void {
state = next;
emit();
}
function getState(): LinkState {
return state;
}
function applyInitialViablePairs(viablePairs?: [string, string][]): void {
if (!viablePairs) return;
const linkPairs = new Set(viablePairs.map(([a, b]) => linkKey(a, b)));
const linkMetrics = new Map<string, LinkMetrics>();
for (const [a, b] of viablePairs) {
linkMetrics.set(linkKey(a, b), {
observed_count: 0,
multibyte_observed_count: 0,
itm_viable: true,
});
setViablePairsArr(viablePairs);
}, []);
}
const applyInitialViableLinks = useCallback((viableLinks?: ViableLinkSnapshot[]) => {
if (!viableLinks || viableLinks.length === 0) return;
setState({
linkPairs,
linkMetrics,
viablePairsArr: viablePairs,
});
}
const pairs = viableLinks.map((l) => [l.node_a_id, l.node_b_id] as [string, string]);
setLinkPairs(new Set(pairs.map(([a, b]) => linkKey(a, b))));
setViablePairsArr(pairs);
setLinkMetrics(() => {
const metrics = new Map<string, LinkMetrics>();
for (const link of viableLinks) {
metrics.set(linkKey(link.node_a_id, link.node_b_id), {
observed_count: link.observed_count,
itm_viable: link.itm_viable,
itm_path_loss_db: link.itm_path_loss_db ?? null,
count_a_to_b: link.count_a_to_b,
count_b_to_a: link.count_b_to_a,
});
}
return metrics;
function applyInitialViableLinks(viableLinks?: ViableLinkSnapshot[]): void {
if (!viableLinks || viableLinks.length === 0) return;
const viablePairsArr = viableLinks.map((link) => [link.node_a_id, link.node_b_id] as [string, string]);
const linkPairs = new Set(viablePairsArr.map(([a, b]) => linkKey(a, b)));
const linkMetrics = new Map<string, LinkMetrics>();
for (const link of viableLinks) {
linkMetrics.set(linkKey(link.node_a_id, link.node_b_id), {
observed_count: link.observed_count,
multibyte_observed_count: link.multibyte_observed_count ?? 0,
itm_viable: link.itm_viable,
itm_path_loss_db: link.itm_path_loss_db ?? null,
count_a_to_b: link.count_a_to_b,
count_b_to_a: link.count_b_to_a,
});
}, []);
}
const applyLinkUpdate = useCallback((update: LinkUpdate) => {
const key = linkKey(update.node_a_id, update.node_b_id);
setLinkMetrics((prev) => {
const next = new Map(prev);
const existing = next.get(key);
next.set(key, {
observed_count: Math.max(existing?.observed_count ?? 0, update.observed_count ?? 0),
itm_viable: update.itm_viable ?? existing?.itm_viable ?? null,
itm_path_loss_db: update.itm_path_loss_db ?? existing?.itm_path_loss_db ?? null,
count_a_to_b: update.count_a_to_b ?? existing?.count_a_to_b,
count_b_to_a: update.count_b_to_a ?? existing?.count_b_to_a,
});
return next;
});
if (update.itm_viable && update.observed_count >= MIN_LINK_OBSERVATIONS) {
setLinkPairs((prev) => {
if (prev.has(key)) return prev;
const next = new Set(prev);
next.add(key);
return next;
});
setViablePairsArr((prev) => {
if (prev.some(([a, b]) => linkKey(a, b) === key)) return prev;
return [...prev, [update.node_a_id, update.node_b_id]];
});
}
}, []);
const applyLinkUpdateBatch = useCallback((updates: LinkUpdate[]) => {
if (updates.length === 0) return;
setLinkMetrics((prev) => {
const next = new Map(prev);
for (const update of updates) {
const key = linkKey(update.node_a_id, update.node_b_id);
const existing = next.get(key);
next.set(key, {
observed_count: Math.max(existing?.observed_count ?? 0, update.observed_count ?? 0),
itm_viable: update.itm_viable ?? existing?.itm_viable ?? null,
itm_path_loss_db: update.itm_path_loss_db ?? existing?.itm_path_loss_db ?? null,
count_a_to_b: update.count_a_to_b ?? existing?.count_a_to_b,
count_b_to_a: update.count_b_to_a ?? existing?.count_b_to_a,
});
}
return next;
});
const newViable = updates.filter(u => u.itm_viable && u.observed_count >= MIN_LINK_OBSERVATIONS);
if (newViable.length > 0) {
setLinkPairs((prev) => {
const next = new Set(prev);
for (const update of newViable) {
next.add(linkKey(update.node_a_id, update.node_b_id));
}
return next;
});
setViablePairsArr((prev) => {
let changed = false;
const added: [string, string][] = [];
for (const update of newViable) {
const key = linkKey(update.node_a_id, update.node_b_id);
if (!prev.some(([a, b]) => linkKey(a, b) === key)) {
added.push([update.node_a_id, update.node_b_id]);
changed = true;
}
}
return changed ? [...prev, ...added] : prev;
});
}
}, []);
return {
setState({
linkPairs,
linkMetrics,
viablePairsArr,
applyInitialViablePairs,
applyInitialViableLinks,
applyLinkUpdate,
applyLinkUpdateBatch,
};
});
}
function applyLinkUpdate(update: LinkUpdate): void {
applyLinkUpdateBatch([update]);
}
function applyLinkUpdateBatch(updates: LinkUpdate[]): void {
if (updates.length === 0) return;
const nextLinkMetrics = new Map(state.linkMetrics);
const nextLinkPairs = new Set(state.linkPairs);
const viablePairs = [...state.viablePairsArr];
const viablePairKeys = new Set(viablePairs.map(([a, b]) => linkKey(a, b)));
for (const update of updates) {
const key = linkKey(update.node_a_id, update.node_b_id);
const existing = nextLinkMetrics.get(key);
nextLinkMetrics.set(key, {
observed_count: Math.max(existing?.observed_count ?? 0, update.observed_count ?? 0),
multibyte_observed_count: Math.max(existing?.multibyte_observed_count ?? 0, update.multibyte_observed_count ?? 0),
itm_viable: update.itm_viable ?? existing?.itm_viable ?? null,
itm_path_loss_db: update.itm_path_loss_db ?? existing?.itm_path_loss_db ?? null,
count_a_to_b: update.count_a_to_b ?? existing?.count_a_to_b,
count_b_to_a: update.count_b_to_a ?? existing?.count_b_to_a,
});
if (update.itm_viable) {
nextLinkPairs.add(key);
if (!viablePairKeys.has(key)) {
viablePairKeys.add(key);
viablePairs.push([update.node_a_id, update.node_b_id]);
}
}
}
setState({
linkPairs: nextLinkPairs,
linkMetrics: nextLinkMetrics,
viablePairsArr: viablePairs,
});
}
export const linkStateStore = {
subscribe,
getState,
applyInitialViablePairs,
applyInitialViableLinks,
applyLinkUpdate,
applyLinkUpdateBatch,
};
export function useLinkPairs(): Set<string> {
return useSyncExternalStore(subscribe, () => state.linkPairs);
}
export function useLinkMetrics(): Map<string, LinkMetrics> {
return useSyncExternalStore(subscribe, () => state.linkMetrics);
}
export function useViablePairsArr(): [string, string][] {
return useSyncExternalStore(subscribe, () => state.viablePairsArr);
}
+237 -149
View File
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useSyncExternalStore } from 'react';
import {
createAggregatedPacketFromLive,
extractPacketSummary,
@@ -21,8 +21,8 @@ export interface MeshNode {
is_online: boolean;
hardware_model?: string;
public_key?: string;
advert_count?: number; // persistent DB count of times this node has advertised
elevation_m?: number; // terrain elevation ASL from SRTM (set when viewshed computed)
advert_count?: number;
elevation_m?: number;
is_inferred?: boolean;
inferred_prefix?: string;
inferred_hash_size_bytes?: number;
@@ -44,27 +44,26 @@ export interface LivePacketData {
direction?: string;
summary?: string;
payload?: Record<string, unknown>;
path?: string[]; // relay hop hashes in packet order (1/2/3-byte => 2/4/6 hex chars)
advertCount?: number; // for Advert packets: persistent count from DB
path?: string[];
advertCount?: number;
ts: number;
}
/** Deduplicated packet entry shown in the live feed. */
export interface AggregatedPacket {
id: string; // stable React key (first seen)
id: string;
packetHash: string;
packetType?: number;
rxNodeId?: string; // observer — for node-name fallback
rxNodeId?: string;
observerIds: string[];
srcNodeId?: string; // sender node id (from decoded payload)
srcNodeId?: string;
summary?: string;
hopCount?: number;
pathHashSizeBytes?: number;
path?: string[]; // relay hop hashes from first observation
path?: string[];
rxCount: number;
txCount: number;
ts: number; // most recent activity
advertCount?: number; // for Advert packets: how many times this node has advertised this session
ts: number;
advertCount?: number;
}
export interface PacketArc {
@@ -76,145 +75,234 @@ export interface PacketArc {
packetHash: string;
}
type NodeStoreState = {
nodes: Map<string, MeshNode>;
packets: AggregatedPacket[];
arcs: PacketArc[];
activeNodes: Set<string>;
};
let state: NodeStoreState = {
nodes: new Map(),
packets: [],
arcs: [],
activeNodes: new Set(),
};
const listeners = new Set<() => void>();
function emit(): void {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function setState(next: NodeStoreState): void {
state = next;
emit();
}
function getState(): NodeStoreState {
return state;
}
function handleInitialState(data: { nodes: MeshNode[]; packets: RecentPacketRow[] }) {
const nodeMap = new Map<string, MeshNode>();
for (const n of data.nodes) nodeMap.set(n.node_id, n);
setState({
...state,
nodes: nodeMap,
packets: mapRecentRows(data.packets),
});
}
function replaceRecentPackets(rows: RecentPacketRow[]) {
const mapped = mapRecentRows(rows);
setState({
...state,
packets: mergePackets(state.packets, mapped),
});
}
function matchesObserverPathHash(observerId: string | undefined, hash: string | undefined): boolean {
if (!observerId || !hash) return false;
const normalizedHash = hash.trim().toUpperCase();
if (!normalizedHash) return false;
return observerId.slice(0, normalizedHash.length).toUpperCase() === normalizedHash;
}
function isObserverSelfEchoLoop(packet: LivePacketData, nodes: Map<string, MeshNode>): boolean {
if (!packet.rxNodeId || !packet.path || packet.path.length < 3) return false;
const observer = nodes.get(packet.rxNodeId);
if (!observer || observer.role !== 2) return false;
return matchesObserverPathHash(packet.rxNodeId, packet.path[0]) && matchesObserverPathHash(packet.rxNodeId, packet.path[packet.path.length - 1]);
}
function handlePacket(packetOrArray: LivePacketData | LivePacketData[]) {
const incomingPackets = Array.isArray(packetOrArray) ? packetOrArray : [packetOrArray];
if (incomingPackets.length === 0) return;
let next = state.packets;
for (const packet of incomingPackets) {
const idx = next.findIndex((p) => p.packetHash === packet.packetHash);
if (idx >= 0) {
const current = next[idx]!;
if (packet.rxNodeId && current.observerIds.includes(packet.rxNodeId) && isObserverSelfEchoLoop(packet, state.nodes)) {
continue;
}
const observerIds = packet.rxNodeId
? [packet.rxNodeId, ...current.observerIds.filter((id) => id !== packet.rxNodeId)]
: current.observerIds;
const candidate: AggregatedPacket = {
...current,
packetType: packet.packetType ?? current.packetType,
rxNodeId: packet.rxNodeId ?? current.rxNodeId,
observerIds,
srcNodeId: packet.srcNodeId ?? current.srcNodeId,
summary: packet.summary ?? extractPacketSummary(packet.payload) ?? current.summary,
hopCount: packet.hopCount ?? current.hopCount,
pathHashSizeBytes: packet.pathHashSizeBytes ?? current.pathHashSizeBytes,
path: packet.path ?? current.path,
advertCount: Math.max(current.advertCount ?? 0, packet.advertCount ?? 0) || undefined,
rxCount: current.rxCount + (packet.direction !== 'tx' ? 1 : 0),
txCount: current.txCount + (packet.direction === 'tx' ? 1 : 0),
ts: packet.ts,
};
const entry: AggregatedPacket = {
...(packetInfoScore(candidate) >= packetInfoScore(current)
? candidate
: mergeAggregatedPacket(current, {
...createAggregatedPacketFromLive(packet),
observerIds,
rxCount: current.rxCount + (packet.direction !== 'tx' ? 1 : 0),
txCount: current.txCount + (packet.direction === 'tx' ? 1 : 0),
})),
rxCount: current.rxCount + (packet.direction !== 'tx' ? 1 : 0),
txCount: current.txCount + (packet.direction === 'tx' ? 1 : 0),
ts: packet.ts,
};
next = next.map((p, i) => i === idx ? entry : p);
} else {
const entry = createAggregatedPacketFromLive(packet);
next = [entry, ...next].slice(0, FEED_MAX_PACKETS);
}
}
setState({
...state,
packets: next,
});
}
function handleNodeUpdate(data: { nodeId: string; ts: number }) {
const existing = state.nodes.get(data.nodeId);
const next = new Map(state.nodes);
next.set(data.nodeId, {
node_id: data.nodeId,
...(existing ?? {}),
last_seen: new Date(data.ts).toISOString(),
is_online: true,
});
setState({
...state,
nodes: next,
});
}
function handleNodeUpdateBatch(updates: { nodeId: string; ts: number }[]) {
if (updates.length === 0) return;
const next = new Map(state.nodes);
for (const data of updates) {
const existing = state.nodes.get(data.nodeId);
next.set(data.nodeId, {
node_id: data.nodeId,
...(existing ?? {}),
last_seen: new Date(data.ts).toISOString(),
is_online: true,
});
}
setState({
...state,
nodes: next,
});
}
function handleNodeUpsert(node: Partial<MeshNode> & { node_id: string }) {
const existing = state.nodes.get(node.node_id) ?? {
node_id: node.node_id,
last_seen: new Date().toISOString(),
is_online: true,
};
const updates = Object.fromEntries(
Object.entries(node).filter(([, value]) => value !== undefined),
) as Partial<MeshNode> & { node_id: string };
const next = new Map(state.nodes);
next.set(node.node_id, { ...existing, ...updates });
setState({
...state,
nodes: next,
});
}
function handleNodeUpsertBatch(nodes: (Partial<MeshNode> & { node_id: string })[]) {
if (nodes.length === 0) return;
const next = new Map(state.nodes);
const nowIso = new Date().toISOString();
for (const node of nodes) {
const existing = state.nodes.get(node.node_id) ?? {
node_id: node.node_id,
last_seen: nowIso,
is_online: true,
};
const updates = Object.fromEntries(
Object.entries(node).filter(([, value]) => value !== undefined),
) as Partial<MeshNode> & { node_id: string };
next.set(node.node_id, { ...existing, ...updates });
}
setState({
...state,
nodes: next,
});
}
export const nodeStore = {
subscribe,
getState,
handleInitialState,
replaceRecentPackets,
handlePacket,
handleNodeUpdate,
handleNodeUpdateBatch,
handleNodeUpsert,
handleNodeUpsertBatch,
};
export function useNodeMap(): Map<string, MeshNode> {
return useSyncExternalStore(subscribe, () => state.nodes);
}
export function usePackets(): AggregatedPacket[] {
return useSyncExternalStore(subscribe, () => state.packets);
}
export function useArcs(): PacketArc[] {
return useSyncExternalStore(subscribe, () => state.arcs);
}
export function useActiveNodes(): Set<string> {
return useSyncExternalStore(subscribe, () => state.activeNodes);
}
export function useNodes() {
const [nodes, setNodes] = useState<Map<string, MeshNode>>(new Map());
const [packets, setPackets] = useState<AggregatedPacket[]>([]);
const [arcs] = useState<PacketArc[]>([]);
const [activeNodes] = useState<Set<string>>(new Set());
const handleInitialState = useCallback((data: {
nodes: MeshNode[];
packets: RecentPacketRow[];
}) => {
const nodeMap = new Map<string, MeshNode>();
for (const n of data.nodes) nodeMap.set(n.node_id, n);
setNodes(nodeMap);
setPackets(mapRecentRows(data.packets));
}, []);
const replaceRecentPackets = useCallback((rows: RecentPacketRow[]) => {
const mapped = mapRecentRows(rows);
setPackets((prev) => mergePackets(prev, mapped));
}, []);
const handlePacket = useCallback((packetOrArray: LivePacketData | LivePacketData[]) => {
const packets = Array.isArray(packetOrArray) ? packetOrArray : [packetOrArray];
if (packets.length === 0) return;
setPackets((prev) => {
let next = prev;
for (const packet of packets) {
const idx = next.findIndex((p) => p.packetHash === packet.packetHash);
if (idx >= 0) {
const current = next[idx]!;
const observerIds = packet.rxNodeId
? [packet.rxNodeId, ...current.observerIds.filter((id) => id !== packet.rxNodeId)]
: current.observerIds;
const candidate: AggregatedPacket = {
...current,
packetType: packet.packetType ?? current.packetType,
rxNodeId: packet.rxNodeId ?? current.rxNodeId,
observerIds,
srcNodeId: packet.srcNodeId ?? current.srcNodeId,
summary: packet.summary ?? extractPacketSummary(packet.payload) ?? current.summary,
hopCount: packet.hopCount ?? current.hopCount,
pathHashSizeBytes: packet.pathHashSizeBytes ?? current.pathHashSizeBytes,
path: packet.path ?? current.path,
advertCount: Math.max(current.advertCount ?? 0, packet.advertCount ?? 0) || undefined,
rxCount: current.rxCount + (packet.direction !== 'tx' ? 1 : 0),
txCount: current.txCount + (packet.direction === 'tx' ? 1 : 0),
ts: packet.ts,
};
const entry: AggregatedPacket = {
...(packetInfoScore(candidate) >= packetInfoScore(current)
? candidate
: mergeAggregatedPacket(current, {
...createAggregatedPacketFromLive(packet),
observerIds,
rxCount: current.rxCount + (packet.direction !== 'tx' ? 1 : 0),
txCount: current.txCount + (packet.direction === 'tx' ? 1 : 0),
})),
rxCount: current.rxCount + (packet.direction !== 'tx' ? 1 : 0),
txCount: current.txCount + (packet.direction === 'tx' ? 1 : 0),
ts: packet.ts,
};
next = next.map((p, i) => i === idx ? entry : p);
} else {
const entry = createAggregatedPacketFromLive(packet);
next = [entry, ...next].slice(0, FEED_MAX_PACKETS);
}
}
return next;
});
}, []);
const handleNodeUpdate = useCallback((data: { nodeId: string; ts: number }) => {
setNodes((prev) => {
const existing = prev.get(data.nodeId);
const next = new Map(prev);
next.set(data.nodeId, {
node_id: data.nodeId,
...(existing ?? {}),
last_seen: new Date(data.ts).toISOString(),
is_online: true,
});
return next;
});
}, []);
const handleNodeUpdateBatch = useCallback((updates: { nodeId: string; ts: number }[]) => {
if (updates.length === 0) return;
setNodes((prev) => {
const next = new Map(prev);
for (const data of updates) {
const existing = prev.get(data.nodeId);
next.set(data.nodeId, {
node_id: data.nodeId,
...(existing ?? {}),
last_seen: new Date(data.ts).toISOString(),
is_online: true,
});
}
return next;
});
}, []);
const handleNodeUpsert = useCallback((node: Partial<MeshNode> & { node_id: string }) => {
setNodes((prev) => {
const existing = prev.get(node.node_id) ?? { node_id: node.node_id, last_seen: new Date().toISOString(), is_online: true };
const next = new Map(prev);
// Filter out undefined values so they don't overwrite existing lat/lon/name etc.
const updates = Object.fromEntries(
Object.entries(node).filter(([, v]) => v !== undefined)
) as Partial<MeshNode> & { node_id: string };
next.set(node.node_id, { ...existing, ...updates });
return next;
});
}, []);
const handleNodeUpsertBatch = useCallback((nodes: (Partial<MeshNode> & { node_id: string })[]) => {
if (nodes.length === 0) return;
setNodes((prev) => {
const next = new Map(prev);
const now = new Date();
for (const node of nodes) {
const existing = prev.get(node.node_id) ?? { node_id: node.node_id, last_seen: now.toISOString(), is_online: true };
const updates = Object.fromEntries(
Object.entries(node).filter(([, v]) => v !== undefined)
) as Partial<MeshNode> & { node_id: string };
next.set(node.node_id, { ...existing, ...updates });
}
return next;
});
}, []);
return {
nodes,
packets,
arcs,
activeNodes,
nodes: useNodeMap(),
packets: usePackets(),
arcs: useArcs(),
activeNodes: useActiveNodes(),
handleInitialState,
replaceRecentPackets,
handlePacket,
+66 -43
View File
@@ -1,16 +1,19 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AggregatedPacket, MeshNode } from './useNodes.js';
import type { AggregatedPacket } from './useNodes.js';
import { useNodeMap, usePackets } from './useNodes.js';
import { withScopeParams, uncachedEndpoint } from '../utils/api.js';
import type { Filters } from '../components/FilterPanel/FilterPanel.js';
import { hasCoords } from '../utils/pathing.js';
import {
aggregateServerPredictions,
buildRegularPacketPaths,
packetObserverIds,
type AggregatedPredictionState,
type MultiObserverBetaResponse,
type PathSegment,
type ServerBetaResponse,
} from './packetPathOverlayUtils.js';
import { useOverlayStore } from '../store/overlayStore.js';
const PATH_TTL = 5_000;
const PREDICTION_CACHE_TTL_MS = 120_000;
@@ -19,8 +22,6 @@ const RECENT_PREDICTION_TTL_MS = 45_000;
const MAX_RECENT_PREDICTIONS = 48;
type UsePacketPathOverlayParams = {
packets: AggregatedPacket[];
nodes: Map<string, MeshNode>;
filters: Filters;
network?: string;
observer?: string;
@@ -39,6 +40,7 @@ type UsePacketPathOverlayResult = {
pathFadingOut: boolean;
pinnedPacketId: string | null;
pinnedPacketSnapshot: AggregatedPacket | null;
activePacketSnapshot: AggregatedPacket | null;
handlePacketPin: (packet: AggregatedPacket) => void;
};
@@ -61,16 +63,34 @@ function cacheKey(packetHash: string, network?: string, observer?: string): stri
}
function multiCacheKey(packetHash: string, observerIds: string[], network?: string): string {
return `multi|${network ?? 'all'}|${observerIds.sort().join(',')}|${packetHash}`;
return `multi|${network ?? 'all'}|${[...observerIds].sort().join(',')}|${packetHash}`;
}
function packetResolutionKey(packet: AggregatedPacket | null | undefined, network?: string, observer?: string): string | null {
if (!packet) return null;
return [
packet.id,
packet.packetHash,
packet.packetType ?? '',
packet.srcNodeId ?? '',
packet.rxNodeId ?? '',
packet.hopCount ?? '',
packet.pathHashSizeBytes ?? '',
packet.path?.join(',') ?? '',
[...packet.observerIds].sort().join(','),
packet.ts,
network ?? 'all',
observer ?? 'all',
].join('|');
}
export function usePacketPathOverlay({
packets,
nodes,
filters,
network,
observer,
}: UsePacketPathOverlayParams): UsePacketPathOverlayResult {
const packets = usePackets();
const nodes = useNodeMap();
const [packetPaths, setPacketPaths] = useState<[number, number][][]>([]);
const [betaPacketPaths, setBetaPacketPaths] = useState<[number, number][][]>([]);
const [betaLowConfidencePaths, setBetaLowConfidencePaths] = useState<[number, number][][]>([]);
@@ -79,8 +99,10 @@ export function usePacketPathOverlay({
const [betaPathConfidence, setBetaPathConfidence] = useState<number | null>(null);
const [betaPermutationCount, setBetaPermutationCount] = useState<number | null>(null);
const [betaRemainingHops, setBetaRemainingHops] = useState<number | null>(null);
const [pinnedPacketId, setPinnedPacketId] = useState<string | null>(null);
const [pinnedPacketSnapshot, setPinnedPacketSnapshot] = useState<AggregatedPacket | null>(null);
const pinnedPacketId = useOverlayStore((state) => state.pinnedPacketId);
const pinnedPacketSnapshot = useOverlayStore((state) => state.pinnedPacketSnapshot);
const togglePinnedPacket = useOverlayStore((state) => state.togglePinnedPacket);
const clearPinnedPacket = useOverlayStore((state) => state.clearPinnedPacket);
// CSS-based fade: instead of animating opacity via 60fps rAF (which caused ~60 MapView
// re-renders/second), we set a single boolean that triggers a CSS transition on the pane.
const [pathFadingOut, setPathFadingOut] = useState(false);
@@ -208,17 +230,21 @@ export function usePacketPathOverlay({
const getPacketObserverIds = useCallback((packet: AggregatedPacket | undefined): string[] => packetObserverIds(packet), []);
const buildLocalPaths = useCallback((packet: AggregatedPacket | undefined, observerIds: string[]) => (
buildRegularPacketPaths(packet, observerIds, nodes)
), [nodes]);
const shouldCollapseAdvertObserverPartials = useCallback((packet: AggregatedPacket | undefined): boolean => {
if (!packet || packet.packetType !== 4 || !packet.srcNodeId) return false;
const src = nodes.get(packet.srcNodeId);
return !hasCoords(src);
}, [nodes]);
const resolvePrediction = useCallback((packetHash: string, networkName?: string, observerId?: string): Promise<ServerBetaResponse | null> => {
const resolvePrediction = useCallback((packetHash: string, networkName?: string, observerId?: string, minFreshTs = 0): Promise<ServerBetaResponse | null> => {
prunePredictionCache();
const key = cacheKey(packetHash, networkName, observerId);
const cached = predictionCacheRef.current.get(key);
if (cached && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) {
if (cached && cached.ts >= minFreshTs && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) {
return Promise.resolve(cached.prediction);
}
@@ -243,12 +269,12 @@ export function usePacketPathOverlay({
const multiPredictionCacheRef = useRef<Map<string, { results: ServerBetaResponse[]; ts: number }>>(new Map());
const multiInflightRef = useRef<Map<string, Promise<ServerBetaResponse[]>>>(new Map());
const resolveMultiPrediction = useCallback((packetHash: string, observerIds: string[], networkName?: string): Promise<ServerBetaResponse[]> => {
const resolveMultiPrediction = useCallback((packetHash: string, observerIds: string[], networkName?: string, minFreshTs = 0): Promise<ServerBetaResponse[]> => {
prunePredictionCache();
const key = multiCacheKey(packetHash, observerIds, networkName);
const cached = multiPredictionCacheRef.current.get(key);
if (cached && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) {
if (cached && cached.ts >= minFreshTs && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) {
return Promise.resolve(cached.results);
}
@@ -283,7 +309,11 @@ export function usePacketPathOverlay({
return p;
}, [prunePredictionCache]);
const latestId = packets.find((p) => p.packetType === 4 || p.packetType === 5)?.id;
const latestPacket = packets.find((p) => p.packetType === 4 || p.packetType === 5) ?? null;
const latestResolutionKey = packetResolutionKey(latestPacket, network, observer);
const activePacketSnapshot = pinnedPacketId !== null
? (packets.find((packet) => packet.id === pinnedPacketId) ?? pinnedPacketSnapshot)
: (filters.betaPaths ? latestPacket : null);
const betaEffectThrottleRef = useRef<number | null>(null);
// Keep a ref to the latest packets so we can read them inside effects without
@@ -308,7 +338,7 @@ export function usePacketPathOverlay({
// effect on every packet arrival.
const latest = packetsRef.current.find((p) => p.packetType === 4 || p.packetType === 5);
const observerIds = getPacketObserverIds(latest);
setPacketPaths([]);
setPacketPaths(buildLocalPaths(latest, observerIds));
if (!isPageVisible) {
setPathFadingOut(false);
@@ -318,8 +348,8 @@ export function usePacketPathOverlay({
if (filters.betaPaths && latest?.packetHash && latest.path?.length && observerIds.length > 0) {
const reqSeq = ++activeReqSeqRef.current;
const resolveFn = observerIds.length > 1
? resolveMultiPrediction(latest.packetHash, observerIds, network)
: Promise.all(observerIds.map((observerId) => resolvePrediction(latest.packetHash, network, observerId)));
? resolveMultiPrediction(latest.packetHash, observerIds, network, latest.ts)
: Promise.all(observerIds.map((observerId) => resolvePrediction(latest.packetHash, network, observerId, latest.ts)));
void resolveFn
.then((predictions) => {
if (reqSeq !== activeReqSeqRef.current) return;
@@ -364,51 +394,41 @@ export function usePacketPathOverlay({
// eslint-disable-next-line react-hooks/exhaustive-deps
// `packets` intentionally omitted — accessed via packetsRef to avoid firing on every
// packet arrival. Effect only re-runs when latestId changes (a new distinct path packet).
}, [latestId, filters.betaPaths, pinnedPacketId, network, observer, getPacketObserverIds, resolvePrediction, resolveMultiPrediction, stopPathTimers, clearPathState, applyServerPredictions, isPageVisible, pruneRecentPredictions]);
}, [latestResolutionKey, filters.betaPaths, pinnedPacketId, network, observer, getPacketObserverIds, buildLocalPaths, resolvePrediction, resolveMultiPrediction, stopPathTimers, clearPathState, applyServerPredictions, isPageVisible, pruneRecentPredictions]);
const handlePacketPin = useCallback((packet: AggregatedPacket) => {
if (pinnedPacketId === packet.id) {
setPinnedPacketId(null);
setPinnedPacketSnapshot(null);
pinnedOverlayKeyRef.current = '';
if (pinnedTimerRef.current) {
clearTimeout(pinnedTimerRef.current);
pinnedTimerRef.current = null;
}
stopPathTimers();
clearPathState();
return;
}
togglePinnedPacket(packet);
}, [togglePinnedPacket]);
stopPathTimers();
useEffect(() => {
if (pinnedTimerRef.current) {
clearTimeout(pinnedTimerRef.current);
pinnedTimerRef.current = null;
}
setPathFadingOut(false);
setPinnedPacketId(packet.id);
setPinnedPacketSnapshot(packet);
if (pinnedPacketId === null) {
pinnedOverlayKeyRef.current = '';
stopPathTimers();
clearPathState();
return;
}
setPathFadingOut(false);
const FADE_MS = 1_000;
pinnedTimerRef.current = setTimeout(() => {
setPathFadingOut(true);
pathFadeTimerRef.current = setTimeout(() => {
pathFadeTimerRef.current = null;
clearPathState();
setPinnedPacketId(null);
setPinnedPacketSnapshot(null);
clearPinnedPacket();
pinnedOverlayKeyRef.current = '';
pinnedTimerRef.current = null;
}, FADE_MS);
}, 30_000);
}, [pinnedPacketId, stopPathTimers, clearPathState]);
}, [clearPathState, clearPinnedPacket, pinnedPacketId, stopPathTimers]);
useEffect(() => {
if (pinnedPacketId === null) {
pinnedOverlayKeyRef.current = '';
return;
}
if (pinnedPacketId === null) return;
pruneRecentPredictions();
if (!isPageVisible) return;
@@ -426,18 +446,19 @@ export function usePacketPathOverlay({
filters.betaPaths ? 'beta-on' : 'beta-off',
network ?? 'all',
observer ?? 'all',
pinnedPacket.ts,
].join('|');
if (overlayKey === pinnedOverlayKeyRef.current) return;
pinnedOverlayKeyRef.current = overlayKey;
setPacketPaths([]);
setPacketPaths(buildLocalPaths(pinnedPacket, observerIds));
if (filters.betaPaths && pinnedPacket.packetHash && pinnedPacket.path?.length && observerIds.length > 0) {
const reqSeq = ++activeReqSeqRef.current;
const resolveFn = observerIds.length > 1
? resolveMultiPrediction(pinnedPacket.packetHash!, observerIds, network)
: Promise.all(observerIds.map((observerId) => resolvePrediction(pinnedPacket.packetHash!, network, observerId)));
? resolveMultiPrediction(pinnedPacket.packetHash!, observerIds, network, pinnedPacket.ts)
: Promise.all(observerIds.map((observerId) => resolvePrediction(pinnedPacket.packetHash!, network, observerId, pinnedPacket.ts)));
void resolveFn
.then((predictions) => {
if (reqSeq !== activeReqSeqRef.current) return;
@@ -470,6 +491,7 @@ export function usePacketPathOverlay({
network,
observer,
getPacketObserverIds,
buildLocalPaths,
shouldCollapseAdvertObserverPartials,
resolvePrediction,
resolveMultiPrediction,
@@ -495,6 +517,7 @@ export function usePacketPathOverlay({
pathFadingOut,
pinnedPacketId,
pinnedPacketSnapshot,
activePacketSnapshot,
handlePacketPin,
};
}
+3 -2
View File
@@ -17,9 +17,10 @@ const FRONTEND: LibEntry[] = [
{ name: 'React 18', role: 'Component-based UI framework', url: 'https://react.dev' },
{ name: 'Vite', role: 'Fast build tool and dev server', url: 'https://vitejs.dev' },
{ name: 'TypeScript', role: 'Static typing across the entire codebase', url: 'https://www.typescriptlang.org' },
{ name: 'Leaflet', role: 'Interactive tile-based map rendering', url: 'https://leafletjs.com' },
{ name: 'react-leaflet', role: 'React bindings for Leaflet', url: 'https://react-leaflet.js.org' },
{ name: 'MapLibre GL JS', role: 'GPU-rendered vector/raster map engine', url: 'https://maplibre.org' },
{ name: 'deck.gl', role: 'WebGL overlay for animated packet arc trails', url: 'https://deck.gl' },
{ name: '@deck.gl/mapbox', role: 'Native deck.gl integration with the MapLibre map', url: 'https://deck.gl/docs/api-reference/mapbox/overview' },
{ name: 'Zustand', role: 'Lightweight client state management for UI atoms', url: 'https://zustand-demo.pmnd.rs' },
{ name: 'react-router-dom', role: 'Client-side routing between pages', url: 'https://reactrouter.com' },
{ name: 'Recharts', role: 'Chart components for stats and history graphs', url: 'https://recharts.org' },
{ name: 'polygon-clipping', role: 'Coverage polygon clipping to UK mainland bounds', url: 'https://github.com/mfogel/polygon-clipping' },
+94 -64
View File
@@ -1,6 +1,6 @@
import React, { FormEvent, useEffect, useMemo, useState } from 'react';
import { CircleMarker, MapContainer, Polyline, Popup, TileLayer, useMap } from 'react-leaflet';
import type { LatLngExpression } from 'leaflet';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
type OwnerNode = {
@@ -162,7 +162,7 @@ function formatPathLoss(value: number | null): string {
function linkBadge(link: OwnerLiveResponse['linkHealth'][number]): string {
if (link.force_viable) return 'Forced';
if (link.itm_viable) return 'Viable';
if (link.itm_path_loss_db != null && link.itm_path_loss_db <= 137.88) return 'Weak';
if (link.itm_path_loss_db != null && link.itm_path_loss_db <= 145.0) return 'Weak';
return 'Unproven';
}
@@ -329,22 +329,98 @@ const TelemetryStatCard: React.FC<{
</article>
);
const MAP_CENTER: LatLngExpression = [54.6, -1.2];
const CARTO_DARK_TILES = 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const OwnerMapView: React.FC<{
ownerCoord: { lat: number; lon: number } | null;
peers: MappedPeer[];
allPoints: Array<{ lat: number; lon: number }>;
}> = ({ ownerCoord, peers, allPoints }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const FitToNodes: React.FC<{ points: Array<{ lat: number; lon: number }> }> = ({ points }) => {
const map = useMap();
useEffect(() => {
if (points.length === 0) return;
if (points.length === 1) {
map.setView([points[0].lat, points[0].lon], 10, { animate: false });
return;
}
map.fitBounds(
points.map((p) => [p.lat, p.lon] as [number, number]),
{ padding: [24, 24], animate: false },
);
}, [map, points]);
return null;
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: { tiles: { type: 'raster', tiles: [CARTO_DARK_TILES], tileSize: 256, maxzoom: 19, attribution: '© OpenStreetMap © CARTO' } },
layers: [{ id: 'bg', type: 'raster', source: 'tiles' }],
},
center: [-1.2, 54.6],
zoom: 7,
attributionControl: false,
});
// Disable interaction — purely static display
map.scrollZoom.disable();
map.dragPan.disable();
map.boxZoom.disable();
map.doubleClickZoom.disable();
map.keyboard.disable();
map.touchZoomRotate.disable();
const EMPTY: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
const buildNodeFC = (): GeoJSON.FeatureCollection => ({
type: 'FeatureCollection',
features: [
...(ownerCoord ? [{
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [ownerCoord.lon, ownerCoord.lat] },
properties: { kind: 'owner' },
}] : []),
...peers.map((p) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [p.lon, p.lat] },
properties: { kind: 'peer' },
})),
],
});
const buildLineFC = (): GeoJSON.FeatureCollection => ({
type: 'FeatureCollection',
features: ownerCoord ? peers.map((p) => ({
type: 'Feature' as const,
geometry: { type: 'LineString' as const, coordinates: [[ownerCoord.lon, ownerCoord.lat], [p.lon, p.lat]] },
properties: {},
})) : [],
});
map.on('load', () => {
map.addSource('owner-lines', { type: 'geojson', data: buildLineFC() });
map.addSource('owner-nodes', { type: 'geojson', data: buildNodeFC() });
map.addLayer({ id: 'owner-lines-layer', type: 'line', source: 'owner-lines',
paint: { 'line-color': '#00c4ff', 'line-width': 1.5, 'line-opacity': 0.6 } });
map.addLayer({ id: 'owner-nodes-layer', type: 'circle', source: 'owner-nodes',
paint: {
'circle-radius': ['case', ['==', ['get', 'kind'], 'owner'], 8, 6],
'circle-color': 'transparent',
'circle-stroke-width': 2,
'circle-stroke-color': ['case', ['==', ['get', 'kind'], 'owner'], '#00c4ff', '#ffb300'],
} });
if (allPoints.length === 1) {
map.setCenter([allPoints[0].lon, allPoints[0].lat]);
map.setZoom(10);
} else if (allPoints.length > 1) {
const bounds = new maplibregl.LngLatBounds();
for (const pt of allPoints) bounds.extend([pt.lon, pt.lat]);
map.fitBounds(bounds, { padding: 24, animate: false });
}
});
return () => {
map.remove();
void EMPTY; // silence unused warning
};
// Re-create map whenever data changes (static display map, re-creation is fine)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ownerCoord, peers, allPoints]);
return <div ref={containerRef} className="owner-map" />;
};
export const OwnerPortalPage: React.FC = () => {
@@ -637,53 +713,7 @@ export const OwnerPortalPage: React.FC = () => {
</div>
</div>
<div className="owner-map-wrap">
<MapContainer
center={MAP_CENTER}
zoom={7}
className="owner-map"
zoomControl={false}
dragging={false}
scrollWheelZoom={false}
doubleClickZoom={false}
boxZoom={false}
keyboard={false}
touchZoom={false}
>
<TileLayer
attribution='&copy; OpenStreetMap contributors &copy; CARTO'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
<FitToNodes points={mapPoints} />
{ownerCoord ? (
<CircleMarker center={[ownerCoord.lat, ownerCoord.lon]} radius={8} pathOptions={{ color: '#00c4ff', weight: 2 }}>
<Popup>
<strong>{live?.ownerNode.name ?? `Owner ${nodeRoleLabel(live?.ownerNode.role ?? null).toLowerCase()}`}</strong><br />
{live?.ownerNode.network} · {live?.ownerNode.iata ?? '-'}
</Popup>
</CircleMarker>
) : null}
{mapPeers.map((peer) => (
<CircleMarker key={peer.node_id} center={[peer.lat, peer.lon]} radius={6} pathOptions={{ color: '#ffb300', weight: 2 }}>
<Popup>
<strong>{peer.name ?? peer.node_id}</strong><br />
{peer.network ?? 'Unknown'} · {peer.iata ?? '-'}<br />
Packets 24h: {peer.packets_24h}
</Popup>
</CircleMarker>
))}
{ownerCoord
? mapPeers.map((peer) => (
<Polyline
key={`link-${peer.node_id}`}
positions={[
[ownerCoord.lat, ownerCoord.lon],
[peer.lat, peer.lon],
]}
pathOptions={{ color: '#00c4ff', weight: 1.5, opacity: 0.6 }}
/>
))
: null}
</MapContainer>
<OwnerMapView ownerCoord={ownerCoord} peers={mapPeers} allPoints={mapPoints} />
</div>
</section>
+110 -38
View File
@@ -4,7 +4,8 @@ import {
PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer,
} from 'recharts';
import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip as LeafletTooltip, useMap } from 'react-leaflet';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { getCurrentSite } from '../config/site.js';
import { chartStatsEndpoint, uncachedEndpoint } from '../utils/api.js';
@@ -179,19 +180,115 @@ const ChartCard: React.FC<{ title: string; sub?: string; children: React.ReactNo
</div>
);
const FitDecodedPath: React.FC<{ points: [number, number][] }> = ({ points }) => {
const map = useMap();
const CARTO_DARK_TILES = 'https://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const DecodedPathMapView: React.FC<{
nodes: Array<{
ord: number;
node_id: string;
name: string | null;
lat: number | null;
lon: number | null;
}>;
}> = ({ nodes }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (points.length < 1) return;
if (points.length === 1) {
map.setView(points[0], 11);
return;
}
map.fitBounds(points, { padding: [24, 24] });
}, [map, points]);
if (!containerRef.current || nodes.length < 2) return;
return null;
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {
tiles: {
type: 'raster',
tiles: [CARTO_DARK_TILES],
tileSize: 256,
maxzoom: 19,
attribution: '© OpenStreetMap © CARTO',
},
},
layers: [{ id: 'bg', type: 'raster', source: 'tiles' }],
},
center: [Number(nodes[0]!.lon), Number(nodes[0]!.lat)],
zoom: 8,
attributionControl: false,
});
map.on('load', () => {
const lineData: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: nodes.map((node) => [Number(node.lon), Number(node.lat)] as [number, number]),
},
properties: {},
}],
};
const pointData: GeoJSON.FeatureCollection<GeoJSON.Point, { ord: string }> = {
type: 'FeatureCollection',
features: nodes.map((node) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [Number(node.lon), Number(node.lat)] },
properties: { ord: String(node.ord) },
})),
};
map.addSource('decoded-path-line', { type: 'geojson', data: lineData });
map.addSource('decoded-path-nodes', { type: 'geojson', data: pointData });
map.addLayer({
id: 'decoded-path-line-layer',
type: 'line',
source: 'decoded-path-line',
paint: {
'line-color': C_PURPLE,
'line-width': 4,
'line-opacity': 0.9,
},
});
map.addLayer({
id: 'decoded-path-node-circles',
type: 'circle',
source: 'decoded-path-nodes',
paint: {
'circle-radius': 10,
'circle-color': '#0b1725',
'circle-stroke-color': C_CYAN,
'circle-stroke-width': 2,
},
});
map.addLayer({
id: 'decoded-path-node-labels',
type: 'symbol',
source: 'decoded-path-nodes',
layout: {
'text-field': ['get', 'ord'],
'text-size': 12,
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
},
paint: {
'text-color': '#ffffff',
},
});
const bounds = new maplibregl.LngLatBounds();
for (const node of nodes) bounds.extend([Number(node.lon), Number(node.lat)]);
map.fitBounds(bounds, { padding: 24, animate: false });
});
return () => {
map.remove();
};
}, [nodes]);
return <div ref={containerRef} style={{ height: '100%', width: '100%' }} />;
};
// ── Main page ─────────────────────────────────────────────────────────────────
@@ -280,7 +377,6 @@ export const StatsPage: React.FC = () => {
const selectedDecodedPathNodes = (selectedDecodedPath?.nodes ?? []).filter(
(node) => Number.isFinite(node.lat) && Number.isFinite(node.lon),
);
const selectedDecodedPathPoints = selectedDecodedPathNodes.map((node) => [Number(node.lat), Number(node.lon)] as [number, number]);
const isRedactedDecodedNode = (node: { name: string | null }) => node.name === 'Redacted repeater';
return (
@@ -701,7 +797,7 @@ export const StatsPage: React.FC = () => {
</>
)}
{selectedDecodedPath && selectedDecodedPathPoints.length > 1 && (
{selectedDecodedPath && selectedDecodedPathNodes.length > 1 && (
<div
className="disclaimer-overlay"
role="dialog"
@@ -726,31 +822,7 @@ export const StatsPage: React.FC = () => {
</button>
</div>
<div className="stats-page__path-modal-map">
<MapContainer
center={selectedDecodedPathPoints[0]}
zoom={8}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution="&copy; OpenStreetMap contributors &copy; CARTO"
/>
<FitDecodedPath points={selectedDecodedPathPoints} />
<Polyline positions={selectedDecodedPathPoints} pathOptions={{ color: C_PURPLE, weight: 4, opacity: 0.9 }} />
{selectedDecodedPathNodes.map((node) => (
<CircleMarker
key={`${node.node_id}-${node.ord}`}
center={[Number(node.lat), Number(node.lon)]}
radius={10}
pathOptions={{ color: C_CYAN, fillColor: '#0b1725', fillOpacity: 0.95, weight: 2 }}
>
<LeafletTooltip permanent direction="center" offset={[0, 0]} className="stats-page__path-node-label">
{node.ord}
</LeafletTooltip>
</CircleMarker>
))}
</MapContainer>
<DecodedPathMapView nodes={selectedDecodedPathNodes} />
</div>
<div className="stats-page__path-modal-list">
{selectedDecodedPathNodes.map((node) => (
+45
View File
@@ -0,0 +1,45 @@
import { create } from 'zustand';
import type { AggregatedPacket } from '../hooks/useNodes.js';
type OverlayStoreState = {
pinnedPacketId: string | null;
pinnedPacketSnapshot: AggregatedPacket | null;
pathNodeIds: Set<string> | null;
betaPathConfidence: number | null;
betaPermutationCount: number | null;
betaRemainingHops: number | null;
togglePinnedPacket: (packet: AggregatedPacket) => void;
clearPinnedPacket: () => void;
setPathNodeIds: (nodeIds: Set<string> | null) => void;
setBetaMetrics: (metrics: {
betaPathConfidence: number | null;
betaPermutationCount: number | null;
betaRemainingHops: number | null;
}) => void;
};
export const useOverlayStore = create<OverlayStoreState>((set) => ({
pinnedPacketId: null,
pinnedPacketSnapshot: null,
pathNodeIds: null,
betaPathConfidence: null,
betaPermutationCount: null,
betaRemainingHops: null,
togglePinnedPacket: (packet) => set((state) => (
state.pinnedPacketId === packet.id
? {
pinnedPacketId: null,
pinnedPacketSnapshot: null,
}
: {
pinnedPacketId: packet.id,
pinnedPacketSnapshot: packet,
}
)),
clearPinnedPacket: () => set({
pinnedPacketId: null,
pinnedPacketSnapshot: null,
}),
setPathNodeIds: (pathNodeIds) => set({ pathNodeIds }),
setBetaMetrics: (metrics) => set(metrics),
}));
+42 -3
View File
@@ -488,7 +488,8 @@ html, body, #root {
}
/* ─── Node Popup ─────────────────────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
.leaflet-popup-content-wrapper,
.maplibregl-popup-content {
background: var(--bg-panel) !important;
border: 1px solid var(--border-bright) !important;
border-radius: var(--radius-lg) !important;
@@ -497,14 +498,32 @@ html, body, #root {
padding: 0 !important;
}
.leaflet-popup-content {
.leaflet-popup-content,
.maplibregl-popup-content {
margin: 0 !important;
}
.leaflet-popup-tip {
.leaflet-popup-tip,
.maplibregl-popup-tip {
background: var(--bg-panel) !important;
}
.maplibregl-popup-content {
min-width: 200px;
}
.maplibregl-popup-close-button {
color: var(--text-muted);
font-size: 16px;
line-height: 1;
padding: 6px 8px;
}
.maplibregl-popup-close-button:hover {
color: var(--text-primary);
background: transparent;
}
.node-popup {
padding: 12px 14px;
min-width: 200px;
@@ -571,6 +590,26 @@ html, body, #root {
background: rgba(0, 196, 255, 0.16);
}
.node-popup__action-btn {
display: block;
width: 100%;
padding: 6px 8px;
background: transparent;
border: 1px solid rgba(0, 196, 255, 0.45);
border-radius: 4px;
color: var(--accent);
font-size: 11px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.node-popup__action-btn:hover {
background: rgba(0, 196, 255, 0.14);
border-color: rgba(0, 196, 255, 0.85);
}
.node-popup__coverage-btn {
display: block;
width: 100%;
+2 -2
View File
@@ -1,4 +1,4 @@
import { MIN_LINK_OBSERVATIONS, type LinkMetrics } from './pathing.js';
import type { LinkMetrics } from './pathing.js';
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
@@ -30,7 +30,7 @@ export function confirmedLinkConfidence(
ambiguity: number;
},
): number {
const observed = meta?.observed_count ?? MIN_LINK_OBSERVATIONS;
const observed = meta?.multibyte_observed_count ?? meta?.observed_count ?? 1;
const obsBoost = Math.min(0.18, Math.log10(1 + observed) * 0.12);
const pathLoss = meta?.itm_path_loss_db;
const plPenalty = pathLoss == null ? 0 : Math.min(0.12, Math.max(0, (pathLoss - 130) / 120));
+1 -1
View File
@@ -8,7 +8,7 @@ const MAX_BETA_HOPS = 25;
const R_EFF_M = 6_371_000 / (1 - 0.25);
const PREFIX_AMBIGUITY_FLOOR_KM = 45;
// ML-optimised parameters (gen 4 / v01, fitness 0.93462)
const WEAK_LINK_PATHLOSS_MAX_DB = 137.88;
const WEAK_LINK_PATHLOSS_MAX_DB = 145.0;
const LOOSE_LINK_PATHLOSS_MAX_DB = 146.0;
const MAX_HOP_KM = 127.19 * 1.609344; // 127.19 miles ≈ 204.7 km
const MAX_PERMUTATION_HOP_KM = MAX_HOP_KM;
+1
View File
@@ -11,6 +11,7 @@ export type HiddenMaskGeometry = {
export type LinkMetrics = {
observed_count: number;
multibyte_observed_count?: number;
itm_viable?: boolean | null;
itm_path_loss_db?: number | null;
count_a_to_b?: number;
+2 -2
View File
@@ -13,8 +13,8 @@ export default defineConfig({
rollupOptions: {
output: {
manualChunks: {
'deck': ['@deck.gl/core', '@deck.gl/layers', '@deck.gl/geo-layers', '@deck.gl/react'],
'leaflet': ['leaflet', 'react-leaflet'],
'deck': ['@deck.gl/core', '@deck.gl/layers', '@deck.gl/geo-layers', '@deck.gl/mapbox'],
'maplibre': ['maplibre-gl'],
'react': ['react', 'react-dom'],
},
},
+276 -167
View File
@@ -70,21 +70,23 @@ RF_N_RAYS = 360 # 1-degree azimuth resolution keeps RF mode tractabl
RF_RADIUS_MULTIPLIER = 1.35 # search beyond geometric horizon to allow limited diffraction gain
RF_MIN_RADIUS_M = 20_000 # avoid under-searching low-elevation repeaters
RF_SOURCE_LINK_RADIUS_MULTIPLIER = float(os.environ.get('RF_SOURCE_LINK_RADIUS_MULTIPLIER', '1.25'))
DEFAULT_PHYSICAL_LINK_RADIUS_KM = float(os.environ.get('DEFAULT_PHYSICAL_LINK_RADIUS_KM', '60'))
MIN_PHYSICAL_LINK_RADIUS_KM = float(os.environ.get('MIN_PHYSICAL_LINK_RADIUS_KM', '20'))
MAX_PHYSICAL_LINK_RADIUS_KM = float(os.environ.get('MAX_PHYSICAL_LINK_RADIUS_KM', '100'))
SUPPORT_REFRESH_S = int(os.environ.get('COVERAGE_SUPPORT_REFRESH_S', '900'))
SUPPORT_NEARBY_REPEATER_KM = float(os.environ.get('COVERAGE_SUPPORT_NEARBY_REPEATER_KM', '12'))
SUPPORT_PENALTY_PER_KM_DB = float(os.environ.get('COVERAGE_SUPPORT_PENALTY_PER_KM_DB', '0.6'))
SUPPORT_MAX_PENALTY_DB = float(os.environ.get('COVERAGE_SUPPORT_MAX_PENALTY_DB', '14'))
SUPPORT_PROJECTION_LAT = float(os.environ.get('COVERAGE_SUPPORT_PROJECTION_LAT', '54.0'))
DEFAULT_LINK_BUDGET_DB = 148.0
DEFAULT_FADE_MARGIN_DB = 10.0
DEFAULT_USABLE_PATH_LOSS_DB = DEFAULT_LINK_BUDGET_DB - DEFAULT_FADE_MARGIN_DB
DEFAULT_USABLE_PATH_LOSS_DB = float(os.environ.get('DEFAULT_USABLE_PATH_LOSS_DB', '145'))
CALIBRATION_REFRESH_S = int(os.environ.get('COVERAGE_CALIBRATION_REFRESH_S', '900'))
CALIBRATION_MIN_LINKS = int(os.environ.get('COVERAGE_CALIBRATION_MIN_LINKS', '24'))
CALIBRATION_MIN_OBSERVED_COUNT = int(os.environ.get('COVERAGE_CALIBRATION_MIN_OBSERVED_COUNT', '3'))
CALIBRATION_MAX_THRESHOLD_BOOST_DB = float(os.environ.get('COVERAGE_CALIBRATION_MAX_THRESHOLD_BOOST_DB', '8'))
CALIBRATION_MAX_THRESHOLD_BOOST_DB = float(os.environ.get('COVERAGE_CALIBRATION_MAX_THRESHOLD_BOOST_DB', '2'))
CALIBRATION_PERCENTILE = float(os.environ.get('COVERAGE_CALIBRATION_PERCENTILE', '0.9'))
CALIBRATION_EXTRA_MARGIN_DB = float(os.environ.get('COVERAGE_CALIBRATION_EXTRA_MARGIN_DB', '1.5'))
CALIBRATION_EXTRA_MARGIN_DB = float(os.environ.get('COVERAGE_CALIBRATION_EXTRA_MARGIN_DB', '0.5'))
LINK_LOS_MAX_V = float(os.environ.get('LINK_LOS_MAX_V', '-0.78'))
# Radio horizon parameters
K_FACTOR = 4 / 3 # effective Earth radius multiplier (standard troposphere)
@@ -157,6 +159,19 @@ def project_xy_km(latitudes, longitudes) -> np.ndarray:
return np.column_stack((lons * 111.32 * cos_ref, lats * 111.32))
def node_dist_km(a: dict, b: dict) -> float:
cos_m = math.cos(math.radians((a['lat'] + b['lat']) / 2))
return math.sqrt(
((a['lat'] - b['lat']) * 111.32) ** 2 +
((a['lon'] - b['lon']) * 111.32 * cos_m) ** 2
)
def physical_candidate_radius_km(radius_m: Optional[float]) -> float:
derived = (radius_m / 1000.0) * RF_SOURCE_LINK_RADIUS_MULTIPLIER if radius_m is not None else DEFAULT_PHYSICAL_LINK_RADIUS_KM
return min(MAX_PHYSICAL_LINK_RADIUS_KM, max(MIN_PHYSICAL_LINK_RADIUS_KM, derived))
def refresh_rf_calibration(db, force: bool = False) -> None:
now = time.time()
if not force and now - float(RF_CALIBRATION['updated_at']) < CALIBRATION_REFRESH_S:
@@ -165,13 +180,12 @@ def refresh_rf_calibration(db, force: bool = False) -> None:
with db.cursor() as cur:
cur.execute(
'''
SELECT itm_path_loss_db, observed_count
SELECT itm_path_loss_db, multibyte_observed_count
FROM node_links
WHERE itm_path_loss_db IS NOT NULL
AND observed_count >= %s
AND multibyte_observed_count > 0
AND force_viable = false
''',
(CALIBRATION_MIN_OBSERVED_COUNT,),
'''
)
rows = cur.fetchall()
@@ -245,10 +259,8 @@ def refresh_support_context(db, force: bool = False) -> None:
AND na.lon IS NOT NULL
AND nb.lat IS NOT NULL
AND nb.lon IS NOT NULL
AND nl.observed_count >= %s
AND (nl.itm_viable = true OR nl.force_viable = true)
''',
(MIN_LINK_OBSERVATIONS,),
'''
)
link_rows = cur.fetchall()
@@ -422,7 +434,8 @@ def compute_path_loss_from_profile(dists: np.ndarray,
))
total_loss = fspl + diff_loss
viable = total_loss < usable_threshold_db
clear_los = max_v <= LINK_LOS_MAX_V
viable = clear_los and total_loss < usable_threshold_db
return total_loss, viable
@@ -859,89 +872,214 @@ def backfill_elevations(db):
log.info(f' {node_id[:12]}…: elevation={elevation_m:.0f} m ASL (from radius {radius_m/1000:.1f} km)')
db.commit()
def process_link_job(db, r_client, job: dict):
"""Resolve relay path prefixes to known nodes (backwards from the receiver),
record observations in node_links, and compute RF path loss for new pairs.
Uses accumulated confirmed-link knowledge (node_links) to prefer known
neighbours over purely geographic proximity the algorithm improves as
more packets are observed.
"""
rx_node_id = job.get('rx_node_id')
src_node_id = job.get('src_node_id')
path_hashes = job.get('path_hashes', [])
if not rx_node_id or not path_hashes:
return
# Load all positioned repeater nodes
def load_positioned_repeaters(db) -> dict[str, dict]:
with db.cursor() as cur:
cur.execute(
'SELECT node_id, lat, lon, elevation_m, name, role FROM nodes '
'WHERE lat IS NOT NULL AND lon IS NOT NULL'
'''
SELECT n.node_id, n.lat, n.lon, n.elevation_m, n.name, n.role, nc.radius_m
FROM nodes n
LEFT JOIN node_coverage nc ON nc.node_id = n.node_id
WHERE n.lat IS NOT NULL
AND n.lon IS NOT NULL
AND n.lat BETWEEN %s AND %s
AND n.lon BETWEEN %s AND %s
AND NOT (ABS(n.lat) < 1e-9 AND ABS(n.lon) < 1e-9)
AND (n.name IS NULL OR n.name NOT LIKE %s)
AND (n.role IS NULL OR n.role = 2)
''',
(UK_LAT_MIN, UK_LAT_MAX, UK_LON_MIN, UK_LON_MAX, '%🚫%'),
)
all_nodes = {
row[0]: {'lat': row[1], 'lon': row[2], 'elevation_m': row[3],
'name': row[4], 'role': row[5]}
return {
row[0]: {
'lat': row[1],
'lon': row[2],
'elevation_m': row[3],
'name': row[4],
'role': row[5],
'radius_m': row[6],
}
for row in cur.fetchall()
}
# Load confirmed link pairs so we can prefer known neighbours when resolving
# relay hashes — forms the self-improving feedback loop.
def publish_link_update(r_client, a_id: str, b_id: str, obs_count: int, path_loss_db: Optional[float],
itm_viable: Optional[bool], count_a_to_b: int, count_b_to_a: int,
multibyte_obs: int) -> None:
r_client.publish(LIVE_CHANNEL, json.dumps({
'type': 'link_update',
'data': {
'node_a_id': a_id,
'node_b_id': b_id,
'observed_count': obs_count,
'itm_path_loss_db': path_loss_db,
'itm_viable': itm_viable,
'count_a_to_b': count_a_to_b,
'count_b_to_a': count_b_to_a,
'multibyte_observed_count': multibyte_obs,
},
'ts': int(time.time() * 1000),
}))
def upsert_link_pair(db, a_id: str, b_id: str, inc_atob: int, inc_btoa: int, inc_multibyte: int):
obs_delta = inc_atob + inc_btoa
with db.cursor() as cur:
cur.execute(
'SELECT node_a_id, node_b_id FROM node_links '
'WHERE itm_viable = true AND observed_count >= %s',
(MIN_LINK_OBSERVATIONS,),
'''INSERT INTO node_links
(node_a_id, node_b_id, observed_count, last_observed,
count_a_to_b, count_b_to_a, multibyte_observed_count)
VALUES (%s, %s, %s, NOW(), %s, %s, %s)
ON CONFLICT (node_a_id, node_b_id) DO UPDATE
SET observed_count = node_links.observed_count + %s,
last_observed = CASE WHEN %s > 0 THEN NOW() ELSE node_links.last_observed END,
count_a_to_b = node_links.count_a_to_b + %s,
count_b_to_a = node_links.count_b_to_a + %s,
multibyte_observed_count = node_links.multibyte_observed_count + %s
RETURNING observed_count, itm_computed_at, itm_path_loss_db, itm_viable,
count_a_to_b, count_b_to_a, multibyte_observed_count''',
(
a_id, b_id, obs_delta, inc_atob, inc_btoa, inc_multibyte,
obs_delta, obs_delta, inc_atob, inc_btoa, inc_multibyte,
),
)
confirmed_pairs: set[tuple[str, str]] = {
return cur.fetchone()
def ensure_physical_link_metrics(db, a_id: str, a: dict, b_id: str, b: dict):
row = upsert_link_pair(db, a_id, b_id, 0, 0, 0)
obs_count = row[0] if row else 0
itm_computed = row[1] if row else None
path_loss_db = row[2] if row else None
itm_viable = row[3] if row else None
count_a_to_b = row[4] if row else 0
count_b_to_a = row[5] if row else 0
multibyte_obs = row[6] if row else 0
missing_endpoint_elev = a.get('elevation_m') is None or b.get('elevation_m') is None
if itm_computed is not None and not missing_endpoint_elev:
return obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs
with tempfile.TemporaryDirectory() as tmp:
vrt = build_link_vrt(a['lat'], a['lon'], b['lat'], b['lon'], tmp)
if not vrt:
return obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs
try:
a_elev = a.get('elevation_m')
b_elev = b.get('elevation_m')
if a_elev is None:
a_elev = sample_elevation(vrt, a['lat'], a['lon'])
a['elevation_m'] = a_elev
with db.cursor() as cur:
cur.execute(
'UPDATE nodes SET elevation_m = %s WHERE node_id = %s AND elevation_m IS NULL',
(round(a_elev, 1), a_id),
)
if b_elev is None:
b_elev = sample_elevation(vrt, b['lat'], b['lon'])
b['elevation_m'] = b_elev
with db.cursor() as cur:
cur.execute(
'UPDATE nodes SET elevation_m = %s WHERE node_id = %s AND elevation_m IS NULL',
(round(b_elev, 1), b_id),
)
path_loss_db, itm_viable = compute_path_loss(
a['lat'], a['lon'], a_elev,
b['lat'], b['lon'], b_elev,
vrt,
)
path_loss_db = round(path_loss_db, 1)
with db.cursor() as cur:
cur.execute(
'''UPDATE node_links
SET itm_path_loss_db = %s,
itm_viable = %s,
itm_computed_at = NOW()
WHERE node_a_id = %s AND node_b_id = %s''',
(path_loss_db, itm_viable, a_id, b_id),
)
log.info(
f'Link {a_id[:8]}…↔{b_id[:8]}…: '
f'{path_loss_db:.1f} dB {"" if itm_viable else ""} '
f'(obs={obs_count})'
)
except Exception as exc:
log.warning(f'Path loss computation failed: {exc}')
return obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs
def process_physical_link_job(db, r_client, job: dict):
node_a_id = job.get('node_a_id')
node_b_id = job.get('node_b_id')
if not node_a_id or not node_b_id or node_a_id == node_b_id:
return
nodes = load_positioned_repeaters(db)
a = nodes.get(node_a_id)
b = nodes.get(node_b_id)
if not a or not b:
return
obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs = ensure_physical_link_metrics(
db, node_a_id, a, node_b_id, b,
)
publish_link_update(r_client, node_a_id, node_b_id, obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs)
def process_observation_link_job(db, r_client, job: dict):
"""Resolve multibyte packet paths and annotate already-physical links."""
rx_node_id = job.get('rx_node_id')
src_node_id = job.get('src_node_id')
path_hashes = job.get('path_hashes', [])
path_hash_size_bytes = int(job.get('path_hash_size_bytes') or 1)
if not rx_node_id or not path_hashes or path_hash_size_bytes <= 1:
return
all_nodes = load_positioned_repeaters(db)
with db.cursor() as cur:
cur.execute(
'SELECT node_a_id, node_b_id FROM node_links WHERE itm_viable = true OR force_viable = true',
)
physical_pairs: set[tuple[str, str]] = {
(min(a, b), max(a, b)) for a, b in cur.fetchall()
}
def confirmed_link(a_id: str, b_id: str) -> bool:
return (min(a_id, b_id), max(a_id, b_id)) in confirmed_pairs
def physical_link(a_id: str, b_id: str) -> bool:
return (min(a_id, b_id), max(a_id, b_id)) in physical_pairs
rx = all_nodes.get(rx_node_id)
if not rx:
return
def node_dist(a: dict, b: dict) -> float:
cos_m = math.cos(math.radians((a['lat'] + b['lat']) / 2))
return math.sqrt(
((a['lat'] - b['lat']) * 111.32) ** 2 +
((a['lon'] - b['lon']) * 111.32 * cos_m) ** 2
)
def normalize_path_hash(value) -> str:
return str(value or '').strip().upper()
def node_matches_path_hash(node_id: str, path_hash: str) -> bool:
return bool(path_hash) and node_id.upper().startswith(path_hash)
def local_prefix_ambiguity_penalty(path_hash: str, target_id: str, target_node: dict, anchor_node: dict, pool: list[tuple[str, dict]]) -> float:
target_dist = node_dist(target_node, anchor_node)
def local_prefix_ambiguity_penalty(path_hash: str, target_id: str, target_node: dict, anchor_node: dict,
pool: list[tuple[str, dict]]) -> float:
target_dist = node_dist_km(target_node, anchor_node)
raw = 0.0
for cand_id, cand_node in pool:
if cand_id == target_id:
continue
if not node_matches_path_hash(cand_id, path_hash):
continue
cand_dist = node_dist(cand_node, anchor_node)
cand_dist = node_dist_km(cand_node, anchor_node)
if cand_dist > PREFIX_AMBIGUITY_RADIUS_KM:
continue
dist_similarity = max(0.0, 1.0 - abs(cand_dist - target_dist) / PREFIX_AMBIGUITY_RADIUS_KM)
proximity = max(0.0, 1.0 - cand_dist / PREFIX_AMBIGUITY_RADIUS_KM)
raw += dist_similarity * proximity
# Bound so this is only a modest confidence deduction in clustered regions.
return min(0.24, raw * 0.12)
# Resolve path working backwards from rx (known position anchor).
# Each node can only appear once — MeshCore nodes never relay the same
# packet twice.
resolved: list[tuple[str, dict]] = []
prev_id = rx_node_id
prev = rx
visited = {rx_node_id}
prev_id = rx_node_id
prev = rx
visited = {rx_node_id}
for raw_hash in reversed(path_hashes):
path_hash = normalize_path_hash(raw_hash)
@@ -951,20 +1089,16 @@ def process_link_job(db, r_client, job: dict):
(nid, nd) for nid, nd in all_nodes.items()
if node_matches_path_hash(nid, path_hash)
and nid not in visited
and (nd['role'] is None or nd['role'] == 2)
and nd['name'] and '🚫' not in nd['name']
]
if not candidates:
continue
# Prefer confirmed neighbours of the previous node, but deduct confidence
# when same-prefix repeaters cluster near the same anchor (possible ambiguity).
best_id = None
best = None
best_score = float('-inf')
for nid, nd in candidates:
confirmed_bonus = 2.5 if confirmed_link(nid, prev_id) else 0.0
distance_score = -node_dist(nd, prev) / 12.0
confirmed_bonus = 2.5 if physical_link(nid, prev_id) else 0.0
distance_score = -node_dist_km(nd, prev) / 12.0
ambiguity_penalty = local_prefix_ambiguity_penalty(path_hash, nid, nd, prev, candidates)
score = confirmed_bonus + distance_score - ambiguity_penalty
if score > best_score:
@@ -977,121 +1111,92 @@ def process_link_job(db, r_client, job: dict):
resolved.insert(0, (best_id, best))
visited.add(best_id)
prev_id = best_id
prev = best
prev = best
# Build adjacency list: src → relays → rx
full: list[tuple[str, dict]] = []
if src_node_id and src_node_id in all_nodes:
full.append((src_node_id, all_nodes[src_node_id]))
full.extend(resolved)
full.append((rx_node_id, rx))
if len(full) < 2:
return
# Upsert observations and compute path loss for each adjacent pair.
# full[i] → full[i+1] means full[i] transmitted, full[i+1] received.
with tempfile.TemporaryDirectory() as tmp:
for i in range(len(full) - 1):
src_id, src = full[i] # transmitted
dst_id, dst = full[i + 1] # received
if src['lat'] is None or src['lon'] is None or dst['lat'] is None or dst['lon'] is None:
continue
for i in range(len(full) - 1):
src_id, src = full[i]
dst_id, dst = full[i + 1]
if src_id == dst_id:
continue
if src['lat'] is None or src['lon'] is None or dst['lat'] is None or dst['lon'] is None:
continue
# Canonical ordering (lower ID first) → unique primary key
if src_id < dst_id:
a_id, a, b_id, b = src_id, src, dst_id, dst
inc_atob, inc_btoa = 1, 0 # src==a transmitted to dst==b
else:
a_id, a, b_id, b = dst_id, dst, src_id, src
inc_atob, inc_btoa = 0, 1 # src==b transmitted to dst==a
if src_id < dst_id:
a_id, a, b_id, b = src_id, src, dst_id, dst
inc_atob, inc_btoa = 1, 0
else:
a_id, a, b_id, b = dst_id, dst, src_id, src
inc_atob, inc_btoa = 0, 1
# Upsert observation with directional counts; check whether ITM already computed
with db.cursor() as cur:
cur.execute(
'''INSERT INTO node_links
(node_a_id, node_b_id, observed_count, last_observed,
count_a_to_b, count_b_to_a)
VALUES (%s, %s, 1, NOW(), %s, %s)
ON CONFLICT (node_a_id, node_b_id) DO UPDATE
SET observed_count = node_links.observed_count + 1,
last_observed = NOW(),
count_a_to_b = node_links.count_a_to_b + %s,
count_b_to_a = node_links.count_b_to_a + %s
RETURNING observed_count, itm_computed_at, itm_path_loss_db, itm_viable, count_a_to_b, count_b_to_a''',
(a_id, b_id, inc_atob, inc_btoa, inc_atob, inc_btoa),
)
row = cur.fetchone()
obs_count = row[0] if row else 1
itm_computed = row[1] if row else None
path_loss_db_db = row[2] if row else None
itm_viable_db = row[3] if row else None
count_a_to_b = row[4] if row else inc_atob
count_b_to_a = row[5] if row else inc_btoa
if not physical_link(a_id, b_id):
continue
# Compute ITM path loss if not yet done and tiles are cached
path_loss_db: Optional[float] = path_loss_db_db
itm_viable: Optional[bool] = itm_viable_db
missing_endpoint_elev = a.get('elevation_m') is None or b.get('elevation_m') is None
if itm_computed is None or missing_endpoint_elev:
vrt = build_link_vrt(a['lat'], a['lon'], b['lat'], b['lon'], tmp)
if vrt:
try:
a_elev = a.get('elevation_m')
b_elev = b.get('elevation_m')
if a_elev is None:
a_elev = sample_elevation(vrt, a['lat'], a['lon'])
a['elevation_m'] = a_elev
with db.cursor() as cur:
cur.execute(
'UPDATE nodes SET elevation_m = %s WHERE node_id = %s AND elevation_m IS NULL',
(round(a_elev, 1), a_id),
)
if b_elev is None:
b_elev = sample_elevation(vrt, b['lat'], b['lon'])
b['elevation_m'] = b_elev
with db.cursor() as cur:
cur.execute(
'UPDATE nodes SET elevation_m = %s WHERE node_id = %s AND elevation_m IS NULL',
(round(b_elev, 1), b_id),
)
path_loss_db, itm_viable = compute_path_loss(
a['lat'], a['lon'], a_elev,
b['lat'], b['lon'], b_elev,
vrt,
)
with db.cursor() as cur:
cur.execute(
'''UPDATE node_links
SET itm_path_loss_db = %s,
itm_viable = %s,
itm_computed_at = NOW()
WHERE node_a_id = %s AND node_b_id = %s''',
(round(path_loss_db, 1), itm_viable, a_id, b_id),
)
path_loss_db = round(path_loss_db, 1)
log.info(
f'Link {a_id[:8]}…↔{b_id[:8]}…: '
f'{path_loss_db:.1f} dB {"" if itm_viable else ""} '
f'(obs={obs_count})'
)
except Exception as exc:
log.warning(f'Path loss computation failed: {exc}')
row = upsert_link_pair(db, a_id, b_id, inc_atob, inc_btoa, 1)
obs_count = row[0] if row else 1
path_loss_db = row[2] if row else None
itm_viable = row[3] if row else None
count_a_to_b = row[4] if row else inc_atob
count_b_to_a = row[5] if row else inc_btoa
multibyte_obs = row[6] if row else 1
# Notify frontend
r_client.publish(LIVE_CHANNEL, json.dumps({
'type': 'link_update',
'data': {
'node_a_id': a_id,
'node_b_id': b_id,
'observed_count': obs_count,
'itm_path_loss_db': path_loss_db,
'itm_viable': itm_viable,
'count_a_to_b': count_a_to_b,
'count_b_to_a': count_b_to_a,
},
'ts': int(time.time() * 1000),
}))
if itm_viable is None:
obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs = ensure_physical_link_metrics(
db, a_id, a, b_id, b,
)
publish_link_update(r_client, a_id, b_id, obs_count, path_loss_db, itm_viable, count_a_to_b, count_b_to_a, multibyte_obs)
def process_link_job(db, r_client, job: dict):
job_type = str(job.get('type') or 'observe').strip().lower()
if job_type == 'physical_pair':
process_physical_link_job(db, r_client, job)
return
process_observation_link_job(db, r_client, job)
def enqueue_physical_link_jobs_for_node(db, r_client, node_id: str, lat: float, lon: float, radius_m: Optional[float]) -> int:
origin = {'lat': lat, 'lon': lon, 'radius_m': radius_m}
origin_radius_km = physical_candidate_radius_km(radius_m)
with db.cursor() as cur:
cur.execute(
'''
SELECT n.node_id, n.lat, n.lon, nc.radius_m
FROM nodes n
LEFT JOIN node_coverage nc ON nc.node_id = n.node_id
WHERE n.node_id <> %s
AND n.lat IS NOT NULL
AND n.lon IS NOT NULL
AND n.lat BETWEEN %s AND %s
AND n.lon BETWEEN %s AND %s
AND NOT (ABS(n.lat) < 1e-9 AND ABS(n.lon) < 1e-9)
AND (n.name IS NULL OR n.name NOT LIKE %s)
AND (n.role IS NULL OR n.role = 2)
''',
(node_id, UK_LAT_MIN, UK_LAT_MAX, UK_LON_MIN, UK_LON_MAX, '%🚫%'),
)
rows = cur.fetchall()
queued = 0
for peer_id, peer_lat, peer_lon, peer_radius_m in rows:
peer = {'lat': peer_lat, 'lon': peer_lon, 'radius_m': peer_radius_m}
if node_dist_km(origin, peer) > max(origin_radius_km, physical_candidate_radius_km(peer_radius_m)):
continue
[a_id, b_id] = sorted((node_id, peer_id))
r_client.lpush(LINK_JOB_QUEUE, json.dumps({
'type': 'physical_pair',
'node_a_id': a_id,
'node_b_id': b_id,
}))
queued += 1
return queued
def enqueue_uncovered(db, r_client):
@@ -1204,6 +1309,10 @@ def process_job(db, r_client, job: dict):
geom, strength_geoms, radius_m, elevation_m = result
store_coverage(db, node_id, geom, strength_geoms, radius_m, elevation_m)
if WORKER_MODE in ('all', 'link'):
queued_links = enqueue_physical_link_jobs_for_node(db, r_client, node_id, lat, lon, radius_m)
if queued_links > 0:
log.info(f'Queued {queued_links} physical link job(s) for {node_id[:12]}')
log.info(f'Done in {time.time() - t0:.1f}s — notifying frontend')
r_client.publish(LIVE_CHANNEL, json.dumps({