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