feat(gps): скорость в км/ч (конверсия узлов Traccar + backfill), деление маршрута на поездки, проигрывание от любой точки, скорости x100-x1000

This commit is contained in:
2026-08-26 09:24:29 +03:00
parent a4b508f70e
commit 3e8849019a
6 changed files with 230 additions and 76 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearch } from 'wouter'; import { useSearch } from 'wouter';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -34,7 +35,11 @@ function todayRange(): { from: string; to: string } {
return { from: toLocalInputValue(start), to: toLocalInputValue(now) }; return { from: toLocalInputValue(start), to: toLocalInputValue(now) };
} }
const PLAY_SPEEDS = [1, 5, 20] as const; function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
const PLAY_SPEEDS = [1, 5, 20, 100, 200, 500, 1000] as const;
/** Пороги скорости (км/ч) → цвет сегмента трека */ /** Пороги скорости (км/ч) → цвет сегмента трека */
const SPEED_STOPS: Array<{ max: number; color: string; label: string }> = [ const SPEED_STOPS: Array<{ max: number; color: string; label: string }> = [
@@ -53,9 +58,56 @@ function speedColor(speed: number | null): string {
return SPEED_STOPS.find((s) => speed < s.max)!.color; return SPEED_STOPS.find((s) => speed < s.max)!.color;
} }
/** Поездка: непрерывный участок трека (разрыв точек дольше tripStopMinutes — граница) */
interface Trip {
points: GpsTrackPoint[];
startTs: number;
endTs: number;
distanceKm: number;
}
/** Разбиение точек трека на поездки по перерывам во времени */
function splitIntoTrips(points: GpsTrackPoint[], tripStopMinutes: number): Trip[] {
if (!tripStopMinutes || points.length < 2) return [];
const gapMs = tripStopMinutes * 60_000;
const trips: Trip[] = [];
let current: GpsTrackPoint[] = [points[0]];
const finish = (pts: GpsTrackPoint[]) => {
let distanceKm = 0;
for (let i = 1; i < pts.length; i++) {
distanceKm += haversineKm(pts[i - 1].lat, pts[i - 1].lng, pts[i].lat, pts[i].lng);
}
trips.push({
points: pts,
startTs: new Date(pts[0].recordedAt).getTime(),
endTs: new Date(pts[pts.length - 1].recordedAt).getTime(),
distanceKm,
});
};
for (let i = 1; i < points.length; i++) {
const gap = new Date(points[i].recordedAt).getTime() - new Date(points[i - 1].recordedAt).getTime();
if (gap > gapMs) {
finish(current);
current = [];
}
current.push(points[i]);
}
finish(current);
// Одна поездка на весь трек — делить нечего
return trips.length > 1 ? trips : [];
}
interface GpsSettingsResponse {
success: boolean;
tripStopMinutes: number;
}
/** /**
* Таб «Маршруты»: трек объекта за период — полилиния на карте, * Таб «Маршруты»: трек объекта за период — полилиния на карте (с раскраской
* статистика (пробег, время, скорость) и анимация воспроизведения. * по скорости), деление дня на поездки, статистика и проигрывание с плейхедом.
*/ */
export function GpsRoutesTab() { export function GpsRoutesTab() {
const { data: assetsData } = useGpsAssets(); const { data: assetsData } = useGpsAssets();
@@ -63,6 +115,13 @@ export function GpsRoutesTab() {
const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false); const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false);
const groups = groupsData?.groups || []; const groups = groupsData?.groups || [];
// Настройки модуля (деление на поездки); кэш общий со вкладкой «Настройки»
const { data: settingsData } = useQuery<GpsSettingsResponse>({
queryKey: ['/api/gps/settings'],
staleTime: 60_000,
});
const tripStopMinutes = settingsData?.tripStopMinutes ?? 10;
// Предвыбор объекта из query (?assetId=) — переход «Маршрут за сегодня» с карты // Предвыбор объекта из query (?assetId=) — переход «Маршрут за сегодня» с карты
const search = useSearch(); const search = useSearch();
const preselectedAssetId = useMemo(() => { const preselectedAssetId = useMemo(() => {
@@ -99,97 +158,106 @@ export function GpsRoutesTab() {
const selectedAsset = assets.find((a) => String(a.id) === (applied ? String(applied.assetId) : assetId)); const selectedAsset = assets.find((a) => String(a.id) === (applied ? String(applied.assetId) : assetId));
// Статистика трека // === Поездки ===
const trips = useMemo(() => splitIntoTrips(points, tripStopMinutes), [points, tripStopMinutes]);
// null = «Весь день» (все точки)
const [selectedTrip, setSelectedTrip] = useState<number | null>(null);
const activeTrip = selectedTrip !== null && trips[selectedTrip] ? trips[selectedTrip] : null;
const activePoints = activeTrip ? activeTrip.points : points;
// Сброс выбора поездки при смене трека
useEffect(() => {
setSelectedTrip(null);
}, [trackData]);
// Статистика по выбранному объёму (поездка или весь день)
const stats = useMemo(() => { const stats = useMemo(() => {
if (points.length === 0) return null; if (activePoints.length === 0) return null;
let distance = 0; let distance = 0;
let maxSpeed = 0; let maxSpeed = 0;
for (let i = 1; i < points.length; i++) { for (let i = 1; i < activePoints.length; i++) {
distance += haversineKm(points[i - 1].lat, points[i - 1].lng, points[i].lat, points[i].lng); distance += haversineKm(activePoints[i - 1].lat, activePoints[i - 1].lng, activePoints[i].lat, activePoints[i].lng);
} }
for (const p of points) { for (const p of activePoints) {
if (p.speed != null && p.speed > maxSpeed) maxSpeed = p.speed; if (p.speed != null && p.speed > maxSpeed) maxSpeed = p.speed;
} }
const startTs = new Date(points[0].recordedAt).getTime(); const startTs = new Date(activePoints[0].recordedAt).getTime();
const endTs = new Date(points[points.length - 1].recordedAt).getTime(); const endTs = new Date(activePoints[activePoints.length - 1].recordedAt).getTime();
const durationMin = Math.max(0, Math.round((endTs - startTs) / 60000)); const durationMin = Math.max(0, Math.round((endTs - startTs) / 60000));
return { distance, maxSpeed, durationMin, startTs, endTs }; return { distance, maxSpeed, durationMin, startTs, endTs };
}, [points]); }, [activePoints]);
// Автоподгонка карты под трек ([[lat, lng], [lat, lng]] — провайдер-агностичный формат GpsMap) // Автоподгонка карты под активный трек ([[lat, lng], [lat, lng]] — формат GpsMap)
const bounds: [[number, number], [number, number]] | undefined = useMemo(() => { const bounds: [[number, number], [number, number]] | undefined = useMemo(() => {
if (points.length === 0) return undefined; if (activePoints.length === 0) return undefined;
const lats = points.map((p) => p.lat); const lats = activePoints.map((p) => p.lat);
const lngs = points.map((p) => p.lng); const lngs = activePoints.map((p) => p.lng);
const pad = 0.005; const pad = 0.005;
return [ return [
[Math.min(...lats) - pad, Math.min(...lngs) - pad], [Math.min(...lats) - pad, Math.min(...lngs) - pad],
[Math.max(...lats) + pad, Math.max(...lngs) + pad], [Math.max(...lats) + pad, Math.max(...lngs) + pad],
]; ];
}, [points]); }, [activePoints]);
// === Воспроизведение трека === // === Воспроизведение трека ===
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [playSpeed, setPlaySpeed] = useState<(typeof PLAY_SPEEDS)[number]>(5); const [playSpeed, setPlaySpeed] = useState<(typeof PLAY_SPEEDS)[number]>(5);
const [playTs, setPlayTs] = useState<number | null>(null); const [playTs, setPlayTs] = useState<number | null>(null);
const playRef = useRef<number | null>(null); // Эпоха ручной установки плейхеда: инкремент перезапускает анимацию
// с новой позиции (клик по точке трека во время проигрывания)
const [playEpoch, setPlayEpoch] = useState(0);
const playTsRef = useRef<number | null>(null);
useEffect(() => {
playTsRef.current = playTs;
}, [playTs]);
const stopPlayback = () => { // Полный сброс воспроизведения при смене трека или поездки
useEffect(() => {
setPlaying(false); setPlaying(false);
setPlayTs(null); setPlayTs(null);
if (playRef.current !== null) { }, [trackData, selectedTrip]);
window.clearInterval(playRef.current);
playRef.current = null;
}
};
// Сбрасываем воспроизведение при смене трека
useEffect(() => {
stopPlayback();
}, [trackData]);
// Анимация: виртуальное время пересчитывается от реального elapsed,
// чтобы не плыть на больших скоростях (x1000)
useEffect(() => { useEffect(() => {
if (!playing || !stats) return; if (!playing || !stats) return;
playRef.current = window.setInterval(() => { const startReal = Date.now();
setPlayTs((ts) => { const startVirtual = playTsRef.current ?? stats.startTs;
const next = (ts ?? stats.startTs) + 100 * playSpeed; const timer = window.setInterval(() => {
if (next >= stats.endTs) { const next = startVirtual + (Date.now() - startReal) * playSpeed;
// Конец трека — останавливаемся на последней точке if (next >= stats.endTs) {
window.setTimeout(stopPlayback, 0); // Конец трека — останавливаемся на последней точке, плейхед остаётся
return stats.endTs; setPlayTs(stats.endTs);
} setPlaying(false);
return next; } else {
}); setPlayTs(next);
}, 100);
return () => {
if (playRef.current !== null) {
window.clearInterval(playRef.current);
playRef.current = null;
} }
}; }, 100);
return () => window.clearInterval(timer);
// playTs намеренно не в deps — стартовая позиция читается из ref
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [playing, playSpeed, stats]); }, [playing, playSpeed, stats, playEpoch]);
// Текущая точка воспроизведения: последняя точка с recordedAt <= playTs // Текущая точка воспроизведения: последняя точка с recordedAt <= playTs
const playPoint = useMemo(() => { const playPoint = useMemo(() => {
if (playTs === null || points.length === 0) return null; if (playTs === null || activePoints.length === 0) return null;
let current = points[0]; let current = activePoints[0];
for (const p of points) { for (const p of activePoints) {
if (new Date(p.recordedAt).getTime() <= playTs) current = p; if (new Date(p.recordedAt).getTime() <= playTs) current = p;
else break; else break;
} }
return current; return current;
}, [playTs, points]); }, [playTs, activePoints]);
const trackCoords = points.map((p) => [p.lat, p.lng] as [number, number]); const trackCoords = activePoints.map((p) => [p.lat, p.lng] as [number, number]);
const first = points[0]; const first = activePoints[0];
const last = points[points.length - 1]; const last = activePoints[activePoints.length - 1];
// Точки записи координат вдоль трека: при > 300 прореживаем равномерно // Точки записи координат вдоль трека: при > 300 прореживаем равномерно
const TRACK_POINTS_LIMIT = 300; const TRACK_POINTS_LIMIT = 300;
const trackPointsStep = Math.max(1, Math.ceil(points.length / TRACK_POINTS_LIMIT)); const trackPointsStep = Math.max(1, Math.ceil(activePoints.length / TRACK_POINTS_LIMIT));
const displayedPoints = points.filter((_, idx) => idx % trackPointsStep === 0); const displayedPoints = activePoints.filter((_, idx) => idx % trackPointsStep === 0);
const pointsThinned = displayedPoints.length < points.length; const pointsThinned = displayedPoints.length < activePoints.length;
// Попап точки трека: время, скорость (км/ч), направление, координаты // Попап точки трека: время, скорость (км/ч), направление, координаты
const pointPopupHtml = (p: GpsTrackPoint): string => { const pointPopupHtml = (p: GpsTrackPoint): string => {
@@ -199,6 +267,12 @@ export function GpsRoutesTab() {
return `<b>${time}</b><br/>Скорость: ${speed}${course}<br/>${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`; return `<b>${time}</b><br/>Скорость: ${speed}${course}<br/>${p.lat.toFixed(6)}, ${p.lng.toFixed(6)}`;
}; };
// Клик по точке трека: попап (на уровне карты) + плейхед на эту точку
const seekToPoint = (p: GpsTrackPoint) => {
setPlayTs(new Date(p.recordedAt).getTime());
setPlayEpoch((e) => e + 1);
};
// Раскраска трека по скорости (включена по умолчанию) // Раскраска трека по скорости (включена по умолчанию)
const [colorize, setColorize] = useState(true); const [colorize, setColorize] = useState(true);
@@ -208,9 +282,9 @@ export function GpsRoutesTab() {
// тянут нормально; если Яндекс просядет — порог снизить до 400. // тянут нормально; если Яндекс просядет — порог снизить до 400.
const COLORIZE_POINTS_LIMIT = 800; const COLORIZE_POINTS_LIMIT = 800;
const speedSegments: GpsMapPolyline[] = useMemo(() => { const speedSegments: GpsMapPolyline[] = useMemo(() => {
if (points.length < 2) return []; if (activePoints.length < 2) return [];
const step = Math.max(1, Math.ceil(points.length / COLORIZE_POINTS_LIMIT)); const step = Math.max(1, Math.ceil(activePoints.length / COLORIZE_POINTS_LIMIT));
const pts = step === 1 ? points : points.filter((_, idx) => idx % step === 0); const pts = step === 1 ? activePoints : activePoints.filter((_, idx) => idx % step === 0);
const segments: GpsMapPolyline[] = []; const segments: GpsMapPolyline[] = [];
for (let i = 1; i < pts.length; i++) { for (let i = 1; i < pts.length; i++) {
segments.push({ segments.push({
@@ -223,12 +297,12 @@ export function GpsRoutesTab() {
}); });
} }
return segments; return segments;
}, [points]); }, [activePoints]);
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи. // Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи.
// При включённой раскраске: базовая полилиния из всех точек под низом, // При включённой раскраске: базовая полилиния из всех точек под низом,
// сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек) // сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек)
const colorizeEnabled = colorize && points.length >= 2; const colorizeEnabled = colorize && activePoints.length >= 2;
const mapPolylines: GpsMapPolyline[] = const mapPolylines: GpsMapPolyline[] =
trackCoords.length >= 2 trackCoords.length >= 2
? [ ? [
@@ -241,7 +315,8 @@ export function GpsRoutesTab() {
] ]
: []; : [];
const mapMarkers: GpsMapMarker[] = [ const mapMarkers: GpsMapMarker[] = [
// Точки записи координат (стрелки курса с попапом; в кластер не включаем) // Точки записи координат (стрелки курса с попапом; в кластер не включаем;
// клик — попап + установка плейхеда проигрывания)
...displayedPoints.map((p) => ({ ...displayedPoints.map((p) => ({
id: `tp-${p.id}`, id: `tp-${p.id}`,
lat: p.lat, lat: p.lat,
@@ -250,11 +325,12 @@ export function GpsRoutesTab() {
color: '#64748b', color: '#64748b',
course: p.course ?? undefined, course: p.course ?? undefined,
popup: pointPopupHtml(p), popup: pointPopupHtml(p),
onClick: () => seekToPoint(p),
noCluster: true, noCluster: true,
zIndex: 2, zIndex: 2,
})), })),
...(first ? [{ id: 'start', lat: first.lat, lng: first.lng, kind: 'dot' as const, color: '#22c55e', label: 'Старт', zIndex: 5, noCluster: true }] : []), ...(first ? [{ id: 'start', lat: first.lat, lng: first.lng, kind: 'dot' as const, color: '#22c55e', label: 'Старт', zIndex: 5, noCluster: true }] : []),
...(last && points.length > 1 ...(last && activePoints.length > 1
? [{ id: 'finish', lat: last.lat, lng: last.lng, kind: 'dot' as const, color: '#ef4444', label: 'Финиш', zIndex: 5, noCluster: true }] ? [{ id: 'finish', lat: last.lat, lng: last.lng, kind: 'dot' as const, color: '#ef4444', label: 'Финиш', zIndex: 5, noCluster: true }]
: []), : []),
...(playPoint ...(playPoint
@@ -297,6 +373,32 @@ export function GpsRoutesTab() {
</AssetTreePanel> </AssetTreePanel>
<div className="space-y-3"> <div className="space-y-3">
{/* Поездки дня (перерыв в точках > tripStopMinutes минут — граница) */}
{trips.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<Button
size="sm"
variant={selectedTrip === null ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => setSelectedTrip(null)}
>
Весь день
</Button>
{trips.map((trip, idx) => (
<Button
key={idx}
size="sm"
variant={selectedTrip === idx ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => setSelectedTrip(idx)}
title={`${trip.points.length} точек`}
>
Поездка {idx + 1} · {formatTime(trip.startTs)}–{formatTime(trip.endTs)} · {trip.distanceKm.toFixed(1)} км
</Button>
))}
</div>
)}
<GpsMap <GpsMap
bounds={bounds} bounds={bounds}
polylines={mapPolylines} polylines={mapPolylines}
@@ -328,8 +430,9 @@ export function GpsRoutesTab() {
<CardContent className="p-3 space-y-3"> <CardContent className="p-3 space-y-3">
<div className="flex items-center gap-4 flex-wrap text-xs text-muted-foreground"> <div className="flex items-center gap-4 flex-wrap text-xs text-muted-foreground">
<span> <span>
Точек: <span className="text-foreground font-medium">{points.length}</span> Точек: <span className="text-foreground font-medium">{activePoints.length}</span>
{trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`} {activeTrip && ` (поездка ${selectedTrip! + 1} из ${trips.length})`}
{!activeTrip && trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`}
{pointsThinned && `, маркеров на карте: ${displayedPoints.length}`} {pointsThinned && `, маркеров на карте: ${displayedPoints.length}`}
</span> </span>
{(trackData?.filteredCount ?? 0) > 0 && ( {(trackData?.filteredCount ?? 0) > 0 && (
@@ -351,16 +454,21 @@ export function GpsRoutesTab() {
Макс. скорость: <span className="text-foreground font-medium">{Math.round(stats.maxSpeed)} км/ч</span> Макс. скорость: <span className="text-foreground font-medium">{Math.round(stats.maxSpeed)} км/ч</span>
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8" className="h-8"
onClick={() => { onClick={() => {
if (playing) { if (playing) {
stopPlayback(); // Пауза: плейхед остаётся на текущей позиции
setPlaying(false);
} else { } else {
setPlayTs((ts) => ts ?? stats.startTs); // Старт: от текущего плейхеда (в т.ч. установленного кликом
// по точке); плейхед в конце или не задан — с начала трека
if (playTs === null || playTs >= stats.endTs) {
setPlayTs(stats.startTs);
}
setPlaying(true); setPlaying(true);
} }
}} }}
@@ -368,7 +476,7 @@ export function GpsRoutesTab() {
{playing ? <Pause className="w-3.5 h-3.5 mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />} {playing ? <Pause className="w-3.5 h-3.5 mr-1" /> : <Play className="w-3.5 h-3.5 mr-1" />}
{playing ? 'Пауза' : 'Проиграть'} {playing ? 'Пауза' : 'Проиграть'}
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1 flex-wrap">
{PLAY_SPEEDS.map((s) => ( {PLAY_SPEEDS.map((s) => (
<Button <Button
key={s} key={s}

View File

@@ -12,6 +12,7 @@ interface GpsSettings {
success: boolean; success: boolean;
jitterMeters: number; jitterMeters: number;
maxJumpSpeedKmh: number; maxJumpSpeedKmh: number;
tripStopMinutes: number;
} }
/** /**
@@ -28,20 +29,23 @@ export function GpsSettingsTab() {
const [jitterMeters, setJitterMeters] = useState('0'); const [jitterMeters, setJitterMeters] = useState('0');
const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0'); const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
const [tripStopMinutes, setTripStopMinutes] = useState('10');
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setJitterMeters(String(data.jitterMeters)); setJitterMeters(String(data.jitterMeters));
setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh)); setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
setTripStopMinutes(String(data.tripStopMinutes));
} }
}, [data]); }, [data]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
// Оба поля сохраняются одним PUT (непереданные на сервере сохраняются) // Все три поля сохраняются одним PUT (непереданные на сервере сохраняются)
const res = await apiRequest('PUT', '/api/gps/settings', { const res = await apiRequest('PUT', '/api/gps/settings', {
jitterMeters: Number(jitterMeters), jitterMeters: Number(jitterMeters),
maxJumpSpeedKmh: Number(maxJumpSpeedKmh), maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
tripStopMinutes: Number(tripStopMinutes),
}); });
return res.json(); return res.json();
}, },
@@ -60,6 +64,7 @@ export function GpsSettingsTab() {
const isIntInRange = (v: string, max: number) => /^\d+$/.test(v.trim()) && Number(v) >= 0 && Number(v) <= max; const isIntInRange = (v: string, max: number) => /^\d+$/.test(v.trim()) && Number(v) >= 0 && Number(v) <= max;
const jitterValid = isIntInRange(jitterMeters, 1000); const jitterValid = isIntInRange(jitterMeters, 1000);
const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000); const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
const tripValid = isIntInRange(tripStopMinutes, 120);
return ( return (
<div className="max-w-2xl space-y-4"> <div className="max-w-2xl space-y-4">
@@ -113,10 +118,28 @@ export function GpsSettingsTab() {
)} )}
</div> </div>
<div className="space-y-1">
<Label className="text-xs">Остановка — конец поездки, минут</Label>
<Input
type="text"
inputMode="numeric"
className="h-8 w-32"
value={tripStopMinutes}
onChange={(e) => setTripStopMinutes(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
0 = не делить на поездки. Перерыв в точках дольше N минут при стоянке разбивает
маршрут дня на отдельные поездки на вкладке «Маршруты».
</p>
{!tripValid && (
<p className="text-xs text-destructive">Целое число от 0 до 120</p>
)}
</div>
<Button <Button
size="sm" size="sm"
className="h-8" className="h-8"
disabled={saveMutation.isPending || !jitterValid || !jumpValid} disabled={saveMutation.isPending || !jitterValid || !jumpValid || !tripValid}
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
> >
{saveMutation.isPending ? ( {saveMutation.isPending ? (

View File

@@ -0,0 +1,9 @@
-- Скорости из Traccar были сохранены в узлах — домножаем историю на 1.852
-- (только трекерные объекты с external_id; у PWA-персон уже км/ч).
UPDATE gps_positions p SET speed = ROUND((p.speed * 1.852)::numeric, 1)
FROM gps_assets a WHERE p.asset_id = a.id AND a.external_id IS NOT NULL AND p.speed IS NOT NULL;
UPDATE gps_assets SET last_speed = ROUND((last_speed * 1.852)::numeric, 1) WHERE external_id IS NOT NULL AND last_speed IS NOT NULL;
-- Настройка деления трека на поездки: стоянка дольше N минут = конец поездки
-- (0 = не делить на поездки).
ALTER TABLE gps_settings ADD COLUMN IF NOT EXISTS trip_stop_minutes INTEGER NOT NULL DEFAULT 10;

View File

@@ -58,11 +58,13 @@ function normalizePoint(raw: any): NormalizedPoint | null {
const lat = parseNum(p.latitude); const lat = parseNum(p.latitude);
const lng = parseNum(p.longitude); const lng = parseNum(p.longitude);
if (lat === null || lng === null) return null; if (lat === null || lng === null) return null;
// Traccar отдаёт скорость в узлах (knots) — в БД храним км/ч
const speedKnots = parseNum(p.speed);
return { return {
externalId: String(raw.device.uniqueId), externalId: String(raw.device.uniqueId),
lat, lat,
lng, lng,
speed: parseNum(p.speed), speed: speedKnots !== null ? Math.round(speedKnots * 1.852 * 10) / 10 : null,
course: parseNum(p.course), course: parseNum(p.course),
accuracy: parseNum(p.accuracy), accuracy: parseNum(p.accuracy),
recordedAt: parseDateParam(p.fixTime ?? p.deviceTime), recordedAt: parseDateParam(p.fixTime ?? p.deviceTime),
@@ -178,13 +180,15 @@ export function registerGpsRoutes(app: Express) {
router.put("/settings", async (req: AuthenticatedRequest, res: Response) => { router.put("/settings", async (req: AuthenticatedRequest, res: Response) => {
// Обновляем только переданные поля (обратная совместимость с клиентом, // Обновляем только переданные поля (обратная совместимость с клиентом,
// который шлёт только jitterMeters) // который шлёт только часть настроек)
const current = await gpsStorage.getSettings(req.user!.organizationId); const current = await gpsStorage.getSettings(req.user!.organizationId);
const jitterMeters = const jitterMeters =
req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters; req.body?.jitterMeters !== undefined ? Number(req.body.jitterMeters) : current.jitterMeters;
const maxJumpSpeedKmh = const maxJumpSpeedKmh =
req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh; req.body?.maxJumpSpeedKmh !== undefined ? Number(req.body.maxJumpSpeedKmh) : current.maxJumpSpeedKmh;
const tripStopMinutes =
req.body?.tripStopMinutes !== undefined ? Number(req.body.tripStopMinutes) : current.tripStopMinutes;
if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) { if (!Number.isInteger(jitterMeters) || jitterMeters < 0 || jitterMeters > 1000) {
return badRequest(res, "jitterMeters: целое число от 0 до 1000"); return badRequest(res, "jitterMeters: целое число от 0 до 1000");
@@ -192,9 +196,17 @@ export function registerGpsRoutes(app: Express) {
if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) { if (!Number.isInteger(maxJumpSpeedKmh) || maxJumpSpeedKmh < 0 || maxJumpSpeedKmh > 5000) {
return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)"); return badRequest(res, "maxJumpSpeedKmh: целое число от 0 до 5000 (0 = фильтр выключен)");
} }
if (!Number.isInteger(tripStopMinutes) || tripStopMinutes < 0 || tripStopMinutes > 120) {
return badRequest(res, "tripStopMinutes: целое число от 0 до 120 (0 = не делить на поездки)");
}
const row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh }); const row = await gpsStorage.upsertSettings(req.user!.organizationId, { jitterMeters, maxJumpSpeedKmh, tripStopMinutes });
res.json({ success: true, jitterMeters: row.jitterMeters, maxJumpSpeedKmh: row.maxJumpSpeedKmh }); res.json({
success: true,
jitterMeters: row.jitterMeters,
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
});
}); });
// ===================== // =====================

View File

@@ -462,7 +462,7 @@ export class GpsStorage {
// ===================== // =====================
/** Настройки GPS организации; если строки нет — дефолты */ /** Настройки GPS организации; если строки нет — дефолты */
async getSettings(organizationId: number): Promise<{ jitterMeters: number; maxJumpSpeedKmh: number }> { async getSettings(organizationId: number): Promise<{ jitterMeters: number; maxJumpSpeedKmh: number; tripStopMinutes: number }> {
const [row] = await db const [row] = await db
.select() .select()
.from(gpsSettings) .from(gpsSettings)
@@ -470,12 +470,13 @@ export class GpsStorage {
return { return {
jitterMeters: row?.jitterMeters ?? 25, jitterMeters: row?.jitterMeters ?? 25,
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250, maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
tripStopMinutes: row?.tripStopMinutes ?? 10,
}; };
} }
async upsertSettings( async upsertSettings(
organizationId: number, organizationId: number,
settings: { jitterMeters: number; maxJumpSpeedKmh: number } settings: { jitterMeters: number; maxJumpSpeedKmh: number; tripStopMinutes: number }
): Promise<GpsSettings> { ): Promise<GpsSettings> {
const [row] = await db const [row] = await db
.insert(gpsSettings) .insert(gpsSettings)

View File

@@ -4114,6 +4114,7 @@ export const gpsSettings = pgTable("gps_settings", {
organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }), organizationId: integer("organization_id").primaryKey().references(() => organizations.id, { onDelete: "cascade" }),
jitterMeters: integer("jitter_meters").notNull().default(25), jitterMeters: integer("jitter_meters").notNull().default(25),
maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250), maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
tripStopMinutes: integer("trip_stop_minutes").notNull().default(10),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });