mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-09-16 12:02:35 +00:00
Add server-side beta path resolver and mobile layer controls
- move beta path resolution onto the backend with packet-level diagnostics and live API access - add path simulation worker plumbing, health/schema updates, and compose wiring - update app overlays to fetch server-computed beta paths with animated rendering and request caching - refine mobile controls so map layers collapse behind a toggle and compact the links legend
This commit is contained in:
@@ -4,6 +4,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:
|
||||
import mqtt from 'mqtt';
|
||||
import { getNodes, getNodeHistory, getRecentPackets, query, MIN_LINK_OBSERVATIONS } from '../db/index.js';
|
||||
import { getWorkerHealthOverview } from '../health/status.js';
|
||||
import { resolveBetaPathForPacketHash } from '../path-beta/resolver.js';
|
||||
|
||||
const router = Router();
|
||||
const OWNER_COOKIE_NAME = 'meshcore_owner_session';
|
||||
@@ -297,6 +298,28 @@ router.get('/packets/recent', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/path-beta/resolve?hash=<packetHash>&network=teesside|ukmesh|all
|
||||
router.get('/path-beta/resolve', async (req, res) => {
|
||||
try {
|
||||
const packetHash = String(req.query['hash'] ?? '').trim();
|
||||
if (!packetHash) {
|
||||
res.status(400).json({ error: 'Missing hash query parameter' });
|
||||
return;
|
||||
}
|
||||
const networkRaw = String(req.query['network'] ?? 'teesside').trim().toLowerCase();
|
||||
const network = networkRaw === 'ukmesh' || networkRaw === 'all' ? networkRaw : 'teesside';
|
||||
const resolved = await resolveBetaPathForPacketHash(packetHash, network);
|
||||
if (!resolved) {
|
||||
res.status(404).json({ error: 'Packet not found' });
|
||||
return;
|
||||
}
|
||||
res.json(resolved);
|
||||
} catch (err) {
|
||||
console.error('[api] GET /path-beta/resolve', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/stats
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
@@ -496,6 +519,84 @@ router.get('/path-learning', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/path-sim/latest — latest path simulation run summary
|
||||
router.get('/path-sim/latest', async (req, res) => {
|
||||
try {
|
||||
const network = (req.query['network'] as string | undefined)?.trim().toLowerCase();
|
||||
const hasNetwork = Boolean(network && network !== 'all');
|
||||
const result = await query<{
|
||||
id: number;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
network: string;
|
||||
packets_total: number;
|
||||
packets_eligible: number;
|
||||
packets_fully_resolved: number;
|
||||
packets_unresolved: number;
|
||||
truncated_searches: number;
|
||||
permutation_histogram: Record<string, number>;
|
||||
remaining_hops_histogram: Record<string, number>;
|
||||
summary: Record<string, unknown>;
|
||||
}>(
|
||||
`SELECT id, started_at::text, completed_at::text, network,
|
||||
packets_total, packets_eligible, packets_fully_resolved, packets_unresolved, truncated_searches,
|
||||
permutation_histogram, remaining_hops_histogram, summary
|
||||
FROM path_simulation_runs
|
||||
${hasNetwork ? 'WHERE network = $1' : ''}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1`,
|
||||
hasNetwork ? [network] : [],
|
||||
);
|
||||
if (result.rows.length < 1) {
|
||||
res.status(404).json({ error: 'No path simulation runs found yet' });
|
||||
return;
|
||||
}
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
console.error('[api] GET /path-sim/latest', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/path-sim/history?limit=20&network=all
|
||||
router.get('/path-sim/history', async (req, res) => {
|
||||
try {
|
||||
const network = (req.query['network'] as string | undefined)?.trim().toLowerCase();
|
||||
const hasNetwork = Boolean(network && network !== 'all');
|
||||
const limit = Math.min(200, Math.max(1, Number(req.query['limit'] ?? 20)));
|
||||
const result = await query<{
|
||||
id: number;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
network: string;
|
||||
packets_total: number;
|
||||
packets_eligible: number;
|
||||
packets_fully_resolved: number;
|
||||
packets_unresolved: number;
|
||||
truncated_searches: number;
|
||||
permutation_histogram: Record<string, number>;
|
||||
remaining_hops_histogram: Record<string, number>;
|
||||
summary: Record<string, unknown>;
|
||||
}>(
|
||||
`SELECT id, started_at::text, completed_at::text, network,
|
||||
packets_total, packets_eligible, packets_fully_resolved, packets_unresolved, truncated_searches,
|
||||
permutation_histogram, remaining_hops_histogram, summary
|
||||
FROM path_simulation_runs
|
||||
${hasNetwork ? 'WHERE network = $1' : ''}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ${hasNetwork ? '$2' : '$1'}`,
|
||||
hasNetwork ? [network, limit] : [limit],
|
||||
);
|
||||
res.json({
|
||||
count: result.rows.length,
|
||||
runs: result.rows,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[api] GET /path-sim/history', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/health — public health overview with worker status and history
|
||||
router.get('/health', async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -255,3 +255,43 @@ CREATE TABLE IF NOT EXISTS frontend_error_events (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS frontend_error_events_time_idx
|
||||
ON frontend_error_events(time DESC);
|
||||
|
||||
-- ─── Beta/red path simulation run reports ───────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS path_simulation_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
network TEXT NOT NULL DEFAULT 'all',
|
||||
packets_total INTEGER NOT NULL DEFAULT 0,
|
||||
packets_eligible INTEGER NOT NULL DEFAULT 0,
|
||||
packets_fully_resolved INTEGER NOT NULL DEFAULT 0,
|
||||
packets_unresolved INTEGER NOT NULL DEFAULT 0,
|
||||
truncated_searches INTEGER NOT NULL DEFAULT 0,
|
||||
permutation_histogram JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
remaining_hops_histogram JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
summary JSONB NOT NULL DEFAULT '{}'::jsonb
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS path_simulation_runs_started_idx
|
||||
ON path_simulation_runs(started_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS path_sim_population (
|
||||
generation INTEGER NOT NULL,
|
||||
variant_id TEXT NOT NULL,
|
||||
params JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
fitness DOUBLE PRECISION,
|
||||
run_id BIGINT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (generation, variant_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS path_sim_population_generation_idx
|
||||
ON path_sim_population(generation, fitness DESC NULLS LAST);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS path_sim_evolution_state (
|
||||
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
current_generation INTEGER NOT NULL DEFAULT 1,
|
||||
evolved_generation INTEGER NOT NULL DEFAULT 0,
|
||||
best_variant_id TEXT,
|
||||
best_fitness DOUBLE PRECISION,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -86,6 +86,7 @@ async function currentWorkers(): Promise<WorkerSnapshot[]> {
|
||||
healthRecent,
|
||||
healthLast,
|
||||
backfillState,
|
||||
pathSimLatest,
|
||||
] = await Promise.all([
|
||||
r.llen('meshcore:viewshed_jobs'),
|
||||
r.llen('meshcore:link_jobs'),
|
||||
@@ -109,6 +110,10 @@ async function currentWorkers(): Promise<WorkerSnapshot[]> {
|
||||
`SELECT COUNT(*)::text AS links, MAX(last_observed)::text AS last_observed
|
||||
FROM node_links`,
|
||||
),
|
||||
query<{ completed_at: string | null }>(
|
||||
`SELECT MAX(completed_at)::text AS completed_at
|
||||
FROM path_simulation_runs`,
|
||||
),
|
||||
]);
|
||||
|
||||
const stats = systemStats();
|
||||
@@ -124,6 +129,8 @@ async function currentWorkers(): Promise<WorkerSnapshot[]> {
|
||||
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 pathSimLast = pathSimLatest.rows[0]?.completed_at ?? null;
|
||||
const pathSimRecent = pathSimLast ? (Date.now() - Date.parse(pathSimLast)) <= 6 * 60 * 60_000 : false;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -176,6 +183,16 @@ async function currentWorkers(): Promise<WorkerSnapshot[]> {
|
||||
mem_used_pct: memPct,
|
||||
disk_used_pct: diskPct,
|
||||
},
|
||||
{
|
||||
worker_name: 'path-sim-worker',
|
||||
status: pathSimRecent ? 'running' : 'idle',
|
||||
queue_depth: 0,
|
||||
processed_1h: pathSimLast && (Date.now() - Date.parse(pathSimLast)) <= 60 * 60_000 ? 1 : 0,
|
||||
last_activity_at: pathSimLast,
|
||||
cpu_load_1m: load,
|
||||
mem_used_pct: memPct,
|
||||
disk_used_pct: diskPct,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,316 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-01:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v01"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "200000"
|
||||
PATH_SIM_MAX_CANDIDATES: "24"
|
||||
PATH_SIM_HOP_MILES: "75"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "135"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "6"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-02:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v02"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "180000"
|
||||
PATH_SIM_MAX_CANDIDATES: "20"
|
||||
PATH_SIM_HOP_MILES: "70"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "134"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "6"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-03:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v03"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "220000"
|
||||
PATH_SIM_MAX_CANDIDATES: "28"
|
||||
PATH_SIM_HOP_MILES: "80"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "136"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "6"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-04:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v04"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "240000"
|
||||
PATH_SIM_MAX_CANDIDATES: "24"
|
||||
PATH_SIM_HOP_MILES: "85"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "135"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "4"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-05:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v05"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "260000"
|
||||
PATH_SIM_MAX_CANDIDATES: "30"
|
||||
PATH_SIM_HOP_MILES: "90"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "136"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "8"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-06:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v06"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "160000"
|
||||
PATH_SIM_MAX_CANDIDATES: "18"
|
||||
PATH_SIM_HOP_MILES: "65"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "133"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "6"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-07:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v07"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "280000"
|
||||
PATH_SIM_MAX_CANDIDATES: "32"
|
||||
PATH_SIM_HOP_MILES: "95"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "137"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "4"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-08:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v08"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "300000"
|
||||
PATH_SIM_MAX_CANDIDATES: "36"
|
||||
PATH_SIM_HOP_MILES: "100"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "138"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "8"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-09:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v09"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "210000"
|
||||
PATH_SIM_MAX_CANDIDATES: "26"
|
||||
PATH_SIM_HOP_MILES: "75"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "134"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "3"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
path-sim-worker-10:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
command: ["node", "dist/workers/path-sim.js"]
|
||||
cpus: "0.5"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required}
|
||||
NODE_ENV: production
|
||||
UV_THREADPOOL_SIZE: "1"
|
||||
PATH_SIM_VARIANT_ID: "v10"
|
||||
PATH_SIM_INTERVAL_MS: ${PATH_SIM_INTERVAL_MS:-21600000}
|
||||
PATH_SIM_NETWORK: ${PATH_SIM_NETWORK:-all}
|
||||
PATH_SIM_WINDOW_DAYS: "60"
|
||||
PATH_SIM_ENABLE_EARLY_STOP: "false"
|
||||
PATH_SIM_MAX_STATES: "230000"
|
||||
PATH_SIM_MAX_CANDIDATES: "22"
|
||||
PATH_SIM_HOP_MILES: "82"
|
||||
PATH_SIM_WEAK_PATHLOSS_DB: "135"
|
||||
PATH_SIM_MODEL_BUCKET_HOURS: "12"
|
||||
PATH_SIM_PERM_BUCKET_CAP: ${PATH_SIM_PERM_BUCKET_CAP:-50}
|
||||
PATH_SIM_LOG_EVERY: ${PATH_SIM_LOG_EVERY:-1}
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
link-backfill-worker:
|
||||
build:
|
||||
context: .
|
||||
|
||||
+9
-20
@@ -13,7 +13,6 @@ import { useDashboardStats } from './hooks/useDashboardStats.js';
|
||||
import { useLinkState } from './hooks/useLinkState.js';
|
||||
import { usePacketPathOverlay } from './hooks/usePacketPathOverlay.js';
|
||||
import { useAppMessageHandler } from './hooks/useAppMessageHandler.js';
|
||||
import { usePathLearningModel } from './hooks/usePathLearningModel.js';
|
||||
import { getCurrentSite } from './config/site.js';
|
||||
|
||||
const DEFAULT_FILTERS: Filters = {
|
||||
@@ -22,7 +21,7 @@ const DEFAULT_FILTERS: Filters = {
|
||||
clientNodes: false,
|
||||
packetPaths: false,
|
||||
betaPaths: false,
|
||||
betaPathThreshold: 0.5,
|
||||
betaPathThreshold: 0.45,
|
||||
links: false,
|
||||
hexClashes: false,
|
||||
hexClashMaxHops: 3,
|
||||
@@ -38,7 +37,7 @@ export const App: React.FC = () => {
|
||||
const raw = localStorage.getItem(FILTERS_KEY);
|
||||
if (!raw) return DEFAULT_FILTERS;
|
||||
const parsed = JSON.parse(raw) as Partial<Filters>;
|
||||
return { ...DEFAULT_FILTERS, ...parsed };
|
||||
return { ...DEFAULT_FILTERS, ...parsed, betaPathThreshold: 0.45 };
|
||||
} catch {
|
||||
return DEFAULT_FILTERS;
|
||||
}
|
||||
@@ -64,20 +63,7 @@ export const App: React.FC = () => {
|
||||
|
||||
const { coverage, handleCoverageUpdate } = useCoverage(networkFilter);
|
||||
const stats = useDashboardStats(networkFilter);
|
||||
const learningModelNetwork = import.meta.env['VITE_NETWORK'] === 'ukmesh' ? 'all' : site.network;
|
||||
const learningModel = usePathLearningModel(learningModelNetwork);
|
||||
|
||||
useEffect(() => {
|
||||
if (!learningModel) return;
|
||||
setFilters((current) => {
|
||||
const tuned = Math.min(0.9, Math.max(0.25, learningModel.recommendedThreshold));
|
||||
if (Math.abs(current.betaPathThreshold - tuned) < 0.01) return current;
|
||||
return { ...current, betaPathThreshold: tuned };
|
||||
});
|
||||
}, [learningModel]);
|
||||
|
||||
const {
|
||||
linkPairs,
|
||||
linkMetrics,
|
||||
viablePairsArr,
|
||||
applyInitialViablePairs,
|
||||
@@ -88,21 +74,21 @@ export const App: React.FC = () => {
|
||||
const {
|
||||
packetPath,
|
||||
betaPacketPath,
|
||||
betaExtraPurplePaths,
|
||||
betaLowConfidencePath,
|
||||
betaLowConfidenceSegments,
|
||||
betaCompletionPaths,
|
||||
betaPathConfidence,
|
||||
betaPermutationCount,
|
||||
betaRemainingHops,
|
||||
pathOpacity,
|
||||
pinnedPacketId,
|
||||
handlePacketPin,
|
||||
} = usePacketPathOverlay({
|
||||
packets,
|
||||
nodes,
|
||||
coverage,
|
||||
linkPairs,
|
||||
linkMetrics,
|
||||
learningModel,
|
||||
filters,
|
||||
network: networkFilter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -225,7 +211,9 @@ export const App: React.FC = () => {
|
||||
linkMetrics={linkMetrics}
|
||||
packetPath={packetPath}
|
||||
betaPath={betaPacketPath}
|
||||
betaExtraPurplePaths={betaExtraPurplePaths}
|
||||
betaLowPath={betaLowConfidencePath}
|
||||
betaLowSegments={betaLowConfidenceSegments}
|
||||
betaCompletionPaths={betaCompletionPaths}
|
||||
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
|
||||
pathOpacity={pathOpacity}
|
||||
@@ -237,6 +225,7 @@ export const App: React.FC = () => {
|
||||
onChange={setFilters}
|
||||
betaPathConfidence={betaPathConfidence}
|
||||
betaPermutationCount={betaPermutationCount}
|
||||
betaRemainingHops={betaRemainingHops}
|
||||
/>
|
||||
|
||||
{filters.livePackets && (
|
||||
|
||||
@@ -17,15 +17,18 @@ interface FilterPanelProps {
|
||||
onChange: (f: Filters) => void;
|
||||
betaPathConfidence?: number | null;
|
||||
betaPermutationCount?: number | null;
|
||||
betaRemainingHops?: number | null;
|
||||
}
|
||||
|
||||
export const LinksLegend: React.FC<{ compact?: boolean; muted?: boolean }> = ({ compact = false, muted = false }) => (
|
||||
<div className={`links-legend-inline${compact ? ' links-legend-inline--compact' : ''}${muted ? ' links-legend-inline--muted' : ''}`}>
|
||||
<div className="links-legend-inline__title">Links Legend</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#22c55e' }} /> Good (≤120 dB)</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#fbbf24' }} /> Marginal (121-135 dB)</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#ef4444' }} /> Weak (>135 dB)</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#d1d5db' }} /> Unknown (no dB yet)</div>
|
||||
<div className="links-legend-inline__grid">
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#22c55e' }} /> Good (≤120 dB)</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#fbbf24' }} /> Marginal (121-135 dB)</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#ef4444' }} /> Weak (>135 dB)</div>
|
||||
<div className="links-legend-inline__row"><span className="links-legend__swatch" style={{ background: '#d1d5db' }} /> Unknown (no dB yet)</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,7 +42,7 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri
|
||||
{ key: 'clientNodes', label: 'Companion / Room', color: '#ff9800' },
|
||||
];
|
||||
|
||||
export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, betaPathConfidence, betaPermutationCount }) => {
|
||||
export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, betaPathConfidence, betaPermutationCount, betaRemainingHops }) => {
|
||||
const toggle = (key: keyof Filters) => {
|
||||
onChange({ ...filters, [key]: !filters[key] });
|
||||
};
|
||||
@@ -52,6 +55,8 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
|
||||
Beta Confidence: <strong>{betaPathConfidence == null ? 'N/A' : `${Math.round(betaPathConfidence * 100)}%`}</strong>
|
||||
<br />
|
||||
Permutations: <strong>{betaPermutationCount == null ? 'N/A' : betaPermutationCount}</strong>
|
||||
<br />
|
||||
Remaining Hops: <strong>{betaRemainingHops == null ? 'N/A' : betaRemainingHops}</strong>
|
||||
</div>
|
||||
)}
|
||||
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
|
||||
@@ -80,22 +85,6 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
|
||||
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
|
||||
/>
|
||||
</div>
|
||||
{key === 'betaPaths' && filters.betaPaths && (
|
||||
<div className="filter-slider" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="filter-slider__label">
|
||||
Confidence: {Math.round(filters.betaPathThreshold * 100)}%
|
||||
</span>
|
||||
<input
|
||||
className="filter-slider__input"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={Math.round(filters.betaPathThreshold * 100)}
|
||||
onChange={(e) => onChange({ ...filters, betaPathThreshold: Number(e.target.value) / 100 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{key === 'hexClashes' && filters.hexClashes && (
|
||||
<div className="filter-slider" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="filter-slider__label">
|
||||
|
||||
@@ -57,6 +57,10 @@ function hasCoords(node: MeshNode | null | undefined): node is MeshNode & { lat:
|
||||
return typeof node?.lat === 'number' && typeof node?.lon === 'number';
|
||||
}
|
||||
|
||||
function isHiddenMapNode(node: MeshNode | null | undefined): boolean {
|
||||
return Boolean(node?.name?.includes('🚫'));
|
||||
}
|
||||
|
||||
// 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 ✓
|
||||
@@ -94,7 +98,9 @@ interface MapViewProps {
|
||||
linkMetrics: Map<string, LinkMetrics>;
|
||||
packetPath: [number, number][] | null;
|
||||
betaPath: [number, number][] | null;
|
||||
betaExtraPurplePaths: [number, number][][];
|
||||
betaLowPath: [number, number][] | null;
|
||||
betaLowSegments: [[number, number], [number, number]][];
|
||||
betaCompletionPaths: [number, number][][];
|
||||
showBetaPaths: boolean;
|
||||
pathOpacity: number;
|
||||
@@ -107,7 +113,7 @@ const DEFAULT_ZOOM = 11;
|
||||
|
||||
export const MapView: React.FC<MapViewProps> = ({
|
||||
nodes, arcs, activeNodes, coverage, showPackets, showCoverage, showClientNodes,
|
||||
showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPath, betaPath, betaLowPath, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady,
|
||||
showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPath, betaPath, betaExtraPurplePaths, betaLowPath, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady,
|
||||
}) => {
|
||||
const [map, setMap] = useState<LeafletMap | null>(null);
|
||||
const [focusedPrefix, setFocusedPrefix] = useState<string | null>(null);
|
||||
@@ -160,6 +166,8 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
// Refs to Leaflet Polyline instances for direct SVG attribute animation
|
||||
const regularPathRef = useRef<LeafletPolyline | null>(null);
|
||||
const betaLowPathRef = useRef<LeafletPolyline | null>(null);
|
||||
const betaPathRef = useRef<LeafletPolyline | null>(null);
|
||||
const aniFrameRef = useRef<number | null>(null);
|
||||
|
||||
// Animate marching dashes by incrementing stroke-dashoffset directly on the
|
||||
@@ -167,9 +175,10 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
// calls _updateStyle (setAttribute) on every prop change, which can interrupt
|
||||
// CSS keyframe animations. Direct DOM manipulation in an rAF loop is stable.
|
||||
const hasRegular = !!packetPath;
|
||||
const hasBeta = Boolean(showBetaPaths && (betaLowPath || betaPath || betaExtraPurplePaths.length > 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRegular) {
|
||||
if (!hasRegular && !hasBeta) {
|
||||
if (aniFrameRef.current !== null) {
|
||||
cancelAnimationFrame(aniFrameRef.current);
|
||||
aniFrameRef.current = null;
|
||||
@@ -184,7 +193,13 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
const val = String(-offset);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rp = (regularPathRef.current as any)?._path as SVGPathElement | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bl = (betaLowPathRef.current as any)?._path as SVGPathElement | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bp = (betaPathRef.current as any)?._path as SVGPathElement | null;
|
||||
if (hasRegular && rp) rp.setAttribute('stroke-dashoffset', val);
|
||||
if (hasBeta && bl) bl.setAttribute('stroke-dashoffset', val);
|
||||
if (hasBeta && bp) bp.setAttribute('stroke-dashoffset', val);
|
||||
aniFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
@@ -195,13 +210,12 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
aniFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasRegular]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [hasRegular, hasBeta]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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
|
||||
&& !n.name?.includes('🚫')
|
||||
), [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]);
|
||||
@@ -437,8 +451,6 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
&& hasCoords(b)
|
||||
&& (Date.now() - new Date(a.last_seen).getTime()) < FOURTEEN_DAYS_MS
|
||||
&& (Date.now() - new Date(b.last_seen).getTime()) < FOURTEEN_DAYS_MS
|
||||
&& !a.name?.includes('🚫')
|
||||
&& !b.name?.includes('🚫')
|
||||
&& (a.role === undefined || a.role === 2)
|
||||
&& (b.role === undefined || b.role === 2)
|
||||
) {
|
||||
@@ -560,6 +572,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
{/* Repeater markers — Leaflet default marker pane at zIndex 600 */}
|
||||
{nodesWithPos.map((node) => {
|
||||
if (isHiddenMapNode(node)) 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;
|
||||
@@ -582,6 +595,7 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
|
||||
{/* Companion radio + room server markers (toggled via filter) */}
|
||||
{showClientNodes && !showHexClashes && clientNodesArr.map((node) => {
|
||||
if (isHiddenMapNode(node)) return null;
|
||||
const isFocusVisible = clashVisibleNodeIds.has(node.node_id);
|
||||
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
|
||||
return (
|
||||
@@ -610,25 +624,14 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beta path — coverage-validated, unambiguous hop resolution */}
|
||||
{showBetaPaths && betaPath && (
|
||||
<Polyline
|
||||
positions={betaPath}
|
||||
pathOptions={{
|
||||
color: '#a855f7',
|
||||
weight: 2.2,
|
||||
dashArray: '6 9',
|
||||
opacity: pathOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beta path — uncertain (red) drawn first so confident (purple) always renders on top */}
|
||||
{showBetaPaths && betaLowPath && (
|
||||
<Polyline
|
||||
ref={betaLowPathRef}
|
||||
positions={betaLowPath}
|
||||
pathOptions={{
|
||||
color: '#ef4444',
|
||||
weight: 2.4,
|
||||
weight: 2.6,
|
||||
dashArray: '6 9',
|
||||
opacity: Math.min(0.9, pathOpacity),
|
||||
}}
|
||||
@@ -636,6 +639,34 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Purple confident portion rendered last — highest z-order so it is never obscured by the red uncertain portion */}
|
||||
{showBetaPaths && betaPath && (
|
||||
<Polyline
|
||||
ref={betaPathRef}
|
||||
positions={betaPath}
|
||||
pathOptions={{
|
||||
color: '#a855f7',
|
||||
weight: 2.8,
|
||||
dashArray: '6 9',
|
||||
opacity: pathOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBetaPaths && betaExtraPurplePaths.map((path, idx) => (
|
||||
<Polyline
|
||||
key={`beta-extra-purple-${idx}`}
|
||||
positions={path}
|
||||
pathOptions={{
|
||||
color: '#a855f7',
|
||||
weight: 2.8,
|
||||
dashArray: '6 9',
|
||||
opacity: pathOpacity,
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{showBetaPaths && betaCompletionPaths.length > 0 && (
|
||||
<Pane name="betaCompletionsPane" style={{ zIndex: 520 }}>
|
||||
{betaCompletionPaths.map((path, idx) => (
|
||||
@@ -644,9 +675,9 @@ export const MapView: React.FC<MapViewProps> = ({
|
||||
positions={path}
|
||||
pathOptions={{
|
||||
color: '#ef4444',
|
||||
weight: 1.2,
|
||||
dashArray: '3 9',
|
||||
opacity: Math.min(0.45, pathOpacity * 0.6),
|
||||
weight: 1.8,
|
||||
dashArray: '4 7',
|
||||
opacity: Math.min(0.78, pathOpacity * 0.95),
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
|
||||
@@ -17,50 +17,62 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
|
||||
filters,
|
||||
onFiltersChange,
|
||||
}) => {
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mobile-controls">
|
||||
<div className="mobile-filter-grid">
|
||||
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`filter-row${filters[key] ? ' filter-row--on' : ''}`}
|
||||
onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })}
|
||||
role="button"
|
||||
aria-pressed={!!filters[key]}
|
||||
>
|
||||
<span className="filter-row__label">
|
||||
{hollow ? (
|
||||
<span className="filter-dot filter-dot--hollow" style={{ borderColor: color, opacity: filters[key] ? 1 : 0.4 }} />
|
||||
) : (
|
||||
<span className="filter-dot" style={{ background: color, opacity: filters[key] ? 1 : 0.3 }} />
|
||||
)}
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-legend-toggle"
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
aria-expanded={showFilters}
|
||||
>
|
||||
<span>Layers</span>
|
||||
<span>{showFilters ? 'Hide' : 'Show'}</span>
|
||||
</button>
|
||||
<div className={`mobile-filter-wrap${showFilters ? '' : ' mobile-filter-wrap--hidden'}`}>
|
||||
<div className="mobile-filter-grid">
|
||||
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`filter-row${filters[key] ? ' filter-row--on' : ''}`}
|
||||
onClick={() => onFiltersChange({ ...filters, [key]: !filters[key] })}
|
||||
role="button"
|
||||
aria-pressed={!!filters[key]}
|
||||
>
|
||||
<span className="filter-row__label">
|
||||
{hollow ? (
|
||||
<span className="filter-dot filter-dot--hollow" style={{ borderColor: color, opacity: filters[key] ? 1 : 0.4 }} />
|
||||
) : (
|
||||
<span className="filter-dot" style={{ background: color, opacity: filters[key] ? 1 : 0.3 }} />
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={`filter-toggle${filters[key] ? ' filter-toggle--on' : ''}`}
|
||||
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filters.hexClashes && (
|
||||
<div className="filter-slider" style={{ margin: '0 8px 8px' }}>
|
||||
<span className="filter-slider__label">
|
||||
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
|
||||
</span>
|
||||
<span
|
||||
className={`filter-toggle${filters[key] ? ' filter-toggle--on' : ''}`}
|
||||
style={filters[key] ? { background: `${color}22`, borderColor: color } : {}}
|
||||
<input
|
||||
className="filter-slider__input"
|
||||
type="range"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={Math.round(filters.hexClashMaxHops)}
|
||||
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
{filters.hexClashes && (
|
||||
<div className="filter-slider" style={{ marginTop: 8 }}>
|
||||
<span className="filter-slider__label">
|
||||
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
|
||||
</span>
|
||||
<input
|
||||
className="filter-slider__input"
|
||||
type="range"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={Math.round(filters.hexClashMaxHops)}
|
||||
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-legend-toggle"
|
||||
|
||||
@@ -1,161 +1,109 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { AggregatedPacket, MeshNode } from './useNodes.js';
|
||||
import type { NodeCoverage } from './useCoverage.js';
|
||||
import { hasCoords, resolvePathWaypoints } from '../utils/pathing.js';
|
||||
import { buildNearestPrefixContinuation, resolveBetaPath, type LinkMetrics, type PathLearningModel } from '../utils/betaPathing.js';
|
||||
import { withNetworkParam, uncachedEndpoint } from '../utils/api.js';
|
||||
import type { Filters } from '../components/FilterPanel/FilterPanel.js';
|
||||
|
||||
const PATH_TTL = 5_000;
|
||||
const PREDICTION_CACHE_TTL_MS = 120_000;
|
||||
const MAX_PREDICTION_CACHE = 1200;
|
||||
type PathSegment = [[number, number], [number, number]];
|
||||
|
||||
type UsePacketPathOverlayParams = {
|
||||
packets: AggregatedPacket[];
|
||||
nodes: Map<string, MeshNode>;
|
||||
coverage: NodeCoverage[];
|
||||
linkPairs: Set<string>;
|
||||
linkMetrics: Map<string, LinkMetrics>;
|
||||
learningModel: PathLearningModel | null;
|
||||
filters: Filters;
|
||||
network?: string;
|
||||
};
|
||||
|
||||
type UsePacketPathOverlayResult = {
|
||||
packetPath: [number, number][] | null;
|
||||
betaPacketPath: [number, number][] | null;
|
||||
betaExtraPurplePaths: [number, number][][];
|
||||
betaLowConfidencePath: [number, number][] | null;
|
||||
betaLowConfidenceSegments: PathSegment[];
|
||||
betaCompletionPaths: [number, number][][];
|
||||
betaPathConfidence: number | null;
|
||||
betaPermutationCount: number | null;
|
||||
betaRemainingHops: number | null;
|
||||
pathOpacity: number;
|
||||
pinnedPacketId: string | null;
|
||||
handlePacketPin: (packet: AggregatedPacket) => void;
|
||||
};
|
||||
|
||||
function distKm(a: MeshNode, b: MeshNode): number {
|
||||
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);
|
||||
type ServerBetaResponse = {
|
||||
ok: boolean;
|
||||
packetHash: string;
|
||||
mode: 'resolved' | 'fallback' | 'none';
|
||||
confidence: number | null;
|
||||
permutationCount: number;
|
||||
remainingHops: number | null;
|
||||
purplePath: [number, number][] | null;
|
||||
extraPurplePaths: [number, number][][];
|
||||
redPath: [number, number][] | null;
|
||||
redSegments: PathSegment[];
|
||||
completionPaths: [number, number][][];
|
||||
};
|
||||
|
||||
function segmentizePath(path: [number, number][] | null): PathSegment[] {
|
||||
if (!path || path.length < 2) return [];
|
||||
const segments: PathSegment[] = [];
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const a = path[i];
|
||||
const b = path[i + 1];
|
||||
if (!a || !b) continue;
|
||||
segments.push([a, b]);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function buildFallbackPrefixPath(
|
||||
hopHashes: string[],
|
||||
src: MeshNode | null,
|
||||
rx: MeshNode,
|
||||
nodes: Map<string, MeshNode>,
|
||||
forceIncludeSource = false,
|
||||
): [number, number][] | null {
|
||||
const repeaters = Array.from(nodes.values()).filter(
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
|
||||
);
|
||||
|
||||
const pickedNearRx: MeshNode[] = [];
|
||||
const visited = new Set<string>([rx.node_id]);
|
||||
let prev = rx;
|
||||
for (const h of [...hopHashes].reverse()) {
|
||||
const prefix = h.slice(0, 2).toUpperCase();
|
||||
const candidates = repeaters
|
||||
.filter((n) => !visited.has(n.node_id) && n.node_id.slice(0, 2).toUpperCase() === prefix)
|
||||
.sort((a, b) => distKm(a, prev) - distKm(b, prev));
|
||||
const chosen = candidates[0];
|
||||
if (!chosen) continue;
|
||||
pickedNearRx.push(chosen);
|
||||
visited.add(chosen.node_id);
|
||||
prev = chosen;
|
||||
}
|
||||
|
||||
const hopsFarToNear = [...pickedNearRx].reverse();
|
||||
const pathNodes: MeshNode[] = [...(hasCoords(src) && forceIncludeSource ? [src] : []), ...hopsFarToNear, rx];
|
||||
// Avoid +1 visual hop inflation in low-confidence fallback mode.
|
||||
if (!forceIncludeSource && hasCoords(src) && pathNodes.length >= 2 && pathNodes[0]?.node_id === src.node_id) {
|
||||
pathNodes.shift();
|
||||
}
|
||||
if (pathNodes.length < 2) return null;
|
||||
return pathNodes.map((n) => [n.lat!, n.lon!]);
|
||||
async function fetchServerBeta(packetHash: string, network?: string, signal?: AbortSignal): Promise<ServerBetaResponse | null> {
|
||||
const endpoint = withNetworkParam(`/api/path-beta/resolve?hash=${encodeURIComponent(packetHash)}`, network);
|
||||
const response = await fetch(uncachedEndpoint(endpoint), { cache: 'no-store', signal });
|
||||
if (!response.ok) return null;
|
||||
return response.json() as Promise<ServerBetaResponse>;
|
||||
}
|
||||
|
||||
function splitResolvedAndAlternatives(
|
||||
result: ReturnType<typeof resolveBetaPath>,
|
||||
hopHashes: string[],
|
||||
srcNodeId: string | undefined,
|
||||
forceIncludeSource: boolean,
|
||||
threshold: number,
|
||||
nodes: Map<string, MeshNode>,
|
||||
): { resolvedPath: [number, number][] | null; lowPath: [number, number][] | null; completionPaths: [number, number][][] } {
|
||||
if (!result) return { resolvedPath: null, lowPath: null, completionPaths: [] };
|
||||
const seg = result.segmentConfidence;
|
||||
const firstLow = seg.findIndex((v) => v < threshold);
|
||||
if (firstLow < 0) return { resolvedPath: result.path, lowPath: null, completionPaths: [] };
|
||||
|
||||
// Receiver-side confident suffix (contiguous from rx backwards).
|
||||
let suffixStartEdge = seg.length;
|
||||
for (let i = seg.length - 1; i >= 0; i--) {
|
||||
if (seg[i]! >= threshold) suffixStartEdge = i;
|
||||
else break;
|
||||
}
|
||||
|
||||
const purpleFromStartEdges = Math.max(0, firstLow);
|
||||
const purpleFromEndEdges = suffixStartEdge < seg.length ? (seg.length - suffixStartEdge) : 0;
|
||||
const preferReceiverSide = purpleFromEndEdges > purpleFromStartEdges;
|
||||
|
||||
if (preferReceiverSide && suffixStartEdge < seg.length) {
|
||||
const resolvedCandidate = result.path.slice(suffixStartEdge);
|
||||
const resolvedPath = resolvedCandidate.length >= 2 ? resolvedCandidate : null;
|
||||
const lowCandidate = result.path.slice(0, suffixStartEdge + 1);
|
||||
const lowPath = lowCandidate.length >= 2 ? lowCandidate : null;
|
||||
return { resolvedPath, lowPath, completionPaths: [] };
|
||||
}
|
||||
|
||||
const resolvedCandidate = result.path.slice(0, firstLow + 1);
|
||||
const resolvedPath = resolvedCandidate.length >= 2 ? resolvedCandidate : null;
|
||||
const lowCandidate = result.path.slice(Math.max(0, firstLow));
|
||||
const lowPath = lowCandidate.length >= 2 ? lowCandidate : null;
|
||||
const stepsRemaining = result.path.length - 1 - firstLow;
|
||||
const startNodeId = result.nodeIds[firstLow];
|
||||
const endNodeId = result.nodeIds[result.nodeIds.length - 1];
|
||||
if (!startNodeId || !endNodeId || stepsRemaining <= 0) {
|
||||
return { resolvedPath, lowPath, completionPaths: [] };
|
||||
}
|
||||
|
||||
const hasSource = Boolean(srcNodeId && result.nodeIds[0] === srcNodeId);
|
||||
const confidentRelayCount = Math.max(0, Math.min(
|
||||
hopHashes.length,
|
||||
(resolvedPath?.length ?? 0) - (hasSource ? 1 : 0),
|
||||
));
|
||||
const remainingPrefixes = hopHashes.slice(confidentRelayCount);
|
||||
const nearestPrefixPath = buildNearestPrefixContinuation(
|
||||
startNodeId,
|
||||
remainingPrefixes,
|
||||
endNodeId,
|
||||
nodes,
|
||||
{ dropStartIfNodeId: forceIncludeSource ? undefined : srcNodeId },
|
||||
);
|
||||
if (nearestPrefixPath && nearestPrefixPath.length >= 2) {
|
||||
return { resolvedPath, lowPath: nearestPrefixPath, completionPaths: [] };
|
||||
}
|
||||
return { resolvedPath, lowPath, completionPaths: [] };
|
||||
function cacheKey(packetHash: string, network?: string): string {
|
||||
return `${network ?? 'teesside'}|${packetHash}`;
|
||||
}
|
||||
|
||||
export function usePacketPathOverlay({
|
||||
packets,
|
||||
nodes,
|
||||
coverage,
|
||||
linkPairs,
|
||||
linkMetrics,
|
||||
learningModel,
|
||||
filters,
|
||||
network,
|
||||
}: UsePacketPathOverlayParams): UsePacketPathOverlayResult {
|
||||
const [packetPath, setPacketPath] = useState<[number, number][] | null>(null);
|
||||
const [betaPacketPath, setBetaPacketPath] = useState<[number, number][] | null>(null);
|
||||
const [betaExtraPurplePaths, setBetaExtraPurplePaths] = useState<[number, number][][]>([]);
|
||||
const [betaLowConfidencePath, setBetaLowConfidencePath] = useState<[number, number][] | null>(null);
|
||||
const [betaLowConfidenceSegments, setBetaLowConfidenceSegments] = useState<PathSegment[]>([]);
|
||||
const [betaCompletionPaths, setBetaCompletionPaths] = useState<[number, number][][]>([]);
|
||||
const [betaPathConfidence, setBetaPathConfidence] = useState<number | null>(null);
|
||||
const [betaPermutationCount, setBetaPermutationCount] = useState<number | null>(null);
|
||||
const [betaRemainingHops, setBetaRemainingHops] = useState<number | null>(null);
|
||||
const [pinnedPacketId, setPinnedPacketId] = useState<string | null>(null);
|
||||
const [pathOpacity, setPathOpacity] = useState(0.75);
|
||||
|
||||
const pinnedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pathFadeRef = useRef<number | null>(null);
|
||||
const recentPredictionsRef = useRef<Map<string, { path: [number, number][]; lowPath: [number, number][] | null; completionPaths: [number, number][][]; confidence: number | null; ts: number }>>(new Map());
|
||||
const betaReqRef = useRef<AbortController | null>(null);
|
||||
const predictionCacheRef = useRef<Map<string, { prediction: ServerBetaResponse | null; ts: number }>>(new Map());
|
||||
const inFlightRef = useRef<Map<string, Promise<ServerBetaResponse | null>>>(new Map());
|
||||
const activeReqSeqRef = useRef(0);
|
||||
const recentPredictionsRef = useRef<Map<string, {
|
||||
purplePath: [number, number][] | null;
|
||||
extraPurplePaths: [number, number][][];
|
||||
redPath: [number, number][] | null;
|
||||
redSegments: PathSegment[];
|
||||
completionPaths: [number, number][][];
|
||||
confidence: number | null;
|
||||
permutations: number | null;
|
||||
remainingHops: number | null;
|
||||
ts: number;
|
||||
}>>(new Map());
|
||||
|
||||
const stopPathTimers = useCallback(() => {
|
||||
if (pathTimerRef.current) {
|
||||
@@ -166,18 +114,120 @@ export function usePacketPathOverlay({
|
||||
cancelAnimationFrame(pathFadeRef.current);
|
||||
pathFadeRef.current = null;
|
||||
}
|
||||
if (betaReqRef.current) {
|
||||
betaReqRef.current.abort();
|
||||
betaReqRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearPathState = useCallback(() => {
|
||||
setPacketPath(null);
|
||||
setBetaPacketPath(null);
|
||||
setBetaExtraPurplePaths([]);
|
||||
setBetaLowConfidencePath(null);
|
||||
setBetaLowConfidenceSegments([]);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(null);
|
||||
setBetaRemainingHops(null);
|
||||
setPathOpacity(0.75);
|
||||
}, []);
|
||||
|
||||
const applyServerPrediction = useCallback((packetHash: string, prediction: ServerBetaResponse | null) => {
|
||||
if (!prediction || !prediction.ok) {
|
||||
const recent = recentPredictionsRef.current.get(packetHash);
|
||||
if (!recent || Date.now() - recent.ts > 45_000) {
|
||||
setBetaPacketPath(null);
|
||||
setBetaExtraPurplePaths([]);
|
||||
setBetaLowConfidencePath(null);
|
||||
setBetaLowConfidenceSegments([]);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(null);
|
||||
setBetaRemainingHops(null);
|
||||
return;
|
||||
}
|
||||
setBetaPacketPath(recent.purplePath);
|
||||
setBetaExtraPurplePaths(recent.extraPurplePaths);
|
||||
setBetaLowConfidencePath(recent.redPath);
|
||||
setBetaLowConfidenceSegments(recent.redSegments);
|
||||
setBetaCompletionPaths(recent.completionPaths);
|
||||
setBetaPathConfidence(recent.confidence);
|
||||
setBetaPermutationCount(recent.permutations);
|
||||
setBetaRemainingHops(recent.remainingHops);
|
||||
return;
|
||||
}
|
||||
|
||||
const purplePath = prediction.purplePath && prediction.purplePath.length >= 2 ? prediction.purplePath : null;
|
||||
const extraPurplePaths = (prediction.extraPurplePaths ?? []).filter((path) => path.length >= 2);
|
||||
const redPath = prediction.redPath && prediction.redPath.length >= 2 ? prediction.redPath : null;
|
||||
const redSegments = prediction.redSegments?.length ? prediction.redSegments : segmentizePath(redPath);
|
||||
const completionPaths = prediction.completionPaths ?? [];
|
||||
const permutations = Number.isFinite(prediction.permutationCount)
|
||||
? prediction.permutationCount
|
||||
: ((redPath ? 1 : 0) + completionPaths.length);
|
||||
|
||||
setBetaPacketPath(purplePath);
|
||||
setBetaExtraPurplePaths(extraPurplePaths);
|
||||
setBetaLowConfidencePath(redPath);
|
||||
setBetaLowConfidenceSegments(redSegments);
|
||||
setBetaCompletionPaths(completionPaths);
|
||||
setBetaPathConfidence(prediction.confidence);
|
||||
setBetaPermutationCount(permutations);
|
||||
setBetaRemainingHops(prediction.remainingHops);
|
||||
|
||||
recentPredictionsRef.current.set(packetHash, {
|
||||
purplePath,
|
||||
extraPurplePaths,
|
||||
redPath,
|
||||
redSegments,
|
||||
completionPaths,
|
||||
confidence: prediction.confidence,
|
||||
permutations,
|
||||
remainingHops: prediction.remainingHops,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prunePredictionCache = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const cache = predictionCacheRef.current;
|
||||
for (const [key, value] of cache) {
|
||||
if (now - value.ts > PREDICTION_CACHE_TTL_MS) cache.delete(key);
|
||||
}
|
||||
if (cache.size <= MAX_PREDICTION_CACHE) return;
|
||||
const sorted = Array.from(cache.entries()).sort((a, b) => a[1].ts - b[1].ts);
|
||||
const removeCount = Math.max(0, cache.size - MAX_PREDICTION_CACHE);
|
||||
for (let i = 0; i < removeCount; i++) {
|
||||
const k = sorted[i]?.[0];
|
||||
if (k) cache.delete(k);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolvePrediction = useCallback((packetHash: string, networkName?: string): Promise<ServerBetaResponse | null> => {
|
||||
prunePredictionCache();
|
||||
const key = cacheKey(packetHash, networkName);
|
||||
const cached = predictionCacheRef.current.get(key);
|
||||
if (cached && Date.now() - cached.ts <= PREDICTION_CACHE_TTL_MS) {
|
||||
return Promise.resolve(cached.prediction);
|
||||
}
|
||||
|
||||
const inflight = inFlightRef.current.get(key);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const p = fetchServerBeta(packetHash, networkName)
|
||||
.then((prediction) => {
|
||||
predictionCacheRef.current.set(key, { prediction, ts: Date.now() });
|
||||
return prediction;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(key);
|
||||
});
|
||||
inFlightRef.current.set(key, p);
|
||||
return p;
|
||||
}, [prunePredictionCache]);
|
||||
|
||||
const latestId = packets[0]?.id;
|
||||
useEffect(() => {
|
||||
if (pinnedPacketId !== null) return;
|
||||
@@ -197,78 +247,36 @@ export function usePacketPathOverlay({
|
||||
setPacketPath(null);
|
||||
}
|
||||
|
||||
if (filters.betaPaths && latest?.rxNodeId && latest.path?.length && hasCoords(rx)) {
|
||||
const src = latest.srcNodeId ? (nodes.get(latest.srcNodeId) ?? null) : null;
|
||||
const forceIncludeSource = latest.packetType === 4; // Advert packets should stay anchored to their source repeater.
|
||||
const hops = latest.hopCount != null ? latest.path.slice(0, latest.hopCount) : latest.path;
|
||||
const pairKey = `${src?.node_id ?? 'unknown'}>${rx.node_id}`;
|
||||
const result = resolveBetaPath(
|
||||
hops,
|
||||
hasCoords(src) ? src : null,
|
||||
rx,
|
||||
nodes,
|
||||
coverage,
|
||||
linkPairs,
|
||||
linkMetrics,
|
||||
learningModel,
|
||||
{ forceIncludeSource },
|
||||
);
|
||||
if (result) {
|
||||
const split = splitResolvedAndAlternatives(result, hops, src?.node_id, forceIncludeSource, filters.betaPathThreshold, nodes);
|
||||
recentPredictionsRef.current.set(pairKey, {
|
||||
path: split.resolvedPath ?? [],
|
||||
lowPath: split.lowPath,
|
||||
completionPaths: split.completionPaths,
|
||||
confidence: result.confidence,
|
||||
ts: Date.now(),
|
||||
if (filters.betaPaths && latest?.packetHash && latest.path?.length) {
|
||||
const reqSeq = ++activeReqSeqRef.current;
|
||||
void resolvePrediction(latest.packetHash, network)
|
||||
.then((prediction) => {
|
||||
if (reqSeq !== activeReqSeqRef.current) return;
|
||||
applyServerPrediction(latest.packetHash, prediction);
|
||||
})
|
||||
.catch(() => {
|
||||
if (reqSeq !== activeReqSeqRef.current) return;
|
||||
applyServerPrediction(latest.packetHash, null);
|
||||
});
|
||||
setBetaPacketPath(split.resolvedPath);
|
||||
setBetaLowConfidencePath(split.lowPath);
|
||||
setBetaCompletionPaths(split.completionPaths);
|
||||
setBetaPathConfidence(result.confidence);
|
||||
setBetaPermutationCount((split.lowPath ? 1 : 0) + split.completionPaths.length);
|
||||
} else {
|
||||
const fallback = buildFallbackPrefixPath(hops, hasCoords(src) ? src : null, rx, nodes, forceIncludeSource);
|
||||
if (fallback) {
|
||||
setBetaPacketPath(null);
|
||||
setBetaLowConfidencePath(fallback);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(1);
|
||||
recentPredictionsRef.current.set(pairKey, {
|
||||
path: [],
|
||||
lowPath: fallback,
|
||||
completionPaths: [],
|
||||
confidence: null,
|
||||
ts: Date.now(),
|
||||
});
|
||||
} else {
|
||||
const recent = recentPredictionsRef.current.get(pairKey);
|
||||
if (recent && Date.now() - recent.ts < 45_000) {
|
||||
setBetaPacketPath(recent.path.length > 1 ? recent.path : null);
|
||||
setBetaLowConfidencePath(recent.lowPath);
|
||||
setBetaCompletionPaths(recent.completionPaths);
|
||||
setBetaPathConfidence(recent.confidence);
|
||||
setBetaPermutationCount((recent.lowPath ? 1 : 0) + recent.completionPaths.length);
|
||||
} else {
|
||||
setBetaPacketPath(null);
|
||||
setBetaLowConfidencePath(null);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setBetaPacketPath(null);
|
||||
setBetaExtraPurplePaths([]);
|
||||
setBetaLowConfidencePath(null);
|
||||
setBetaLowConfidenceSegments([]);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(null);
|
||||
setBetaRemainingHops(null);
|
||||
}
|
||||
|
||||
if (!filters.packetPaths && !filters.betaPaths) { setPathOpacity(0.75); return; }
|
||||
if (!latest) { setPathOpacity(0.75); return; }
|
||||
if (!filters.packetPaths && !filters.betaPaths) {
|
||||
setPathOpacity(0.75);
|
||||
return;
|
||||
}
|
||||
if (!latest) {
|
||||
setPathOpacity(0.75);
|
||||
return;
|
||||
}
|
||||
|
||||
setPathOpacity(0.75);
|
||||
pathTimerRef.current = setTimeout(() => {
|
||||
@@ -287,7 +295,7 @@ export function usePacketPathOverlay({
|
||||
pathFadeRef.current = requestAnimationFrame(animate);
|
||||
}, PATH_TTL - 1_000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [latestId, filters.packetPaths, filters.betaPaths, pinnedPacketId, linkMetrics, learningModel, filters.betaPathThreshold]);
|
||||
}, [latestId, filters.packetPaths, filters.betaPaths, pinnedPacketId, network]);
|
||||
|
||||
const handlePacketPin = useCallback((packet: AggregatedPacket) => {
|
||||
if (pinnedPacketId === packet.id) {
|
||||
@@ -307,78 +315,28 @@ export function usePacketPathOverlay({
|
||||
pinnedTimerRef.current = null;
|
||||
}
|
||||
|
||||
const rx = packet.rxNodeId ? nodes.get(packet.rxNodeId) : undefined;
|
||||
|
||||
setPacketPath(null);
|
||||
|
||||
if (packet.rxNodeId && packet.path?.length && hasCoords(rx)) {
|
||||
const src = packet.srcNodeId ? (nodes.get(packet.srcNodeId) ?? null) : null;
|
||||
const forceIncludeSource = packet.packetType === 4; // Advert packets should stay anchored to their source repeater.
|
||||
const hops = packet.hopCount != null ? packet.path.slice(0, packet.hopCount) : packet.path;
|
||||
const pairKey = `${src?.node_id ?? 'unknown'}>${rx.node_id}`;
|
||||
const result = resolveBetaPath(
|
||||
hops,
|
||||
hasCoords(src) ? src : null,
|
||||
rx,
|
||||
nodes,
|
||||
coverage,
|
||||
linkPairs,
|
||||
linkMetrics,
|
||||
learningModel,
|
||||
{ forceIncludeSource },
|
||||
);
|
||||
if (result) {
|
||||
const split = splitResolvedAndAlternatives(result, hops, src?.node_id, forceIncludeSource, filters.betaPathThreshold, nodes);
|
||||
recentPredictionsRef.current.set(pairKey, {
|
||||
path: split.resolvedPath ?? [],
|
||||
lowPath: split.lowPath,
|
||||
completionPaths: split.completionPaths,
|
||||
confidence: result.confidence,
|
||||
ts: Date.now(),
|
||||
if (packet.packetHash && packet.path?.length) {
|
||||
const reqSeq = ++activeReqSeqRef.current;
|
||||
void resolvePrediction(packet.packetHash, network)
|
||||
.then((prediction) => {
|
||||
if (reqSeq !== activeReqSeqRef.current) return;
|
||||
applyServerPrediction(packet.packetHash, prediction);
|
||||
})
|
||||
.catch(() => {
|
||||
if (reqSeq !== activeReqSeqRef.current) return;
|
||||
applyServerPrediction(packet.packetHash, null);
|
||||
});
|
||||
setBetaPacketPath(split.resolvedPath);
|
||||
setBetaLowConfidencePath(split.lowPath);
|
||||
setBetaCompletionPaths(split.completionPaths);
|
||||
setBetaPathConfidence(result.confidence);
|
||||
setBetaPermutationCount((split.lowPath ? 1 : 0) + split.completionPaths.length);
|
||||
} else {
|
||||
const fallback = buildFallbackPrefixPath(hops, hasCoords(src) ? src : null, rx, nodes, forceIncludeSource);
|
||||
if (fallback) {
|
||||
setBetaPacketPath(null);
|
||||
setBetaLowConfidencePath(fallback);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(1);
|
||||
recentPredictionsRef.current.set(pairKey, {
|
||||
path: [],
|
||||
lowPath: fallback,
|
||||
completionPaths: [],
|
||||
confidence: null,
|
||||
ts: Date.now(),
|
||||
});
|
||||
} else {
|
||||
const recent = recentPredictionsRef.current.get(pairKey);
|
||||
if (recent && Date.now() - recent.ts < 45_000) {
|
||||
setBetaPacketPath(recent.path.length > 1 ? recent.path : null);
|
||||
setBetaLowConfidencePath(recent.lowPath);
|
||||
setBetaCompletionPaths(recent.completionPaths);
|
||||
setBetaPathConfidence(recent.confidence);
|
||||
setBetaPermutationCount((recent.lowPath ? 1 : 0) + recent.completionPaths.length);
|
||||
} else {
|
||||
setBetaPacketPath(null);
|
||||
setBetaLowConfidencePath(null);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setBetaPacketPath(null);
|
||||
setBetaExtraPurplePaths([]);
|
||||
setBetaLowConfidencePath(null);
|
||||
setBetaLowConfidenceSegments([]);
|
||||
setBetaCompletionPaths([]);
|
||||
setBetaPathConfidence(null);
|
||||
setBetaPermutationCount(null);
|
||||
setBetaRemainingHops(null);
|
||||
}
|
||||
|
||||
setPathOpacity(0.75);
|
||||
@@ -401,8 +359,7 @@ export function usePacketPathOverlay({
|
||||
};
|
||||
pathFadeRef.current = requestAnimationFrame(animate);
|
||||
}, 30_000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pinnedPacketId, nodes, coverage, linkPairs, linkMetrics, learningModel, filters.betaPathThreshold, stopPathTimers, clearPathState]);
|
||||
}, [pinnedPacketId, network, stopPathTimers, clearPathState, applyServerPrediction, resolvePrediction]);
|
||||
|
||||
useEffect(() => () => {
|
||||
stopPathTimers();
|
||||
@@ -412,10 +369,13 @@ export function usePacketPathOverlay({
|
||||
return {
|
||||
packetPath,
|
||||
betaPacketPath,
|
||||
betaExtraPurplePaths,
|
||||
betaLowConfidencePath,
|
||||
betaLowConfidenceSegments,
|
||||
betaCompletionPaths,
|
||||
betaPathConfidence,
|
||||
betaPermutationCount,
|
||||
betaRemainingHops,
|
||||
pathOpacity,
|
||||
pinnedPacketId,
|
||||
handlePacketPin,
|
||||
|
||||
@@ -51,6 +51,7 @@ function workerLabel(name: string): string {
|
||||
if (name === 'path-learning') return 'Path Learning';
|
||||
if (name === 'health-worker') return 'Health Worker';
|
||||
if (name === 'link-backfill-worker') return 'Link Backfill Worker';
|
||||
if (name === 'path-sim-worker') return 'Path Simulation Worker';
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -77,6 +78,7 @@ export const HealthPage: React.FC = () => {
|
||||
'path-learning': 'Rebuilds the beta path-learning priors from historical packet behavior so route predictions stay current.',
|
||||
'health-worker': 'Captures periodic worker/system health snapshots used by this Health page for live and historical status.',
|
||||
'link-backfill-worker': 'One-shot startup worker that backfills historical link observations when link tables are empty.',
|
||||
'path-sim-worker': 'Replays historical packets through beta/red continuation logic and stores aggregate resolution/permutation diagnostics.',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -598,6 +598,12 @@ html, body, #root {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.links-legend-inline__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.links-legend-inline__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -612,6 +618,7 @@ html, body, #root {
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Live packet feed (bottom left) ────────────────────────────────────── */
|
||||
@@ -1795,6 +1802,12 @@ html, body, #root {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.mobile-filter-grid .filter-row { padding: 8px 6px; }
|
||||
.mobile-filter-wrap {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.mobile-filter-wrap--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Mobile search: shown below filter grid ──────────────────────────── */
|
||||
.mobile-search {
|
||||
@@ -1824,6 +1837,16 @@ html, body, #root {
|
||||
text-transform: uppercase;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.links-legend-inline--compact .links-legend-inline__grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 10px;
|
||||
row-gap: 5px;
|
||||
}
|
||||
.links-legend-inline--compact .links-legend-inline__row {
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
.mobile-search .node-search {
|
||||
position: static;
|
||||
transform: none;
|
||||
|
||||
@@ -7,7 +7,10 @@ export type { LinkMetrics } from './pathing.js';
|
||||
const MAX_BETA_HOPS = 25;
|
||||
const R_EFF_M = 6_371_000 / (1 - 0.25);
|
||||
const PREFIX_AMBIGUITY_FLOOR_KM = 45;
|
||||
const WEAK_LINK_PATHLOSS_MAX_DB = 135;
|
||||
// ML-optimised parameters (gen 4 / v01, fitness 0.93462)
|
||||
const WEAK_LINK_PATHLOSS_MAX_DB = 137.88;
|
||||
const MAX_HOP_KM = 127.19 * 1.609344; // 127.19 miles ≈ 204.7 km
|
||||
const MAX_PERMUTATION_HOP_KM = MAX_HOP_KM;
|
||||
|
||||
export type PathLearningModel = {
|
||||
prefixProbabilities: Map<string, number>;
|
||||
@@ -125,9 +128,9 @@ export function resolveBetaPath(
|
||||
const rxLat = rx.lat;
|
||||
const rxLon = rx.lon;
|
||||
|
||||
type HopResult = { node: MeshNode; conf: number } | null;
|
||||
type HopResult = { node: MeshNode; conf: number };
|
||||
const candidatesPool = Array.from(allNodes.values()).filter(
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2),
|
||||
);
|
||||
const prefixCounts = new Map<string, number>();
|
||||
const prefixBuckets = new Map<string, MeshNode[]>();
|
||||
@@ -140,7 +143,7 @@ export function resolveBetaPath(
|
||||
}
|
||||
|
||||
const totalDist = hasCoords(src) ? distKm(src, rx) : 0;
|
||||
const corridorMaxKm = Math.max(10, Math.min(45, totalDist * 0.33));
|
||||
const corridorMaxKm = Math.max(10, Math.min(80, totalDist * 0.35));
|
||||
const receiverRegion = rx.iata ?? 'unknown';
|
||||
const bucketHours = learningModel?.bucketHours ?? 6;
|
||||
const hourBucket = currentHourBucket(bucketHours);
|
||||
@@ -338,7 +341,7 @@ export function resolveBetaPath(
|
||||
return dir >= minimumDirectionalSupport(observed) * 0.6;
|
||||
})
|
||||
.sort((a, b) => sortScore(b) - sortScore(a))
|
||||
.slice(0, 4)
|
||||
.slice(0, 16)
|
||||
.map((c) => {
|
||||
usedIds.add(c.node_id);
|
||||
const meta = linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
|
||||
@@ -373,7 +376,7 @@ export function resolveBetaPath(
|
||||
return (reachOk && losOk) || (reachOk && isWeakOrBetter(meta)) || (losOk && isWeakOrBetter(meta));
|
||||
})
|
||||
.sort((a, b) => sortScore(b) - sortScore(a))
|
||||
.slice(0, 3)
|
||||
.slice(0, 10)
|
||||
.map((c) => {
|
||||
usedIds.add(c.node_id);
|
||||
const distancePenalty = Math.min(0.12, distKm(c, prevNode) / 120);
|
||||
@@ -394,12 +397,12 @@ export function resolveBetaPath(
|
||||
.filter((c) => {
|
||||
if (usedIds.has(c.node_id)) return false;
|
||||
if (!inCorridor(c, prevNode)) return false;
|
||||
if (distKm(c, prevNode) >= 65) return false;
|
||||
if (distKm(c, prevNode) >= MAX_HOP_KM * 0.5) return false;
|
||||
const meta = linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
|
||||
return hasLoS(c, prevNode) || isWeakOrBetter(meta);
|
||||
})
|
||||
.sort((a, b) => sortScore(b) - sortScore(a))
|
||||
.slice(0, 1)
|
||||
.slice(0, 6)
|
||||
.map((c) => {
|
||||
const prior = distanceElevationPrior(c, prevNode);
|
||||
const prefixBoost = prefixPrior(prefix, prevPrefix, c.node_id) * 0.16;
|
||||
@@ -416,18 +419,16 @@ export function resolveBetaPath(
|
||||
return [...confirmed, ...reachable, ...fallback];
|
||||
}
|
||||
|
||||
const maxSkips = Math.min(3, Math.floor(pathHashes.length / 2));
|
||||
const ambiguity = pathHashes.reduce(
|
||||
(sum, h) => sum + (prefixCounts.get(h.slice(0, 2).toUpperCase()) ?? 0),
|
||||
0,
|
||||
);
|
||||
let budget = Math.max(600, Math.min(12_000, 260 + pathHashes.length * 140 + ambiguity * 42));
|
||||
let budget = Math.max(3_000, Math.min(308_232, 1_000 + pathHashes.length * 3_000 + ambiguity * 800));
|
||||
|
||||
function solve(
|
||||
hopIdx: number,
|
||||
prevNode: MeshNode,
|
||||
nextTowardRx: string | null,
|
||||
skipsLeft: number,
|
||||
visited: Set<string>,
|
||||
): HopResult[] | null {
|
||||
if (hopIdx < 0) return [];
|
||||
@@ -440,30 +441,23 @@ export function resolveBetaPath(
|
||||
for (const opt of options) {
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(opt.node.node_id);
|
||||
const rest = solve(hopIdx - 1, opt.node, prevNode.node_id, skipsLeft, nextVisited);
|
||||
const rest = solve(hopIdx - 1, opt.node, prevNode.node_id, nextVisited);
|
||||
if (rest !== null) return [opt, ...rest];
|
||||
}
|
||||
|
||||
if (skipsLeft > 0) {
|
||||
const rest = solve(hopIdx - 1, prevNode, nextTowardRx, skipsLeft - 1, visited);
|
||||
if (rest !== null) return [null, ...rest];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = solve(pathHashes.length - 1, rx, null, maxSkips, new Set([rx.node_id]));
|
||||
const raw = solve(pathHashes.length - 1, rx, null, new Set([rx.node_id]));
|
||||
if (!raw) return null;
|
||||
|
||||
const hops = [...raw].reverse().filter((r): r is { node: MeshNode; conf: number } => r !== null);
|
||||
const hops = [...raw].reverse();
|
||||
if (hops.length === 0) return null;
|
||||
|
||||
const totalHops = raw.length;
|
||||
const skipped = totalHops - hops.length;
|
||||
const meanHopConfidence = hops.reduce((sum, h) => sum + h.conf, 0) / hops.length;
|
||||
const resolvedRatio = hops.length / totalHops;
|
||||
const skipPenalty = Math.max(0.2, 1 - skipped * 0.28);
|
||||
const rawConfidence = meanHopConfidence * resolvedRatio * skipPenalty;
|
||||
const rawConfidence = meanHopConfidence * resolvedRatio;
|
||||
const calibratedConfidence = rawConfidence * (learningModel?.confidenceScale ?? 1) + (learningModel?.confidenceBias ?? 0);
|
||||
const confidence = clamp(calibratedConfidence, 0, 1);
|
||||
|
||||
@@ -490,7 +484,7 @@ export function resolveBetaPath(
|
||||
segmentConfidence.push(hops[0]?.conf ?? confidence); // src -> first hop
|
||||
continue;
|
||||
}
|
||||
const hopIdx = hasSource ? i : i;
|
||||
const hopIdx = hasSource ? i - 1 : i;
|
||||
segmentConfidence.push(hops[hopIdx]?.conf ?? hops[hops.length - 1]?.conf ?? confidence);
|
||||
}
|
||||
|
||||
@@ -508,7 +502,7 @@ export function enumerateBetaCompletions(
|
||||
): [number, number][][] {
|
||||
if (stepsRemaining <= 0 || startNodeId === endNodeId || maxPaths <= 0) return [];
|
||||
const candidates = Array.from(allNodes.values()).filter(
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2),
|
||||
);
|
||||
const byId = new Map<string, MeshNode>();
|
||||
for (const n of candidates) byId.set(n.node_id, n);
|
||||
@@ -560,10 +554,10 @@ export function buildNearestPrefixContinuation(
|
||||
remainingPrefixes: string[],
|
||||
endNodeId: string,
|
||||
allNodes: Map<string, MeshNode>,
|
||||
options?: { dropStartIfNodeId?: string },
|
||||
options?: { dropStartIfNodeId?: string; blockedNodeIds?: string[] },
|
||||
): [number, number][] | null {
|
||||
const candidates = Array.from(allNodes.values()).filter(
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2),
|
||||
);
|
||||
const byId = new Map<string, MeshNode>();
|
||||
for (const n of candidates) byId.set(n.node_id, n);
|
||||
@@ -572,6 +566,9 @@ export function buildNearestPrefixContinuation(
|
||||
const end = byId.get(endNodeId);
|
||||
if (!start || !end) return null;
|
||||
|
||||
const blocked = new Set(options?.blockedNodeIds ?? []);
|
||||
if (blocked.has(start.node_id) || blocked.has(end.node_id)) return null;
|
||||
|
||||
const pathNodes: MeshNode[] = [start];
|
||||
const visited = new Set<string>([start.node_id]);
|
||||
let current = start;
|
||||
@@ -615,13 +612,13 @@ export function enumeratePrefixContinuations(
|
||||
remainingPrefixes: string[],
|
||||
endNodeId: string,
|
||||
allNodes: Map<string, MeshNode>,
|
||||
options?: { dropStartIfNodeId?: string; maxRenderPaths?: number; maxSearchStates?: number },
|
||||
): { paths: [number, number][][]; totalCount: number; truncated: boolean } {
|
||||
options?: { dropStartIfNodeId?: string; maxRenderPaths?: number; maxSearchStates?: number; blockedNodeIds?: string[] },
|
||||
): { paths: [number, number][][]; totalCount: number; truncated: boolean; longestPrefixDepth: number } {
|
||||
const maxRenderPaths = Math.max(1, options?.maxRenderPaths ?? 320);
|
||||
const maxSearchStates = Math.max(1000, options?.maxSearchStates ?? 120_000);
|
||||
|
||||
const candidates = Array.from(allNodes.values()).filter(
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
|
||||
(n) => hasCoords(n) && (n.role === undefined || n.role === 2),
|
||||
);
|
||||
const byId = new Map<string, MeshNode>();
|
||||
const byPrefix = new Map<string, MeshNode[]>();
|
||||
@@ -635,21 +632,46 @@ export function enumeratePrefixContinuations(
|
||||
|
||||
const start = byId.get(startNodeId);
|
||||
const end = byId.get(endNodeId);
|
||||
if (!start || !end) return { paths: [], totalCount: 0, truncated: false };
|
||||
if (!start || !end) return { paths: [], totalCount: 0, truncated: false, longestPrefixDepth: 0 };
|
||||
const blocked = new Set(options?.blockedNodeIds ?? []);
|
||||
if (blocked.has(start.node_id) || blocked.has(end.node_id)) {
|
||||
return { paths: [], totalCount: 0, truncated: false, longestPrefixDepth: 0 };
|
||||
}
|
||||
|
||||
const discovered: string[][] = [];
|
||||
const partialDiscovered: string[][] = [];
|
||||
let totalCount = 0;
|
||||
let partialCount = 0;
|
||||
let states = 0;
|
||||
let truncated = false;
|
||||
let longestPrefixDepth = 0;
|
||||
let bestPartialDepth = 0;
|
||||
|
||||
const recordPartial = (depth: number, path: string[]) => {
|
||||
if (path.length < 2 || depth <= 0) return;
|
||||
if (depth > bestPartialDepth) {
|
||||
bestPartialDepth = depth;
|
||||
partialDiscovered.length = 0;
|
||||
partialCount = 0;
|
||||
}
|
||||
if (depth === bestPartialDepth) {
|
||||
partialCount += 1;
|
||||
if (partialDiscovered.length < maxRenderPaths) partialDiscovered.push([...path]);
|
||||
}
|
||||
};
|
||||
|
||||
const dfs = (idx: number, current: MeshNode, path: string[], visited: Set<string>) => {
|
||||
if (idx > longestPrefixDepth) longestPrefixDepth = idx;
|
||||
if (states++ >= maxSearchStates) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
if (idx >= remainingPrefixes.length) {
|
||||
if (current.node_id !== end.node_id) {
|
||||
if (visited.has(end.node_id)) return;
|
||||
if (visited.has(end.node_id)) {
|
||||
recordPartial(idx, path);
|
||||
return;
|
||||
}
|
||||
path.push(end.node_id);
|
||||
}
|
||||
totalCount += 1;
|
||||
@@ -660,8 +682,12 @@ export function enumeratePrefixContinuations(
|
||||
|
||||
const prefix = remainingPrefixes[idx]!.slice(0, 2).toUpperCase();
|
||||
const nodesForPrefix = (byPrefix.get(prefix) ?? [])
|
||||
.filter((n) => !visited.has(n.node_id) && n.node_id !== end.node_id)
|
||||
.filter((n) => !visited.has(n.node_id) && !blocked.has(n.node_id) && n.node_id !== end.node_id && distKm(n, current) <= MAX_PERMUTATION_HOP_KM)
|
||||
.sort((a, b) => distKm(a, current) - distKm(b, current));
|
||||
if (nodesForPrefix.length === 0) {
|
||||
recordPartial(idx, path);
|
||||
return;
|
||||
}
|
||||
for (const next of nodesForPrefix) {
|
||||
visited.add(next.node_id);
|
||||
path.push(next.node_id);
|
||||
@@ -678,7 +704,8 @@ export function enumeratePrefixContinuations(
|
||||
const visited = new Set<string>([start.node_id]);
|
||||
dfs(0, start, [start.node_id], visited);
|
||||
|
||||
const paths = discovered
|
||||
const renderSource = totalCount > 0 ? discovered : partialDiscovered;
|
||||
const paths = renderSource
|
||||
.map((ids) => {
|
||||
const renderIds = (options?.dropStartIfNodeId && ids[0] === options.dropStartIfNodeId) ? ids.slice(1) : ids;
|
||||
const nodes = renderIds.map((id) => byId.get(id)).filter((n): n is MeshNode => Boolean(n));
|
||||
@@ -688,5 +715,10 @@ export function enumeratePrefixContinuations(
|
||||
})
|
||||
.filter((p): p is [number, number][] => Array.isArray(p));
|
||||
|
||||
return { paths, totalCount, truncated };
|
||||
return {
|
||||
paths,
|
||||
totalCount: totalCount > 0 ? totalCount : partialCount,
|
||||
truncated,
|
||||
longestPrefixDepth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function resolvePathWaypoints(
|
||||
for (let i = 0; i < N; i++) {
|
||||
const prefix = pathHashes[i]!.toUpperCase();
|
||||
const candidates = Array.from(allNodes.values()).filter(
|
||||
(n) => hasCoords(n) && !n.name?.includes('🚫') && n.node_id.toUpperCase().startsWith(prefix),
|
||||
(n) => hasCoords(n) && n.node_id.toUpperCase().startsWith(prefix),
|
||||
);
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user