updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

442
src/lib/dispatchShared.js Normal file
View 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
}));
}