From 0386005463542f52228163d462992b71d62ebad6 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 12:49:00 +0300 Subject: [PATCH] =?UTF-8?q?fix(gps):=20LBS-=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=BB=D0=B8=D0=BD=D0=B8=D0=B9=20(?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=B5=D1=86=20=C2=AB=D0=BC=D1=8F=D1=87=D0=B0?= =?UTF-8?q?=C2=BB),=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B5=D0=B7=D0=B4=D0=BA=D0=B8=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=84=D0=B5=D0=B9=D0=BA=D0=BE=D0=B2=D1=8B=D1=85=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D1=80=D1=8B=D0=B2=D0=BE=D0=B2,=20=D1=82=D0=BE=D1=81?= =?UTF-8?q?=D1=82=20OSRM=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/gps/marker-html.ts | 14 +- client/src/pages/gps/RoutesTab.tsx | 220 +++++++++++++++-------- 2 files changed, 151 insertions(+), 83 deletions(-) diff --git a/client/src/components/gps/marker-html.ts b/client/src/components/gps/marker-html.ts index 27cfff6..62ca25b 100644 --- a/client/src/components/gps/marker-html.ts +++ b/client/src/components/gps/marker-html.ts @@ -67,10 +67,16 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string { let body: string; if (marker.kind === 'dot') { - body = - marker.course != null - ? `
${arrowSvg(color)}
` - : `
`; + if (marker.approximate) { + // LBS-точка трека: полый серый кружок без стрелки (курс у LBS фейковый). + // Внешний блок 16px сохраняет якорь dot-маркера (anchorY=8) + body = `
`; + } else { + body = + marker.course != null + ? `
${arrowSvg(color)}
` + : `
`; + } } else { const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG; if (marker.approximate) { diff --git a/client/src/pages/gps/RoutesTab.tsx b/client/src/pages/gps/RoutesTab.tsx index ac40672..667a323 100644 --- a/client/src/pages/gps/RoutesTab.tsx +++ b/client/src/pages/gps/RoutesTab.tsx @@ -68,9 +68,13 @@ interface Trip { distanceKm: number; /** Примерный пробег по LBS-сегментам (точки по вышкам) */ lbsDistanceKm: number; + /** Все точки поездки — LBS (GPS не было вообще): пробега нет, только «~по вышкам» */ + allLbs: boolean; } -/** Разбиение точек трека на поездки по перерывам во времени */ +/** Разбиение точек трека на поездки по перерывам во времени. + * Разрыв > tripStopMinutes делит поездку, только если точки по обе стороны + * разрыва — не-LBS: чисто-LBS период с любыми разрывами остаётся одной поездкой */ function splitIntoTrips(points: GpsTrackPoint[], tripStopMinutes: number): Trip[] { if (!tripStopMinutes || points.length < 2) return []; const gapMs = tripStopMinutes * 60_000; @@ -92,12 +96,14 @@ function splitIntoTrips(points: GpsTrackPoint[], tripStopMinutes: number): Trip[ endTs: new Date(pts[pts.length - 1].recordedAt).getTime(), distanceKm, lbsDistanceKm, + allLbs: pts.every((p) => p.isLbs), }); }; for (let i = 1; i < points.length; i++) { const gap = new Date(points[i].recordedAt).getTime() - new Date(points[i - 1].recordedAt).getTime(); - if (gap > gapMs) { + // Граница поездки — только разрыв между двумя GPS-точками + if (gap > gapMs && !points[i - 1].isLbs && !points[i].isLbs) { finish(current); current = []; } @@ -160,26 +166,43 @@ export function GpsRoutesTab() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [preselectedAssetId]); - const { data: trackData, isFetching } = useGpsTrack( - applied?.assetId ?? null, - applied ? new Date(applied.from).toISOString() : '', - applied ? new Date(applied.to).toISOString() : '', - !!applied, - matchRoads - ); + // Основной трек — всегда без matched (matched запрашивается отдельным запросом, + // только когда есть хотя бы 2 GPS-точки — иначе привязывать нечего) + const fromIso = applied ? new Date(applied.from).toISOString() : ''; + const toIso = applied ? new Date(applied.to).toISOString() : ''; + const { data: trackData, isFetching } = useGpsTrack(applied?.assetId ?? null, fromIso, toIso, !!applied, false); const points: GpsTrackPoint[] = trackData?.points || []; - // matched === null — OSRM недоступен: тихо показываем обычный трек, - // один раз предупреждаем toast'ом (на каждый новый запрос трека) + // Число точных GPS-точек в загруженном треке (LBS — координаты вышек) + const gpsPointCount = useMemo(() => points.filter((p) => !p.isLbs).length, [points]); + const wantMatched = matchRoads && gpsPointCount >= 2; + const { data: matchedTrackData } = useGpsTrack( + applied?.assetId ?? null, + fromIso, + toIso, + !!applied && wantMatched, + true + ); + + // Тосты (по одному разу на новый запрос трека): + // - GPS-точек < 2 → matched не запрашиваем, предупреждаем, что привязки нет; + // - GPS есть, но matched === null → OSRM недоступен, тихо показываем трек по точкам + const noGpsToastRef = useRef(false); const osrmToastRef = useRef(false); useEffect(() => { + noGpsToastRef.current = false; osrmToastRef.current = false; }, [applied, matchRoads]); useEffect(() => { - if (!matchRoads || !trackData || trackData.matched !== null || osrmToastRef.current) return; + if (!matchRoads || !trackData || points.length === 0 || gpsPointCount >= 2 || noGpsToastRef.current) return; + noGpsToastRef.current = true; + toast({ title: 'Нет точных GPS-точек', description: 'Привязка к дорогам недоступна' }); + }, [trackData, points.length, gpsPointCount, matchRoads, toast]); + useEffect(() => { + if (!wantMatched || !matchedTrackData || matchedTrackData.matched !== null || osrmToastRef.current) return; osrmToastRef.current = true; toast({ title: 'OSRM недоступен', description: 'Показан трек по точкам' }); - }, [trackData, matchRoads, toast]); + }, [matchedTrackData, wantMatched, toast]); const selectedAsset = assets.find((a) => String(a.id) === (applied ? String(applied.assetId) : assetId)); @@ -201,11 +224,16 @@ export function GpsRoutesTab() { let distance = 0; let lbsDistance = 0; let maxSpeed = 0; + let hasGpsPairs = false; for (let i = 1; i < activePoints.length; i++) { const d = haversineKm(activePoints[i - 1].lat, activePoints[i - 1].lng, activePoints[i].lat, activePoints[i].lng); // LBS-участки (точки по вышкам) считаем отдельно — примерный пробег - if (activePoints[i - 1].isLbs || activePoints[i].isLbs) lbsDistance += d; - else distance += d; + if (activePoints[i - 1].isLbs || activePoints[i].isLbs) { + lbsDistance += d; + } else { + distance += d; + hasGpsPairs = true; + } } for (const p of activePoints) { // Примерные точки (LBS) не учитываем в макс. скорости @@ -215,7 +243,7 @@ export function GpsRoutesTab() { const startTs = new Date(activePoints[0].recordedAt).getTime(); const endTs = new Date(activePoints[activePoints.length - 1].recordedAt).getTime(); const durationMin = Math.max(0, Math.round((endTs - startTs) / 60000)); - return { distance, lbsDistance, maxSpeed, durationMin, startTs, endTs }; + return { distance, lbsDistance, hasGpsPairs, maxSpeed, durationMin, startTs, endTs }; }, [activePoints]); // Автоподгонка карты под активный трек ([[lat, lng], [lat, lng]] — формат GpsMap) @@ -269,20 +297,45 @@ export function GpsRoutesTab() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, playSpeed, stats, playEpoch]); - // Текущая точка воспроизведения: последняя точка с recordedAt <= playTs + // Текущая точка воспроизведения: последняя GPS-точка с recordedAt <= playTs + // (LBS-точки плейхед игнорирует — позиция по вышкам не является проездом) const playPoint = useMemo(() => { - if (playTs === null || activePoints.length === 0) return null; - let current = activePoints[0]; - for (const p of activePoints) { + if (playTs === null) return null; + const gpsPts = activePoints.filter((p) => !p.isLbs); + if (gpsPts.length === 0) return null; + let current = gpsPts[0]; + for (const p of gpsPts) { if (new Date(p.recordedAt).getTime() <= playTs) current = p; else break; } return current; }, [playTs, activePoints]); - const trackCoords = activePoints.map((p) => [p.lat, p.lng] as [number, number]); - const first = activePoints[0]; - const last = activePoints[activePoints.length - 1]; + // Точные GPS-точки активного объёма (линии строятся только между ними — + // LBS-точки никогда не соединяются, иначе день на LBS рисует «мяч» из вышек) + const gpsPoints = useMemo(() => activePoints.filter((p) => !p.isLbs), [activePoints]); + const hasLbsPoints = gpsPoints.length < activePoints.length; + + // «Холсты» трека: последовательные не-LBS точки; LBS-точка разрывает холст. + // Каждый холст из ≥2 точек — отдельная полилиния track-N + const trackCanvases = useMemo(() => { + const canvases: Array> = []; + let current: Array<[number, number]> = []; + for (const p of activePoints) { + if (p.isLbs) { + if (current.length >= 2) canvases.push(current); + current = []; + } else { + current.push([p.lat, p.lng]); + } + } + if (current.length >= 2) canvases.push(current); + return canvases; + }, [activePoints]); + + // Старт/Финиш — по точным GPS-точкам (первая/последняя LBS-вышка не годится) + const first = gpsPoints[0] ?? activePoints[0]; + const last = gpsPoints[gpsPoints.length - 1] ?? activePoints[activePoints.length - 1]; // Точки записи координат вдоль трека: при > 300 прореживаем равномерно const TRACK_POINTS_LIMIT = 300; @@ -305,66 +358,62 @@ export function GpsRoutesTab() { // Выбранная кликом точка трека — инфо-панель поверх карты (вместо balloon карты) const [selectedPoint, setSelectedPoint] = useState(null); - // Сегменты трека: LBS-участки (хотя бы одна точка сегмента — по вышкам) - // рисуем серым пунктиром и тоньше, остальные — по скорости начальной точки. - // При > 800 точек прореживаем до ~800 (базовая полилиния из всех точек - // остаётся под низом). 800 сегментов оба провайдера тянут нормально; - // если Яндекс просядет — порог снизить до 400. + // Сегменты трека, окрашенные по скорости в начальной точке сегмента — + // только пары GPS↔GPS (LBS-точки линиями не соединяются никогда). + // При > 800 GPS-точек прореживаем до ~800 для раскраски (базовые холсты + // из всех GPS-точек остаются под низом). 800 сегментов оба провайдера + // тянут нормально; если Яндекс просядет — порог снизить до 400. const COLORIZE_POINTS_LIMIT = 800; - const { speedSegments, lbsSegments } = useMemo(() => { - const speed: GpsMapPolyline[] = []; - const lbs: GpsMapPolyline[] = []; - if (activePoints.length < 2) return { speedSegments: speed, lbsSegments: lbs }; - const step = Math.max(1, Math.ceil(activePoints.length / COLORIZE_POINTS_LIMIT)); - const pts = step === 1 ? activePoints : activePoints.filter((_, idx) => idx % step === 0); + const speedSegments: GpsMapPolyline[] = useMemo(() => { + if (gpsPoints.length < 2) return []; + const step = Math.max(1, Math.ceil(gpsPoints.length / COLORIZE_POINTS_LIMIT)); + const pts = step === 1 ? gpsPoints : gpsPoints.filter((_, idx) => idx % step === 0); + const segments: GpsMapPolyline[] = []; for (let i = 1; i < pts.length; i++) { - const pair: Array<[number, number]> = [ - [pts[i - 1].lat, pts[i - 1].lng], - [pts[i].lat, pts[i].lng], - ]; - if (pts[i - 1].isLbs || pts[i].isLbs) { - lbs.push({ id: `lbs-${i}`, points: pair, color: SPEED_UNKNOWN_COLOR, dashed: true, width: 3 }); - } else { - speed.push({ id: `seg-${i}`, points: pair, color: speedColor(pts[i - 1].speed) }); - } + segments.push({ + id: `seg-${i}`, + points: [ + [pts[i - 1].lat, pts[i - 1].lng], + [pts[i].lat, pts[i].lng], + ], + color: speedColor(pts[i - 1].speed), + }); } - return { speedSegments: speed, lbsSegments: lbs }; - }, [activePoints]); + return segments; + }, [gpsPoints]); // Линия по дорогам (OSRM) поверх раскраски: сплошная, цвет объекта, толще + const matched = wantMatched ? matchedTrackData?.matched : undefined; const matchedLine: GpsMapPolyline[] = - matchRoads && trackData?.matched && trackData.matched.points.length >= 2 + matched && matched.points.length >= 2 ? [ { id: 'matched', - points: trackData.matched.points, + points: matched.points, color: selectedAsset?.color || '#3b82f6', width: 5, }, ] : []; - // Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи. - // При включённой раскраске: базовая полилиния из всех точек под низом, - // сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек); - // LBS-участки (по вышкам) — всегда серым пунктиром поверх; + // Данные для провайдер-агностичной карты: холсты трека из GPS-точек + + // маркеры старта/финиша/плейхеда + точки записи (LBS — полые кружки). + // При включённой раскраске: холсты под низом серым, сверху — сегменты, + // окрашенные по скорости (с прореживанием до 800 точек); // matched-линия по дорогам — верхним слоем - const colorizeEnabled = colorize && activePoints.length >= 2; - const mapPolylines: GpsMapPolyline[] = - trackCoords.length >= 2 - ? [ - { - id: 'track', - points: trackCoords, - color: colorizeEnabled ? '#94a3b8' : selectedAsset?.color || '#3b82f6', - }, - ...(colorizeEnabled ? speedSegments : []), - ...lbsSegments, - ...matchedLine, - ] - : matchedLine; + const colorizeEnabled = colorize && gpsPoints.length >= 2; + const mapPolylines: GpsMapPolyline[] = [ + ...trackCanvases.map((pts, idx) => ({ + id: `track-${idx}`, + points: pts, + color: colorizeEnabled ? '#94a3b8' : selectedAsset?.color || '#3b82f6', + })), + ...(colorizeEnabled ? speedSegments : []), + ...matchedLine, + ]; const mapMarkers: GpsMapMarker[] = [ - // Точки записи координат (стрелки курса с попапом; в кластер не включаем; + // Точки записи координат (стрелки курса для GPS-точек; LBS — полые серые + // кружки без стрелки: курс у LBS фейковый; в кластер не включаем; // клик — попап + установка плейхеда проигрывания) ...displayedPoints.map((p) => ({ id: `tp-${p.id}`, @@ -372,7 +421,8 @@ export function GpsRoutesTab() { lng: p.lng, kind: 'dot' as const, color: '#64748b', - course: p.course ?? undefined, + course: p.isLbs ? undefined : (p.course ?? undefined), + approximate: p.isLbs === true ? true : undefined, onClick: () => seekToPoint(p), noCluster: true, zIndex: 100, // над полилиниями (matched/сегменты), иначе клики перехватывает линия @@ -445,8 +495,10 @@ export function GpsRoutesTab() { onClick={() => setSelectedTrip(idx)} title={`${trip.points.length} точек`} > - Поездка {idx + 1} · {formatTime(trip.startTs)}–{formatTime(trip.endTs)} · {trip.distanceKm.toFixed(1)} км - {trip.lbsDistanceKm > 0 && ` (+ ~${trip.lbsDistanceKm.toFixed(1)})`} + Поездка {idx + 1} · {formatTime(trip.startTs)}–{formatTime(trip.endTs)} ·{' '} + {trip.allLbs + ? '~по вышкам' + : `${trip.distanceKm.toFixed(1)} км${trip.lbsDistanceKm > 0 ? ` (+ ~${trip.lbsDistanceKm.toFixed(1)})` : ''}`} ))} @@ -477,15 +529,17 @@ export function GpsRoutesTab() { {selectedPoint.isLbs && (
Примерная позиция (по вышкам)
)} - {selectedPoint.course != null &&
Направление: {Math.round(selectedPoint.course)}°
} + {selectedPoint.course != null && !selectedPoint.isLbs && ( +
Направление: {Math.round(selectedPoint.course)}°
+ )}
{selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
Плейхед установлен на эту точку
)} - {/* Легенда раскраски трека по скорости + LBS-участки (пунктир) */} - {(colorizeEnabled || lbsSegments.length > 0) && ( + {/* Легенда раскраски трека по скорости + LBS-точки (полые кружки) */} + {(colorizeEnabled || hasLbsPoints) && (
{colorizeEnabled && ( <> @@ -498,10 +552,13 @@ export function GpsRoutesTab() { км/ч )} - {lbsSegments.length > 0 && ( + {hasLbsPoints && ( - - пунктир — нет GPS (по вышкам) + + полый кружок — нет GPS (по вышкам) )}
@@ -535,21 +592,26 @@ export function GpsRoutesTab() { )} - Пробег: {stats.distance.toFixed(1)} км + Пробег:{' '} + {stats.hasGpsPairs ? ( + {stats.distance.toFixed(1)} км + ) : ( + — (нет GPS-данных) + )} {stats.lbsDistance > 0 && ( - {' '}+ ~{stats.lbsDistance.toFixed(1)} км по вышкам + {' '}+ ~{stats.lbsDistance.toFixed(1)} км по вышкам (недостоверно) )} - {trackData?.matched && ( + {matched && ( Пробег по дорогам:{' '} - {(trackData.matched.distance / 1000).toFixed(1)} км + {(matched.distance / 1000).toFixed(1)} км )}