diff --git a/src/modules/energy/LoraMeshViz.tsx b/src/modules/energy/LoraMeshViz.tsx new file mode 100644 index 0000000..db96f22 --- /dev/null +++ b/src/modules/energy/LoraMeshViz.tsx @@ -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(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 ; +} diff --git a/src/modules/energy/LoraModule.tsx b/src/modules/energy/LoraModule.tsx index 8e6b11e..8d21e99 100644 --- a/src/modules/energy/LoraModule.tsx +++ b/src/modules/energy/LoraModule.tsx @@ -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() { ))} + {/* Визуализатор ретрансляции/маршрутизации LoRa-хопов */} +
+
+ Маршрутизация LoRa · хопы и ретрансляция +
+
+ 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 }))} + /> +
+
+ {/* Обнаружены новые устройства (pending) */} {pending.length > 0 && (