From 3ff74d0d17a76914859a5d7526d0c4787cf52c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Sat, 29 Aug 2026 21:36:55 +0300 Subject: [PATCH] =?UTF-8?q?fix(gps):=20zIndex=20=D1=82=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=20=D1=82=D1=80=D0=B5=D0=BA=D0=B0=20=D0=BD=D0=B0=D0=B4=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B8=D0=BB=D0=B8=D0=BD=D0=B8=D1=8F=D0=BC?= =?UTF-8?q?=D0=B8=20+=20capture-=D0=BA=D0=BB=D0=B8=D0=BA=D0=B8;=20offroad-?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20LBS=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B5=20=D1=82=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0=20(=D1=87=D0=B8=D1=81=D1=82=D0=B8=D1=82=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D1=8B=D0=B9=20=C2=AB=D0=BC=D1=8F=D1=87=C2=BB?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/OsmMap.tsx | 2 +- client/src/components/gps/Yandex21Map.tsx | 4 +- client/src/pages/gps/RoutesTab.tsx | 13 +++-- client/src/pages/gps/types.ts | 2 + server/gps/geozone.service.ts | 2 +- server/gps/routes.ts | 60 +++++++++++++++++++++-- 6 files changed, 72 insertions(+), 11 deletions(-) diff --git a/client/src/components/gps/OsmMap.tsx b/client/src/components/gps/OsmMap.tsx index ed9bdd0..86727ef 100644 --- a/client/src/components/gps/OsmMap.tsx +++ b/client/src/components/gps/OsmMap.tsx @@ -108,7 +108,7 @@ export function OsmMap({ marker.onClick(); map.closePopup(); } - }); + }, true); // capture: leaflet глушит bubble mapRef.current = map; diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 297953b..95ab383 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -166,8 +166,8 @@ export function Yandex21Map({ mapRef.current?.balloon.close(); } }; - container.addEventListener('click', handler); - return () => container.removeEventListener('click', handler); + container.addEventListener('click', handler, true); // capture: ymaps/leaflet глушат bubble + return () => container.removeEventListener('click', handler, true); }, [mapReady]); // Перемещение viewport только при реальном изменении center/zoom/bounds, diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index f94ca85..ac40672 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -375,14 +375,14 @@ export function GpsRoutesTab() { course: p.course ?? undefined, onClick: () => seekToPoint(p), noCluster: true, - zIndex: 2, + zIndex: 100, // над полилиниями (matched/сегменты), иначе клики перехватывает линия })), - ...(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: 110, noCluster: true }] : []), ...(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: 110, noCluster: true }] : []), ...(playPoint - ? [{ id: 'playhead', lat: playPoint.lat, lng: playPoint.lng, kind: 'dot' as const, color: selectedAsset?.color || '#3b82f6', zIndex: 20, noCluster: true }] + ? [{ id: 'playhead', lat: playPoint.lat, lng: playPoint.lng, kind: 'dot' as const, color: selectedAsset?.color || '#3b82f6', zIndex: 120, noCluster: true }] : []), ]; @@ -523,6 +523,11 @@ export function GpsRoutesTab() { {!activeTrip && trackData?.sampled && ` (всего ${trackData.total}, показана каждая N-я)`} {pointsThinned && `, маркеров на карте: ${displayedPoints.length}`} + {(trackData?.offroadFiltered ?? 0) > 0 && ( + + Отсеяно LBS-мусора: {trackData!.offroadFiltered} + + )} {(trackData?.filteredCount ?? 0) > 0 && ( Отфильтровано мусорных точек:{' '} diff --git a/client/src/pages/gps/types.ts b/client/src/pages/gps/types.ts index a81275e..e96b2dd 100644 --- a/client/src/pages/gps/types.ts +++ b/client/src/pages/gps/types.ts @@ -78,6 +78,8 @@ export interface GpsTrackResponse { total: number; /** Число отброшенных сервером устаревших/телепорт-точек */ filteredCount?: number; + /** Отсеяно LBS-мусорных точек (далеко от дороги) при выдаче */ + offroadFiltered?: number; sampled: boolean; /** Линия трека по дорогам от OSRM (при ?matched=1); null — OSRM недоступен */ matched?: { points: Array<[number, number]>; distance: number; duration: number } | null; diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index ab84714..4a0bd6d 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -342,7 +342,7 @@ let osrmNearestWarned = false; * code=NoSegment — ближайшая дорога вне радиуса поиска OSRM (порядка км), * надёжный сигнал LBS: возвращаем Infinity (точно offroad). */ -async function offRoadDistance(lat: number, lng: number, thresholdMeters: number): Promise { +export async function offRoadDistance(lat: number, lng: number, thresholdMeters: number): Promise { let data: { code?: string; waypoints?: Array<{ distance?: number }> }; try { // ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat diff --git a/server/gps/routes.ts b/server/gps/routes.ts index 5370601..bfb2fa7 100644 --- a/server/gps/routes.ts +++ b/server/gps/routes.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import type { Express, Response } from "express"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { gpsStorage } from "./storage"; -import { processPosition, filterTrackPoints } from "./geozone.service"; +import { processPosition, filterTrackPoints, offRoadDistance } from "./geozone.service"; import { hasGpsAccess } from "./access"; import { ensureDevice, deleteDevice } from "./traccar.client"; import { OSRM_URL } from "./osrm"; @@ -149,6 +149,27 @@ function badRequest(res: Response, error: string) { return res.status(400).json({ success: false, error }); } +// Максимум LBS-точек, проверяемых offroad-фильтром при выдаче трека +// (бюджет ~5 сек при ~1-2 мс на OSRM nearest и конкуренции 10) +const MAX_LBS_OFFROAD_CHECK = 2000; +// Конкуренция запросов к OSRM nearest при выдаче трека +const OFFROAD_CHECK_CONCURRENCY = 10; + +/** + * Простой p-limit без зависимостей: выполняет fn над элементами + * с ограничением числа одновременных вызовов. + */ +async function runWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { + let index = 0; + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (index < items.length) { + const item = items[index++]; + await fn(item); + } + }); + await Promise.all(workers); +} + // ===================== // Приём позиций от Traccar (публичный, по секрету) // ===================== @@ -624,14 +645,46 @@ export function registerGpsRoutes(app: Express) { const settings = await gpsStorage.getSettings(orgId); const { points: filtered, filteredCount } = filterTrackPoints(all, settings); + // Offroad-фильтр при выдаче: СТАРЫЕ LBS-точки (помеченные до внедрения + // фильтра при приёме) далеко от дорог рисуют «мяч» зигзагов — выкидываем + // их из выдачи через OSRM nearest. Проверяются только isLbs-точки, + // настоящий GPS не трогаем (поля/дворы легальны). 0 в настройке = выкл. let points = filtered; + let offroadFiltered = 0; + if (settings.offRoadMeters > 0) { + let lbsPoints = points.filter((p) => p.isLbs); + if (lbsPoints.length > MAX_LBS_OFFROAD_CHECK) { + // Бюджет времени ~5 сек: прореживаем LBS-точки равномерно до лимита; + // не попавшие в проверку остаются в выдаче как есть + const step = Math.ceil(lbsPoints.length / MAX_LBS_OFFROAD_CHECK); + const thinned = lbsPoints.filter((_, idx) => idx % step === 0); + console.log( + `[GPS] track offroad: asset=${id} LBS-точек ${lbsPoints.length} > ${MAX_LBS_OFFROAD_CHECK}, проверяем ${thinned.length} (прорежено)` + ); + lbsPoints = thinned; + } + if (lbsPoints.length > 0) { + const dropped = new Set<(typeof lbsPoints)[number]>(); + await runWithConcurrency(lbsPoints, OFFROAD_CHECK_CONCURRENCY, async (p) => { + const dist = await offRoadDistance(p.lat, p.lng, settings.offRoadMeters); + if (dist !== null) dropped.add(p); + }); + if (dropped.size > 0) { + offroadFiltered = dropped.size; + points = points.filter((p) => !dropped.has(p)); + } + } + } + let sampled = false; if (points.length > MAX_TRACK_POINTS) { + // Точки ДО прореживания — для гарантии включения последней точки трека + const beforeSample = points; const step = Math.ceil(points.length / MAX_TRACK_POINTS); points = points.filter((_, idx) => idx % step === 0); // Всегда включаем последнюю точку трека - if (points[points.length - 1] !== filtered[filtered.length - 1]) { - points.push(filtered[filtered.length - 1]); + if (points[points.length - 1] !== beforeSample[beforeSample.length - 1]) { + points.push(beforeSample[beforeSample.length - 1]); } sampled = true; } @@ -650,6 +703,7 @@ export function registerGpsRoutes(app: Express) { points, total, filteredCount, + offroadFiltered, sampled, ...(matched !== undefined ? { matched } : {}), });