Files
iistwin/client/src/pages/gps/MapTab.tsx

159 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState } from 'react';
import { Card } from '@/components/ui/card';
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 { 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 {
if (!value) return '—';
return new Date(value).toLocaleString('ru-RU');
}
/**
* Таб «Карта»: текущие позиции всех активных объектов, геозоны,
* фильтр по группе, попап объекта с переходом к маршруту за сегодня.
* Провайдер карты (Яндекс/OSM) выбирается внутри GpsMap.
*/
export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) {
const { data: assetsData } = useGpsAssets();
const { data: positionsData } = useGpsPositions();
const { data: geozonesData } = useGpsGeozones();
const { data: groupsData } = useGpsGroups();
useGpsLiveUpdates();
const [groupFilter, setGroupFilter] = useState<string>('all');
const [selected, setSelected] = useState<GpsPosition | null>(null);
const assets = assetsData?.assets || [];
const positions = (positionsData?.positions || []).filter((p) => p.lat != null && p.lng != null);
const geozones = (geozonesData?.geozones || []).filter((g) => g.isActive !== false);
const groups = groupsData?.groups || [];
// Фильтр по группе: принадлежность объекта группам берём из списка assets
const groupAssetIds = useMemo(() => {
if (groupFilter === 'all') return null;
const group = groups.find((g) => String(g.id) === groupFilter);
return group ? new Set(group.assetIds) : new Set<number>();
}, [groupFilter, groups]);
const visiblePositions = useMemo(
() => (groupAssetIds ? positions.filter((p) => groupAssetIds.has(p.assetId)) : positions),
[positions, groupAssetIds]
);
// Автоподгонка bounds при первой загрузке позиций (дальше пользователь двигает карту сам)
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(...lats) - pad, Math.min(...lngs) - pad],
[Math.max(...lats) + pad, Math.max(...lngs) + pad],
]);
}, [positions, bounds]);
// Если выбранный объект пропал из позиций (стал неактивным) — закрываем панель
useEffect(() => {
if (selected && !positions.some((p) => p.assetId === selected.assetId)) {
setSelected(null);
}
}, [positions, selected]);
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">
<Select value={groupFilter} onValueChange={setGroupFilter}>
<SelectTrigger className="w-56 h-8 text-xs">
<SelectValue placeholder="Все группы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Все группы</SelectItem>
{groups.map((g) => (
<SelectItem key={g.id} value={String(g.id)}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
Объектов на карте: {visiblePositions.length}
</span>
</div>
<div className="relative">
<GpsMap
bounds={bounds ?? undefined}
markers={mapMarkers}
polygons={mapPolygons}
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
/>
{/* Панель выбранного объекта поверх карты */}
{selectedLive && (
<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>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant="outline" className="text-[10px]">
{selectedLive.type === 'person' ? 'Человек' : 'Транспорт'}
</Badge>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: selectedLive.isOnline ? '#22c55e' : '#9ca3af' }}
/>
{selectedLive.isOnline ? 'Онлайн' : 'Офлайн'}
</span>
</div>
</div>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setSelected(null)}>
<X className="w-3.5 h-3.5" />
</Button>
</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<div>
Скорость:{' '}
{selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
</div>
<div>Последняя точка: {formatDateTime(selectedLive.recordedAt)}</div>
{selectedAsset?.externalId && <div>Трекер: {selectedAsset.externalId}</div>}
</div>
<Button size="sm" className="w-full h-8" onClick={() => onShowRoute(selectedLive.assetId)}>
<Route className="w-3.5 h-3.5 mr-1" />
Маршрут за сегодня
</Button>
</Card>
)}
</div>
</div>
);
}