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