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

View File

@@ -12,6 +12,7 @@ interface GpsSettings {
success: boolean;
jitterMeters: number;
maxJumpSpeedKmh: number;
tripStopMinutes: number;
}
/**
@@ -28,20 +29,23 @@ export function GpsSettingsTab() {
const [jitterMeters, setJitterMeters] = useState('0');
const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
const [tripStopMinutes, setTripStopMinutes] = useState('10');
useEffect(() => {
if (data) {
setJitterMeters(String(data.jitterMeters));
setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
setTripStopMinutes(String(data.tripStopMinutes));
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async () => {
// Оба поля сохраняются одним PUT (непереданные на сервере сохраняются)
// Все три поля сохраняются одним PUT (непереданные на сервере сохраняются)
const res = await apiRequest('PUT', '/api/gps/settings', {
jitterMeters: Number(jitterMeters),
maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
tripStopMinutes: Number(tripStopMinutes),
});
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 jitterValid = isIntInRange(jitterMeters, 1000);
const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
const tripValid = isIntInRange(tripStopMinutes, 120);
return (
<div className="max-w-2xl space-y-4">
@@ -113,10 +118,28 @@ export function GpsSettingsTab() {
)}
</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
size="sm"
className="h-8"
disabled={saveMutation.isPending || !jitterValid || !jumpValid}
disabled={saveMutation.isPending || !jitterValid || !jumpValid || !tripValid}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? (