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"),