feat(gps): last_seen по времени приёма, фильтр джиттера (настройки), линейка, трек по скорости; fix: карта не перекрывает диалоги (isolation)

This commit is contained in:
2026-08-25 17:01:44 +03:00
parent 670cecfe05
commit b635d1e16e
12 changed files with 453 additions and 34 deletions

View File

@@ -218,7 +218,9 @@ export function GpsMap({
}
return (
<div className={cn('relative', className)}>
// isolate создаёт stacking context: z-index'ы Leaflet-панов (до 1000)
// не выныривают поверх диалогов shadcn (z-50) снаружи карты
<div className={cn('relative isolate', className)}>
{provider === 'yandex' ? (
<Yandex21Map
apiKey={apiKey!}

View File

@@ -176,7 +176,16 @@ export function GpsAssetsTab() {
</span>
</TableCell>
<TableCell className="text-xs font-mono">{asset.externalId || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDateTime(asset.lastSeenAt)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
<div>{formatDateTime(asset.lastSeenAt)}</div>
{/* Время точки по устройству, если сильно отстаёт от приёма сервером */}
{asset.lastSeenAt &&
asset.lastRecordedAt &&
Math.abs(new Date(asset.lastSeenAt).getTime() - new Date(asset.lastRecordedAt).getTime()) >
3_600_000 && (
<div className="text-[10px]">Точка от: {formatDateTime(asset.lastRecordedAt)}</div>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 flex-wrap">
{(asset.groups || []).map((g) => (

View File

@@ -2,18 +2,34 @@ 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 { X, Route } from 'lucide-react';
import { GpsMap, type GpsMapMarker, type GpsMapPolygon } from '@/components/gps/GpsMap';
import { X, Route, Ruler } from 'lucide-react';
import { GpsMap, type GpsMapMarker, type GpsMapPolygon, type GpsMapPolyline } from '@/components/gps/GpsMap';
import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
import { useGpsAssets, useGpsGeozones, useGpsGroups, useGpsLiveUpdates, useGpsPositions } from '@/hooks/useGps';
import { useToast } from '@/hooks/use-toast';
import type { GpsAsset, GpsPosition } from './types';
function formatDateTime(value: string | null): string {
function formatDateTime(value: string | null | undefined): string {
if (!value) return '—';
return new Date(value).toLocaleString('ru-RU');
}
/** Расстояние между двумя точками в км (формула гаверсинуса) */
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(a));
}
/** Форматирование расстояния линейки: «1.24 км» / «850 м» */
function formatDistance(km: number): string {
return km >= 1 ? `${km.toFixed(2)} км` : `${Math.round(km * 1000)} м`;
}
/** Вид карты: либо автоподгонка по bounds, либо центрирование на точке */
interface MapView {
bounds?: [[number, number], [number, number]];
@@ -23,7 +39,7 @@ interface MapView {
/**
* Таб «Карта»: слева панель-дерево объектов (группы/поиск/фильтр),
* справа — текущие позиции на карте, геозоны, попап объекта.
* справа — текущие позиции на карте, геозоны, попап объекта, линейка.
*/
export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => void }) {
const { toast } = useToast();
@@ -76,6 +92,38 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
}
}, [positions, selected]);
// === Линейка: измерение расстояния кликами по карте ===
const [rulerMode, setRulerMode] = useState(false);
const [rulerPoints, setRulerPoints] = useState<Array<[number, number]>>([]);
const toggleRuler = () => {
setRulerMode((m) => !m);
setRulerPoints([]);
setSelected(null);
};
// Выход из режима линейки по Esc
useEffect(() => {
if (!rulerMode) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setRulerMode(false);
setRulerPoints([]);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [rulerMode]);
// Суммарное расстояние по точкам линейки
const rulerDistanceKm = useMemo(() => {
let total = 0;
for (let i = 1; i < rulerPoints.length; i++) {
total += haversineKm(rulerPoints[i - 1][0], rulerPoints[i - 1][1], rulerPoints[i][0], rulerPoints[i][1]);
}
return total;
}, [rulerPoints]);
// Клик по объекту в дереве: центрируем карту и открываем попап;
// объект без координат — только подсказка
const handleSelectAsset = (asset: GpsAsset) => {
@@ -91,17 +139,33 @@ 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 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: rulerMode ? undefined : () => setSelected(p),
})),
...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({
id: `ruler-${idx}`,
lat,
lng,
kind: 'dot',
color: '#3b82f6',
noCluster: true,
zIndex: 15,
// У последней точки линейки — суммарное расстояние
...(idx === rulerPoints.length - 1 && rulerPoints.length > 1
? { label: formatDistance(rulerDistanceKm) }
: {}),
})),
];
const mapPolygons: GpsMapPolygon[] = geozones.map((g) => ({
id: `gz-${g.id}`,
@@ -109,6 +173,16 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
color: g.color,
}));
const mapPolylines: GpsMapPolyline[] =
rulerPoints.length >= 2 ? [{ id: 'ruler', points: rulerPoints, color: '#3b82f6' }] : [];
// Время в попапе: «Обновлено» — приём точки сервером (реальная активность);
// «Точка от» — время по устройству, если отстаёт больше чем на час
const seenAt = selectedLive ? selectedLive.lastSeenAt ?? selectedLive.recordedAt : null;
const recordedAt = selectedLive?.lastRecordedAt ?? null;
const recordedStale =
seenAt && recordedAt && Math.abs(new Date(seenAt).getTime() - new Date(recordedAt).getTime()) > 3_600_000;
return (
<div className="grid grid-cols-1 md:grid-cols-[280px_1fr] gap-4">
<AssetTreePanel
@@ -121,8 +195,25 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
/>
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Объектов на карте: {visiblePositions.length}
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant={rulerMode ? 'default' : 'outline'}
className="h-7 text-xs"
onClick={toggleRuler}
>
<Ruler className="w-3.5 h-3.5 mr-1" />
Линейка
</Button>
{rulerMode ? (
<span className="text-xs text-muted-foreground">
Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход
</span>
) : (
<span className="text-xs text-muted-foreground">
Объектов на карте: {visiblePositions.length}
</span>
)}
</div>
<div className="relative">
<GpsMap
@@ -131,6 +222,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
zoom={view.zoom ?? 10}
markers={mapMarkers}
polygons={mapPolygons}
polylines={mapPolylines}
onMapClick={rulerMode ? (lat, lng) => setRulerPoints((p) => [...p, [lat, lng]]) : undefined}
className="h-[60vh] min-h-[400px] w-full rounded-md overflow-hidden border border-border"
/>
@@ -162,7 +255,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
Скорость:{' '}
{selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
</div>
<div>Последняя точка: {formatDateTime(selectedLive.recordedAt)}</div>
<div>Обновлено: {formatDateTime(seenAt)}</div>
{recordedStale && <div>Точка от: {formatDateTime(recordedAt)}</div>}
{selectedAsset?.externalId && <div>Трекер: {selectedAsset.externalId}</div>}
</div>
<Button size="sm" className="w-full h-8" onClick={() => onShowRoute(selectedLive.assetId)}>

View File

@@ -4,6 +4,7 @@ import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, Pause, Play } from 'lucide-react';
import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap';
import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
@@ -35,6 +36,23 @@ function todayRange(): { from: string; to: string } {
const PLAY_SPEEDS = [1, 5, 20] as const;
/** Пороги скорости (км/ч) → цвет сегмента трека */
const SPEED_STOPS: Array<{ max: number; color: string; label: string }> = [
{ max: 30, color: '#22c55e', label: '<30' },
{ max: 60, color: '#84cc16', label: '30–60' },
{ max: 90, color: '#eab308', label: '60–90' },
{ max: 110, color: '#f97316', label: '90–110' },
{ max: 130, color: '#ef4444', label: '110–130' },
{ max: Infinity, color: '#b91c1c', label: '>130' },
];
const SPEED_UNKNOWN_COLOR = '#94a3b8';
/** Цвет сегмента трека по скорости в точке (км/ч); null — серый */
function speedColor(speed: number | null): string {
if (speed == null) return SPEED_UNKNOWN_COLOR;
return SPEED_STOPS.find((s) => speed < s.max)!.color;
}
/**
* Таб «Маршруты»: трек объекта за период — полилиния на карте,
* статистика (пробег, время, скорость) и анимация воспроизведения.
@@ -181,10 +199,46 @@ export function GpsRoutesTab() {
return `<b>${time}</b><br/>Скорость: ${speed}${course}<br/>${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`;
};
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи
// Раскраска трека по скорости (включена по умолчанию)
const [colorize, setColorize] = useState(true);
// Сегменты трека, окрашенные по скорости в начальной точке сегмента.
// При > 800 точек прореживаем до ~800 для раскраски (базовая полилиния
// из всех точек остаётся под низом). 800 сегментов оба провайдера
// тянут нормально; если Яндекс просядет — порог снизить до 400.
const COLORIZE_POINTS_LIMIT = 800;
const speedSegments: GpsMapPolyline[] = useMemo(() => {
if (points.length < 2) return [];
const step = Math.max(1, Math.ceil(points.length / COLORIZE_POINTS_LIMIT));
const pts = step === 1 ? points : points.filter((_, idx) => idx % step === 0);
const segments: GpsMapPolyline[] = [];
for (let i = 1; i < pts.length; i++) {
segments.push({
id: `seg-${i}`,
points: [
[pts[i - 1].lat, pts[i - 1].lng],
[pts[i].lat, pts[i].lng],
],
color: speedColor(pts[i - 1].speed),
});
}
return segments;
}, [points]);
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи.
// При включённой раскраске: базовая полилиния из всех точек под низом,
// сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек)
const colorizeEnabled = colorize && points.length >= 2;
const mapPolylines: GpsMapPolyline[] =
trackCoords.length >= 2
? [{ id: 'track', points: trackCoords, color: selectedAsset?.color || '#3b82f6' }]
? [
{
id: 'track',
points: trackCoords,
color: colorizeEnabled ? '#94a3b8' : selectedAsset?.color || '#3b82f6',
},
...(colorizeEnabled ? speedSegments : []),
]
: [];
const mapMarkers: GpsMapMarker[] = [
// Точки записи координат (стрелки курса с попапом; в кластер не включаем)
@@ -235,6 +289,10 @@ export function GpsRoutesTab() {
{isFetching && <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />}
Показать
</Button>
<label className="flex items-center gap-2 pt-1 cursor-pointer">
<Checkbox checked={colorize} onCheckedChange={(v) => setColorize(!!v)} />
<span className="text-xs">Раскрасить по скорости</span>
</label>
</div>
</AssetTreePanel>
@@ -246,6 +304,19 @@ export function GpsRoutesTab() {
className="h-[55vh] min-h-[350px] w-full rounded-md overflow-hidden border border-border"
/>
{/* Легенда раскраски трека по скорости */}
{colorizeEnabled && (
<div className="flex items-center gap-2 flex-wrap text-[10px] text-muted-foreground">
{SPEED_STOPS.map((s) => (
<span key={s.label} className="flex items-center gap-1">
<span className="w-4 h-1.5 rounded-sm" style={{ backgroundColor: s.color }} />
{s.label}
</span>
))}
<span>км/ч</span>
</div>
)}
{applied && !isFetching && points.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
За выбранный период точек нет

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Loader2, Save, SlidersHorizontal } from 'lucide-react';
import { apiRequest } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
interface GpsSettings {
success: boolean;
jitterMeters: number;
}
/**
* Таб «Настройки» GPS-модуля.
* Секция «Трекинг»: фильтр дрожания позиции (джиттер) — сервер не записывает
* точки ближе N метров при скорости < 5 км/ч. Сюда же позже добавятся
* другие настройки модуля — структура секций-Card под это готова.
*/
export function GpsSettingsTab() {
const { toast } = useToast();
const { data, isLoading } = useQuery<GpsSettings>({
queryKey: ['/api/gps/settings'],
});
const [jitterMeters, setJitterMeters] = useState('0');
useEffect(() => {
if (data) setJitterMeters(String(data.jitterMeters));
}, [data]);
const saveMutation = useMutation({
mutationFn: async () => {
const value = Number(jitterMeters);
const res = await apiRequest('PUT', '/api/gps/settings', { jitterMeters: value });
return res.json();
},
onSuccess: () => {
toast({ title: 'Настройки сохранены' });
},
onError: (err: any) => {
toast({
title: 'Ошибка',
description: err?.message || 'Не удалось сохранить настройки',
variant: 'destructive',
});
},
});
const jitterValid = /^\d+$/.test(jitterMeters.trim()) && Number(jitterMeters) >= 0 && Number(jitterMeters) <= 1000;
return (
<div className="max-w-2xl space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<SlidersHorizontal className="w-4 h-4" />
Трекинг
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center h-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="space-y-1">
<Label className="text-xs">Фильтр дрожания позиции, метров</Label>
<Input
type="text"
inputMode="numeric"
className="h-8 w-32"
value={jitterMeters}
onChange={(e) => setJitterMeters(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
0 = выключен. Точки ближе N метров при скорости &lt; 5 км/ч не записываются — убирает
«кучу мелких перемещений» у стоящего объекта. Действует на новые точки.
</p>
{!jitterValid && (
<p className="text-xs text-destructive">Целое число от 0 до 1000</p>
)}
</div>
<Button
size="sm"
className="h-8"
disabled={saveMutation.isPending || !jitterValid}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" />
) : (
<Save className="w-3.5 h-3.5 mr-1" />
)}
Сохранить
</Button>
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useLocation } from 'wouter';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Map, Truck, Hexagon, Route, Folder, BookOpen } from 'lucide-react';
import { Map, Truck, Hexagon, Route, Folder, BookOpen, Settings } from 'lucide-react';
import Layout from '@/components/Layout';
import { GpsMapTab } from './MapTab';
import { GpsAssetsTab } from './AssetsTab';
@@ -8,6 +8,7 @@ import { GpsGeozonesTab } from './GeozonesTab';
import { GpsRoutesTab } from './RoutesTab';
import { GpsGroupsTab } from './GroupsTab';
import { GpsGuidesTab } from './GuidesTab';
import { GpsSettingsTab } from './SettingsTab';
const TABS = [
{ value: 'map', label: 'Карта', icon: Map },
@@ -16,6 +17,7 @@ const TABS = [
{ value: 'routes', label: 'Маршруты', icon: Route },
{ value: 'groups', label: 'Группы', icon: Folder },
{ value: 'guides', label: 'Инструкции', icon: BookOpen },
{ value: 'settings', label: 'Настройки', icon: Settings },
];
/**
@@ -62,6 +64,9 @@ export default function GpsPage() {
<TabsContent value="guides">
<GpsGuidesTab />
</TabsContent>
<TabsContent value="settings">
<GpsSettingsTab />
</TabsContent>
</Tabs>
</div>
</Layout>

View File

@@ -20,6 +20,8 @@ export interface GpsAsset {
lastSpeed: number | null;
lastCourse: number | null;
lastSeenAt: string | null;
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
lastRecordedAt?: string | null;
createdAt: string | null;
/** Группы объекта (добавляет GET /api/gps/assets) */
groups?: Array<{ id: number; name: string }>;
@@ -38,7 +40,12 @@ export interface GpsPosition {
lng: number | null;
speed: number | null;
course: number | null;
/** Время приёма последней точки сервером (= реальная активность объекта) */
recordedAt: string | null;
/** Время приёма последней точки сервером */
lastSeenAt?: string | null;
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
lastRecordedAt?: string | null;
}
/** Точка трека (GET /api/gps/assets/:id/track) */