egorin: демо-раздел «Карта-лаборатория (GL)» — MapLibre GL, donut-кластеризация, 3D-здания, CARTO (порт benzrf/orbit-gps-mobile); главная карта = Leaflet+2ГИС везде; docs

This commit is contained in:
german
2026-07-09 14:01:17 +03:00
parent 010a5308f2
commit 27067d3d12
6 changed files with 274 additions and 37 deletions
+21
View File
@@ -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`.
+3 -1
View File
@@ -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' },
+185
View File
@@ -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<Status, string> = { 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 `<path d="M ${r + r0 * x0} ${r + r0 * y0} L ${r + r * x0} ${r + r * y0} A ${r} ${r} 0 ${large} 1 ${r + r * x1} ${r + r * y1} L ${r + r0 * x1} ${r + r0 * y1} A ${r0} ${r0} 0 ${large} 0 ${r + r0 * x0} ${r + r0 * y0}" fill="${color}"/>`;
}
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 = `<svg width="${w}" height="${w}" viewBox="0 0 ${w} ${w}">`;
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 += `<circle cx="${r}" cy="${r}" r="${r0}" fill="#141b22"/><text x="${r}" y="${r}" dominant-baseline="central" text-anchor="middle" fill="#cfe3d6" font-size="${Math.round(r * 0.58)}" font-weight="700" font-family="Inter,system-ui">${total}</text></svg>`;
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 = `<div style="width:26px;height:26px;border-radius:50%;background:${c};border:2px solid #0F172A;display:grid;place-items:center;box-shadow:0 0 0 3px ${c}40;font-size:13px">${glyphOf(e)}</div><div style="margin-top:2px;font:600 10px Inter,system-ui;color:#cfe3d6;background:rgba(12,16,20,.7);padding:1px 5px;border-radius:6px;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis">${e.name}</div>`;
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<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const markers = useRef<Record<string, maplibregl.Marker>>({});
const onScreen = useRef<Record<string, maplibregl.Marker>>({});
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<string, maplibregl.Marker> = {};
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 <div ref={box} className={className} style={{ width: '100%', height: '100%', minHeight: 320 }} />;
}
+57
View File
@@ -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 (
<div className="flex h-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-accent/15 text-accent"><Boxes className="h-5 w-5" /></span>
<div>
<div className="text-base font-semibold leading-tight">Карта-лаборатория (GL)</div>
<div className="text-[11px] text-fg-muted">MapLibre GL · кластеризация donut · 2ГИС · референс benzrf.ru</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${live ? 'bg-ok/20 text-ok' : 'bg-muted text-fg-muted'}`}>{live ? 'live · шлюз' : 'демо'} · {objs.length} объектов</span>
<button onClick={() => setThreeD((v) => !v)} className={`flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-sm font-semibold transition-colors ${threeD ? 'bg-accent text-[#06210f]' : 'border border-border text-fg hover:bg-muted'}`}>
<Layers3 className="h-4 w-4" /> 3D-здания
</button>
</div>
</div>
<div className="relative min-h-[420px] flex-1 overflow-hidden rounded-2xl border border-border">
<MapClusterGL entities={objs} threeD={threeD} className="absolute inset-0" />
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 rounded-xl bg-surface/80 px-3 py-2 backdrop-blur">
{LEGEND.map((l) => (
<span key={l.k} className="flex items-center gap-2 text-[11px] text-fg-muted">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: l.c }} /> {l.label}
</span>
))}
</div>
</div>
<p className="text-[11px] text-fg-muted">
Скрипты отрисовки портированы из прототипа <span className="font-mono">prototypes/orbit-gps-mobile.html</span>:
GeoJSON-источник с <span className="font-mono">cluster:true</span>, donut-кластеры (SVG-сегменты по статусам),
подложка 2ГИС, 3D-здания OpenFreeMap (fill-extrusion). Кластер разворачивается по клику.
</p>
</div>
);
}
+5 -13
View File
@@ -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ГИС-растр отдаётся как <img>, работает везде; в WebGL/MapLibre 2ГИС не годится —
// нет CORS. GL-рендер с кластеризацией вынесен в демо-раздел «Карта-лаборатория» на CARTO).
export function MapSwitch(props: ComponentProps<typeof MapView>) {
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 ? <MapViewGL {...(props as any)} /> : <MapView {...props} />;
return <MapView {...props} />;
}
+3 -23
View File
@@ -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, string>): string {
if (colors?.[e.id]) return colors[e.id];