feat(gps): фильтр «далеко от дороги» в LBS-режиме (offRoadMeters, OSRM nearest); пробег «точно + примерно (по вышкам)» в Маршрутах

This commit is contained in:
2026-08-29 21:12:09 +03:00
parent 9eaff65500
commit a302f0c0ea
8 changed files with 135 additions and 12 deletions

View File

@@ -64,7 +64,10 @@ interface Trip {
points: GpsTrackPoint[];
startTs: number;
endTs: number;
/** Точный пробег (только не-LBS сегменты) */
distanceKm: number;
/** Примерный пробег по LBS-сегментам (точки по вышкам) */
lbsDistanceKm: number;
}
/** Разбиение точек трека на поездки по перерывам во времени */
@@ -76,16 +79,19 @@ function splitIntoTrips(points: GpsTrackPoint[], tripStopMinutes: number): Trip[
const finish = (pts: GpsTrackPoint[]) => {
let distanceKm = 0;
let lbsDistanceKm = 0;
for (let i = 1; i < pts.length; i++) {
// LBS-участки (точки по вышкам) в пробег не считаем
if (pts[i - 1].isLbs || pts[i].isLbs) continue;
distanceKm += haversineKm(pts[i - 1].lat, pts[i - 1].lng, pts[i].lat, pts[i].lng);
const d = haversineKm(pts[i - 1].lat, pts[i - 1].lng, pts[i].lat, pts[i].lng);
// LBS-участки (точки по вышкам) считаем отдельно — примерный пробег
if (pts[i - 1].isLbs || pts[i].isLbs) lbsDistanceKm += d;
else distanceKm += d;
}
trips.push({
points: pts,
startTs: new Date(pts[0].recordedAt).getTime(),
endTs: new Date(pts[pts.length - 1].recordedAt).getTime(),
distanceKm,
lbsDistanceKm,
});
};
@@ -193,11 +199,13 @@ export function GpsRoutesTab() {
const stats = useMemo(() => {
if (activePoints.length === 0) return null;
let distance = 0;
let lbsDistance = 0;
let maxSpeed = 0;
for (let i = 1; i < activePoints.length; i++) {
// LBS-участки (точки по вышкам) в пробег не считаем
if (activePoints[i - 1].isLbs || activePoints[i].isLbs) continue;
distance += haversineKm(activePoints[i - 1].lat, activePoints[i - 1].lng, activePoints[i].lat, activePoints[i].lng);
const d = haversineKm(activePoints[i - 1].lat, activePoints[i - 1].lng, activePoints[i].lat, activePoints[i].lng);
// LBS-участки (точки по вышкам) считаем отдельно — примерный пробег
if (activePoints[i - 1].isLbs || activePoints[i].isLbs) lbsDistance += d;
else distance += d;
}
for (const p of activePoints) {
// Примерные точки (LBS) не учитываем в макс. скорости
@@ -207,7 +215,7 @@ export function GpsRoutesTab() {
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 };
return { distance, lbsDistance, maxSpeed, durationMin, startTs, endTs };
}, [activePoints]);
// Автоподгонка карты под активный трек ([[lat, lng], [lat, lng]] — формат GpsMap)
@@ -438,6 +446,7 @@ export function GpsRoutesTab() {
title={`${trip.points.length} точек`}
>
Поездка {idx + 1} · {formatTime(trip.startTs)}–{formatTime(trip.endTs)} · {trip.distanceKm.toFixed(1)} км
{trip.lbsDistanceKm > 0 && ` (+ ~${trip.lbsDistanceKm.toFixed(1)})`}
</Button>
))}
</div>
@@ -522,6 +531,14 @@ export function GpsRoutesTab() {
)}
<span>
Пробег: <span className="text-foreground font-medium">{stats.distance.toFixed(1)} км</span>
{stats.lbsDistance > 0 && (
<span
className="text-amber-600 dark:text-amber-400"
title="Примерный пробег по LBS-точкам (позиция по вышкам, без GPS)"
>
{' '}+ ~{stats.lbsDistance.toFixed(1)} км по вышкам
</span>
)}
</span>
{trackData?.matched && (
<span title="По линии OSRM map matching">

View File

@@ -16,6 +16,8 @@ interface GpsSettings {
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
/** Отсев точек «далеко от дороги» в режиме потери GPS (0 = выкл); может отсутствовать на старом бэкенде */
offRoadMeters?: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
@@ -45,6 +47,7 @@ export function GpsSettingsTab() {
const [maxJumpSpeedKmh, setMaxJumpSpeedKmh] = useState('0');
const [tripStopMinutes, setTripStopMinutes] = useState('10');
const [gpsLostHours, setGpsLostHours] = useState('5');
const [offRoadMeters, setOffRoadMeters] = useState('200');
useEffect(() => {
if (data) {
@@ -52,6 +55,7 @@ export function GpsSettingsTab() {
setMaxJumpSpeedKmh(String(data.maxJumpSpeedKmh));
setTripStopMinutes(String(data.tripStopMinutes));
setGpsLostHours(String(data.gpsLostHours));
setOffRoadMeters(String(data.offRoadMeters ?? 200));
}
}, [data]);
@@ -68,6 +72,7 @@ export function GpsSettingsTab() {
maxJumpSpeedKmh: Number(maxJumpSpeedKmh),
tripStopMinutes: Number(tripStopMinutes),
gpsLostHours: Number(gpsLostHours),
offRoadMeters: Number(offRoadMeters),
});
return res.json();
},
@@ -85,6 +90,7 @@ export function GpsSettingsTab() {
const jumpValid = isIntInRange(maxJumpSpeedKmh, 5000);
const tripValid = isIntInRange(tripStopMinutes, 120);
const gpsLostValid = /^\d+$/.test(gpsLostHours.trim()) && Number(gpsLostHours) >= 1 && Number(gpsLostHours) <= 72;
const offRoadValid = isIntInRange(offRoadMeters, 2000);
return (
<div className="max-w-2xl space-y-4">
@@ -151,6 +157,23 @@ export function GpsSettingsTab() {
{!gpsLostValid && <p className="text-xs text-destructive">Целое число от 1 до 72</p>}
</div>
<div className="space-y-1">
<Label className="text-xs">Отсев «далеко от дороги», метров</Label>
<Input
type="text"
inputMode="numeric"
className="h-8 w-32"
value={offRoadMeters}
onChange={(e) => setOffRoadMeters(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
0 = выключен. В режиме потери GPS (по бейджу «Нет GPS») точки дальше N метров
от ближайшей дороги отбрасываются как LBS-мусор. На реальные заезды в поля/дворы
при живом GPS не влияет.
</p>
{!offRoadValid && <p className="text-xs text-destructive">Целое число от 0 до 2000</p>}
</div>
<div className="space-y-1">
<Label className="text-xs">Остановка — конец поездки, минут</Label>
<Input
@@ -170,7 +193,7 @@ export function GpsSettingsTab() {
<Button
size="sm"
className="h-8"
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid || !gpsLostValid}
disabled={saveTrackingMutation.isPending || !jitterValid || !jumpValid || !tripValid || !gpsLostValid || !offRoadValid}
onClick={() => saveTrackingMutation.mutate()}
>
{saveTrackingMutation.isPending ? (