feat(gps): Яндекс-подложка переведена на JS API 2.1 (v3 без бесплатного тарифа), общий marker-html для обеих подложек
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, '&').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
|
||||
? `<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) {
|
||||
|
||||
195
client/src/components/gps/Yandex21Map.tsx
Normal file
195
client/src/components/gps/Yandex21Map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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] } : {}) }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
client/src/components/gps/marker-html.ts
Normal file
46
client/src/components/gps/marker-html.ts
Normal 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, '&').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
|
||||
? `<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>`;
|
||||
}
|
||||
@@ -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
32
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/*"]
|
||||
|
||||
Reference in New Issue
Block a user