272 lines
12 KiB
TypeScript
272 lines
12 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
import type { MonitoredEntity } from '@/core/types';
|
|
import { useTheme } from '@/core/theme';
|
|
|
|
// Базовые слои карт (переключатель в углу). 2ГИС и Яндекс — для РФ.
|
|
function baseLayers(): Record<string, L.TileLayer> {
|
|
return {
|
|
'Тёмная': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', { attribution: '© OSM · © CARTO', maxZoom: 19, subdomains: 'abcd' }),
|
|
'Светлая': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', { attribution: '© OSM · © CARTO', maxZoom: 19, subdomains: 'abcd' }),
|
|
'OSM': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap', maxZoom: 19 }),
|
|
'2ГИС': L.tileLayer('https://tile{s}.maps.2gis.com/tiles?x={x}&y={y}&z={z}', { attribution: '© 2ГИС', maxZoom: 18, subdomains: '0123' }),
|
|
'Яндекс': L.tileLayer('https://core-renderer-tiles.maps.yandex.net/tiles?l=map&x={x}&y={y}&z={z}&scale=1&lang=ru_RU', { attribution: '© Яндекс Карты', maxZoom: 19 }),
|
|
};
|
|
}
|
|
|
|
function color(e: MonitoredEntity, colors?: Record<string, string>): string {
|
|
if (colors?.[e.id]) return colors[e.id];
|
|
if (!e.online) return '#64748B';
|
|
if (e.type === 'energy') return '#0EA5E9';
|
|
if (e.type === 'network') return '#A78BFA';
|
|
return '#22C55E';
|
|
}
|
|
|
|
function makeIcon(e: MonitoredEntity, follow: boolean, colors?: Record<string, string>, glyphs?: Record<string, string>) {
|
|
const c = color(e, colors);
|
|
const glyph = glyphs?.[e.id];
|
|
if (glyph) {
|
|
// Маркер с пиктограммой (напр. ГБР ГЛОНАСС-авто на пульте ПЦН)
|
|
const ring = follow ? `box-shadow:0 0 0 4px ${c}66, 0 0 0 8px ${c}22;` : `box-shadow:0 0 0 3px ${c}40;`;
|
|
return L.divIcon({
|
|
className: '',
|
|
html: `<div style="display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:${c};border:2px solid #0F172A;${ring}">${glyph}</div>`,
|
|
iconSize: [26, 26],
|
|
iconAnchor: [13, 13],
|
|
popupAnchor: [0, -14],
|
|
});
|
|
}
|
|
const ring = follow ? `box-shadow:0 0 0 4px ${c}66, 0 0 0 8px ${c}22;` : e.online ? `box-shadow:0 0 0 4px ${c}33;` : '';
|
|
return L.divIcon({
|
|
className: '',
|
|
html: `<div style="width:14px;height:14px;border-radius:50%;background:${c};border:2px solid #0F172A;${ring}"></div>`,
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7],
|
|
popupAnchor: [0, -10],
|
|
});
|
|
}
|
|
|
|
export function MapView({
|
|
entities,
|
|
selectedId,
|
|
followId,
|
|
onSelect,
|
|
className,
|
|
tracks,
|
|
trackIds,
|
|
trackColors,
|
|
links,
|
|
colors,
|
|
glyphs,
|
|
cursor,
|
|
circles,
|
|
events,
|
|
}: {
|
|
entities: MonitoredEntity[];
|
|
selectedId: string;
|
|
followId: string | null;
|
|
onSelect: (id: string) => void;
|
|
className?: string;
|
|
tracks?: Record<string, Array<[number, number]>>;
|
|
trackIds?: string[];
|
|
trackColors?: Record<string, string>;
|
|
links?: Array<{ id: string; from: [number, number]; to: [number, number]; style: 'solid' | 'dash' | 'flow'; color: string }>;
|
|
colors?: Record<string, string>; // переопределение цвета маркера по id объекта (ПЦН: тревога/охрана)
|
|
glyphs?: Record<string, string>; // HTML-пиктограмма внутри маркера по id (ПЦН: ГБР ГЛОНАСС-авто)
|
|
cursor?: { lat: number; lon: number; label?: string } | null; // курсор плеера трека
|
|
circles?: Array<{ id: string; lat: number; lon: number; radius: number; color: string; name?: string }>; // геозоны
|
|
events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>; // дорожные события
|
|
}) {
|
|
const theme = useTheme((s) => s.theme);
|
|
const elRef = useRef<HTMLDivElement | null>(null);
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
const markersRef = useRef<Record<string, L.Marker>>({});
|
|
const linesRef = useRef<Record<string, L.Polyline>>({});
|
|
const linksRef = useRef<Record<string, L.Polyline>>({});
|
|
const cursorRef = useRef<L.Marker | null>(null);
|
|
const circlesRef = useRef<Record<string, L.Circle>>({});
|
|
const eventsRef = useRef<L.Marker[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!elRef.current || mapRef.current) return;
|
|
const map = L.map(elRef.current, { center: [44.05, 43.06], zoom: 11, zoomControl: true });
|
|
const bases = baseLayers();
|
|
// По умолчанию — 2ГИС на всех разделах (переключение слоёв остаётся в контроле)
|
|
(bases['2ГИС'] ?? bases[theme === 'dark' ? 'Тёмная' : 'Светлая']).addTo(map);
|
|
L.control.layers(bases, undefined, { position: 'topright', collapsed: true }).addTo(map);
|
|
mapRef.current = map;
|
|
|
|
const pts = entities.filter((e) => e.lat != null && e.lon != null);
|
|
pts.forEach((e) => {
|
|
const m = L.marker([e.lat!, e.lon!], { icon: makeIcon(e, false, colors, glyphs) }).addTo(map);
|
|
m.bindPopup(`<div style="font-weight:600">${e.name}</div><div style="opacity:.7;font-size:12px">${e.online ? 'на связи' : 'нет связи'}</div>`, { maxWidth: 220 });
|
|
m.on('click', () => onSelect(e.id));
|
|
markersRef.current[e.id] = m;
|
|
});
|
|
if (pts.length) map.fitBounds(L.latLngBounds(pts.map((e) => [e.lat!, e.lon!] as [number, number])).pad(0.4));
|
|
const t = setTimeout(() => map.invalidateSize(), 200);
|
|
return () => {
|
|
clearTimeout(t);
|
|
map.remove();
|
|
mapRef.current = null;
|
|
markersRef.current = {};
|
|
linesRef.current = {};
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
entities.forEach((e) => {
|
|
const m = markersRef.current[e.id];
|
|
if (m && e.lat != null && e.lon != null) {
|
|
m.setLatLng([e.lat, e.lon]);
|
|
m.setIcon(makeIcon(e, followId === e.id, colors, glyphs));
|
|
}
|
|
});
|
|
const map = mapRef.current;
|
|
if (map && followId) {
|
|
const f = entities.find((x) => x.id === followId);
|
|
if (f && f.lat != null && f.lon != null) map.panTo([f.lat, f.lon], { animate: true, duration: 0.5 });
|
|
}
|
|
}, [entities, followId, colors, glyphs]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map || !followId) return;
|
|
const f = entities.find((x) => x.id === followId);
|
|
if (f && f.lat != null && f.lon != null) map.setView([f.lat, f.lon], Math.max(map.getZoom(), 15), { animate: true });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [followId]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
const e = entities.find((x) => x.id === selectedId);
|
|
if (!map || !e || e.lat == null || e.lon == null || followId === selectedId) return;
|
|
map.flyTo([e.lat, e.lon], 15, { duration: 0.6 });
|
|
markersRef.current[e.id]?.openPopup();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedId]);
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
const ids = (trackIds || []).filter(Boolean);
|
|
Object.keys(linesRef.current).forEach((id) => {
|
|
if (!ids.includes(id)) {
|
|
map.removeLayer(linesRef.current[id]);
|
|
delete linesRef.current[id];
|
|
}
|
|
});
|
|
ids.forEach((id) => {
|
|
const pts = (tracks || {})[id];
|
|
if (!pts || pts.length < 2) return;
|
|
const baseId = id.startsWith('trip-') ? id.slice(5) : id;
|
|
const c = trackColors?.[id] ?? trackColors?.[baseId] ?? (followId === id ? '#22C55E' : '#0EA5E9');
|
|
if (linesRef.current[id]) linesRef.current[id].setLatLngs(pts).setStyle({ color: c });
|
|
else linesRef.current[id] = L.polyline(pts, { color: c, weight: 3, opacity: 0.85 }).addTo(map);
|
|
});
|
|
}, [tracks, trackIds, followId, trackColors]);
|
|
|
|
// Связи между узлами (визуализация связанности): сплошная / пунктир / анимированный пунктир
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
const cur = links || [];
|
|
const ids = new Set(cur.map((l) => l.id));
|
|
Object.keys(linksRef.current).forEach((id) => {
|
|
if (!ids.has(id)) {
|
|
map.removeLayer(linksRef.current[id]);
|
|
delete linksRef.current[id];
|
|
}
|
|
});
|
|
cur.forEach((l) => {
|
|
if (linksRef.current[l.id]) map.removeLayer(linksRef.current[l.id]);
|
|
linksRef.current[l.id] = L.polyline([l.from, l.to], {
|
|
color: l.color,
|
|
weight: l.style === 'solid' ? 3.5 : 2.5,
|
|
opacity: 0.9,
|
|
dashArray: l.style === 'solid' ? undefined : '8 10',
|
|
className: l.style === 'flow' ? 'link-flow' : '',
|
|
}).addTo(map);
|
|
});
|
|
}, [links]);
|
|
|
|
// Курсор плеера трека — отдельный маркер, движется по маршруту при воспроизведении
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
if (!cursor) {
|
|
if (cursorRef.current) {
|
|
map.removeLayer(cursorRef.current);
|
|
cursorRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
const icon = L.divIcon({
|
|
className: '',
|
|
html: `<div style="width:18px;height:18px;border-radius:50%;background:#22C55E;border:3px solid #fff;box-shadow:0 0 0 4px #22C55E55,0 1px 4px rgba(0,0,0,.5)"></div>`,
|
|
iconSize: [18, 18],
|
|
iconAnchor: [9, 9],
|
|
});
|
|
if (!cursorRef.current) {
|
|
cursorRef.current = L.marker([cursor.lat, cursor.lon], { icon, zIndexOffset: 1000 }).addTo(map);
|
|
} else {
|
|
cursorRef.current.setLatLng([cursor.lat, cursor.lon]);
|
|
}
|
|
if (cursor.label) cursorRef.current.bindTooltip(cursor.label, { permanent: false, direction: 'top' });
|
|
}, [cursor]);
|
|
|
|
// Геозоны (круги): рисуем/обновляем/удаляем
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
const cur = circles || [];
|
|
const ids = new Set(cur.map((c) => c.id));
|
|
Object.keys(circlesRef.current).forEach((id) => {
|
|
if (!ids.has(id)) {
|
|
map.removeLayer(circlesRef.current[id]);
|
|
delete circlesRef.current[id];
|
|
}
|
|
});
|
|
cur.forEach((c) => {
|
|
const existing = circlesRef.current[c.id];
|
|
if (existing) {
|
|
existing.setLatLng([c.lat, c.lon]).setRadius(c.radius).setStyle({ color: c.color, fillColor: c.color });
|
|
} else {
|
|
const circle = L.circle([c.lat, c.lon], { radius: c.radius, color: c.color, fillColor: c.color, fillOpacity: 0.12, weight: 2 }).addTo(map);
|
|
if (c.name) circle.bindTooltip(c.name, { permanent: false, direction: 'center' });
|
|
circlesRef.current[c.id] = circle;
|
|
}
|
|
});
|
|
}, [circles]);
|
|
|
|
// Дорожные события на треке (старт/стоп/стоянка/превышение)
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
eventsRef.current.forEach((m) => map.removeLayer(m));
|
|
eventsRef.current = [];
|
|
(events || []).forEach((e) => {
|
|
const icon = L.divIcon({
|
|
className: '',
|
|
html: `<div style="display:grid;place-items:center;width:18px;height:18px;border-radius:50%;background:${e.color};border:2px solid #0F172A;color:#fff;font:700 11px system-ui;box-shadow:0 1px 3px rgba(0,0,0,.5)">${e.char}</div>`,
|
|
iconSize: [18, 18],
|
|
iconAnchor: [9, 9],
|
|
popupAnchor: [0, -12],
|
|
});
|
|
const m = L.marker([e.lat, e.lon], { icon, zIndexOffset: 500 }).addTo(map);
|
|
m.bindPopup(`<div style="font-weight:600">${e.label}</div>${e.sub ? `<div style="opacity:.7;font-size:12px">${e.sub}</div>` : ''}`, { maxWidth: 220 });
|
|
eventsRef.current.push(m);
|
|
});
|
|
}, [events]);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => mapRef.current?.invalidateSize();
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, []);
|
|
|
|
return <div ref={elRef} className={className} />;
|
|
}
|