diff --git a/client/src/components/gps/GpsMap.tsx b/client/src/components/gps/GpsMap.tsx index 66c422b..ed413fb 100644 --- a/client/src/components/gps/GpsMap.tsx +++ b/client/src/components/gps/GpsMap.tsx @@ -3,8 +3,7 @@ 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 { Yandex21Map } from './Yandex21Map'; import { OsmMap } from './OsmMap'; /** @@ -215,7 +214,7 @@ export function GpsMap({ return (
{provider === 'yandex' ? ( - setYandexReady(true)} onError={(message) => triggerFallback(message)} + className="h-full w-full" /> ) : ( ); } - -/** Яндекс-реализация: 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 index 6b47ec8..79f5a17 100644 --- a/client/src/components/gps/OsmMap.tsx +++ b/client/src/components/gps/OsmMap.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap'; +import { gpsMarkerHtml } from './marker-html'; /** * Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка, @@ -27,38 +28,6 @@ interface OsmMapProps { 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, @@ -135,7 +104,7 @@ export function OsmMap({ 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] }), + icon: L.divIcon({ html: gpsMarkerHtml(m), className: '', iconSize: [0, 0] }), zIndexOffset: m.zIndex ?? 0, }); if (m.onClick) { diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx new file mode 100644 index 0000000..953ec0f --- /dev/null +++ b/client/src/components/gps/Yandex21Map.tsx @@ -0,0 +1,195 @@ +import { useEffect, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap'; +import { loadYandexMaps } from './ymaps-loader'; +import { gpsMarkerHtml } from './marker-html'; + +/** + * Реализация GPS-карты на Яндекс.Картах JS API 2.1 (императивный ymaps, + * тот же стиль, что OsmMap на Leaflet: ref-контейнер, карта создаётся один + * раз, обновления слоёв через эффекты). + * + * ВАЖНО: в 2.1 координаты уже в порядке [lat, lng] — конвертация не нужна. + */ + +const MOSCOW: [number, number] = [55.755864, 37.617698]; +const DEFAULT_COLOR = '#3b82f6'; + +interface Yandex21MapProps { + apiKey: string; + /** Центр [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; + /** Карта создана и готова к работе */ + onReady?: () => void; + /** Ошибка загрузки скрипта/API (GpsMap использует для fallback на OSM) */ + onError?: (message: string) => void; + className?: string; +} + +export function Yandex21Map({ + apiKey, + center, + zoom = 10, + bounds, + markers = [], + polygons = [], + polylines = [], + onMapClick, + onReady, + onError, + className, +}: Yandex21MapProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const collectionRef = useRef(null); + const [mapReady, setMapReady] = useState(false); + + // Колбэки через ref, чтобы эффекты не перезапускались при каждом рендере + const onReadyRef = useRef(onReady); + const onErrorRef = useRef(onError); + const onMapClickRef = useRef(onMapClick); + const markerClicksRef = useRef(new Map void>()); + useEffect(() => { + onReadyRef.current = onReady; + onErrorRef.current = onError; + onMapClickRef.current = onMapClick; + markerClicksRef.current = new Map(markers.filter((m) => m.onClick).map((m) => [m.id, m.onClick!])); + }); + + // Загрузка скрипта и создание карты (один раз на apiKey) + useEffect(() => { + let cancelled = false; + setMapReady(false); + + loadYandexMaps(apiKey) + .then(() => { + if (cancelled || !containerRef.current || mapRef.current) return; + + const map = new ymaps.Map(containerRef.current, { + center: center ?? MOSCOW, + zoom, + controls: ['zoomControl'], + }); + + map.events.add('click', (e: ymaps.IEvent) => { + const coords = e.get('coords') as [number, number]; + onMapClickRef.current?.(coords[0], coords[1]); + }); + + const collection = new ymaps.GeoObjectCollection(); + map.geoObjects.add(collection); + + mapRef.current = map; + collectionRef.current = collection; + setMapReady(true); + onReadyRef.current?.(); + }) + .catch((err) => { + console.error('[GPS] Ошибка загрузки Яндекс.Карт 2.1:', err); + if (!cancelled) { + onErrorRef.current?.('Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.'); + } + }); + + return () => { + cancelled = true; + if (mapRef.current) { + mapRef.current.destroy(); + mapRef.current = null; + collectionRef.current = null; + } + }; + // Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiKey]); + + // Перемещение 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 (!mapReady || !map || lastViewKeyRef.current === viewKey) return; + lastViewKeyRef.current = viewKey; + if (bounds) { + map.setBounds(bounds, { checkZoomRange: true }); + } else if (center) { + map.setCenter(center, zoom); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewKey, mapReady]); + + // Маркеры/полигоны/полилинии: перестраиваем коллекцию при изменении данных + // (объектов немного — removeAll + добавить заново дёшево); + // колбэки кликов не входят в ключ — они читаются из ref в момент клика + const dataKey = JSON.stringify([ + markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex]), + polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]), + polylines.map((l) => [l.id, l.points, l.color, l.dashed]), + ]); + useEffect(() => { + const collection = collectionRef.current; + if (!mapReady || !collection) return; + collection.removeAll(); + + for (const p of polygons) { + if (p.points.length < 3) continue; + const color = p.color || DEFAULT_COLOR; + collection.add( + new ymaps.Polygon([p.points], {}, { + fillColor: color, + fillOpacity: p.fillOpacity ?? 0.15, + strokeColor: color, + strokeWidth: 2, + }) + ); + } + + for (const l of polylines) { + if (l.points.length < 2) continue; + collection.add( + new ymaps.Polyline(l.points, {}, { + strokeColor: l.color || DEFAULT_COLOR, + strokeWidth: 4, + strokeStyle: l.dashed ? 'dash' : 'solid', + }) + ); + } + + for (const m of markers) { + const placemark = new ymaps.Placemark( + [m.lat, m.lng], + {}, + { + iconLayout: ymaps.templateLayoutFactory.createClass(gpsMarkerHtml(m)), + ...(m.zIndex !== undefined ? { zIndex: m.zIndex } : {}), + } + ); + if (m.onClick) { + placemark.events.add('click', () => { + markerClicksRef.current.get(m.id)?.(); + }); + } + collection.add(placemark); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataKey, mapReady]); + + return ( +
+
+ {!mapReady && ( +
+ +
+ )} +
+ ); +} diff --git a/client/src/components/gps/YandexMap.tsx b/client/src/components/gps/YandexMap.tsx deleted file mode 100644 index e690eb0..0000000 --- a/client/src/components/gps/YandexMap.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, MapPin } from 'lucide-react'; -import type { DomEventHandler } from '@yandex/ymaps3-types/imperative/YMapListener'; -import { loadYandexMaps, type YmapsReactModule } from './ymaps-loader'; - -/** [долгота, широта] — порядок координат Яндекс.Карт JS API 3.0 (LngLat) */ -export type LngLat = [number, number]; -/** Прямоугольник карты: [[lng, lat], [lng, lat]] (юго-запад, северо-восток) */ -export type LngLatBounds = [LngLat, LngLat]; - -/** Контекст с reactify-модулем ymaps3 — дочерние компоненты (маркеры, фичи) берут из него YMapMarker/YMapFeature */ -const YmapsContext = createContext(null); - -/** Доступ к react-компонентам ymaps3 внутри ; null, пока скрипт не загружен */ -export function useYmaps(): YmapsReactModule | null { - return useContext(YmapsContext); -} - -interface YandexMapProps { - /** API-ключ Яндекс.Карт; null — ключ не настроен, показываем заглушку */ - apiKey?: string | null; - /** Центр [lng, lat]; по умолчанию — Москва */ - center?: LngLat; - zoom?: number; - /** Границы для автоподгонки; приоритетнее center/zoom */ - bounds?: LngLatBounds; - /** Клик по карте (используется для рисования геозон) */ - onMapClick?: (coordinates: LngLat) => void; - /** Скрипт ymaps3 успешно загружен и карта готова к рендеру */ - onReady?: () => void; - /** Ошибка загрузки скрипта/API (используется GpsMap для fallback на OSM) */ - onError?: (message: string) => void; - className?: string; - children?: React.ReactNode; -} - -const MOSCOW: LngLat = [37.617698, 55.755864]; - -/** - * Обёртка над Яндекс.Картами JS API 3.0. - * Загружает скрипт один раз (singleton в ymaps-loader), пока грузится — скелетон, - * без ключа — заглушка с инструкцией. - */ -export function YandexMap({ - apiKey, - center, - 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); - onReadyRef.current?.(); - } - }) - .catch((err) => { - console.error('[GPS] Ошибка загрузки Яндекс.Карт:', err); - if (!cancelled) { - const message = 'Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.'; - setError(message); - onErrorRef.current?.(message); - } - }); - return () => { - cancelled = true; - }; - }, [apiKey]); - - // Стабильная location: пересоздаём объект только при реальном изменении, - // чтобы рефетчи react-query не сбрасывали viewport, который двигает пользователь - const locationKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`; - const location = useMemo( - () => (bounds ? { bounds } : { center: center ?? MOSCOW, zoom }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [locationKey] - ); - - // Колбэк клика через ref, чтобы не пересоздавать YMapListener - const onMapClickRef = useRef(onMapClick); - useEffect(() => { - onMapClickRef.current = onMapClick; - }); - - const handleMapClick: DomEventHandler = (_object, event) => { - const c = event.coordinates; - onMapClickRef.current?.([c[0], c[1]]); - }; - - if (!apiKey) { - return ( -
-
- -

