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 ``;
- }
-
- const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
- return ``;
-}
-
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 (
-
- );
- }
-
- 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 (
-
- );
-}
-
-/** Маркер объекта на карте */
-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 ``;
+ }
+
+ const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
+ return ``;
+}
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/):
- *