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/уведомления.