diff --git a/client/src/components/gps/Yandex21Map.tsx b/client/src/components/gps/Yandex21Map.tsx index 49d6af2..93e311a 100644 --- a/client/src/components/gps/Yandex21Map.tsx +++ b/client/src/components/gps/Yandex21Map.tsx @@ -57,6 +57,7 @@ export function Yandex21Map({ const onReadyRef = useRef(onReady); const onErrorRef = useRef(onError); const onMapClickRef = useRef(onMapClick); + const placemarksRef = useRef(new Map()); const markersByIdRef = useRef(new Map()); useEffect(() => { onReadyRef.current = onReady; @@ -189,7 +190,11 @@ export function Yandex21Map({ // (объектов немного — removeAll + добавить заново дёшево); // колбэки кликов не входят в ключ — они читаются из ref в момент клика const dataKey = JSON.stringify([ - markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]), + // Плейхед без координат: его перемещение не должно пересобирать коллекцию + // (иначе открытый balloon точки трека уничтожается ребилдом при seek) + markers.map((m) => m.id === 'playhead' + ? [m.id, m.kind, m.color, m.zIndex] + : [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course]), polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]), polylines.map((l) => [l.id, l.points, l.color, l.dashed]), ]); @@ -199,6 +204,7 @@ export function Yandex21Map({ if (!mapReady || !collection || !clusterer) return; collection.removeAll(); clusterer.removeAll(); + placemarksRef.current.clear(); for (const p of polygons) { if (p.points.length < 3) continue; @@ -255,6 +261,7 @@ export function Yandex21Map({ // в обычную коллекцию (точки трека, старт/финиш, вершины геозон) const clusterable = (m.kind === 'vehicle' || m.kind === 'person') && !m.noCluster; const placemark = createPlacemark(m); + placemarksRef.current.set(String(m.id), placemark); if (clusterable) { clusterer.add(placemark); } else { @@ -264,6 +271,15 @@ export function Yandex21Map({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dataKey, mapReady]); + // Плейхед двигаем напрямую (без removeAll — иначе закрывается balloon) + const playhead = markers.find((m) => m.id === 'playhead'); + useEffect(() => { + if (!mapReady || !playhead) return; + const pm = placemarksRef.current.get('playhead'); + if (pm) pm.geometry?.setCoordinates([playhead.lat, playhead.lng]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playhead?.lat, playhead?.lng, mapReady]); + return (
diff --git a/server/gps/geozone.service.ts b/server/gps/geozone.service.ts index c31ba04..c432c34 100644 --- a/server/gps/geozone.service.ts +++ b/server/gps/geozone.service.ts @@ -168,6 +168,12 @@ export class JumpFilterWalker { return "accept"; } + // Жёсткое противоречие скоростей (LBS-телепорт): отбрасываем сразу, + // такая точка никогда не принимается и не может стать jump-кандидатом + if (this.isContradiction(point)) { + return "jump"; + } + // jump: логика кандидата на смену локации const now = point.recordedAt.getTime(); if (this.candidate && now - this.candidate.firstAt > CANDIDATE_TTL_MS) { @@ -199,17 +205,40 @@ export class JumpFilterWalker { return "jump"; } + /** Метрики перемещения относительно последней принятой точки */ + private jumpMetrics(point: PrevPoint): { dtSec: number; dist: number; impliedKmh: number } | null { + if (!this.prev) return null; + const dtSec = (point.recordedAt.getTime() - this.prev.recordedAt.getTime()) / 1000; + const dist = haversineMeters(this.prev.lat, this.prev.lng, point.lat, point.lng); + const impliedKmh = + dtSec > 0 ? dist / 1000 / (dtSec / 3600) : dist > this.settings.jitterMeters ? Infinity : 0; + return { dtSec, dist, impliedKmh }; + } + + /** + * Жёсткое противоречие скоростей (LBS-телепорт к вышке): точка «стоит» + * (speed < 5), но перемещение от предыдущей принятой требует скорости + * выше предельной — физически невозможно. Отбрасывается всегда и не + * может стать jump-кандидатом (иначе стабильный LBS-кластер легализуется). + */ + private isContradiction(point: PrevPoint): boolean { + const m = this.jumpMetrics(point); + if (!m) return false; + const reported = point.speed ?? 0; + const maxJump = this.settings.maxJumpSpeedKmh > 0 ? this.settings.maxJumpSpeedKmh : 250; + return reported < 5 && m.dtSec < 300 && m.impliedKmh > maxJump; + } + /** Нереалистичный скачок скорости относительно prev (LBS-позиции) */ private isJump(point: PrevPoint): boolean { if (!this.prev || this.settings.maxJumpSpeedKmh <= 0) return false; - const dtSec = (point.recordedAt.getTime() - this.prev.recordedAt.getTime()) / 1000; - const dist = haversineMeters(this.prev.lat, this.prev.lng, point.lat, point.lng); - if (dtSec <= 0) { - // Защита (после stale-проверки недостижимо, но на всякий случай) - return dist > this.settings.jitterMeters; - } - const impliedKmh = dist / 1000 / (dtSec / 3600); - return impliedKmh > this.settings.maxJumpSpeedKmh; + const m = this.jumpMetrics(point); + if (!m || m.impliedKmh <= this.settings.maxJumpSpeedKmh) return false; + // Реальная быстрая езда: трекер разгонялся между редкими точками, + // implied согласован с заявленной скоростью (допуск x3.5, минимум 90 км/ч) + const reported = point.speed ?? 0; + if (m.impliedKmh <= Math.max(reported * 3.5, 90)) return false; + return true; } }