egorin: визуализатор маршрутизации LoRa-хопов (canvas mesh-паутина, многоскачковая ретрансляция, бегущие пакеты)

This commit is contained in:
german
2026-07-10 16:36:49 +03:00
parent fc10cb77ba
commit d4c05152c5
2 changed files with 120 additions and 1 deletions
+105
View File
@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react';
// Визуализатор ретрансляции/маршрутизации LoRa-хопов: узлы-паутинка вокруг шлюза,
// нити-связи (прямые и многоскачковые), бегущие по ним пакеты (передвижение по массиву).
interface Node { id: string; label: string; status: 'active' | 'pending' | 'disabled'; rssi?: number; transport?: 'lora' | 'lan' }
const COL = { active: [63, 208, 107], pending: [244, 183, 64], disabled: [125, 148, 163], gw: [34, 224, 255], pkt: [124, 220, 255] } as const;
const rgba = (c: readonly number[], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes: Node[]; gatewayLabel?: string; className?: string }) {
const ref = useRef<HTMLCanvasElement>(null);
const nodesRef = useRef(nodes);
nodesRef.current = nodes;
useEffect(() => {
const cv = ref.current;
if (!cv) return;
const ctx = cv.getContext('2d')!;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let raf = 0;
const resize = () => { cv.width = cv.clientWidth * dpr; cv.height = cv.clientHeight * dpr; };
resize();
const ro = new ResizeObserver(resize);
ro.observe(cv);
const draw = (now: number) => {
const W = cv.width, H = cv.height, cx = W / 2, cy = H / 2;
ctx.clearRect(0, 0, W, H);
const ns = nodesRef.current;
const R = Math.min(W, H) * 0.36;
// раскладка узлов по кольцу; слабый RSSI (< -100) — «дальний», ретранслируется через ближнего
const pts = ns.map((n, i) => {
const ang = (i / Math.max(1, ns.length)) * Math.PI * 2 - Math.PI / 2;
const far = (n.rssi ?? -90) < -100;
const r = far ? R * 1.0 : R * 0.66;
return { n, x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r, far, ang };
});
// связи: дальние — через ближайшего не-дальнего соседа (2 хопа), иначе прямо к шлюзу
const relays = pts.filter((p) => !p.far);
const linkFor = (p: typeof pts[number]) => {
if (!p.far || !relays.length) return { x: cx, y: cy, hop: false };
let best = relays[0], bd = Infinity;
for (const r of relays) { const d = (r.x - p.x) ** 2 + (r.y - p.y) ** 2; if (d < bd) { bd = d; best = r; } }
return { x: best.x, y: best.y, hop: true, relay: best };
};
// паутинка-фон (тонкие нити между близкими узлами)
ctx.lineWidth = dpr;
for (let a = 0; a < pts.length; a++) for (let b = a + 1; b < pts.length; b++) {
const dx = pts[a].x - pts[b].x, dy = pts[a].y - pts[b].y;
if (dx * dx + dy * dy < (R * 0.7) ** 2) {
ctx.strokeStyle = rgba(COL.gw, 0.05);
ctx.beginPath(); ctx.moveTo(pts[a].x, pts[a].y); ctx.lineTo(pts[b].x, pts[b].y); ctx.stroke();
}
}
const tsec = now * 0.001;
// связи узел → (ретранслятор) → шлюз + бегущие пакеты
for (const p of pts) {
const target = linkFor(p);
const c = COL[p.n.status];
// сегмент 1: узел → цель
ctx.strokeStyle = rgba(c, 0.28); ctx.lineWidth = 1.4 * dpr;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(target.x, target.y); ctx.stroke();
// сегмент 2 (если хоп): ретранслятор → шлюз
if (target.hop) {
ctx.strokeStyle = rgba(COL.gw, 0.22);
ctx.beginPath(); ctx.moveTo(target.x, target.y); ctx.lineTo(cx, cy); ctx.stroke();
}
if (p.n.status !== 'active') continue;
// бегущий пакет по маршруту (узел→…→шлюз)
const route: Array<[number, number]> = target.hop ? [[p.x, p.y], [target.x, target.y], [cx, cy]] : [[p.x, p.y], [cx, cy]];
const speed = 0.35 + ((p.n.rssi ?? -90) + 120) / 200; // ближе — быстрее
const fp = (tsec * speed + p.ang) % 1;
const seg = fp * (route.length - 1), si = Math.floor(seg), fr = seg - si;
const [ax, ay] = route[si], [bx, by] = route[si + 1] || route[si];
const px = ax + (bx - ax) * fr, py = ay + (by - ay) * fr;
ctx.fillStyle = rgba(COL.pkt, 0.95); ctx.beginPath(); ctx.arc(px, py, 2.6 * dpr, 0, 6.283); ctx.fill();
ctx.fillStyle = rgba(COL.pkt, 0.2); ctx.beginPath(); ctx.arc(px, py, 7 * dpr, 0, 6.283); ctx.fill();
}
// узлы
for (const p of pts) {
const c = COL[p.n.status];
const pulse = p.n.status === 'active' ? 0.6 + 0.4 * Math.sin(tsec * 2.4 + p.ang) : 1;
ctx.fillStyle = rgba(c, 0.9); ctx.beginPath(); ctx.arc(p.x, p.y, (p.n.transport === 'lan' ? 5 : 4) * dpr * pulse, 0, 6.283); ctx.fill();
ctx.fillStyle = rgba(c, 0.14); ctx.beginPath(); ctx.arc(p.x, p.y, 12 * dpr * pulse, 0, 6.283); ctx.fill();
ctx.fillStyle = 'rgba(207,227,214,0.75)'; ctx.font = `${9 * dpr}px Inter, system-ui`; ctx.textAlign = 'center';
ctx.fillText(p.n.label, p.x, p.y + 18 * dpr);
}
// шлюз в центре
const gp = 0.6 + 0.4 * Math.sin(tsec * 1.6);
ctx.fillStyle = rgba(COL.gw, 0.16); ctx.beginPath(); ctx.arc(cx, cy, 22 * dpr * gp, 0, 6.283); ctx.fill();
ctx.fillStyle = rgba(COL.gw, 0.95); ctx.beginPath(); ctx.arc(cx, cy, 7 * dpr, 0, 6.283); ctx.fill();
ctx.fillStyle = '#0b0f14'; ctx.font = `bold ${8 * dpr}px Inter`; ctx.fillText('GW', cx, cy + 3 * dpr);
ctx.fillStyle = rgba(COL.gw, 0.9); ctx.font = `${9 * dpr}px Inter`; ctx.fillText(gatewayLabel, cx, cy + 30 * dpr);
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}, [gatewayLabel]);
return <canvas ref={ref} className={className} style={{ width: '100%', height: '100%', display: 'block' }} />;
}
+15 -1
View File
@@ -1,7 +1,8 @@
import { useEffect, useState, useCallback } from 'react';
import { RadioTower, Cable, Wifi, Zap, Plus, Router } from 'lucide-react';
import { RadioTower, Cable, Wifi, Zap, Plus, Router, Waypoints } from 'lucide-react';
import { apiGet, apiPost } from '@/api/client';
import { cn } from '@/lib/utils';
import { LoraMeshViz } from './LoraMeshViz';
interface LoraDevice {
node_id: string; name?: string | null; project?: string | null; object?: string | null;
@@ -68,6 +69,19 @@ export function LoraModule() {
))}
</div>
{/* Визуализатор ретрансляции/маршрутизации LoRa-хопов */}
<div className="overflow-hidden rounded-2xl border border-border bg-surface">
<div className="flex items-center gap-2 border-b border-border px-3 py-2 text-[11px] uppercase tracking-wide text-fg-muted">
<Waypoints className="h-3.5 w-3.5" /> Маршрутизация LoRa · хопы и ретрансляция
</div>
<div className="h-[280px] w-full bg-[#070a10]">
<LoraMeshViz
gatewayLabel={gws[0]?.gw_id || 'gw1'}
nodes={devices.filter((d) => d.transport === 'lora' && d.status !== 'disabled').map((d) => ({ id: d.node_id, label: d.name || d.node_id, status: d.status, rssi: d.last_rssi, transport: d.transport }))}
/>
</div>
</div>
{/* Обнаружены новые устройства (pending) */}
{pending.length > 0 && (
<div className="rounded-2xl border border-warn/40 bg-warn/10 p-3">