feat(gps): режим «потерян GPS» — LBS-точки помечаются (is_lbs), примерная позиция на карте, пунктир в треке; OSRM map matching трека (matched endpoint + переключатель «По дорогам»)
This commit is contained in:
@@ -26,6 +26,8 @@ export interface GpsMapMarker {
|
||||
label?: string;
|
||||
/** Приглушённый маркер для offline-объектов */
|
||||
offline?: boolean;
|
||||
/** Примерная позиция (LBS по вышкам, нет GPS): полупрозрачный маркер с пунктирной обводкой */
|
||||
approximate?: boolean;
|
||||
onClick?: () => void;
|
||||
zIndex?: number;
|
||||
/** HTML/text попапа по клику (balloon на Яндексе, popup на Leaflet) */
|
||||
@@ -52,6 +54,8 @@ export interface GpsMapPolyline {
|
||||
points: Array<[number, number]>;
|
||||
color?: string | null;
|
||||
dashed?: boolean;
|
||||
/** Толщина линии в px (по умолчанию 4) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface GpsMapProps {
|
||||
|
||||
@@ -141,7 +141,7 @@ export function OsmMap({
|
||||
|
||||
// Маркеры: перестраиваем слои при изменении данных (без учёта колбэков — они в ref)
|
||||
const markersKey = 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])
|
||||
markers.map((m) => [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!m.onClick, m.zIndex, m.popup, m.noCluster, m.course])
|
||||
);
|
||||
useEffect(() => {
|
||||
const layer = markersLayerRef.current;
|
||||
@@ -186,7 +186,7 @@ export function OsmMap({
|
||||
// Полигоны и полилинии
|
||||
const featuresKey = JSON.stringify([
|
||||
polygons.map((p) => [p.id, p.points, p.color, p.fillOpacity, p.strokeWidth]),
|
||||
polylines.map((l) => [l.id, l.points, l.color, l.dashed]),
|
||||
polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
|
||||
]);
|
||||
useEffect(() => {
|
||||
const layer = featuresLayerRef.current;
|
||||
@@ -206,7 +206,7 @@ export function OsmMap({
|
||||
if (l.points.length < 2) continue;
|
||||
L.polyline(l.points, {
|
||||
color: l.color || DEFAULT_COLOR,
|
||||
weight: 4,
|
||||
weight: l.width ?? 4,
|
||||
dashArray: l.dashed ? '8 6' : undefined,
|
||||
}).addTo(layer);
|
||||
}
|
||||
|
||||
@@ -194,9 +194,9 @@ export function Yandex21Map({
|
||||
// (иначе открытый 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]),
|
||||
: [m.id, m.lat, m.lng, m.color, m.kind, m.label, m.offline, m.approximate, !!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]),
|
||||
polylines.map((l) => [l.id, l.points, l.color, l.dashed, l.width]),
|
||||
]);
|
||||
useEffect(() => {
|
||||
const collection = collectionRef.current;
|
||||
@@ -224,7 +224,7 @@ export function Yandex21Map({
|
||||
collection.add(
|
||||
new ymaps.Polyline(l.points, {}, {
|
||||
strokeColor: l.color || DEFAULT_COLOR,
|
||||
strokeWidth: 4,
|
||||
strokeWidth: l.width ?? 4,
|
||||
strokeStyle: l.dashed ? 'dash' : 'solid',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -55,7 +55,8 @@ export function gpsMarkerLayout(marker: GpsMapMarker): GpsMarkerLayout {
|
||||
*/
|
||||
export function gpsMarkerHtml(marker: GpsMapMarker): string {
|
||||
const color = marker.color || DEFAULT_COLOR;
|
||||
const opacity = marker.offline ? 'opacity:0.5;' : '';
|
||||
// approximate (позиция по вышкам) — полупрозрачный маркер; offline — приглушённый
|
||||
const opacity = marker.offline ? 'opacity:0.5;' : marker.approximate ? 'opacity:0.55;' : '';
|
||||
const layout = gpsMarkerLayout(marker);
|
||||
const title = escapeHtml(marker.label || '');
|
||||
const label = marker.label
|
||||
@@ -70,7 +71,15 @@ export function gpsMarkerHtml(marker: GpsMapMarker): string {
|
||||
: `<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;
|
||||
body = `<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>`;
|
||||
if (marker.approximate) {
|
||||
// Примерная позиция: пунктирная обводка + бейдж «~» в углу кружка
|
||||
body = `<div style="position:relative;width:28px;height:28px;">` +
|
||||
`<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px dashed #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>` +
|
||||
`<div style="position:absolute;top:-4px;right:-4px;width:12px;height:12px;border-radius:50%;background:#f59e0b;border:1px solid #fff;color:#fff;font-size:9px;line-height:10px;font-weight:700;display:flex;align-items:center;justify-content:center;">~</div>` +
|
||||
`</div>`;
|
||||
} else {
|
||||
body = `<div style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;border:2px solid #fff;background:${color};box-shadow:0 1px 4px rgba(0,0,0,0.4);">${icon}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;width:${layout.width}px;user-select:none;cursor:pointer;${opacity}">${body}${label}</div>`;
|
||||
|
||||
@@ -91,12 +91,14 @@ export function useGpsGeozoneEvents(filters: { assetId?: number; geozoneId?: num
|
||||
});
|
||||
}
|
||||
|
||||
/** Трек объекта за период; загружается только при enabled (по кнопке «Показать») */
|
||||
export function useGpsTrack(assetId: number | null, from: string, to: string, enabled: boolean) {
|
||||
/** Трек объекта за период; загружается только при enabled (по кнопке «Показать»).
|
||||
* matched=true — дополнительно запрашивает линию по дорогам (OSRM) */
|
||||
export function useGpsTrack(assetId: number | null, from: string, to: string, enabled: boolean, matched = false) {
|
||||
return useQuery<GpsTrackResponse>({
|
||||
queryKey: ['/api/gps/assets', assetId, 'track', from, to],
|
||||
queryKey: ['/api/gps/assets', assetId, 'track', from, to, matched],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ from, to });
|
||||
if (matched) params.set('matched', '1');
|
||||
const res = await apiRequest('GET', `/api/gps/assets/${assetId}/track?${params.toString()}`);
|
||||
return res.json();
|
||||
},
|
||||
|
||||
@@ -200,6 +200,12 @@ export function GpsAssetsTab() {
|
||||
<TableCell className="text-xs font-mono">{asset.externalId || '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<div>{formatDateTime(asset.lastSeenAt)}</div>
|
||||
{/* Примерная позиция: последняя точка — координаты вышки (LBS) */}
|
||||
{asset.lastIsLbs && (
|
||||
<div className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
(примерно, по вышкам)
|
||||
</div>
|
||||
)}
|
||||
{/* Время точки по устройству, если сильно отстаёт от приёма сервером */}
|
||||
{asset.lastSeenAt &&
|
||||
asset.lastRecordedAt &&
|
||||
|
||||
@@ -150,6 +150,8 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
kind: p.type,
|
||||
label: p.name,
|
||||
offline: !p.isOnline,
|
||||
// Позиция по вышкам (LBS) — полупрозрачный маркер с пунктирной обводкой
|
||||
approximate: p.lastIsLbs === true,
|
||||
onClick: rulerMode ? undefined : () => setSelected(p),
|
||||
})),
|
||||
...rulerPoints.map(([lat, lng], idx): GpsMapMarker => ({
|
||||
@@ -255,6 +257,14 @@ export function GpsMapTab({ onShowRoute }: { onShowRoute: (assetId: number) => v
|
||||
Скорость:{' '}
|
||||
{selectedLive.speed != null ? `${Math.round(selectedLive.speed)} км/ч` : '—'}
|
||||
</div>
|
||||
{selectedLive.lastIsLbs && (
|
||||
<div className="text-amber-600 dark:text-amber-400">
|
||||
Позиция примерная (по вышкам, нет GPS)
|
||||
</div>
|
||||
)}
|
||||
{selectedLive.gpsLost && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Нет GPS-сигнала</div>
|
||||
)}
|
||||
<div>Обновлено: {formatDateTime(seenAt)}</div>
|
||||
{recordedStale && <div>Точка от: {formatDateTime(recordedAt)}</div>}
|
||||
{selectedAsset?.externalId && <div>Трекер: {selectedAsset.externalId}</div>}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Loader2, Pause, Play } from 'lucide-react';
|
||||
import { GpsMap, type GpsMapMarker, type GpsMapPolyline } from '@/components/gps/GpsMap';
|
||||
import { AssetTreePanel } from '@/components/gps/AssetTreePanel';
|
||||
import { useGpsAssets, useGpsGroups, useGpsTrack } from '@/hooks/useGps';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { GpsTrackPoint } from './types';
|
||||
|
||||
/** Расстояние между двумя точками в км (формула гаверсинуса) */
|
||||
@@ -76,6 +77,8 @@ function splitIntoTrips(points: GpsTrackPoint[], tripStopMinutes: number): Trip[
|
||||
const finish = (pts: GpsTrackPoint[]) => {
|
||||
let distanceKm = 0;
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
// LBS-участки (точки по вышкам) в пробег не считаем
|
||||
if (pts[i - 1].isLbs || pts[i].isLbs) continue;
|
||||
distanceKm += haversineKm(pts[i - 1].lat, pts[i - 1].lng, pts[i].lat, pts[i].lng);
|
||||
}
|
||||
trips.push({
|
||||
@@ -110,6 +113,7 @@ interface GpsSettingsResponse {
|
||||
* по скорости), деление дня на поездки, статистика и проигрывание с плейхедом.
|
||||
*/
|
||||
export function GpsRoutesTab() {
|
||||
const { toast } = useToast();
|
||||
const { data: assetsData } = useGpsAssets();
|
||||
const { data: groupsData } = useGpsGroups();
|
||||
const assets = (assetsData?.assets || []).filter((a) => a.isActive !== false);
|
||||
@@ -135,6 +139,8 @@ export function GpsRoutesTab() {
|
||||
const [to, setTo] = useState(initialRange.to);
|
||||
// Применённые параметры запроса — трек грузится только по кнопке «Показать»
|
||||
const [applied, setApplied] = useState<{ assetId: number; from: string; to: string } | null>(null);
|
||||
// Линия трека по дорогам (OSRM map matching); переключатель дёргает refetch
|
||||
const [matchRoads, setMatchRoads] = useState(true);
|
||||
|
||||
// Пришли по ссылке с карты — сразу показываем трек за сегодня
|
||||
useEffect(() => {
|
||||
@@ -152,10 +158,23 @@ export function GpsRoutesTab() {
|
||||
applied?.assetId ?? null,
|
||||
applied ? new Date(applied.from).toISOString() : '',
|
||||
applied ? new Date(applied.to).toISOString() : '',
|
||||
!!applied
|
||||
!!applied,
|
||||
matchRoads
|
||||
);
|
||||
const points: GpsTrackPoint[] = trackData?.points || [];
|
||||
|
||||
// matched === null — OSRM недоступен: тихо показываем обычный трек,
|
||||
// один раз предупреждаем toast'ом (на каждый новый запрос трека)
|
||||
const osrmToastRef = useRef(false);
|
||||
useEffect(() => {
|
||||
osrmToastRef.current = false;
|
||||
}, [applied, matchRoads]);
|
||||
useEffect(() => {
|
||||
if (!matchRoads || !trackData || trackData.matched !== null || osrmToastRef.current) return;
|
||||
osrmToastRef.current = true;
|
||||
toast({ title: 'OSRM недоступен', description: 'Показан трек по точкам' });
|
||||
}, [trackData, matchRoads, toast]);
|
||||
|
||||
const selectedAsset = assets.find((a) => String(a.id) === (applied ? String(applied.assetId) : assetId));
|
||||
|
||||
// === Поездки ===
|
||||
@@ -176,9 +195,13 @@ export function GpsRoutesTab() {
|
||||
let distance = 0;
|
||||
let maxSpeed = 0;
|
||||
for (let i = 1; i < activePoints.length; i++) {
|
||||
// LBS-участки (точки по вышкам) в пробег не считаем
|
||||
if (activePoints[i - 1].isLbs || activePoints[i].isLbs) continue;
|
||||
distance += haversineKm(activePoints[i - 1].lat, activePoints[i - 1].lng, activePoints[i].lat, activePoints[i].lng);
|
||||
}
|
||||
for (const p of activePoints) {
|
||||
// Примерные точки (LBS) не учитываем в макс. скорости
|
||||
if (p.isLbs) continue;
|
||||
if (p.speed != null && p.speed > maxSpeed) maxSpeed = p.speed;
|
||||
}
|
||||
const startTs = new Date(activePoints[0].recordedAt).getTime();
|
||||
@@ -274,32 +297,50 @@ export function GpsRoutesTab() {
|
||||
// Выбранная кликом точка трека — инфо-панель поверх карты (вместо balloon карты)
|
||||
const [selectedPoint, setSelectedPoint] = useState<GpsTrackPoint | null>(null);
|
||||
|
||||
// Сегменты трека, окрашенные по скорости в начальной точке сегмента.
|
||||
// При > 800 точек прореживаем до ~800 для раскраски (базовая полилиния
|
||||
// из всех точек остаётся под низом). 800 сегментов оба провайдера
|
||||
// тянут нормально; если Яндекс просядет — порог снизить до 400.
|
||||
// Сегменты трека: LBS-участки (хотя бы одна точка сегмента — по вышкам)
|
||||
// рисуем серым пунктиром и тоньше, остальные — по скорости начальной точки.
|
||||
// При > 800 точек прореживаем до ~800 (базовая полилиния из всех точек
|
||||
// остаётся под низом). 800 сегментов оба провайдера тянут нормально;
|
||||
// если Яндекс просядет — порог снизить до 400.
|
||||
const COLORIZE_POINTS_LIMIT = 800;
|
||||
const speedSegments: GpsMapPolyline[] = useMemo(() => {
|
||||
if (activePoints.length < 2) return [];
|
||||
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 segments: GpsMapPolyline[] = [];
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
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),
|
||||
});
|
||||
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) });
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
return { speedSegments: speed, lbsSegments: lbs };
|
||||
}, [activePoints]);
|
||||
|
||||
// Линия по дорогам (OSRM) поверх раскраски: сплошная, цвет объекта, толще
|
||||
const matchedLine: GpsMapPolyline[] =
|
||||
matchRoads && trackData?.matched && trackData.matched.points.length >= 2
|
||||
? [
|
||||
{
|
||||
id: 'matched',
|
||||
points: trackData.matched.points,
|
||||
color: selectedAsset?.color || '#3b82f6',
|
||||
width: 5,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// Данные для провайдер-агностичной карты: полилиния трека + маркеры старта/финиша/плейхеда + точки записи.
|
||||
// При включённой раскраске: базовая полилиния из всех точек под низом,
|
||||
// сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек)
|
||||
// сверху — сегменты, окрашенные по скорости (с прореживанием до 800 точек);
|
||||
// LBS-участки (по вышкам) — всегда серым пунктиром поверх;
|
||||
// matched-линия по дорогам — верхним слоем
|
||||
const colorizeEnabled = colorize && activePoints.length >= 2;
|
||||
const mapPolylines: GpsMapPolyline[] =
|
||||
trackCoords.length >= 2
|
||||
@@ -310,8 +351,10 @@ export function GpsRoutesTab() {
|
||||
color: colorizeEnabled ? '#94a3b8' : selectedAsset?.color || '#3b82f6',
|
||||
},
|
||||
...(colorizeEnabled ? speedSegments : []),
|
||||
...lbsSegments,
|
||||
...matchedLine,
|
||||
]
|
||||
: [];
|
||||
: matchedLine;
|
||||
const mapMarkers: GpsMapMarker[] = [
|
||||
// Точки записи координат (стрелки курса с попапом; в кластер не включаем;
|
||||
// клик — попап + установка плейхеда проигрывания)
|
||||
@@ -366,6 +409,10 @@ export function GpsRoutesTab() {
|
||||
<Checkbox checked={colorize} onCheckedChange={(v) => setColorize(!!v)} />
|
||||
<span className="text-xs">Раскрасить по скорости</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={matchRoads} onCheckedChange={(v) => setMatchRoads(!!v)} />
|
||||
<span className="text-xs">По дорогам</span>
|
||||
</label>
|
||||
</div>
|
||||
</AssetTreePanel>
|
||||
|
||||
@@ -418,6 +465,9 @@ export function GpsRoutesTab() {
|
||||
</button>
|
||||
</div>
|
||||
<div>Скорость: <b>{selectedPoint.speed != null ? `${Math.round(selectedPoint.speed)} км/ч` : '—'}</b></div>
|
||||
{selectedPoint.isLbs && (
|
||||
<div className="text-amber-600 dark:text-amber-400">Примерная позиция (по вышкам)</div>
|
||||
)}
|
||||
{selectedPoint.course != null && <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>
|
||||
@@ -425,16 +475,26 @@ export function GpsRoutesTab() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Легенда раскраски трека по скорости */}
|
||||
{colorizeEnabled && (
|
||||
{/* Легенда раскраски трека по скорости + LBS-участки (пунктир) */}
|
||||
{(colorizeEnabled || lbsSegments.length > 0) && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-[10px] text-muted-foreground">
|
||||
{SPEED_STOPS.map((s) => (
|
||||
<span key={s.label} className="flex items-center gap-1">
|
||||
<span className="w-4 h-1.5 rounded-sm" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
{colorizeEnabled && (
|
||||
<>
|
||||
{SPEED_STOPS.map((s) => (
|
||||
<span key={s.label} className="flex items-center gap-1">
|
||||
<span className="w-4 h-1.5 rounded-sm" style={{ backgroundColor: s.color }} />
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
<span>км/ч</span>
|
||||
</>
|
||||
)}
|
||||
{lbsSegments.length > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-4 border-t-2 border-dashed" style={{ borderColor: SPEED_UNKNOWN_COLOR }} />
|
||||
пунктир — нет GPS (по вышкам)
|
||||
</span>
|
||||
))}
|
||||
<span>км/ч</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -463,6 +523,14 @@ export function GpsRoutesTab() {
|
||||
<span>
|
||||
Пробег: <span className="text-foreground font-medium">{stats.distance.toFixed(1)} км</span>
|
||||
</span>
|
||||
{trackData?.matched && (
|
||||
<span title="По линии OSRM map matching">
|
||||
Пробег по дорогам:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{(trackData.matched.distance / 1000).toFixed(1)} км
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Время в пути:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
|
||||
@@ -23,6 +23,10 @@ export interface GpsAsset {
|
||||
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
||||
lastRecordedAt?: string | null;
|
||||
lastGpsFixAt?: string | null;
|
||||
/** Режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS (по вышкам) */
|
||||
gpsLost?: boolean;
|
||||
/** Последняя точка — координаты вышки (примерная позиция) */
|
||||
lastIsLbs?: boolean;
|
||||
createdAt: string | null;
|
||||
/** Группы объекта (добавляет GET /api/gps/assets) */
|
||||
groups?: Array<{ id: number; name: string }>;
|
||||
@@ -48,6 +52,10 @@ export interface GpsPosition {
|
||||
/** Время последней точки по устройству (может быть старым без GPS-фикса) */
|
||||
lastRecordedAt?: string | null;
|
||||
lastGpsFixAt?: string | null;
|
||||
/** Режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS (по вышкам) */
|
||||
gpsLost?: boolean;
|
||||
/** Последняя точка — координаты вышки (примерная позиция) */
|
||||
lastIsLbs?: boolean;
|
||||
}
|
||||
|
||||
/** Точка трека (GET /api/gps/assets/:id/track) */
|
||||
@@ -59,6 +67,8 @@ export interface GpsTrackPoint {
|
||||
speed: number | null;
|
||||
course: number | null;
|
||||
accuracy: number | null;
|
||||
/** Точка — координаты вышки (LBS), а не GPS-фикс */
|
||||
isLbs?: boolean;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
@@ -69,6 +79,8 @@ export interface GpsTrackResponse {
|
||||
/** Число отброшенных сервером устаревших/телепорт-точек */
|
||||
filteredCount?: number;
|
||||
sampled: boolean;
|
||||
/** Линия трека по дорогам от OSRM (при ?matched=1); null — OSRM недоступен */
|
||||
matched?: { points: Array<[number, number]>; distance: number; duration: number } | null;
|
||||
}
|
||||
|
||||
/** Геозона: polygon — массив пар [lat, lng] */
|
||||
|
||||
Reference in New Issue
Block a user