updates on the kalman filter and the dispatch page by time filteration updates
This commit is contained in:
@@ -314,6 +314,7 @@ export const fetchCountAPI = async (appId, userid, startdate, enddate, rowsPerPa
|
||||
const response = await axios.get(url);
|
||||
const data = response.data.details;
|
||||
return {
|
||||
total: data.total,
|
||||
uncoveredLength: data.pending,
|
||||
assignedLength: data.accepted,
|
||||
arrivedLength: data.arrived,
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
MdWbSunny,
|
||||
MdNightsStay,
|
||||
MdCheck,
|
||||
MdTrendingUp,
|
||||
MdTrendingDown,
|
||||
MdTrendingFlat,
|
||||
MdStorefront,
|
||||
MdLocationOn,
|
||||
MdDirectionsBike,
|
||||
@@ -87,7 +84,6 @@ import {
|
||||
changeRiderAPI,
|
||||
fetchCountAPI,
|
||||
fetchDeliveries,
|
||||
fetchPercentageAPI,
|
||||
fetchRidersList,
|
||||
notifyRider,
|
||||
updateDeliveryAPI,
|
||||
@@ -559,13 +555,9 @@ const Deliveries = () => {
|
||||
/* ============================================= || fetchDeliveries | ============================================= */
|
||||
|
||||
const {
|
||||
data: deliveriesData,
|
||||
isLoading: fetchDeliveriesIsLoading,
|
||||
isError: fetchDeliveriesIsError,
|
||||
error: fetchDeliveriesError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
refetch: fetchDeliveriesRefetch
|
||||
} = useInfiniteQuery({
|
||||
queryKey: [
|
||||
@@ -584,7 +576,6 @@ const Deliveries = () => {
|
||||
queryFn: fetchDeliveries,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined
|
||||
});
|
||||
const rows = deliveriesData?.pages.flatMap((page) => page.rows) || [];
|
||||
|
||||
// (filteredRows is defined below, after countSourceRows / countSourceLoading
|
||||
// are in scope — they're the single source of truth for both the table and
|
||||
@@ -778,21 +769,6 @@ const Deliveries = () => {
|
||||
|
||||
/* ============================================= || 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 | ============================================= */
|
||||
|
||||
const {
|
||||
@@ -851,13 +827,11 @@ const Deliveries = () => {
|
||||
});
|
||||
|
||||
const errorMessage = fetchDeliveriesIsError
|
||||
? `Error fetching percentages: ${fetchDeliveriesError?.message}`
|
||||
: fetchPercentageIsError
|
||||
? `Error fetching percentages: ${fetchPercentageError?.message}`
|
||||
? `Error fetching deliveries: ${fetchDeliveriesError?.message}`
|
||||
: fetchCountIsError
|
||||
? `Error fetching percentages: ${fetchCountError?.message}`
|
||||
? `Error fetching count summary: ${fetchCountError?.message}`
|
||||
: ridersListIsError
|
||||
? `Error fetching percentages: ${ridersListError?.message}`
|
||||
? `Error fetching riders: ${ridersListError?.message}`
|
||||
: fetchtenantsIsError
|
||||
? `Error tenant list: ${fetchtenantsError?.message}`
|
||||
: fetchlocationsIsError
|
||||
@@ -873,7 +847,6 @@ const Deliveries = () => {
|
||||
return (
|
||||
<>
|
||||
{(fetchCountIsLoading ||
|
||||
fetchPercentageIsLoading ||
|
||||
countSourceIsLoading ||
|
||||
fetchtenantsIsLoading ||
|
||||
fetchlocationsIsLoading ||
|
||||
@@ -891,7 +864,6 @@ const Deliveries = () => {
|
||||
}}
|
||||
open={
|
||||
fetchCountIsLoading ||
|
||||
fetchPercentageIsLoading ||
|
||||
fetchDeliveriesIsLoading ||
|
||||
fetchtenantsIsLoading ||
|
||||
fetchlocationsIsLoading ||
|
||||
@@ -979,16 +951,14 @@ const Deliveries = () => {
|
||||
{/* ============================================= || KPI Cards | ============================================= */}
|
||||
<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[1], value: percentageData?.assignedOrders, percentage: percentageData?.percentage2 },
|
||||
{ ...KPI_META[2], value: percentageData?.pickedOrders, percentage: percentageData?.percentage3 },
|
||||
{ ...KPI_META[3], value: percentageData?.coveredOrders, percentage: percentageData?.percentage4 }
|
||||
{ ...KPI_META[0], value: countData?.total, percentage: null },
|
||||
{ ...KPI_META[1], value: countData?.uncoveredLength, percentage: countData?.total ? Math.round((countData.uncoveredLength / countData.total) * 100) : 0 },
|
||||
{ ...KPI_META[2], value: countData?.coveredLength, percentage: countData?.total ? Math.round((countData.coveredLength / countData.total) * 100) : 0 },
|
||||
{ ...KPI_META[3], value: countData?.cancelLength, percentage: countData?.total ? Math.round((countData.cancelLength / countData.total) * 100) : 0 }
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
const pct = typeof item.percentage === 'number' ? item.percentage : Number(item.percentage);
|
||||
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 (
|
||||
<Grid item key={item.key} xs={6} sm={6} md={3}>
|
||||
<Paper
|
||||
@@ -1037,7 +1007,7 @@ const Deliveries = () => {
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
{fetchPercentageIsLoading ? (
|
||||
{fetchCountIsLoading ? (
|
||||
<Skeleton sx={{ width: 70, height: { xs: 28, md: 36 } }} animation="wave" />
|
||||
) : (
|
||||
<Typography
|
||||
@@ -1061,20 +1031,19 @@ const Deliveries = () => {
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
bgcolor: soft(trendColor),
|
||||
color: trendColor,
|
||||
bgcolor: soft(item.color),
|
||||
color: item.color,
|
||||
fontSize: { xs: 10, sm: 11 },
|
||||
fontWeight: 800
|
||||
}}
|
||||
>
|
||||
<Trend size={12} />
|
||||
{Math.abs(pct)}%
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: DT.textMuted, display: { xs: 'none', sm: 'inline' } }}
|
||||
>
|
||||
vs. yesterday
|
||||
of total
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -2161,11 +2161,18 @@
|
||||
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 {
|
||||
padding: 12px 16px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
@@ -2176,6 +2183,8 @@
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dispatch-container .trip-stats {
|
||||
@@ -2183,7 +2192,121 @@
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
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 {
|
||||
@@ -5523,6 +5646,37 @@
|
||||
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 {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -271,98 +271,244 @@ function polylineLengthKm(points) {
|
||||
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
|
||||
// chronologically sorted list of GPS pings. Per-axis state: [position,
|
||||
// velocity]. Constant-velocity dynamics with random acceleration as process
|
||||
// noise; measurement model H = [1, 0] (we measure position only).
|
||||
// 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).
|
||||
//
|
||||
// Why a Kalman filter here:
|
||||
// Raw /getdeliverylogs pings contain jitter (multi-path in dense urban
|
||||
// areas), brief stationary noise (rider parked at the drop, GPS still
|
||||
// wandering), and occasional outliers (cold-start fix). The Kalman pass
|
||||
// fuses each ping with the predicted trajectory from prior pings, weighted
|
||||
// by their relative uncertainty. The output is a smooth polyline that
|
||||
// tracks the rider's real path without the zig-zags and bunched-up dots
|
||||
// near drops, and it costs O(N) — runs once per delivery on fetch.
|
||||
// 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:
|
||||
// processNoise (q) — random-acceleration variance (deg²/s²). Lower = a
|
||||
// smoother result but slower to follow sharp turns.
|
||||
// measurementNoise (r) — GPS-fix variance (deg²). Higher = trust pings
|
||||
// less, lean on the predicted state more.
|
||||
// The defaults below correspond loosely to ~5m GPS accuracy and gentle
|
||||
// urban acceleration. Bump q if smoothing eats genuine turns; bump r if
|
||||
// the line still wiggles between pings.
|
||||
// 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 [];
|
||||
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 =
|
||||
options.processNoise != null ? options.processNoise : 1e-9;
|
||||
options.processNoise != null ? options.processNoise : 1e-10;
|
||||
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) =>
|
||||
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.
|
||||
const smoothAxis = (axisKey) => {
|
||||
let x = pings[0][axisKey]; // position estimate
|
||||
let v = 0; // velocity estimate
|
||||
// Initial covariance — large enough that the first few measurements
|
||||
// dominate over the initial state.
|
||||
let p00 = 1, p01 = 0, p10 = 0, p11 = 1;
|
||||
const out = [x];
|
||||
let prevTs = tsOf(pings[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;
|
||||
|
||||
for (let i = 1; i < pings.length; i++) {
|
||||
const ts = tsOf(pings[i]) || prevTs + 1000;
|
||||
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 xPred = x + v * dt;
|
||||
const vPred = v;
|
||||
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 = p00 + dt * (p01 + p10) + dt2 * p11 + (dt4 / 4) * processNoise;
|
||||
const np01 = p01 + dt * p11 + (dt3 / 2) * processNoise;
|
||||
const np10 = p10 + dt * p11 + (dt3 / 2) * processNoise;
|
||||
const np11 = p11 + dt2 * processNoise;
|
||||
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 ───
|
||||
// y = z − Hx' (innovation, measurement vs prediction)
|
||||
const z = pings[i][axisKey];
|
||||
const y = z - xPred;
|
||||
// ─── 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;
|
||||
// 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 K1 = np10 / S;
|
||||
// x = x' + K y
|
||||
x = xPred + K0 * y;
|
||||
v = vPred + K1 * y;
|
||||
const newPos = xPredPos + K0 * y;
|
||||
const newVel = xPredVel + K1 * y;
|
||||
// P = (I − K H) P'
|
||||
p00 = (1 - K0) * np00;
|
||||
p01 = (1 - K0) * np01;
|
||||
p10 = np10 - K1 * np00;
|
||||
p11 = np11 - K1 * np01;
|
||||
|
||||
out.push(x);
|
||||
xPost[i] = [newPos, newVel];
|
||||
pPost[i] = [
|
||||
(1 - K0) * np00,
|
||||
(1 - K0) * np01,
|
||||
np10 - K1 * np00,
|
||||
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 lngs = smoothAxis('lng');
|
||||
return pings.map((p, i) => ({
|
||||
return accepted.map((p, i) => ({
|
||||
lat: lats[i],
|
||||
lng: lngs[i],
|
||||
logdate: p.logdate,
|
||||
@@ -702,6 +848,17 @@ const Dispatch = ({
|
||||
const [focusedZone, setFocusedZone] = useState(null);
|
||||
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
|
||||
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
|
||||
// their popups when the user clicks a step in the focused-rider sidebar.
|
||||
const orderMarkerRefs = useRef({});
|
||||
@@ -2271,6 +2428,12 @@ const Dispatch = ({
|
||||
<div className="pu-rider">
|
||||
<MdTwoWheeler /> <span>{o.rider_name || o.ridername || 'Unassigned'}</span>
|
||||
</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 && (
|
||||
<div className="pu-delivery-id">Delivery #{o.deliveryid}</div>
|
||||
)}
|
||||
@@ -3566,10 +3729,40 @@ const Dispatch = ({
|
||||
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
|
||||
});
|
||||
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;
|
||||
return Object.entries(trips)
|
||||
.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 className="trip-header" style={{ background: `${focusedRider.color}12`, borderColor: `${focusedRider.color}30` }}>
|
||||
<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><MdStraighten /></Ico>{tOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km</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 className="zone-order-grid">
|
||||
{tOrders.map((o, idx) => {
|
||||
{displayOrders.map((o, idx) => {
|
||||
const kitchenKey = (o.kitchen_key || o.pickupcustomer || 'Unknown').toLowerCase().trim();
|
||||
const showTransition = prevKitchenKey !== null && kitchenKey !== prevKitchenKey;
|
||||
prevKitchenKey = kitchenKey;
|
||||
@@ -3592,6 +3820,20 @@ const Dispatch = ({
|
||||
const profit = parseFloat(o.profit || 0);
|
||||
const isLoss = profit < 0;
|
||||
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 (
|
||||
<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={`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}
|
||||
tabIndex={canFocus ? 0 : 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}
|
||||
>
|
||||
<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">Order #{o.orderid}</div>
|
||||
</div>
|
||||
@@ -3716,7 +3963,8 @@ const Dispatch = ({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
@@ -4562,33 +4810,34 @@ const Dispatch = ({
|
||||
eventHandlers={
|
||||
orderForTrack
|
||||
? {
|
||||
// Match the planned-route marker UX: hover surfaces
|
||||
// the rich order card in the centered overlay. The
|
||||
// ~200ms grace timer on mouseout lets the cursor
|
||||
// travel onto the overlay without flicker. Pinning
|
||||
// is implicit while focusedCompareStep === this
|
||||
// step, so the card stays put while the user is
|
||||
// inspecting this delivery.
|
||||
mouseover: () => {
|
||||
if (popupHoverTimerRef.current) {
|
||||
clearTimeout(popupHoverTimerRef.current);
|
||||
popupHoverTimerRef.current = null;
|
||||
}
|
||||
setCenterPopupOrder(orderForTrack);
|
||||
},
|
||||
mouseout: () => {
|
||||
if (focusedCompareStep === t.sequenceStep) return;
|
||||
if (popupHoverTimerRef.current) {
|
||||
clearTimeout(popupHoverTimerRef.current);
|
||||
}
|
||||
popupHoverTimerRef.current = setTimeout(() => {
|
||||
setCenterPopupOrder((cur) =>
|
||||
cur && String(cur.orderid) === String(orderForTrack.orderid) ? null : cur
|
||||
// Click-only: hovering the numbered pin does
|
||||
// nothing. Earlier the modal opened on mouseover,
|
||||
// which made the rich order card flash open while
|
||||
// panning the map and blocked the operator from
|
||||
// reading other steps in the trail. Click toggles
|
||||
// the modal — same `pinnedPopupsRef` pattern the
|
||||
// planned-route marker uses, so the modal stays
|
||||
// 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) {
|
||||
clearTimeout(popupHoverTimerRef.current);
|
||||
popupHoverTimerRef.current = null;
|
||||
}, 200);
|
||||
},
|
||||
click: handleEndMarkerClick
|
||||
}
|
||||
const id = String(orderForTrack.orderid);
|
||||
if (pinnedPopupsRef.current.has(id)) {
|
||||
pinnedPopupsRef.current.delete(id);
|
||||
setCenterPopupOrder(null);
|
||||
} else {
|
||||
pinnedPopupsRef.current.add(id);
|
||||
setCenterPopupOrder(orderForTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
: { click: handleEndMarkerClick }
|
||||
}
|
||||
|
||||
@@ -60,26 +60,57 @@ const MapWithRoute = ({ coordinates, additionalProps, order, setMapOpen }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = coordinates[0];
|
||||
const end = coordinates[coordinates.length - 1];
|
||||
|
||||
const url = `https://router.project-osrm.org/route/v1/driving/${start.lng},${start.lat};${end.lng},${end.lat}?overview=full&geometries=geojson`;
|
||||
const subsample = (arr, max) => {
|
||||
if (arr.length <= max) return arr;
|
||||
const step = Math.ceil(arr.length / max);
|
||||
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 {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const ptsM = subsample(coordinates, 90);
|
||||
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`;
|
||||
|
||||
if (data.routes?.length) {
|
||||
const points = data.routes[0].geometry.coordinates.map(([lng, lat]) => ({
|
||||
lat,
|
||||
lng
|
||||
}));
|
||||
setRoutePoints(points);
|
||||
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);
|
||||
}
|
||||
|
||||
// Attempt 2 — waypoint routing through a coarser subsample.
|
||||
try {
|
||||
const ptsR = subsample(coordinates, 25);
|
||||
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`;
|
||||
|
||||
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) {
|
||||
console.error('OSRM Error:', err);
|
||||
console.error('OSRM Route fallback error:', err);
|
||||
setRoutePoints(coordinates);
|
||||
} finally {
|
||||
setLoading(false); // ALWAYS STOP LOADING
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ import { FaCircleCheck } from 'react-icons/fa6';
|
||||
|
||||
import MapWithRoute from './mapWithRoute';
|
||||
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 Loader from 'components/Loader';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
@@ -218,6 +218,205 @@ const StampCell = ({ value, formatDate, formatTime, success }) => {
|
||||
|
||||
// ==============================|| 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() {
|
||||
const loadMoreRef = useRef();
|
||||
const containerRef = useRef();
|
||||
@@ -319,14 +518,35 @@ export default function OrdersDetails() {
|
||||
try {
|
||||
const res = await axios.get(`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${id}`);
|
||||
const datas = res.data.details;
|
||||
if (datas.length != 0) {
|
||||
setRiderStart(datas[0].logdate);
|
||||
setRiderEnd(datas[datas.length - 1].logdate);
|
||||
const coData = datas.map((data) => ({ lat: data.latitude, lng: data.longitude }));
|
||||
if (Array.isArray(datas) && datas.length !== 0) {
|
||||
// Sort chronologically by logdate
|
||||
const sorted = datas
|
||||
.map((r) => {
|
||||
const ts = r?.logdate ? dayjs(r.logdate) : null;
|
||||
return {
|
||||
lat: parseFloat(r?.latitude ?? r?.lat),
|
||||
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 if (datas == null || !datas) {
|
||||
} else {
|
||||
opentoast('No Valid Logs Found', 'error', 2000);
|
||||
}
|
||||
} else {
|
||||
opentoast('No Logs Found ', 'error', 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -416,9 +636,9 @@ export default function OrdersDetails() {
|
||||
isError: getriderbydeliveryIsError,
|
||||
error: getriderbydeliveryError
|
||||
} = useQuery({
|
||||
queryKey: ['getriderbydelivery', startdate, enddate, appId, tenantid, locationid],
|
||||
queryFn: () => getriderbydelivery(startdate, enddate, appId, tenantid, locationid),
|
||||
enabled: appId != 0
|
||||
queryKey: ['fetchRidersList', appId],
|
||||
queryFn: fetchRidersList,
|
||||
enabled: appId !== 0
|
||||
});
|
||||
|
||||
const {
|
||||
|
||||
Reference in New Issue
Block a user