diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx
index cff6f37..f94ca85 100644
--- a/client/src/pages/gps/RoutesTab.tsx
+++ b/client/src/pages/gps/RoutesTab.tsx
@@ -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)})`}
))}
@@ -522,6 +531,14 @@ export function GpsRoutesTab() {
)}
Пробег: {stats.distance.toFixed(1)} км
+ {stats.lbsDistance > 0 && (
+
+ {' '}+ ~{stats.lbsDistance.toFixed(1)} км по вышкам
+
+ )}
{trackData?.matched && (
diff --git a/client/src/pages/gps/SettingsTab.tsx b/client/src/pages/gps/SettingsTab.tsx
index c205238..7e81baa 100644
--- a/client/src/pages/gps/SettingsTab.tsx
+++ b/client/src/pages/gps/SettingsTab.tsx
@@ -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 (
@@ -151,6 +157,23 @@ export function GpsSettingsTab() {
{!gpsLostValid &&
Целое число от 1 до 72
}
+
+
+ setOffRoadMeters(e.target.value)}
+ />
+
+ 0 = выключен. В режиме потери GPS (по бейджу «Нет GPS») точки дальше N метров
+ от ближайшей дороги отбрасываются как LBS-мусор. На реальные заезды в поля/дворы
+ при живом GPS не влияет.
+
+ {!offRoadValid &&
Целое число от 0 до 2000
}
+
+
saveTrackingMutation.mutate()}
>
{saveTrackingMutation.isPending ? (
diff --git a/migrations/0077_gps_offroad.sql b/migrations/0077_gps_offroad.sql
new file mode 100644
index 0000000..64e0d47
--- /dev/null
+++ b/migrations/0077_gps_offroad.sql
@@ -0,0 +1,4 @@
+-- Порог «далеко от дороги» для фильтра LBS-точек в режиме потери GPS.
+-- Точка дальше off_road_meters от ближайшей дороги (OSRM nearest) считается
+-- координатой сотовой вышки и отбрасывается. 0 = фильтр выключен.
+ALTER TABLE gps_settings ADD COLUMN IF NOT EXISTS off_road_meters integer NOT NULL DEFAULT 200;
diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts
index a2b1361..ab84714 100644
--- a/server/gps/geozone.service.ts
+++ b/server/gps/geozone.service.ts
@@ -1,4 +1,5 @@
import { gpsStorage } from "./storage";
+import { OSRM_URL } from "./osrm";
import { storage } from "../storage";
import { webPushService } from "../services/web-push.service";
import { eventBus, publishNotificationSSE } from "../routes/shared";
@@ -309,8 +310,10 @@ export function filterTrackPoints();
// Окно после последнего stale re-send'а, в котором свежие точки считаются LBS
const STALE_LBS_WINDOW_MS = 10 * 60 * 1000;
+// Таймаут запроса к OSRM Nearest API (фильтр «далеко от дороги»)
+const OSRM_NEAREST_TIMEOUT_MS = 2000;
+// Warn про недоступность OSRM для nearest пишем один раз до следующего успешного ответа
+let osrmNearestWarned = false;
+
+/**
+ * Проверка «далеко от дороги» через OSRM Nearest API (только для LBS-режима).
+ * Возвращает расстояние до ближайшей дороги (м), если оно БОЛЬШЕ порога
+ * (точку надо отбросить), иначе null.
+ * При недоступности OSRM/таймауте — null: приём точки не блокируем.
+ * code=NoSegment — ближайшая дорога вне радиуса поиска OSRM (порядка км),
+ * надёжный сигнал LBS: возвращаем Infinity (точно offroad).
+ */
+async function offRoadDistance(lat: number, lng: number, thresholdMeters: number): Promise {
+ let data: { code?: string; waypoints?: Array<{ distance?: number }> };
+ try {
+ // ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat
+ const res = await fetch(`${OSRM_URL}/nearest/v1/driving/${lng},${lat}`, {
+ signal: AbortSignal.timeout(OSRM_NEAREST_TIMEOUT_MS),
+ });
+ data = (await res.json()) as typeof data;
+ if (osrmNearestWarned) {
+ console.log("[GPS] OSRM снова доступен (nearest)");
+ osrmNearestWarned = false;
+ }
+ } catch (err) {
+ // OSRM недоступен — пропускаем проверку, точка идёт обычным путём
+ if (!osrmNearestWarned) {
+ console.warn(`[GPS] OSRM недоступен для nearest (${OSRM_URL}):`, err instanceof Error ? err.message : err);
+ osrmNearestWarned = true;
+ }
+ return null;
+ }
+
+ // Дорога вне радиуса поиска — точно далеко от дороги
+ if (data.code === "NoSegment") return Infinity;
+ if (data.code !== "Ok") return null;
+
+ const dist = data.waypoints?.[0]?.distance;
+ if (typeof dist !== "number") return null;
+ return dist > thresholdMeters ? dist : null;
+}
+
function getAssetWalker(assetId: number, prev: PrevPoint | null, settings: GpsFilterSettings): JumpFilterWalker {
let walker = assetWalkers.get(assetId);
if (!walker) {
@@ -408,6 +454,22 @@ export async function processPosition(
const lastStaleAt = assetLastStaleAt.get(assetId);
const isLbs = lastStaleAt !== undefined && Date.now() - lastStaleAt < STALE_LBS_WINDOW_MS;
+ // Фильтр «далеко от дороги» — ТОЛЬКО в режиме потери GPS (LBS-точки):
+ // координаты сотовых вышек обычно >100 м от ближайшей дороги, настоящий
+ // GPS — почти никогда (p90 ≈ 7 м). Вне LBS-режима проверку не выполняем:
+ // при живом GPS заезды в поля/дворы легальны.
+ if (isLbs && settings.offRoadMeters > 0) {
+ const distToRoad = await offRoadDistance(input.lat, input.lng, settings.offRoadMeters);
+ if (distToRoad !== null) {
+ console.log(
+ `[GPS] skipped offroad: asset=${assetId} dist_to_road=${distToRoad === Infinity ? "> радиуса поиска OSRM" : `${Math.round(distToRoad)}m`} > порога ${settings.offRoadMeters}m`
+ );
+ // Точку не сохраняем, координаты и геозоны не трогаем — только «устройство живо»
+ await gpsStorage.touchAssetSeen(assetId);
+ return;
+ }
+ }
+
// Фильтр джиттера (округление мелких перемещений)
if (settings.jitterMeters > 0 && prev) {
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
diff --git a/server/gps/osrm.ts b/server/gps/osrm.ts
new file mode 100644
index 0000000..4261e32
--- /dev/null
+++ b/server/gps/osrm.ts
@@ -0,0 +1,5 @@
+/**
+ * Общая конфигурация OSRM-сервиса (map matching / nearest).
+ * Контейнер во внутренней Docker-сети; адрес переопределяется env OSRM_URL.
+ */
+export const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";
diff --git a/server/gps/routes.ts b/server/gps/routes.ts
index 97a6f4a..5370601 100644
--- a/server/gps/routes.ts
+++ b/server/gps/routes.ts
@@ -5,6 +5,7 @@ import { gpsStorage } from "./storage";
import { processPosition, filterTrackPoints } from "./geozone.service";
import { hasGpsAccess } from "./access";
import { ensureDevice, deleteDevice } from "./traccar.client";
+import { OSRM_URL } from "./osrm";
import { storage } from "../storage";
/**
@@ -19,8 +20,7 @@ const MAX_TRACK_POINTS = 2000;
// OSRM map matching (привязка трека к дорогам)
// =====================
-// URL OSRM-сервиса (контейнер во внутренней Docker-сети)
-const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";
+// URL OSRM-сервиса — см. ./osrm (общий модуль, используется и в geozone.service)
// Лимит OSRM Match API — 100 координат на запрос; берём с запасом
const OSRM_CHUNK_SIZE = 95;
// Таймаут запроса к OSRM
@@ -432,6 +432,11 @@ export function registerGpsRoutes(app: Express) {
if (!Number.isInteger(gpsLostHours) || gpsLostHours < 1 || gpsLostHours > 72) {
return badRequest(res, "gpsLostHours: целое число от 1 до 72");
}
+ const offRoadMeters =
+ req.body?.offRoadMeters !== undefined ? Number(req.body.offRoadMeters) : current.offRoadMeters;
+ if (!Number.isInteger(offRoadMeters) || offRoadMeters < 0 || offRoadMeters > 2000) {
+ return badRequest(res, "offRoadMeters: целое число от 0 до 2000 (0 = фильтр выключен)");
+ }
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
let defaultLat = current.defaultLat;
@@ -471,6 +476,7 @@ export function registerGpsRoutes(app: Express) {
maxJumpSpeedKmh,
tripStopMinutes,
gpsLostHours,
+ offRoadMeters,
defaultLat,
defaultLng,
defaultZoom,
@@ -481,6 +487,7 @@ export function registerGpsRoutes(app: Express) {
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
gpsLostHours: row.gpsLostHours,
+ offRoadMeters: row.offRoadMeters,
defaultLat: row.defaultLat,
defaultLng: row.defaultLng,
defaultZoom: row.defaultZoom,
diff --git a/server/gps/storage.ts b/server/gps/storage.ts
index 4103994..9fc71c9 100644
--- a/server/gps/storage.ts
+++ b/server/gps/storage.ts
@@ -504,6 +504,7 @@ export class GpsStorage {
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
+ offRoadMeters: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
@@ -517,6 +518,7 @@ export class GpsStorage {
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
tripStopMinutes: row?.tripStopMinutes ?? 10,
gpsLostHours: row?.gpsLostHours ?? 5,
+ offRoadMeters: row?.offRoadMeters ?? 200,
defaultLat: row?.defaultLat ?? null,
defaultLng: row?.defaultLng ?? null,
defaultZoom: row?.defaultZoom ?? null,
@@ -530,6 +532,7 @@ export class GpsStorage {
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
+ offRoadMeters: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
diff --git a/shared/schema.ts b/shared/schema.ts
index 37ee644..d806472 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -4131,6 +4131,8 @@ export const gpsSettings = pgTable("gps_settings", {
maxJumpSpeedKmh: integer("max_jump_speed_kmh").notNull().default(250),
tripStopMinutes: integer("trip_stop_minutes").notNull().default(10),
gpsLostHours: integer("gps_lost_hours").notNull().default(5),
+ // Порог «далеко от дороги» (м) для фильтра LBS-точек в режиме потери GPS; 0 = фильтр выключен
+ offRoadMeters: integer("off_road_meters").notNull().default(200),
defaultLat: doublePrecision("default_lat"),
defaultLng: doublePrecision("default_lng"),
defaultZoom: integer("default_zoom"),