fix(gps): LBS-точки без линий (конец «мяча»), честные поездки без фейковых разрывов, тост OSRM только при реальной недоступности
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user