updates on the kalman filter and the dispatch page by time filteration updates
This commit is contained in:
@@ -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: () => {
|
||||
// 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;
|
||||
}
|
||||
setCenterPopupOrder(orderForTrack);
|
||||
},
|
||||
mouseout: () => {
|
||||
if (focusedCompareStep === t.sequenceStep) return;
|
||||
if (popupHoverTimerRef.current) {
|
||||
clearTimeout(popupHoverTimerRef.current);
|
||||
const id = String(orderForTrack.orderid);
|
||||
if (pinnedPopupsRef.current.has(id)) {
|
||||
pinnedPopupsRef.current.delete(id);
|
||||
setCenterPopupOrder(null);
|
||||
} else {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user