From 3e8849019afa010c48838b073721470997b9a8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 26 Aug 2026 09:24:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D1=81=D0=BA=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=20=D0=B2=20=D0=BA=D0=BC/=D1=87=20(=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D1=83=D0=B7?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20Traccar=20+=20backfill),=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B5=D0=B7?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8,=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=20=D0=BB?= =?UTF-8?q?=D1=8E=D0=B1=D0=BE=D0=B9=20=D1=82=D0=BE=D1=87=D0=BA=D0=B8,=20?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82=D0=B8=20x100-x1000?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/gps/RoutesTab.tsx | 244 +++++++++++++++++------- client/src/pages/gps/SettingsTab.tsx | 27 ++- migrations/0072_gps_speed_kmh_trips.sql | 9 + server/gps/routes.ts | 20 +- server/gps/storage.ts | 5 +- shared/schema.ts | 1 + 6 files changed, 230 insertions(+), 76 deletions(-) create mode 100644 migrations/0072_gps_speed_kmh_trips.sql diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index a294620..b6c3cde 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -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({ + 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(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(null); - const playRef = useRef(null); + // Эпоха ручной установки плейхеда: инкремент перезапускает анимацию + // с новой позиции (клик по точке трека во время проигрывания) + const [playEpoch, setPlayEpoch] = useState(0); + const playTsRef = useRef(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 `${time}
Скорость: ${speed}${course}
${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() {
+ {/* Поездки дня (перерыв в точках > tripStopMinutes минут — граница) */} + {trips.length > 0 && ( +
+ + {trips.map((trip, idx) => ( + + ))} +
+ )} +
- Точек: {points.length} - {trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`} + Точек: {activePoints.length} + {activeTrip && ` (поездка ${selectedTrip! + 1} из ${trips.length})`} + {!activeTrip && trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`} {pointsThinned && `, маркеров на карте: ${displayedPoints.length}`} {(trackData?.filteredCount ?? 0) > 0 && ( @@ -351,16 +454,21 @@ export function GpsRoutesTab() { Макс. скорость: {Math.round(stats.maxSpeed)} км/ч
-
+
-
+
{PLAY_SPEEDS.map((s) => (