From 67dbf90d6583dd12fe9c13e29bf47af699b0d2cf 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: Mon, 31 Aug 2026 21:30:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D0=BC=D0=BE=D1=81=D1=82=D1=8B-?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D0=BE=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=B4=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D0=BC=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83=20GPS-?= =?UTF-8?q?=D1=8F=D0=BA=D0=BE=D1=80=D1=8F=D0=BC=D0=B8=20=D0=BD=D0=B0=20LBS?= =?UTF-8?q?-=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BA=D0=B0=D1=85=20(OSRM=20rou?= =?UTF-8?q?te,=20=D0=BF=D1=83=D0=BD=D0=BA=D1=82=D0=B8=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/gps/RoutesTab.tsx | 14 +++++++++ client/src/pages/gps/types.ts | 2 ++ server/gps/routes.ts | 50 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index 1f3b0ac..b0e7c3b 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -410,6 +410,14 @@ export function GpsRoutesTab() { })), ...(colorizeEnabled ? speedSegments : []), ...matchedLine, + // Мосты-предположения по дорогам на LBS-участках — пунктир цветом объекта + ...(trackData?.bridges ?? []).map((b, idx) => ({ + id: `bridge-${idx}`, + points: b.points, + color: selectedAsset?.color || '#3b82f6', + dashed: true, + width: 3, + })), ]; const mapMarkers: GpsMapMarker[] = [ // Точки записи координат (стрелки курса для GPS-точек; LBS — полые серые @@ -561,6 +569,12 @@ export function GpsRoutesTab() { полый кружок — нет GPS (по вышкам) )} + {(trackData?.bridges?.length ?? 0) > 0 && ( + + + пунктир — предполагаемый участок (нет GPS) + + )} )} diff --git a/client/src/pages/gps/types.ts b/client/src/pages/gps/types.ts index e96b2dd..5c1f144 100644 --- a/client/src/pages/gps/types.ts +++ b/client/src/pages/gps/types.ts @@ -80,6 +80,8 @@ export interface GpsTrackResponse { filteredCount?: number; /** Отсеяно LBS-мусорных точек (далеко от дороги) при выдаче */ offroadFiltered?: number; + /** Мосты-предположения по дорогам между GPS-якорями на LBS-участках (OSRM route) */ + bridges?: Array<{ points: Array<[number, number]>; distance: number }>; sampled: boolean; /** Линия трека по дорогам от OSRM (при ?matched=1); null — OSRM недоступен */ matched?: { points: Array<[number, number]>; distance: number; duration: number } | null; diff --git a/server/gps/routes.ts b/server/gps/routes.ts index edcbbbd..c0463ef 100644 --- a/server/gps/routes.ts +++ b/server/gps/routes.ts @@ -149,6 +149,52 @@ function badRequest(res: Response, error: string) { return res.status(400).json({ success: false, error }); } +/** + * Мосты-предположения: если между двумя соседними по времени точными + * GPS-точками (якорями) есть хотя бы одна LBS-точка, строим между якорями + * маршрут по дорогам через OSRM Route API — целый трек без мусорных + * вышечных линий. Максимум 50 мостов на трек; OSRM недоступен — без мостов. + */ +const MAX_BRIDGES = 50; +const OSRM_ROUTE_TIMEOUT_MS = 3000; + +interface TrackBridge { + points: [number, number][]; // [lat, lng] + distance: number; +} + +async function buildBridges(points: Array<{ lat: number; lng: number; recordedAt: Date; isLbs: boolean }>): Promise { + // Якоря — точные точки (не LBS) по времени + const anchors = points.filter((p) => !p.isLbs); + const bridges: TrackBridge[] = []; + for (let i = 1; i < anchors.length && bridges.length < MAX_BRIDGES; i++) { + const a = anchors[i - 1]; + const b = anchors[i]; + const aTs = a.recordedAt.getTime(); + const bTs = b.recordedAt.getTime(); + // Мост нужен, только если между якорями есть хотя бы одна LBS-точка + const hasLbsBetween = points.some( + (p) => p.isLbs && p.recordedAt.getTime() > aTs && p.recordedAt.getTime() < bTs + ); + if (!hasLbsBetween) continue; + try { + const url = `${OSRM_URL}/route/v1/driving/${a.lng},${a.lat};${b.lng},${b.lat}?overview=full&geometries=geojson`; + const res = await fetch(url, { signal: AbortSignal.timeout(OSRM_ROUTE_TIMEOUT_MS) }); + if (!res.ok) continue; + const data = (await res.json()) as { code: string; routes?: Array<{ distance: number; geometry: { coordinates: [number, number][] } }> }; + const route = data.routes?.[0]; + if (data.code !== "Ok" || !route) continue; + bridges.push({ + points: route.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]), + distance: Math.round(route.distance), + }); + } catch { + // OSRM недоступен для этого моста — просто пропускаем + } + } + return bridges; +} + // Максимум LBS-точек, проверяемых offroad-фильтром при выдаче трека // (бюджет ~5 сек при ~1-2 мс на OSRM nearest и конкуренции 10) const MAX_LBS_OFFROAD_CHECK = 2000; @@ -694,6 +740,9 @@ export function registerGpsRoutes(app: Express) { matched = gpsOnly.length >= 2 ? await matchTrackCached(id, from, to, gpsOnly) : null; } + // Мосты-предположения по дорогам между GPS-якорями на LBS-участках + const bridges = await buildBridges(points); + res.json({ success: true, points, @@ -702,6 +751,7 @@ export function registerGpsRoutes(app: Express) { offroadFiltered, sampled, ...(matched !== undefined ? { matched } : {}), + ...(bridges.length > 0 ? { bridges } : {}), }); });