+ {/* Время точки по устройству, если сильно отстаёт от приёма сервером */}
+ {asset.lastSeenAt &&
+ asset.lastRecordedAt &&
+ Math.abs(new Date(asset.lastSeenAt).getTime() - new Date(asset.lastRecordedAt).getTime()) >
+ 3_600_000 && (
+
Точка от: {formatDateTime(asset.lastRecordedAt)}
+ )}
+
{(asset.groups || []).map((g) => (
diff --git a/client/src/pages/gps/MapTab.tsx b/client/src/pages/gps/MapTab.tsx
index 46a4fb6..b5b3c67 100644
--- a/client/src/pages/gps/MapTab.tsx
+++ b/client/src/pages/gps/MapTab.tsx
@@ -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>([]);
+
+ 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 (
v
/>
-
- Объектов на карте: {visiblePositions.length}
+
+
+ {rulerMode ? (
+
+ Линейка: кликайте по карте, Esc/повторный клик по кнопке — выход
+
+ ) : (
+
+ Объектов на карте: {visiblePositions.length}
+
+ )}
За выбранный период точек нет
diff --git a/client/src/pages/gps/SettingsTab.tsx b/client/src/pages/gps/SettingsTab.tsx
new file mode 100644
index 0000000..e25f02a
--- /dev/null
+++ b/client/src/pages/gps/SettingsTab.tsx
@@ -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({
+ 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 (
+
+
+
+
+
+ Трекинг
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+ setJitterMeters(e.target.value)}
+ />
+
+ 0 = выключен. Точки ближе N метров при скорости < 5 км/ч не записываются — убирает
+ «кучу мелких перемещений» у стоящего объекта. Действует на новые точки.
+