feat(gps): бесплатная подложка OSM с авто-fallback и ручным переключателем провайдера карты
This commit is contained in:
338
client/src/components/gps/GpsMap.tsx
Normal file
338
client/src/components/gps/GpsMap.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useGpsConfig } from '@/hooks/useGps';
|
||||
import { YandexMap, type LngLat, type LngLatBounds } from './YandexMap';
|
||||
import { GpsAssetMarker, GpsDotMarker, GpsGeozonePolygon, GpsPolyline } from './map-features';
|
||||
import { OsmMap } from './OsmMap';
|
||||
|
||||
/**
|
||||
* Провайдер-агностичная карта GPS-модуля.
|
||||
* Табы передают декларативные data-props (markers/polygons/polylines) и не знают
|
||||
* о движке карты. Провайдер выбирается автоматически:
|
||||
* - нет ключа Яндекс.Карт → сразу OpenStreetMap (Leaflet);
|
||||
* - ключ есть → Яндекс.Карты; при ошибке загрузки/квоты/таймауте 10 сек —
|
||||
* fallback на OpenStreetMap, в localStorage пишется дата fallback'а,
|
||||
* до конца суток Яндекс больше не дёргаем (у бесплатного ключа дневной лимит).
|
||||
*/
|
||||
|
||||
export interface GpsMapMarker {
|
||||
id: string | number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
color?: string | null;
|
||||
/** vehicle/person — кружок с иконкой; dot — маленькая точка (старт/финиш/вершина) */
|
||||
kind: 'vehicle' | 'person' | 'dot';
|
||||
label?: string;
|
||||
/** Приглушённый маркер для offline-объектов */
|
||||
offline?: boolean;
|
||||
onClick?: () => void;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface GpsMapPolygon {
|
||||
id: string | number;
|
||||
/** Вершины полигона [lat, lng] */
|
||||
points: Array<[number, number]>;
|
||||
color?: string | null;
|
||||
fillOpacity?: number;
|
||||
}
|
||||
|
||||
export interface GpsMapPolyline {
|
||||
id: string | number;
|
||||
/** Точки линии [lat, lng] */
|
||||
points: Array<[number, number]>;
|
||||
color?: string | null;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
interface GpsMapProps {
|
||||
/** Центр [lat, lng]; по умолчанию — Москва */
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
/** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */
|
||||
bounds?: [[number, number], [number, number]];
|
||||
markers?: GpsMapMarker[];
|
||||
polygons?: GpsMapPolygon[];
|
||||
polylines?: GpsMapPolyline[];
|
||||
/** Клик по карте (режим рисования геозон) */
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type MapProvider = 'yandex' | 'osm';
|
||||
/** Ручной выбор подложки пользователем */
|
||||
type ProviderPref = 'auto' | 'yandex' | 'osm';
|
||||
|
||||
const FALLBACK_LS_KEY = 'gps-map-provider-fallback';
|
||||
const PREF_LS_KEY = 'gps-map-provider';
|
||||
const YANDEX_LOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** Сегодняшняя дата (локальная) в формате YYYY-MM-DD */
|
||||
function todayLocal(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** Был ли сегодня fallback с Яндекса на OSM */
|
||||
function isFallbackToday(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(FALLBACK_LS_KEY) === todayLocal();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markFallbackToday(): void {
|
||||
try {
|
||||
localStorage.setItem(FALLBACK_LS_KEY, todayLocal());
|
||||
} catch {
|
||||
// private mode и т.п. — просто работаем без запоминания
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохранённый ручной выбор подложки (по умолчанию — 'auto') */
|
||||
function readProviderPref(): ProviderPref {
|
||||
try {
|
||||
const v = localStorage.getItem(PREF_LS_KEY);
|
||||
return v === 'yandex' || v === 'osm' ? v : 'auto';
|
||||
} catch {
|
||||
return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
export function GpsMap({
|
||||
center,
|
||||
zoom = 10,
|
||||
bounds,
|
||||
markers,
|
||||
polygons,
|
||||
polylines,
|
||||
onMapClick,
|
||||
className,
|
||||
}: GpsMapProps) {
|
||||
const { data: configData, isLoading: configLoading } = useGpsConfig();
|
||||
const { toast } = useToast();
|
||||
const apiKey = configData?.yandexMapsKey ?? null;
|
||||
|
||||
// Ручной выбор подложки: 'auto' | 'yandex' | 'osm', хранится в localStorage
|
||||
const [pref, setPrefState] = useState<ProviderPref>(readProviderPref);
|
||||
const prefRef = useRef(pref);
|
||||
useEffect(() => {
|
||||
prefRef.current = pref;
|
||||
});
|
||||
|
||||
const setPref = (next: ProviderPref) => {
|
||||
setPrefState(next);
|
||||
try {
|
||||
localStorage.setItem(PREF_LS_KEY, next);
|
||||
} catch {
|
||||
// private mode — выбор живёт только в сессии
|
||||
}
|
||||
};
|
||||
|
||||
// Принудительный OSM после ошибки Яндекса в этой сессии
|
||||
const [fallback, setFallback] = useState(false);
|
||||
const [yandexReady, setYandexReady] = useState(false);
|
||||
const fallbackFiredRef = useRef(false);
|
||||
|
||||
// При смене ручного выбора сбрасываем сессионное состояние,
|
||||
// чтобы карта пересоздалась на новом провайдере без перезагрузки страницы
|
||||
useEffect(() => {
|
||||
setFallback(false);
|
||||
setYandexReady(false);
|
||||
fallbackFiredRef.current = false;
|
||||
}, [pref]);
|
||||
|
||||
// Выбор провайдера:
|
||||
// - 'osm' — всегда OpenStreetMap, Яндекс не дёргаем вообще;
|
||||
// - 'yandex' — принудительно Яндекс (игнорируем суточный fallback-флаг);
|
||||
// - 'auto' — Яндекс, если есть ключ и сегодня не было fallback'а
|
||||
const provider: MapProvider | null = configLoading
|
||||
? null
|
||||
: pref === 'osm' || !apiKey
|
||||
? 'osm'
|
||||
: pref === 'yandex'
|
||||
? fallback
|
||||
? 'osm'
|
||||
: 'yandex'
|
||||
: fallback || isFallbackToday()
|
||||
? 'osm'
|
||||
: 'yandex';
|
||||
|
||||
// Принудительный «Яндекс» без ключа — один раз предупреждаем, что работает OSM
|
||||
const missingKeyNotifiedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pref === 'yandex' && !configLoading && !apiKey && !missingKeyNotifiedRef.current) {
|
||||
missingKeyNotifiedRef.current = true;
|
||||
toast({
|
||||
title: 'Ключ Яндекс.Карт не задан',
|
||||
description: 'Используется OpenStreetMap',
|
||||
});
|
||||
}
|
||||
}, [pref, configLoading, apiKey, toast]);
|
||||
|
||||
const triggerFallback = useCallback(
|
||||
(reason: string) => {
|
||||
if (fallbackFiredRef.current) return;
|
||||
fallbackFiredRef.current = true;
|
||||
console.warn(`[GPS] Fallback на OpenStreetMap: ${reason}`);
|
||||
// Суточный флаг пишем только в режиме 'auto' — в принудительном 'yandex'
|
||||
// завтра (или после смены переключателя) снова пробуем Яндекс
|
||||
if (prefRef.current === 'auto') {
|
||||
markFallbackToday();
|
||||
}
|
||||
setFallback(true);
|
||||
toast({
|
||||
title: 'Яндекс.Карты недоступны',
|
||||
description: 'Включена бесплатная подложка OpenStreetMap',
|
||||
});
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
// Таймаут загрузки Яндекс.Карт: если за 10 сек скрипт не поднялся — уходим на OSM
|
||||
useEffect(() => {
|
||||
if (provider !== 'yandex' || yandexReady) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
triggerFallback('таймаут загрузки скрипта');
|
||||
}, YANDEX_LOAD_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [provider, yandexReady, triggerFallback]);
|
||||
|
||||
if (provider === null) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{provider === 'yandex' ? (
|
||||
<YandexProviderMap
|
||||
apiKey={apiKey!}
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
bounds={bounds}
|
||||
markers={markers}
|
||||
polygons={polygons}
|
||||
polylines={polylines}
|
||||
onMapClick={onMapClick}
|
||||
onReady={() => setYandexReady(true)}
|
||||
onError={(message) => triggerFallback(message)}
|
||||
/>
|
||||
) : (
|
||||
<OsmMap
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
bounds={bounds}
|
||||
markers={markers}
|
||||
polygons={polygons}
|
||||
polylines={polylines}
|
||||
onMapClick={onMapClick}
|
||||
className="h-full w-full rounded-md overflow-hidden"
|
||||
/>
|
||||
)}
|
||||
{/* Бейдж текущей подложки + ручной переключатель провайдера */}
|
||||
<div className="absolute top-2 right-2 z-[1001] flex flex-col items-end gap-1">
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] text-muted-foreground bg-background/80 border border-border pointer-events-none">
|
||||
{provider === 'yandex' ? 'Яндекс.Карты' : 'OpenStreetMap'}
|
||||
</span>
|
||||
<div className="flex rounded border border-border bg-background/80 overflow-hidden">
|
||||
{(
|
||||
[
|
||||
{ value: 'auto', label: 'Авто' },
|
||||
{ value: 'yandex', label: 'Яндекс' },
|
||||
{ value: 'osm', label: 'OSM' },
|
||||
] as Array<{ value: ProviderPref; label: string }>
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setPref(option.value)}
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 text-[10px] leading-4 transition-colors',
|
||||
pref === option.value
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
182
client/src/components/gps/OsmMap.tsx
Normal file
182
client/src/components/gps/OsmMap.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { GpsMapMarker, GpsMapPolygon, GpsMapPolyline } from './GpsMap';
|
||||
|
||||
/**
|
||||
* Реализация GPS-карты на Leaflet + OpenStreetMap (бесплатная подложка,
|
||||
* fallback для Яндекс.Карт). Чистый Leaflet без react-leaflet:
|
||||
* карта создаётся один раз на ref-контейнере, слои перестраиваются эффектами.
|
||||
*/
|
||||
|
||||
const OSM_TILES_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const OSM_ATTRIBUTION = '© OpenStreetMap contributors';
|
||||
const MOSCOW: [number, number] = [55.755864, 37.617698];
|
||||
const DEFAULT_COLOR = '#3b82f6';
|
||||
|
||||
interface OsmMapProps {
|
||||
/** Центр [lat, lng]; по умолчанию — Москва */
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
/** Границы для автоподгонки [[lat, lng], [lat, lng]]; приоритетнее center/zoom */
|
||||
bounds?: [[number, number], [number, number]];
|
||||
markers?: GpsMapMarker[];
|
||||
polygons?: GpsMapPolygon[];
|
||||
polylines?: GpsMapPolyline[];
|
||||
onMapClick?: (lat: number, lng: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Иконки lucide (car / user) в виде SVG-строк для divIcon — react-компоненты
|
||||
// внутрь Leaflet-маркера вставить нельзя, поэтому разметка собирается вручную
|
||||
const CAR_SVG = `<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,
|
||||
bounds,
|
||||
markers = [],
|
||||
polygons = [],
|
||||
polylines = [],
|
||||
onMapClick,
|
||||
className,
|
||||
}: OsmMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
const markersLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
const featuresLayerRef = useRef<L.LayerGroup | null>(null);
|
||||
|
||||
// Колбэки кликов маркеров и карты — через ref, чтобы не пересоздавать слои
|
||||
// при смене идентичности функций (react-query рефетчи)
|
||||
const markerClicksRef = useRef(new Map<string | number, () => void>());
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
useEffect(() => {
|
||||
markerClicksRef.current = new Map(markers.filter((m) => m.onClick).map((m) => [m.id, m.onClick!]));
|
||||
onMapClickRef.current = onMapClick;
|
||||
});
|
||||
|
||||
// Создание карты (один раз)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(containerRef.current).setView(center ?? MOSCOW, zoom);
|
||||
L.tileLayer(OSM_TILES_URL, { attribution: OSM_ATTRIBUTION, maxZoom: 19 }).addTo(map);
|
||||
|
||||
markersLayerRef.current = L.layerGroup().addTo(map);
|
||||
featuresLayerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
map.on('click', (e: L.LeafletMouseEvent) => {
|
||||
onMapClickRef.current?.(e.latlng.lat, e.latlng.lng);
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
markersLayerRef.current = null;
|
||||
featuresLayerRef.current = null;
|
||||
};
|
||||
// Карта создаётся один раз; центр/zoom/bounds обновляет отдельный эффект
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Перемещение viewport только при реальном изменении center/zoom/bounds,
|
||||
// чтобы рефетчи не сбрасывали позицию, которую двигает пользователь
|
||||
const viewKey = bounds ? `b:${JSON.stringify(bounds)}` : `c:${center?.[0]},${center?.[1]}:${zoom}`;
|
||||
const lastViewKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || lastViewKeyRef.current === viewKey) return;
|
||||
lastViewKeyRef.current = viewKey;
|
||||
if (bounds) {
|
||||
map.fitBounds(bounds);
|
||||
} else if (center) {
|
||||
map.setView(center, zoom);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewKey]);
|
||||
|
||||
// Маркеры: перестраиваем слой при изменении данных (без учёта колбэков — они в ref)
|
||||
const markersKey = JSON.stringify(
|
||||
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex])
|
||||
);
|
||||
useEffect(() => {
|
||||
const layer = markersLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
for (const m of markers) {
|
||||
const leafletMarker = L.marker([m.lat, m.lng], {
|
||||
icon: L.divIcon({ html: markerHtml(m), className: '', iconSize: [0, 0] }),
|
||||
zIndexOffset: m.zIndex ?? 0,
|
||||
});
|
||||
if (m.onClick) {
|
||||
leafletMarker.on('click', () => {
|
||||
markerClicksRef.current.get(m.id)?.();
|
||||
});
|
||||
}
|
||||
leafletMarker.addTo(layer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [markersKey]);
|
||||
|
||||
// Полигоны и полилинии
|
||||
const featuresKey = JSON.stringify([
|
||||
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity]),
|
||||
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
|
||||
]);
|
||||
useEffect(() => {
|
||||
const layer = featuresLayerRef.current;
|
||||
if (!layer) return;
|
||||
layer.clearLayers();
|
||||
for (const p of polygons) {
|
||||
if (p.points.length < 3) continue;
|
||||
const color = p.color || DEFAULT_COLOR;
|
||||
L.polygon(p.points, {
|
||||
color,
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: p.fillOpacity ?? 0.15,
|
||||
}).addTo(layer);
|
||||
}
|
||||
for (const l of polylines) {
|
||||
if (l.points.length < 2) continue;
|
||||
L.polyline(l.points, {
|
||||
color: l.color || DEFAULT_COLOR,
|
||||
weight: 4,
|
||||
dashArray: l.dashed ? '8 6' : undefined,
|
||||
}).addTo(layer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [featuresKey]);
|
||||
|
||||
return <div ref={containerRef} className={className} />;
|
||||
}
|
||||
@@ -26,6 +26,10 @@ interface YandexMapProps {
|
||||
bounds?: LngLatBounds;
|
||||
/** Клик по карте (используется для рисования геозон) */
|
||||
onMapClick?: (coordinates: LngLat) => void;
|
||||
/** Скрипт ymaps3 успешно загружен и карта готова к рендеру */
|
||||
onReady?: () => void;
|
||||
/** Ошибка загрузки скрипта/API (используется GpsMap для fallback на OSM) */
|
||||
onError?: (message: string) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
@@ -43,23 +47,40 @@ export function YandexMap({
|
||||
zoom = 10,
|
||||
bounds,
|
||||
onMapClick,
|
||||
onReady,
|
||||
onError,
|
||||
className,
|
||||
children,
|
||||
}: YandexMapProps) {
|
||||
const [ymaps, setYmaps] = useState<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);
|
||||
if (!cancelled) {
|
||||
setYmaps(module);
|
||||
onReadyRef.current?.();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[GPS] Ошибка загрузки Яндекс.Карт:', err);
|
||||
if (!cancelled) setError('Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.');
|
||||
if (!cancelled) {
|
||||
const message = 'Не удалось загрузить Яндекс.Карты. Проверьте API-ключ и доступность api-maps.yandex.ru.';
|
||||
setError(message);
|
||||
onErrorRef.current?.(message);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -12,9 +12,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
||||
import { Loader2, Pencil, Trash2, Undo2, Check, Hexagon } from 'lucide-react';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { YandexMap } from '@/components/gps/YandexMap';
|
||||
import { GpsDotMarker, GpsGeozonePolygon, GpsPolyline } from '@/components/gps/map-features';
|
||||
import { invalidateGps, useGpsConfig, useGpsGeozoneEvents, useGpsGeozones } from '@/hooks/useGps';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap';
|
||||
import { invalidateGps, useGpsGeozoneEvents, useGpsGeozones } from '@/hooks/useGps';
|
||||
import type { GpsGeozone } from './types';
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
@@ -42,7 +41,6 @@ const DEFAULT_FORM: GeozoneFormState = {
|
||||
*/
|
||||
export function GpsGeozonesTab() {
|
||||
const { toast } = useToast();
|
||||
const { data: configData } = useGpsConfig();
|
||||
const { data: geozonesData, isLoading } = useGpsGeozones();
|
||||
const geozones = geozonesData?.geozones || [];
|
||||
|
||||
@@ -139,6 +137,26 @@ export function GpsGeozonesTab() {
|
||||
setEditing(g);
|
||||
};
|
||||
|
||||
// Данные для провайдер-агностичной карты: существующие зоны, строящийся
|
||||
// полигон (замкнутая заливка при 3+ точках, иначе пунктирная линия), вершины
|
||||
const mapPolygons: GpsMapPolygon[] = [
|
||||
...geozones.map((g): GpsMapPolygon => ({ id: g.id, points: g.polygon, color: g.color })),
|
||||
...(draft.length >= 3
|
||||
? [{ id: 'draft', points: draft, color: '#f97316', fillOpacity: 0.25 }]
|
||||
: []),
|
||||
];
|
||||
const mapPolylines: GpsMapPolyline[] =
|
||||
draft.length >= 2 ? [{ id: 'draft-line', points: draft, color: '#f97316', dashed: true }] : [];
|
||||
const draftMarkers: GpsMapMarker[] = draft.map(([lat, lng], idx) => ({
|
||||
id: `draft-${idx}`,
|
||||
lat,
|
||||
lng,
|
||||
kind: 'dot',
|
||||
color: '#f97316',
|
||||
label: String(idx + 1),
|
||||
zIndex: 10,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-4">
|
||||
@@ -234,25 +252,13 @@ export function GpsGeozonesTab() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<YandexMap
|
||||
apiKey={configData?.yandexMapsKey}
|
||||
<GpsMap
|
||||
className="h-[55vh] min-h-[350px] w-full rounded-md overflow-hidden border border-border"
|
||||
onMapClick={
|
||||
drawing
|
||||
? ([lng, lat]) => setDraft((d) => [...d, [lat, lng]])
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{geozones.map((g) => (
|
||||
<GpsGeozonePolygon key={g.id} polygon={g.polygon} color={g.color} />
|
||||
))}
|
||||
{/* Строящийся полигон: замкнутая заливка при 3+ точках, иначе пунктирная линия */}
|
||||
{draft.length >= 3 && <GpsGeozonePolygon polygon={draft} color="#f97316" fillOpacity={0.25} />}
|
||||
{draft.length >= 2 && <GpsPolyline points={draft} color="#f97316" width={2} dashed />}
|
||||
{draft.map(([lat, lng], idx) => (
|
||||
<GpsDotMarker key={idx} coordinates={[lng, lat]} color="#f97316" label={String(idx + 1)} zIndex={10} />
|
||||
))}
|
||||
</YandexMap>
|
||||
onMapClick={drawing ? (lat, lng) => setDraft((d) => [...d, [lat, lng]]) : undefined}
|
||||
polygons={mapPolygons}
|
||||
polylines={mapPolylines}
|
||||
markers={draftMarkers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { X, Route } from 'lucide-react';
|
||||
import { YandexMap, type LngLatBounds } from '@/components/gps/YandexMap';
|
||||
import { GpsAssetMarker, GpsGeozonePolygon } from '@/components/gps/map-features';
|
||||
import { useGpsAssets, useGpsConfig, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap';
|
||||
import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps';
|
||||
import type { GpsPosition } from './types';
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
@@ -17,9 +16,9 @@ function formatDateTime(value: string | null): string {
|
||||
/**
|
||||
* Таб «Карта»: текущие позиции всех активных объектов, геозоны,
|
||||
* фильтр по группе, попап объекта с переходом к маршруту за сегодня.
|
||||
* Провайдер карты (Яндекс/OSM) выбирается внутри GpsMap.
|
||||
*/
|
||||
export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) {
|
||||
const { data: configData } = useGpsConfig();
|
||||
const { data: assetsData } = useGpsAssets();
|
||||
const { data: positionsData } = useGpsPositions();
|
||||
const { data: geozonesData } = useGpsGeozones();
|
||||
@@ -47,15 +46,15 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
);
|
||||
|
||||
// Автоподгонка bounds при первой загрузке позиций (дальше пользователь двигает карту сам)
|
||||
const [bounds, setBounds] = useState<LngLatBounds | null>(null);
|
||||
const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null);
|
||||
useEffect(() => {
|
||||
if (bounds || positions.length === 0) return;
|
||||
const lats = positions.map((p) => p.lat as number);
|
||||
const lngs = positions.map((p) => p.lng as number);
|
||||
const pad = 0.01;
|
||||
setBounds([
|
||||
[Math.min(...lngs) - pad, Math.min(...lats) - pad],
|
||||
[Math.max(...lngs) + pad, Math.max(...lats) + pad],
|
||||
[Math.min(...lats) - pad, Math.min(...lngs) - pad],
|
||||
[Math.max(...lats) + pad, Math.max(...lngs) + pad],
|
||||
]);
|
||||
}, [positions, bounds]);
|
||||
|
||||
@@ -69,6 +68,24 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
const selectedAsset = selected ? assets.find((a) => a.id === selected.assetId) : null;
|
||||
const selectedLive = selected ? positions.find((p) => p.assetId === selected.assetId) ?? selected : null;
|
||||
|
||||
// Маркеры объектов и полигоны геозон для провайдер-агностичной карты
|
||||
const mapMarkers: GpsMapMarker[] = visiblePositions.map((p) => ({
|
||||
id: p.assetId,
|
||||
lat: p.lat as number,
|
||||
lng: p.lng as number,
|
||||
color: p.color,
|
||||
kind: p.type,
|
||||
label: p.name,
|
||||
offline: !p.isOnline,
|
||||
onClick: () => setSelected(p),
|
||||
}));
|
||||
|
||||
const mapPolygons: GpsMapPolygon[] = geozones.map((g) => ({
|
||||
id: `gz-${g.id}`,
|
||||
points: g.polygon,
|
||||
color: g.color,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -91,30 +108,16 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<YandexMap
|
||||
apiKey={configData?.yandexMapsKey}
|
||||
<GpsMap
|
||||
bounds={bounds ?? undefined}
|
||||
markers={mapMarkers}
|
||||
polygons={mapPolygons}
|
||||
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
|
||||
>
|
||||
{geozones.map((g) => (
|
||||
<GpsGeozonePolygon key={`gz-${g.id}`} polygon={g.polygon} color={g.color} />
|
||||
))}
|
||||
{visiblePositions.map((p) => (
|
||||
<GpsAssetMarker
|
||||
key={p.assetId}
|
||||
coordinates={[p.lng as number, p.lat as number]}
|
||||
name={p.name}
|
||||
type={p.type}
|
||||
color={p.color}
|
||||
isOnline={p.isOnline}
|
||||
onClick={() => setSelected(p)}
|
||||
/>
|
||||
))}
|
||||
</YandexMap>
|
||||
/>
|
||||
|
||||
{/* Панель выбранного объекта поверх карты */}
|
||||
{selectedLive && (
|
||||
<Card className="absolute top-2 right-2 w-72 p-3 space-y-2 shadow-lg z-10">
|
||||
<Card className="absolute top-2 right-2 w-72 p-3 space-y-2 shadow-lg z-[1002]">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate">{selectedLive.name}</div>
|
||||
|
||||
@@ -6,9 +6,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { YandexMap, type LngLatBounds } from '@/components/gps/YandexMap';
|
||||
import { GpsDotMarker, GpsPolyline } from '@/components/gps/map-features';
|
||||
import { useGpsAssets, useGpsConfig, useGpsTrack } from '@/hooks/useGps';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap';
|
||||
import { useGpsAssets, useGpsTrack } from '@/hooks/useGps';
|
||||
import type { GpsTrackPoint } from './types';
|
||||
|
||||
/** Расстояние между двумя точками в км (формула гаверсинуса) */
|
||||
@@ -41,7 +40,6 @@ const PLAY_SPEEDS = [1, 5, 20] as const;
|
||||
* статистика (пробег, время, скорость) и анимация воспроизведения.
|
||||
*/
|
||||
export function GpsRoutesTab() {
|
||||
const { data: configData } = useGpsConfig();
|
||||
const { data: assetsData } = useGpsAssets();
|
||||
const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false);
|
||||
|
||||
@@ -98,15 +96,15 @@ export function GpsRoutesTab() {
|
||||
return { distance, maxSpeed, durationMin, startTs, endTs };
|
||||
}, [points]);
|
||||
|
||||
// Автоподгонка карты под трек
|
||||
const bounds: LngLatBounds | undefined = useMemo(() => {
|
||||
// Автоподгонка карты под трек ([[lat, lng], [lat, lng]] — провайдер-агностичный формат GpsMap)
|
||||
const bounds: [[number, number], [number, number]] | undefined = useMemo(() => {
|
||||
if (points.length === 0) return undefined;
|
||||
const lats = points.map((p) => p.lat);
|
||||
const lngs = points.map((p) => p.lng);
|
||||
const pad = 0.005;
|
||||
return [
|
||||
[Math.min(...lngs) - pad, Math.min(...lats) - pad],
|
||||
[Math.max(...lngs) + pad, Math.max(...lats) + pad],
|
||||
[Math.min(...lats) - pad, Math.min(...lngs) - pad],
|
||||
[Math.max(...lats) + pad, Math.max(...lngs) + pad],
|
||||
];
|
||||
}, [points]);
|
||||
|
||||
@@ -167,6 +165,21 @@ export function GpsRoutesTab() {
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда
|
||||
const mapPolylines: GpsMapPolyline[] =
|
||||
trackCoords.length >= 2
|
||||
? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }]
|
||||
: [];
|
||||
const mapMarkers: GpsMapMarker[] = [
|
||||
...(first ? [{ id: 'start', lat: first.lat, lng: first.lng, kind: 'dot' as const, color: '#22c55e', label: 'Старт', zIndex: 5 }] : []),
|
||||
...(last && points.length > 1
|
||||
? [{ id: 'finish', lat: last.lat, lng: last.lng, kind: 'dot' as const, color: '#ef4444', label: 'Финиш', zIndex: 5 }]
|
||||
: []),
|
||||
...(playPoint
|
||||
? [{ id: 'playhead', lat: playPoint.lat, lng: playPoint.lng, kind: 'dot' as const, color: selectedAsset?.color || '#3b82f6', zIndex: 20 }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
@@ -204,24 +217,12 @@ export function GpsRoutesTab() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<YandexMap
|
||||
apiKey={configData?.yandexMapsKey}
|
||||
<GpsMap
|
||||
bounds={bounds}
|
||||
polylines={mapPolylines}
|
||||
markers={mapMarkers}
|
||||
className="h-[55vh] min-h-[350px] w-full rounded-md overflow-hidden border border-border"
|
||||
>
|
||||
<GpsPolyline points={trackCoords} color={selectedAsset?.color || '#3b82f6'} />
|
||||
{first && <GpsDotMarker coordinates={[first.lng, first.lat]} color="#22c55e" label="Старт" zIndex={5} />}
|
||||
{last && points.length > 1 && (
|
||||
<GpsDotMarker coordinates={[last.lng, last.lat]} color="#ef4444" label="Финиш" zIndex={5} />
|
||||
)}
|
||||
{playPoint && (
|
||||
<GpsDotMarker
|
||||
coordinates={[playPoint.lng, playPoint.lat]}
|
||||
color={selectedAsset?.color || '#3b82f6'}
|
||||
zIndex={20}
|
||||
/>
|
||||
)}
|
||||
</YandexMap>
|
||||
/>
|
||||
|
||||
{applied && !isFetching && points.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -110,6 +110,7 @@
|
||||
"jspreadsheet-ce": "^4.13.4",
|
||||
"jsuites": "^6.3.4",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.453.0",
|
||||
"lvovich": "^2.1.0",
|
||||
"mammoth": "^1.12.0",
|
||||
@@ -158,6 +159,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
@@ -6526,6 +6528,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -6569,6 +6578,16 @@
|
||||
"integrity": "sha512-hUK1zZIova7WAABlj1zV+EHMw70h/UergJy6ps+tJBfZ1kvzqNNe2L6RrXts1txyFDWvBxZ7byQ+MkeL+7W4Wg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -11216,6 +11235,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lemonadejs": {
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lemonadejs/-/lemonadejs-5.3.6.tgz",
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
"jspreadsheet-ce": "^4.13.4",
|
||||
"jsuites": "^6.3.4",
|
||||
"jszip": "^3.10.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.453.0",
|
||||
"lvovich": "^2.1.0",
|
||||
"mammoth": "^1.12.0",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/passport": "^1.0.16",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
|
||||
Reference in New Issue
Block a user