Files
egorin/src/modules/gps/MapView.tsx
T

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} />;
}