feat(gps): скорость в км/ч (конверсия узлов Traccar + backfill), деление маршрута на поездки, проигрывание от любой точки, скорости x100-x1000
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user