diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css index 7797d42..bc33946 100644 --- a/src/pages/nearle/dispatch/Dispatch.css +++ b/src/pages/nearle/dispatch/Dispatch.css @@ -1122,16 +1122,89 @@ display: flex; min-height: 0; overflow: hidden; + position: relative; /* anchor for the compare-divider-badge */ } /* Sidebar */ .testing-container #sidebar { width: 400px; + flex: 0 0 400px; background: var(--bg-sub); display: flex; flex-direction: column; border-right: 1px solid var(--border); z-index: 5; + transition: width 0.32s cubic-bezier(0.4, 0, 0.2, 1), + flex-basis 0.32s cubic-bezier(0.4, 0, 0.2, 1), + border-right-color 0.2s ease; + overflow: hidden; +} + +/* Collapsed state — slide the sidebar out so the maps can use the full width. + Children stay rendered (their state is preserved) but are masked by + overflow:hidden. The peek tab below stays visible to re-open. */ +.testing-container #body.sidebar-collapsed #sidebar { + width: 0; + flex: 0 0 0; + border-right-color: transparent; +} + +/* Peek tab — vertical pill that hugs the sidebar's right edge. Tracks the + sidebar width so it sits flush against whichever side is currently visible: + at left:400px when expanded, at left:0 when collapsed. */ +.testing-container .sidebar-toggle-tab { + position: absolute; + top: 50%; + left: 400px; + transform: translate(-50%, -50%); + width: 22px; + height: 56px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border, rgba(15, 23, 42, 0.12)); + border-radius: 10px; + background: #fff; + color: var(--text, #0f172a); + font-size: 18px; + line-height: 1; + cursor: pointer; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12), + 0 1px 3px rgba(15, 23, 42, 0.06); + z-index: 1200; + transition: left 0.32s cubic-bezier(0.4, 0, 0.2, 1), + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.testing-container .sidebar-toggle-tab:hover { + background: linear-gradient(135deg, #6366f1, #3b82f6); + color: #fff; + transform: translate(-50%, -50%) scale(1.06); + box-shadow: 0 6px 16px rgba(99, 102, 241, 0.35); +} + +.testing-container .sidebar-toggle-tab:focus-visible { + outline: 2px solid var(--accent, #3b82f6); + outline-offset: 2px; +} + +.testing-container .sidebar-toggle-tab.is-collapsed { + left: 0; + transform: translate(0, -50%); + border-radius: 0 10px 10px 0; + border-left: none; +} + +.testing-container .sidebar-toggle-tab.is-collapsed:hover { + transform: translate(0, -50%) scale(1.06); +} + +.testing-container .sidebar-toggle-tab svg { + display: block; } /* Sidebar header — moved here from the top bar. Layered card: title row with @@ -1675,49 +1748,6 @@ margin-left: -8px; } -/* Currently-going-on order in the focused-rider view — first non-delivered, - non-skipped stop in trip+step order. Light green tint + green accent rail + - a small "IN PROGRESS" tag so users see at a glance which delivery is live. */ -.testing-container .step-row.is-going-on { - background: rgba(34, 197, 94, 0.12); - box-shadow: inset 3px 0 0 var(--success); - padding-left: 8px; - margin-left: -8px; - position: relative; - border-radius: 6px; -} - -.testing-container .step-row.is-going-on:hover { - background: rgba(34, 197, 94, 0.18); -} - -.testing-container .step-row.is-going-on::after { - content: 'IN PROGRESS'; - position: absolute; - top: 6px; - right: 8px; - padding: 2px 8px; - border-radius: 999px; - background: var(--success); - color: #fff; - font-size: 9px; - font-weight: 800; - letter-spacing: 0.08em; - box-shadow: 0 2px 6px rgba(34, 197, 94, 0.35); - animation: going-on-pulse 1.6s ease-in-out infinite; -} - -@keyframes going-on-pulse { - 0%, 100% { box-shadow: 0 2px 6px rgba(34, 197, 94, 0.35); } - 50% { box-shadow: 0 2px 14px rgba(34, 197, 94, 0.7); } -} - -/* When the going-on row is ALSO the focused/clicked stop, keep the green rail - (priority signal) but tint a hair stronger so the click state is still felt. */ -.testing-container .step-row.is-going-on.active { - background: rgba(34, 197, 94, 0.2); - box-shadow: inset 3px 0 0 var(--success); -} .testing-container .step-row:not(:last-child)::before { content: ''; @@ -2601,6 +2631,1398 @@ .testing-container #map-wrap { flex: 1; position: relative; + transition: flex 0.32s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* When Compare mode is on, the dispatch map shrinks to half the body so the + actual-tracks pane (#compare-map-wrap) can sit beside it. A real gutter + between the two maps (margin-right) replaces the old hairline border so + the two halves read as clearly separate panels. */ +.testing-container #map-wrap.compare-split { + flex: 1 1 calc(50% - 8px); + min-width: 0; + margin-right: 16px; + border-right: 0; + border-radius: 0 14px 14px 0; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06); + overflow: hidden; +} + +/* "Planned Route" badge floating on the left map when compare is open */ +.testing-container .compare-planned-label { + position: absolute; + top: 12px; + left: 12px; + z-index: 1000; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px 5px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); + border: 1px solid rgba(99, 102, 241, 0.3); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1); + font-size: 10px; + font-weight: 800; + color: #4338ca; + letter-spacing: 0.06em; + text-transform: uppercase; + pointer-events: none; + animation: compare-label-in 0.22s ease-out; +} + +.testing-container .compare-planned-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #4338ca); + flex-shrink: 0; +} + +@keyframes compare-label-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Right half of Compare mode */ +.testing-container #compare-map-wrap { + flex: 1 1 calc(50% - 8px); + min-width: 0; + position: relative; + display: flex; + flex-direction: column; + background: #fff; + border-radius: 14px 0 0 14px; + box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.06); + overflow: hidden; + animation: compare-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes compare-slide-in { + from { opacity: 0; transform: translateX(18px); } + to { opacity: 1; transform: translateX(0); } +} + + +.testing-container #compare-map-wrap .leaflet-container { + flex: 1; + min-height: 0; + background: #f0f4f8 !important; +} + +/* Wraps the right MapContainer so a position:relative anchor exists for the + bottom-right Animate Routes overlay (mirrors #ov-br on the main map). The + delta panel sits outside this wrapper so the overlay can't accidentally + land on top of the stats cards. */ +.testing-container .compare-map-area { + flex: 1; + min-height: 0; + position: relative; + display: flex; + flex-direction: column; +} + +.testing-container .compare-ov-br { + position: absolute; + right: 12px; + bottom: 12px; + z-index: 1000; + display: flex; + gap: 8px; +} + +/* Compare header */ +.testing-container .compare-header { + padding: 10px 14px 8px; + border-bottom: 1px solid var(--border, rgba(15, 23, 42, 0.08)); + background: linear-gradient(180deg, #fff 0%, #f8fafc 100%); + flex-shrink: 0; +} + +.testing-container .compare-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 7px; +} + +.testing-container .compare-title { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 800; + color: #0f172a; + letter-spacing: 0.01em; + min-width: 0; +} + +.testing-container .compare-title-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} + +.testing-container .compare-title-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.testing-container .compare-title-badge { + display: inline-flex; + align-items: center; + padding: 3px 9px; + border-radius: 999px; + background: rgba(14, 165, 233, 0.1); + border: 1px solid rgba(14, 165, 233, 0.25); + color: #0284c7; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + flex-shrink: 0; +} + +/* Track-load progress bar */ +.testing-container .compare-progress { + display: flex; + align-items: center; + gap: 8px; +} + +.testing-container .compare-progress-bar-wrap { + flex: 1; + height: 3px; + background: rgba(15, 23, 42, 0.07); + border-radius: 999px; + overflow: hidden; +} + +.testing-container .compare-progress-bar-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #0ea5e9, #6366f1); + transition: width 0.45s cubic-bezier(0.4, 0, 0.2, 1); +} + +.testing-container .compare-progress-bar-fill.is-done { + background: linear-gradient(90deg, #22c55e, #16a34a); +} + +.testing-container .compare-progress-text { + font-size: 12px; + font-weight: 700; + color: #94a3b8; + white-space: nowrap; + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +/* Per-delivery track legend — sits between map and stats card */ +.testing-container .compare-track-legend { + flex-shrink: 0; + max-height: 150px; + overflow-y: auto; + border-bottom: 1px solid var(--border, rgba(15, 23, 42, 0.07)); + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.25) transparent; +} + +.testing-container .compare-track-legend::-webkit-scrollbar { + width: 4px; +} + +.testing-container .compare-track-legend::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.25); + border-radius: 999px; +} + +.testing-container .compare-track-item { + display: flex; + align-items: center; + gap: 9px; + padding: 5px 14px; + border-bottom: 1px solid rgba(15, 23, 42, 0.04); + transition: background 0.12s; +} + +.testing-container .compare-track-item:last-child { + border-bottom: 0; +} + +.testing-container .compare-track-item:hover { + background: rgba(15, 23, 42, 0.025); +} + +.testing-container .compare-track-num { + width: 20px; + height: 20px; + border-radius: 50%; + color: #fff; + font-size: 9px; + font-weight: 800; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); +} + +.testing-container .compare-track-info { + flex: 1; + min-width: 0; +} + +.testing-container .compare-track-customer { + font-size: 11px; + font-weight: 700; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .compare-track-meta { + font-size: 10px; + color: #94a3b8; + font-weight: 600; + margin-top: 1px; +} + +.testing-container .compare-track-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + flex-shrink: 0; +} + +.testing-container .compare-track-status { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.testing-container .compare-track-kms { + font-size: 10px; + font-weight: 700; + color: #64748b; +} + +.testing-container .compare-track-no-data { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 9px; + font-weight: 600; + color: #cbd5e1; +} + +.testing-container .compare-track-spinner { + width: 9px; + height: 9px; + border-radius: 50%; + border: 1.5px solid rgba(100, 116, 139, 0.18); + border-top-color: #94a3b8; + animation: compare-spin 0.65s linear infinite; + flex-shrink: 0; +} + +@keyframes compare-spin { + to { transform: rotate(360deg); } +} + +/* Summary stats card at the bottom of the Compare pane */ +.testing-container .compare-overall-card { + background: linear-gradient(180deg, #fff 0%, #f8fafc 100%); + border-top: 1px solid var(--border, rgba(15, 23, 42, 0.07)); + padding: 10px 14px; + flex-shrink: 0; +} + +.testing-container .compare-overall-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 9px; +} + +.testing-container .compare-overall-dot { + width: 11px; + height: 11px; + border-radius: 50%; + flex-shrink: 0; +} + +.testing-container .compare-overall-name { + font-size: 12px; + font-weight: 800; + color: #0f172a; + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .compare-overall-rate { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.22); + color: #16a34a; + flex-shrink: 0; +} + +.testing-container .compare-overall-rate.is-partial { + background: rgba(245, 158, 11, 0.1); + border-color: rgba(245, 158, 11, 0.25); + color: #b45309; +} + +.testing-container .compare-overall-rate.is-zero { + background: rgba(100, 116, 139, 0.08); + border-color: rgba(100, 116, 139, 0.2); + color: #64748b; +} + +.testing-container .compare-overall-stats { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; +} + +.testing-container .compare-overall-stat { + background: rgba(15, 23, 42, 0.03); + border: 1px solid rgba(15, 23, 42, 0.06); + border-radius: 10px; + padding: 7px 6px 6px; + text-align: center; + transition: background 0.15s, transform 0.15s; +} + +.testing-container .compare-overall-stat:hover { + background: rgba(15, 23, 42, 0.055); + transform: translateY(-1px); +} + +.testing-container .compare-overall-stat-icon { + font-size: 13px; + line-height: 1; + margin-bottom: 3px; + color: #94a3b8; + display: flex; + align-items: center; + justify-content: center; +} + +.testing-container .compare-overall-stat-value { + font-size: 14px; + font-weight: 800; + color: #0f172a; + line-height: 1.1; +} + +.testing-container .compare-overall-stat-unit { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + margin-left: 2px; +} + +.testing-container .compare-overall-stat-label { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 2px; +} + +.testing-container .compare-overall-stat.is-profit { + background: rgba(34, 197, 94, 0.06); + border-color: rgba(34, 197, 94, 0.16); +} + +.testing-container .compare-overall-stat.is-profit .compare-overall-stat-value { color: #16a34a; } +.testing-container .compare-overall-stat.is-profit .compare-overall-stat-icon { color: #22c55e; } + +.testing-container .compare-overall-stat.is-loss { + background: rgba(239, 68, 68, 0.06); + border-color: rgba(239, 68, 68, 0.16); +} + +.testing-container .compare-overall-stat.is-loss .compare-overall-stat-value { color: #dc2626; } +.testing-container .compare-overall-stat.is-loss .compare-overall-stat-icon { color: #ef4444; } + +/* ── Compare UI v2 ────────────────────────────────────────────── + Redesigned compare pane: rich header (title + sync toggle + step + timeline + legend), and a delta panel below the map that compares + planned-vs-actual per focused step or rolled up across the day. +─────────────────────────────────────────────────────────────────── */ + +.testing-container .compare-header-v2 { + padding: 12px 14px 10px; + border-bottom: 1px solid var(--border, rgba(15, 23, 42, 0.08)); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.testing-container .compare-header-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.testing-container .compare-header-row .compare-title { + flex: 1; + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 800; + color: #0f172a; + min-width: 0; +} + +.testing-container .compare-header-tools { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.testing-container .compare-overall-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid rgba(99, 102, 241, 0.28); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(59, 130, 246, 0.08)); + color: #4338ca; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.18s ease; +} + +.testing-container .compare-overall-btn:hover { + background: linear-gradient(135deg, #6366f1, #3b82f6); + border-color: #6366f1; + color: #fff; + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.28); + transform: translateY(-1px); +} + +.testing-container .compare-overall-btn svg { + font-size: 15px; +} + +.testing-container .compare-sync-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--border, rgba(15, 23, 42, 0.12)); + background: #fff; + color: #64748b; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: all 0.18s ease; +} + +.testing-container .compare-sync-toggle:hover { + border-color: rgba(99, 102, 241, 0.4); + color: #4338ca; +} + +.testing-container .compare-sync-toggle.is-on { + background: linear-gradient(135deg, #22c55e, #16a34a); + border-color: #16a34a; + color: #fff; + box-shadow: 0 4px 10px rgba(34, 197, 94, 0.22); +} + +.testing-container .compare-sync-toggle svg { + font-size: 15px; +} + +/* Step timeline — a horizontally scrollable row of step dots. Each step + is a button so it's keyboard-focusable + screen-reader friendly. */ +.testing-container .compare-timeline-wrap { + display: flex; + flex-direction: column; + gap: 6px; +} + +.testing-container .compare-timeline { + display: flex; + align-items: center; + gap: 0; + padding: 2px 2px 4px; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: rgba(100, 116, 139, 0.25) transparent; +} + +.testing-container .compare-timeline::-webkit-scrollbar { height: 4px; } +.testing-container .compare-timeline::-webkit-scrollbar-thumb { + background: rgba(100, 116, 139, 0.25); + border-radius: 999px; +} + +.testing-container .compare-step-spacer { + width: 16px; + height: 2px; + background: linear-gradient(90deg, rgba(148, 163, 184, 0), rgba(148, 163, 184, 0.55) 30%, rgba(148, 163, 184, 0.55) 70%, rgba(148, 163, 184, 0)); + flex-shrink: 0; + /* Pushes the spacer line up so it visually centers on the circle row + (the step is a column with circle on top + tick below; align-items: + center on the parent would otherwise center the dash between them). + Tuned for circle 32px + gap 11 + tick ~13. */ + margin-bottom: 24px; +} + +.testing-container .compare-step { + display: inline-flex; + flex-direction: column; + align-items: center; + /* gap was 3px — when the focused step's outer ring rendered, it covered + the time label below the circle. 11px gives the ring (~4-5px outside + the scaled circle) breathing room with a couple of pixels to spare. */ + gap: 11px; + padding: 2px 2px 0; + background: transparent; + border: 0; + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: transform 0.18s ease; +} + +.testing-container .compare-step:hover { + transform: translateY(-1px); +} + +.testing-container .compare-step-circle { + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--step-color, #94a3b8); + color: #fff; + font-size: 13px; + font-weight: 800; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(255, 255, 255, 0.6); + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; +} + +.testing-container .compare-step:hover .compare-step-circle { + transform: scale(1.08); + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.6); +} + +/* Focused step — bumps scale + adds a tight glow ring tinted with the step's + own color so it visually matches the polyline / drop pin on the map. The + glow is intentionally contained (low blur + low spread) so the tick label + below stays readable; a wider halo would bleed through the time text. */ +.testing-container .compare-step.is-focused .compare-step-circle { + transform: scale(1.18); + box-shadow: + 0 4px 10px rgba(15, 23, 42, 0.22), + 0 0 0 2px #fff, + 0 0 0 4px var(--step-color, #6366f1); +} + +/* Step number stays crisp on the focused circle */ +.testing-container .compare-step.is-focused .compare-step-tick { + color: var(--step-color, #4338ca); + font-weight: 800; +} + +.testing-container .compare-step.is-pending .compare-step-circle { + background: #fff; + border: 2px solid var(--step-color, #cbd5e1); + color: var(--step-color, #94a3b8); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} + +.testing-container .compare-step.is-skipped .compare-step-circle { + opacity: 0.42; + background: #cbd5e1; +} + +.testing-container .compare-step.is-loading .compare-step-circle { + background: #fff; + border: 2px solid rgba(99, 102, 241, 0.45); + color: transparent; +} + +.testing-container .compare-step.is-no-data .compare-step-circle { + background: repeating-linear-gradient( + 45deg, + #e2e8f0 0 4px, + #f1f5f9 4px 8px + ); + color: #94a3b8; +} + +.testing-container .compare-step-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid rgba(99, 102, 241, 0.18); + border-top-color: #6366f1; + animation: compare-step-spin 0.7s linear infinite; +} + +@keyframes compare-step-spin { + to { transform: rotate(360deg); } +} + +.testing-container .compare-step-tick { + font-size: 11px; + font-weight: 700; + color: #64748b; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; + line-height: 1; +} + +/* (focused-tick styling consolidated above with the step color) */ + +.testing-container .compare-step-flag { + position: absolute; + top: -2px; + right: -2px; + width: 8px; + height: 8px; + border-radius: 50%; + background: #dc2626; + border: 1.5px solid #fff; + box-shadow: 0 2px 4px rgba(220, 38, 38, 0.45); +} + +/* Progress strip — sits under the timeline, reuses the existing progress + bar children styles. Same role as the old compare-progress block. */ +.testing-container .compare-progress-strip { + display: flex; + align-items: center; + gap: 8px; +} + +/* Legend strip — horizontal row of swatches identifying line styles. */ +.testing-container .compare-legend { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding-top: 2px; + border-top: 1px dashed rgba(15, 23, 42, 0.06); +} + +.testing-container .compare-legend-item { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 700; + color: #64748b; + letter-spacing: 0.02em; +} + +.testing-container .compare-legend-swatch { + width: 22px; + height: 4px; + border-radius: 2px; + flex-shrink: 0; +} + +.testing-container .compare-legend-swatch.is-planned { + background: repeating-linear-gradient( + 90deg, + #6366f1 0 5px, + transparent 5px 9px + ); + height: 3px; +} + +.testing-container .compare-legend-swatch.is-actual { + background: linear-gradient(90deg, currentColor, currentColor); + height: 4px; +} + +/* Solid step-color swatch used by both "Planned (left)" and "Actual GPS + (right)" entries — they now share the same per-step palette so the same + color appears on both maps for the same delivery. */ +.testing-container .compare-legend-swatch.is-step-color { + height: 4px; + border-radius: 2px; +} + +/* Small status note in the legend strip — replaces the now-removed + "Transit (no GPS)" item with a one-liner telling the operator how the + actual-GPS polyline gets built (Kalman smooth + OSRM road-snap). */ +.testing-container .compare-legend-note { + margin-left: auto; + font-size: 11px; + font-weight: 700; + color: #94a3b8; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .compare-legend-swatch.is-transit { + background: repeating-linear-gradient( + 90deg, + #94a3b8 0 3px, + transparent 3px 6px + ); + height: 2px; +} + +/* Delta panel — sits below the actual-GPS map. Per-step view when a step + is focused, day-summary view otherwise. */ +.testing-container .compare-delta { + padding: 12px 14px 14px; + border-top: 1px solid var(--border, rgba(15, 23, 42, 0.07)); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + animation: compare-delta-in 0.22s cubic-bezier(0.4, 0, 0.2, 1); +} + +.testing-container .compare-delta.is-anomaly { + background: linear-gradient(180deg, #fff 0%, #fef2f2 100%); + border-top-color: rgba(220, 38, 38, 0.25); +} + +@keyframes compare-delta-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.testing-container .compare-delta-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.testing-container .compare-delta-step-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: #6366f1; + color: #fff; + font-size: 14px; + font-weight: 800; + flex-shrink: 0; + box-shadow: 0 3px 10px rgba(15, 23, 42, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.5); +} + +.testing-container .compare-delta-step-badge svg { + font-size: 17px; +} + +.testing-container .compare-delta-title-text { + flex: 1; + min-width: 0; +} + +.testing-container .compare-delta-title-main { + font-size: 16px; + font-weight: 800; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.25; +} + +.testing-container .compare-delta-title-sub { + font-size: 13px; + font-weight: 600; + color: #94a3b8; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .compare-delta-status { + display: inline-flex; + align-items: center; + padding: 4px 11px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + flex-shrink: 0; +} + +.testing-container .compare-delta-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.testing-container .compare-delta-cell { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px 13px 10px; + border-radius: 12px; + background: #fff; + border: 1px solid var(--border, rgba(15, 23, 42, 0.08)); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.testing-container .compare-delta-cell:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06); +} + +.testing-container .compare-delta-cell.is-anomaly { + border-color: rgba(220, 38, 38, 0.42); + background: linear-gradient(180deg, #fff, #fef2f2); + box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.18) inset; +} + +.testing-container .compare-delta-cell-label { + font-size: 11px; + font-weight: 800; + color: #94a3b8; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.testing-container .compare-delta-cell-val { + font-size: 22px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + line-height: 1.15; +} + +.testing-container .compare-delta-cell-val.is-over { color: #dc2626; } +.testing-container .compare-delta-cell-val.is-under { color: #16a34a; } + +.testing-container .compare-delta-cell-unit { + font-size: 13px; + font-weight: 700; + color: #94a3b8; + margin-left: 2px; +} + +.testing-container .compare-delta-cell-sub { + font-size: 12px; + font-weight: 600; + color: #64748b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Numbered drop pin on the Compare map — one per delivery, planted at the + delivery's last GPS ping (the drop). Carries the step number and, when + delivered, a green check overlay. The .is-focused variant pulses + scales + so the operator can spot the currently scrutinized step at a glance. */ +.testing-container .compare-step-pin { + position: relative; + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--pin-color, #475569); + color: #fff; + border: 3px solid #fff; + font-size: 13px; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.32), + 0 0 0 1px rgba(255, 255, 255, 0.18); + cursor: pointer; + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.2s ease; +} + +.testing-container .compare-step-pin:hover { + transform: scale(1.08); + z-index: 1200; +} + +.testing-container .compare-step-pin-num { + position: relative; + z-index: 1; + line-height: 1; +} + +.testing-container .compare-step-pin.is-skipped { + opacity: 0.45; + filter: grayscale(0.6); +} + +.testing-container .compare-step-pin.is-focused { + transform: scale(1.22); + z-index: 1300; + box-shadow: + 0 8px 22px rgba(15, 23, 42, 0.38), + 0 0 0 3px #ffffff, + 0 0 0 5px var(--pin-color, #6366f1), + 0 0 18px 6px color-mix(in srgb, var(--pin-color, #6366f1) 35%, transparent); + animation: compare-pin-pulse 1.6s ease-in-out infinite; +} + +.testing-container .compare-step-pin.is-focused:hover { + transform: scale(1.3); +} + +/* Pulse halo for the focused step's drop pin. Uses a separate pseudo so the + pin itself can scale on hover without distorting the halo. */ +.testing-container .compare-step-pin.is-focused::before { + content: ''; + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid var(--pin-color, #6366f1); + opacity: 0.65; + animation: compare-pin-halo 1.6s ease-out infinite; + pointer-events: none; +} + +@keyframes compare-pin-pulse { + 0%, 100% { + box-shadow: + 0 8px 22px rgba(15, 23, 42, 0.38), + 0 0 0 3px #ffffff, + 0 0 0 5px var(--pin-color, #6366f1), + 0 0 18px 6px color-mix(in srgb, var(--pin-color, #6366f1) 35%, transparent); + } + 50% { + box-shadow: + 0 10px 28px rgba(15, 23, 42, 0.5), + 0 0 0 3px #ffffff, + 0 0 0 6px var(--pin-color, #6366f1), + 0 0 28px 10px color-mix(in srgb, var(--pin-color, #6366f1) 55%, transparent); + } +} + +@keyframes compare-pin-halo { + 0% { inset: -2px; opacity: 0.7; } + 100% { inset: -16px; opacity: 0; } +} + +/* Anomaly ring — replaces the colored outer ring with a red one when the + step is flagged (route deviation > 25% or arrival > 15 min late). */ +.testing-container .compare-step-pin.is-anomaly { + box-shadow: + 0 4px 14px rgba(220, 38, 38, 0.35), + 0 0 0 1px rgba(255, 255, 255, 0.18), + 0 0 0 3px #ffffff, + 0 0 0 5px #dc2626; +} + +.testing-container .compare-step-pin.is-anomaly.is-focused { + box-shadow: + 0 8px 22px rgba(220, 38, 38, 0.5), + 0 0 0 3px #ffffff, + 0 0 0 5px #dc2626, + 0 0 22px 8px rgba(220, 38, 38, 0.45); + animation: compare-pin-pulse-anomaly 1.4s ease-in-out infinite; +} + +@keyframes compare-pin-pulse-anomaly { + 0%, 100% { + box-shadow: + 0 8px 22px rgba(220, 38, 38, 0.5), + 0 0 0 3px #ffffff, + 0 0 0 5px #dc2626, + 0 0 22px 8px rgba(220, 38, 38, 0.45); + } + 50% { + box-shadow: + 0 10px 28px rgba(220, 38, 38, 0.65), + 0 0 0 3px #ffffff, + 0 0 0 6px #dc2626, + 0 0 32px 12px rgba(220, 38, 38, 0.55); + } +} + +/* Delivered checkmark — small green badge in the lower-right corner of the + drop pin. Reads as "this drop completed" without needing the status tag + that lives in the timeline + delta panel. */ +.testing-container .compare-step-pin-check { + position: absolute; + bottom: -3px; + right: -3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #16a34a; + border: 1.5px solid #fff; + padding: 1px; + box-shadow: 0 2px 5px rgba(15, 23, 42, 0.34); + z-index: 2; +} + +/* Pickup pin — sits at the rider's day origin (where the order was picked + up). Glyph is a shopping-bag / takeout-bag in the rider's color, so the + meaning reads at a glance as "this is the pickup point". Sized larger + than the numbered drop pins (40 vs 34) so the marker reads prominently + even at deep map zooms — Leaflet's divIcons hold a fixed pixel size at + every zoom level, so a too-small pin visually shrinks against the + surrounding street/building detail as you zoom in. The bigger fixed + pixel size keeps it readable from city-level (z12) down to street-level + (z18+). Only rendered for sequenceStep === 1; subsequent steps don't + get a start marker since their origin is the previous step's drop. */ +.testing-container .compare-start-pin { + width: 40px; + height: 40px; + border-radius: 50%; + background: #ffffff; + color: var(--pin-color, #475569); + border: 3px solid var(--pin-color, #475569); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 5px 14px rgba(15, 23, 42, 0.32), + 0 0 0 1px rgba(255, 255, 255, 0.6); + transition: transform 0.18s ease, box-shadow 0.18s ease; + cursor: pointer; +} + +.testing-container .compare-start-pin:hover { + transform: scale(1.1); + z-index: 1100; + box-shadow: 0 7px 20px rgba(15, 23, 42, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.7); +} + +.testing-container .compare-start-pin svg { + width: 22px; + height: 22px; + display: block; +} + +/* Lightweight tooltip shown on marker hover. Replaces the older heavy popup + (which clipped and forced an auto-pan). Just a teaser; persistent details + live in the delta panel below the map. */ +.testing-container .compare-tooltip { + background: rgba(15, 23, 42, 0.95); + color: #f8fafc; + border: 0; + border-radius: 12px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.06); + padding: 0; + white-space: normal; + font-family: inherit; + backdrop-filter: blur(6px); +} + +/* Leaflet draws a triangular tip pointing at the marker via a CSS border on + ::before. Re-tint it to match the dark tooltip body. */ +.testing-container .compare-tooltip.leaflet-tooltip-top::before { + border-top-color: rgba(15, 23, 42, 0.95); +} +.testing-container .compare-tooltip.leaflet-tooltip-bottom::before { + border-bottom-color: rgba(15, 23, 42, 0.95); +} +.testing-container .compare-tooltip.leaflet-tooltip-left::before { + border-left-color: rgba(15, 23, 42, 0.95); +} +.testing-container .compare-tooltip.leaflet-tooltip-right::before { + border-right-color: rgba(15, 23, 42, 0.95); +} + +.testing-container .cmp-tip { + padding: 9px 12px 8px; + min-width: 200px; + max-width: 260px; +} + +.testing-container .cmp-tip-header { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.testing-container .cmp-tip-step { + width: 24px; + height: 24px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: #6366f1; + color: #fff; + font-size: 11px; + font-weight: 800; + flex-shrink: 0; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.testing-container .cmp-tip-step svg { + font-size: 14px; +} + +.testing-container .cmp-tip-title-stack { + flex: 1; + min-width: 0; +} + +.testing-container .cmp-tip-title { + font-size: 12px; + font-weight: 800; + color: #f8fafc; + letter-spacing: 0.01em; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .cmp-tip-sub { + font-size: 10px; + font-weight: 600; + color: #cbd5e1; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .cmp-tip-tag { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + flex-shrink: 0; +} + +.testing-container .cmp-tip-anomaly { + margin-top: 7px; + padding: 5px 8px; + border-radius: 8px; + background: rgba(220, 38, 38, 0.16); + border: 1px solid rgba(220, 38, 38, 0.32); + color: #fecaca; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; +} + +.testing-container .cmp-tip-action { + margin-top: 7px; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + font-size: 9px; + font-weight: 700; + color: #94a3b8; + letter-spacing: 0.08em; + text-transform: uppercase; + text-align: center; +} + +/* Compare-map popup — styled card with header (step pin + title + status + tag) and a key/value table of order details. Leaflet's default popup is + a plain white tooltip; this overrides the wrapper/tip so the popup + matches the rest of the dispatch UI. */ +.testing-container .compare-popup .leaflet-popup-content-wrapper { + padding: 0; + border-radius: 14px; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(15, 23, 42, 0.06); + overflow: hidden; + background: #fff; +} + +.testing-container .compare-popup .leaflet-popup-content { + margin: 0; + width: auto !important; + min-width: 240px; +} + +.testing-container .compare-popup .leaflet-popup-tip { + background: #fff; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.18); +} + +.testing-container .compare-popup .leaflet-popup-close-button { + top: 6px; + right: 6px; + color: #94a3b8; + font-size: 18px; + font-weight: 700; + padding: 4px 6px; +} + +.testing-container .compare-popup .leaflet-popup-close-button:hover { + color: #0f172a; +} + +.testing-container .cmp-pop { + font-family: 'Inter', -apple-system, sans-serif; + color: #0f172a; + line-height: 1.35; +} + +.testing-container .cmp-pop-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 32px 12px 14px; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); + border-bottom: 1px solid rgba(15, 23, 42, 0.06); +} + +.testing-container .cmp-pop-pin { + width: 30px; + height: 30px; + border-radius: 50%; + color: #fff; + font-weight: 800; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + border: 2.5px solid #fff; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); + flex-shrink: 0; +} + +.testing-container .cmp-pop-titles { + flex: 1; + min-width: 0; +} + +.testing-container .cmp-pop-title { + font-size: 13px; + font-weight: 800; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.testing-container .cmp-pop-sub { + font-size: 9.5px; + font-weight: 700; + color: #64748b; + letter-spacing: 0.05em; + text-transform: uppercase; + margin-top: 2px; +} + +.testing-container .cmp-pop-tag { + padding: 3px 8px; + border-radius: 999px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + background: #e0e7ff; + color: #4338ca; + flex-shrink: 0; + white-space: nowrap; +} + +.testing-container .cmp-pop-tag-start { + background: #ecfeff; + color: #0e7490; +} + +.testing-container .cmp-pop-rows { + padding: 8px 14px 12px; + display: flex; + flex-direction: column; +} + +.testing-container .cmp-pop-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + font-size: 12px; + padding: 6px 0; + border-bottom: 1px dashed rgba(15, 23, 42, 0.07); +} + +.testing-container .cmp-pop-row:last-child { + border-bottom: 0; +} + +.testing-container .cmp-pop-k { + color: #64748b; + font-weight: 600; + white-space: nowrap; +} + +.testing-container .cmp-pop-v { + color: #0f172a; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.testing-container .cmp-pop-v.is-loss { + color: #dc2626; +} + +.testing-container .cmp-pop-v.is-profit { + color: #16a34a; +} + +.testing-container .cmp-pop-coord { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + font-weight: 600; + color: #475569; } .testing-container .leaflet-container { @@ -3228,6 +4650,13 @@ @media (max-width: 1280px) { .testing-container #sidebar { width: 360px; + flex-basis: 360px; + } + .testing-container .sidebar-toggle-tab { + left: 360px; + } + .testing-container .sidebar-toggle-tab.is-collapsed { + left: 0; } } @@ -3235,6 +4664,13 @@ @media (max-width: 1180px) { .testing-container #sidebar { width: 320px; + flex-basis: 320px; + } + .testing-container .sidebar-toggle-tab { + left: 320px; + } + .testing-container .sidebar-toggle-tab.is-collapsed { + left: 0; } .testing-container .rd-rider-name { font-size: 24px; @@ -3266,6 +4702,13 @@ @media (max-width: 1080px) { .testing-container #sidebar { width: 290px; + flex-basis: 290px; + } + .testing-container .sidebar-toggle-tab { + left: 290px; + } + .testing-container .sidebar-toggle-tab.is-collapsed { + left: 0; } /* Header — hide decorative city pill, tighten spacing */ @@ -3382,6 +4825,13 @@ @media (max-width: 960px) { .testing-container #sidebar { width: 250px; + flex-basis: 250px; + } + .testing-container .sidebar-toggle-tab { + left: 250px; + } + .testing-container .sidebar-toggle-tab.is-collapsed { + left: 0; } /* Make strat-row horizontally scrollable if buttons overflow */ diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js index 880bbcc..d46b445 100644 --- a/src/pages/nearle/dispatch/Dispatch.js +++ b/src/pages/nearle/dispatch/Dispatch.js @@ -1,9 +1,10 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, ZoomControl } from 'react-leaflet'; +import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, useMapEvents, ZoomControl } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import dayjs from 'dayjs'; -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query'; +import axios from 'axios'; import { MdMap, MdDirectionsBike, @@ -31,7 +32,10 @@ import { MdAccessTime, MdGpsFixed, MdPower, - MdSearch + MdSearch, + MdChevronLeft, + MdChevronRight, + MdLocalMall } from 'react-icons/md'; import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs } from '../../api/api'; import './Dispatch.css'; @@ -167,6 +171,271 @@ const getRowBatch = (r, fieldId = 'delivery', batches = BATCHES_DEFAULT) => { return getBatchForHour(d.hour(), batches); }; +// Sits inside the Compare MapContainer and unpins any pinned popup whenever +// the operator clicks empty map space. Markers' click events do NOT bubble +// to the map, so this only fires on background clicks (which is what we +// want — clicking elsewhere should release the pin). +function CompareMapClickUnpin({ onUnpin }) { + useMapEvents({ click: () => onUnpin() }); + return null; +} + +// Helper component used by the Compare map — fits the leaflet view to every +// loaded GPS point ONCE per rider (or to just the focused step's coords when +// a step is being scrutinized). Previously the effect depended directly on +// `tracks`, whose identity changes on every parent render — fitBounds ran on +// every render and snapped the map back mid-drag. Now we fit only when the +// fit *signature* changes (rider's delivery IDs, focused step, load count). +function FitMapToTracks({ tracks, focusStep }) { + const map = useMap(); + const fittedForRef = useRef(''); + const trackIdsKey = useMemo( + () => (tracks || []).map((t) => String(t.deliveryid)).sort().join(','), + [tracks] + ); + const loadedCount = useMemo( + () => (tracks || []).filter((t) => (t.coords || []).length > 0).length, + [tracks] + ); + const fitKey = `${trackIdsKey}|step:${focusStep || 'all'}|loaded:${loadedCount}`; + useEffect(() => { + if (loadedCount === 0) return; + if (fittedForRef.current === fitKey) return; + // When a single step is focused, fit ONLY to that delivery's coords so + // the operator can scrutinize the snapped vs. raw path without the rest + // of the day's bounds zooming them out. + const subset = focusStep + ? (tracks || []).filter((t) => t.sequenceStep === focusStep) + : (tracks || []); + const pts = subset.flatMap((t) => (t.coords || []).map((p) => [p.lat, p.lng])); + if (pts.length === 0) return; + try { + const padding = focusStep ? [60, 60] : [40, 40]; + map.fitBounds(pts, { padding, animate: false, maxZoom: focusStep ? 17 : 16 }); + fittedForRef.current = fitKey; + } catch (e) { /* ignore */ } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, fitKey]); + return null; +} + +// Captures the Leaflet map instance for the parent component via a ref. Used +// by the Compare two-pane sync — the parent needs both map instances so it +// can mirror pans/zooms from one to the other without going through state +// (which would lag a frame and stutter the follower). +function CaptureMap({ targetRef }) { + const map = useMap(); + useEffect(() => { + targetRef.current = map; + return () => { targetRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + return null; +} + +// Listens for pan/zoom on the map this component lives inside, and only +// forwards the new view to the sibling map when the user originated the +// motion. Two gates: (1) sourceRef must equal this side — blocks the +// follower side's own moveend (from being setView'd by us) from echoing +// back; (2) the moveend must arrive within USER_GESTURE_WINDOW_MS of the +// last user input event — blocks programmatic flyTo/setView animations +// (auto-fit, focus-step zoom) from masquerading as gestures. +const USER_GESTURE_WINDOW_MS = 800; +function CompareSyncEmitter({ side, sourceRef, lastInputAtRef, onView, enabled }) { + const stamp = () => { + sourceRef.current = side; + lastInputAtRef.current = Date.now(); + }; + useMapEvents({ + mousedown: stamp, + touchstart: stamp, + wheel: stamp, + // drag/zoom fire continuously during a user drag or pinch — keeps the + // input timestamp fresh so the final moveend lands inside the window. + drag: () => { lastInputAtRef.current = Date.now(); }, + zoom: () => { lastInputAtRef.current = Date.now(); }, + moveend: (e) => { + if (!enabled) return; + if (sourceRef.current !== side) return; + if (Date.now() - (lastInputAtRef.current || 0) > USER_GESTURE_WINDOW_MS) return; + onView(e.target); + }, + zoomend: (e) => { + if (!enabled) return; + if (sourceRef.current !== side) return; + if (Date.now() - (lastInputAtRef.current || 0) > USER_GESTURE_WINDOW_MS) return; + onView(e.target); + } + }); + return 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). +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 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 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). +// +// 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. +// +// 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. +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 }]; + } + + const processNoise = + options.processNoise != null ? options.processNoise : 1e-9; + const measurementNoise = + options.measurementNoise != null ? options.measurementNoise : 1e-7; + + 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]); + + for (let i = 1; i < pings.length; i++) { + const ts = tsOf(pings[i]) || prevTs + 1000; + const dt = Math.max(0.1, (ts - prevTs) / 1000); + prevTs = ts; + + // ─── Predict ─── + // x' = F x where F = [[1, dt], [0, 1]] + const xPred = x + v * dt; + const vPred = v; + // P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]] + 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; + + // ─── Update ─── + // y = z − Hx' (innovation, measurement vs prediction) + const z = pings[i][axisKey]; + const y = z - xPred; + // S = H P' H^T + R (innovation covariance) + const S = np00 + measurementNoise; + // K = P' H^T / S (Kalman gain) + const K0 = np00 / S; + const K1 = np10 / S; + // x = x' + K y + x = xPred + K0 * y; + v = vPred + 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); + } + return out; + }; + + const lats = smoothAxis('lat'); + const lngs = smoothAxis('lng'); + return pings.map((p, i) => ({ + lat: lats[i], + lng: lngs[i], + logdate: p.logdate, + _ts: p._ts + })); +} + +// Splits a routed OSRM polyline into per-step segments by finding the +// polyline index closest to each drop waypoint. Returns an array of +// segments where segments[i] runs from the previous waypoint (or polyline +// start when i===0) to drops[i]. Adjacent segments share their endpoint +// so the rendered polylines visually touch with no gap. +// +// Used by renderRoutes in Compare mode to recolor the planned-route +// polyline per step, so the left map's step colors match the right map's +// per-step palette + the timeline strip. Without per-segment splitting +// the planned polyline would be a single rider-colored line that doesn't +// visually link to step N's actual GPS polyline. +function splitPolylineByDrops(polyline, drops) { + if (!Array.isArray(polyline) || polyline.length < 2 || !drops || !drops.length) { + return []; + } + const findClosestIndex = (target) => { + let best = 0; + let bestD = Infinity; + for (let i = 0; i < polyline.length; i++) { + const dy = polyline[i][0] - target[0]; + const dx = polyline[i][1] - target[1]; + const d = dy * dy + dx * dx; + if (d < bestD) { bestD = d; best = i; } + } + return best; + }; + const cutIndices = drops.map(findClosestIndex); + // Force monotonically non-decreasing — in pathological route shapes the + // raw "closest" indices can briefly regress (OSRM doubles back near a + // waypoint), which would produce empty/negative slices below. + for (let i = 1; i < cutIndices.length; i++) { + if (cutIndices[i] < cutIndices[i - 1]) cutIndices[i] = cutIndices[i - 1]; + } + const segments = []; + let prev = 0; + cutIndices.forEach((idx) => { + const end = Math.max(idx, prev); + segments.push(polyline.slice(prev, end + 1)); + prev = end; + }); + return segments; +} + const FINAL_STATUSES = new Set(['delivered']); const SKIPPED_STATUSES = new Set(['cancelled', 'skipped']); @@ -302,43 +571,148 @@ L.Icon.Default.mergeOptions({ const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000']; -const MapController = ({ focusedItem, viewMode, orders, kitchens }) => { +// Per-step palette used by the Compare map (polylines, timeline dots, drop +// pins, delta panel). Wider and more deliberately spaced around the hue +// wheel than RIDER_COLORS so a 10-delivery rider day reads as 10 visibly +// different lines — operators don't have to second-guess which polyline +// is step 7 vs step 2. Tuned darker than RIDER_COLORS so the lines stay +// legible on the light OSM tile background. +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 +]; +const stepColor = (i) => STEP_PALETTE[((i % STEP_PALETTE.length) + STEP_PALETTE.length) % STEP_PALETTE.length]; + +const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => { const map = useMap(); + // Last fit signature. We only call fitBounds when this changes — otherwise + // every parent render (data refetch, sidebar tick, etc.) would refit the + // map and snap it back mid-drag, which felt like the map was un-draggable. + const lastFitKeyRef = useRef(''); + + // Stable identifier for the current focus target. Uses ids where possible + // so it doesn't churn when the underlying objects get rebuilt by useMemo. + // `locationKey` is included everywhere so switching hubs (Coimbatore → + // Nagercoil etc.) always triggers a refit onto the new region, even when + // the new hub happens to have the same kitchen/order count. + // + // We also append a coarse centroid (lat/lon rounded to 1 decimal) of the + // current dataset. This is what fixes the Nagercoil case: when only the + // hub id changes but the kitchen/order array is briefly the previous + // hub's stale data (or empty during refetch), the fit only happens once + // truly-new data arrives — at which point the centroid signature flips + // (e.g. 11.0,77.0 → 8.2,77.4) and we refit to the new region. + const fitKey = useMemo(() => { + const loc = locationKey != null ? `loc:${locationKey}|` : ''; + const centroidSig = (pairs) => { + let lat = 0; + let lon = 0; + let n = 0; + for (const p of pairs) { + if (Number.isFinite(p[0]) && Number.isFinite(p[1])) { + lat += p[0]; + lon += p[1]; + n += 1; + } + } + return n === 0 ? '0' : `${(lat / n).toFixed(1)},${(lon / n).toFixed(1)}`; + }; + if (focusedItem) { + const id = + focusedItem.id ?? + focusedItem.kitchenName ?? + focusedItem.name ?? + (focusedItem.lat != null ? `${focusedItem.lat},${focusedItem.lon}` : 'item'); + const n = focusedItem.orders ? focusedItem.orders.length : 0; + return `${loc}f|${id}|${n}`; + } + const kPairs = (kitchens || []).map((k) => [k.lat, k.lon]); + const kSig = centroidSig(kPairs); + if (viewMode === 'kitchens') { + const n = kPairs.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])).length; + return `${loc}k|${n}|${kSig}`; + } + if (viewMode === 'all') { + const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`; + } + return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`; + }, [focusedItem, viewMode, orders, kitchens, locationKey]); useEffect(() => { + if (lastFitKeyRef.current === fitKey) return; + let pts = []; if (focusedItem) { if (focusedItem.orders) { - pts = focusedItem.orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); - focusedItem.orders.forEach(o => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))])); + pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))])); } else { pts = [[focusedItem.lat, focusedItem.lon]]; } } else if (viewMode === 'kitchens') { - // Fit to all kitchen pickup positions so the user sees them when switching to By Location pts = (kitchens || []) - .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) - .map(k => [k.lat, k.lon]); - // Fall back to delivery drops if no valid kitchen coords are available + .filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon)) + .map((k) => [k.lat, k.lon]); if (pts.length === 0) { - pts = orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); } } else if (viewMode === 'all') { - pts = orders.map(o => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + } else { + // No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the + // current hub's footprint so switching from Coimbatore → Nagercoil + // (or any hub change) recenters the map onto the new region instead + // of leaving it on the previous city. + pts = (kitchens || []) + .filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon)) + .map((k) => [k.lat, k.lon]); + if (pts.length === 0) { + pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]); + } } - if (pts.length > 0) { - const filtered = pts.filter(p => !isNaN(p[0]) && !isNaN(p[1])); - if (filtered.length > 0) { - const bounds = L.latLngBounds(filtered); - if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [50, 50], animate: true }); + const filtered = pts.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])); + if (filtered.length > 0) { + const bounds = L.latLngBounds(filtered); + if (bounds.isValid()) { + // Zoom into a single-point bounds (focused rider with one drop, or + // a single kitchen) instead of using maxZoom defaults which would + // leave the map zoomed out. + const isSinglePoint = filtered.length === 1 || bounds.getNorthEast().equals(bounds.getSouthWest()); + if (isSinglePoint) { + map.setView(filtered[0], 15, { animate: true, duration: 0.6 }); + } else { + map.flyToBounds(bounds, { padding: [60, 60], duration: 0.6, maxZoom: 16 }); } + lastFitKeyRef.current = fitKey; } - } else { - map.setView([11.022, 76.982], 12, { animate: true }); + return; } - }, [focusedItem, viewMode, orders, kitchens, map]); + // No data to fit to yet — hub switch is mid-flight, or this hub has no + // orders today. Do NOT lock the fit key: when real hub data arrives the + // fitKey will change (its centroid signature flips, e.g. 11.0,77.0 → + // 8.2,77.4) and this effect will re-run with proper coordinates. + // + // Also do NOT call setView([11.022, 76.982], 12) here — that was the + // bug that left Nagercoil (and every non-Coimbatore hub) stuck on the + // Coimbatore default during the brief window between picking the hub + // and its data arriving. + }, [fitKey, focusedItem, viewMode, orders, kitchens, map]); return null; }; @@ -571,10 +945,70 @@ const Dispatch = ({ // being listed in useCallback deps (which caused a render-loop: fetch → state // update → new fetchRoute → effect re-runs → repeat). const osrmRoutesRef = useRef({}); + // Road-snapped polylines for each delivery's actual GPS trace (Compare map). + // Keyed by deliveryid. Same null/false/array semantics as osrmRoutes. + const [osrmTrackRoutes, setOsrmTrackRoutes] = useState({}); + const osrmTrackRoutesRef = useRef({}); const [isAnimating, setIsAnimating] = useState(false); const [animatedSegments, setAnimatedSegments] = useState([]); + // Per-step polyline progress for the Compare actual-GPS map. Keyed by + // sequenceStep, value is the index up to which the step's polyline should + // be rendered (positions.slice(0, value)). Lets each step's polyline DRAW + // progressively across the map — matches the planned animation's + // pair-by-pair drawing style instead of the older "whole step pops in" + // behavior. Cleared when Animate stops. + const [animatedActualProgress, setAnimatedActualProgress] = useState({}); + // Mirror of isAnimating accessible inside the rAF loop. The loop needs to + // bail mid-animation if the user clicks Stop, but it can't read isAnimating + // directly (closure captures the value at scheduling time). + const isAnimatingRef = useRef(false); const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); + // Compare mode — when ON, the body splits 50/50: left half is the regular + // dispatch map (planned routes), right half is a second map that overlays + // the focused rider's ACTUAL GPS tracks per delivery, pulled from the same + // /deliveries/getdeliverylogs/?deliveryid=X endpoint the Order Details map + // uses. Lets the operator visually verify whether the rider stuck to the + // dispatched plan. Disabled when no rider is focused (the comparison is + // per-rider). Kept as a single state flag so we don't entangle it with + // viewMode/focused* logic. + const [compareOpen, setCompareOpen] = useState(false); + // In controlled mode the parent owns selectedRiderId, so handleRiderFocus only + // fires onRiderSelect — focusedRider won't update until the parent re-renders. + // This ref lets us defer setCompareOpen(true) until focusedRider is confirmed. + const pendingCompareRef = useRef(false); + // (Compare popup-pin state removed — the marker tooltips now open on hover + // automatically and click drives focusedCompareStep, so there's nothing left + // to "pin". See the timeline + delta panel for the persistent detail surface.) + + // Sidebar collapse — hides the 400px sidebar (slides to width 0) so the + // two maps in Compare mode can use the full body width. Auto-collapses when + // Compare opens and restores the prior state when Compare closes; a peek + // tab on the left edge of the body lets the operator override at any time. + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const preCompareCollapsedRef = useRef(false); + const prevCompareOpenRef = useRef(false); + + // Compare UI v2 — step-focus + map sync. + // focusedCompareStep: null = "overall" (whole day); 1..N = drill into that + // single delivery. Both maps zoom to that step's bounds and the delta + // panel switches from day-summary to per-step planned-vs-actual. + // compareSyncEnabled: when true, panning/zooming either map mirrors the + // view to the other. Toggleable because operators sometimes want to + // park one view and explore the other independently. + // leftMapRef / rightMapRef: Leaflet map instances captured via the + // CaptureMap helper component so the parent can call setView on the + // opposite map directly (no state round-trip = no frame lag). + // syncSourceRef: 'left' | 'right' | null — which side the user is + // currently driving. The follower side ignores moveend events while + // the source side fires them, breaking the ping-pong loop. + const [focusedCompareStep, setFocusedCompareStep] = useState(null); + const [compareSyncEnabled, setCompareSyncEnabled] = useState(true); + const leftMapRef = useRef(null); + const rightMapRef = useRef(null); + const syncSourceRef = useRef(null); + const syncLastInputAtRef = useRef(0); + // Pull the partners/getriderlogs feed for the currently selected hub + date. // This endpoint returns the exact live GPS position for every rider at the // hub (latitude/longitude/logdate/status). We render those positions as @@ -942,6 +1376,260 @@ const Dispatch = ({ // Live rider positions (Rapido-style bikes on the map) const riderPositions = useMemo(() => riders.map(computeRiderPosition).filter(Boolean), [riders]); + // List of deliveryids tied to the focused rider's orders — used to drive the + // batched per-delivery GPS log fetch for Compare mode. Deduped; ignores rows + // without a deliveryid (e.g. unaccepted orders) since /getdeliverylogs needs + // a real id. + const focusedRiderDeliveryIds = useMemo(() => { + if (!focusedRider) return []; + const set = new Set(); + focusedRider.orders.forEach((o) => { + if (o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0) set.add(String(o.deliveryid)); + }); + return Array.from(set); + }, [focusedRider]); + + // Fan-out per-delivery GPS log queries in parallel. Each result is cached by + // deliveryid, so toggling Compare off/on for the same rider is instant. + // We only enable the queries while Compare is open AND a rider is focused — + // otherwise the fetches would fire on idle dispatch viewing and waste + // bandwidth. + const deliveryLogQueries = useQueries({ + queries: focusedRiderDeliveryIds.map((deliveryid) => ({ + queryKey: ['deliveryLogs', deliveryid], + queryFn: async () => { + const res = await axios.get( + `${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${deliveryid}` + ); + // Accept several possible response shapes — the live API has shipped + // {details:[…]}, plain arrays, and {data:[…]} variants over time, and + // any of those should produce a polyline. Pick the first non-empty + // array-like found. + const candidates = [res?.data?.details, res?.data?.data, res?.data, res]; + const rows = candidates.find((c) => Array.isArray(c)) || []; + // Also accept lat/lng (alternate naming) in case the endpoint ever + // returns the same shape the front-end uses internally. + // Sort by logdate ascending so the polyline follows the rider's + // chronological path. The endpoint isn't guaranteed to return rows + // in order — without this, consecutive points can be out of sequence + // and the rendered track zig-zags between them. + const sorted = rows + .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); + // Kalman pass: smooths raw GPS jitter (multi-path noise, stationary + // wobble at the drop, occasional cold-start outliers) before the + // pings ever reach the renderer or OSRM. The filter needs the + // _ts on each ping to compute per-step dt, so we run it BEFORE + // stripping _ts on the way out. + const smoothed = kalmanSmoothGps(sorted); + return smoothed.map(({ _ts, ...p }) => p); + }, + enabled: compareOpen && focusedRider != null, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1 + })) + }); + + // Normalize the parallel query results into a flat array of per-delivery + // tracks for the Compare map. Tracks are sorted by `deliverytime` (with + // `expecteddeliverytime` and original step number as fallbacks) so the + // sequence on the map mirrors the order they were actually completed. + // A 1-indexed `sequenceStep` is attached to drive the numbered markers. + const riderActualTracks = useMemo(() => { + if (!focusedRider) return []; + const tsOf = (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 sorted = focusedRider.orders + .filter((o) => o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0) + .sort((a, b) => { + const diff = tsOf(a) - tsOf(b); + if (diff !== 0) return diff; + return (a.step || 0) - (b.step || 0); + }); + return sorted.map((o, i) => { + const idx = focusedRiderDeliveryIds.indexOf(String(o.deliveryid)); + const q = idx >= 0 ? deliveryLogQueries[idx] : null; + return { + sequenceStep: i + 1, + orderid: o.orderid, + deliveryid: o.deliveryid, + deliverycustomer: o.deliverycustomer, + pickupcustomer: o.pickupcustomer, + step: o.step, + tripNumber: o.trip_number || 1, + deliverytime: o.deliverytime || o.expecteddeliverytime, + kms: parseFloat(o.actualkms || o.kms || 0) || 0, + profit: parseFloat(o.profit || 0) || 0, + orderstatus: o.orderstatus, + isLoading: q?.isLoading || q?.isFetching, + isError: q?.isError, + coords: q?.data || [] + }; + }); + }, [focusedRider, focusedRiderDeliveryIds, deliveryLogQueries]); + + + // Per-step planned-vs-actual deltas for the Compare delta panel. Pure + // computation off the data we already have: + // plannedKm — order.kms field (set when dispatch planned this trip) + // actualKm — prefer the OSRM-snapped polyline length (most accurate + // once snapping completes); fall back to order.actualkms + // (backend-computed) and finally to the raw GPS polyline + // length so the panel never shows blank while OSRM is + // still in flight. + // timeDeltaMin — actual deliverytime - expecteddeliverytime, in minutes. + // Negative = ahead of plan, positive = late. + // anomaly — true when actualKm > 1.25 * plannedKm OR + // timeDeltaMin > 15 (configurable thresholds; chosen so + // small noise doesn't trip the flag but a wrong-turn does). + const compareDeltas = useMemo(() => { + if (!focusedRider) return []; + return riderActualTracks.map((t) => { + const order = focusedRider.orders.find( + (o) => String(o.deliveryid) === String(t.deliveryid) + ); + const plannedKm = parseFloat(order?.kms || 0) || 0; + const snapped = osrmTrackRoutes[t.deliveryid]; + let actualKm = 0; + if (Array.isArray(snapped) && snapped.length >= 2) { + actualKm = polylineLengthKm(snapped); + } else if (order?.actualkms != null && order.actualkms !== '') { + actualKm = parseFloat(order.actualkms) || 0; + } else if (t.coords.length >= 2) { + actualKm = polylineLengthKm(t.coords.map((p) => [p.lat, p.lng])); + } + const kmDelta = actualKm - plannedKm; + const kmDeltaPct = plannedKm > 0 ? (kmDelta / plannedKm) * 100 : null; + const expectedTs = order?.expecteddeliverytime + ? dayjs(order.expecteddeliverytime) + : null; + const actualTs = order?.deliverytime ? dayjs(order.deliverytime) : null; + const timeDeltaMin = + expectedTs?.isValid() && actualTs?.isValid() + ? actualTs.diff(expectedTs, 'minute') + : null; + const anomaly = + (plannedKm > 0 && actualKm > plannedKm * 1.25) || + (timeDeltaMin != null && timeDeltaMin > 15); + return { + sequenceStep: t.sequenceStep, + deliveryid: t.deliveryid, + orderid: t.orderid, + order, + plannedKm, + actualKm, + kmDelta, + kmDeltaPct, + expectedTs: expectedTs?.isValid() ? expectedTs : null, + actualTs: actualTs?.isValid() ? actualTs : null, + timeDeltaMin, + anomaly, + orderstatus: t.orderstatus, + deliverycustomer: t.deliverycustomer, + pickupcustomer: order?.pickupcustomer, + isLoading: t.isLoading, + coordsCount: t.coords.length + }; + }); + }, [riderActualTracks, focusedRider, osrmTrackRoutes]); + + // Roll-up of the per-step deltas — used by the overall summary card shown + // when no specific step is focused. Excluding loading rows from the + // anomaly count prevents the "47% deviation" headline from churning while + // OSRM is still snapping. + const compareSummary = useMemo(() => { + if (compareDeltas.length === 0) { + return { plannedKm: 0, actualKm: 0, kmDeltaPct: null, anomalies: 0, late: 0, onTime: 0 }; + } + const ready = compareDeltas.filter((d) => !d.isLoading && d.coordsCount > 0); + const plannedKm = ready.reduce((s, d) => s + d.plannedKm, 0); + const actualKm = ready.reduce((s, d) => s + d.actualKm, 0); + const kmDeltaPct = plannedKm > 0 ? ((actualKm - plannedKm) / plannedKm) * 100 : null; + const anomalies = ready.filter((d) => d.anomaly).length; + const late = ready.filter((d) => d.timeDeltaMin != null && d.timeDeltaMin > 5).length; + const onTime = ready.filter((d) => d.timeDeltaMin != null && d.timeDeltaMin <= 5).length; + return { plannedKm, actualKm, kmDeltaPct, anomalies, late, onTime }; + }, [compareDeltas]); + + // When Compare is open and a step is focused, override MapController's + // focusedItem with a synthetic single-order item so the planned (left) + // map zooms onto the same delivery the operator is scrutinizing. Without + // this, only the actual (right) map would zoom and the two views would + // disagree. + const compareFocusItem = useMemo(() => { + if (!compareOpen || !focusedCompareStep || !focusedRider) return null; + const track = riderActualTracks.find((t) => t.sequenceStep === focusedCompareStep); + if (!track) return null; + const order = focusedRider.orders.find( + (o) => String(o.deliveryid) === String(track.deliveryid) + ); + if (!order) return null; + return { orders: [order], id: `cmp-step-${focusedCompareStep}-${order.orderid}` }; + }, [compareOpen, focusedCompareStep, focusedRider, riderActualTracks]); + + // Reset focused step when leaving Compare, switching riders, or re-entering + // Compare. Otherwise "step 5" leaks across riders and the next rider opens + // stuck on a step that may not exist in their day. + useEffect(() => { + setFocusedCompareStep(null); + }, [compareOpen, focusedRider?.id]); + + // Mirror pan/zoom from whichever map the user is driving. Called by + // CompareSyncEmitter on moveend/zoomend; we read center/zoom off the + // source map and apply them to the other instance with animate:false so + // the follower doesn't visibly catch up half a beat later. + const handleCompareMapView = useCallback((side, sourceMap) => { + if (!compareSyncEnabled) return; + const target = side === 'left' ? rightMapRef.current : leftMapRef.current; + if (!target || !sourceMap) return; + try { + target.setView(sourceMap.getCenter(), sourceMap.getZoom(), { animate: false }); + } catch (e) { /* ignore */ } + }, [compareSyncEnabled]); + + // When the focused rider changes, close any open comparison so the operator + // doesn't see stale tracks from a previous rider. + useEffect(() => { + if (!focusedRider && compareOpen) setCompareOpen(false); + }, [focusedRider, compareOpen]); + + // Controlled-mode deferred compare open: once the parent has updated + // selectedRiderId and focusedRider resolves, honour the pending open request. + useEffect(() => { + if (pendingCompareRef.current && focusedRider) { + pendingCompareRef.current = false; + setCompareOpen(true); + } + }, [focusedRider]); + + // Auto-collapse the sidebar when Compare turns on (two maps need the room), + // and restore the operator's prior sidebar state when Compare turns off. + // Manual toggle is still available via the peek tab while compare is open. + useEffect(() => { + if (compareOpen && !prevCompareOpenRef.current) { + preCompareCollapsedRef.current = sidebarCollapsed; + setSidebarCollapsed(true); + } else if (!compareOpen && prevCompareOpenRef.current) { + setSidebarCollapsed(preCompareCollapsedRef.current); + } + prevCompareOpenRef.current = compareOpen; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [compareOpen]); + const fetchRoute = useCallback(async (riderId, tripKey, points) => { const cacheKey = `${riderId}-${tripKey}`; // Use the ref (not state) for the in-flight / already-cached check so this @@ -985,8 +1673,107 @@ const Dispatch = ({ useEffect(() => { osrmRoutesRef.current = {}; setOsrmRoutes({}); + osrmTrackRoutesRef.current = {}; + setOsrmTrackRoutes({}); }, [selectedDate, selectedBatch]); + // Snap a raw GPS trace (lat/lng pings from /getdeliverylogs) to the road + // network so the Compare map always shows a smooth road-following polyline, + // never a zig-zag of aerial segments between sparse pings. + // + // Strategy: try OSRM's match service first (purpose-built for noisy GPS + // traces). If it returns nothing useful (sparse data, off-road pings, etc.) + // fall back to OSRM's route service over a subsampled set of the same + // points — that still produces a smooth road polyline, just with less + // fidelity. Cached per-deliveryid. + const fetchTrackRoute = useCallback(async (deliveryid, points) => { + if (osrmTrackRoutesRef.current[deliveryid] !== undefined) return; + if (!Array.isArray(points) || points.length < 2) return; + + osrmTrackRoutesRef.current[deliveryid] = null; + setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: null })); + + const storeSuccess = (poly) => { + osrmTrackRoutesRef.current[deliveryid] = poly; + setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: poly })); + }; + const storeFailure = () => { + osrmTrackRoutesRef.current[deliveryid] = false; + setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: false })); + }; + + // Even subsample to <=N coordinates while keeping first + last. Public + // OSRM caps match at 100 and route is slower with many waypoints, so + // we pick a different N for each service. + 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 ptsM = subsample(points, 90); + const coordsM = ptsM.map((p) => `${p[1]},${p[0]}`).join(';'); + const matchUrl = `https://router.project-osrm.org/match/v1/driving/${coordsM}?overview=full&geometries=geojson&gaps=ignore&tidy=true`; + const resM = await fetch(matchUrl); + const jsonM = await resM.json(); + if (jsonM.matchings && jsonM.matchings.length > 0) { + const poly = jsonM.matchings.flatMap((m) => + (m.geometry?.coordinates || []).map((c) => [c[1], c[0]]) + ); + if (poly.length >= 2) { + storeSuccess(poly); + return; + } + } + } catch (e) { + console.warn('OSRM Match error, trying route fallback:', e); + } + + // Attempt 2 — waypoint routing through a coarser subsample. Always + // returns a road polyline as long as the points are routable. + try { + const ptsR = subsample(points, 25); + const coordsR = ptsR.map((p) => `${p[1]},${p[0]}`).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((c) => [c[1], c[0]]); + if (poly.length >= 2) { + storeSuccess(poly); + return; + } + } + storeFailure(); + } catch (e) { + console.error('OSRM Route fallback error:', e); + storeFailure(); + } + }, []); + + // Kick a snap-to-road request for every delivery whose GPS log just landed. + // Only runs while Compare is open so idle dispatch viewing doesn't hit OSRM. + useEffect(() => { + if (!compareOpen || !focusedRider) return; + riderActualTracks.forEach((t) => { + if (!t.deliveryid || t.coords.length < 2) return; + const pts = t.coords.map((c) => [c.lat, c.lng]); + fetchTrackRoute(t.deliveryid, pts); + }); + }, [riderActualTracks, compareOpen, focusedRider, fetchTrackRoute]); + + // Mirror isAnimating into a ref so the rAF tick loop in startAnimation can + // bail mid-animation when the user clicks Stop (the rAF closure captures + // state at scheduling time and would otherwise keep ticking). + useEffect(() => { + isAnimatingRef.current = isAnimating; + }, [isAnimating]); + useEffect(() => { if (embedded) return undefined; const tick = () => { @@ -1089,11 +1876,20 @@ const Dispatch = ({ if (isAnimating) { setIsAnimating(false); setAnimatedSegments([]); + setAnimatedActualProgress({}); return; } setIsAnimating(true); setAnimatedSegments([]); + setAnimatedActualProgress({}); + + // Capture into locals so the setTimeout callbacks below don't read stale + // state if the user toggles focus mid-animation. + const isCompareFocused = compareOpen && focusedRider; + const compareDeliveryToStep = isCompareFocused + ? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])) + : null; const allSegs = []; riders.forEach(r => { @@ -1127,11 +1923,58 @@ const Dispatch = ({ const path = roadPath || aerialPath; if (path.length < 2) return; + // Compare mode for the focused rider: color each animation pair by + // its step using STEP_PALETTE so the planned animation visually + // matches the static per-step polylines + the right map's actual + // GPS animation. We reuse splitPolylineByDrops to assign each + // polyline-pair (path[i] → path[i+1]) to the step containing it. + const isCompareRider = isCompareFocused && r.id === focusedRider.id; + let pairColorAt = () => r.color; // (idx) → color + if (isCompareRider) { + const validDrops = sorted.filter(hasValidDrop); + const dropCoords = validDrops.map((o) => [ + parseFloat(o.droplat || o.deliverylat), + parseFloat(o.droplon || o.deliverylong) + ]); + const stepSegs = roadPath + ? splitPolylineByDrops(roadPath, dropCoords) + : (() => { + const hasPickup = aerialPath.length > dropCoords.length; + const out = []; + for (let i = 0; i < dropCoords.length; i++) { + const a = hasPickup ? i : i - 1; + const b = hasPickup ? i + 1 : i; + if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) { + out.push([]); + } else { + out.push([aerialPath[a], aerialPath[b]]); + } + } + return out; + })(); + // Map each path index → step index by accumulating segment lengths + // (segments share endpoints so we use length-1 per segment). + const pathIdxToStepIdx = []; + let acc = 0; + stepSegs.forEach((seg, sIdx) => { + const segLen = Math.max(0, (seg?.length || 0) - 1); + for (let k = 0; k < segLen; k++) pathIdxToStepIdx[acc + k] = sIdx; + acc += segLen; + }); + pairColorAt = (idx) => { + const sIdx = pathIdxToStepIdx[idx]; + if (sIdx == null) return r.color; + const order = sorted.filter(hasValidDrop)[sIdx]; + const ss = order ? compareDeliveryToStep.get(String(order.deliveryid)) : null; + return ss ? stepColor(ss - 1) : r.color; + }; + } + for (let i = 0; i < path.length - 1; i++) { allSegs.push({ from: path[i], to: path[i + 1], - color: r.color, + color: pairColorAt(i), delay: (parseInt(r.id.slice(-3)) || 0) * 0.05 + (parseInt(tNum) * 40) + i * (isKitchenAerial ? 40 : 8) }); } @@ -1143,10 +1986,64 @@ const Dispatch = ({ setTimeout(() => { setAnimatedSegments(prev => [...prev, s]); if (idx === allSegs.length - 1) { - setTimeout(() => setIsAnimating(false), 1000); + setTimeout(() => { + setIsAnimating(false); + setAnimatedActualProgress({}); + }, 1000); } }, s.delay); }); + + // Compare-mode: progressively DRAW the actual-GPS polylines on the right + // map — same visual style as the planned animation on the left (line + // grows from start to end), but driven by a single rAF loop instead of + // hundreds of per-pair setTimeouts. For each step we know: + // stepStart = idx * perStep — when this step begins drawing + // stepEnd = stepStart + perStep — when its polyline is fully drawn + // progress = ceil(t * positions.length) where t = (now - stepStart) / perStep + // The tick updates one state object containing all steps' progress so + // React batches the re-render across the whole right map. + if (isCompareFocused && riderActualTracks.length > 0) { + const tracksSnapshot = [...riderActualTracks]; + // Snapshot the resolved positions per step at animation start. Prefer + // OSRM-snapped path when available so the drawing follows real roads; + // fall back to Kalman-smoothed coords otherwise. + const stepPositions = tracksSnapshot.map((t) => { + const snap = osrmTrackRoutes[t.deliveryid]; + if (Array.isArray(snap) && snap.length >= 2) return snap; + return t.coords.map((p) => [p.lat, p.lng]); + }); + const lastDelay = allSegs.length > 0 + ? allSegs[allSegs.length - 1].delay + : tracksSnapshot.length * 800; + const totalDuration = Math.max(lastDelay, tracksSnapshot.length * 600); + const perStep = totalDuration / Math.max(1, tracksSnapshot.length); + const animStartTs = Date.now(); + + const tick = () => { + if (!isAnimatingRef.current) return; // user clicked Stop + const elapsed = Date.now() - animStartTs; + const next = {}; + tracksSnapshot.forEach((t, idx) => { + const positions = stepPositions[idx]; + if (!positions || positions.length < 2) return; + const stepStart = idx * perStep; + const stepEnd = stepStart + perStep; + if (elapsed >= stepEnd) { + next[t.sequenceStep] = positions.length; + } else if (elapsed >= stepStart) { + const ratio = (elapsed - stepStart) / perStep; + next[t.sequenceStep] = Math.max(2, Math.ceil(ratio * positions.length)); + } + // else: step hasn't started — leave undefined (filter will hide) + }); + setAnimatedActualProgress(next); + if (elapsed < totalDuration + 200) { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + } }; const createKitchenIcon = (name, focused = false) => L.divIcon({ @@ -1185,13 +2082,39 @@ const Dispatch = ({ if (focusedKitchen) ordersToRender = focusedKitchen.orders; ordersToRender = ordersToRender.filter(hasValidDrop); + // Pre-build the deliveryid → sequenceStep lookup once per render so each + // marker can resolve its step palette color without an O(N) scan. + const compareDeliveryToStep = + compareOpen && focusedRider + ? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])) + : null; + return ordersToRender.map((o, idx) => { const rid = o.rider_id; const active = rid ? activeRiders.has(rid) : true; - const color = getRiderColor(rid); + let color = getRiderColor(rid); - // Use the 'step' field from data, fallback to index - const seq = o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0); + // Compare mode: recolor and renumber the focused rider's markers by + // sequenceStep (delivery-time order, same as the right map + timeline) + // instead of rider color + planned step. Both maps now show the same + // color + number for the same delivery — a step-3 pin on the left + // planned map matches the step-3 polyline + drop pin on the right. + let compareSeq; + if ( + compareDeliveryToStep && + rid === focusedRider.id && + o.deliveryid != null + ) { + const ss = compareDeliveryToStep.get(String(o.deliveryid)); + if (ss) { + color = stepColor(ss - 1); + compareSeq = ss; + } + } + + // Use the 'step' field from data, fallback to index. In compare mode + // override with sequenceStep so the marker number matches the timeline. + const seq = compareSeq || o.step || (focusedRider || focusedKitchen ? (ordersToRender.indexOf(o) + 1) : 0); // Bumped from 22 → 32 so the step number reads at city-level zoom. const sz = 32; @@ -1244,7 +2167,7 @@ const Dispatch = ({ } }} > - +
ORDER #{o.orderid}
@@ -1410,6 +2333,98 @@ const Dispatch = ({ // reads as an estimate vs. an actual routed road polyline. const dashArray = failed ? '8 6' : undefined; + // Compare mode: recolor the planned polyline per step so the left + // (planned) map's segment for delivery X uses the same STEP_PALETTE + // color as the right (actual) map's GPS polyline for delivery X and + // the timeline dot for X. Without this, the operator can't visually + // link a step on the timeline to its planned segment on the left. + const isCompareTarget = + compareOpen && focusedRider && r.id === focusedRider.id; + if (isCompareTarget) { + const deliveryToStep = new Map( + riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]) + ); + const validDrops = sorted.filter(hasValidDrop); + const dropCoords = validDrops.map((o) => [ + parseFloat(o.droplat || o.deliverylat), + parseFloat(o.droplon || o.deliverylong) + ]); + // hasRoad: split the OSRM polyline at each drop's nearest index. + // !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints + // only prepends the pickup when one's available. Detect that and + // align segment[i] with validDrops[i] either way. + let segments; + if (hasRoad) { + segments = splitPolylineByDrops(finalPoints, dropCoords); + } else { + const hasPickup = finalPoints.length > dropCoords.length; + segments = []; + for (let i = 0; i < dropCoords.length; i++) { + const idxA = hasPickup ? i : i - 1; + const idxB = hasPickup ? i + 1 : i; + if ( + idxA < 0 || + idxA >= finalPoints.length || + idxB >= finalPoints.length + ) { + segments.push([]); + } else { + segments.push([finalPoints[idxA], finalPoints[idxB]]); + } + } + } + // One halo under the whole trip so crossing roads still read as + // a single planned route. Per-step segments draw on top with their + // step's color. + routes.push( + + ); + segments.forEach((seg, i) => { + if (!seg || seg.length < 2) return; + const order = validDrops[i]; + const sequenceStep = order + ? deliveryToStep.get(String(order.deliveryid)) + : null; + const color = sequenceStep ? stepColor(sequenceStep - 1) : r.color; + const isFocusedThisStep = + focusedCompareStep != null && focusedCompareStep === sequenceStep; + // Focused segment pops; non-focused dim when *some* step is + // focused so the active one stands out. When no step is focused + // every segment renders at full opacity. + const segWeight = isFocusedThisStep ? weight + 1.5 : weight; + const segOpacity = isFocusedThisStep + ? 1 + : focusedCompareStep + ? opacity * 0.5 + : opacity; + routes.push( + + ); + }); + return; // skip the default single-color render below + } + routes.push( @@ -1974,7 +2989,16 @@ const Dispatch = ({
) : ( -
+
+ -
- +
+ - + {compareOpen && } + {compareOpen && ( + handleCompareMapView('left', m)} + enabled={compareSyncEnabled} + /> + )} + {kitchens .filter(k => Number.isFinite(k.lat) && Number.isFinite(k.lon)) .filter(k => !focusedRider || k.riders.has(focusedRider.id)) @@ -2554,7 +3601,7 @@ const Dispatch = ({ mouseout: (e) => e.target.closePopup() }} > - +
KITCHEN
{k.kitchenName}
@@ -2599,7 +3646,7 @@ const Dispatch = ({ mouseout: (e) => e.target.closePopup() }} > - +
RIDER
{p.riderName}
Progress{p.completedCount} / {p.totalCount} delivered
@@ -2650,7 +3697,7 @@ const Dispatch = ({ mouseout: (e) => e.target.closePopup() }} > - +
LIVE GPS
{r.username}
Status{r.status || 'unknown'}
@@ -2664,6 +3711,13 @@ const Dispatch = ({ })} + {compareOpen && ( +
+ + Planned Route +
+ )} +
{/*
@@ -2712,8 +3766,667 @@ const Dispatch = ({ + {/* Compare planned vs. actual: when no rider is focused yet we + auto-pick the first rider with orders, so the button always + does something visible. The comparison itself is per-rider. */} +
+ + {/* Compare-mode second pane — actual GPS tracks per delivery for the + focused rider. Fans out /deliveries/getdeliverylogs/?deliveryid=X + (one query per order, cached by deliveryid) and overlays each + track as a colored polyline + start/end markers. Renders alongside + #map-wrap when compareOpen so the operator can eyeball planned vs. + actual side by side. */} + {compareOpen && focusedRider && ( +
+ {(() => { + const total = riderActualTracks.length; + const loaded = riderActualTracks.filter((t) => t.coords.length > 0).length; + const loading = riderActualTracks.filter((t) => t.isLoading).length; + const pct = total > 0 ? Math.round((loaded / total) * 100) : 0; + const isDone = total > 0 && loaded === total && loading === 0; + const focusedDelta = + focusedCompareStep != null + ? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep) + : null; + return ( +
+
+
+ + {focusedRider.riderName} + ACTUAL vs PLANNED +
+
+ {focusedCompareStep != null && ( + + )} + +
+
+ + {/* Step timeline — every delivery as a tappable dot in chronological + order. The operator can drill into any step to scrutinize that + single delivery on both maps. Filled = delivered, ring = pending, + dim = cancelled/skipped, ring with spinner = GPS still loading. */} +
+
+ {compareDeltas.map((d, i) => { + const statusLow = String(d.orderstatus || '').toLowerCase(); + const isDelivered = FINAL_STATUSES.has(statusLow); + const isSkipped = SKIPPED_STATUSES.has(statusLow); + const isFocused = focusedCompareStep === d.sequenceStep; + const isLoading = d.isLoading && d.coordsCount === 0; + const isNoData = !d.isLoading && d.coordsCount === 0; + const color = stepColor(i); + const cls = [ + 'compare-step', + isFocused && 'is-focused', + isDelivered && 'is-delivered', + isSkipped && 'is-skipped', + !isDelivered && !isSkipped && 'is-pending', + isLoading && 'is-loading', + isNoData && 'is-no-data', + d.anomaly && 'is-anomaly' + ].filter(Boolean).join(' '); + return ( + + {i > 0 && } + + + ); + })} +
+
+
+
+
+ + {loading > 0 ? `Loading GPS… ${loaded}/${total}` : `${loaded}/${total} tracks`} + +
+
+ + {/* Legend strip — one line of icons so the operator instantly knows + what each line/marker means. Lives in the header so it doesn't + compete with the map for vertical real estate. */} + {(() => { + // Both Planned (left) and Actual (right) now share the same + // per-step palette — the legend swatch shows the focused + // step's color when one is focused, or a multi-hue gradient + // when in overall mode (signals "varies by step"). + const swatchBg = focusedDelta + ? stepColor(focusedDelta.sequenceStep - 1) + : `linear-gradient(90deg, ${STEP_PALETTE.slice(0, 6).join(', ')})`; + return ( +
+ + + Planned (left) + + + + Actual GPS (right) + + + Kalman-smoothed GPS · OSRM road-snapped + +
+ ); + })()} +
+ ); + })()} +
+ + + + {/* Stable component that fits the comparison map to every + loaded GPS point — or to just one step's coords when the + operator drills into a step via the timeline. */} + + + handleCompareMapView('right', m)} + enabled={compareSyncEnabled} + /> + {riderActualTracks.map((t, i) => { + if (t.coords.length === 0) return null; + const color = stepColor(i); + const startPos = [t.coords[0].lat, t.coords[0].lng]; + const endPos = [t.coords[t.coords.length - 1].lat, t.coords[t.coords.length - 1].lng]; + // Always render a smooth polyline. Pipeline: + // raw pings → Kalman smoothing (in queryFn, see + // kalmanSmoothGps) → t.coords → optional OSRM map-matching + // (fetchTrackRoute) → osrmTrackRoutes[deliveryid]. + // We prefer the OSRM-snapped path when present so the line + // follows real roads; while OSRM is still in flight or fails, + // the Kalman-smoothed coords are already clean enough to plot + // directly (no zig-zag), so we fall back to t.coords as-is. + const snapped = osrmTrackRoutes[t.deliveryid]; + const hasRoad = Array.isArray(snapped) && snapped.length >= 2; + const fullPositions = hasRoad + ? snapped + : t.coords.map((p) => [p.lat, p.lng]); + + // During Animate: slice the polyline up to this step's + // current progress index so it visibly DRAWS from start to + // end (matches the planned animation's segment-by-segment + // drawing on the left map). When not animating, render the + // full polyline. When animating but progress is <2 (step + // hasn't started yet), drawPolyline becomes false and we + // skip the polyline render entirely for this frame. + let positions = fullPositions; + let drawPolyline = true; + if (isAnimating) { + const progress = animatedActualProgress[t.sequenceStep] || 0; + if (progress < 2) { + drawPolyline = false; + } else { + positions = fullPositions.slice(0, Math.min(progress, fullPositions.length)); + } + } + + const isFocusedStep = focusedCompareStep === t.sequenceStep; + const statusLow = String(t.orderstatus || '').toLowerCase(); + const isDelivered = FINAL_STATUSES.has(statusLow); + const isSkipped = SKIPPED_STATUSES.has(statusLow); + const delta = compareDeltas.find((d) => d.sequenceStep === t.sequenceStep); + const isAnomaly = !!delta?.anomaly; + + const dropClasses = ['compare-step-pin']; + if (isFocusedStep) dropClasses.push('is-focused'); + if (isDelivered) dropClasses.push('is-delivered'); + if (isSkipped) dropClasses.push('is-skipped'); + if (isAnomaly) dropClasses.push('is-anomaly'); + + // Drop pin — colored circle with the step number; gets a green + // check overlay when delivered, a red ring when flagged for + // deviation, and pulses when focused via the timeline. Size + // bumps when focused so the operator can spot it instantly. + const dropPinHtml = + `
` + + `${t.sequenceStep}` + + (isDelivered + ? '' + : '') + + '
'; + const sequenceIcon = L.divIcon({ + className: '', + iconSize: [36, 36], + iconAnchor: [18, 18], + html: dropPinHtml + }); + + // The start of every step (except the first) sits on top of the + // previous step's drop point — same lat/lng to within a few + // meters. Rendering a "start" pin there clutters the map and + // overlaps the previous step's drop. So only the first step + // gets a start marker (the rider's day origin); for steps 2..N + // the connector polyline already shows where the next track + // begins. + const showStartMarker = t.sequenceStep === 1; + // Step 1's marker shows the order pickup point. The glyph is + // the Material "local mall" / shopping-bag icon (takeout bag + // with a handle) — reads as "this is where the rider picked + // up the parcel". Sized at 40×40 so the pin stays prominent + // even at deep zooms (Leaflet divIcons hold a fixed pixel + // size at every zoom level, so a small icon visually shrinks + // against the surrounding map features as you zoom in; a + // larger pixel size compensates). + const startIconEl = showStartMarker + ? L.divIcon({ + className: '', + iconSize: [40, 40], + iconAnchor: [20, 20], + html: + `
` + + '
' + }) + : null; + + const handleStepClick = (e) => { + if (e.originalEvent) e.originalEvent.stopPropagation(); + setFocusedCompareStep((prev) => + prev === t.sequenceStep ? null : t.sequenceStep + ); + }; + + return ( + + {/* White halo polyline — gives every track an outline so + crossing tracks of similar hues still read as separate + lines. Heavier when focused so the active step's path + visually pops over its neighbours. Hidden during the + animation interval before this step's reveal starts. */} + {drawPolyline && ( + + )} + {drawPolyline && ( + + )} + + {showStartMarker && ( + + +
+
+ + + +
+
+ {t.pickupcustomer || 'Pickup'} +
+
+ {t.coords[0]?.logdate + ? `Picked up · ${dayjs(t.coords[0].logdate).format('hh:mm A')}` + : 'Rider trip origin'} +
+
+
+
Click for step 1 details
+
+
+
+ )} + + + + {(() => { + const ss = getStatusStyle(t.orderstatus); + return ( +
+
+ + {t.sequenceStep} + +
+
+ {t.deliverycustomer || `Step ${t.sequenceStep}`} +
+
+ {t.deliverytime + ? `Delivered ${dayjs(t.deliverytime).format('hh:mm A')}` + : `${t.coords.length} GPS pings`} +
+
+ {t.orderstatus && ( + + {ss.label} + + )} +
+ {isAnomaly && ( +
+ Deviation flagged — see details below +
+ )} +
+ {isFocusedStep ? 'Click to deselect' : 'Click for details'} +
+
+ ); + })()} +
+
+
+ ); + })} +
+ + {/* Same Animate Routes button as the main map's #ov-br, mirrored + onto the compare map so the operator can trigger the planned + + actual animation from either side. While running, planned + segments draw on the left (per-step colored when in compare + mode) and actual GPS polylines pop in step-by-step on the + right, scheduled to finish together. */} +
+ +
+
+ + {/* Delta panel — when a step is focused, shows planned-vs-actual + numbers for just that delivery (distance, route deviation %, time + variance). When no step is focused, rolls up the same numbers + across the whole day. Anomaly cells turn red when a step ran + >25% longer than planned or arrived >15 min late. */} + {(() => { + const focused = + focusedCompareStep != null + ? compareDeltas.find((d) => d.sequenceStep === focusedCompareStep) + : null; + + if (focused) { + const color = stepColor(focused.sequenceStep - 1); + const kmDeltaSign = focused.kmDelta >= 0 ? '+' : ''; + const kmDeltaCls = focused.anomaly + ? 'is-over' + : focused.kmDelta < -0.1 + ? 'is-under' + : ''; + const timeDeltaCls = + focused.timeDeltaMin != null + ? focused.timeDeltaMin > 10 + ? 'is-over' + : focused.timeDeltaMin < -2 + ? 'is-under' + : '' + : ''; + const statusStyle = getStatusStyle(focused.orderstatus); + return ( +
+
+ + {focused.sequenceStep} + +
+
+ {focused.deliverycustomer || `Step ${focused.sequenceStep}`} +
+
+ {focused.pickupcustomer ? `from ${focused.pickupcustomer} · ` : ''} + Order #{focused.orderid} +
+
+ {focused.orderstatus && ( + + {statusStyle.label} + + )} +
+
+
+ Distance + + {focused.actualKm.toFixed(2)}{' '} + km + + + planned {focused.plannedKm.toFixed(2)} km + +
+
+ Δ Route + + {kmDeltaSign} + {focused.kmDelta.toFixed(2)} km + + + {focused.kmDeltaPct != null + ? `${kmDeltaSign}${focused.kmDeltaPct.toFixed(0)}% vs plan` + : 'no planned km'} + +
+
+ Time + + {focused.timeDeltaMin != null + ? `${focused.timeDeltaMin > 0 ? '+' : ''}${focused.timeDeltaMin} min` + : '—'} + + + {focused.actualTs && focused.expectedTs + ? `${focused.actualTs.format('HH:mm')} vs ${focused.expectedTs.format('HH:mm')}` + : focused.actualTs + ? `delivered ${focused.actualTs.format('HH:mm')}` + : 'in flight'} + +
+
+
+ ); + } + + const sum = compareSummary; + const deltaCls = + sum.kmDeltaPct == null + ? '' + : sum.kmDeltaPct > 25 + ? 'is-over' + : sum.kmDeltaPct < -5 + ? 'is-under' + : ''; + const total = sum.onTime + sum.late; + return ( +
+
+ + + +
+
Day summary
+
+ Click any step above to scrutinize that delivery +
+
+
+
+
+ Total distance + + {sum.actualKm.toFixed(1)}{' '} + km + + + planned {sum.plannedKm.toFixed(1)} km + +
+
0 ? ' is-anomaly' : ''}`}> + Route deviation + + {sum.kmDeltaPct != null + ? `${sum.kmDeltaPct > 0 ? '+' : ''}${sum.kmDeltaPct.toFixed(0)}%` + : '—'} + + + {sum.anomalies > 0 + ? `${sum.anomalies} step${sum.anomalies > 1 ? 's' : ''} flagged` + : 'within plan'} + +
+
+ On-time + + {sum.onTime} + {total > 0 && ( + /{total} + )} + + + {sum.late > 0 ? `${sum.late} late` : 'all on schedule'} + +
+
+
+ ); + })()} + +
+ )}
)}