updates on the kalman filter and the dispatch page by time filteration updates

This commit is contained in:
2026-06-02 18:07:35 +05:30
parent 77ad9c5eea
commit 6c51d1dcc0
6 changed files with 785 additions and 161 deletions

View File

@@ -314,6 +314,7 @@ export const fetchCountAPI = async (appId, userid, startdate, enddate, rowsPerPa
const response = await axios.get(url); const response = await axios.get(url);
const data = response.data.details; const data = response.data.details;
return { return {
total: data.total,
uncoveredLength: data.pending, uncoveredLength: data.pending,
assignedLength: data.accepted, assignedLength: data.accepted,
arrivedLength: data.arrived, arrivedLength: data.arrived,

View File

@@ -15,9 +15,6 @@ import {
MdWbSunny, MdWbSunny,
MdNightsStay, MdNightsStay,
MdCheck, MdCheck,
MdTrendingUp,
MdTrendingDown,
MdTrendingFlat,
MdStorefront, MdStorefront,
MdLocationOn, MdLocationOn,
MdDirectionsBike, MdDirectionsBike,
@@ -87,7 +84,6 @@ import {
changeRiderAPI, changeRiderAPI,
fetchCountAPI, fetchCountAPI,
fetchDeliveries, fetchDeliveries,
fetchPercentageAPI,
fetchRidersList, fetchRidersList,
notifyRider, notifyRider,
updateDeliveryAPI, updateDeliveryAPI,
@@ -559,13 +555,9 @@ const Deliveries = () => {
/* ============================================= || fetchDeliveries | ============================================= */ /* ============================================= || fetchDeliveries | ============================================= */
const { const {
data: deliveriesData,
isLoading: fetchDeliveriesIsLoading, isLoading: fetchDeliveriesIsLoading,
isError: fetchDeliveriesIsError, isError: fetchDeliveriesIsError,
error: fetchDeliveriesError, error: fetchDeliveriesError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch: fetchDeliveriesRefetch refetch: fetchDeliveriesRefetch
} = useInfiniteQuery({ } = useInfiniteQuery({
queryKey: [ queryKey: [
@@ -584,7 +576,6 @@ const Deliveries = () => {
queryFn: fetchDeliveries, queryFn: fetchDeliveries,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
}); });
const rows = deliveriesData?.pages.flatMap((page) => page.rows) || [];
// (filteredRows is defined below, after countSourceRows / countSourceLoading // (filteredRows is defined below, after countSourceRows / countSourceLoading
// are in scope — they're the single source of truth for both the table and // are in scope — they're the single source of truth for both the table and
@@ -778,21 +769,6 @@ const Deliveries = () => {
/* ============================================= || fetchPercentageAPI | ============================================= */ /* ============================================= || fetchPercentageAPI | ============================================= */
const {
data: percentageData,
isLoading: fetchPercentageIsLoading,
isError: fetchPercentageIsError,
error: fetchPercentageError
} = useQuery({
queryKey: ['fetchpercentageaPI', appId],
queryFn: () => fetchPercentageAPI(appId)
});
useEffect(() => {
if (percentageData) {
console.log('percentageData', percentageData);
}
}, [percentageData]);
/* ============================================= || fetchcount | ============================================= */ /* ============================================= || fetchcount | ============================================= */
const { const {
@@ -851,18 +827,16 @@ const Deliveries = () => {
}); });
const errorMessage = fetchDeliveriesIsError const errorMessage = fetchDeliveriesIsError
? `Error fetching percentages: ${fetchDeliveriesError?.message}` ? `Error fetching deliveries: ${fetchDeliveriesError?.message}`
: fetchPercentageIsError : fetchCountIsError
? `Error fetching percentages: ${fetchPercentageError?.message}` ? `Error fetching count summary: ${fetchCountError?.message}`
: fetchCountIsError : ridersListIsError
? `Error fetching percentages: ${fetchCountError?.message}` ? `Error fetching riders: ${ridersListError?.message}`
: ridersListIsError : fetchtenantsIsError
? `Error fetching percentages: ${ridersListError?.message}` ? `Error tenant list: ${fetchtenantsError?.message}`
: fetchtenantsIsError : fetchlocationsIsError
? `Error tenant list: ${fetchtenantsError?.message}` ? `Error location list: ${fetchlocationsError?.message}`
: fetchlocationsIsError : null;
? `Error location list: ${fetchlocationsError?.message}`
: null;
if (errorMessage) { if (errorMessage) {
console.log('errorMessage', errorMessage); console.log('errorMessage', errorMessage);
@@ -873,7 +847,6 @@ const Deliveries = () => {
return ( return (
<> <>
{(fetchCountIsLoading || {(fetchCountIsLoading ||
fetchPercentageIsLoading ||
countSourceIsLoading || countSourceIsLoading ||
fetchtenantsIsLoading || fetchtenantsIsLoading ||
fetchlocationsIsLoading || fetchlocationsIsLoading ||
@@ -891,7 +864,6 @@ const Deliveries = () => {
}} }}
open={ open={
fetchCountIsLoading || fetchCountIsLoading ||
fetchPercentageIsLoading ||
fetchDeliveriesIsLoading || fetchDeliveriesIsLoading ||
fetchtenantsIsLoading || fetchtenantsIsLoading ||
fetchlocationsIsLoading || fetchlocationsIsLoading ||
@@ -979,16 +951,14 @@ const Deliveries = () => {
{/* ============================================= || KPI Cards | ============================================= */} {/* ============================================= || KPI Cards | ============================================= */}
<Grid container spacing={{ xs: 1.25, sm: 1.5, md: 2 }} sx={{ mt: '1px' }}> <Grid container spacing={{ xs: 1.25, sm: 1.5, md: 2 }} sx={{ mt: '1px' }}>
{[ {[
{ ...KPI_META[0], value: percentageData?.uncoveredOrders, percentage: percentageData?.percentage1 }, { ...KPI_META[0], value: countData?.total, percentage: null },
{ ...KPI_META[1], value: percentageData?.assignedOrders, percentage: percentageData?.percentage2 }, { ...KPI_META[1], value: countData?.uncoveredLength, percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0 },
{ ...KPI_META[2], value: percentageData?.pickedOrders, percentage: percentageData?.percentage3 }, { ...KPI_META[2], value: countData?.coveredLength, percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0 },
{ ...KPI_META[3], value: percentageData?.coveredOrders, percentage: percentageData?.percentage4 } { ...KPI_META[3], value: countData?.cancelLength, percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0 }
].map((item) => { ].map((item) => {
const Icon = item.icon; const Icon = item.icon;
const pct = typeof item.percentage === 'number' ? item.percentage : Number(item.percentage); const pct = typeof item.percentage === 'number' ? item.percentage : Number(item.percentage);
const hasPct = !Number.isNaN(pct) && item.percentage !== undefined && item.percentage !== null; const hasPct = !Number.isNaN(pct) && item.percentage !== undefined && item.percentage !== null;
const Trend = !hasPct ? MdTrendingFlat : pct > 0 ? MdTrendingUp : pct < 0 ? MdTrendingDown : MdTrendingFlat;
const trendColor = !hasPct || pct === 0 ? DT.textMuted : pct > 0 ? '#10b981' : '#ef4444';
return ( return (
<Grid item key={item.key} xs={6} sm={6} md={3}> <Grid item key={item.key} xs={6} sm={6} md={3}>
<Paper <Paper
@@ -1037,7 +1007,7 @@ const Deliveries = () => {
> >
{item.label} {item.label}
</Typography> </Typography>
{fetchPercentageIsLoading ? ( {fetchCountIsLoading ? (
<Skeleton sx={{ width: 70, height: { xs: 28, md: 36 } }} animation="wave" /> <Skeleton sx={{ width: 70, height: { xs: 28, md: 36 } }} animation="wave" />
) : ( ) : (
<Typography <Typography
@@ -1061,20 +1031,19 @@ const Deliveries = () => {
px: 0.75, px: 0.75,
py: 0.25, py: 0.25,
borderRadius: 999, borderRadius: 999,
bgcolor: soft(trendColor), bgcolor: soft(item.color),
color: trendColor, color: item.color,
fontSize: { xs: 10, sm: 11 }, fontSize: { xs: 10, sm: 11 },
fontWeight: 800 fontWeight: 800
}} }}
> >
<Trend size={12} />
{Math.abs(pct)}% {Math.abs(pct)}%
</Box> </Box>
<Typography <Typography
variant="caption" variant="caption"
sx={{ color: DT.textMuted, display: { xs: 'none', sm: 'inline' } }} sx={{ color: DT.textMuted, display: { xs: 'none', sm: 'inline' } }}
> >
vs. yesterday of total
</Typography> </Typography>
</Stack> </Stack>
)} )}

View File

@@ -2161,11 +2161,18 @@
overflow: hidden; overflow: hidden;
} }
/* Trip-header — badge + stats + sort toggle in one row.
Layout: `flex-start` with a `gap` so badge and stats stay hugged
together on the left; `margin-left: auto` on the toggle pushes it
to the right. `flex-wrap` so the toggle drops to a second line on
narrow sidebars instead of squeezing the stats. */
.dispatch-container .trip-header { .dispatch-container .trip-header {
padding: 12px 16px; padding: 10px 14px;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
align-items: center; align-items: center;
gap: 10px;
flex-wrap: wrap;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: #fff; background: #fff;
} }
@@ -2176,6 +2183,8 @@
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
flex-shrink: 0;
letter-spacing: 0.02em;
} }
.dispatch-container .trip-stats { .dispatch-container .trip-stats {
@@ -2183,7 +2192,121 @@
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
gap: 12px; gap: 10px;
flex-shrink: 0;
}
/* Trip-card sort toggle — full-width segmented control.
Wraps onto its own row below the badge + stats (the trip-header is
`flex-wrap: wrap`, and `flex-basis: 100%` here forces the break).
Each pill = 50% of the row → big symmetric tap targets that read as
a clear "tab bar" instead of a discreet right-side switch. Active
state is a clean white "thumb" with the mode's semantic color
applied to the icon (indigo for Planned, emerald for By time), so
you can tell the mode at a glance from across the screen without
reading the labels. */
.dispatch-container .trip-sort-toggle {
display: flex;
align-items: stretch;
width: 100%;
flex-basis: 100%;
margin-left: 0;
padding: 3px;
background: #f1f5f9;
border-radius: 10px;
position: relative;
isolation: isolate;
}
.dispatch-container .trip-sort-pill {
flex: 1 1 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px;
min-height: 28px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1;
color: #64748b;
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
transition: color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
-webkit-appearance: none;
appearance: none;
}
.dispatch-container .trip-sort-pill svg {
width: 14px;
height: 14px;
flex: 0 0 auto;
display: block;
color: #94a3b8;
transition: color 0.18s ease, transform 0.18s ease;
}
.dispatch-container .trip-sort-pill:hover:not(.is-active) {
color: #0f172a;
}
.dispatch-container .trip-sort-pill:hover:not(.is-active) svg {
color: #475569;
}
.dispatch-container .trip-sort-pill:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.4);
}
.dispatch-container .trip-sort-pill.is-active {
color: #0f172a;
background: #ffffff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(15, 23, 42, 0.04),
0 2px 6px rgba(15, 23, 42, 0.06);
}
/* Mode-semantic accent on the active pill's icon — picks up the same
indigo / emerald used on the Compare control + the time-rank badge
below, so the page reads as a coherent system. */
.dispatch-container .trip-sort-toggle[data-mode='planned']
.trip-sort-pill.is-active svg {
color: #6366f1;
}
.dispatch-container .trip-sort-toggle[data-mode='time']
.trip-sort-pill.is-active svg {
color: #10b981;
}
/* Time-mode visual cues on individual order cards. Numbered badge
switches from "planned step N" to "delivered #N inside this trip"
when sort mode is `time`. Emerald (delivered family) signals
"ranked by completion," distinguishing it from the planned step. */
.dispatch-container .zone-order-num.is-time-rank {
background: #ecfdf5;
color: #047857;
box-shadow: inset 0 0 0 1.5px #6ee7b7;
}
/* Cards whose order isn't yet delivered, while time-sort is active.
These are pushed to the end of the trip list (MAX_SAFE_INTEGER sort
key) — the muted styling tells the operator "not yet delivered"
at a glance without reading every status pill. */
.dispatch-container .zone-order-card.is-pending-time {
opacity: 0.72;
}
.dispatch-container .zone-order-card.is-pending-time .zone-order-num.is-time-rank {
background: #f8fafc;
color: #64748b;
box-shadow: inset 0 0 0 1.5px #cbd5e1;
} }
.dispatch-container .step-wrap { .dispatch-container .step-wrap {
@@ -5523,6 +5646,37 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Delivery customer line — sits directly under the rider name in
the popup header. Visual rank: bigger/bolder than .pu-delivery-id
(which is just a backend reference), smaller/lighter than .pu-rider
(the primary identity). One-line, ellipsis-truncated for long
customer names, with the full name in the title tooltip. */
.dispatch-container .dispatch-popup .pu-customer {
margin-top: 4px;
padding: 0;
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
display: flex;
align-items: center;
gap: 6px;
letter-spacing: -0.005em;
max-width: 100%;
}
.dispatch-container .dispatch-popup .pu-customer svg {
font-size: 14px;
opacity: 0.85;
flex: 0 0 auto;
}
.dispatch-container .dispatch-popup .pu-customer span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.dispatch-container .dispatch-popup .pu-delivery-id { .dispatch-container .dispatch-popup .pu-delivery-id {
margin-top: 6px; margin-top: 6px;
font-size: 11px; font-size: 11px;

View File

@@ -271,98 +271,244 @@ function polylineLengthKm(points) {
return total; return total;
} }
// ─── Kalman filter for GPS pings ───────────────────────────────────────── // ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
// //
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a // Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings. Per-axis state: [position, // chronologically sorted list of GPS pings, followed by a Rauch-Tung-
// velocity]. Constant-velocity dynamics with random acceleration as process // Striebel backward pass. Per-axis state: [position, velocity]. Constant-
// noise; measurement model H = [1, 0] (we measure position only). // velocity dynamics with random acceleration as process noise; measurement
// model H = [1, 0] (we measure position only).
// //
// Why a Kalman filter here: // Pipeline:
// Raw /getdeliverylogs pings contain jitter (multi-path in dense urban // 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
// areas), brief stationary noise (rider parked at the drop, GPS still // e.g. cold-start fix, GPS multipath). These would otherwise tug the
// wandering), and occasional outliers (cold-start fix). The Kalman pass // forward filter even with the in-loop Mahalanobis gate enabled.
// fuses each ping with the predicted trajectory from prior pings, weighted // 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
// by their relative uncertainty. The output is a smooth polyline that // innovation exceeds the gate are not used to update; the prediction
// tracks the rider's real path without the zig-zags and bunched-up dots // is kept as the posterior. Stores prior + posterior moments at each
// near drops, and it costs O(N) — runs once per delivery on fetch. // 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: // Tuning (all in degrees² since pings are in lat/lng):
// processNoise (q) — random-acceleration variance (deg²/s²). Lower = a // processNoise (q) — random-acceleration variance (deg²/s²). Default
// smoother result but slower to follow sharp turns. // tuned for urban two-wheelers (~1 m/s² accel).
// measurementNoise (r) — GPS-fix variance (deg²). Higher = trust pings // Lower = smoother but slower to follow sharp turns.
// less, lean on the predicted state more. // measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
// The defaults below correspond loosely to ~5m GPS accuracy and gentle // which matches consumer GPS in open urban areas.
// urban acceleration. Bump q if smoothing eats genuine turns; bump r if // Bump for dense canyons.
// the line still wiggles between pings. // 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 = {}) { function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return []; if (!Array.isArray(pings) || pings.length === 0) return [];
if (pings.length === 1) {
return [{ lat: pings[0].lat, lng: pings[0].lng, logdate: pings[0].logdate }]; // 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 = const processNoise =
options.processNoise != null ? options.processNoise : 1e-9; options.processNoise != null ? options.processNoise : 1e-10;
const measurementNoise = const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 1e-7; 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) => const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0); p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// Run a 1D Kalman over one axis (lat or lng). State: [pos, vel]; cov: 2x2. // 2. Scan forward to find the first valid starting anchor
const smoothAxis = (axisKey) => { let startIdx = 0;
let x = pings[0][axisKey]; // position estimate while (startIdx < cleanedPings.length - 1) {
let v = 0; // velocity estimate const p0 = cleanedPings[startIdx];
// Initial covariance — large enough that the first few measurements const p1 = cleanedPings[startIdx + 1];
// dominate over the initial state. const ts0 = tsOf(p0);
let p00 = 1, p01 = 0, p10 = 0, p11 = 1; const ts1 = tsOf(p1) || ts0 + 1000;
const out = [x]; const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
let prevTs = tsOf(pings[0]); const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
const speedKmh = (km / dtSec) * 3600;
for (let i = 1; i < pings.length; i++) { if (speedKmh <= maxSpeedKmh) {
const ts = tsOf(pings[i]) || prevTs + 1000; 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); const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts; prevTs = ts;
dtArr[i] = dt;
// ─── Predict ─── // ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]] // x' = F x where F = [[1, dt], [0, 1]]
const xPred = x + v * dt; const [xPrev, vPrev] = xPost[i - 1];
const vPred = v; const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]] // 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 dt2 = dt * dt;
const dt3 = dt2 * dt; const dt3 = dt2 * dt;
const dt4 = dt3 * dt; const dt4 = dt3 * dt;
const np00 = p00 + dt * (p01 + p10) + dt2 * p11 + (dt4 / 4) * processNoise; const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
const np01 = p01 + dt * p11 + (dt3 / 2) * processNoise; const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
const np10 = p10 + dt * p11 + (dt3 / 2) * processNoise; const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
const np11 = p11 + dt2 * processNoise; const np11 = pp11 + dt2 * processNoise;
xPrior[i] = [xPredPos, xPredVel];
pPrior[i] = [np00, np01, np10, np11];
// ─── Update ─── // ─── Update (with Mahalanobis gating) ───
// y = z Hx' (innovation, measurement vs prediction) // y = z Hx' (innovation)
const z = pings[i][axisKey];
const y = z - xPred;
// S = H P' H^T + R (innovation covariance) // 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 S = np00 + measurementNoise;
// K = P' H^T / S (Kalman gain) 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 K0 = np00 / S;
const K1 = np10 / S; const K1 = np10 / S;
// x = x' + K y // x = x' + K y
x = xPred + K0 * y; const newPos = xPredPos + K0 * y;
v = vPred + K1 * y; const newVel = xPredVel + K1 * y;
// P = (I K H) P' // P = (I K H) P'
p00 = (1 - K0) * np00; xPost[i] = [newPos, newVel];
p01 = (1 - K0) * np01; pPost[i] = [
p10 = np10 - K1 * np00; (1 - K0) * np00,
p11 = np11 - K1 * np01; (1 - K0) * np01,
np10 - K1 * np00,
out.push(x); np11 - K1 * np01
];
} }
return out;
// ─── 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 lats = smoothAxis('lat');
const lngs = smoothAxis('lng'); const lngs = smoothAxis('lng');
return pings.map((p, i) => ({ return accepted.map((p, i) => ({
lat: lats[i], lat: lats[i],
lng: lngs[i], lng: lngs[i],
logdate: p.logdate, logdate: p.logdate,
@@ -702,6 +848,17 @@ const Dispatch = ({
const [focusedZone, setFocusedZone] = useState(null); const [focusedZone, setFocusedZone] = useState(null);
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map. // Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
const [focusedStop, setFocusedStop] = useState(null); const [focusedStop, setFocusedStop] = useState(null);
// Sort mode for the focused-rider trip cards in the left sidebar.
// 'planned' (default) — orders displayed in dispatched sequence (step asc).
// 'time' — orders displayed in actual completion sequence
// (deliverytime asc, expecteddeliverytime fallback,
// step tiebreaker). Mirrors how the Compare timeline
// numbers its sequenceStep so an operator can switch
// between sidebar + Compare without re-mapping
// which delivery is "1st", "2nd", etc.
// Global per the focused rider — every trip in the rider detail shares the
// mode so the toggle in any trip-header reflects (and controls) all trips.
const [tripSortMode, setTripSortMode] = useState('planned');
// Holds leaflet marker instances keyed by orderid so we can imperatively open // Holds leaflet marker instances keyed by orderid so we can imperatively open
// their popups when the user clicks a step in the focused-rider sidebar. // their popups when the user clicks a step in the focused-rider sidebar.
const orderMarkerRefs = useRef({}); const orderMarkerRefs = useRef({});
@@ -2271,6 +2428,12 @@ const Dispatch = ({
<div className="pu-rider"> <div className="pu-rider">
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span> <MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
</div> </div>
{(o.deliverycustomer || o.customername) && (
<div className="pu-customer" title={o.deliverycustomer || o.customername}>
<MdMarkunreadMailbox />
<span>{o.deliverycustomer || o.customername}</span>
</div>
)}
{o.deliveryid != null && ( {o.deliveryid != null && (
<div className="pu-delivery-id">Delivery #{o.deliveryid}</div> <div className="pu-delivery-id">Delivery #{o.deliveryid}</div>
)} )}
@@ -3566,10 +3729,40 @@ const Dispatch = ({
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s); return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
}); });
const activeOrderId = activeOrder ? activeOrder.orderid : null; const activeOrderId = activeOrder ? activeOrder.orderid : null;
// Completion-time epoch for a delivery. Mirrors the Compare
// timeline's `tsOf` (Dispatch.js — riderActualTracks builder)
// so the sequence number on the sidebar in 'time' mode lines
// up with the Compare timeline's sequenceStep.
const completionTs = (o) => {
const t = o.deliverytime || o.expecteddeliverytime;
if (!t) return Number.MAX_SAFE_INTEGER;
const d = dayjs(t);
return d.isValid() ? d.valueOf() : Number.MAX_SAFE_INTEGER;
};
const isTimeMode = tripSortMode === 'time';
let prevKitchenKey = null; let prevKitchenKey = null;
return Object.entries(trips) return Object.entries(trips)
.sort(([a], [b]) => Number(a) - Number(b)) .sort(([a], [b]) => Number(a) - Number(b))
.map(([tNum, tOrders]) => ( .map(([tNum, tOrders]) => {
// 'planned' keeps the trip's incoming step order (the
// caller already sorted by trip_number, step). 'time'
// re-sorts inside each trip by completion time; step
// is the tiebreaker so two deliveries logged in the
// same minute still render in dispatched order.
// Kitchen-transition badges are computed off the displayed
// order so the "Switch to X" rows stay accurate after
// reordering — without this they'd reference the
// original sequence and confuse the operator.
const displayOrders = isTimeMode
? [...tOrders].sort((a, b) => {
const diff = completionTs(a) - completionTs(b);
if (diff !== 0) return diff;
return (a.step || 0) - (b.step || 0);
})
: tOrders;
return (
<div key={tNum} className="trip-block"> <div key={tNum} className="trip-block">
<div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}> <div className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
<span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span> <span className="th-badge" style={{ background: focusedRider.color }}>Trip {tNum}</span>
@@ -3577,9 +3770,44 @@ const Dispatch = ({
<span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span> <span><Ico><MdLocationOn /></Ico>{tOrders.length} stops</span>
<span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span> <span><Ico><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</span>
</span> </span>
{/* iOS-style segmented control. Track sits in a soft
inset bg; the active item is a clean white "thumb"
with a subtle shadow. We deliberately don't fill
the active pill with the rider's color — the
`Trip N` badge to the left already carries that
identity, and doubling the accent makes the row
read as competing pills instead of a controlled
hierarchy. The CSS owns all interactive states. */}
<div
className="trip-sort-toggle"
role="group"
aria-label="Sort stops by"
data-mode={isTimeMode ? 'time' : 'planned'}
>
<button
type="button"
className={`trip-sort-pill ${!isTimeMode ? 'is-active' : ''}`}
aria-pressed={!isTimeMode}
onClick={() => setTripSortMode('planned')}
title="Sort stops by planned step (dispatched order)"
>
<MdFormatListBulleted aria-hidden="true" />
<span>Planned</span>
</button>
<button
type="button"
className={`trip-sort-pill ${isTimeMode ? 'is-active' : ''}`}
aria-pressed={isTimeMode}
onClick={() => setTripSortMode('time')}
title="Sort stops by completion time (which delivery was done first)"
>
<MdAccessTime aria-hidden="true" />
<span>By time</span>
</button>
</div>
</div> </div>
<div className="zone-order-grid"> <div className="zone-order-grid">
{tOrders.map((o, idx) => { {displayOrders.map((o, idx) => {
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim(); const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey; const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
prevKitchenKey = kitchenKey; prevKitchenKey = kitchenKey;
@@ -3592,6 +3820,20 @@ const Dispatch = ({
const profit = parseFloat(o.profit || 0); const profit = parseFloat(o.profit || 0);
const isLoss = profit < 0; const isLoss = profit < 0;
const estMeters = calculateEstMeters(focusedRider.id, o); const estMeters = calculateEstMeters(focusedRider.id, o);
// Badge number:
// planned mode → planned step (o.step), so a
// reordered list still surfaces the dispatched
// step number on each card.
// time mode → completion sequence inside the
// trip (1 = delivered first). Undelivered rows
// sort to the end so their idx+1 is the highest.
const displayNum = isTimeMode ? idx + 1 : (o.step || idx + 1);
// "Not yet delivered" indicator for time mode — we
// pushed these rows to the end via MAX_SAFE_INTEGER
// sort key, but a visual cue makes that obvious
// without forcing the operator to read the status pill.
const isUndeliveredInTimeMode =
isTimeMode && !o.deliverytime;
return ( return (
<React.Fragment key={o.orderid}> <React.Fragment key={o.orderid}>
@@ -3599,7 +3841,7 @@ const Dispatch = ({
<div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div> <div className="kitchen-transition"><span className="kt-ico"><MdSwapHoriz /></span> Switch to <strong>{o.pickupcustomer}</strong></div>
)} )}
<div <div
className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''}`} className={`zone-order-card ${canFocus ? 'clickable' : ''} ${isStopActive ? 'active' : ''} ${isGoingOn ? 'going-on' : ''} ${isUndeliveredInTimeMode ? 'is-pending-time' : ''}`}
role={canFocus ? 'button' : undefined} role={canFocus ? 'button' : undefined}
tabIndex={canFocus ? 0 : undefined} tabIndex={canFocus ? 0 : undefined}
onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined} onClick={canFocus ? () => setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
@@ -3612,7 +3854,12 @@ const Dispatch = ({
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined} title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
> >
<div className="zone-order-card-head"> <div className="zone-order-card-head">
<div className="zone-order-num">{o.step || idx + 1}</div> <div
className={`zone-order-num ${isTimeMode ? 'is-time-rank' : ''}`}
title={isTimeMode ? `Delivered #${idx + 1} in this trip` : `Planned step ${o.step || idx + 1}`}
>
{displayNum}
</div>
<div className="zone-order-id-block"> <div className="zone-order-id-block">
<div className="zone-order-id">Order #{o.orderid}</div> <div className="zone-order-id">Order #{o.orderid}</div>
</div> </div>
@@ -3716,7 +3963,8 @@ const Dispatch = ({
})} })}
</div> </div>
</div> </div>
)); );
});
})()} })()}
</> </>
) : ( ) : (
@@ -4562,33 +4810,34 @@ const Dispatch = ({
eventHandlers={ eventHandlers={
orderForTrack orderForTrack
? { ? {
// Match the planned-route marker UX: hover surfaces // Click-only: hovering the numbered pin does
// the rich order card in the centered overlay. The // nothing. Earlier the modal opened on mouseover,
// ~200ms grace timer on mouseout lets the cursor // which made the rich order card flash open while
// travel onto the overlay without flicker. Pinning // panning the map and blocked the operator from
// is implicit while focusedCompareStep === this // reading other steps in the trail. Click toggles
// step, so the card stays put while the user is // the modal — same `pinnedPopupsRef` pattern the
// inspecting this delivery. // planned-route marker uses, so the modal stays
mouseover: () => { // pinned (doesn't auto-close on mouse leave) until
// the user clicks the pin again, the × button, or
// the map background.
click: (e) => {
if (e.originalEvent) e.originalEvent.stopPropagation();
setFocusedCompareStep((prev) =>
prev === t.sequenceStep ? null : t.sequenceStep
);
if (popupHoverTimerRef.current) { if (popupHoverTimerRef.current) {
clearTimeout(popupHoverTimerRef.current); clearTimeout(popupHoverTimerRef.current);
popupHoverTimerRef.current = null; popupHoverTimerRef.current = null;
} }
setCenterPopupOrder(orderForTrack); const id = String(orderForTrack.orderid);
}, if (pinnedPopupsRef.current.has(id)) {
mouseout: () => { pinnedPopupsRef.current.delete(id);
if (focusedCompareStep === t.sequenceStep) return; setCenterPopupOrder(null);
if (popupHoverTimerRef.current) { } else {
clearTimeout(popupHoverTimerRef.current); pinnedPopupsRef.current.add(id);
setCenterPopupOrder(orderForTrack);
} }
popupHoverTimerRef.current = setTimeout(() => { }
setCenterPopupOrder((cur) =>
cur && String(cur.orderid) === String(orderForTrack.orderid) ? null : cur
);
popupHoverTimerRef.current = null;
}, 200);
},
click: handleEndMarkerClick
} }
: { click: handleEndMarkerClick } : { click: handleEndMarkerClick }
} }

View File

@@ -60,26 +60,57 @@ const MapWithRoute = ({ coordinates, additionalProps, order, setMapOpen }) => {
return; return;
} }
const start = coordinates[0]; const subsample = (arr, max) => {
const end = coordinates[coordinates.length - 1]; if (arr.length <= max) return arr;
const step = Math.ceil(arr.length / max);
const url = `https://router.project-osrm.org/route/v1/driving/${start.lng},${start.lat};${end.lng},${end.lat}?overview=full&geometries=geojson`; const out = arr.filter((_, i) => i % step === 0);
const last = arr[arr.length - 1];
if (out[out.length - 1] !== last) out.push(last);
return out;
};
// Attempt 1 — map-matching (best fidelity for dense traces).
try { try {
const res = await fetch(url); const ptsM = subsample(coordinates, 90);
const data = await res.json(); const coordsM = ptsM.map((p) => `${p.lng},${p.lat}`).join(';');
const matchUrl = `https://router.project-osrm.org/match/v1/driving/${coordsM}?overview=full&geometries=geojson&gaps=ignore&tidy=true`;
const resM = await fetch(matchUrl);
const jsonM = await resM.json();
if (jsonM.matchings && jsonM.matchings.length > 0) {
const poly = jsonM.matchings.flatMap((m) =>
(m.geometry?.coordinates || []).map(([lng, lat]) => ({ lat, lng }))
);
if (poly.length >= 2) {
setRoutePoints(poly);
setLoading(false);
return;
}
}
} catch (e) {
console.warn('OSRM Match error, trying route fallback:', e);
}
if (data.routes?.length) { // Attempt 2 — waypoint routing through a coarser subsample.
const points = data.routes[0].geometry.coordinates.map(([lng, lat]) => ({ try {
lat, const ptsR = subsample(coordinates, 25);
lng const coordsR = ptsR.map((p) => `${p.lng},${p.lat}`).join(';');
})); const routeUrl = `https://router.project-osrm.org/route/v1/driving/${coordsR}?overview=full&geometries=geojson`;
setRoutePoints(points);
const resR = await fetch(routeUrl);
const jsonR = await resR.json();
if (jsonR.routes && jsonR.routes[0]) {
const poly = jsonR.routes[0].geometry.coordinates.map(([lng, lat]) => ({ lat, lng }));
setRoutePoints(poly);
} else {
// Fallback to drawing direct lines between coordinates
setRoutePoints(coordinates);
} }
} catch (err) { } catch (err) {
console.error('OSRM Error:', err); console.error('OSRM Route fallback error:', err);
setRoutePoints(coordinates);
} finally { } finally {
setLoading(false); // ALWAYS STOP LOADING setLoading(false);
} }
}; };

View File

@@ -60,7 +60,7 @@ import { FaCircleCheck } from 'react-icons/fa6';
import MapWithRoute from './mapWithRoute'; import MapWithRoute from './mapWithRoute';
import CircularLoader from 'components/CircularLoader'; import CircularLoader from 'components/CircularLoader';
import { fetchDeliveries, getriderbydelivery, gettenantlocations, getTenants } from 'pages/api/api'; import { fetchDeliveries, fetchRidersList, gettenantlocations, getTenants } from 'pages/api/api';
import { CSVExport } from 'components/third-party/ReactTable'; import { CSVExport } from 'components/third-party/ReactTable';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
@@ -218,6 +218,205 @@ const StampCell = ({ value, formatDate, formatTime, success }) => {
// ==============================|| Orders Details ||============================== // // ==============================|| Orders Details ||============================== //
// Haversine distance between two [lat, lng] points in kilometers.
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 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;
const xPost = new Array(N);
const pPost = new Array(N);
const xPrior = new Array(N);
const pPrior = new Array(N);
const dtArr = new Array(N);
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
const [xPrev, vPrev] = xPost[i - 1];
const xPredPos = xPrev + vPrev * dt;
const xPredVel = vPrev;
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
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;
}
const K0 = np00 / S;
const K1 = np10 / S;
const newPos = xPredPos + K0 * y;
const newVel = xPredVel + K1 * y;
xPost[i] = [newPos, newVel];
pPost[i] = [
(1 - K0) * np00,
(1 - K0) * np01,
np10 - K1 * np00,
np11 - K1 * np01
];
}
// RTS backward smoother
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;
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;
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
}));
}
export default function OrdersDetails() { export default function OrdersDetails() {
const loadMoreRef = useRef(); const loadMoreRef = useRef();
const containerRef = useRef(); const containerRef = useRef();
@@ -319,14 +518,35 @@ export default function OrdersDetails() {
try { try {
const res = await axios.get(`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${id}`); const res = await axios.get(`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${id}`);
const datas = res.data.details; const datas = res.data.details;
if (datas.length != 0) { if (Array.isArray(datas) && datas.length !== 0) {
setRiderStart(datas[0].logdate); // Sort chronologically by logdate
setRiderEnd(datas[datas.length - 1].logdate); const sorted = datas
const coData = datas.map((data) => ({ lat: data.latitude, lng: data.longitude })); .map((r) => {
setRiderCoordinates(coData); const ts = r?.logdate ? dayjs(r.logdate) : null;
calculateTotalDistance(coData); return {
setMapOpen(true); lat: parseFloat(r?.latitude ?? r?.lat),
} else if (datas == null || !datas) { lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon),
logdate: r?.logdate,
_ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER
};
})
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.sort((a, b) => a._ts - b._ts);
if (sorted.length !== 0) {
setRiderStart(sorted[0].logdate);
setRiderEnd(sorted[sorted.length - 1].logdate);
// Apply Kalman filter
const smoothed = kalmanSmoothGps(sorted);
const coData = smoothed.map((data) => ({ lat: data.lat, lng: data.lng }));
setRiderCoordinates(coData);
calculateTotalDistance(coData);
setMapOpen(true);
} else {
opentoast('No Valid Logs Found', 'error', 2000);
}
} else {
opentoast('No Logs Found ', 'error', 2000); opentoast('No Logs Found ', 'error', 2000);
} }
} catch (error) { } catch (error) {
@@ -416,9 +636,9 @@ export default function OrdersDetails() {
isError: getriderbydeliveryIsError, isError: getriderbydeliveryIsError,
error: getriderbydeliveryError error: getriderbydeliveryError
} = useQuery({ } = useQuery({
queryKey: ['getriderbydelivery', startdate, enddate, appId, tenantid, locationid], queryKey: ['fetchRidersList', appId],
queryFn: () => getriderbydelivery(startdate, enddate, appId, tenantid, locationid), queryFn: fetchRidersList,
enabled: appId != 0 enabled: appId !== 0
}); });
const { const {