egorin LoRa-карта: всплывающие бейджи-символы над узлами (счётчик опроса+мощность), красный при опросе вне норматива, счётчик последнего опроса, разворот карты на пол-экрана; фикс fp<0 в LoraMeshViz (убивал RAF); frozen-датчик в демо
This commit is contained in:
+5
-3
@@ -74,6 +74,7 @@ export function seedLora() {
|
||||
{ id: 'eg-lan1', name: 'ТП-7 щит учёта', project: 'egorin', object: 'ТП-7 «Центр»', hw: 'lan', caps: { pzem: true, relays: 4, ctl: true }, ip: '192.168.1.91', relay_names: ['Ввод-1', 'Ввод-2', 'Освещение', 'Вентиляция'], status: 'active', base: 12.5 },
|
||||
{ id: 'orb1', name: 'ORBIT склад', project: 'orbit', object: 'Склад «Весовая»', hw: 'lora', caps: { pzem: true, relays: 1, ctl: true }, gw: 'gw1', rssi: -104, relay_names: ['Освещение цех'], status: 'active', base: 6.1 },
|
||||
{ id: 'eg-lan2', name: 'РП-2 распред.', project: 'egorin', object: 'РП-2', hw: 'lan', caps: { pzem: true, relays: 4, ctl: true }, ip: '192.168.1.93', relay_names: ['Фидер-1', 'Фидер-2', 'Фидер-3', 'Резерв'], status: 'active', base: 21.0 },
|
||||
{ id: 'eg03', name: 'ТП-3 фидер (обрыв связи)', project: 'egorin', object: 'ТП-3 «Западный»', hw: 'lora', caps: { pzem: true, relays: 1, ctl: true }, gw: 'gw1', rssi: -98, relay_names: ['Фидер'], status: 'active', base: 5.2, frozen: true }, // активен, но перестал опрашиваться → вне норматива (красный)
|
||||
{ id: 'eg02', name: null, project: null, object: null, hw: 'lora', caps: { pzem: true, relays: 2, ctl: true }, gw: 'gw1', rssi: -111, status: 'pending', base: 3.4 }, // pending — только вышел в эфир
|
||||
];
|
||||
seed.forEach((s) => {
|
||||
@@ -83,12 +84,13 @@ export function seedLora() {
|
||||
if (s.relay_names) d.relay_names = s.relay_names;
|
||||
d.relays = Array.from({ length: s.caps.relays }, (_, i) => (i === 0 ? 1 : 0));
|
||||
d._base = s.base; d._kwh = Math.round(s.base * 240 + Math.random() * 500);
|
||||
d.last_rssi = s.rssi;
|
||||
d.last_rssi = s.rssi; d._frozen = !!s.frozen;
|
||||
if (s.frozen) { const v = 229, a = s.base, pf = 0.9; Object.assign(d, { v, a, pf, w: +(v * a * pf).toFixed(1), kwh: d._kwh, hz: 50 }); } // одна телеметрия, дальше не обновляется
|
||||
});
|
||||
// Тикер телеметрии активных узлов
|
||||
// Тикер телеметрии активных узлов (frozen — пропускаем: имитация обрыва опроса)
|
||||
setInterval(() => {
|
||||
for (const d of devices.values()) {
|
||||
if (d.status === 'disabled') continue;
|
||||
if (d.status === 'disabled' || d._frozen) continue;
|
||||
const load = d._base ? d._base * (0.7 + Math.random() * 0.5) * (d.relays?.some((r) => r) ? 1 : 0.15) : 0;
|
||||
const v = +(224 + Math.random() * 12).toFixed(1);
|
||||
const a = +(load).toFixed(3);
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useEffect, useRef } from 'react';
|
||||
|
||||
// Визуализатор ретрансляции/маршрутизации LoRa-хопов: узлы-паутинка вокруг шлюза,
|
||||
// нити-связи (прямые и многоскачковые), бегущие по ним пакеты (передвижение по массиву).
|
||||
interface Node { id: string; label: string; status: 'active' | 'pending' | 'disabled'; rssi?: number; transport?: 'lora' | 'lan' }
|
||||
interface Node { id: string; label: string; status: 'active' | 'pending' | 'disabled'; rssi?: number; transport?: 'lora' | 'lan'; lastSeen?: string; w?: number }
|
||||
|
||||
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 COL = { active: [63, 208, 107], pending: [244, 183, 64], disabled: [125, 148, 163], gw: [34, 224, 255], pkt: [124, 220, 255], stale: [239, 68, 68] } as const;
|
||||
const rgba = (c: readonly number[], a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
|
||||
// возраст последнего опроса, сек
|
||||
const ageSec = (iso?: string) => (iso ? Math.max(0, (Date.now() - Date.parse(iso)) / 1000) : Infinity);
|
||||
|
||||
export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes: Node[]; gatewayLabel?: string; className?: string }) {
|
||||
export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', normSec = 15, className }: { nodes: Node[]; gatewayLabel?: string; normSec?: number; className?: string }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const nodesRef = useRef(nodes);
|
||||
nodesRef.current = nodes;
|
||||
@@ -33,7 +35,9 @@ export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes:
|
||||
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 };
|
||||
const age = ageSec(n.lastSeen);
|
||||
const stale = n.status === 'active' && age > normSec; // опрос вне норматива
|
||||
return { n, x: cx + Math.cos(ang) * r, y: cy + Math.sin(ang) * r, far, ang, age, stale };
|
||||
});
|
||||
// связи: дальние — через ближайшего не-дальнего соседа (2 хопа), иначе прямо к шлюзу
|
||||
const relays = pts.filter((p) => !p.far);
|
||||
@@ -58,7 +62,7 @@ export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes:
|
||||
// связи узел → (ретранслятор) → шлюз + бегущие пакеты
|
||||
for (const p of pts) {
|
||||
const target = linkFor(p);
|
||||
const c = COL[p.n.status];
|
||||
const c = p.stale ? COL.stale : 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();
|
||||
@@ -71,22 +75,35 @@ export function LoraMeshViz({ nodes, gatewayLabel = 'gw1', className }: { nodes:
|
||||
// бегущий пакет по маршруту (узел→…→шлюз)
|
||||
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 fp = (((tsec * speed + p.ang) % 1) + 1) % 1; // всегда [0,1) — p.ang бывает <0
|
||||
const seg = fp * (route.length - 1), si = Math.min(route.length - 2, Math.max(0, 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;
|
||||
const c = p.stale ? COL.stale : COL[p.n.status];
|
||||
const pulse = p.n.status === 'active' ? 0.6 + 0.4 * Math.sin(tsec * (p.stale ? 5 : 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(c, p.stale ? 0.22 : 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);
|
||||
// всплывающий бейдж над узлом: возраст опроса + мощность
|
||||
if (p.n.status === 'active') {
|
||||
const bob = Math.sin(tsec * 2 + p.ang) * 2 * dpr;
|
||||
const ageTxt = p.age >= 3600 ? `${Math.round(p.age / 3600)}ч` : p.age >= 60 ? `${Math.round(p.age / 60)}м` : `${Math.round(p.age)}с`;
|
||||
const txt = (p.stale ? '⚠ ' : '') + ageTxt + (p.n.w != null ? ` · ${Math.round(p.n.w)}Вт` : '');
|
||||
ctx.font = `bold ${8.5 * dpr}px Inter, system-ui`;
|
||||
const bw = ctx.measureText(txt).width + 10 * dpr, bh = 13 * dpr, x0 = p.x - bw / 2, y0 = p.y - 15 * dpr + bob, rr = 4 * dpr;
|
||||
ctx.fillStyle = rgba(p.stale ? COL.stale : COL.gw, p.stale ? 0.92 : 0.18);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 + rr, y0); ctx.arcTo(x0 + bw, y0, x0 + bw, y0 + bh, rr); ctx.arcTo(x0 + bw, y0 + bh, x0, y0 + bh, rr); ctx.arcTo(x0, y0 + bh, x0, y0, rr); ctx.arcTo(x0, y0, x0 + bw, y0, rr); ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = p.stale ? '#fff' : rgba(COL.gw, 0.95);
|
||||
ctx.fillText(txt, p.x, y0 + bh - 3.5 * dpr);
|
||||
}
|
||||
}
|
||||
// шлюз в центре
|
||||
const gp = 0.6 + 0.4 * Math.sin(tsec * 1.6);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { RadioTower, Cable, Wifi, Zap, Plus, Router, Waypoints } from 'lucide-react';
|
||||
import { RadioTower, Cable, Wifi, Zap, Plus, Router, Waypoints, Maximize2, Minimize2, Timer } from 'lucide-react';
|
||||
import { apiGet, apiPost } from '@/api/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LoraMeshViz } from './LoraMeshViz';
|
||||
@@ -23,6 +23,9 @@ export function LoraModule() {
|
||||
const [devices, setDevices] = useState<LoraDevice[]>([]);
|
||||
const [gws, setGws] = useState<Gateway[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'egorin' | 'orbit'>('all');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [, setTick] = useState(0); // раз в сек — живой счётчик «последний опрос»
|
||||
useEffect(() => { const t = setInterval(() => setTick((x) => x + 1), 1000); return () => clearInterval(t); }, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -40,6 +43,13 @@ export function LoraModule() {
|
||||
|
||||
const sumKwh = active.reduce((s, d) => s + (d.kwh || 0), 0);
|
||||
const sumW = active.reduce((s, d) => s + (d.w || 0), 0);
|
||||
const NORM_SEC = 15; // норматив свежести опроса
|
||||
const pollAges = active.map((d) => (d.last_seen ? (Date.now() - Date.parse(d.last_seen)) / 1000 : Infinity));
|
||||
const lastPollAge = pollAges.length ? Math.min(...pollAges) : Infinity; // с последнего опроса (мин по узлам)
|
||||
const staleCount = pollAges.filter((a) => a > NORM_SEC).length;
|
||||
const vizNodes = 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, lastSeen: d.last_seen, w: d.w }));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -70,17 +80,34 @@ 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>
|
||||
{(() => {
|
||||
const pollTxt = lastPollAge === Infinity ? '—' : lastPollAge < 60 ? `${Math.round(lastPollAge)} с назад` : `${Math.round(lastPollAge / 60)} мин назад`;
|
||||
const header = (
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-fg-muted"><Waypoints className="h-3.5 w-3.5" /> Маршрутизация LoRa · хопы и ретрансляция</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-semibold', staleCount ? 'bg-crit/20 text-crit' : 'bg-muted text-fg-muted')}>
|
||||
<Timer className="h-3 w-3" /> последний опрос: {pollTxt}{staleCount ? ` · ${staleCount} вне норматива` : ''}
|
||||
</span>
|
||||
<button onClick={() => setExpanded((v) => !v)} title={expanded ? 'Свернуть' : 'Развернуть карту'} className="grid h-7 w-7 place-items-center rounded-lg text-fg-muted hover:bg-muted hover:text-fg">
|
||||
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const viz = <LoraMeshViz gatewayLabel={gws[0]?.gw_id || 'gw1'} normSec={NORM_SEC} nodes={vizNodes} />;
|
||||
return expanded ? (
|
||||
<div className="fixed inset-3 z-[4000] flex flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-card">
|
||||
{header}
|
||||
<div className="min-h-0 flex-1 bg-[#070a10]">{viz}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
{header}
|
||||
<div className="h-[280px] w-full bg-[#070a10]">{viz}</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Обнаружены новые устройства (pending) */}
|
||||
{pending.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user