updates on the ui changes and changed into doormile
This commit is contained in:
442
src/lib/dispatchShared.js
Normal file
442
src/lib/dispatchShared.js
Normal file
@@ -0,0 +1,442 @@
|
||||
// Shared constants and pure helpers for the Dispatch board and its
|
||||
// sub-components. Lives outside the page so the host component and the child
|
||||
// views cannot form a circular import.
|
||||
//
|
||||
// Ported from the Express Console's `dispatch/dispatchShared.js`. The geometry
|
||||
// and the Kalman/RTS smoother are carried over verbatim — their tuning was
|
||||
// arrived at against real two-wheeler GPS traces, and re-deriving it would be
|
||||
// guesswork dressed up as a rewrite.
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
|
||||
// Replaces MUI's `useMediaQuery(theme.breakpoints.down('md'))`, which was the
|
||||
// last MUI dependency in Preview.js and CompareDataPanel.js. MUI's `md`
|
||||
// breakpoint is 900px and `down()` is exclusive, so this is the identical
|
||||
// `max-width: 899.95px` query it generated — the same width the scoped media
|
||||
// blocks in Dispatch.css already key off.
|
||||
const NARROW_VIEWPORT_QUERY = '(max-width: 899.95px)';
|
||||
|
||||
export function useIsNarrowViewport() {
|
||||
const [isNarrow, setIsNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(NARROW_VIEWPORT_QUERY).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return undefined;
|
||||
const mql = window.matchMedia(NARROW_VIEWPORT_QUERY);
|
||||
const onChange = (e) => setIsNarrow(e.matches);
|
||||
// Re-sync on mount in case the viewport changed between the lazy initial
|
||||
// state and the effect running.
|
||||
setIsNarrow(mql.matches);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isNarrow;
|
||||
}
|
||||
|
||||
// Enter/Space activation for div/li surfaces that carry an onClick.
|
||||
//
|
||||
// The dispatch UI is built from clickable cards and list rows that wrap
|
||||
// headings, badges and bars, so they can't become real <button>s (interactive
|
||||
// content nested in a button is invalid markup, and it would fight the card
|
||||
// CSS). `role="button"` + `tabIndex={0}` + this handler is the sanctioned
|
||||
// shape. Pair all three — a role without a tabIndex is not reachable, and a
|
||||
// click without a key handler is mouse-only.
|
||||
export const onActivate = (handler) => (e) => {
|
||||
if (!handler) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Status colour for the places that need a RAW HEX rather than a rendered
|
||||
// badge — Leaflet divIcon HTML built as a template string, and the `style`
|
||||
// attribute on a few inline pills. Anything that renders normal JSX should use
|
||||
// <StatusBadge status={...}/> instead; this exists only because you cannot put
|
||||
// a React component inside an `L.divIcon({ html: '...' })` string.
|
||||
//
|
||||
// This used to be a private STATUS_STYLES table, and it had drifted badly from
|
||||
// the canonical one in themes/dt/status.js:
|
||||
//
|
||||
// picked #0ea5e9 → #8b5cf6 (was identical to `active`, so two
|
||||
// active #0ea5e9 → #14b8a6 different states looked the same)
|
||||
// accepted #8b5cf6 → #6366f1 (was identical to the new `picked`)
|
||||
// arrived #ea580c → #06b6d4
|
||||
// delivered #22c55e → #10b981
|
||||
// skipped #94a3b8 → #f97316 (was the same grey as "unknown")
|
||||
//
|
||||
// So the dispatch map painted a picked-up order and an in-transit order the
|
||||
// same blue, while the deliveries table painted them purple and teal. Now both
|
||||
// read from one table. `getStatusMeta` also resolves the raw backend enums
|
||||
// (miler_assigned, converted_to_consignment, …), which this never did — those
|
||||
// previously fell through to the grey "unknown" branch.
|
||||
//
|
||||
// The `{ label, bg, fg }` shape is kept so the existing call sites are
|
||||
// unchanged; `fg` is always white, as it was in every entry of the old table.
|
||||
/**
|
||||
* A status as `{ label, bg, fg }` for a Leaflet `divIcon`.
|
||||
*
|
||||
* This is the ONE sanctioned reason a status colour is read as a raw hex
|
||||
* instead of rendered through `<StatusBadge>`: a divIcon is built from an HTML
|
||||
* template string, and a React component cannot go inside one. Anywhere that
|
||||
* renders normal JSX must use the badge, or the map and the tables drift into
|
||||
* painting the same state two different colours — which is exactly what
|
||||
* happened before this became a single table.
|
||||
*/
|
||||
const MARKER_COLOR = {
|
||||
pending: '#94a3b8',
|
||||
pending_pickup: '#94a3b8',
|
||||
miler_assigned: '#6366f1',
|
||||
accepted: '#6366f1',
|
||||
pickup_scheduled: '#6366f1',
|
||||
arrived: '#06b6d4',
|
||||
picked: '#8b5cf6',
|
||||
converted_to_consignment: '#8b5cf6',
|
||||
active: '#14b8a6',
|
||||
out_for_delivery: '#14b8a6',
|
||||
delivered: '#10b981',
|
||||
skipped: '#f97316',
|
||||
cancelled: '#ef4444',
|
||||
canceled: '#ef4444'
|
||||
};
|
||||
|
||||
export const getStatusStyle = (status) => {
|
||||
const key = String(status || '').toLowerCase();
|
||||
const label = key ? key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) : 'Unknown';
|
||||
return { label, bg: MARKER_COLOR[key] || '#94a3b8', fg: '#fff' };
|
||||
};
|
||||
|
||||
// Order-status sets used for completion / skipped decisions across the
|
||||
// rider list, the planned-route renderer, and the compare data panel.
|
||||
export const FINAL_STATUSES = new Set(['delivered']);
|
||||
export const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']);
|
||||
|
||||
// Per-step palette — wider and more deliberately spaced than the rider
|
||||
// palette so a 10-stop day reads as 10 distinct colors on the compare
|
||||
// map's polylines + pins.
|
||||
export const STEP_PALETTE = [
|
||||
'#2563eb', // blue-600
|
||||
'#dc2626', // red-600
|
||||
'#16a34a', // green-600
|
||||
'#ea580c', // orange-600
|
||||
'#9333ea', // purple-600
|
||||
'#0891b2', // cyan-600
|
||||
'#ca8a04', // yellow-600
|
||||
'#db2777', // pink-600
|
||||
'#0f766e', // teal-700
|
||||
'#7c3aed', // violet-600
|
||||
'#65a30d', // lime-600
|
||||
'#0284c7', // sky-600
|
||||
'#b91c1c', // red-700
|
||||
'#15803d', // green-700
|
||||
'#a16207', // yellow-700
|
||||
'#86198f' // fuchsia-800
|
||||
];
|
||||
|
||||
export const stepColor = (i) =>
|
||||
STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length];
|
||||
|
||||
// Pure helper — converts 1, 2, 3, 21 → "1st", "2nd", "3rd", "21st". Used
|
||||
// by the compare data panel for the route-sequence diff list ("Visited
|
||||
// 4th · planned 2nd").
|
||||
export const ordinal = (n) => {
|
||||
if (n == null) return '';
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
const v = n % 100;
|
||||
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
};
|
||||
|
||||
// An order is "active" (currently in progress) when it's neither completed
|
||||
// (delivered) nor skipped/cancelled. The Active view uses this to collapse a
|
||||
// rider down to the single delivery they're working on right now.
|
||||
export const isActiveDelivery = (o) => {
|
||||
const s = String(o?.orderstatus || '').toLowerCase();
|
||||
return s === 'active';
|
||||
};
|
||||
|
||||
// A rider's single in-progress delivery: the first non-final, non-skipped
|
||||
// stop in (trip, step) order. Returns null when the rider has nothing active
|
||||
// (everything delivered/cancelled, or GPS-only with no orders).
|
||||
export const getActiveOrder = (orders) => {
|
||||
if (!Array.isArray(orders) || !orders.length) return null;
|
||||
const sorted = [...orders].sort((a, b) => {
|
||||
const tA = a.trip_number || 1;
|
||||
const tB = b.trip_number || 1;
|
||||
if (tA !== tB) return tA - tB;
|
||||
return (a.step || 0) - (b.step || 0);
|
||||
});
|
||||
return sorted.find(isActiveDelivery) || null;
|
||||
};
|
||||
|
||||
// Haversine distance between two [lat, lng] points in kilometers. Good to
|
||||
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
|
||||
// polyline so the Compare delta panel can show "actual km" without depending
|
||||
// on the backend's actualkms field (which can be stale or missing). Also
|
||||
// reused by deliveries.js's Update Status dialog to compute a real Actual
|
||||
// KMs figure from GET /admin/consignments/:id/logs — see polylineLengthKm.
|
||||
export function haversineKm(a, b) {
|
||||
const R = 6371; // km
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const lat1 = toRad(a[0]);
|
||||
const lat2 = toRad(b[0]);
|
||||
const dLat = toRad(b[0] - a[0]);
|
||||
const dLon = toRad(b[1] - a[1]);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
|
||||
export function polylineLengthKm(points) {
|
||||
if (!Array.isArray(points) || points.length < 2) return 0;
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
total += haversineKm(points[i - 1], points[i]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── Kalman filter + RTS smoother for GPS pings ──────────────────────────
|
||||
//
|
||||
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
|
||||
// chronologically sorted list of GPS pings, followed by a Rauch-Tung-
|
||||
// Striebel backward pass. Per-axis state: [position, velocity]. Constant-
|
||||
// velocity dynamics with random acceleration as process noise; measurement
|
||||
// model H = [1, 0] (we measure position only).
|
||||
//
|
||||
// Pipeline:
|
||||
// 1. Pre-filter teleport pings (>maxSpeedKmh between consecutive pings,
|
||||
// e.g. cold-start fix, GPS multipath). These would otherwise tug the
|
||||
// forward filter even with the in-loop Mahalanobis gate enabled.
|
||||
// 2. Forward Kalman pass with Mahalanobis 3σ outlier gating — pings whose
|
||||
// innovation exceeds the gate are not used to update; the prediction
|
||||
// is kept as the posterior. Stores prior + posterior moments at each
|
||||
// step so the backward pass can run.
|
||||
// 3. Backward RTS smoother — refines every step using ALL future
|
||||
// observations. Logs are fetched in one shot (not streamed) so we
|
||||
// can afford the second pass; the accuracy lift is biggest near the
|
||||
// start of the trail and through turns the forward pass under-corrects.
|
||||
//
|
||||
// Tuning (all in degrees² since pings are in lat/lng):
|
||||
// processNoise (q) — random-acceleration variance (deg²/s²). Default
|
||||
// tuned for urban two-wheelers (~1 m/s² accel).
|
||||
// Lower = smoother but slower to follow sharp turns.
|
||||
// measurementNoise (r) — GPS-fix variance (deg²). Default = ~5 m std dev,
|
||||
// which matches consumer GPS in open urban areas.
|
||||
// Bump for dense canyons.
|
||||
// outlierGate — Mahalanobis² threshold for in-loop rejection.
|
||||
// 9.0 = 3σ (≈ 99.7% of inliers pass).
|
||||
// maxSpeedKmh — pre-filter for impossible inter-ping speed.
|
||||
// 120 km/h covers any legal two-wheeler movement
|
||||
// plus margin; anything above is GPS error.
|
||||
export function kalmanSmoothGps(pings, options = {}) {
|
||||
if (!Array.isArray(pings) || pings.length === 0) return [];
|
||||
|
||||
// 1. Filter out obviously invalid coordinate pings (e.g. 0,0 or NaN)
|
||||
const cleanedPings = pings.filter(p =>
|
||||
Number.isFinite(p.lat) &&
|
||||
Number.isFinite(p.lng) &&
|
||||
(Math.abs(p.lat) > 0.1 || Math.abs(p.lng) > 0.1)
|
||||
);
|
||||
|
||||
if (cleanedPings.length === 0) return [];
|
||||
if (cleanedPings.length === 1) {
|
||||
return [{ lat: cleanedPings[0].lat, lng: cleanedPings[0].lng, logdate: cleanedPings[0].logdate, _ts: cleanedPings[0]._ts }];
|
||||
}
|
||||
|
||||
const processNoise =
|
||||
options.processNoise != null ? options.processNoise : 1e-10;
|
||||
const measurementNoise =
|
||||
options.measurementNoise != null ? options.measurementNoise : 2e-9;
|
||||
const outlierGate =
|
||||
options.outlierGate != null ? options.outlierGate : 9.0;
|
||||
const maxSpeedKmh =
|
||||
options.maxSpeedKmh != null ? options.maxSpeedKmh : 120;
|
||||
|
||||
const tsOf = (p) =>
|
||||
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
|
||||
|
||||
// 2. Scan forward to find the first valid starting anchor
|
||||
let startIdx = 0;
|
||||
while (startIdx < cleanedPings.length - 1) {
|
||||
const p0 = cleanedPings[startIdx];
|
||||
const p1 = cleanedPings[startIdx + 1];
|
||||
const ts0 = tsOf(p0);
|
||||
const ts1 = tsOf(p1) || ts0 + 1000;
|
||||
const dtSec = Math.max(0.001, (ts1 - ts0) / 1000);
|
||||
const km = haversineKm([p0.lat, p0.lng], [p1.lat, p1.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
|
||||
if (speedKmh <= maxSpeedKmh) {
|
||||
break;
|
||||
} else {
|
||||
// Speed is too high. Check if p1->p2 is normal (meaning p0 is the outlier)
|
||||
if (startIdx + 2 < cleanedPings.length) {
|
||||
const p2 = cleanedPings[startIdx + 2];
|
||||
const ts2 = tsOf(p2) || ts1 + 1000;
|
||||
const dtSec12 = Math.max(0.001, (ts2 - ts1) / 1000);
|
||||
const km12 = haversineKm([p1.lat, p1.lng], [p2.lat, p2.lng]);
|
||||
const speedKmh12 = (km12 / dtSec12) * 3600;
|
||||
|
||||
if (speedKmh12 <= maxSpeedKmh) {
|
||||
startIdx = startIdx + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
startIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Teleport filter starting from the valid anchor
|
||||
const accepted = [cleanedPings[startIdx]];
|
||||
let lastTs = tsOf(cleanedPings[startIdx]);
|
||||
for (let i = startIdx + 1; i < cleanedPings.length; i++) {
|
||||
const p = cleanedPings[i];
|
||||
const ts = tsOf(p) || lastTs + 1000;
|
||||
const dtSec = Math.max(0.001, (ts - lastTs) / 1000);
|
||||
const prev = accepted[accepted.length - 1];
|
||||
const km = haversineKm([prev.lat, prev.lng], [p.lat, p.lng]);
|
||||
const speedKmh = (km / dtSec) * 3600;
|
||||
if (speedKmh > maxSpeedKmh) continue;
|
||||
accepted.push(p);
|
||||
lastTs = ts;
|
||||
}
|
||||
|
||||
if (accepted.length < 2) {
|
||||
return accepted.map((p) => ({ lat: p.lat, lng: p.lng, logdate: p.logdate, _ts: p._ts }));
|
||||
}
|
||||
|
||||
// Run a 1D Kalman + RTS smoother over one axis. Returns smoothed
|
||||
// positions parallel to `accepted`.
|
||||
const smoothAxis = (axisKey) => {
|
||||
const N = accepted.length;
|
||||
// Per-step storage for the backward RTS pass.
|
||||
const xPost = new Array(N); // [pos, vel] posterior after update
|
||||
const pPost = new Array(N); // 2x2 cov posterior, flattened [p00,p01,p10,p11]
|
||||
const xPrior = new Array(N); // predicted mean before update
|
||||
const pPrior = new Array(N); // predicted cov before update
|
||||
const dtArr = new Array(N); // dt from i-1 → i, for RTS transition
|
||||
|
||||
// Initial state: position = first measurement, velocity from the first
|
||||
// two pings (better than 0 — keeps the start of the trail from lagging
|
||||
// behind the rider's actual motion). Initial position covariance = r
|
||||
// (we just measured it); initial velocity covariance is loose so it
|
||||
// can be refined quickly.
|
||||
const ts0 = tsOf(accepted[0]);
|
||||
const ts1 = tsOf(accepted[1]);
|
||||
const dt01 = Math.max(0.1, (ts1 - ts0) / 1000);
|
||||
const v0 = (accepted[1][axisKey] - accepted[0][axisKey]) / dt01;
|
||||
xPost[0] = [accepted[0][axisKey], v0];
|
||||
pPost[0] = [measurementNoise, 0, 0, 1];
|
||||
xPrior[0] = xPost[0].slice();
|
||||
pPrior[0] = pPost[0].slice();
|
||||
dtArr[0] = 0;
|
||||
|
||||
let prevTs = ts0;
|
||||
for (let i = 1; i < N; i++) {
|
||||
const ts = tsOf(accepted[i]) || prevTs + 1000;
|
||||
const dt = Math.max(0.1, (ts - prevTs) / 1000);
|
||||
prevTs = ts;
|
||||
dtArr[i] = dt;
|
||||
|
||||
// ─── Predict ───
|
||||
// x' = F x where F = [[1, dt], [0, 1]]
|
||||
const [xPrev, vPrev] = xPost[i - 1];
|
||||
const xPredPos = xPrev + vPrev * dt;
|
||||
const xPredVel = vPrev;
|
||||
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i - 1];
|
||||
const dt2 = dt * dt;
|
||||
const dt3 = dt2 * dt;
|
||||
const dt4 = dt3 * dt;
|
||||
const np00 = pp00 + dt * (pp01 + pp10) + dt2 * pp11 + (dt4 / 4) * processNoise;
|
||||
const np01 = pp01 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np10 = pp10 + dt * pp11 + (dt3 / 2) * processNoise;
|
||||
const np11 = pp11 + dt2 * processNoise;
|
||||
xPrior[i] = [xPredPos, xPredVel];
|
||||
pPrior[i] = [np00, np01, np10, np11];
|
||||
|
||||
// ─── Update (with Mahalanobis gating) ───
|
||||
// y = z − Hx' (innovation)
|
||||
// S = H P' H^T + R (innovation covariance)
|
||||
// Reject the measurement if mahal² = y²/S exceeds the gate. The
|
||||
// prediction then carries forward as the posterior — the trail stays
|
||||
// continuous instead of being yanked toward a bad fix.
|
||||
const z = accepted[i][axisKey];
|
||||
const y = z - xPredPos;
|
||||
const S = np00 + measurementNoise;
|
||||
const mahal2 = (y * y) / S;
|
||||
if (mahal2 > outlierGate) {
|
||||
xPost[i] = [xPredPos, xPredVel];
|
||||
pPost[i] = [np00, np01, np10, np11];
|
||||
continue;
|
||||
}
|
||||
// K = P' H^T / S
|
||||
const K0 = np00 / S;
|
||||
const K1 = np10 / S;
|
||||
// x = x' + K y
|
||||
const newPos = xPredPos + K0 * y;
|
||||
const newVel = xPredVel + K1 * y;
|
||||
// P = (I − K H) P'
|
||||
xPost[i] = [newPos, newVel];
|
||||
pPost[i] = [
|
||||
(1 - K0) * np00,
|
||||
(1 - K0) * np01,
|
||||
np10 - K1 * np00,
|
||||
np11 - K1 * np01
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Backward RTS smoother ─────────────────────────────────────────
|
||||
// x_smooth[N-1] = x_post[N-1]
|
||||
// For i = N-2 … 0:
|
||||
// C = P_post[i] · F^T · inv(P_prior[i+1])
|
||||
// x_smooth[i] = x_post[i] + C · (x_smooth[i+1] − x_prior[i+1])
|
||||
// F^T for a constant-velocity model is [[1,0],[dt,1]], so
|
||||
// P_post · F^T = [[p00 + dt·p01, p01],
|
||||
// [p10 + dt·p11, p11]]
|
||||
const xSmooth = new Array(N);
|
||||
xSmooth[N - 1] = xPost[N - 1].slice();
|
||||
for (let i = N - 2; i >= 0; i--) {
|
||||
const dt = dtArr[i + 1];
|
||||
const [pp00, pp01, pp10, pp11] = pPost[i];
|
||||
const a = pp00 + dt * pp01;
|
||||
const b = pp01;
|
||||
const c = pp10 + dt * pp11;
|
||||
const d = pp11;
|
||||
// Invert P_prior[i+1] (2x2): inv = (1/det) · [[q11,-q01],[-q10,q00]]
|
||||
const [q00, q01, q10, q11] = pPrior[i + 1];
|
||||
const det = q00 * q11 - q01 * q10;
|
||||
if (!Number.isFinite(det) || Math.abs(det) < 1e-30) {
|
||||
xSmooth[i] = xPost[i].slice();
|
||||
continue;
|
||||
}
|
||||
const inv00 = q11 / det;
|
||||
const inv01 = -q01 / det;
|
||||
const inv10 = -q10 / det;
|
||||
const inv11 = q00 / det;
|
||||
// Smoother gain C = (P_post · F^T) · inv(P_prior_next)
|
||||
const c00 = a * inv00 + b * inv10;
|
||||
const c01 = a * inv01 + b * inv11;
|
||||
const c10 = c * inv00 + d * inv10;
|
||||
const c11 = c * inv01 + d * inv11;
|
||||
const dxPos = xSmooth[i + 1][0] - xPrior[i + 1][0];
|
||||
const dxVel = xSmooth[i + 1][1] - xPrior[i + 1][1];
|
||||
xSmooth[i] = [
|
||||
xPost[i][0] + c00 * dxPos + c01 * dxVel,
|
||||
xPost[i][1] + c10 * dxPos + c11 * dxVel
|
||||
];
|
||||
}
|
||||
|
||||
return xSmooth.map((s) => s[0]);
|
||||
};
|
||||
|
||||
const lats = smoothAxis('lat');
|
||||
const lngs = smoothAxis('lng');
|
||||
return accepted.map((p, i) => ({
|
||||
lat: lats[i],
|
||||
lng: lngs[i],
|
||||
logdate: p.logdate,
|
||||
_ts: p._ts
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user