From a6a42fdb15c090f9f3067455561f27f962a719bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Mon, 24 Aug 2026 21:05:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D0=B1=D0=B5=D1=81=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=B4=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=BA=D0=B0=20OSM=20=D1=81=20=D0=B0=D0=B2=D1=82=D0=BE-fa?= =?UTF-8?q?llback=20=D0=B8=20=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=BC=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/GpsMap.tsx | 338 ++++++++++++++++++++++++ client/src/components/gps/OsmMap.tsx | 182 +++++++++++++ client/src/components/gps/YandexMap.tsx | 25 +- client/src/pages/gps/GeozonesTab.tsx | 50 ++-- client/src/pages/gps/MapTab.tsx | 55 ++-- client/src/pages/gps/RoutesTab.tsx | 49 ++-- package-lock.json | 25 ++ package.json | 2 + 8 files changed, 652 insertions(+), 74 deletions(-) create mode 100644 client/src/components/gps/GpsMap.tsx create mode 100644 client/src/components/gps/OsmMap.tsx diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx new file mode 100644 index 0000000..66c422b --- /dev/null +++ b/client/src/components/gps/GpsMap.tsx @@ -0,0 +1,338 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useToast } from '@/hooks/use-toast'; +import { useGpsConfig } from '@/hooks/useGps'; +import { YandexMap, type LngLat, type LngLatBounds } from './YandexMap'; +import { GpsAssetMarker, GpsDotMarker, GpsGeozonePolygon, GpsPolyline } from './map-features'; +import { OsmMap } from './OsmMap'; + +/** + * Провайдер-агностичная карта GPS-модуля. + * Табы передают декларативные data-props (markers/polygons/polylines) и не знают + * о движке карты. Провайдер выбирается автоматически: + * - нет ключа Яндекс.Карт → сразу OpenStreetMap (Leaflet); + * - ключ есть → Яндекс.Карты; при ошибке загрузки/квоты/таймауте 10 сек — + * fallback на OpenStreetMap, в localStorage пишется дата fallback'а, + * до конца суток Яндекс больше не дёргаем (у бесплатного ключа дневной лимит). + */ + +export interface GpsMapMarker { + id: string | number; + lat: number; + lng: number; + color?: string | null; + /** vehicle/person — кружок с иконкой; dot — маленькая точка (старт/финиш/вершина) */ + kind: 'vehicle' | 'person' | 'dot'; + label?: string; + /** Приглушённый маркер для offline-объектов */ + offline?: boolean; + onClick?: () => void; + zIndex?: number; +} + +export interface GpsMapPolygon { + id: string | number; + /** Вершины полигона [lat, lng] */ + points: Array<[number, number]>; + color?: string | null; + fillOpacity?: number; +} + +export interface GpsMapPolyline { + id: string | number; + /** Точки линии [lat, lng] */ + points: Array<[number, number]>; + color?: string | null; + dashed?: boolean; +} + +interface GpsMapProps { + /** Центр [lat, lng]; по умолчанию — Москва */ + center?: [number, number]; + zoom?: number; + /** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */ + bounds?: [[number, number], [number, number]]; + markers?: GpsMapMarker[]; + polygons?: GpsMapPolygon[]; + polylines?: GpsMapPolyline[]; + /** Клик по карте (режим рисования геозон) */ + onMapClick?: (lat: number, lng: number) => void; + className?: string; +} + +type MapProvider = 'yandex' | 'osm'; +/** Ручной выбор подложки пользователем */ +type ProviderPref = 'auto' | 'yandex' | 'osm'; + +const FALLBACK_LS_KEY = 'gps-map-provider-fallback'; +const PREF_LS_KEY = 'gps-map-provider'; +const YANDEX_LOAD_TIMEOUT_MS = 10_000; + +/** Сегодняшняя дата (локальная) в формате YYYY-MM-DD */ +function todayLocal(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** Был ли сегодня fallback с Яндекса на OSM */ +function isFallbackToday(): boolean { + try { + return localStorage.getItem(FALLBACK_LS_KEY) === todayLocal(); + } catch { + return false; + } +} + +function markFallbackToday(): void { + try { + localStorage.setItem(FALLBACK_LS_KEY, todayLocal()); + } catch { + // private mode и т.п. — просто работаем без запоминания + } +} + +/** Сохранённый ручной выбор подложки (по умолчанию — 'auto') */ +function readProviderPref(): ProviderPref { + try { + const v = localStorage.getItem(PREF_LS_KEY); + return v === 'yandex' || v === 'osm' ? v : 'auto'; + } catch { + return 'auto'; + } +} + +export function GpsMap({ + center, + zoom = 10, + bounds, + markers, + polygons, + polylines, + onMapClick, + className, +}: GpsMapProps) { + const { data: configData, isLoading: configLoading } = useGpsConfig(); + const { toast } = useToast(); + const apiKey = configData?.yandexMapsKey ?? null; + + // Ручной выбор подложки: 'auto' | 'yandex' | 'osm', хранится в localStorage + const [pref, setPrefState] = useState(readProviderPref); + const prefRef = useRef(pref); + useEffect(() => { + prefRef.current = pref; + }); + + const setPref = (next: ProviderPref) => { + setPrefState(next); + try { + localStorage.setItem(PREF_LS_KEY, next); + } catch { + // private mode — выбор живёт только в сессии + } + }; + + // Принудительный OSM после ошибки Яндекса в этой сессии + const [fallback, setFallback] = useState(false); + const [yandexReady, setYandexReady] = useState(false); + const fallbackFiredRef = useRef(false); + + // При смене ручного выбора сбрасываем сессионное состояние, + // чтобы карта пересоздалась на новом провайдере без перезагрузки страницы + useEffect(() => { + setFallback(false); + setYandexReady(false); + fallbackFiredRef.current = false; + }, [pref]); + + // Выбор провайдера: + // - 'osm' — всегда OpenStreetMap, Яндекс не дёргаем вообще; + // - 'yandex' — принудительно Яндекс (игнорируем суточный fallback-флаг); + // - 'auto' — Яндекс, если есть ключ и сегодня не было fallback'а + const provider: MapProvider | null = configLoading + ? null + : pref === 'osm' || !apiKey + ? 'osm' + : pref === 'yandex' + ? fallback + ? 'osm' + : 'yandex' + : fallback || isFallbackToday() + ? 'osm' + : 'yandex'; + + // Принудительный «Яндекс» без ключа — один раз предупреждаем, что работает OSM + const missingKeyNotifiedRef = useRef(false); + useEffect(() => { + if (pref === 'yandex' && !configLoading && !apiKey && !missingKeyNotifiedRef.current) { + missingKeyNotifiedRef.current = true; + toast({ + title: 'Ключ Яндекс.Карт не задан', + description: 'Используется OpenStreetMap', + }); + } + }, [pref, configLoading, apiKey, toast]); + + const triggerFallback = useCallback( + (reason: string) => { + if (fallbackFiredRef.current) return; + fallbackFiredRef.current = true; + console.warn(`[GPS] Fallback на OpenStreetMap: ${reason}`); + // Суточный флаг пишем только в режиме 'auto' — в принудительном 'yandex' + // завтра (или после смены переключателя) снова пробуем Яндекс + if (prefRef.current === 'auto') { + markFallbackToday(); + } + setFallback(true); + toast({ + title: 'Яндекс.Карты недоступны', + description: 'Включена бесплатная подложка OpenStreetMap', + }); + }, + [toast] + ); + + // Таймаут загрузки Яндекс.Карт: если за 10 сек скрипт не поднялся — уходим на OSM + useEffect(() => { + if (provider !== 'yandex' || yandexReady) return; + const timer = window.setTimeout(() => { + triggerFallback('таймаут загрузки скрипта'); + }, YANDEX_LOAD_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [provider, yandexReady, triggerFallback]); + + if (provider === null) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ {provider === 'yandex' ? ( + setYandexReady(true)} + onError={(message) => triggerFallback(message)} + /> + ) : ( + + )} + {/* Бейдж текущей подложки + ручной переключатель провайдера */} +
+ + {provider === 'yandex' ? 'Яндекс.Карты' : 'OpenStreetMap'} + +
+ {( + [ + { value: 'auto', label: 'Авто' }, + { value: 'yandex', label: 'Яндекс' }, + { value: 'osm', label: 'OSM' }, + ] as Array<{ value: ProviderPref; label: string }> + ).map((option) => ( + + ))} +
+
+
+ ); +} + +/** Яндекс-реализация: data-props → ymaps-компоненты (маркеры/полигоны/полилинии) */ +function YandexProviderMap({ + apiKey, + center, + zoom, + bounds, + markers, + polygons, + polylines, + onMapClick, + onReady, + onError, +}: GpsMapProps & { apiKey: string; onReady: () => void; onError: (message: string) => void }) { + // Конвертация [lat, lng] → яндексовый порядок [lng, lat] + const yCenter: LngLat | undefined = center ? [center[1], center[0]] : undefined; + const yBounds: LngLatBounds | undefined = bounds + ? [ + [bounds[0][1], bounds[0][0]], + [bounds[1][1], bounds[1][0]], + ] + : undefined; + + return ( + onMapClick(lat, lng) : undefined} + onReady={onReady} + onError={onError} + className="h-full w-full" + > + {polygons?.map((p) => ( + + ))} + {polylines?.map((l) => ( + + ))} + {markers?.map((m) => + m.kind === 'dot' ? ( + + ) : ( + + ) + )} + + ); +} diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx new file mode 100644 index 0000000..6b47ec8 --- /dev/null +++ b/client/src/components/gps/OsmMap.tsx @@ -0,0 +1,182 @@ +import { useEffect, useRef } from 'react'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap'; + +/** + * Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка, + * fallback для Яндекс.Карт). Чистый Leaflet без react-leaflet: + * карта создаётся один раз на ref-контейнере, слои перестраиваются эффектами. + */ + +const OSM_TILES_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +const OSM_ATTRIBUTION = '© OpenStreetMap contributors'; +const MOSCOW: [number, number] = [55.755864, 37.617698]; +const DEFAULT_COLOR = '#3b82f6'; + +interface OsmMapProps { + /** Центр [lat, lng]; по умолчанию — Москва */ + center?: [number, number]; + zoom?: number; + /** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */ + bounds?: [[number, number], [number, number]]; + markers?: GpsMapMarker[]; + polygons?: GpsMapPolygon[]; + polylines?: GpsMapPolyline[]; + onMapClick?: (lat: number, lng: number) => void; + className?: string; +} + +// Иконки lucide (car / user) в виде SVG-строк для divIcon — react-компоненты +// внутрь Leaflet-маркера вставить нельзя, поэтому разметка собирается вручную +const CAR_SVG = ``; +const USER_SVG = ``; + +/** Экранирование пользовательского текста для HTML-разметки маркера */ +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** HTML цветного кружка с иконкой/без + подпись (визуально как маркеры на Яндекс.Картах) */ +function markerHtml(marker: GpsMapMarker): string { + const color = marker.color || DEFAULT_COLOR; + const opacity = marker.offline ? 'opacity:0.5;' : ''; + const label = marker.label + ? `
${escapeHtml(marker.label)}
` + : ''; + + if (marker.kind === 'dot') { + return `
+
+ ${label} +
`; + } + + const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG; + return `
+
${icon}
+ ${label} +
`; +} + +export function OsmMap({ + center, + zoom = 10, + bounds, + markers = [], + polygons = [], + polylines = [], + onMapClick, + className, +}: OsmMapProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersLayerRef = useRef(null); + const featuresLayerRef = useRef(null); + + // Колбэки кликов маркеров и карты — через ref, чтобы не пересоздавать слои + // при смене идентичности функций (react-query рефетчи) + const markerClicksRef = useRef(new Map void>()); + const onMapClickRef = useRef(onMapClick); + useEffect(() => { + markerClicksRef.current = new Map(markers.filter((m) => m.onClick).map((m) => [m.id, m.onClick!])); + onMapClickRef.current = onMapClick; + }); + + // Создание карты (один раз) + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const map = L.map(containerRef.current).setView(center ?? MOSCOW, zoom); + L.tileLayer(OSM_TILES_URL, { attribution: OSM_ATTRIBUTION, maxZoom: 19 }).addTo(map); + + markersLayerRef.current = L.layerGroup().addTo(map); + featuresLayerRef.current = L.layerGroup().addTo(map); + + map.on('click', (e: L.LeafletMouseEvent) => { + onMapClickRef.current?.(e.latlng.lat, e.latlng.lng); + }); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; + markersLayerRef.current = null; + featuresLayerRef.current = null; + }; + // Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Перемещение viewport только при реальном изменении center/zoom/bounds, + // чтобы рефетчи не сбрасывали позицию, которую двигает пользователь + const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`; + const lastViewKeyRef = useRef(null); + useEffect(() => { + const map = mapRef.current; + if (!map || lastViewKeyRef.current === viewKey) return; + lastViewKeyRef.current = viewKey; + if (bounds) { + map.fitBounds(bounds); + } else if (center) { + map.setView(center, zoom); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewKey]); + + // Маркеры: перестраиваем слой при изменении данных (без учёта колбэков — они в ref) + const markersKey = JSON.stringify( + markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex]) + ); + useEffect(() => { + const layer = markersLayerRef.current; + if (!layer) return; + layer.clearLayers(); + for (const m of markers) { + const leafletMarker = L.marker([m.lat, m.lng], { + icon: L.divIcon({ html: markerHtml(m), className: '', iconSize: [0, 0] }), + zIndexOffset: m.zIndex ?? 0, + }); + if (m.onClick) { + leafletMarker.on('click', () => { + markerClicksRef.current.get(m.id)?.(); + }); + } + leafletMarker.addTo(layer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [markersKey]); + + // Полигоны и полилинии + const featuresKey = JSON.stringify([ + polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]), + polylines.map((l) => [l.id, l.points, l.color, l.dashed]), + ]); + useEffect(() => { + const layer = featuresLayerRef.current; + if (!layer) return; + layer.clearLayers(); + for (const p of polygons) { + if (p.points.length < 3) continue; + const color = p.color || DEFAULT_COLOR; + L.polygon(p.points, { + color, + weight: 2, + fillColor: color, + fillOpacity: p.fillOpacity ?? 0.15, + }).addTo(layer); + } + for (const l of polylines) { + if (l.points.length < 2) continue; + L.polyline(l.points, { + color: l.color || DEFAULT_COLOR, + weight: 4, + dashArray: l.dashed ? '8 6' : undefined, + }).addTo(layer); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [featuresKey]); + + return
; +} diff --git a/client/src/components/gps/YandexMap.tsx b/client/src/components/gps/YandexMap.tsx index 0b61aba..e690eb0 100644 --- a/client/src/components/gps/YandexMap.tsx +++ b/client/src/components/gps/YandexMap.tsx @@ -26,6 +26,10 @@ interface YandexMapProps { bounds?: LngLatBounds; /** Клик по карте (используется для рисования геозон) */ onMapClick?: (coordinates: LngLat) => void; + /** Скрипт ymaps3 успешно загружен и карта готова к рендеру */ + onReady?: () => void; + /** Ошибка загрузки скрипта/API (используется GpsMap для fallback на OSM) */ + onError?: (message: string) => void; className?: string; children?: React.ReactNode; } @@ -43,23 +47,40 @@ export function YandexMap({ zoom = 10, bounds, onMapClick, + onReady, + onError, className, children, }: YandexMapProps) { const [ymaps, setYmaps] = useState(null); const [error, setError] = useState(null); + // Колбэки через ref, чтобы эффект загрузки не перезапускался при каждом рендере + const onReadyRef = useRef(onReady); + const onErrorRef = useRef(onError); + useEffect(() => { + onReadyRef.current = onReady; + onErrorRef.current = onError; + }); + useEffect(() => { if (!apiKey) return; let cancelled = false; setError(null); loadYandexMaps(apiKey) .then((module) => { - if (!cancelled) setYmaps(module); + if (!cancelled) { + setYmaps(module); + onReadyRef.current?.(); + } }) .catch((err) => { console.error('[GPS] Ошибка загрузки Яндекс.Карт:', err); - if (!cancelled) setError('Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.'); + if (!cancelled) { + const message = 'Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.'; + setError(message); + onErrorRef.current?.(message); + } }); return () => { cancelled = true; diff --git a/client/src/pages/gps/GeozonesTab.tsx b/client/src/pages/gps/GeozonesTab.tsx index 6fee38f..b6f590c 100644 --- a/client/src/pages/gps/GeozonesTab.tsx +++ b/client/src/pages/gps/GeozonesTab.tsx @@ -12,9 +12,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Loader2, Pencil, Trash2, Undo2, Check, Hexagon } from 'lucide-react'; import { apiRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; -import { YandexMap } from '@/components/gps/YandexMap'; -import { GpsDotMarker, GpsGeozonePolygon, GpsPolyline } from '@/components/gps/map-features'; -import { invalidateGps, useGpsConfig, useGpsGeozoneEvents, useGpsGeozones } from '@/hooks/useGps'; +import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap'; +import { invalidateGps, useGpsGeozoneEvents, useGpsGeozones } from '@/hooks/useGps'; import type { GpsGeozone } from './types'; function formatDateTime(value: string): string { @@ -42,7 +41,6 @@ const DEFAULT_FORM: GeozoneFormState = { */ export function GpsGeozonesTab() { const { toast } = useToast(); - const { data: configData } = useGpsConfig(); const { data: geozonesData, isLoading } = useGpsGeozones(); const geozones = geozonesData?.geozones || []; @@ -139,6 +137,26 @@ export function GpsGeozonesTab() { setEditing(g); }; + // Данные для провайдер-агностичной карты: существующие зоны, строящийся + // полигон (замкнутая заливка при 3+ точках, иначе пунктирная линия), вершины + const mapPolygons: GpsMapPolygon[] = [ + ...geozones.map((g): GpsMapPolygon => ({ id: g.id, points: g.polygon, color: g.color })), + ...(draft.length >= 3 + ? [{ id: 'draft', points: draft, color: '#f97316', fillOpacity: 0.25 }] + : []), + ]; + const mapPolylines: GpsMapPolyline[] = + draft.length >= 2 ? [{ id: 'draft-line', points: draft, color: '#f97316', dashed: true }] : []; + const draftMarkers: GpsMapMarker[] = draft.map(([lat, lng], idx) => ({ + id: `draft-${idx}`, + lat, + lng, + kind: 'dot', + color: '#f97316', + label: String(idx + 1), + zIndex: 10, + })); + return (
@@ -234,25 +252,13 @@ export function GpsGeozonesTab() {
)} - setDraft((d) => [...d, [lat, lng]]) - : undefined - } - > - {geozones.map((g) => ( - - ))} - {/* Строящийся полигон: замкнутая заливка при 3+ точках, иначе пунктирная линия */} - {draft.length >= 3 && } - {draft.length >= 2 && } - {draft.map(([lat, lng], idx) => ( - - ))} - + onMapClick={drawing ? (lat, lng) => setDraft((d) => [...d, [lat, lng]]) : undefined} + polygons={mapPolygons} + polylines={mapPolylines} + markers={draftMarkers} + />
diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx index 53f3e31..efe5198 100644 --- a/client/src/pages/gps/MapTab.tsx +++ b/client/src/pages/gps/MapTab.tsx @@ -4,9 +4,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { X, Route } from 'lucide-react'; -import { YandexMap, type LngLatBounds } from '@/components/gps/YandexMap'; -import { GpsAssetMarker, GpsGeozonePolygon } from '@/components/gps/map-features'; -import { useGpsAssets, useGpsConfig, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps'; +import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap'; +import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps'; import type { GpsPosition } from './types'; function formatDateTime(value: string | null): string { @@ -17,9 +16,9 @@ function formatDateTime(value: string | null): string { /** * Таб «Карта»: текущие позиции всех активных объектов, геозоны, * фильтр по группе, попап объекта с переходом к маршруту за сегодня. + * Провайдер карты (Яндекс/OSM) выбирается внутри GpsMap. */ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) { - const { data: configData } = useGpsConfig(); const { data: assetsData } = useGpsAssets(); const { data: positionsData } = useGpsPositions(); const { data: geozonesData } = useGpsGeozones(); @@ -47,15 +46,15 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v ); // Автоподгонка bounds при первой загрузке позиций (дальше пользователь двигает карту сам) - const [bounds, setBounds] = useState(null); + const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null); useEffect(() => { if (bounds || positions.length === 0) return; const lats = positions.map((p) => p.lat as number); const lngs = positions.map((p) => p.lng as number); const pad = 0.01; setBounds([ - [Math.min(...lngs) - pad, Math.min(...lats) - pad], - [Math.max(...lngs) + pad, Math.max(...lats) + pad], + [Math.min(...lats) - pad, Math.min(...lngs) - pad], + [Math.max(...lats) + pad, Math.max(...lngs) + pad], ]); }, [positions, bounds]); @@ -69,6 +68,24 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v const selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null; const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null; + // Маркеры объектов и полигоны геозон для провайдер-агностичной карты + const mapMarkers: GpsMapMarker[] = visiblePositions.map((p) => ({ + id: p.assetId, + lat: p.lat as number, + lng: p.lng as number, + color: p.color, + kind: p.type, + label: p.name, + offline: !p.isOnline, + onClick: () => setSelected(p), + })); + + const mapPolygons: GpsMapPolygon[] = geozones.map((g) => ({ + id: `gz-${g.id}`, + points: g.polygon, + color: g.color, + })); + return (
@@ -91,30 +108,16 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
- - {geozones.map((g) => ( - - ))} - {visiblePositions.map((p) => ( - setSelected(p)} - /> - ))} - + /> {/* Панель выбранного объекта поверх карты */} {selectedLive && ( - +
{selectedLive.name}
diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index 6016ff7..34ec526 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -6,9 +6,8 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Loader2, Pause, Play } from 'lucide-react'; -import { YandexMap, type LngLatBounds } from '@/components/gps/YandexMap'; -import { GpsDotMarker, GpsPolyline } from '@/components/gps/map-features'; -import { useGpsAssets, useGpsConfig, useGpsTrack } from '@/hooks/useGps'; +import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap'; +import { useGpsAssets, useGpsTrack } from '@/hooks/useGps'; import type { GpsTrackPoint } from './types'; /** Расстояние между двумя точками в км (формула гаверсинуса) */ @@ -41,7 +40,6 @@ const PLAY_SPEEDS = [1, 5, 20] as const; * статистика (пробег, время, скорость) и анимация воспроизведения. */ export function GpsRoutesTab() { - const { data: configData } = useGpsConfig(); const { data: assetsData } = useGpsAssets(); const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false); @@ -98,15 +96,15 @@ export function GpsRoutesTab() { return { distance, maxSpeed, durationMin, startTs, endTs }; }, [points]); - // Автоподгонка карты под трек - const bounds: LngLatBounds | undefined = useMemo(() => { + // Автоподгонка карты под трек ([[lat, lng], [lat, lng]] — провайдер-агностичный формат GpsMap) + const bounds: [[number, number], [number, number]] | undefined = useMemo(() => { if (points.length === 0) return undefined; const lats = points.map((p) => p.lat); const lngs = points.map((p) => p.lng); const pad = 0.005; return [ - [Math.min(...lngs) - pad, Math.min(...lats) - pad], - [Math.max(...lngs) + pad, Math.max(...lats) + pad], + [Math.min(...lats) - pad, Math.min(...lngs) - pad], + [Math.max(...lats) + pad, Math.max(...lngs) + pad], ]; }, [points]); @@ -167,6 +165,21 @@ export function GpsRoutesTab() { const first = points[0]; const last = points[points.length - 1]; + // Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + const mapPolylines: GpsMapPolyline[] = + trackCoords.length >= 2 + ? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }] + : []; + const mapMarkers: GpsMapMarker[] = [ + ...(first ? [{ id: 'start', lat: first.lat, lng: first.lng, kind: 'dot' as const, color: '#22c55e', label: 'Старт', zIndex: 5 }] : []), + ...(last && points.length > 1 + ? [{ id: 'finish', lat: last.lat, lng: last.lng, kind: 'dot' as const, color: '#ef4444', label: 'Финиш', zIndex: 5 }] + : []), + ...(playPoint + ? [{ id: 'playhead', lat: playPoint.lat, lng: playPoint.lng, kind: 'dot' as const, color: selectedAsset?.color || '#3b82f6', zIndex: 20 }] + : []), + ]; + return (
@@ -204,24 +217,12 @@ export function GpsRoutesTab() {
- - - {first && } - {last && points.length > 1 && ( - - )} - {playPoint && ( - - )} - + /> {applied && !isFetching && points.length === 0 && (

diff --git a/package-lock.json b/package-lock.json index 6fb9e80..5f93250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,6 +110,7 @@ "jspreadsheet-ce": "^4.13.4", "jsuites": "^6.3.4", "jszip": "^3.10.1", + "leaflet": "^1.9.4", "lucide-react": "^0.453.0", "lvovich": "^2.1.0", "mammoth": "^1.12.0", @@ -158,6 +159,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "4.17.21", "@types/express-session": "^1.18.0", + "@types/leaflet": "^1.9.22", "@types/node": "20.16.11", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38", @@ -6526,6 +6528,13 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -6569,6 +6578,16 @@ "integrity": "sha512-hUK1zZIova7WAABlj1zV+EHMw70h/UergJy6ps+tJBfZ1kvzqNNe2L6RrXts1txyFDWvBxZ7byQ+MkeL+7W4Wg==", "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -11216,6 +11235,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lemonadejs": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/lemonadejs/-/lemonadejs-5.3.6.tgz", diff --git a/package.json b/package.json index a364904..e9eda3b 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "jspreadsheet-ce": "^4.13.4", "jsuites": "^6.3.4", "jszip": "^3.10.1", + "leaflet": "^1.9.4", "lucide-react": "^0.453.0", "lvovich": "^2.1.0", "mammoth": "^1.12.0", @@ -162,6 +163,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "4.17.21", "@types/express-session": "^1.18.0", + "@types/leaflet": "^1.9.22", "@types/node": "20.16.11", "@types/passport": "^1.0.16", "@types/passport-local": "^1.0.38",