updates on the fix
This commit is contained in:
@@ -58,6 +58,7 @@ import ProfitabilitySection from './ProfitabilitySection';
|
||||
import ActiveSection from './ActiveSection';
|
||||
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../../api/api';
|
||||
import { getConsignmentLogs } from 'pages/api/doormileApi';
|
||||
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||
import {
|
||||
STATUS_STYLES,
|
||||
getStatusStyle,
|
||||
@@ -66,7 +67,10 @@ import {
|
||||
STEP_PALETTE,
|
||||
stepColor,
|
||||
isActiveDelivery,
|
||||
getActiveOrder
|
||||
getActiveOrder,
|
||||
haversineKm,
|
||||
polylineLengthKm,
|
||||
kalmanSmoothGps
|
||||
} from './dispatchShared';
|
||||
import CompareDataPanel from './CompareDataPanel';
|
||||
import './Dispatch.css';
|
||||
@@ -224,7 +228,12 @@ const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
|
||||
const str = String(t).trim();
|
||||
// Skip bare date strings — no time component, would always parse to midnight.
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||
const d = dayjs(t);
|
||||
// parseDoormileTimestamp strips a false trailing Z some Doormile timestamps
|
||||
// carry (see utils/doormileTimestamp.js) — bare dayjs(t) would treat that as
|
||||
// a real UTC instant and shift it +5:30 (IST), potentially bucketing a
|
||||
// just-assigned row into the wrong slot or out of every slot entirely.
|
||||
// deliveries.js's getRowBatchId uses the same parse so both pages agree.
|
||||
const d = parseDoormileTimestamp(t);
|
||||
if (!d.isValid()) return null;
|
||||
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
|
||||
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
|
||||
@@ -273,274 +282,9 @@ function MapAutoResize({ trigger }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Haversine distance between two [lat, lng] points in kilometers. Good to
|
||||
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
|
||||
// polyline so the Compare delta panel can show "actual km" without depending
|
||||
// on the backend's actualkms field (which can be stale or missing).
|
||||
function haversineKm(a, b) {
|
||||
const R = 6371; // km
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const lat1 = toRad(a[0]);
|
||||
const lat2 = toRad(b[0]);
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLon = toRad(b[1] - a[1]);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
function polylineLengthKm(points) {
|
||||
if (!Array.isArray(points) || points.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
total += haversineKm(points[i - 1], points[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
|
||||
//
|
||||
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
|
||||
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
|
||||
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
|
||||
// velocity dynamics with random acceleration as process noise; measurement
|
||||
// model H = [1, 0] (we measure position only).
|
||||
//
|
||||
// Pipeline:
|
||||
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
|
||||
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
|
||||
// forward filter even with the in-loop Mahalanobis gate enabled.
|
||||
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
|
||||
// innovation exceeds the gate are not used to update; the prediction
|
||||
// is kept as the posterior. Stores prior + posterior moments at each
|
||||
// step so the backward pass can run.
|
||||
// 3. Backward RTS smoother — refines every step using ALL future
|
||||
// observations. Logs are fetched in one shot (not streamed) so we
|
||||
// can afford the second pass; the accuracy lift is biggest near the
|
||||
// start of the trail and through turns the forward pass under-corrects.
|
||||
//
|
||||
// Tuning (all in degrees² since pings are in lat/lng):
|
||||
// processNoise (q) — random-acceleration variance (deg²/s²). Default
|
||||
// tuned for urban two-wheelers (~1 m/s² accel).
|
||||
// Lower = smoother but slower to follow sharp turns.
|
||||
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
|
||||
// which matches consumer GPS in open urban areas.
|
||||
// Bump for dense canyons.
|
||||
// outlierGate — Mahalanobis² threshold for in-loop rejection.
|
||||
// 9.0 = 3σ (≈ 99.7% of inliers pass).
|
||||
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
|
||||
// 120 km/h covers any legal two-wheeler movement
|
||||
// plus margin; anything above is GPS error.
|
||||
function kalmanSmoothGps(pings, options = {}) {
|
||||
if (!Array.isArray(pings) || pings.length === 0) return [];
|
||||
|
||||
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
|
||||
const cleanedPings = pings.filter(p =>
|
||||
Number.isFinite(p.lat) &&
|
||||
Number.isFinite(p.lng) &&
|
||||
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
|
||||
);
|
||||
|
||||
if (cleanedPings.length === 0) return [];
|
||||
if (cleanedPings.length === 1) {
|
||||
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
|
||||
}
|
||||
|
||||
const processNoise =
|
||||
options.processNoise != null ? options.processNoise : 1e-10;
|
||||
const measurementNoise =
|
||||
options.measurementNoise != null ? options.measurementNoise : 2e-9;
|
||||
const outlierGate =
|
||||
options.outlierGate != null ? options.outlierGate : 9.0;
|
||||
const maxSpeedKmh =
|
||||
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
|
||||
|
||||
const tsOf = (p) =>
|
||||
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
|
||||
|
||||
// 2. Scan forward to find the first valid starting anchor
|
||||
let startIdx = 0;
|
||||
while (startIdx < cleanedPings.length - 1) {
|
||||
const p0 = cleanedPings[startIdx];
|
||||
const p1 = cleanedPings[startIdx + 1];
|
||||
const ts0 = tsOf(p0);
|
||||
const ts1 = tsOf(p1) || ts0 + 1000;
|
||||
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
|
||||
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
|
||||
if (speedKmh <= maxSpeedKmh) {
|
||||
break;
|
||||
} else {
|
||||
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
|
||||
if (startIdx + 2 < cleanedPings.length) {
|
||||
const p2 = cleanedPings[startIdx + 2];
|
||||
const ts2 = tsOf(p2) || ts1 + 1000;
|
||||
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
|
||||
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
|
||||
const speedKmh12 = (km12 / dtSec12) * 3600;
|
||||
|
||||
if (speedKmh12 <= maxSpeedKmh) {
|
||||
startIdx = startIdx + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
startIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Teleport filter starting from the valid anchor
|
||||
const accepted = [cleanedPings[startIdx]];
|
||||
let lastTs = tsOf(cleanedPings[startIdx]);
|
||||
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
|
||||
const p = cleanedPings[i];
|
||||
const ts = tsOf(p) || lastTs + 1000;
|
||||
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
|
||||
const prev = accepted[accepted.length - 1];
|
||||
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
if (speedKmh > maxSpeedKmh) continue;
|
||||
accepted.push(p);
|
||||
lastTs = ts;
|
||||
}
|
||||
|
||||
if (accepted.length < 2) {
|
||||
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
|
||||
}
|
||||
|
||||
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
|
||||
// positions parallel to `accepted`.
|
||||
const smoothAxis = (axisKey) => {
|
||||
const N = accepted.length;
|
||||
// Per-step storage for the backward RTS pass.
|
||||
const xPost = new Array(N); // [pos, vel] posterior after update
|
||||
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
|
||||
const xPrior = new Array(N); // predicted mean before update
|
||||
const pPrior = new Array(N); // predicted cov before update
|
||||
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
|
||||
|
||||
// Initial state: position = first measurement, velocity from the first
|
||||
// two pings (better than 0 — keeps the start of the trail from lagging
|
||||
// behind the rider's actual motion). Initial position covariance = r
|
||||
// (we just measured it); initial velocity covariance is loose so it
|
||||
// can be refined quickly.
|
||||
const ts0 = tsOf(accepted[0]);
|
||||
const ts1 = tsOf(accepted[1]);
|
||||
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
|
||||
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
|
||||
xPost[0] = [accepted[0][axisKey], v0];
|
||||
pPost[0] = [measurementNoise, 0, 0, 1];
|
||||
xPrior[0] = xPost[0].slice();
|
||||
pPrior[0] = pPost[0].slice();
|
||||
dtArr[0] = 0;
|
||||
|
||||
let prevTs = ts0;
|
||||
for (let i = 1; i < N; i++) {
|
||||
const ts = tsOf(accepted[i]) || prevTs + 1000;
|
||||
const dt = Math.max(0.1, (ts - prevTs) / 1000);
|
||||
prevTs = ts;
|
||||
dtArr[i] = dt;
|
||||
|
||||
// ─── Predict ───
|
||||
// x' = F x where F = [[1, dt], [0, 1]]
|
||||
const [xPrev, vPrev] = xPost[i - 1];
|
||||
const xPredPos = xPrev + vPrev * dt;
|
||||
const xPredVel = vPrev;
|
||||
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
|
||||
const dt2 = dt * dt;
|
||||
const dt3 = dt2 * dt;
|
||||
const dt4 = dt3 * dt;
|
||||
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
|
||||
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np11 = pp11 + dt2 * processNoise;
|
||||
xPrior[i] = [xPredPos, xPredVel];
|
||||
pPrior[i] = [np00, np01, np10, np11];
|
||||
|
||||
// ─── Update (with Mahalanobis gating) ───
|
||||
// y = z − Hx' (innovation)
|
||||
// S = H P' H^T + R (innovation covariance)
|
||||
// Reject the measurement if mahal² = y²/S exceeds the gate. The
|
||||
// prediction then carries forward as the posterior — the trail stays
|
||||
// continuous instead of being yanked toward a bad fix.
|
||||
const z = accepted[i][axisKey];
|
||||
const y = z - xPredPos;
|
||||
const S = np00 + measurementNoise;
|
||||
const mahal2 = (y * y) / S;
|
||||
if (mahal2 > outlierGate) {
|
||||
xPost[i] = [xPredPos, xPredVel];
|
||||
pPost[i] = [np00, np01, np10, np11];
|
||||
continue;
|
||||
}
|
||||
// K = P' H^T / S
|
||||
const K0 = np00 / S;
|
||||
const K1 = np10 / S;
|
||||
// x = x' + K y
|
||||
const newPos = xPredPos + K0 * y;
|
||||
const newVel = xPredVel + K1 * y;
|
||||
// P = (I − K H) P'
|
||||
xPost[i] = [newPos, newVel];
|
||||
pPost[i] = [
|
||||
(1 - K0) * np00,
|
||||
(1 - K0) * np01,
|
||||
np10 - K1 * np00,
|
||||
np11 - K1 * np01
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Backward RTS smoother ─────────────────────────────────────────
|
||||
// x_smooth[N-1] = x_post[N-1]
|
||||
// For i = N-2 … 0:
|
||||
// C = P_post[i] · F^T · inv(P_prior[i+1])
|
||||
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
|
||||
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
|
||||
// P_post · F^T = [[p00 + dt·p01, p01],
|
||||
// [p10 + dt·p11, p11]]
|
||||
const xSmooth = new Array(N);
|
||||
xSmooth[N - 1] = xPost[N - 1].slice();
|
||||
for (let i = N - 2; i >= 0; i--) {
|
||||
const dt = dtArr[i + 1];
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i];
|
||||
const a = pp00 + dt * pp01;
|
||||
const b = pp01;
|
||||
const c = pp10 + dt * pp11;
|
||||
const d = pp11;
|
||||
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
|
||||
const [q00, q01, q10, q11] = pPrior[i + 1];
|
||||
const det = q00 * q11 - q01 * q10;
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
|
||||
xSmooth[i] = xPost[i].slice();
|
||||
continue;
|
||||
}
|
||||
const inv00 = q11 / det;
|
||||
const inv01 = -q01 / det;
|
||||
const inv10 = -q10 / det;
|
||||
const inv11 = q00 / det;
|
||||
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
|
||||
const c00 = a * inv00 + b * inv10;
|
||||
const c01 = a * inv01 + b * inv11;
|
||||
const c10 = c * inv00 + d * inv10;
|
||||
const c11 = c * inv01 + d * inv11;
|
||||
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
|
||||
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
|
||||
xSmooth[i] = [
|
||||
xPost[i][0] + c00 * dxPos + c01 * dxVel,
|
||||
xPost[i][1] + c10 * dxPos + c11 * dxVel
|
||||
];
|
||||
}
|
||||
|
||||
return xSmooth.map((s) => s[0]);
|
||||
};
|
||||
|
||||
const lats = smoothAxis('lat');
|
||||
const lngs = smoothAxis('lng');
|
||||
return accepted.map((p, i) => ({
|
||||
lat: lats[i],
|
||||
lng: lngs[i],
|
||||
logdate: p.logdate,
|
||||
_ts: p._ts
|
||||
}));
|
||||
}
|
||||
// haversineKm/polylineLengthKm/kalmanSmoothGps moved to dispatchShared.js so
|
||||
// deliveries.js's Update Status dialog can compute the same real, GPS-based
|
||||
// Actual KMs figure instead of leaving that field permanently blank.
|
||||
|
||||
// Splits a routed OSRM polyline into per-step segments by finding the
|
||||
// polyline index closest to each drop waypoint. Returns an array of
|
||||
@@ -594,7 +338,7 @@ const formatTimeOnly = (t) => {
|
||||
if (!t) return null;
|
||||
const d = dayjs(t);
|
||||
if (!d.isValid()) return String(t);
|
||||
return d.format('HH:mm:ss');
|
||||
return d.format('hh:mm A');
|
||||
};
|
||||
|
||||
// Stages the popup walks through, top → bottom, in real-world delivery order.
|
||||
@@ -981,14 +725,6 @@ const ANALYSIS_BATCH_WINDOWS = [
|
||||
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
|
||||
];
|
||||
|
||||
// Tolerant field-name lookup so the Analysis card still renders cleanly even
|
||||
// if the API response uses slightly different keys than expected.
|
||||
const analysisPick = (obj, keys) => {
|
||||
for (const k of keys) {
|
||||
if (obj && obj[k] != null && obj[k] !== '') return obj[k];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const analysisFormatNum = (v) => {
|
||||
if (v == null) return '—';
|
||||
if (typeof v === 'number') return v.toLocaleString('en-IN');
|
||||
@@ -996,14 +732,6 @@ const analysisFormatNum = (v) => {
|
||||
if (Number.isFinite(n)) return n.toLocaleString('en-IN');
|
||||
return String(v);
|
||||
};
|
||||
const analysisFormatKm = (v) => (v == null ? '—' : `${parseFloat(v).toFixed(1)} km`);
|
||||
const analysisFormatRupees = (v) => (v == null ? '—' : `₹${parseFloat(v).toFixed(0)}`);
|
||||
const analysisFormatPct = (v) => {
|
||||
if (v == null) return '—';
|
||||
const n = parseFloat(v);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`;
|
||||
};
|
||||
// Parse "HH:mm:ss" or "HH:mm" → seconds since midnight. Returns null when the
|
||||
// string is missing or malformed. Used to compute gantt percentages for the
|
||||
// rider timelines on the Analysis page — the API ships those fields as bare
|
||||
@@ -3507,13 +3235,6 @@ const Dispatch = ({
|
||||
return routes;
|
||||
};
|
||||
|
||||
const toggleRider = (rid) => {
|
||||
const newActive = new Set(activeRiders);
|
||||
if (newActive.has(rid)) newActive.delete(rid);
|
||||
else newActive.add(rid);
|
||||
setActiveRiders(newActive);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`dispatch-container${embedded ? ' embedded' : ''}${compareOpen ? ' compare-open' : ''}`}>
|
||||
{!embedded && (
|
||||
@@ -4617,7 +4338,7 @@ const Dispatch = ({
|
||||
|
||||
<div className="zone-order-stats">
|
||||
<span className="zone-order-chip" title="Distance">
|
||||
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
|
||||
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
|
||||
</span>
|
||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||
@@ -4766,7 +4487,7 @@ const Dispatch = ({
|
||||
|
||||
<div className="zone-order-stats">
|
||||
<span className="zone-order-chip" title="Distance">
|
||||
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
|
||||
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
|
||||
</span>
|
||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||
@@ -4913,7 +4634,7 @@ const Dispatch = ({
|
||||
|
||||
<div className="zone-order-stats">
|
||||
<span className="zone-order-chip" title="Distance">
|
||||
<Ico><MdStraighten /></Ico>{o.actualkms || o.kms || 0} km
|
||||
<Ico><MdStraighten /></Ico>{Number(o.actualkms || o.kms || 0).toFixed(2)} km
|
||||
</span>
|
||||
<span className={`zone-order-chip ${isLoss ? 'is-loss' : 'is-profit'}`} title="Profit">
|
||||
<Ico><MdAccountBalanceWallet /></Ico>{isLoss ? '-' : ''}₹{Math.abs(profit).toFixed(0)}
|
||||
@@ -5120,6 +4841,14 @@ const Dispatch = ({
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' />
|
||||
<ZoomControl position="bottomright" />
|
||||
{compareOpen && <CaptureMap targetRef={leftMapRef} />}
|
||||
{compareOpen && (
|
||||
<CompareMapClickUnpin
|
||||
onUnpin={() => {
|
||||
pinnedPopupsRef.current.clear();
|
||||
setCenterPopupOrder(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MapAutoResize trigger={`${sidebarCollapsed}|${compareOpen}|${compareDataCollapsed}`} />
|
||||
<MapController focusedItem={compareFocusItem || ((focusedRider || focusedKitchen) && focusedStop) || focusedRider || focusedKitchen || focusedZone} viewMode={viewMode} orders={allViewOrders} kitchens={kitchens} locationKey={selectedAppLocationId} extraPoints={allViewLivePoints} />
|
||||
{kitchens
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import logger from '../../../utils/logger';
|
||||
import {
|
||||
Autocomplete,
|
||||
Backdrop,
|
||||
@@ -26,15 +27,17 @@ import dayjs from 'dayjs';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import { HiOutlineArrowLeft } from 'react-icons/hi';
|
||||
import { IoReload } from 'react-icons/io5';
|
||||
import { MdTwoWheeler, MdSwapHoriz } from 'react-icons/md';
|
||||
import { MdTwoWheeler, MdSwapHoriz, MdWarning } from 'react-icons/md';
|
||||
|
||||
import {
|
||||
buildMilerLookup,
|
||||
createAutomationDeliveries,
|
||||
createOptimisationDeliveries,
|
||||
fetchRidersList,
|
||||
finalCreatedeliveries,
|
||||
notifyRider,
|
||||
reconcileSteps
|
||||
reconcileSteps,
|
||||
resolveMilerForOrder
|
||||
} from '../../api/api';
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import CSVExport from 'components/third-party/ReactTable';
|
||||
@@ -152,6 +155,14 @@ const moveOrderInPreviewData = (preview, { orderId, newRiderId, newRiderName })
|
||||
movedOrder = r.orders[oi];
|
||||
r.orders.splice(oi, 1);
|
||||
homeZoneIdx = zi;
|
||||
// A rider left with zero orders after this move is a ghost entry —
|
||||
// Dispatch's rider list renders every zone.riders[] entry
|
||||
// unconditionally, so it would keep showing as a clickable
|
||||
// 0-trips/0km/₹0 card with nothing inside once its last order is
|
||||
// reassigned elsewhere. Drop it from the zone entirely.
|
||||
if (r.orders.length === 0) {
|
||||
zone.riders.splice(ri, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +254,15 @@ const applyReconcileResponse = (preview, response) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Same ghost-rider cleanup as moveOrderInPreviewData: if the reconcile
|
||||
// response came back with an empty orders[] for a rider (every stop it
|
||||
// had got reassigned elsewhere during reconciliation), don't leave that
|
||||
// rider sitting in the tree as a 0-trips/0km/₹0 card with nothing inside.
|
||||
next.zones.forEach((zone) => {
|
||||
if (!Array.isArray(zone.riders)) return;
|
||||
zone.riders = zone.riders.filter((r) => Array.isArray(r.orders) && r.orders.length > 0);
|
||||
});
|
||||
} else {
|
||||
next.zones = [
|
||||
{
|
||||
@@ -329,19 +349,17 @@ const Preview = () => {
|
||||
const autoRiders = stateData.autoRiders || [];
|
||||
const absentRidersPayload = stateData.absentRidersPayload || [];
|
||||
|
||||
const appId = useMemo(() => {
|
||||
if (stateData.appId) return stateData.appId;
|
||||
if (typeof window !== 'undefined') {
|
||||
const v = localStorage.getItem('applocationid');
|
||||
return v ? Number(v) : 0;
|
||||
}
|
||||
return 0;
|
||||
}, [stateData.appId]);
|
||||
|
||||
// fetchRidersList() takes no params — GET /admin/milers is tenant-scoped
|
||||
// server-side from the auth token, not by appId. Gating this query on
|
||||
// `!!appId` was wrong: orders.js (the entry point into this page) always
|
||||
// navigates here with a hardcoded appId of 0 (it has no zone picker at
|
||||
// all), which made this query permanently disabled unless a stale
|
||||
// `applocationid` happened to be cached in localStorage from a previous
|
||||
// Dispatch.js visit — the Change Rider dropdown showed "no riders" for
|
||||
// anyone who reached this page the normal way.
|
||||
const { data: ridersList } = useQuery({
|
||||
queryKey: ['ridersList', appId],
|
||||
queryKey: ['ridersList'],
|
||||
queryFn: fetchRidersList,
|
||||
enabled: !!appId,
|
||||
staleTime: 5 * 60 * 1000
|
||||
});
|
||||
|
||||
@@ -361,6 +379,24 @@ const Preview = () => {
|
||||
return [];
|
||||
}, [reconcileRiders, dispatchPreviewData]);
|
||||
|
||||
// Which orders carry a rider id the AI solver assigned that doesn't match
|
||||
// any real Doormile miler (userid/milerprofileid/name — same rule
|
||||
// finalCreatedeliveries uses server-side, see api.js). Bike-hypertuning
|
||||
// mode never sends the solver a rider pool at all, so it assigns from its
|
||||
// own internal roster — one that predates the Doormile migration and can
|
||||
// return ids with no live counterpart. Surfacing this here, before
|
||||
// commit, lets the operator fix it via the existing Change Rider flow
|
||||
// instead of the batch silently failing (or worse, notifying/assigning
|
||||
// the wrong person) after Assign is clicked.
|
||||
const unverifiedOrderIds = useMemo(() => {
|
||||
const lookup = buildMilerLookup(ridersList || []);
|
||||
const ids = new Set();
|
||||
finaldeliveryList.forEach((order) => {
|
||||
if (!resolveMilerForOrder(order, lookup)) ids.add(String(order.orderid));
|
||||
});
|
||||
return ids;
|
||||
}, [finaldeliveryList, ridersList]);
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = finaldeliveryList.map((item) => ({
|
||||
zone_name: item.zone_name,
|
||||
@@ -387,7 +423,17 @@ const Preview = () => {
|
||||
const notifyRiderMutation = useMutation({
|
||||
mutationFn: notifyRider,
|
||||
onSuccess: () => OpenToast('Notification sent Successfully', 'success', 2000),
|
||||
onError: (error) => OpenToast(error.message, 'error', 2000)
|
||||
onError: (error) => {
|
||||
// doormileAxios's response interceptor rewrites a failed request's
|
||||
// rejection to `error.response.data` directly (see utils/doormileAxios.js),
|
||||
// so `error` here IS the backend's JSON body, not an axios Error — its
|
||||
// `.message` key only exists if the backend happened to name the field
|
||||
// that. Logging the raw object is the only reliable way to see what a
|
||||
// 400 actually complained about (e.g. "no device token", "invalid
|
||||
// miler") instead of a blank/undefined toast.
|
||||
logger.error('notifyRiderMutation failed:', error);
|
||||
OpenToast(error?.message || error?.error || 'Failed to notify rider — see console for details', 'error', 2000);
|
||||
}
|
||||
});
|
||||
|
||||
const createDeliveryMutation = useMutation({
|
||||
@@ -415,26 +461,23 @@ const Preview = () => {
|
||||
// database, which neither the Orders "pending" list nor the Deliveries
|
||||
// "dispatched" filter ever read (both come from GET /admin/bookings).
|
||||
mutationFn: finalCreatedeliveries,
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
OpenToast('Delivery Created Successfully', 'success', 2000);
|
||||
setIsLoading(false);
|
||||
// stateData.rider (a single rider forwarded via navigate() from the
|
||||
// Orders page) is never actually populated in the real flow — that
|
||||
// page's navigate() call doesn't include a `rider` key at all — so
|
||||
// this was a permanent no-op and no rider ever got notified after
|
||||
// assignment. Notify every rider actually present in the committed
|
||||
// list instead. notifyRider expects a milerprofileid (not an FCM
|
||||
// token — the server looks the device up itself); rider_id/userid
|
||||
// here is the id this page already treats as canonical throughout
|
||||
// (see flattenRiders/moveOrderInPreviewData above) since it's the
|
||||
// only rider identifier the solver echoes back — unconfirmed whether
|
||||
// that's actually a milerprofileid by the time it reaches here.
|
||||
const notifiedRiderIds = new Set();
|
||||
finaldeliveryList.forEach((order) => {
|
||||
const riderId = order.rider_id ?? order.userid;
|
||||
if (riderId == null || notifiedRiderIds.has(String(riderId))) return;
|
||||
notifiedRiderIds.add(String(riderId));
|
||||
notifyRiderMutation.mutate(riderId);
|
||||
// assignment. Notify every rider finalCreatedeliveries actually
|
||||
// resolved and assigned (data.resolvedMilerProfileIds — real
|
||||
// milerprofileids from GET /admin/milers, deduped there). Previously
|
||||
// this notified using order.rider_id/userid directly, which is the
|
||||
// solver's own internal rider numbering — confirmed live to NOT be a
|
||||
// real Doormile userid or milerprofileid (see api.js's
|
||||
// finalCreatedeliveries) — so every notification went out with a
|
||||
// bogus id and likely silently failed server-side.
|
||||
(data?.resolvedMilerProfileIds || []).forEach((milerprofileid) => {
|
||||
notifyRiderMutation.mutate(milerprofileid);
|
||||
});
|
||||
navigate('/doormile/deliveries');
|
||||
},
|
||||
@@ -446,9 +489,13 @@ const Preview = () => {
|
||||
});
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: reconcileSteps,
|
||||
mutationFn: (payload) => {
|
||||
logger.debug('reconcile: sending payload', payload);
|
||||
return reconcileSteps(payload);
|
||||
},
|
||||
onMutate: () => setReconcileLoading(true),
|
||||
onSuccess: (data) => {
|
||||
logger.debug('reconcile: response', data);
|
||||
if (Array.isArray(data?.riders)) {
|
||||
// Merge: applyReconcileResponse replaces orders for riders present
|
||||
// in the response and leaves the rest of the cache untouched.
|
||||
@@ -459,14 +506,17 @@ const Preview = () => {
|
||||
setDirtyRiderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
data.riders.forEach((r) => next.delete(String(r.rider_id)));
|
||||
logger.debug('reconcile: dirtyRiderIds after clearing reconciled riders', [...next]);
|
||||
return next;
|
||||
});
|
||||
OpenToast('Steps reconciled — preview updated', 'success', 2000);
|
||||
} else {
|
||||
logger.error('reconcile: response had no riders array — dirtyRiderIds NOT cleared, Assign Orders stays disabled', data);
|
||||
OpenToast('Reconcile returned no rider data', 'warning', 3000);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('reconcile: request failed', error?.response?.status, error?.response?.data || error?.message);
|
||||
OpenToast(error.message || 'Reconcile failed', 'error', 4000);
|
||||
},
|
||||
onSettled: () => setReconcileLoading(false)
|
||||
@@ -513,11 +563,18 @@ const Preview = () => {
|
||||
OpenToast(`Reconcile ${dirtyRiderIds.size} edited rider(s) before assigning`, 'warning', 4000);
|
||||
return;
|
||||
}
|
||||
// Same reasoning as the button's disabled state — belt-and-suspenders
|
||||
// in case this ever fires from somewhere other than that button.
|
||||
if (unverifiedOrderIds.size > 0) {
|
||||
OpenToast(`${unverifiedOrderIds.size} order(s) have an unrecognized rider — use Change Rider to fix them first`, 'warning', 4000);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
createFinalDeliveryMutation.mutate({ deliveries: finaldeliveryList });
|
||||
};
|
||||
|
||||
const handleReconcile = () => {
|
||||
logger.debug('handleReconcile: dirtyRiderIds', [...dirtyRiderIds], 'reconcileRiders ids', reconcileRiders.map((r) => r.rider_id));
|
||||
if (!reconcileRiders.length) {
|
||||
OpenToast('No riders to reconcile', 'warning', 3000);
|
||||
return;
|
||||
@@ -529,6 +586,10 @@ const Preview = () => {
|
||||
dirtyRiderIds.has(String(r.rider_id))
|
||||
);
|
||||
if (!dirty.length) {
|
||||
logger.error(
|
||||
'handleReconcile: dirtyRiderIds is non-empty but none of them match a rider currently in reconcileRiders — nothing to send, Assign Orders stays disabled',
|
||||
[...dirtyRiderIds]
|
||||
);
|
||||
OpenToast('No edits to reconcile', 'info', 2500);
|
||||
return;
|
||||
}
|
||||
@@ -559,21 +620,42 @@ const Preview = () => {
|
||||
`${selectedNewRider.firstname || ''} ${selectedNewRider.lastname || ''}`.trim() ||
|
||||
`Rider ${newRiderId}`;
|
||||
|
||||
setDispatchPreviewData((prev) =>
|
||||
moveOrderInPreviewData(prev, {
|
||||
orderId: selectedOrder.orderid,
|
||||
oldRiderId: selectedOldRiderId,
|
||||
newRiderId,
|
||||
newRiderName
|
||||
})
|
||||
);
|
||||
const moved = moveOrderInPreviewData(dispatchPreviewData, {
|
||||
orderId: selectedOrder.orderid,
|
||||
oldRiderId: selectedOldRiderId,
|
||||
newRiderId,
|
||||
newRiderName
|
||||
});
|
||||
setDispatchPreviewData(moved);
|
||||
|
||||
// If that was the old rider's LAST order, moveOrderInPreviewData's own
|
||||
// ghost-rider cleanup already removed them from the tree entirely (see
|
||||
// that function). There is nothing left of theirs to reconcile — and
|
||||
// marking them dirty anyway is a real bug, not just unnecessary: the
|
||||
// reconcile response can only ever echo back riders that were actually
|
||||
// sent to it, handleReconcile only sends riders still present in
|
||||
// reconcileRiders (derived from this same tree), so a rider who no
|
||||
// longer exists here can NEVER be sent, NEVER come back in the
|
||||
// response, and therefore NEVER get cleared from dirtyRiderIds —
|
||||
// permanently stuck at size > 0, permanently disabling Assign Orders.
|
||||
// Confirmed via logging (reconcile: dirtyRiderIds after clearing
|
||||
// reconciled riders) that this is exactly what happens.
|
||||
const oldRiderStillExists = Array.isArray(moved?.zones)
|
||||
? moved.zones.some((z) => (z.riders || []).some((r) => String(r.rider_id ?? r.userid) === String(selectedOldRiderId)))
|
||||
: false;
|
||||
logger.debug('confirmChangeRider: old rider still has orders after move?', oldRiderStillExists, 'oldRiderId', selectedOldRiderId);
|
||||
|
||||
// Both riders' step sequences are now potentially stale: the old rider
|
||||
// lost a stop, the new rider gained one. Mark both as dirty so the next
|
||||
// Reconcile sends exactly these two.
|
||||
// Reconcile sends exactly these two — unless the old rider is gone.
|
||||
setDirtyRiderIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (selectedOldRiderId != null) next.add(String(selectedOldRiderId));
|
||||
if (selectedOldRiderId != null) {
|
||||
if (oldRiderStillExists) next.add(String(selectedOldRiderId));
|
||||
else next.delete(String(selectedOldRiderId));
|
||||
}
|
||||
if (newRiderId != null && Number.isFinite(newRiderId)) next.add(String(newRiderId));
|
||||
logger.debug('confirmChangeRider: dirtyRiderIds after change', [...next]);
|
||||
return next;
|
||||
});
|
||||
setHasReconciled(false);
|
||||
@@ -742,6 +824,7 @@ const Preview = () => {
|
||||
{r.orders.map((o, idx) => {
|
||||
const stepNum = o.step ?? idx + 1;
|
||||
const color = stepColor(Number(stepNum) - 1);
|
||||
const isUnverified = unverifiedOrderIds.has(String(o.orderid));
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${o.orderid}-${idx}`}
|
||||
@@ -749,17 +832,20 @@ const Preview = () => {
|
||||
<Box>
|
||||
<div>Order #{o.orderid}</div>
|
||||
<div>{o.deliveryaddress || o.deliverysuburb || ''}</div>
|
||||
<div style={{ marginTop: 4, opacity: 0.8 }}>Click to change rider</div>
|
||||
<div style={{ marginTop: 4, opacity: 0.8 }}>
|
||||
{isUnverified ? 'Rider not recognized — click to assign a real rider' : 'Click to change rider'}
|
||||
</div>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
onClick={() => openChangeRider(r, o)}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
bgcolor: color,
|
||||
bgcolor: isUnverified ? '#ef4444' : color,
|
||||
color: '#fff',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -767,13 +853,27 @@ const Preview = () => {
|
||||
fontWeight: 800,
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
boxShadow:
|
||||
'0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
|
||||
boxShadow: isUnverified
|
||||
? '0 0 0 2px #fff, 0 0 0 4px #ef4444, 0 1px 3px rgba(15,23,42,0.15)'
|
||||
: '0 0 0 2px rgba(255,255,255,0.6), 0 1px 3px rgba(15,23,42,0.15)',
|
||||
transition: 'transform 0.15s',
|
||||
'&:hover': { transform: 'scale(1.08)' }
|
||||
}}
|
||||
>
|
||||
{stepNum}
|
||||
{isUnverified && (
|
||||
<MdWarning
|
||||
size={14}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -5,
|
||||
right: -5,
|
||||
color: '#ef4444',
|
||||
background: '#fff',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -823,12 +923,20 @@ const Preview = () => {
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Tooltip title={dirtyRiderIds.size > 0 ? `Reconcile ${dirtyRiderIds.size} edited rider(s) first` : ''}>
|
||||
<Tooltip
|
||||
title={
|
||||
dirtyRiderIds.size > 0
|
||||
? `Reconcile ${dirtyRiderIds.size} edited rider(s) first`
|
||||
: unverifiedOrderIds.size > 0
|
||||
? `Fix ${unverifiedOrderIds.size} order(s) with an unrecognized rider first`
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<span style={isMobile ? { width: '100%' } : undefined}>
|
||||
<Button
|
||||
variant="contained"
|
||||
fullWidth={isMobile}
|
||||
disabled={dirtyRiderIds.size > 0}
|
||||
disabled={dirtyRiderIds.size > 0 || unverifiedOrderIds.size > 0}
|
||||
onClick={handleFinalCreateDelivery}
|
||||
>
|
||||
Assign Orders
|
||||
|
||||
@@ -85,3 +85,274 @@ export const getActiveOrder = (orders) => {
|
||||
});
|
||||
return sorted.find(isActiveDelivery) || null;
|
||||
};
|
||||
|
||||
// Haversine distance between two [lat, lng] points in kilometers. Good to
|
||||
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
|
||||
// polyline so the Compare delta panel can show "actual km" without depending
|
||||
// on the backend's actualkms field (which can be stale or missing). Also
|
||||
// reused by deliveries.js's Update Status dialog to compute a real Actual
|
||||
// KMs figure from GET /admin/consignments/:id/logs — see polylineLengthKm.
|
||||
export function haversineKm(a, b) {
|
||||
const R = 6371; // km
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const lat1 = toRad(a[0]);
|
||||
const lat2 = toRad(b[0]);
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLon = toRad(b[1] - a[1]);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export function polylineLengthKm(points) {
|
||||
if (!Array.isArray(points) || points.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
total += haversineKm(points[i - 1], points[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
|
||||
//
|
||||
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
|
||||
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
|
||||
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
|
||||
// velocity dynamics with random acceleration as process noise; measurement
|
||||
// model H = [1, 0] (we measure position only).
|
||||
//
|
||||
// Pipeline:
|
||||
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
|
||||
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
|
||||
// forward filter even with the in-loop Mahalanobis gate enabled.
|
||||
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
|
||||
// innovation exceeds the gate are not used to update; the prediction
|
||||
// is kept as the posterior. Stores prior + posterior moments at each
|
||||
// step so the backward pass can run.
|
||||
// 3. Backward RTS smoother — refines every step using ALL future
|
||||
// observations. Logs are fetched in one shot (not streamed) so we
|
||||
// can afford the second pass; the accuracy lift is biggest near the
|
||||
// start of the trail and through turns the forward pass under-corrects.
|
||||
//
|
||||
// Tuning (all in degrees² since pings are in lat/lng):
|
||||
// processNoise (q) — random-acceleration variance (deg²/s²). Default
|
||||
// tuned for urban two-wheelers (~1 m/s² accel).
|
||||
// Lower = smoother but slower to follow sharp turns.
|
||||
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
|
||||
// which matches consumer GPS in open urban areas.
|
||||
// Bump for dense canyons.
|
||||
// outlierGate — Mahalanobis² threshold for in-loop rejection.
|
||||
// 9.0 = 3σ (≈ 99.7% of inliers pass).
|
||||
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
|
||||
// 120 km/h covers any legal two-wheeler movement
|
||||
// plus margin; anything above is GPS error.
|
||||
export function kalmanSmoothGps(pings, options = {}) {
|
||||
if (!Array.isArray(pings) || pings.length === 0) return [];
|
||||
|
||||
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
|
||||
const cleanedPings = pings.filter(p =>
|
||||
Number.isFinite(p.lat) &&
|
||||
Number.isFinite(p.lng) &&
|
||||
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
|
||||
);
|
||||
|
||||
if (cleanedPings.length === 0) return [];
|
||||
if (cleanedPings.length === 1) {
|
||||
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
|
||||
}
|
||||
|
||||
const processNoise =
|
||||
options.processNoise != null ? options.processNoise : 1e-10;
|
||||
const measurementNoise =
|
||||
options.measurementNoise != null ? options.measurementNoise : 2e-9;
|
||||
const outlierGate =
|
||||
options.outlierGate != null ? options.outlierGate : 9.0;
|
||||
const maxSpeedKmh =
|
||||
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
|
||||
|
||||
const tsOf = (p) =>
|
||||
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
|
||||
|
||||
// 2. Scan forward to find the first valid starting anchor
|
||||
let startIdx = 0;
|
||||
while (startIdx < cleanedPings.length - 1) {
|
||||
const p0 = cleanedPings[startIdx];
|
||||
const p1 = cleanedPings[startIdx + 1];
|
||||
const ts0 = tsOf(p0);
|
||||
const ts1 = tsOf(p1) || ts0 + 1000;
|
||||
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
|
||||
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
|
||||
if (speedKmh <= maxSpeedKmh) {
|
||||
break;
|
||||
} else {
|
||||
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
|
||||
if (startIdx + 2 < cleanedPings.length) {
|
||||
const p2 = cleanedPings[startIdx + 2];
|
||||
const ts2 = tsOf(p2) || ts1 + 1000;
|
||||
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
|
||||
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
|
||||
const speedKmh12 = (km12 / dtSec12) * 3600;
|
||||
|
||||
if (speedKmh12 <= maxSpeedKmh) {
|
||||
startIdx = startIdx + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
startIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Teleport filter starting from the valid anchor
|
||||
const accepted = [cleanedPings[startIdx]];
|
||||
let lastTs = tsOf(cleanedPings[startIdx]);
|
||||
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
|
||||
const p = cleanedPings[i];
|
||||
const ts = tsOf(p) || lastTs + 1000;
|
||||
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
|
||||
const prev = accepted[accepted.length - 1];
|
||||
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
if (speedKmh > maxSpeedKmh) continue;
|
||||
accepted.push(p);
|
||||
lastTs = ts;
|
||||
}
|
||||
|
||||
if (accepted.length < 2) {
|
||||
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
|
||||
}
|
||||
|
||||
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
|
||||
// positions parallel to `accepted`.
|
||||
const smoothAxis = (axisKey) => {
|
||||
const N = accepted.length;
|
||||
// Per-step storage for the backward RTS pass.
|
||||
const xPost = new Array(N); // [pos, vel] posterior after update
|
||||
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
|
||||
const xPrior = new Array(N); // predicted mean before update
|
||||
const pPrior = new Array(N); // predicted cov before update
|
||||
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
|
||||
|
||||
// Initial state: position = first measurement, velocity from the first
|
||||
// two pings (better than 0 — keeps the start of the trail from lagging
|
||||
// behind the rider's actual motion). Initial position covariance = r
|
||||
// (we just measured it); initial velocity covariance is loose so it
|
||||
// can be refined quickly.
|
||||
const ts0 = tsOf(accepted[0]);
|
||||
const ts1 = tsOf(accepted[1]);
|
||||
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
|
||||
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
|
||||
xPost[0] = [accepted[0][axisKey], v0];
|
||||
pPost[0] = [measurementNoise, 0, 0, 1];
|
||||
xPrior[0] = xPost[0].slice();
|
||||
pPrior[0] = pPost[0].slice();
|
||||
dtArr[0] = 0;
|
||||
|
||||
let prevTs = ts0;
|
||||
for (let i = 1; i < N; i++) {
|
||||
const ts = tsOf(accepted[i]) || prevTs + 1000;
|
||||
const dt = Math.max(0.1, (ts - prevTs) / 1000);
|
||||
prevTs = ts;
|
||||
dtArr[i] = dt;
|
||||
|
||||
// ─── Predict ───
|
||||
// x' = F x where F = [[1, dt], [0, 1]]
|
||||
const [xPrev, vPrev] = xPost[i - 1];
|
||||
const xPredPos = xPrev + vPrev * dt;
|
||||
const xPredVel = vPrev;
|
||||
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
|
||||
const dt2 = dt * dt;
|
||||
const dt3 = dt2 * dt;
|
||||
const dt4 = dt3 * dt;
|
||||
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
|
||||
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np11 = pp11 + dt2 * processNoise;
|
||||
xPrior[i] = [xPredPos, xPredVel];
|
||||
pPrior[i] = [np00, np01, np10, np11];
|
||||
|
||||
// ─── Update (with Mahalanobis gating) ───
|
||||
// y = z − Hx' (innovation)
|
||||
// S = H P' H^T + R (innovation covariance)
|
||||
// Reject the measurement if mahal² = y²/S exceeds the gate. The
|
||||
// prediction then carries forward as the posterior — the trail stays
|
||||
// continuous instead of being yanked toward a bad fix.
|
||||
const z = accepted[i][axisKey];
|
||||
const y = z - xPredPos;
|
||||
const S = np00 + measurementNoise;
|
||||
const mahal2 = (y * y) / S;
|
||||
if (mahal2 > outlierGate) {
|
||||
xPost[i] = [xPredPos, xPredVel];
|
||||
pPost[i] = [np00, np01, np10, np11];
|
||||
continue;
|
||||
}
|
||||
// K = P' H^T / S
|
||||
const K0 = np00 / S;
|
||||
const K1 = np10 / S;
|
||||
// x = x' + K y
|
||||
const newPos = xPredPos + K0 * y;
|
||||
const newVel = xPredVel + K1 * y;
|
||||
// P = (I − K H) P'
|
||||
xPost[i] = [newPos, newVel];
|
||||
pPost[i] = [
|
||||
(1 - K0) * np00,
|
||||
(1 - K0) * np01,
|
||||
np10 - K1 * np00,
|
||||
np11 - K1 * np01
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Backward RTS smoother ─────────────────────────────────────────
|
||||
// x_smooth[N-1] = x_post[N-1]
|
||||
// For i = N-2 … 0:
|
||||
// C = P_post[i] · F^T · inv(P_prior[i+1])
|
||||
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
|
||||
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
|
||||
// P_post · F^T = [[p00 + dt·p01, p01],
|
||||
// [p10 + dt·p11, p11]]
|
||||
const xSmooth = new Array(N);
|
||||
xSmooth[N - 1] = xPost[N - 1].slice();
|
||||
for (let i = N - 2; i >= 0; i--) {
|
||||
const dt = dtArr[i + 1];
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i];
|
||||
const a = pp00 + dt * pp01;
|
||||
const b = pp01;
|
||||
const c = pp10 + dt * pp11;
|
||||
const d = pp11;
|
||||
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
|
||||
const [q00, q01, q10, q11] = pPrior[i + 1];
|
||||
const det = q00 * q11 - q01 * q10;
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
|
||||
xSmooth[i] = xPost[i].slice();
|
||||
continue;
|
||||
}
|
||||
const inv00 = q11 / det;
|
||||
const inv01 = -q01 / det;
|
||||
const inv10 = -q10 / det;
|
||||
const inv11 = q00 / det;
|
||||
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
|
||||
const c00 = a * inv00 + b * inv10;
|
||||
const c01 = a * inv01 + b * inv11;
|
||||
const c10 = c * inv00 + d * inv10;
|
||||
const c11 = c * inv01 + d * inv11;
|
||||
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
|
||||
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
|
||||
xSmooth[i] = [
|
||||
xPost[i][0] + c00 * dxPos + c01 * dxVel,
|
||||
xPost[i][1] + c10 * dxPos + c11 * dxVel
|
||||
];
|
||||
}
|
||||
|
||||
return xSmooth.map((s) => s[0]);
|
||||
};
|
||||
|
||||
const lats = smoothAxis('lat');
|
||||
const lngs = smoothAxis('lng');
|
||||
return accepted.map((p, i) => ({
|
||||
lat: lats[i],
|
||||
lng: lngs[i],
|
||||
logdate: p.logdate,
|
||||
_ts: p._ts
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user