feat(gps): Яндекс-подложка переведена на JS API 2.1 (v3 без бесплатного тарифа), общий marker-html для обеих подложек

This commit is contained in:
2026-08-25 14:29:47 +03:00
parent 4018ee0f1b
commit c7aac447bc
10 changed files with 277 additions and 482 deletions

View File

@@ -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 (
<div className={cn('relative', className)}>
{provider === 'yandex' ? (
<YandexProviderMap
<Yandex21Map
apiKey={apiKey!}
center={center}
zoom={zoom}
@@ -226,6 +225,7 @@ export function GpsMap({
onMapClick={onMapClick}
onReady={() => setYandexReady(true)}
onError={(message) => triggerFallback(message)}
className="h-full w-full"
/>
) : (
<OsmMap
@@ -271,68 +271,3 @@ export function GpsMap({
</div>
);
}
/** Яндекс-реализация: 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 (
<YandexMap
apiKey={apiKey}
center={yCenter}
zoom={zoom}
bounds={yBounds}
onMapClick={onMapClick ? ([lng, lat]) => onMapClick(lat, lng) : undefined}
onReady={onReady}
onError={onError}
className="h-full w-full"
>
{polygons?.map((p) => (
<GpsGeozonePolygon key={p.id} polygon={p.points} color={p.color} fillOpacity={p.fillOpacity} />
))}
{polylines?.map((l) => (
<GpsPolyline key={l.id} points={l.points} color={l.color ?? undefined} dashed={l.dashed} />
))}
{markers?.map((m) =>
m.kind === 'dot' ? (
<GpsDotMarker
key={m.id}
coordinates={[m.lng, m.lat]}
color={m.color || '#3b82f6'}
label={m.label}
zIndex={m.zIndex}
/>
) : (
<GpsAssetMarker
key={m.id}
coordinates={[m.lng, m.lat]}
name={m.label || ''}
type={m.kind}
color={m.color}
isOnline={!m.offline}
zIndex={m.zIndex}
onClick={m.onClick}
/>
)
)}
</YandexMap>
);
}

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`;
const USER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
/** Экранирование пользовательского текста для HTML-разметки маркера */
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** HTML цветного кружка с иконкой/без + подпись (визуально как маркеры на Яндекс.Картах) */
function markerHtml(marker: GpsMapMarker): string {
const color = marker.color || DEFAULT_COLOR;
const opacity = marker.offline ? 'opacity:0.5;' : '';
const label = marker.label
? `<div style="margin-top:2px;padding:0 4px;border-radius:4px;font-size:10px;line-height:16px;font-weight:500;background:rgba(255,255,255,0.9);border:1px solid #d4d4d8;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;color:#18181b;">${escapeHtml(marker.label)}</div>`
: '';
if (marker.kind === 'dot') {
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;${opacity}">
<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${escapeHtml(marker.label || '')}"></div>
${label}
</div>`;
}
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;cursor:pointer;${opacity}">
<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>
${label}
</div>`;
}
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) {

View File

@@ -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<HTMLDivElement>(null);
const mapRef = useRef<ymaps.Map | null>(null);
const collectionRef = useRef<ymaps.GeoObjectCollection | null>(null);
const [mapReady, setMapReady] = useState(false);
// Колбэки через ref, чтобы эффекты не перезапускались при каждом рендере
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
const onMapClickRef = useRef(onMapClick);
const markerClicksRef = useRef(new Map<string | number, () => 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<string | null>(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 (
<div className={className} style={{ position: 'relative' }}>
<div ref={containerRef} className="h-full w-full" />
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/30">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
}

View File

@@ -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<YmapsReactModule | null>(null);
/** Доступ к react-компонентам ymaps3 внутри <YandexMap>; 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<YmapsReactModule | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className={className}>
<div className="flex flex-col items-center justify-center h-full min-h-[300px] gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-center">
<MapPin className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground max-w-md">
Укажите API-ключ Яндекс.Карт в настройках (Боты → Доступы к сервисам → «Яндекс.Карты (для модуля GPS)»).
</p>
</div>
</div>
);
}
if (error) {
return (
<div className={className}>
<div className="flex items-center justify-center h-full min-h-[300px] rounded-md border border-dashed border-border bg-muted/30 p-6">
<p className="text-sm text-destructive">{error}</p>
</div>
</div>
);
}
if (!ymaps) {
return (
<div className={className}>
<div className="flex items-center justify-center h-full min-h-[300px] rounded-md border border-border bg-muted/30">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
const { YMap, YMapDefaultSchemeLayer, YMapDefaultFeaturesLayer, YMapListener } = ymaps;
return (
<div className={className}>
<YmapsContext.Provider value={ymaps}>
<YMap location={location}>
<YMapDefaultSchemeLayer />
<YMapDefaultFeaturesLayer />
<YMapListener onClick={handleMapClick} />
{children}
</YMap>
</YmapsContext.Provider>
</div>
);
}

View File

@@ -1,165 +0,0 @@
import { Car, User } from 'lucide-react';
import { useYmaps, type LngLat } from './YandexMap';
/**
* Общие элементы карты GPS-модуля: маркер объекта, полигон геозоны, полилиния трека.
* Все компоненты должны рендериться внутри <YandexMap> (берут 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 (
<div
className="flex flex-col items-center select-none"
style={{ transform: 'translate(-50%, -50%)', opacity: isOnline === false ? 0.5 : 1 }}
>
<div
className="flex items-center justify-center w-7 h-7 rounded-full border-2 border-white shadow-md"
style={{ backgroundColor: color || DEFAULT_COLOR }}
>
<Icon className="w-4 h-4 text-white" />
</div>
<div
className="mt-0.5 px-1 rounded text-[10px] leading-4 font-medium bg-background/90 border border-border whitespace-nowrap max-w-[120px] truncate"
title={name}
>
{name}
</div>
</div>
);
}
/** Маркер объекта на карте */
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 (
<YMapMarker
coordinates={coordinates}
zIndex={zIndex}
onClick={onClick ? () => onClick() : undefined}
>
<AssetMarkerContent name={name} type={type} color={color} isOnline={isOnline} />
</YMapMarker>
);
}
/** Простой круглый маркер (старт/финиш трека, точка воспроизведения) */
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 (
<YMapMarker coordinates={coordinates} zIndex={zIndex}>
<div className="flex flex-col items-center select-none" style={{ transform: 'translate(-50%, -50%)' }}>
<div
className="w-4 h-4 rounded-full border-2 border-white shadow"
style={{ backgroundColor: color }}
title={label}
/>
{label && (
<div className="mt-0.5 px-1 rounded text-[10px] leading-4 font-medium bg-background/90 border border-border whitespace-nowrap">
{label}
</div>
)}
</div>
</YMapMarker>
);
}
/** Полигон геозоны (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 (
<YMapFeature
geometry={{
type: 'Polygon',
coordinates: [polygon.map(([lat, lng]) => [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 (
<YMapFeature
geometry={{
type: 'LineString',
coordinates: points.map(([lat, lng]) => [lng, lat] as LngLat),
}}
style={{
stroke: [{ color, width, ...(dashed ? { dash: [8, 6] } : {}) }],
}}
/>
);
}

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`;
const USER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
/** Экранирование пользовательского текста для HTML-разметки маркера */
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* 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
? `<div style="margin-top:2px;padding:0 4px;border-radius:4px;font-size:10px;line-height:16px;font-weight:500;background:rgba(255,255,255,0.9);border:1px solid #d4d4d8;white-space:nowrap;max-width:120px;overflow:hidden;text-overflow:ellipsis;color:#18181b;">${escapeHtml(marker.label)}</div>`
: '';
if (marker.kind === 'dot') {
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;${opacity}">
<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${escapeHtml(marker.label || '')}"></div>
${label}
</div>`;
}
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
return `<div style="display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-50%);user-select:none;cursor:pointer;${opacity}">
<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>
${label}
</div>`;
}

View File

@@ -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<typeof import('@yandex/ymaps3-types')>;
let loadPromise: Promise<YmapsReactModule> | null = null;
let loadPromise: Promise<void> | null = null;
let loadedApiKey: string | null = null;
/**
* Загружает Яндекс.Карты JS API 3.0 и возвращает reactify-модуль (singleton).
* Скрипт подключается один раз на страницу; при повторных вызовах
* с тем же ключом возвращается тот же промис.
*
* Официальная схема (https://yandex.ru/dev/jsapi30/):
* <script src="https://api-maps.yandex.ru/v3/?apikey=KEY&lang=ru_RU">
* await ymaps3.ready;
* const ymaps3React = await ymaps3.import('@yandex/ymaps3-reactify');
* const reactify = ymaps3React.reactify.bindTo(React, ReactDOM);
* const { YMap, ... } = reactify.module(ymaps3);
* Подключает <script src="https://api-maps.yandex.ru/2.1/?apikey=KEY&lang=ru_RU">
* и ждёт ymaps.ready(). Скрипт грузится один раз на страницу; при повторных
* вызовах с тем же ключом возвращается тот же промис. При ошибке — reject
* (GpsMap ловит и уходит в OSM-fallback).
*/
export function loadYandexMaps(apiKey: string): Promise<YmapsReactModule> {
export function loadYandexMaps(apiKey: string): Promise<void> {
if (loadPromise && loadedApiKey === apiKey) return loadPromise;
if (loadPromise) {
// Скрипт уже загружен с другим ключом — переиспользовать загруженный модуль
// Скрипт уже загружен с другим ключом — переиспользуем его
// (ключ меняется крайне редко; до перезагрузки страницы работает старый).
console.warn('[GPS] API-ключ Яндекс.Карт изменился после загрузки скрипта, используется уже загруженный модуль');
console.warn('[GPS] API-ключ Яндекс.Карт изменился после загрузки скрипта, используется уже загруженный');
return loadPromise;
}
loadedApiKey = apiKey;
loadPromise = (async () => {
await injectScript(apiKey);
await ymaps3.ready;
const ymaps3React = await ymaps3.import('@yandex/ymaps3-reactify');
const reactify = (ymaps3React as { reactify: { bindTo(r: unknown, rd: unknown): { module(m: unknown): YmapsReactModule } } })
.reactify.bindTo(React, ReactDOM);
return reactify.module(ymaps3);
await ymaps.ready();
})().catch((err) => {
// При ошибке сбрасываем singleton, чтобы следующий рендер мог повторить попытку
// При ошибке сбрасываем singleton, чтобы следующая попытка могла повторить загрузку
loadPromise = null;
loadedApiKey = null;
throw err;
@@ -55,15 +42,15 @@ export function loadYandexMaps(apiKey: string): Promise<YmapsReactModule> {
function injectScript(apiKey: string): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof window !== 'undefined' && (window as unknown as { ymaps3?: unknown }).ymaps3) {
if (typeof window !== 'undefined' && (window as unknown as { ymaps?: unknown }).ymaps) {
resolve();
return;
}
const script = document.createElement('script');
script.src = `https://api-maps.yandex.ru/v3/?apikey=${encodeURIComponent(apiKey)}&lang=ru_RU`;
script.src = `https://api-maps.yandex.ru/2.1/?apikey=${encodeURIComponent(apiKey)}&lang=ru_RU`;
script.type = 'text/javascript';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Не удалось загрузить скрипт Яндекс.Карт'));
script.onerror = () => reject(new Error('Не удалось загрузить скрипт Яндекс.Карт 2.1'));
document.head.appendChild(script);
});
}

32
package-lock.json generated
View File

@@ -166,8 +166,8 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.5.13",
"@types/yandex-maps": "^2.1.36",
"@vitejs/plugin-react": "^4.3.2",
"@yandex/ymaps3-types": "^1.0.20315584",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.28.1",
@@ -6826,6 +6826,13 @@
"@types/node": "*"
}
},
"node_modules/@types/yandex-maps": {
"version": "2.1.36",
"resolved": "https://registry.npmjs.org/@types/yandex-maps/-/yandex-maps-2.1.36.tgz",
"integrity": "sha512-SxKOLe627WMFkC3jo7VelT29Uvbvg+LHRtD3cA6W0qWNuf7WQBv+lTaN+os1Vdum+EpglKBJbzjV8PJqwPZUQA==",
"dev": true,
"license": "MIT"
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz",
@@ -6968,29 +6975,6 @@
"node": ">=10.0.0"
}
},
"node_modules/@yandex/ymaps3-types": {
"version": "1.0.20315584",
"resolved": "https://registry.npmjs.org/@yandex/ymaps3-types/-/ymaps3-types-1.0.20315584.tgz",
"integrity": "sha512-fJ6K8uat1RYt9ASE1wMlcg2IpPQDHdQBDtcRKHvLrWHhQRTRnFGbvYY8XVo5rTYRN9Mr94X7f2BZOMtrxN16ag==",
"dev": true,
"license": "Apache-2.0",
"peerDependencies": {
"@types/react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"@vue/runtime-core": "3"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
},
"@vue/runtime-core": {
"optional": true
}
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",

View File

@@ -170,8 +170,8 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@types/ws": "^8.5.13",
"@types/yandex-maps": "^2.1.36",
"@vitejs/plugin-react": "^4.3.2",
"@yandex/ymaps3-types": "^1.0.20315584",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.28.1",

View File

@@ -15,7 +15,8 @@
"allowImportingTsExtensions": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"types": ["node", "vite/client"],
"types": ["node", "vite/client", "yandex-maps"],
"allowUmdGlobalAccess": true,
"paths": {
"@/*": ["./client/src/*"],
"@shared/*": ["./shared/*"]