mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-01 17:08:17 +00:00
Rewrite map stack and tighten path topology
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ───
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
/**
|
||||
* Tile pre-rendering worker.
|
||||
*
|
||||
* On startup and every hour, renders all node tiles covering the UK
|
||||
* (z=5–13) for each active network and writes them to Redis with a
|
||||
* 90-minute TTL. Everything outside the UK bbox continues to render
|
||||
* on-demand via the tile API route.
|
||||
*
|
||||
* Yields to the event loop every batch flush so tile compression
|
||||
* does not monopolise the worker event loop.
|
||||
* is never starved.
|
||||
*/
|
||||
import { Redis } from 'ioredis';
|
||||
import { buildTileIndex, renderTileFromIndex } from './renderer.js';
|
||||
import { getTileSnapshotNodes } from './snapshot.js';
|
||||
|
||||
// UK bounding box — same as the viewshed eligibility check in index.ts
|
||||
const UK_LAT_MIN = 49.5;
|
||||
const UK_LAT_MAX = 61.5;
|
||||
const UK_LON_MIN = -8.5;
|
||||
const UK_LON_MAX = 2.5;
|
||||
|
||||
export const UK_ZOOM_MIN = 5;
|
||||
export const UK_ZOOM_MAX = 13;
|
||||
|
||||
// Tile TTL: 90 min. Worker runs every 60 min, so tiles never expire
|
||||
// between passes. On-demand route also uses this TTL for UK tiles.
|
||||
export const UK_TILE_TTL_MS = 90 * 60 * 1000;
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const BATCH_SIZE = 50; // tiles per pipeline flush + event-loop yield
|
||||
const TILE_WORKER_STATE_KEY = 'meshcore:tile_worker:state';
|
||||
const TILE_WORKER_RESUME_MAX_AGE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Networks to pre-render. Each entry is [db_network, cache_key_scope].
|
||||
// 'all' scope uses undefined for getNodes() (returns all non-test nodes).
|
||||
const NETWORKS: Array<{ db: string | undefined; scope: string }> = [
|
||||
{ db: undefined, scope: 'all' },
|
||||
{ db: 'teesside', scope: 'teesside' },
|
||||
];
|
||||
|
||||
type TileCursor = {
|
||||
scopeIndex: number;
|
||||
z: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
function firstCursor(): TileCursor {
|
||||
const firstScopeIndex = 0;
|
||||
const { xMin, yMin } = ukTileRange(UK_ZOOM_MIN);
|
||||
return {
|
||||
scopeIndex: firstScopeIndex,
|
||||
z: UK_ZOOM_MIN,
|
||||
x: xMin,
|
||||
y: yMin,
|
||||
};
|
||||
}
|
||||
|
||||
function parseInteger(value: string | undefined): number | null {
|
||||
if (value == null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isInteger(num) ? num : null;
|
||||
}
|
||||
|
||||
function parseResumeCursor(state: Record<string, string>): TileCursor | null {
|
||||
if (state['status'] !== 'running') return null;
|
||||
const updatedAt = state['updated_at'] ? Date.parse(state['updated_at']) : Number.NaN;
|
||||
if (!Number.isFinite(updatedAt) || Date.now() - updatedAt > TILE_WORKER_RESUME_MAX_AGE_MS) return null;
|
||||
|
||||
const scopeIndex = parseInteger(state['next_scope_index']);
|
||||
const z = parseInteger(state['next_zoom']);
|
||||
const x = parseInteger(state['next_x']);
|
||||
const y = parseInteger(state['next_y']);
|
||||
if (scopeIndex == null || z == null || x == null || y == null) return null;
|
||||
if (scopeIndex < 0 || scopeIndex >= NETWORKS.length) return null;
|
||||
if (z < UK_ZOOM_MIN || z > UK_ZOOM_MAX) return null;
|
||||
|
||||
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
|
||||
if (x < xMin || x > xMax || y < yMin || y > yMax) return null;
|
||||
return { scopeIndex, z, x, y };
|
||||
}
|
||||
|
||||
function nextCursor(cursor: TileCursor): TileCursor | null {
|
||||
const { scopeIndex, z, x, y } = cursor;
|
||||
const range = ukTileRange(z);
|
||||
if (y < range.yMax) {
|
||||
return { scopeIndex, z, x, y: y + 1 };
|
||||
}
|
||||
if (x < range.xMax) {
|
||||
return { scopeIndex, z, x: x + 1, y: range.yMin };
|
||||
}
|
||||
if (z < UK_ZOOM_MAX) {
|
||||
const nextZ = z + 1;
|
||||
const nextRange = ukTileRange(nextZ);
|
||||
return { scopeIndex, z: nextZ, x: nextRange.xMin, y: nextRange.yMin };
|
||||
}
|
||||
if (scopeIndex + 1 < NETWORKS.length) {
|
||||
const nextScopeIndex = scopeIndex + 1;
|
||||
const nextRange = ukTileRange(UK_ZOOM_MIN);
|
||||
return { scopeIndex: nextScopeIndex, z: UK_ZOOM_MIN, x: nextRange.xMin, y: nextRange.yMin };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cursorMatches(scopeIndex: number, z: number, x: number, y: number, cursor: TileCursor): boolean {
|
||||
return cursor.scopeIndex === scopeIndex && cursor.z === z && cursor.x === x && cursor.y === y;
|
||||
}
|
||||
|
||||
export function isUkTile(z: number, x: number, y: number): boolean {
|
||||
const pow2 = Math.pow(2, z);
|
||||
const lonW = (x / pow2) * 360 - 180;
|
||||
const lonE = ((x + 1) / pow2) * 360 - 180;
|
||||
if (lonE < UK_LON_MIN || lonW > UK_LON_MAX) return false;
|
||||
const nN = Math.PI - (2 * Math.PI * y) / pow2;
|
||||
const nS = Math.PI - (2 * Math.PI * (y + 1)) / pow2;
|
||||
const latN = Math.atan(Math.sinh(nN)) * (180 / Math.PI);
|
||||
const latS = Math.atan(Math.sinh(nS)) * (180 / Math.PI);
|
||||
return latN >= UK_LAT_MIN && latS <= UK_LAT_MAX;
|
||||
}
|
||||
|
||||
function ukTileRange(z: number) {
|
||||
const pow2 = Math.pow(2, z);
|
||||
const xMin = Math.floor((UK_LON_MIN + 180) / 360 * pow2);
|
||||
const xMax = Math.floor((UK_LON_MAX + 180) / 360 * pow2);
|
||||
function mercY(lat: number) {
|
||||
const r = lat * Math.PI / 180;
|
||||
return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * pow2);
|
||||
}
|
||||
return { xMin, xMax, yMin: mercY(UK_LAT_MAX), yMax: mercY(UK_LAT_MIN) };
|
||||
}
|
||||
|
||||
function totalUkTiles(): number {
|
||||
let n = 0;
|
||||
for (let z = UK_ZOOM_MIN; z <= UK_ZOOM_MAX; z++) {
|
||||
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
|
||||
n += (xMax - xMin + 1) * (yMax - yMin + 1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
async function renderPass(redis: Redis): Promise<void> {
|
||||
const tileCount = totalUkTiles();
|
||||
const start = Date.now();
|
||||
const totalTilesAllScopes = tileCount * NETWORKS.length;
|
||||
const previousState = await redis.hgetall(TILE_WORKER_STATE_KEY);
|
||||
const resumeCursor = parseResumeCursor(previousState);
|
||||
const resumed = Boolean(resumeCursor);
|
||||
const startCursor = resumeCursor ?? firstCursor();
|
||||
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'running',
|
||||
updated_at: new Date().toISOString(),
|
||||
started_at: resumed ? (previousState['started_at'] || new Date(start).toISOString()) : new Date(start).toISOString(),
|
||||
total_tiles: String(totalTilesAllScopes),
|
||||
done_tiles: resumed ? String(Number(previousState['done_tiles'] ?? 0)) : '0',
|
||||
remaining_tiles: resumed
|
||||
? String(Math.max(0, totalTilesAllScopes - Number(previousState['done_tiles'] ?? 0)))
|
||||
: String(totalTilesAllScopes),
|
||||
scope: resumed ? String(previousState['scope'] ?? '') : '',
|
||||
zoom: resumed ? String(previousState['zoom'] ?? '') : '',
|
||||
next_scope_index: String(startCursor.scopeIndex),
|
||||
next_zoom: String(startCursor.z),
|
||||
next_x: String(startCursor.x),
|
||||
next_y: String(startCursor.y),
|
||||
resumed_from_checkpoint: resumed ? '1' : '0',
|
||||
last_error: '',
|
||||
});
|
||||
|
||||
let globalDone = resumed ? Number(previousState['done_tiles'] ?? 0) : 0;
|
||||
|
||||
if (resumed) {
|
||||
console.log(
|
||||
`[tile-worker] resuming pass from scope=${NETWORKS[startCursor.scopeIndex]?.scope ?? startCursor.scopeIndex} z=${startCursor.z} x=${startCursor.x} y=${startCursor.y} done=${globalDone.toLocaleString()}/${totalTilesAllScopes.toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let scopeIndex = startCursor.scopeIndex; scopeIndex < NETWORKS.length; scopeIndex++) {
|
||||
const { db, scope } = NETWORKS[scopeIndex]!;
|
||||
const nodes = await getTileSnapshotNodes(db);
|
||||
|
||||
// Build spatial index once per network — buckets each node into the
|
||||
// exact tile(s) it falls in at every zoom level. Avoids iterating
|
||||
// all nodes for every tile.
|
||||
const index = buildTileIndex(nodes, UK_ZOOM_MIN, UK_ZOOM_MAX);
|
||||
console.log(`[tile-worker] ${scope} — ${nodes.length} nodes indexed (${index.prohibited.length} prohibited)`);
|
||||
|
||||
let done = 0;
|
||||
const resumingThisScope = startCursor.scopeIndex === scopeIndex;
|
||||
// Pipeline batches writes to Redis — 100 SET commands per round-trip
|
||||
// instead of one per tile. We flush and yield to the event loop every
|
||||
// BATCH_SIZE tiles so HTTP/WS serving is never starved.
|
||||
let pipe = redis.pipeline();
|
||||
let pipeCount = 0;
|
||||
let pendingNextCursor: TileCursor | null = resumingThisScope ? startCursor : null;
|
||||
|
||||
const flush = async () => {
|
||||
if (pipeCount === 0) return;
|
||||
await pipe.exec();
|
||||
pipe = redis.pipeline();
|
||||
pipeCount = 0;
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'running',
|
||||
updated_at: new Date().toISOString(),
|
||||
done_tiles: String(globalDone),
|
||||
remaining_tiles: String(Math.max(0, totalTilesAllScopes - globalDone)),
|
||||
scope,
|
||||
zoom: pendingNextCursor ? String(pendingNextCursor.z) : String(UK_ZOOM_MAX),
|
||||
next_scope_index: pendingNextCursor ? String(pendingNextCursor.scopeIndex) : String(NETWORKS.length),
|
||||
next_zoom: pendingNextCursor ? String(pendingNextCursor.z) : '',
|
||||
next_x: pendingNextCursor ? String(pendingNextCursor.x) : '',
|
||||
next_y: pendingNextCursor ? String(pendingNextCursor.y) : '',
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
const startZoom = resumingThisScope ? startCursor.z : UK_ZOOM_MIN;
|
||||
for (let z = startZoom; z <= UK_ZOOM_MAX; z++) {
|
||||
const { xMin, xMax, yMin, yMax } = ukTileRange(z);
|
||||
const startX = resumingThisScope && z === startCursor.z ? startCursor.x : xMin;
|
||||
for (let x = startX; x <= xMax; x++) {
|
||||
const startY = resumingThisScope && z === startCursor.z && x === startCursor.x ? startCursor.y : yMin;
|
||||
for (let y = startY; y <= yMax; y++) {
|
||||
const key = `tile:nodes:${scope}:${z}:${x}:${y}`;
|
||||
// Empty tiles return the cached 334-byte buffer instantly (no deflate).
|
||||
const png = await renderTileFromIndex(z, x, y, index);
|
||||
pipe.set(key, png, 'PX', UK_TILE_TTL_MS);
|
||||
done++;
|
||||
globalDone++;
|
||||
pipeCount++;
|
||||
pendingNextCursor = nextCursor({ scopeIndex, z, x, y });
|
||||
|
||||
if (pipeCount >= BATCH_SIZE) await flush();
|
||||
}
|
||||
}
|
||||
|
||||
await flush(); // flush remainder at end of each zoom level
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'running',
|
||||
updated_at: new Date().toISOString(),
|
||||
done_tiles: String(globalDone),
|
||||
remaining_tiles: String(Math.max(0, totalTilesAllScopes - globalDone)),
|
||||
scope,
|
||||
zoom: String(z),
|
||||
next_scope_index: pendingNextCursor ? String(pendingNextCursor.scopeIndex) : String(NETWORKS.length),
|
||||
next_zoom: pendingNextCursor ? String(pendingNextCursor.z) : '',
|
||||
next_x: pendingNextCursor ? String(pendingNextCursor.x) : '',
|
||||
next_y: pendingNextCursor ? String(pendingNextCursor.y) : '',
|
||||
});
|
||||
const pct = Math.round(done / tileCount * 100);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`[tile-worker] ${scope} z=${z} — ${done.toLocaleString()}/${tileCount.toLocaleString()} tiles (${pct}%) — ${elapsed}s elapsed`);
|
||||
}
|
||||
await flush();
|
||||
|
||||
const totalElapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(`[tile-worker] ${scope} complete — ${done.toLocaleString()} tiles in ${totalElapsed}s`);
|
||||
}
|
||||
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'idle',
|
||||
updated_at: new Date().toISOString(),
|
||||
done_tiles: String(totalTilesAllScopes),
|
||||
remaining_tiles: '0',
|
||||
last_pass_finished_at: new Date().toISOString(),
|
||||
last_pass_tiles: String(totalTilesAllScopes),
|
||||
next_scope_index: '',
|
||||
next_zoom: '',
|
||||
next_x: '',
|
||||
next_y: '',
|
||||
});
|
||||
}
|
||||
|
||||
export function startTileWorker(): void {
|
||||
const redisUrl = process.env['REDIS_URL'] ?? 'redis://redis:6379';
|
||||
const redis = new Redis(redisUrl);
|
||||
redis.on('error', (e: Error) => console.error('[tile-worker] redis error', e.message));
|
||||
|
||||
let running = false;
|
||||
|
||||
const run = () => {
|
||||
if (running) {
|
||||
console.log('[tile-worker] skipping pass start — previous pass still running');
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
console.log(`[tile-worker] starting pass — ${totalUkTiles()} UK tiles × ${NETWORKS.length} networks`);
|
||||
void renderPass(redis)
|
||||
.catch(async (err: Error) => {
|
||||
await redis.hset(TILE_WORKER_STATE_KEY, {
|
||||
status: 'error',
|
||||
updated_at: new Date().toISOString(),
|
||||
last_error: err.message,
|
||||
}).catch(() => {});
|
||||
console.error('[tile-worker] pass failed:', err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
};
|
||||
|
||||
// First pass after 5 s (let server finish starting up), then every hour.
|
||||
setTimeout(run, 5_000);
|
||||
setInterval(run, REFRESH_INTERVAL_MS);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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}
|
||||
|
||||
Generated
+311
-81
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 (>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 (>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 }) => (
|
||||
|
||||
@@ -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
@@ -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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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 && <> · {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);
|
||||
@@ -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 && <> · {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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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='© OpenStreetMap contributors © 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>
|
||||
|
||||
|
||||
@@ -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="© OpenStreetMap contributors © 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) => (
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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%;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user