egorin: макет горэлектросетей — ТП + автопарк бригад (автовышка) на карте, рация с доской объявлений (мигание+чекбоксы) и списком чтения, 2ГИС по умолчанию
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Radio, X, Volume2, VolumeX, Play } from 'lucide-react';
|
import { Radio, X, Volume2, VolumeX, Play, Megaphone, ListChecks, Trash2, CheckSquare, Square, Volume1 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useRadio, speak, beep } from '@/core/radio';
|
import { useRadio, speak, beep } from '@/core/radio';
|
||||||
|
|
||||||
@@ -8,9 +8,11 @@ const DOT: Record<string, string> = { critical: 'bg-crit', warning: 'bg-warn', i
|
|||||||
|
|
||||||
// Мини-окно «Рация»: включение/отключение оповещения + эфир. Открывается кнопкой в шапке.
|
// Мини-окно «Рация»: включение/отключение оповещения + эфир. Открывается кнопкой в шапке.
|
||||||
export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () => void }) {
|
export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const { on, ether, toggle } = useRadio();
|
const { on, ether, toggle, board, readList, toggleTask, removeBoard, enqueueRead, removeRead, clearRead, readAll } = useRadio();
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const openTasks = board.filter((b) => b.kind === 'task' && !b.done).length;
|
||||||
|
|
||||||
const test = () => {
|
const test = () => {
|
||||||
beep(on ? 880 : 440, 0.14, 0.25);
|
beep(on ? 880 : 440, 0.14, 0.25);
|
||||||
if (on) speak('Проверка связи. Рация в эфире.');
|
if (on) speak('Проверка связи. Рация в эфире.');
|
||||||
@@ -64,6 +66,57 @@ export function RadioMiniWindow({ open, onClose }: { open: boolean; onClose: ()
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Доска объявлений */}
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-fg-muted"><Megaphone className="h-3 w-3" /> Доска объявлений</span>
|
||||||
|
{openTasks > 0 && <span className="rounded-full bg-warn/20 px-1.5 text-[10px] font-semibold text-warn">{openTasks} задач</span>}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-52 space-y-1 overflow-y-auto px-2 pb-2">
|
||||||
|
{board.map((b) => (
|
||||||
|
<div key={b.id} className={cn('group flex items-start gap-2 rounded-lg px-2 py-1.5', b.urgent && !b.done ? 'sm-blink bg-crit/10' : 'hover:bg-muted/50')}>
|
||||||
|
{b.kind === 'task' ? (
|
||||||
|
<button onClick={() => toggleTask(b.id)} title={b.done ? 'Открыть задачу' : 'Закрыть задачу'} className={cn('mt-0.5 shrink-0', b.done ? 'text-ok' : 'text-fg-muted hover:text-fg')}>
|
||||||
|
{b.done ? <CheckSquare className="h-4 w-4" /> : <Square className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={cn('mt-1 h-2 w-2 shrink-0 rounded-full', b.urgent ? 'bg-crit' : 'bg-info')} />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn('text-[11px] leading-snug', b.done ? 'text-fg-muted line-through' : 'text-fg')}>{b.text}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button onClick={() => enqueueRead(b.text)} title="В список чтения (озвучить)" className="text-fg-muted hover:text-accent"><Volume1 className="h-3.5 w-3.5" /></button>
|
||||||
|
<button onClick={() => removeBoard(b.id)} title="Удалить" className="text-fg-muted hover:text-crit"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список чтения — голосовые уведомления */}
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<div className="flex items-center justify-between px-3 py-1.5">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-fg-muted"><ListChecks className="h-3 w-3" /> Список чтения · {readList.length}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={readAll} disabled={readList.length === 0} className="flex items-center gap-1 rounded-md bg-accent/15 px-2 py-0.5 text-[10px] font-semibold text-accent hover:bg-accent/25 disabled:opacity-40">
|
||||||
|
<Volume2 className="h-3 w-3" /> Читать
|
||||||
|
</button>
|
||||||
|
<button onClick={clearRead} disabled={readList.length === 0} title="Очистить" className="text-fg-muted hover:text-crit disabled:opacity-40"><Trash2 className="h-3.5 w-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-36 space-y-0.5 overflow-y-auto px-2 pb-2">
|
||||||
|
{readList.length === 0 && <div className="px-2 py-3 text-center text-[11px] text-fg-muted">Список пуст. Добавьте объявление кнопкой 🔈.</div>}
|
||||||
|
{readList.map((t, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 rounded-lg px-2 py-1 hover:bg-muted/50">
|
||||||
|
<button onClick={() => speak(t)} title="Прочитать" className="mt-0.5 shrink-0 text-fg-muted hover:text-accent"><Play className="h-3.5 w-3.5" /></button>
|
||||||
|
<div className="min-w-0 flex-1 text-[11px] leading-snug text-fg-muted">{t}</div>
|
||||||
|
<button onClick={() => removeRead(i)} title="Убрать" className="shrink-0 text-fg-muted hover:text-crit"><X className="h-3.5 w-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@ export const USERS: User[] = [
|
|||||||
id: 'energetik-ro',
|
id: 'energetik-ro',
|
||||||
name: 'Сидоров А.',
|
name: 'Сидоров А.',
|
||||||
role: 'Энергетик · часть, только просмотр',
|
role: 'Энергетик · часть, только просмотр',
|
||||||
access: { energy: { caps: ['view'], objects: ['cab-07'] } },
|
access: { energy: { caps: ['view'], objects: ['tp-07'] } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'energetik-rw',
|
id: 'energetik-rw',
|
||||||
|
|||||||
@@ -68,11 +68,51 @@ export function radioReport(ev: MonitorEvent) {
|
|||||||
setTimeout(() => speak(`${prefix}${ev.callsign}. ${ev.text}`), ev.severity === 'critical' ? 500 : 200);
|
setTimeout(() => speak(`${prefix}${ev.callsign}. ${ev.text}`), ev.severity === 'critical' ? 500 : 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Доска объявлений: объявления и задачи бригад. Задачи закрываются чекбоксом (зачёркивание).
|
||||||
|
export interface BoardItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
kind: 'announce' | 'task';
|
||||||
|
urgent?: boolean; // мигающее уведомление
|
||||||
|
done?: boolean; // задача закрыта → текст перечёркнут
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowISO = () => new Date().toISOString();
|
||||||
|
const uid = () => 'b' + Math.random().toString(36).slice(2, 9);
|
||||||
|
|
||||||
|
const BOARD_SEED: BoardItem[] = [
|
||||||
|
{ id: 'brd-1', text: 'АВАРИЯ: обесточен ТП-14 (мкр. Бештау). Бригада ВЛ-1 в пути.', kind: 'announce', urgent: true, ts: nowISO() },
|
||||||
|
{ id: 'brd-2', text: 'Плановое отключение ТП-7 сегодня 14:00–16:00 (замена трансформатора).', kind: 'announce', ts: nowISO() },
|
||||||
|
{ id: 'brd-3', text: 'Заменить изолятор на опоре №42, ВЛ-10кВ «Центр».', kind: 'task', ts: nowISO() },
|
||||||
|
{ id: 'brd-4', text: 'Автовышке АГП-22: обрезка веток у ЛЭП, ул. Лесная.', kind: 'task', urgent: true, ts: nowISO() },
|
||||||
|
{ id: 'brd-5', text: 'Снять показания счётчиков ТП-3, ТП-9.', kind: 'task', done: true, ts: nowISO() },
|
||||||
|
];
|
||||||
|
const READ_SEED: string[] = [
|
||||||
|
'Внимание бригадам. На ТП-14 пропало сетевое питание, потребители переведены на резерв.',
|
||||||
|
'Диспетчер: автовышке АГП-22 подтвердить прибытие на улицу Лесная.',
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadBoard(): BoardItem[] {
|
||||||
|
try { const j = localStorage.getItem('sm-radio-board'); if (j) return JSON.parse(j); } catch { /* ignore */ }
|
||||||
|
return BOARD_SEED;
|
||||||
|
}
|
||||||
|
function saveBoard(v: BoardItem[]) { try { localStorage.setItem('sm-radio-board', JSON.stringify(v)); } catch { /* ignore */ } }
|
||||||
|
|
||||||
interface RadioState {
|
interface RadioState {
|
||||||
on: boolean;
|
on: boolean;
|
||||||
ether: MonitorEvent[]; // лента «эфир»
|
ether: MonitorEvent[]; // лента «эфир»
|
||||||
|
board: BoardItem[]; // доска объявлений
|
||||||
|
readList: string[]; // список чтения (очередь голосовых уведомлений)
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
push: (ev: MonitorEvent) => void;
|
push: (ev: MonitorEvent) => void;
|
||||||
|
toggleTask: (id: string) => void; // закрыть/открыть задачу (зачёркивание)
|
||||||
|
addBoard: (text: string, kind?: BoardItem['kind'], urgent?: boolean) => void;
|
||||||
|
removeBoard: (id: string) => void;
|
||||||
|
enqueueRead: (text: string) => void; // добавить голосовое уведомление в список чтения
|
||||||
|
removeRead: (i: number) => void;
|
||||||
|
clearRead: () => void;
|
||||||
|
readAll: () => void; // озвучить весь список чтения по очереди
|
||||||
}
|
}
|
||||||
|
|
||||||
const initOn = (() => {
|
const initOn = (() => {
|
||||||
@@ -86,6 +126,34 @@ const initOn = (() => {
|
|||||||
export const useRadio = create<RadioState>((set, get) => ({
|
export const useRadio = create<RadioState>((set, get) => ({
|
||||||
on: initOn,
|
on: initOn,
|
||||||
ether: [],
|
ether: [],
|
||||||
|
board: loadBoard(),
|
||||||
|
readList: READ_SEED,
|
||||||
|
toggleTask: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const board = s.board.map((b) => (b.id === id ? { ...b, done: !b.done } : b));
|
||||||
|
saveBoard(board);
|
||||||
|
return { board };
|
||||||
|
}),
|
||||||
|
addBoard: (text, kind = 'announce', urgent = false) =>
|
||||||
|
set((s) => {
|
||||||
|
const board = [{ id: uid(), text, kind, urgent, ts: nowISO() }, ...s.board].slice(0, 60);
|
||||||
|
saveBoard(board);
|
||||||
|
return { board };
|
||||||
|
}),
|
||||||
|
removeBoard: (id) =>
|
||||||
|
set((s) => {
|
||||||
|
const board = s.board.filter((b) => b.id !== id);
|
||||||
|
saveBoard(board);
|
||||||
|
return { board };
|
||||||
|
}),
|
||||||
|
enqueueRead: (text) => set((s) => ({ readList: [...s.readList, text].slice(0, 40) })),
|
||||||
|
removeRead: (i) => set((s) => ({ readList: s.readList.filter((_, idx) => idx !== i) })),
|
||||||
|
clearRead: () => set({ readList: [] }),
|
||||||
|
readAll: () => {
|
||||||
|
ctx(); // прайм аудио на жесте
|
||||||
|
const list = get().readList;
|
||||||
|
list.forEach((t, i) => setTimeout(() => speak(t), i * 200)); // speechSynthesis сам ставит в очередь
|
||||||
|
},
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
const on = !get().on;
|
const on = !get().on;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+48
-38
@@ -63,70 +63,80 @@ export const wbChannels: WbChannel[] = [
|
|||||||
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false },
|
{ id: 'mr6c_214/IN1', device: 'WB-MR6C #214', name: 'Датчик двери', kind: 'input', on: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
// --- Transport / GPS: объекты Navtelecom (FLEX/NTCB) ---
|
// --- Городские электросети: бригадный автопарк (GPS) + трансформаторные подстанции (энерго) ---
|
||||||
|
const iso = (minAgo = 0) => new Date(Date.now() - minAgo * 60000).toISOString();
|
||||||
export const entities: MonitoredEntity[] = [
|
export const entities: MonitoredEntity[] = [
|
||||||
|
// Бригадный автопарк — 5 машин (включая автовышку)
|
||||||
{
|
{
|
||||||
id: 'veh-123',
|
id: 'veh-agp22', clientId: 'tempelhoff', name: 'Автовышка АГП-22', type: 'transport', online: true,
|
||||||
clientId: 'tempelhoff',
|
telemetry: { speed: 28, sat: 12, fuel: 64, voltage: 13.9 }, meta: { boom: 'поднята' }, lat: 44.046, lon: 43.062, updatedAt: iso(0),
|
||||||
name: 'ГАЗель-123',
|
|
||||||
type: 'transport',
|
|
||||||
online: true,
|
|
||||||
telemetry: { speed: 64, sat: 11, fuel: 58, voltage: 13.8 },
|
|
||||||
lat: 44.05,
|
|
||||||
lon: 43.06,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'veh-204',
|
id: 'veh-vl1', clientId: 'tempelhoff', name: 'Бригада ВЛ-1 (ГАЗель)', type: 'transport', online: true,
|
||||||
clientId: 'tempelhoff',
|
telemetry: { speed: 0, sat: 10, fuel: 47, voltage: 12.8 }, lat: 44.081, lon: 43.001, updatedAt: iso(1),
|
||||||
name: 'МТЗ-82 трактор',
|
|
||||||
type: 'transport',
|
|
||||||
online: true,
|
|
||||||
telemetry: { speed: 0, sat: 9, fuel: 31, voltage: 12.9 },
|
|
||||||
lat: 44.02,
|
|
||||||
lon: 43.11,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cab-07',
|
id: 'veh-avar', clientId: 'tempelhoff', name: 'Аварийная бригада (УАЗ)', type: 'transport', online: true,
|
||||||
clientId: 'tempelhoff',
|
telemetry: { speed: 54, sat: 11, fuel: 38, voltage: 13.7 }, lat: 44.058, lon: 43.041, updatedAt: iso(0),
|
||||||
name: 'Щит КТП-7',
|
},
|
||||||
type: 'energy',
|
{
|
||||||
online: false,
|
id: 'veh-kabel', clientId: 'tempelhoff', name: 'Кабельщики (ГАЗ-3309)', type: 'transport', online: true,
|
||||||
telemetry: { pwr_ext: 0, pwr_int: 3.9, temp: 24 },
|
telemetry: { speed: 12, sat: 9, fuel: 57, voltage: 13.6 }, lat: 44.033, lon: 43.089, updatedAt: iso(0),
|
||||||
lat: 44.08,
|
},
|
||||||
lon: 43.0,
|
{
|
||||||
updatedAt: new Date(Date.now() - 9 * 60000).toISOString(),
|
id: 'veh-master', clientId: 'tempelhoff', name: 'Мастер участка (Нива)', type: 'transport', online: true,
|
||||||
|
telemetry: { speed: 41, sat: 12, fuel: 72, voltage: 13.8 }, lat: 44.069, lon: 43.073, updatedAt: iso(0),
|
||||||
|
},
|
||||||
|
// Трансформаторные подстанции — узлы энергомониторинга
|
||||||
|
{
|
||||||
|
id: 'tp-14', clientId: 'tempelhoff', name: 'ТП-14 «Бештау»', type: 'energy', online: false,
|
||||||
|
telemetry: { load: 0, u: 0, i: 0, temp: 23 }, meta: { feeder: 'ВЛ-10 «Север»' }, lat: 44.082, lon: 43.002, updatedAt: iso(9),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tp-07', clientId: 'tempelhoff', name: 'ТП-7 «Центр»', type: 'energy', online: true,
|
||||||
|
telemetry: { load: 184, u: 402, i: 265, temp: 41 }, meta: { feeder: 'ВЛ-10 «Центр»' }, lat: 44.048, lon: 43.058, updatedAt: iso(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tp-03', clientId: 'tempelhoff', name: 'ТП-3 «Западный»', type: 'energy', online: true,
|
||||||
|
telemetry: { load: 96, u: 398, i: 139, temp: 33 }, meta: { feeder: 'ВЛ-10 «Запад»' }, lat: 44.041, lon: 43.02, updatedAt: iso(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tp-09', clientId: 'tempelhoff', name: 'ТП-9 «Южный»', type: 'energy', online: true,
|
||||||
|
telemetry: { load: 142, u: 401, i: 205, temp: 37 }, meta: { feeder: 'ВЛ-10 «Юг»' }, lat: 44.018, lon: 43.07, updatedAt: iso(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rp-02', clientId: 'tempelhoff', name: 'РП-2 (распределительный пункт)', type: 'energy', online: true,
|
||||||
|
telemetry: { load: 310, u: 6300, i: 29, temp: 29 }, meta: { feeder: 'ПС 110/10 «Город»' }, lat: 44.062, lon: 43.045, updatedAt: iso(0),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const events: MonitorEvent[] = [
|
export const events: MonitorEvent[] = [
|
||||||
{
|
{
|
||||||
id: 'e1',
|
id: 'e1',
|
||||||
entityId: 'cab-07',
|
entityId: 'tp-14',
|
||||||
callsign: 'Щит КТП-7',
|
callsign: 'ТП-14 «Бештау»',
|
||||||
severity: 'critical',
|
severity: 'critical',
|
||||||
type: 'power_loss',
|
type: 'power_loss',
|
||||||
text: 'Пропадание сетевого питания, переход на ИБП.',
|
text: 'Пропадание питания на ВЛ-10 «Север». Потребители обесточены, выехала ВЛ-1.',
|
||||||
ts: new Date(Date.now() - 9 * 60000).toISOString(),
|
ts: new Date(Date.now() - 9 * 60000).toISOString(),
|
||||||
ackRequired: true,
|
ackRequired: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'e2',
|
id: 'e2',
|
||||||
entityId: 'veh-204',
|
entityId: 'tp-07',
|
||||||
callsign: 'МТЗ-82 трактор',
|
callsign: 'ТП-7 «Центр»',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
type: 'fuel_low',
|
type: 'overload',
|
||||||
text: 'Низкий уровень топлива (31%).',
|
text: 'Перегрев трансформатора 41 °C при нагрузке 184 кВт.',
|
||||||
ts: new Date(Date.now() - 42 * 60000).toISOString(),
|
ts: new Date(Date.now() - 42 * 60000).toISOString(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'e3',
|
id: 'e3',
|
||||||
entityId: 'veh-123',
|
entityId: 'veh-agp22',
|
||||||
callsign: 'ГАЗель-123',
|
callsign: 'Автовышка АГП-22',
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
type: 'geofence_in',
|
type: 'geofence_in',
|
||||||
text: 'Вход в геозону «База».',
|
text: 'Прибытие на объект: ул. Лесная (обрезка веток у ЛЭП).',
|
||||||
ts: new Date(Date.now() - 78 * 60000).toISOString(),
|
ts: new Date(Date.now() - 78 * 60000).toISOString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+5
-5
@@ -16,11 +16,11 @@ export interface Trip {
|
|||||||
const h = (n: number) => new Date(Date.now() - n * 3600000).toISOString();
|
const h = (n: number) => new Date(Date.now() - n * 3600000).toISOString();
|
||||||
|
|
||||||
export const trips: Trip[] = [
|
export const trips: Trip[] = [
|
||||||
{ id: 't1', entityId: 'veh-123', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] },
|
{ id: 't1', entityId: 'veh-avar', start: h(6), end: h(5.2), from: 'База «Тэмпельхоф»', to: 'Пятигорск, рынок', distanceKm: 18.4, durationMin: 48, maxSpeed: 78, avgSpeed: 41, route: [[44.07, 43.05], [44.061, 43.058], [44.052, 43.064], [44.043, 43.071]] },
|
||||||
{ id: 't2', entityId: 'veh-123', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] },
|
{ id: 't2', entityId: 'veh-avar', start: h(4.5), end: h(3.7), from: 'Пятигорск, рынок', to: 'Ессентуки, склад', distanceKm: 22.1, durationMin: 49, maxSpeed: 84, avgSpeed: 38, route: [[44.043, 43.071], [44.05, 43.0], [44.05, 42.93], [44.045, 42.86]] },
|
||||||
{ id: 't3', entityId: 'veh-123', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] },
|
{ id: 't3', entityId: 'veh-avar', start: h(2), end: h(1.4), from: 'Ессентуки, склад', to: 'База «Тэмпельхоф»', distanceKm: 31.6, durationMin: 37, maxSpeed: 91, avgSpeed: 51, route: [[44.045, 42.86], [44.052, 42.95], [44.062, 43.0], [44.07, 43.05]] },
|
||||||
{ id: 't4', entityId: 'veh-204', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] },
|
{ id: 't4', entityId: 'veh-agp22', start: h(8), end: h(6.5), from: 'Поле №3', to: 'МТМ (ремонт)', distanceKm: 6.2, durationMin: 90, maxSpeed: 24, avgSpeed: 9, route: [[44.02, 43.11], [44.025, 43.1], [44.03, 43.095]] },
|
||||||
{ id: 't5', entityId: 'veh-204', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] },
|
{ id: 't5', entityId: 'veh-agp22', start: h(3), end: h(2.2), from: 'МТМ', to: 'Поле №5', distanceKm: 8.8, durationMin: 48, maxSpeed: 28, avgSpeed: 12, route: [[44.03, 43.095], [44.038, 43.085], [44.045, 43.08]] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function tripsOf(entityId: string): Trip[] {
|
export function tripsOf(entityId: string): Trip[] {
|
||||||
|
|||||||
@@ -61,6 +61,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Мигающее уведомление на доске объявлений */
|
||||||
|
.sm-blink {
|
||||||
|
animation: smblink 1.05s steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
@keyframes smblink {
|
||||||
|
0%, 55% {
|
||||||
|
box-shadow: inset 3px 0 0 0 rgb(var(--crit, 239 68 68));
|
||||||
|
background-color: rgba(239, 68, 68, 0.14);
|
||||||
|
}
|
||||||
|
56%, 100% {
|
||||||
|
box-shadow: inset 3px 0 0 0 transparent;
|
||||||
|
background-color: rgba(239, 68, 68, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.001ms !important;
|
animation-duration: 0.001ms !important;
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export function MapView({
|
|||||||
if (!elRef.current || mapRef.current) return;
|
if (!elRef.current || mapRef.current) return;
|
||||||
const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true });
|
const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true });
|
||||||
const bases = baseLayers();
|
const bases = baseLayers();
|
||||||
bases[theme === 'dark' ? 'Тёмная' : 'Светлая'].addTo(map);
|
// По умолчанию — 2ГИС на всех разделах (переключение слоёв остаётся в контроле)
|
||||||
|
(bases['2ГИС'] ?? bases[theme === 'dark' ? 'Тёмная' : 'Светлая']).addTo(map);
|
||||||
L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map);
|
L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map);
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,29 @@ interface Props {
|
|||||||
events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>;
|
events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STYLE = (dark: boolean) =>
|
// По умолчанию — 2ГИС (растровый источник) на всех разделах. dark/light не влияет на тайлы 2ГИС.
|
||||||
`https://basemaps.cartocdn.com/gl/${dark ? 'dark-matter' : 'positron'}-gl-style/style.json`;
|
const STYLE = (_dark: boolean): any => ({
|
||||||
|
version: 8,
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||||
|
sources: {
|
||||||
|
'2gis': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
'https://tile0.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||||
|
'https://tile1.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||||
|
'https://tile2.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||||
|
'https://tile3.maps.2gis.com/tiles?x={x}&y={y}&z={z}',
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© 2ГИС',
|
||||||
|
maxzoom: 18,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{ id: 'bg', type: 'background', paint: { 'background-color': '#0b0f14' } },
|
||||||
|
{ id: '2gis', type: 'raster', source: '2gis' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
function objColor(e: MonitoredEntity, colors?: Record<string, string>): string {
|
function objColor(e: MonitoredEntity, colors?: Record<string, string>): string {
|
||||||
if (colors?.[e.id]) return colors[e.id];
|
if (colors?.[e.id]) return colors[e.id];
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function useSmartData(user: User): { channels: WbChannel[]; events: Monit
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: 'door-' + new Date().toISOString(),
|
id: 'door-' + new Date().toISOString(),
|
||||||
entityId: 'cab-07',
|
entityId: 'tp-07',
|
||||||
callsign: 'Датчик двери · WB-MR6C #214',
|
callsign: 'Датчик двери · WB-MR6C #214',
|
||||||
severity: (open ? 'warning' : 'info') as MonitorEvent['severity'],
|
severity: (open ? 'warning' : 'info') as MonitorEvent['severity'],
|
||||||
type: open ? 'door_open' : 'door_closed',
|
type: open ? 'door_open' : 'door_closed',
|
||||||
|
|||||||
Reference in New Issue
Block a user