feat(gps): бесплатная подложка OSM с авто-fallback и ручным переключателем провайдера карты

This commit is contained in:
2026-08-24 21:05:22 +03:00
parent 541248b103
commit a6a42fdb15
8 changed files with 652 additions and 74 deletions

View 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>
);
}

View 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, '&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,
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} />;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View File

@@ -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",

View File

@@ -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",