From bec3cc054d91bcc8bcccc1b277390ac0efd1c569 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: Sun, 30 Aug 2026 20:26:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(gps):=20=D1=82=D1=80=D0=B0=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D0=B9=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20=D1=82=D1=80=D0=B5=D0=BA=D0=B0=20(=D0=BC=D0=B5?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=8F=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D1=8C=20=D0=B8=D0=B7=2010=20=D1=82=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D0=BA=20+=20lookahead-=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B7=D0=B4=D0=B0);=20=D1=81=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D1=83=20=D0=B2=D1=8B=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D1=88=D0=B8=D1=85=20LBS-=D1=82=D0=BE=D1=87=D0=B5=D0=BA?= =?UTF-8?q?=20=D1=81=20=C2=AB~=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/gps/RoutesTab.tsx | 2 +- server/gps/geozone.service.ts | 100 +++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index 667a323..1f3b0ac 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -525,7 +525,7 @@ export function GpsRoutesTab() { ✕ -
Скорость: {selectedPoint.speed != null ? `${Math.round(selectedPoint.speed)} км/ч` : '—'}
+
Скорость: {selectedPoint.speed != null ? `${selectedPoint.isLbs ? '~' : ''}${Math.round(selectedPoint.speed)} км/ч${selectedPoint.isLbs ? ' (по вышкам)' : ''}` : '—'}
{selectedPoint.isLbs && (
Примерная позиция (по вышкам)
)} diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index 4a0bd6d..f00e602 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -77,6 +77,15 @@ const CANDIDATE_TTL_MS = 30 * 60 * 1000; // принятая точка «в движении» (speed > 10 км/ч) была меньше 5 минут назад const ECHO_WINDOW_MS = 5 * 60 * 1000; +// Траекторный фильтр при выдаче (проход после walker'а, до spike-фильтра): +// ловит «мяч» вышек — LBS-точки, скачущие по области десятки км, где каждая +// по отдельности в пределах off_road_meters от какой-нибудь дороги. +const TRAJ_WINDOW_SIZE = 10; // скользящее окно последних принятых точек +const TRAJ_ANCHOR_DIST_M = 1500; // дальше этого от якоря (медиана окна) — подозрительная +const TRAJ_CONFIRM_DIST_M = 800; // ближе этого к подозрительной — подтверждение переезда +const TRAJ_LOOKAHEAD = 5; // сколько следующих точек потока смотрим для подтверждения +const TRAJ_MIN_HISTORY = 3; // меньше точек в окне — фильтр ещё не работает + interface JumpCandidate { lat: number; lng: number; @@ -246,7 +255,8 @@ export class JumpFilterWalker { /** * Фильтрация сохранённого трека при выдаче (без удаления из БД): * те же правила stale/jump/echo с подтверждением кандидата, что и при приёме, - * плюс spike-фильтр с lookahead (эхо стоянки между движущимися точками). + * затем траекторный фильтр по скользящему окну («мяч» вышек) и + * spike-фильтр с lookahead (эхо стоянки между движущимися точками). */ export function filterTrackPoints( points: T[], @@ -263,14 +273,80 @@ export function filterTrackPoints { + window.push(p); + if (window.length > TRAJ_WINDOW_SIZE) window.shift(); + }; + + for (let i = 0; i < accepted.length; i++) { + const p = accepted[i]; + + // Истории мало — окно ещё не работает, принимаем всё + if (window.length < TRAJ_MIN_HISTORY) { + trajectory.push(p); + pushToWindow(p); + continue; + } + + const anchor = medianAnchor(window); + if (haversineMeters(anchor.lat, anchor.lng, p.lat, p.lng) <= TRAJ_ANCHOR_DIST_M) { + // Точка логически вписывается в траекторию + if (pending.length > 0) { + filteredCount += pending.length; + pending = []; + } + trajectory.push(p); + pushToWindow(p); + continue; + } + + // Подозрительная (улетела от якоря) — смотрим вперёд по потоку + let confirm = 0; + const lookaheadEnd = Math.min(i + 1 + TRAJ_LOOKAHEAD, accepted.length); + for (let j = i + 1; j < lookaheadEnd; j++) { + if (haversineMeters(p.lat, p.lng, accepted[j].lat, accepted[j].lng) <= TRAJ_CONFIRM_DIST_M) { + confirm++; + } + } + + if (confirm >= 2) { + // Реальный переезд: принимаем накопленный pending и текущую точку, + // окно скользит на новый район + for (const q of pending) { + trajectory.push(q); + pushToWindow(q); + } + pending = []; + trajectory.push(p); + pushToWindow(p); + } else { + // Ждём: либо переезд подтвердится следующими точками, либо поток + // вернётся к якорю и pending будет отброшен + pending.push(p); + } + } + // Хвост pending в конце потока — мусор (к якорю так и не вернулись) + filteredCount += pending.length; + // Spike-фильтр с lookahead: точка-«выброс» к координатам стоянки между // движущимися точками (dist(prev,cur) большая, а следующая точка снова // рядом с prev, сама точка почти стоит, соседи едут) const out: T[] = []; - for (let i = 0; i < accepted.length; i++) { - const cur = accepted[i]; + for (let i = 0; i < trajectory.length; i++) { + const cur = trajectory[i]; const prev = out[out.length - 1]; - const next = accepted[i + 1]; + const next = trajectory[i + 1]; if (prev && next) { const dPrevCur = haversineMeters(prev.lat, prev.lng, cur.lat, cur.lng); const dPrevNext = haversineMeters(prev.lat, prev.lng, next.lat, next.lng); @@ -290,6 +366,22 @@ export function filterTrackPoints): { lat: number; lng: number } { + const mid = Math.floor(window.length / 2); + const median = (values: number[]) => { + const sorted = [...values].sort((a, b) => a - b); + return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + }; + return { + lat: median(window.map((p) => p.lat)), + lng: median(window.map((p) => p.lng)), + }; +} + /** * Основная точка входа: сохраняет позицию, обновляет last_* объекта, * проверяет геозоны и рассылает SSE/уведомления.