feat(gps): фильтр «далеко от дороги» в LBS-режиме (offRoadMeters, OSRM nearest); пробег «точно + примерно (по вышкам)» в Маршрутах
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user