egorin LoRa-карта: всплывающие бейджи-символы над узлами (счётчик опроса+мощность), красный при опросе вне норматива, счётчик последнего опроса, разворот карты на пол-экрана; фикс fp<0 в LoraMeshViz (убивал RAF); frozen-датчик в демо

This commit is contained in:
german
2026-07-12 13:11:48 +03:00
parent d4c05152c5
commit 2f339803b4
3 changed files with 72 additions and 26 deletions
+5 -3
View File
@@ -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);
+28 -11
View File
@@ -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);
+39 -12
View File
@@ -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 && (