diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index 4936db0..d114a59 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -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) => { diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index ba6d7b9..c59fa17 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -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( `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( `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, diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 44a4ec5..6c5b274 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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) โ”€โ”€โ”€ diff --git a/backend/src/health/status.ts b/backend/src/health/status.ts index 972f6d5..1623976 100644 --- a/backend/src/health/status.ts +++ b/backend/src/health/status.ts @@ -111,7 +111,6 @@ async function currentWorkers(precomputedStats?: ReturnType) const [ viewshedDepth, linkDepth, - tileState, viewshedRecent, linkRecent, viewshedLast, @@ -123,7 +122,6 @@ async function currentWorkers(precomputedStats?: ReturnType) ] = 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) 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) 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', diff --git a/backend/src/index.ts b/backend/src/index.ts index 469eab2..df71de3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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; diff --git a/backend/src/mqtt/client.ts b/backend/src/mqtt/client.ts index 39fd0f5..94d8d8d 100644 --- a/backend/src/mqtt/client.ts +++ b/backend/src/mqtt/client.ts @@ -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 { 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 { 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 { 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 { } 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 { 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 { diff --git a/backend/src/path-beta/constants.ts b/backend/src/path-beta/constants.ts index 7159cba..2936b25 100644 --- a/backend/src/path-beta/constants.ts +++ b/backend/src/path-beta/constants.ts @@ -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) diff --git a/backend/src/path-beta/resolver.ts b/backend/src/path-beta/resolver.ts index 8d083b4..83ba17d 100644 --- a/backend/src/path-beta/resolver.ts +++ b/backend/src/path-beta/resolver.ts @@ -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= 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, + coverageByNode: Map, + linkMetrics: Map, +): 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, + coverageByNode: Map, linkMetrics: Map, ): { 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, + coverageByNode: Map, linkMetrics: Map, ): { 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, +): Map { + const anchors = new Map(); + 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(); @@ -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 { 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 { 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(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); diff --git a/backend/src/path-beta/types.ts b/backend/src/path-beta/types.ts index 97de9a5..ce4c37d 100644 --- a/backend/src/path-beta/types.ts +++ b/backend/src/path-beta/types.ts @@ -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; diff --git a/backend/src/path-learning/rebuild.ts b/backend/src/path-learning/rebuild.ts index 0f5875e..7c79276 100644 --- a/backend/src/path-learning/rebuild.ts +++ b/backend/src/path-learning/rebuild.ts @@ -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( `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, ); diff --git a/backend/src/queue/publisher.ts b/backend/src/queue/publisher.ts index b90f5d8..ad87445 100644 --- a/backend/src/queue/publisher.ts +++ b/backend/src/queue/publisher.ts @@ -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, })); } diff --git a/backend/src/tiles/renderer.ts b/backend/src/tiles/renderer.ts deleted file mode 100644 index e4e81a2..0000000 --- a/backend/src/tiles/renderer.ts +++ /dev/null @@ -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 { - // 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 | null = null; -function emptyTile(): Promise { - 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>; - /** 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>(); - 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 { - 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 { - 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); -} diff --git a/backend/src/tiles/snapshot.ts b/backend/src/tiles/snapshot.ts deleted file mode 100644 index 14ab81c..0000000 --- a/backend/src/tiles/snapshot.ts +++ /dev/null @@ -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 & { 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; - return normalizeSnapshotNode(parsed); - } catch { - return null; - } -} - -export async function getTileSnapshotNodes(network?: string): Promise { - 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 { - const res = await query( - `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(); - const seenNetworks = new Set(); - 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): Promise { - const nodeId = toNullableString(node['node_id']); - if (!nodeId) return; - - const r = redis(); - const existing = parseSnapshotNode(await r.get(nodeKey(nodeId))); - const mergedInput: Partial & { 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(); -} diff --git a/backend/src/tiles/worker.ts b/backend/src/tiles/worker.ts deleted file mode 100644 index b87c843..0000000 --- a/backend/src/tiles/worker.ts +++ /dev/null @@ -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): 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 { - 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((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); -} diff --git a/backend/src/workers/health.ts b/backend/src/workers/health.ts index 06e1615..cdd1a08 100644 --- a/backend/src/workers/health.ts +++ b/backend/src/workers/health.ts @@ -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) => { diff --git a/backend/src/workers/link-backfill.ts b/backend/src/workers/link-backfill.ts index c9cd5ab..faa946f 100644 --- a/backend/src/workers/link-backfill.ts +++ b/backend/src/workers/link-backfill.ts @@ -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); }); } diff --git a/backend/src/workers/link-recompute.ts b/backend/src/workers/link-recompute.ts new file mode 100644 index 0000000..90b303c --- /dev/null +++ b/backend/src/workers/link-recompute.ts @@ -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( + `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); + }); diff --git a/backend/src/workers/tile.ts b/backend/src/workers/tile.ts deleted file mode 100644 index 12ad32d..0000000 --- a/backend/src/workers/tile.ts +++ /dev/null @@ -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); -}); diff --git a/docker-compose.yml b/docker-compose.yml index 9c88fb1..cac884a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3cbc611..c87d987 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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 + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index d36ae8d..c388d6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ed9bdf..d639471 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); - const [deckViewState, setDeckViewState] = useState({ 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(null); const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY)); const [inferredNodes, setInferredNodes] = useState([]); const [inferredActiveNodeIds, setInferredActiveNodeIds] = useState>(new Set()); @@ -63,95 +61,14 @@ export const App: React.FC = () => { const clashRestoreRef = useRef<{ coverage: boolean; clientNodes: boolean } | null>(null); const prevHexClashesRef = useRef(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 | null>(null); - const pathNodeIds = useMemo | 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; - 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; 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 = () => { />
- -
- {filters.livePackets && ( - - )} + {filters.livePackets && } {showDisclaimer && } diff --git a/frontend/src/components/FilterPanel/FilterPanel.tsx b/frontend/src/components/FilterPanel/FilterPanel.tsx index 458c367..2afc025 100644 --- a/frontend/src/components/FilterPanel/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel/FilterPanel.tsx @@ -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 }> = ({
Links Legend
-
Good (โ‰ค120 dB)
-
Marginal (121-135 dB)
-
Weak (>135 dB)
+
Good (โ‰ค130 dB)
+
Marginal (131-138 dB)
+
Weak (>138 dB)
Unknown (no dB yet)
@@ -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 = ({ 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 = ({ filters, onChange, bet
Layers
{filters.betaPaths && (
- Beta Confidence: {betaPathConfidence == null ? 'N/A' : `${Math.round(betaPathConfidence * 100)}%`} + Beta Confidence: {resolvedConfidence == null ? 'N/A' : `${Math.round(resolvedConfidence * 100)}%`}
- Permutations: {betaPermutationCount == null ? 'N/A' : betaPermutationCount} + Permutations: {resolvedPermutations == null ? 'N/A' : resolvedPermutations}
- Remaining Hops: {betaRemainingHops == null ? 'N/A' : betaRemainingHops} + Remaining Hops: {resolvedRemainingHops == null ? 'N/A' : resolvedRemainingHops}
)} {FILTER_ROWS.map(({ key, label, color, hollow }) => ( diff --git a/frontend/src/components/Map/DeckGLOverlay.tsx b/frontend/src/components/Map/DeckGLOverlay.tsx index 9bef7d1..c9812a6 100644 --- a/frontend/src/components/Map/DeckGLOverlay.tsx +++ b/frontend/src/components/Map/DeckGLOverlay.tsx @@ -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; } @@ -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, -) { - 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({ - 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({ - 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({ - 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({ + 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({ + 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({ + 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 = React.memo(({ +export const DeckGLOverlay: React.FC = ({ + 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(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 ( - - ); -}); + // 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 }; diff --git a/frontend/src/components/Map/LiveOverlayController.tsx b/frontend/src/components/Map/LiveOverlayController.tsx new file mode 100644 index 0000000..de16bb8 --- /dev/null +++ b/frontend/src/components/Map/LiveOverlayController.tsx @@ -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 = ({ + 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>(); + 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 | null>(null); + const pathNodeIds = useMemo | null>(() => { + if (!showPathOnly) { + if (pathNodeIdsPrevRef.current !== null) pathNodeIdsPrevRef.current = null; + return null; + } + if (!activePacketSnapshot) { + const empty = new Set(); + pathNodeIdsPrevRef.current = empty; + return empty; + } + const pointKey = (lat: number, lon: number) => `${lat.toFixed(5)},${lon.toFixed(5)}`; + const ids = new Set(); + + 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 ( + + ); +}; diff --git a/frontend/src/components/Map/MapLibreMap.tsx b/frontend/src/components/Map/MapLibreMap.tsx new file mode 100644 index 0000000..da1434c --- /dev/null +++ b/frontend/src/components/Map/MapLibreMap.tsx @@ -0,0 +1,1069 @@ +/** + * MapLibreMap โ€” replaces MapView (Leaflet). + * + * Node dots are rendered as a MapLibre GeoJSON circle layer (GPU, no React fibers). + * Pan/zoom is pure GPU โ€” zero JS work on move events. + * Coverage, hex-clash lines, and privacy rings are also GeoJSON layers. + * Click hit-testing uses MapLibre's built-in R-tree spatial index. + * deck.gl overlays are integrated via @deck.gl/mapbox (MapboxOverlay). + */ +import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { MeshNode } from '../../hooks/useNodes.js'; +import { nodeStore } from '../../hooks/useNodes.js'; +import { coverageStore, type NodeCoverage } from '../../hooks/useCoverage.js'; +import { linkStateStore } from '../../hooks/useLinkState.js'; +import type { HiddenMaskGeometry } from '../../utils/pathing.js'; +import { + buildHiddenCoordMask, + hasCoords, + isProhibitedMapNode, + maskNodePoint, + maskCircleCenter, + HIDDEN_NODE_MASK_RADIUS_METERS, + linkKey, +} from '../../utils/pathing.js'; +import { NodeSearch } from './NodeSearch.js'; +import { useOverlayStore } from '../../store/overlayStore.js'; + +// โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const DEFAULT_CENTER: [number, number] = [54.57, -1.23]; // [lat, lon] Teesside +const DEFAULT_ZOOM = 11; +const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000; +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const MAP_REFRESH_INTERVAL_MS = 100; + +const EMPTY_FC: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] }; + +// CartoDB Dark Matter style definition for MapLibre +const MAP_STYLE: maplibregl.StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap © CARTO', + maxzoom: 19, + }, + }, + layers: [ + { id: 'background', type: 'raster', source: 'carto-dark' }, + ], +}; + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +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 PopupState { + nodeId: string; + lngLat: maplibregl.LngLatLike; +} + +// Properties stored in GeoJSON features (must be JSON-serialisable) +interface NodeFeatureProps { + node_id: string; + name: string | null; + role: number; + is_online: boolean; + is_stale: boolean; + is_prohibited: boolean; + is_inferred: boolean; + hex_clash_state: 'offender' | 'relay' | null; + visible: boolean; + last_seen: string; + public_key: string | null; + advert_count: number | null; + elevation_m: number | null; + hardware_model: string | null; +} + +export interface MapLibreMapProps { + inferredNodes: MeshNode[]; + inferredActiveNodeIds: Set; + showLinks: boolean; + showCoverage: boolean; + showClientNodes: boolean; + showHexClashes: boolean; + maxHexClashHops: number; + onMapReady?: (map: maplibregl.Map) => void; +} + +interface ClashComputation { + clashOffenderNodeIds: Set; + clashRelayIds: Set; + clashPathLines: Array<{ key: string; positions: [number, number][] }>; + clashModeActive: boolean; +} + +// โ”€โ”€ GeoJSON builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function circleLineString( + lat: number, + lon: number, + radiusMeters: number, + steps = 48, +): GeoJSON.Feature { + const latRad = lat * (Math.PI / 180); + const coords: [number, number][] = []; + for (let i = 0; i <= steps; i++) { + const angle = (i / steps) * 2 * Math.PI; + const dLat = (radiusMeters / 111320) * Math.cos(angle); + const dLon = (radiusMeters / (111320 * Math.cos(latRad))) * Math.sin(angle); + coords.push([lon + dLon, lat + dLat]); + } + return { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }; +} + +function buildNodeGeoJSON( + nodes: Map, + inferredNodes: MeshNode[], + hiddenCoordMask: Map, + showClientNodes: boolean, + clashOffenderIds: Set, + clashRelayIds: Set, + showHexClashes: boolean, + pathNodeIds: Set | null, +): GeoJSON.FeatureCollection { + const now = Date.now(); + const features: GeoJSON.Feature[] = []; + + const addNode = (node: MeshNode, isInferred: boolean) => { + if (!hasCoords(node)) return; + const ageMs = now - new Date(node.last_seen).getTime(); + if (ageMs > FOURTEEN_DAYS_MS) return; + + const isClientNode = node.role === 1 || node.role === 3; + if (isClientNode && !showClientNodes) return; + + const isStale = ageMs > SEVEN_DAYS_MS; + const isProhibited = isProhibitedMapNode(node); + const masked = maskNodePoint(node as MeshNode & { lat: number; lon: number }, hiddenCoordMask); + + let hexClashState: NodeFeatureProps['hex_clash_state'] = null; + if (showHexClashes) { + hexClashState = clashOffenderIds.has(node.node_id) + ? 'offender' + : clashRelayIds.has(node.node_id) + ? 'relay' + : null; + } + + // Determine visibility (for filter-based show/hide) + let visible = true; + if (showHexClashes && (clashOffenderIds.size > 0 || clashRelayIds.size > 0)) { + visible = clashOffenderIds.has(node.node_id) || clashRelayIds.has(node.node_id); + } else if (pathNodeIds !== null) { + visible = pathNodeIds.has(node.node_id.toLowerCase()); + } + + const props: NodeFeatureProps = { + node_id: node.node_id, + name: node.name ?? null, + role: node.role ?? 2, + is_online: node.is_online, + is_stale: isStale, + is_prohibited: isProhibited, + is_inferred: isInferred, + hex_clash_state: hexClashState, + visible, + last_seen: node.last_seen, + public_key: node.public_key ?? null, + advert_count: node.advert_count ?? null, + elevation_m: node.elevation_m ?? null, + hardware_model: node.hardware_model ?? null, + }; + + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [masked[1], masked[0]] }, // [lon, lat] + properties: props, + }); + }; + + for (const node of nodes.values()) addNode(node, false); + for (const node of inferredNodes) addNode(node, true); + + return { type: 'FeatureCollection', features }; +} + +function buildPrivacyRingsGeoJSON( + nodes: Map, + hiddenCoordMask: Map, +): GeoJSON.FeatureCollection { + const features: GeoJSON.Feature[] = []; + for (const node of nodes.values()) { + if (!hasCoords(node) || !isProhibitedMapNode(node)) continue; + const center = maskCircleCenter([node.lat!, node.lon!], hiddenCoordMask); + features.push(circleLineString(center[0], center[1], HIDDEN_NODE_MASK_RADIUS_METERS)); + } + return { type: 'FeatureCollection', features }; +} + +function buildCoverageGeoJSON(coverage: NodeCoverage[]): GeoJSON.FeatureCollection { + if (coverage.length === 0) return EMPTY_FC; + const features: GeoJSON.Feature[] = []; + for (const c of coverage) { + if (c.geom.type === 'Polygon' || c.geom.type === 'MultiPolygon') { + features.push({ + type: 'Feature', + geometry: c.geom as GeoJSON.Geometry, + properties: { node_id: c.node_id }, + }); + } + } + return { type: 'FeatureCollection', features }; +} + +function buildClashLinesGeoJSON( + lines: { key: string; positions: [number, number][] }[], +): GeoJSON.FeatureCollection { + return { + type: 'FeatureCollection', + features: lines.map((line) => ({ + type: 'Feature', + geometry: { + type: 'LineString', + // positions are [lat, lon] โ€” convert to [lon, lat] for GeoJSON + coordinates: line.positions.map(([lat, lon]) => [lon, lat]), + }, + properties: { key: line.key }, + })), + }; +} + +function buildLinksGeoJSON( + nodes: Map, + viablePairsArr: [string, string][], + linkMetrics: Map, + hiddenCoordMask: Map, +): GeoJSON.FeatureCollection { + const features: GeoJSON.Feature[] = []; + const seen = new Set(); + + for (const [aId, bId] of viablePairsArr) { + const edgeId = linkKey(aId, bId); + if (seen.has(edgeId)) continue; + seen.add(edgeId); + + const a = nodes.get(aId); + const b = nodes.get(bId); + if (!hasCoords(a) || !hasCoords(b)) continue; + + const aMasked = maskNodePoint(a, hiddenCoordMask); + const bMasked = maskNodePoint(b, hiddenCoordMask); + const pathLoss = linkMetrics.get(edgeId)?.itm_path_loss_db ?? null; + const distance = distKm(a, b); + const color = pathLoss == null + ? '#d1d5db' + : pathLoss <= 130 + ? '#22c55e' + : pathLoss <= 138 + ? '#fbbf24' + : '#ef4444'; + + const coordinates = distance > 0.02 + ? [[aMasked[1], aMasked[0]], [bMasked[1], bMasked[0]]] + : [[aMasked[1], aMasked[0]], [bMasked[1] + 0.0018, bMasked[0] + 0.0018]]; + + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: { + key: edgeId, + color, + width: pathLoss == null ? 1.2 : pathLoss <= 130 ? 2.2 : pathLoss <= 138 ? 1.8 : 1.4, + opacity: pathLoss == null ? 0.38 : pathLoss <= 130 ? 0.72 : pathLoss <= 138 ? 0.62 : 0.5, + }, + }); + } + + return { type: 'FeatureCollection', features }; +} + +function distKm(a: MeshNode, b: MeshNode): number { + 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); +} + +function buildCoverageByNodeId(coverage: NodeCoverage[]): Map { + const coverageByNodeId = new Map(); + for (const item of coverage) coverageByNodeId.set(item.node_id, item); + return coverageByNodeId; +} + +function nodeRangeKm(nodeId: string, coverageByNodeId: Map): number { + const coverage = coverageByNodeId.get(nodeId); + if (!coverage?.radius_m) return 50; + return Math.min(80, Math.max(50, coverage.radius_m / 1000)); +} + +function pairInReceiveRange(a: MeshNode, b: MeshNode, coverageByNodeId: Map): boolean { + const distance = distKm(a, b); + const range = Math.max(nodeRangeKm(a.node_id, coverageByNodeId), nodeRangeKm(b.node_id, coverageByNodeId)); + return distance <= range; +} + +function computeClashData( + nodes: Map, + coverage: NodeCoverage[], + viablePairsArr: [string, string][], + linkMetrics: Map, + showHexClashes: boolean, + maxHexClashHops: number, + focusedNodeId: string | null, + focusedPrefixNodeIds: Set | null, +): ClashComputation { + const coverageByNodeId = buildCoverageByNodeId(coverage); + const nodesWithPos = Array.from(nodes.values()).filter( + (node) => hasCoords(node) && (node.role === undefined || node.role === 2) + && (Date.now() - new Date(node.last_seen).getTime()) < FOURTEEN_DAYS_MS, + ); + + const repeaterPrefixIds = new Map(); + for (const node of nodesWithPos) { + const prefix = node.node_id.slice(0, 2).toUpperCase(); + const existing = repeaterPrefixIds.get(prefix); + if (existing) existing.push(node.node_id); + else repeaterPrefixIds.set(prefix, [node.node_id]); + } + + const clashAdjacency = new Map>(); + for (const [aId, bId] of viablePairsArr) { + const a = nodes.get(aId); + const b = nodes.get(bId); + if (!hasCoords(a) || !hasCoords(b)) continue; + const edgeKey = linkKey(aId, bId); + const pathLoss = linkMetrics.get(edgeKey)?.itm_path_loss_db; + if (pathLoss == null) continue; + if (!pairInReceiveRange(a, b, coverageByNodeId)) continue; + if (!clashAdjacency.has(aId)) clashAdjacency.set(aId, new Set()); + if (!clashAdjacency.has(bId)) clashAdjacency.set(bId, new Set()); + clashAdjacency.get(aId)?.add(bId); + clashAdjacency.get(bId)?.add(aId); + } + + const shortestPathWithinRelayHops = (fromId: string, toId: string): string[] | null => { + if (fromId === toId) return [fromId]; + const maxEdges = Math.max(1, Math.floor(maxHexClashHops) + 1); + const visited = new Set([fromId]); + const previous = new Map(); + const queue: Array<{ id: string; edges: number }> = [{ id: fromId, edges: 0 }]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) break; + if (current.edges >= maxEdges) continue; + for (const next of clashAdjacency.get(current.id) ?? []) { + if (visited.has(next)) continue; + visited.add(next); + previous.set(next, current.id); + if (next === toId) { + const path = [toId]; + let cursor = toId; + while (previous.has(cursor)) { + cursor = previous.get(cursor)!; + path.unshift(cursor); + } + return path; + } + queue.push({ id: next, edges: current.edges + 1 }); + } + } + + return null; + }; + + const activePaths: Array<{ key: string; nodeIds: string[]; offenderA: string; offenderB: string }> = []; + if (showHexClashes) { + for (const ids of repeaterPrefixIds.values()) { + if (ids.length < 2) continue; + for (let i = 0; i < ids.length - 1; i += 1) { + for (let j = i + 1; j < ids.length; j += 1) { + const fromId = ids[i]!; + const toId = ids[j]!; + const path = shortestPathWithinRelayHops(fromId, toId); + if (!path || path.length < 2) continue; + activePaths.push({ + key: `clash-${fromId.slice(0, 8)}-${toId.slice(0, 8)}-${path.length}`, + nodeIds: path, + offenderA: fromId, + offenderB: toId, + }); + } + } + } + } else if (focusedNodeId && focusedPrefixNodeIds && focusedPrefixNodeIds.size >= 2) { + for (const targetId of focusedPrefixNodeIds) { + if (targetId === focusedNodeId) continue; + const path = shortestPathWithinRelayHops(focusedNodeId, targetId); + if (!path || path.length < 2) continue; + activePaths.push({ + key: `focus-${focusedNodeId.slice(0, 8)}-${targetId.slice(0, 8)}-${path.length}`, + nodeIds: path, + offenderA: focusedNodeId, + offenderB: targetId, + }); + } + } + + const clashOffenderNodeIds = new Set(); + const clashVisibleNodeIds = new Set(); + for (const path of activePaths) { + clashOffenderNodeIds.add(path.offenderA); + clashOffenderNodeIds.add(path.offenderB); + for (const nodeId of path.nodeIds) clashVisibleNodeIds.add(nodeId); + } + + const clashRelayIds = new Set(); + for (const nodeId of clashVisibleNodeIds) { + if (!clashOffenderNodeIds.has(nodeId)) clashRelayIds.add(nodeId); + } + + const clashPathLines: Array<{ key: string; positions: [number, number][] }> = []; + const edgeKeys = new Set(); + for (const path of activePaths) { + for (let i = 0; i < path.nodeIds.length - 1; i += 1) { + 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); + const distance = distKm(a, b); + clashPathLines.push({ + key: `${path.key}-${edgeKey}`, + positions: distance > 0.02 + ? [[a.lat!, a.lon!], [b.lat!, b.lon!]] + : [[a.lat!, a.lon!], [b.lat! + 0.0018, b.lon! + 0.0018]], + }); + } + } + + return { + clashOffenderNodeIds, + clashRelayIds, + clashPathLines, + clashModeActive: showHexClashes || Boolean(focusedPrefixNodeIds), + }; +} + +// โ”€โ”€ Popup helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const GPU_ROLE_LABELS: Record = { + 1: 'Companion Radio', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor', +}; + +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`; +} + +// โ”€โ”€ NodePopupContent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const NodePopupContent: React.FC<{ + props: NodeFeatureProps; + lat: number; + lon: number; + links: NodeLink[] | null; + onFocusSamePrefix: (nodeId: string) => void; + samePrefixCount: number; +}> = ({ props, lat, lon, links, onFocusSamePrefix, samePrefixCount }) => { + const isRepeater = props.role === undefined || props.role === 2; + const ageMs = Date.now() - new Date(props.last_seen).getTime(); + const isStale = ageMs > SEVEN_DAYS_MS; + const statusLabel = isStale ? 'STALE' : props.is_online ? 'ONLINE' : 'OFFLINE'; + const statusColor = isStale ? 'var(--danger)' : props.is_online ? 'var(--online)' : 'var(--offline)'; + const fallbackName = GPU_ROLE_LABELS[props.role ?? 2] ?? 'Unknown Device'; + const displayName = props.is_prohibited + ? `Redacted ${fallbackName}` + : (props.name ?? `Unknown ${fallbackName}`); + + return ( +
+
{displayName}
+ {props.public_key && ( +
+ Public key + {props.public_key} +
+ )} + {!isRepeater && props.role !== undefined && ( +
+ Type + {GPU_ROLE_LABELS[props.role] ?? 'Unknown'} +
+ )} +
+ Status + {statusLabel} +
+ {props.hardware_model && ( +
+ Hardware + {props.hardware_model} +
+ )} +
+ Last seen + {timeAgo(props.last_seen)} +
+ {props.advert_count !== null && props.advert_count !== undefined && ( +
+ Times seen + {props.advert_count} +
+ )} +
+ Position + {props.is_prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`} +
+ {props.is_prohibited && ( +
+ Location + Redacted within 1 mile radius +
+ )} + {props.elevation_m !== null && props.elevation_m !== undefined && ( +
+ Elevation + {Math.round(props.elevation_m)} m ASL +
+ )} + {isRepeater && samePrefixCount > 1 && ( +
+ +
+ )} + {!isRepeater && links === null && ( +
Loading neighboursโ€ฆ
+ )} + {!isRepeater && links !== null && links.length > 0 && ( +
+
Confirmed neighbours
+ {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 ( +
+ + {arrow} {lk.peer_name ?? lk.peer_id.slice(0, 8)} + + + {lk.observed_count}ร— seen + {lk.itm_path_loss_db != null && <> · {Math.round(lk.itm_path_loss_db)} dB} + +
+ ); + })} +
+ )} +
+ ); +}; + +// โ”€โ”€ Main Component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const MapLibreMap: React.FC = ({ + inferredNodes, + inferredActiveNodeIds: _inferredActiveNodeIds, + showLinks, + showCoverage, + showClientNodes, + showHexClashes, + maxHexClashHops, + onMapReady, +}) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const mapLoadedRef = useRef(false); + const mlPopupRef = useRef(null); + const popupContainerRef = useRef(document.createElement('div')); + const nodesRef = useRef(nodeStore.getState().nodes); + const coverageRef = useRef(coverageStore.getState().coverage); + const viablePairsRef = useRef(linkStateStore.getState().viablePairsArr); + const linkMetricsRef = useRef(linkStateStore.getState().linkMetrics); + const inferredNodesRef = useRef(inferredNodes); + const showLinksRef = useRef(showLinks); + const showCoverageRef = useRef(showCoverage); + const showClientNodesRef = useRef(showClientNodes); + const showHexClashesRef = useRef(showHexClashes); + const maxHexClashHopsRef = useRef(maxHexClashHops); + const pathNodeIdsRef = useRef(useOverlayStore.getState().pathNodeIds); + const hiddenCoordMaskRef = useRef>(new Map()); + const refreshTimerRef = useRef(null); + const popupStateRef = useRef(null); + + const [popupState, setPopupState] = useState(null); + const [popupLinks, setPopupLinks] = useState(null); + const [focusedPrefix, setFocusedPrefix] = useState(null); + const [focusedNodeId, setFocusedNodeId] = useState(null); + const [focusedPrefixNodeIds, setFocusedPrefixNodeIds] = useState | null>(null); + const [popupVersion, setPopupVersion] = useState(0); + const focusTimerRef = useRef(null); + + // -- Focus mode (same-prefix highlight) ------------------------------------ + + const clearFocusTimer = useCallback(() => { + if (focusTimerRef.current !== null) { + window.clearTimeout(focusTimerRef.current); + focusTimerRef.current = null; + } + }, []); + + const refreshMapSources = useCallback(() => { + if (!mapLoadedRef.current || !mapRef.current) return; + + const nodes = nodesRef.current; + const coverage = coverageRef.current; + const viablePairsArr = viablePairsRef.current; + const linkMetrics = linkMetricsRef.current; + const currentPathNodeIds = pathNodeIdsRef.current; + const currentHiddenCoordMask = buildHiddenCoordMask(nodes.values()); + hiddenCoordMaskRef.current = currentHiddenCoordMask; + + const clash = computeClashData( + nodes, + coverage, + viablePairsArr, + linkMetrics, + showHexClashesRef.current, + maxHexClashHopsRef.current, + focusedNodeId, + focusedPrefixNodeIds, + ); + + const nodeGeoJSON = buildNodeGeoJSON( + nodes, + inferredNodesRef.current, + currentHiddenCoordMask, + showClientNodesRef.current, + clash.clashOffenderNodeIds, + clash.clashRelayIds, + clash.clashModeActive, + clash.clashModeActive ? null : currentPathNodeIds, + ); + (mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON); + + const privacyGeoJSON = buildPrivacyRingsGeoJSON(nodes, currentHiddenCoordMask); + (mapRef.current.getSource('privacy-rings') as maplibregl.GeoJSONSource | undefined)?.setData(privacyGeoJSON); + + const linksGeoJSON = showLinksRef.current + ? buildLinksGeoJSON(nodes, viablePairsArr, linkMetrics, currentHiddenCoordMask) + : EMPTY_FC; + (mapRef.current.getSource('viable-links') as maplibregl.GeoJSONSource | undefined)?.setData(linksGeoJSON); + mapRef.current.setLayoutProperty('viable-links-layer', 'visibility', showLinksRef.current ? 'visible' : 'none'); + + const coverageGeoJSON = showCoverageRef.current && !clash.clashModeActive + ? buildCoverageGeoJSON(coverage) + : EMPTY_FC; + (mapRef.current.getSource('coverage') as maplibregl.GeoJSONSource | undefined)?.setData(coverageGeoJSON); + mapRef.current.setLayoutProperty('coverage-fill', 'visibility', + showCoverageRef.current && !clash.clashModeActive ? 'visible' : 'none'); + + const clashGeoJSON = clash.clashModeActive && clash.clashPathLines.length > 0 + ? buildClashLinesGeoJSON(clash.clashPathLines) + : EMPTY_FC; + (mapRef.current.getSource('clash-lines') as maplibregl.GeoJSONSource | undefined)?.setData(clashGeoJSON); + mapRef.current.setLayoutProperty('clash-lines-layer', 'visibility', + clash.clashModeActive && clash.clashPathLines.length > 0 ? 'visible' : 'none'); + }, [focusedNodeId, focusedPrefixNodeIds]); + + const scheduleRefresh = useCallback(() => { + if (refreshTimerRef.current !== null) return; + refreshTimerRef.current = window.setTimeout(() => { + refreshTimerRef.current = null; + refreshMapSources(); + }, MAP_REFRESH_INTERVAL_MS); + }, [refreshMapSources]); + + const handleFocusSamePrefix = useCallback((nodeId: string) => { + const prefix = nodeId.slice(0, 2).toUpperCase(); + const ids = Array.from(nodesRef.current.values()) + .filter((node) => hasCoords(node) && (node.role === undefined || node.role === 2)) + .filter((node) => node.node_id.slice(0, 2).toUpperCase() === prefix) + .map((node) => node.node_id); + clearFocusTimer(); + setFocusedPrefix(prefix); + setFocusedNodeId(nodeId); + setFocusedPrefixNodeIds(new Set(ids.length > 0 ? ids : [nodeId])); + // Auto-clear after 10s + focusTimerRef.current = window.setTimeout(() => { + setFocusedPrefix(null); + setFocusedNodeId(null); + setFocusedPrefixNodeIds(null); + focusTimerRef.current = null; + }, 10_000); + }, [clearFocusTimer]); + + useEffect(() => () => { + clearFocusTimer(); + if (refreshTimerRef.current !== null) { + window.clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }, [clearFocusTimer]); + + // -- Map initialisation (runs once on mount) -------------------------------- + + useEffect(() => { + if (!containerRef.current) return; + + const map = new maplibregl.Map({ + container: containerRef.current, + style: MAP_STYLE, + center: [DEFAULT_CENTER[1], DEFAULT_CENTER[0]], // [lon, lat] + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + + map.on('load', () => { + mapLoadedRef.current = true; + + // โ”€โ”€ Node dots source + layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + map.addSource('nodes', { type: 'geojson', data: EMPTY_FC }); + + map.addLayer({ + id: 'node-dots', + type: 'circle', + source: 'nodes', + filter: ['==', ['get', 'visible'], true], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 3, 9, 4, 11, 5, 13, 7, 16, 9, + ], + 'circle-color': [ + 'case', + ['==', ['get', 'hex_clash_state'], 'offender'], '#ef4444', + ['==', ['get', 'hex_clash_state'], 'relay'], '#22c55e', + ['get', 'is_prohibited'], '#f59e0b', + ['get', 'is_inferred'], '#7dd3fc', + ['get', 'is_stale'], '#6b7280', + ['!', ['get', 'is_online']], '#6b7280', + ['==', ['get', 'role'], 1], '#ff9f43', + ['==', ['get', 'role'], 3], '#a78bfa', + ['==', ['get', 'role'], 4], '#34d399', + '#00c4ff', // repeater (role 2 / default) + ], + 'circle-opacity': [ + 'case', + ['get', 'is_stale'], 0.4, + ['!', ['get', 'is_online']], 0.4, + ['get', 'is_inferred'], 0.7, + 1.0, + ], + 'circle-stroke-width': [ + 'case', + ['get', 'is_prohibited'], 1.4, + 0, + ], + 'circle-stroke-color': '#f59e0b', + 'circle-stroke-opacity': 0.7, + }, + }); + + // โ”€โ”€ Privacy rings source + layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + map.addSource('privacy-rings', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'privacy-rings-layer', + type: 'line', + source: 'privacy-rings', + paint: { + 'line-color': '#f59e0b', + 'line-width': 1.4, + 'line-opacity': 0.55, + 'line-dasharray': [4, 6], + }, + }); + + // โ”€โ”€ Viable links source + layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + map.addSource('viable-links', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'viable-links-layer', + type: 'line', + source: 'viable-links', + layout: { + visibility: 'none', + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': ['get', 'color'], + 'line-width': ['get', 'width'], + 'line-opacity': ['get', 'opacity'], + }, + }); + + // โ”€โ”€ Coverage source + layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + map.addSource('coverage', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'coverage-fill', + type: 'fill', + source: 'coverage', + layout: { visibility: 'none' }, + paint: { + 'fill-color': '#22c55e', + 'fill-opacity': 0.18, + }, + }); + + // โ”€โ”€ Clash lines source + layer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + map.addSource('clash-lines', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'clash-lines-layer', + type: 'line', + source: 'clash-lines', + layout: { visibility: 'none' }, + paint: { + 'line-color': '#f97316', + 'line-width': 2.2, + 'line-opacity': 0.9, + }, + }); + + // โ”€โ”€ Click handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + map.on('click', 'node-dots', (e) => { + const feature = e.features?.[0]; + if (!feature) return; + const props = feature.properties as NodeFeatureProps; + // MapLibre serialises properties to JSON strings for non-primitive types, + // but all our props are primitives so this is safe. + const coords = (feature.geometry as GeoJSON.Point).coordinates as [number, number]; + setPopupLinks(null); + setPopupState({ nodeId: props.node_id, lngLat: { lng: coords[0], lat: coords[1] } }); + }); + + // Make cursor a pointer over node dots + map.on('mouseenter', 'node-dots', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', 'node-dots', () => { + map.getCanvas().style.cursor = ''; + }); + + mapRef.current = map; + onMapReady?.(map); + refreshMapSources(); + }); + + return () => { + mapLoadedRef.current = false; + map.remove(); + mapRef.current = null; + }; + }, [onMapReady, refreshMapSources]); + + // -- Imperative source updates --------------------------------------------- + + useEffect(() => { + inferredNodesRef.current = inferredNodes; + scheduleRefresh(); + }, [inferredNodes, scheduleRefresh]); + + useEffect(() => { + showLinksRef.current = showLinks; + scheduleRefresh(); + }, [showLinks, scheduleRefresh]); + + useEffect(() => { + showCoverageRef.current = showCoverage; + scheduleRefresh(); + }, [showCoverage, scheduleRefresh]); + + useEffect(() => { + showClientNodesRef.current = showClientNodes; + scheduleRefresh(); + }, [showClientNodes, scheduleRefresh]); + + useEffect(() => { + showHexClashesRef.current = showHexClashes; + scheduleRefresh(); + }, [showHexClashes, scheduleRefresh]); + + useEffect(() => { + maxHexClashHopsRef.current = maxHexClashHops; + scheduleRefresh(); + }, [maxHexClashHops, scheduleRefresh]); + + useEffect(() => { + popupStateRef.current = popupState; + }, [popupState]); + + useEffect(() => { + const unsubscribeNodes = nodeStore.subscribe(() => { + nodesRef.current = nodeStore.getState().nodes; + scheduleRefresh(); + if (popupStateRef.current) setPopupVersion((value) => value + 1); + }); + const unsubscribeCoverage = coverageStore.subscribe(() => { + coverageRef.current = coverageStore.getState().coverage; + scheduleRefresh(); + }); + const unsubscribeLinks = linkStateStore.subscribe(() => { + const linkState = linkStateStore.getState(); + viablePairsRef.current = linkState.viablePairsArr; + linkMetricsRef.current = linkState.linkMetrics; + scheduleRefresh(); + }); + const unsubscribeOverlay = useOverlayStore.subscribe((overlayState) => { + if (overlayState.pathNodeIds === pathNodeIdsRef.current) return; + pathNodeIdsRef.current = overlayState.pathNodeIds; + scheduleRefresh(); + }); + + return () => { + unsubscribeNodes(); + unsubscribeCoverage(); + unsubscribeLinks(); + unsubscribeOverlay(); + }; + }, [scheduleRefresh]); + + useEffect(() => { + scheduleRefresh(); + }, [focusedNodeId, focusedPrefixNodeIds, scheduleRefresh]); + + // -- Popup management ------------------------------------------------------ + + // Find the full MeshNode from nodeId (checks nodes and inferredNodes) + const getNode = useCallback((nodeId: string): MeshNode | undefined => { + return nodesRef.current.get(nodeId) ?? inferredNodesRef.current.find((node) => node.node_id === nodeId); + }, []); + + // Fetch neighbour links for non-repeater node popups + useEffect(() => { + if (!popupState) return; + const node = getNode(popupState.nodeId); + if (!node || node.role === undefined || node.role === 2) return; + // Non-repeater โ€” fetch neighbours + setPopupLinks(null); + fetch(`/api/nodes/${popupState.nodeId}/links`) + .then((r) => r.json() as Promise) + .then(setPopupLinks) + .catch(() => setPopupLinks([])); + }, [popupState?.nodeId, getNode]); // eslint-disable-line react-hooks/exhaustive-deps + + // Show/update/close the MapLibre popup when popupState changes + useEffect(() => { + const map = mapRef.current; + if (!map || !mapLoadedRef.current) return; + + if (!popupState) { + mlPopupRef.current?.remove(); + return; + } + + if (!mlPopupRef.current) { + mlPopupRef.current = new maplibregl.Popup({ maxWidth: '280px', closeOnClick: false }) + .setDOMContent(popupContainerRef.current) + .on('close', () => setPopupState(null)); + } + + mlPopupRef.current.setLngLat(popupState.lngLat).addTo(map); + }, [popupState]); + + // Resolve popup props from current nodes map + const popupNodeProps = useMemo((): NodeFeatureProps | null => { + if (!popupState) return null; + const node = getNode(popupState.nodeId); + if (!node || !hasCoords(node)) return null; + const now = Date.now(); + const ageMs = now - new Date(node.last_seen).getTime(); + const masked = maskNodePoint(node as MeshNode & { lat: number; lon: number }, hiddenCoordMaskRef.current); + return { + node_id: node.node_id, + name: node.name ?? null, + role: node.role ?? 2, + is_online: node.is_online, + is_stale: ageMs > SEVEN_DAYS_MS, + is_prohibited: isProhibitedMapNode(node), + is_inferred: !!node.is_inferred, + hex_clash_state: null, + visible: true, + last_seen: node.last_seen, + public_key: node.public_key ?? null, + advert_count: node.advert_count ?? null, + elevation_m: node.elevation_m ?? null, + hardware_model: node.hardware_model ?? null, + // Store masked position temporarily in unused fields: + // (we pass lat/lon separately to NodePopupContent) + _maskedLat: masked[0], + _maskedLon: masked[1], + } as NodeFeatureProps & { _maskedLat: number; _maskedLon: number }; + }, [popupState, popupVersion, getNode]); + + const popupSamePrefixCount = useMemo(() => { + if (!popupState) return 1; + const prefix = popupState.nodeId.slice(0, 2).toUpperCase(); + return Array.from(nodesRef.current.values()).filter( + (node) => hasCoords(node) + && (node.role === undefined || node.role === 2) + && node.node_id.slice(0, 2).toUpperCase() === prefix, + ).length || 1; + }, [popupState, popupVersion]); + + // -- Render ---------------------------------------------------------------- + + return ( +
+ +
+ + {/* Popup content rendered into the MapLibre popup's DOM node via portal */} + {popupState && popupNodeProps && createPortal( + , + popupContainerRef.current, + )} + + {/* Focus mode indicator */} + {focusedPrefix && ( +
+ Showing {focusedPrefix}xx prefix nodes +
+ )} +
+ ); +}; diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx deleted file mode 100644 index 6d3f49b..0000000 --- a/frontend/src/components/Map/MapView.tsx +++ /dev/null @@ -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 = ({ 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 = { - 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; - onNodeClick: (node: MeshNode, lat: number, lon: number) => void; -} -const GPUClickHandler: React.FC = ({ 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 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; - inferredNodes: MeshNode[]; - inferredActiveNodeIds: Set; - activeNodes: Set; - coverage: NodeCoverage[]; - onDeckViewStateChange: (vs: DeckViewState) => void; - showCoverage: boolean; - showClientNodes: boolean; - showHexClashes: boolean; - maxHexClashHops: number; - viablePairsArr: [string, string][]; - linkMetrics: Map; - hiddenCoordMask: Map; - pathNodeIds: Set | 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(null); - const [viewBounds, setViewBounds] = useState(null); - const [focusedPrefix, setFocusedPrefix] = useState(null); - const [focusedNodeId, setFocusedNodeId] = useState(null); - const [focusedPrefixNodeIds, setFocusedPrefixNodeIds] = useState | null>(null); - const [focusHidePhase, setFocusHidePhase] = useState<'idle' | 'hide' | 'fade'>('idle'); - const hideTimerRef = useRef(null); - const fadeTimerRef = useRef(null); - - // GPU popup โ€” one popup at a time driven by click handler - const [gpuPopupNode, setGpuPopupNode] = useState(null); - const [gpuPopupLat, setGpuPopupLat] = useState(0); - const [gpuPopupLon, setGpuPopupLon] = useState(0); - const [gpuPopupLinks, setGpuPopupLinks] = useState(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({ - 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(); - 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(); - 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>(); - 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([fromId]); - const prev = new Map(); - 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(); - 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(); - 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(); - 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 ( -
- - - {/* CartoDB Dark Matter tiles */} - - - {/* Server-rendered node dot tiles */} - {gpuRendered && ( - - )} - - {/* Sync Leaflet map position to deck.gl */} - - - {/* 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 && ( - - - - )} - - {/* Confirmed link lines โ€” ITM-viable node pairs */} - {clashModeActive && ( - - {visibleClashPathLines.map((line) => ( - - ))} - - )} - - {/* โ”€โ”€ 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 ( - - ); - })} - - {gpuRendered && ( - - )} - - {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 ( - { setGpuPopupNode(null); setGpuPopupLinks(null); } }} - > -
-
{displayName}
- {node.public_key && ( -
- Public key - {node.public_key} -
- )} - {!isRepeater && node.role !== undefined && ( -
- Type - {GPU_ROLE_LABELS[node.role] ?? 'Unknown'} -
- )} -
- Status - {statusLabel} -
- {node.hardware_model && ( -
- Hardware - {node.hardware_model} -
- )} -
- Last seen - {gpuTimeAgo(node.last_seen)} -
- {node.advert_count !== undefined && ( -
- Times seen - {node.advert_count} -
- )} -
- Position - {prohibited ? 'Redacted' : `${gpuPopupLat.toFixed(5)}, ${gpuPopupLon.toFixed(5)}`} -
- {prohibited && ( -
- Location - Redacted within 1 mile radius -
- )} - {node.elevation_m !== undefined && node.elevation_m !== null && ( -
- Elevation - {Math.round(node.elevation_m)} m ASL -
- )} - {!isRepeater && gpuPopupLinks === null && ( -
Loading neighboursโ€ฆ
- )} - {!isRepeater && gpuPopupLinks !== null && gpuPopupLinks.length > 0 && ( -
-
Confirmed neighbours
- {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 ( -
- {arrow} {lk.peer_name ?? lk.peer_id.slice(0, 8)} - - {lk.observed_count}ร— seen - {lk.itm_path_loss_db != null && <> · {Math.round(lk.itm_path_loss_db)} dB} - -
- ); - })} -
- )} -
-
- ); - })()} - - {/* โ”€โ”€ 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 ( - - ); - })} - - {!gpuRendered && !showHexClashes && visibleInferredNodes.map((node) => ( - - ))} - - {!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 ( - - ); - })} - -
-
- ); -}, propsAreEqual); diff --git a/frontend/src/components/Map/NodeMarker.tsx b/frontend/src/components/Map/NodeMarker.tsx deleted file mode 100644 index 072ead7..0000000 --- a/frontend/src/components/Map/NodeMarker.tsx +++ /dev/null @@ -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 = { - 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 = 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(null); - const timerRef = useRef | 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 = ( -
-
{displayName}
- {node.public_key && ( -
- Public key - {node.public_key} -
- )} -
- Status - {statusLabel} -
-
- Position - {prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`} -
- {prohibited && ( -
- Location - Redacted within 1 mile radius -
- )} -
- ); - - const color = markerColor(variant, node.is_online, isStale, hexClashState); - const radius = isHighlighted ? 5 : 3; - - return ( - <> - - { - 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 : ( -
-
{displayName}
- {node.public_key && ( -
- Public key - {node.public_key} -
- )} - {node.role !== undefined && node.role !== 2 && ( -
- Type - {ROLE_LABELS[node.role] ?? 'Unknown'} -
- )} - {(isInferred || node.is_inferred) && ( - <> -
- Type - {node.is_inferred ? 'Inferred repeater' : 'Inferred active'} -
- {node.inferred_prefix && ( -
- Prefix - {node.inferred_prefix} -
- )} - {(node.inferred_packet_count || node.inferred_observations) && ( -
- Evidence - {node.inferred_packet_count ?? 0} packet(s) / {node.inferred_observations ?? 0} sighting(s) -
- )} - {(node.inferred_prev_name || node.inferred_next_name) && ( -
- Between - {node.inferred_prev_name ?? 'unknown'} ยท {node.inferred_next_name ?? 'unknown'} -
- )} - - )} -
- Status - {statusLabel} -
- {node.hardware_model && ( -
- Hardware - {node.hardware_model} -
- )} -
- Last seen - {timeAgo(node.last_seen)} -
- {node.advert_count !== undefined && ( -
- Times seen - {node.advert_count} -
- )} -
- Position - {prohibited ? 'Redacted' : `${lat.toFixed(5)}, ${lon.toFixed(5)}`} -
- {node.elevation_m !== undefined && node.elevation_m !== null && ( -
- Elevation - {Math.round(node.elevation_m)} m ASL -
- )} - {nodeCoverage && ( - - )} - {links === null &&
Loading neighboursโ€ฆ
} - {links !== null && links.length > 0 && ( -
-
Confirmed neighbours
- {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 ( -
- {arrow} {lk.peer_name ?? lk.peer_id.slice(0, 8)} - - {lk.observed_count}ร— seen - {lk.itm_path_loss_db != null && <> · {Math.round(lk.itm_path_loss_db)} dB} - -
- ); - })} -
- )} -
- )} -
-
- - {prohibited && ( - - )} - - {(previewBands.red.length > 0 || previewBands.amber.length > 0 || previewBands.green.length > 0) && ( - - {previewBands.red.length > 0 && ( - - )} - {previewBands.amber.length > 0 && ( - - )} - {previewBands.green.length > 0 && ( - - )} - - )} - - ); -}); diff --git a/frontend/src/components/Map/NodeSearch.tsx b/frontend/src/components/Map/NodeSearch.tsx index dd8597e..95057ae 100644 --- a/frontend/src/components/Map/NodeSearch.tsx +++ b/frontend/src/components/Map/NodeSearch.tsx @@ -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; - map: LeafletMap | null; + map: maplibregl.Map | null; } -export const NodeSearch: React.FC = ({ nodes, map }) => { +export const NodeSearch: React.FC = ({ map }) => { + const nodes = useNodeMap(); const [query, setQuery] = useState(''); - const [open, setOpen] = useState(false); - const containerRef = useRef(null); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); const results = useMemo(() => { if (!query.trim()) return []; @@ -36,7 +37,8 @@ export const NodeSearch: React.FC = ({ 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); }; diff --git a/frontend/src/components/Map/PacketArcLayer.tsx b/frontend/src/components/Map/PacketArcLayer.tsx deleted file mode 100644 index 4307e93..0000000 --- a/frontend/src/components/Map/PacketArcLayer.tsx +++ /dev/null @@ -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[] { - 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({ - 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({ - 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 = React.memo(({ arcs, showArcs, viewState }) => { - const layers = useArcLayers(arcs, showArcs); - - if (layers.length === 0) return null; - - return ( - - ); -}); diff --git a/frontend/src/components/PacketFeed.tsx b/frontend/src/components/PacketFeed.tsx index e2aceda..1033960 100644 --- a/frontend/src/components/PacketFeed.tsx +++ b/frontend/src/components/PacketFeed.tsx @@ -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 = { 0: 'REQ', @@ -15,17 +16,13 @@ const TYPE_LABELS: Record = { 11: 'CTL', }; -interface Props { - packets: AggregatedPacket[]; - nodes: Map; - mqttObserverCount?: number; - onPacketClick?: (packet: AggregatedPacket) => void; - pinnedPacketId?: string | null; -} - const VISIBLE_ROWS = 8; -export const PacketFeed: React.FC = 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 = React.memo(({ packets, nodes, onPacke
onPacketClick?.(p)} + onClick={() => togglePinnedPacket(p)} role="button" tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && onPacketClick?.(p)} + onKeyDown={(e) => e.key === 'Enter' && togglePinnedPacket(p)} > {observerIata && ( {observerIata} diff --git a/frontend/src/components/app/MobileControls.tsx b/frontend/src/components/app/MobileControls.tsx index a965c1e..70795e2 100644 --- a/frontend/src/components/app/MobileControls.tsx +++ b/frontend/src/components/app/MobileControls.tsx @@ -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; + map: maplibregl.Map | null; filters: Filters; onFiltersChange: (next: Filters) => void; }; export const MobileControls: React.FC = ({ map, - nodes, filters, onFiltersChange, }) => { @@ -73,7 +70,7 @@ export const MobileControls: React.FC = ({ )}
- +
); diff --git a/frontend/src/hooks/useAppMessageHandler.ts b/frontend/src/hooks/useAppMessageHandler.ts index a32b20a..cd99eb0 100644 --- a/frontend/src/hooks/useAppMessageHandler.ts +++ b/frontend/src/hooks/useAppMessageHandler.ts @@ -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; diff --git a/frontend/src/hooks/useCoverage.ts b/frontend/src/hooks/useCoverage.ts index 4d3163a..b99cb07 100644 --- a/frontend/src/hooks/useCoverage.ts +++ b/frontend/src/hooks/useCoverage.ts @@ -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>; + node_id: string; + geom: { type: string; coordinates: unknown }; + strength_geoms?: Partial>; 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([]); +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 }; } diff --git a/frontend/src/hooks/useLinkState.ts b/frontend/src/hooks/useLinkState.ts index c652026..75bf4b6 100644 --- a/frontend/src/hooks/useLinkState.ts +++ b/frontend/src/hooks/useLinkState.ts @@ -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>(new Set()); - const [linkMetrics, setLinkMetrics] = useState>(new Map()); - const [viablePairsArr, setViablePairsArr] = useState<[string, string][]>([]); +type LinkState = { + linkPairs: Set; + linkMetrics: Map; + 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(); - 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(); + 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(); - 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(); + 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 { + return useSyncExternalStore(subscribe, () => state.linkPairs); +} + +export function useLinkMetrics(): Map { + return useSyncExternalStore(subscribe, () => state.linkMetrics); +} + +export function useViablePairsArr(): [string, string][] { + return useSyncExternalStore(subscribe, () => state.viablePairsArr); } diff --git a/frontend/src/hooks/useNodes.ts b/frontend/src/hooks/useNodes.ts index baac5d5..217e9c7 100644 --- a/frontend/src/hooks/useNodes.ts +++ b/frontend/src/hooks/useNodes.ts @@ -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; - 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; + packets: AggregatedPacket[]; + arcs: PacketArc[]; + activeNodes: Set; +}; + +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(); + 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): 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 & { 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 & { node_id: string }; + const next = new Map(state.nodes); + next.set(node.node_id, { ...existing, ...updates }); + setState({ + ...state, + nodes: next, + }); +} + +function handleNodeUpsertBatch(nodes: (Partial & { 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 & { 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 { + 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 { + return useSyncExternalStore(subscribe, () => state.activeNodes); +} + export function useNodes() { - const [nodes, setNodes] = useState>(new Map()); - const [packets, setPackets] = useState([]); - const [arcs] = useState([]); - const [activeNodes] = useState>(new Set()); - - const handleInitialState = useCallback((data: { - nodes: MeshNode[]; - packets: RecentPacketRow[]; - }) => { - const nodeMap = new Map(); - 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 & { 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 & { node_id: string }; - next.set(node.node_id, { ...existing, ...updates }); - return next; - }); - }, []); - - const handleNodeUpsertBatch = useCallback((nodes: (Partial & { 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 & { 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, diff --git a/frontend/src/hooks/usePacketPathOverlay.ts b/frontend/src/hooks/usePacketPathOverlay.ts index a6d0394..d8b1a1d 100644 --- a/frontend/src/hooks/usePacketPathOverlay.ts +++ b/frontend/src/hooks/usePacketPathOverlay.ts @@ -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; 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(null); const [betaPermutationCount, setBetaPermutationCount] = useState(null); const [betaRemainingHops, setBetaRemainingHops] = useState(null); - const [pinnedPacketId, setPinnedPacketId] = useState(null); - const [pinnedPacketSnapshot, setPinnedPacketSnapshot] = useState(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 => { + const resolvePrediction = useCallback((packetHash: string, networkName?: string, observerId?: string, minFreshTs = 0): Promise => { 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>(new Map()); const multiInflightRef = useRef>>(new Map()); - const resolveMultiPrediction = useCallback((packetHash: string, observerIds: string[], networkName?: string): Promise => { + const resolveMultiPrediction = useCallback((packetHash: string, observerIds: string[], networkName?: string, minFreshTs = 0): Promise => { 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(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, }; } diff --git a/frontend/src/pages/OpenSourcePage.tsx b/frontend/src/pages/OpenSourcePage.tsx index b8971ad..8dc7f58 100644 --- a/frontend/src/pages/OpenSourcePage.tsx +++ b/frontend/src/pages/OpenSourcePage.tsx @@ -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' }, diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index e38fdab..eee2492 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -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<{ ); -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(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
; }; export const OwnerPortalPage: React.FC = () => { @@ -637,53 +713,7 @@ export const OwnerPortalPage: React.FC = () => {
- - - - {ownerCoord ? ( - - - {live?.ownerNode.name ?? `Owner ${nodeRoleLabel(live?.ownerNode.role ?? null).toLowerCase()}`}
- {live?.ownerNode.network} ยท {live?.ownerNode.iata ?? '-'} -
-
- ) : null} - {mapPeers.map((peer) => ( - - - {peer.name ?? peer.node_id}
- {peer.network ?? 'Unknown'} ยท {peer.iata ?? '-'}
- Packets 24h: {peer.packets_24h} -
-
- ))} - {ownerCoord - ? mapPeers.map((peer) => ( - - )) - : null} -
+
diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index 02e6be0..4b79fce 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -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 ); -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(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 = { + 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 = { + 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
; }; // โ”€โ”€ 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 && (
{
- - - - - {selectedDecodedPathNodes.map((node) => ( - - - {node.ord} - - - ))} - +
{selectedDecodedPathNodes.map((node) => ( diff --git a/frontend/src/store/overlayStore.ts b/frontend/src/store/overlayStore.ts new file mode 100644 index 0000000..75063c2 --- /dev/null +++ b/frontend/src/store/overlayStore.ts @@ -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 | null; + betaPathConfidence: number | null; + betaPermutationCount: number | null; + betaRemainingHops: number | null; + togglePinnedPacket: (packet: AggregatedPacket) => void; + clearPinnedPacket: () => void; + setPathNodeIds: (nodeIds: Set | null) => void; + setBetaMetrics: (metrics: { + betaPathConfidence: number | null; + betaPermutationCount: number | null; + betaRemainingHops: number | null; + }) => void; +}; + +export const useOverlayStore = create((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), +})); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 845837b..7afd0a2 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -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%; diff --git a/frontend/src/utils/betaLinks.ts b/frontend/src/utils/betaLinks.ts index 861935e..8e4b339 100644 --- a/frontend/src/utils/betaLinks.ts +++ b/frontend/src/utils/betaLinks.ts @@ -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)); diff --git a/frontend/src/utils/betaPathing.ts b/frontend/src/utils/betaPathing.ts index fce0660..043d1d6 100644 --- a/frontend/src/utils/betaPathing.ts +++ b/frontend/src/utils/betaPathing.ts @@ -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; diff --git a/frontend/src/utils/pathing.ts b/frontend/src/utils/pathing.ts index c23ad91..9c29fc9 100644 --- a/frontend/src/utils/pathing.ts +++ b/frontend/src/utils/pathing.ts @@ -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; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e0b63c5..b757bfb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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'], }, }, diff --git a/viewshed-worker/worker.py b/viewshed-worker/worker.py index 0b083c7..cc4755c 100644 --- a/viewshed-worker/worker.py +++ b/viewshed-worker/worker.py @@ -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({