fix(gps): LBS-точки без линий (конец «мяча»), честные поездки без фейковых разрывов, тост OSRM только при реальной недоступности

This commit is contained in:
2026-08-30 12:49:00 +03:00
parent 3f4d35ae83
commit 0386005463
2 changed files with 151 additions and 83 deletions

View File

@@ -67,10 +67,16 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string {
let body: string;
if (marker.kind === 'dot') {
body =
marker.course != null
? `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;transform:rotate(${marker.course}deg);" title="${title}">${arrowSvg(color)}</div>`
: `<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${title}"></div>`;
if (marker.approximate) {
// LBS-точка трека: полый серый кружок без стрелки (курс у LBS фейковый).
// Внешний блок 16px сохраняет якорь dot-маркера (anchorY=8)
body = `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;" title="${title}"><div style="width:10px;height:10px;border-radius:50%;border:2px solid #94a3b8;background:rgba(255,255,255,0.5);box-shadow:0 1px 2px rgba(0,0,0,0.2);"></div></div>`;
} else {
body =
marker.course != null
? `<div style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;transform:rotate(${marker.course}deg);" title="${title}">${arrowSvg(color)}</div>`
: `<div style="width:16px;height:16px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 3px rgba(0,0,0,0.3);" title="${title}"></div>`;
}
} else {
const icon = marker.kind === 'person' ? USER_SVG : CAR_SVG;
if (marker.approximate) {

View File

@@ -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<Array<[number, number]>> = [];
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<GpsTrackPoint | null>(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)})` : ''}`}
</Button>
))}
</div>
@@ -477,15 +529,17 @@ export function GpsRoutesTab() {
{selectedPoint.isLbs && (
<div className="text-amber-600 dark:text-amber-400">Примерная позиция (по вышкам)</div>
)}
{selectedPoint.course != null && <div>Направление: {Math.round(selectedPoint.course)}°</div>}
{selectedPoint.course != null && !selectedPoint.isLbs && (
<div>Направление: {Math.round(selectedPoint.course)}°</div>
)}
<div className="text-muted-foreground">{selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}</div>
<div className="text-muted-foreground">Плейхед установлен на эту точку</div>
</div>
)}
</div>
{/* Легенда раскраски трека по скорости + LBS-участки (пунктир) */}
{(colorizeEnabled || lbsSegments.length > 0) && (
{/* Легенда раскраски трека по скорости + LBS-точки (полые кружки) */}
{(colorizeEnabled || hasLbsPoints) && (
<div className="flex items-center gap-2 flex-wrap text-[10px] text-muted-foreground">
{colorizeEnabled && (
<>
@@ -498,10 +552,13 @@ export function GpsRoutesTab() {
<span>км/ч</span>
</>
)}
{lbsSegments.length > 0 && (
{hasLbsPoints && (
<span className="flex items-center gap-1">
<span className="w-4 border-t-2 border-dashed" style={{ borderColor: SPEED_UNKNOWN_COLOR }} />
пунктир — нет GPS (по вышкам)
<span
className="w-2.5 h-2.5 rounded-full border-2"
style={{ borderColor: SPEED_UNKNOWN_COLOR, backgroundColor: 'rgba(255,255,255,0.5)' }}
/>
полый кружок — нет GPS (по вышкам)
</span>
)}
</div>
@@ -535,21 +592,26 @@ export function GpsRoutesTab() {
</span>
)}
<span>
Пробег: <span className="text-foreground font-medium">{stats.distance.toFixed(1)} км</span>
Пробег:{' '}
{stats.hasGpsPairs ? (
<span className="text-foreground font-medium">{stats.distance.toFixed(1)} км</span>
) : (
<span className="text-foreground font-medium">— (нет GPS-данных)</span>
)}
{stats.lbsDistance > 0 && (
<span
className="text-amber-600 dark:text-amber-400"
title="Примерный пробег по LBS-точкам (позиция по вышкам, без GPS)"
>
{' '}+ ~{stats.lbsDistance.toFixed(1)} км по вышкам
{' '}+ ~{stats.lbsDistance.toFixed(1)} км по вышкам (недостоверно)
</span>
)}
</span>
{trackData?.matched && (
{matched && (
<span title="По линии OSRM map matching">
Пробег по дорогам:{' '}
<span className="text-foreground font-medium">
{(trackData.matched.distance / 1000).toFixed(1)} км
{(matched.distance / 1000).toFixed(1)} км
</span>
</span>
)}