- Укажите API-ключ Яндекс.Карт в настройках (Боты → Доступы к сервисам → «Яндекс.Карты (для модуля GPS)»). -

-
-
- ); - } - - if (error) { - return ( -
-
-

{error}

-
-
- ); - } - - if (!ymaps) { - return ( -
-
- -
-
- ); - } - - const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapListener } = ymaps; - - return ( -
- - - - - - {children} - - -
- ); -} diff --git a/client/src/components/gps/map-features.tsx b/client/src/components/gps/map-features.tsx deleted file mode 100644 index 13b8fa4..0000000 --- a/client/src/components/gps/map-features.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { Car, User } from 'lucide-react'; -import { useYmaps, type LngLat } from './YandexMap'; - -/** - * Общие элементы карты GPS-модуля: маркер объекта, полигон геозоны, полилиния трека. - * Все компоненты должны рендериться внутри (берут ymaps3 из контекста). - */ - -const DEFAULT_COLOR = '#3b82f6'; - -/** DOM-содержимое маркера объекта: цветной кружок с иконкой + подпись */ -function AssetMarkerContent({ - name, - type, - color, - isOnline, -}: { - name: string; - type: 'vehicle' | 'person'; - color?: string | null; - isOnline?: boolean | null; -}) { - const Icon = type === 'person' ? User : Car; - return ( -
-
- -
-
- {name} -
-
- ); -} - -/** Маркер объекта на карте */ -export function GpsAssetMarker({ - coordinates, - name, - type, - color, - isOnline, - zIndex, - onClick, -}: { - coordinates: LngLat; - name: string; - type: 'vehicle' | 'person'; - color?: string | null; - isOnline?: boolean | null; - zIndex?: number; - onClick?: () => void; -}) { - const ymaps = useYmaps(); - if (!ymaps) return null; - const { YMapMarker } = ymaps; - return ( - onClick() : undefined} - > - - - ); -} - -/** Простой круглый маркер (старт/финиш трека, точка воспроизведения) */ -export function GpsDotMarker({ - coordinates, - color, - label, - zIndex, -}: { - coordinates: LngLat; - color: string; - label?: string; - zIndex?: number; -}) { - const ymaps = useYmaps(); - if (!ymaps) return null; - const { YMapMarker } = ymaps; - return ( - -
-
- {label && ( -
- {label} -
- )} -
- - ); -} - -/** Полигон геозоны (polygon хранится как пары [lat, lng] — конвертируем в LngLat) */ -export function GpsGeozonePolygon({ - polygon, - color, - fillOpacity = 0.15, -}: { - polygon: Array<[number, number]>; - color?: string | null; - fillOpacity?: number; -}) { - const ymaps = useYmaps(); - if (!ymaps || polygon.length < 3) return null; - const { YMapFeature } = ymaps; - const c = color || DEFAULT_COLOR; - return ( - [lng, lat] as LngLat)], - }} - style={{ - fill: c, - fillOpacity, - stroke: [{ color: c, width: 2 }], - }} - /> - ); -} - -/** Полилиния трека/маршрута (points — пары [lat, lng]) */ -export function GpsPolyline({ - points, - color = DEFAULT_COLOR, - width = 4, - dashed = false, -}: { - points: Array<[number, number]>; - color?: string; - width?: number; - dashed?: boolean; -}) { - const ymaps = useYmaps(); - if (!ymaps || points.length < 2) return null; - const { YMapFeature } = ymaps; - return ( - [lng, lat] as LngLat), - }} - style={{ - stroke: [{ color, width, ...(dashed ? { dash: [8, 6] } : {}) }], - }} - /> - ); -} diff --git a/client/src/components/gps/marker-html.ts b/client/src/components/gps/marker-html.ts new file mode 100644 index 0000000..8f7ef02 --- /dev/null +++ b/client/src/components/gps/marker-html.ts @@ -0,0 +1,46 @@ +import type { GpsMapMarker } from './GpsMap'; + +/** + * Общая HTML-разметка кастомных маркеров GPS-модуля. + * Используется обеими реализациями карты: Leaflet (divIcon) и Яндекс.Карты 2.1 + * (templateLayoutFactory) — визуально маркеры одинаковые на обеих подложках. + */ + +const DEFAULT_COLOR = '#3b82f6'; + +// Иконки lucide (car / user) в виде SVG-строк: react-компоненты внутрь +// HTML-маркера карты вставить нельзя, разметка собирается вручную +const CAR_SVG = ``; +const USER_SVG = ``; + +/** Экранирование пользовательского текста для HTML-разметки маркера */ +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** + * HTML маркера: цветной кружок с иконкой (vehicle/person) или маленькая точка + * (dot) + подпись снизу; offline — приглушённый (opacity 0.5). + * Внутренний контейнер центрируется через translate(-50%, -50%) — точка + * привязки карты должна быть в левом верхнем углу элемента. + */ +export function gpsMarkerHtml(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} +
`; +} diff --git a/client/src/components/gps/ymaps-loader.ts b/client/src/components/gps/ymaps-loader.ts index 1be6cd8..77980fa 100644 --- a/client/src/components/gps/ymaps-loader.ts +++ b/client/src/components/gps/ymaps-loader.ts @@ -1,50 +1,37 @@ -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -// Типы JS API 3.0: подключают глобальный `ymaps3` и типы reactify-модуля. -// Пакет types-only, в рантайме скрипт грузится с api-maps.yandex.ru. -import type { ReactifiedModule } from '@yandex/ymaps3-types/reactify'; - /** - * Реакт-обёртка над ymaps3: результат reactify.module(ymaps3). - * Содержит YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, - * YMapMarker, YMapFeature, YMapListener и т.д. как React-компоненты. + * Загрузчик Яндекс.Карт JS API 2.1 (singleton). + * + * Почему 2.1, а не 3.0: у JS API 3.0 нет бесплатного тарифа — ключи + * «JavaScript API и HTTP Геокодер» на v3 отклоняются с 403 «Invalid api key», + * а на 2.1 работают. + * + * Типы — пакет @types/yandex-maps (глобальный namespace ymaps, UMD). */ -export type YmapsReactModule = ReactifiedModule; -let loadPromise: Promise | null = null; +let loadPromise: Promise | null = null; let loadedApiKey: string | null = null; /** - * Загружает Яндекс.Карты JS API 3.0 и возвращает reactify-модуль (singleton). - * Скрипт подключается один раз на страницу; при повторных вызовах - * с тем же ключом возвращается тот же промис. - * - * Официальная схема (https://yandex.ru/dev/jsapi30/): - *