diff --git a/docs/map-gl-benzrf.md b/docs/map-gl-benzrf.md new file mode 100644 index 0000000..27eb5ef --- /dev/null +++ b/docs/map-gl-benzrf.md @@ -0,0 +1,21 @@ +# Карта: GL-рендер по референсу benzrf.ru (кластеризация + donut + 3D) + +Портирован рендер-подход benzrf.ru из прототипа `prototypes/orbit-gps-mobile.html` в React. + +## Модули +- **`src/modules/gps/MapClusterGL.tsx`** — карта MapLibre GL: GeoJSON-источник с + `cluster:true` + `clusterProperties` (статусы), **donut-кластеры** (SVG), пины со + статус-цветом, экспандинг кластера по клику, подложка **2ГИС** + OpenFreeMap для 3D, + **3D-здания** (fill-extrusion). +- **`src/modules/gps/MapLabModule.tsx`** — демо-раздел **«Карта-лаборатория (GL)»** + (маршрут `/map-lab`): карта + тумблер 3D + легенда статусов, данные из `useGpsData`. + +## Статусы → цвет +moving `#3fd06b` · parked/норма `#f4b740` · offline `#7d94a3` · alarm (ТП) `#ef4444`. + +## Смысл +Основная карта = Leaflet (DOM-маркеры). GL-рендер (GPU + кластеризация) — плавно при +сотнях объектов и длинных треках. Демо-раздел показывает подход, не трогая рабочий +GPS-раздел. Дальнейшее: треки на line-слои с градиентом, полная миграция. + +Прод: `https://egorin.r.servaki.online/map-lab`. diff --git a/src/config/modules.ts b/src/config/modules.ts index 71ce5a4..b88c056 100644 --- a/src/config/modules.ts +++ b/src/config/modules.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react'; -import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive } from 'lucide-react'; +import { Zap, Cpu, Navigation, Network, ShieldCheck, Siren, Smartphone, FolderGit2, Gauge, Wallet, BellRing, MapPinned, History, MapPin, Building2, Archive, Boxes } from 'lucide-react'; import { EnergyModule } from '@/modules/energy/EnergyModule'; import { SmartModule } from '@/modules/smart/SmartModule'; import { LocationsModule } from '@/modules/smart/LocationsModule'; @@ -7,6 +7,7 @@ import { SecurityModule } from '@/modules/security/SecurityModule'; import { ObjectsModule } from '@/modules/security/ObjectsModule'; import { ArchiveModule } from '@/modules/security/ArchiveModule'; import { GpsModule } from '@/modules/gps/GpsModule'; +import { MapLabModule } from '@/modules/gps/MapLabModule'; import { GeoModule } from '@/modules/geo/GeoModule'; import { EventsModule } from '@/modules/gps/EventsModule'; import { NetworkModule } from '@/modules/network/NetworkModule'; @@ -38,6 +39,7 @@ export const MODULES: ModuleDef[] = [ { id: 'security-objects', path: '/security-objects', title: 'Охраняемые объекты', short: 'Объекты', icon: Building2, component: ObjectsModule, section: 'security', hidden: true }, { id: 'security-archive', path: '/security-archive', title: 'Архив сработок', short: 'Архив', icon: Archive, component: ArchiveModule, section: 'security', hidden: true }, { id: 'gps', path: '/gps', title: 'GPS-мониторинг', short: 'GPS', icon: Navigation, component: GpsModule, section: 'gps' }, + { id: 'map-lab', path: '/map-lab', title: 'Карта-лаборатория (GL)', short: 'GL-карта', icon: Boxes, component: MapLabModule, section: 'gps' }, { id: 'geo', path: '/geofences', title: 'Геозоны', short: 'Геозоны', icon: MapPinned, component: GeoModule, section: 'gps', hidden: true }, { id: 'events', path: '/events', title: 'История событий', short: 'События', icon: History, component: EventsModule, section: 'gps', hidden: true }, { id: 'network', path: '/network', title: 'Сетевая инфраструктура', short: 'Сеть', icon: Network, component: NetworkModule, section: 'netinfra' }, diff --git a/src/modules/gps/MapClusterGL.tsx b/src/modules/gps/MapClusterGL.tsx new file mode 100644 index 0000000..1c8431f --- /dev/null +++ b/src/modules/gps/MapClusterGL.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { MonitoredEntity } from '@/core/types'; + +// «Карта-лаборатория» — рендер по референсу benzrf.ru / прототипа orbit-gps-mobile: +// MapLibre GL, GeoJSON-источник с нативной кластеризацией + donut-кластеры (SVG-сегменты +// по статусам), 2ГИС-подложка, опц. 3D-здания (OpenFreeMap fill-extrusion). + +type Status = 'moving' | 'parked' | 'offline' | 'alarm'; +const COLORS: Record = { moving: '#3fd06b', parked: '#f4b740', offline: '#7d94a3', alarm: '#ef4444' }; + +function statusOf(e: MonitoredEntity): Status { + if (!e.online) return e.type === 'energy' ? 'alarm' : 'offline'; + if (e.type === 'transport') return (e.telemetry?.speed ?? 0) > 0 ? 'moving' : 'parked'; + return 'parked'; // стационарные онлайн (энерго/сеть) +} +function glyphOf(e: MonitoredEntity): string { + if (e.type === 'energy') return '⚡'; + if (e.type === 'network') return '📶'; + return '▲'; +} + +// Подложка: CARTO dark (растр, CORS-совместим с WebGL — 2ГИС-тайлы в GL не отдают CORS/204, +// поэтому 2ГИС остаётся на Leaflet-разделах, а GL-демо — на CARTO, как в эталоне benzrf). +// + OpenFreeMap-вектор для 3D-зданий. +const STYLE: any = { + version: 8, + sources: { + carto: { type: 'raster', tileSize: 256, tiles: ['a', 'b', 'c', 'd'].map((s) => `https://${s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png`), attribution: '© OpenStreetMap © CARTO', maxzoom: 19 }, + openfreemap: { type: 'vector', url: 'https://tiles.openfreemap.org/planet' }, + }, + layers: [ + { id: 'bg', type: 'background', paint: { 'background-color': '#0b0f14' } }, + { id: 'base', type: 'raster', source: 'carto' }, + ], +}; + +function fc(entities: MonitoredEntity[]) { + return { + type: 'FeatureCollection', + features: entities + .filter((e) => e.lat != null && e.lon != null) + .map((e) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [e.lon!, e.lat!] }, properties: { id: e.id, status: statusOf(e) } })), + } as any; +} + +function donutSegment(start: number, end: number, r: number, r0: number, color: string): string { + if (end - start === 1) end -= 0.00001; + const a0 = 2 * Math.PI * (start - 0.25), a1 = 2 * Math.PI * (end - 0.25); + const x0 = Math.cos(a0), y0 = Math.sin(a0), x1 = Math.cos(a1), y1 = Math.sin(a1); + const large = end - start > 0.5 ? 1 : 0; + return ``; +} +function createDonut(props: any): HTMLElement { + const keys: Status[] = ['moving', 'parked', 'offline', 'alarm']; + const total = props.point_count as number; + const r = total >= 15 ? 27 : total >= 8 ? 23 : 19; + const r0 = Math.round(r * 0.62), w = r * 2; + let svg = ``; + let offset = 0; + for (const k of keys) { + const n = Number(props[k] || 0); + if (!n) continue; + svg += donutSegment(offset / total, (offset + n) / total, r, r0, COLORS[k]); + offset += n; + } + svg += `${total}`; + const el = document.createElement('div'); + el.style.cursor = 'pointer'; + el.style.filter = 'drop-shadow(0 2px 6px rgba(0,0,0,.5))'; + el.innerHTML = svg; + return el; +} +function createPin(e: MonitoredEntity, onClick: () => void): HTMLElement { + const c = COLORS[statusOf(e)]; + const el = document.createElement('div'); + el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer'; + el.innerHTML = `
${glyphOf(e)}
${e.name}
`; + el.addEventListener('click', (ev) => { ev.stopPropagation(); onClick(); }); + return el; +} + +export function MapClusterGL({ entities, threeD, onSelect, className }: { entities: MonitoredEntity[]; threeD?: boolean; onSelect?: (id: string) => void; className?: string }) { + const box = useRef(null); + const mapRef = useRef(null); + const markers = useRef>({}); + const onScreen = useRef>({}); + const entRef = useRef(entities); + entRef.current = entities; + + useEffect(() => { + if (!box.current || mapRef.current) return; + const map = new maplibregl.Map({ container: box.current, center: [43.0, 44.04], zoom: 10.4, style: STYLE, attributionControl: { compact: true } as any, preserveDrawingBuffer: true }); + mapRef.current = map; + map.touchZoomRotate.enableRotation(); + // Fix: MapLibre рисует пустой холст, если контейнер доразметился после init. + // ResizeObserver + отложенные resize (rAF/таймауты) форсируют перерисовку после раскладки. + const ro = new ResizeObserver(() => map.resize()); + ro.observe(box.current); + // map.resize() в одиночку иногда не форсирует перерисовку из-за раскладки контейнера; + // синтетический window-resize надёжно триггерит внутренний обработчик MapLibre (проверено). + const bump = () => { try { map.resize(); window.dispatchEvent(new Event('resize')); } catch { /* map removed */ } }; + requestAnimationFrame(bump); + const t1 = setTimeout(bump, 250), t2 = setTimeout(bump, 700), t3 = setTimeout(bump, 1400); + + const updateMarkers = () => { + if (!map.getSource('objects') || !map.isSourceLoaded('objects')) return; + const next: Record = {}; + const feats = map.querySourceFeatures('objects'); + for (const f of feats) { + const coords = (f.geometry as any).coordinates as [number, number]; + const p: any = f.properties; + let id: string, m: maplibregl.Marker | undefined; + if (p.cluster) { + id = 'c' + p.cluster_id; + m = markers.current[id]; + const sig = [p.point_count, p.moving, p.parked, p.offline, p.alarm].join('|'); + if (m && (m as any)._sig !== sig) { m.remove(); delete markers.current[id]; delete onScreen.current[id]; m = undefined; } + if (!m) { + const el = createDonut(p); + el.addEventListener('click', (e) => { + e.stopPropagation(); + (map.getSource('objects') as any).getClusterExpansionZoom(p.cluster_id).then((z: number) => map.easeTo({ center: coords, zoom: z + 0.4, duration: 500 })); + }); + m = markers.current[id] = new maplibregl.Marker({ element: el }).setLngLat(coords); + (m as any)._sig = sig; + } + } else { + id = 'v' + p.id; + const e = entRef.current.find((x) => x.id === p.id); + if (!e) continue; + m = markers.current[id]; + if (!m) m = markers.current[id] = new maplibregl.Marker({ element: createPin(e, () => onSelect?.(e.id)) }).setLngLat(coords); + else m.setLngLat(coords); + } + next[id] = m; + if (!onScreen.current[id]) m.addTo(map); + } + for (const id in onScreen.current) if (!next[id]) onScreen.current[id].remove(); + onScreen.current = next; + }; + + map.on('load', () => { + map.addSource('objects', { + type: 'geojson', data: fc(entRef.current), cluster: true, clusterMaxZoom: 13, clusterRadius: 56, + clusterProperties: { + moving: ['+', ['case', ['==', ['get', 'status'], 'moving'], 1, 0]], + parked: ['+', ['case', ['==', ['get', 'status'], 'parked'], 1, 0]], + offline: ['+', ['case', ['==', ['get', 'status'], 'offline'], 1, 0]], + alarm: ['+', ['case', ['==', ['get', 'status'], 'alarm'], 1, 0]], + }, + } as any); + map.addLayer({ id: 'stub', type: 'circle', source: 'objects', paint: { 'circle-radius': 0, 'circle-opacity': 0 } }); + map.on('render', updateMarkers); + map.resize(); // на случай, если контейнер получил размер только сейчас + }); + return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); ro.disconnect(); map.remove(); mapRef.current = null; markers.current = {}; onScreen.current = {}; }; + }, [onSelect]); + + // Обновление данных источника + useEffect(() => { + const map = mapRef.current; + const src = map?.getSource('objects') as any; + if (src) src.setData(fc(entities)); + }, [entities]); + + // 3D-здания + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const apply = () => { + if (threeD && !map.getLayer('b3d')) { + map.addLayer({ id: 'b3d', type: 'fill-extrusion', source: 'openfreemap', 'source-layer': 'building', minzoom: 14, paint: { 'fill-extrusion-color': '#2c3a47', 'fill-extrusion-height': ['coalesce', ['get', 'render_height'], 12], 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], 'fill-extrusion-opacity': 0.78 } } as any, 'stub'); + map.easeTo({ pitch: 52, duration: 600 }); + } else if (!threeD && map.getLayer('b3d')) { + map.removeLayer('b3d'); + map.easeTo({ pitch: 0, duration: 600 }); + } + }; + if (map.isStyleLoaded()) apply(); else map.once('load', apply); + }, [threeD]); + + return
; +} diff --git a/src/modules/gps/MapLabModule.tsx b/src/modules/gps/MapLabModule.tsx new file mode 100644 index 0000000..2873fc2 --- /dev/null +++ b/src/modules/gps/MapLabModule.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { Boxes, Layers3 } from 'lucide-react'; +import { useAuth } from '@/core/store'; +import { useGpsData } from './useGpsData'; +import { MapClusterGL } from './MapClusterGL'; + +const LEGEND: Array<{ k: string; c: string; label: string }> = [ + { k: 'moving', c: '#3fd06b', label: 'В движении' }, + { k: 'parked', c: '#f4b740', label: 'Стоянка / норма' }, + { k: 'offline', c: '#7d94a3', label: 'Нет связи' }, + { k: 'alarm', c: '#ef4444', label: 'Авария (ТП)' }, +]; + +// Демо-раздел «Карта-лаборатория»: рендер карты по референсу benzrf.ru — +// MapLibre GL + нативная кластеризация (donut-кластеры) + 2ГИС + 3D-здания. +export function MapLabModule() { + const user = useAuth((s) => s.user); + const { objs, live } = useGpsData(user); + const [threeD, setThreeD] = useState(false); + + return ( +
+
+
+ +
+
Карта-лаборатория (GL)
+
MapLibre GL · кластеризация donut · 2ГИС · референс benzrf.ru
+
+
+
+ {live ? 'live · шлюз' : 'демо'} · {objs.length} объектов + +
+
+ +
+ +
+ {LEGEND.map((l) => ( + + {l.label} + + ))} +
+
+ +

+ Скрипты отрисовки портированы из прототипа prototypes/orbit-gps-mobile.html: + GeoJSON-источник с cluster:true, donut-кластеры (SVG-сегменты по статусам), + подложка 2ГИС, 3D-здания OpenFreeMap (fill-extrusion). Кластер разворачивается по клику. +

+
+ ); +} diff --git a/src/modules/gps/MapSwitch.tsx b/src/modules/gps/MapSwitch.tsx index 7f706c7..b902256 100644 --- a/src/modules/gps/MapSwitch.tsx +++ b/src/modules/gps/MapSwitch.tsx @@ -1,17 +1,9 @@ -import { useEffect, useState, type ComponentProps } from 'react'; +import { type ComponentProps } from 'react'; import { MapView } from './MapView'; -import { MapViewGL } from './MapViewGL'; -// «Вторая схема» карты: на мобильном (<1024px) — MapLibre GL (плавно, GPU), -// на десктопе — привычный Leaflet. Пропсы идентичны, переключение по ширине окна. +// Основная карта — Leaflet с подложкой 2ГИС на всех разделах и устройствах +// (2ГИС-растр отдаётся как , работает везде; в WebGL/MapLibre 2ГИС не годится — +// нет CORS. GL-рендер с кластеризацией вынесен в демо-раздел «Карта-лаборатория» на CARTO). export function MapSwitch(props: ComponentProps) { - const [gl, setGl] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches); - useEffect(() => { - const mq = window.matchMedia('(max-width: 1023px)'); - const on = () => setGl(mq.matches); - on(); - mq.addEventListener('change', on); - return () => mq.removeEventListener('change', on); - }, []); - return gl ? : ; + return ; } diff --git a/src/modules/gps/MapViewGL.tsx b/src/modules/gps/MapViewGL.tsx index d597cf8..2f18565 100644 --- a/src/modules/gps/MapViewGL.tsx +++ b/src/modules/gps/MapViewGL.tsx @@ -27,29 +27,9 @@ interface Props { events?: Array<{ id: string; lat: number; lon: number; color: string; char: string; label: string; sub?: string }>; } -// По умолчанию — 2ГИС (растровый источник) на всех разделах. dark/light не влияет на тайлы 2ГИС. -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' }, - ], -}); +// CARTO GL-стиль (2ГИС-растр в WebGL не работает — нет CORS; основная карта = Leaflet+2ГИС). +const STYLE = (dark: boolean) => + `https://basemaps.cartocdn.com/gl/${dark ? 'dark-matter' : 'positron'}-gl-style/style.json`; function objColor(e: MonitoredEntity, colors?: Record): string { if (colors?.[e.id]) return colors[e.id];