@@ -605,26 +702,39 @@ function CompareDataPanel({
role="button"
title="Focus this step"
>
-
-
-
Biggest deviation
+
+
+
+
+
+
+
+ Biggest deviation
+
+
+ Step {worstStep.sequenceStep}
+
{worstStep.deliverycustomer || `Step ${worstStep.sequenceStep}`}
- {worstStep.kmDeltaPct != null
- ? `${worstStep.kmDeltaPct > 0 ? '+' : ''}${worstStep.kmDeltaPct.toFixed(0)}% route`
- : ''}
- {worstStep.timeDeltaMin != null && worstStep.timeDeltaMin > 0
- ? ` · +${worstStep.timeDeltaMin}m late`
- : ''}
+ {worstStep.kmDeltaPct != null && (
+
+ {worstStep.kmDeltaPct > 0 ? '+' : ''}
+ {worstStep.kmDeltaPct.toFixed(0)}% route
+
+ )}
+ {worstStep.timeDeltaMin != null && worstStep.timeDeltaMin > 0 && (
+
+ +{worstStep.timeDeltaMin}m late
+
+ )}
diff --git a/src/pages/nearle/dispatch/Dispatch.css b/src/pages/nearle/dispatch/Dispatch.css
index f813660..bef0ca7 100644
--- a/src/pages/nearle/dispatch/Dispatch.css
+++ b/src/pages/nearle/dispatch/Dispatch.css
@@ -3486,14 +3486,31 @@
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. */
+/* Solid step-color swatch used by both Planned and Actual legend entries
+ on the unified compare map — they share the same per-step palette and
+ are distinguished by stroke style instead. */
.dispatch-container .compare-legend-swatch.is-step-color {
height: 4px;
border-radius: 2px;
}
+/* Dashed variant — mirrors the planned polyline's dashed stroke in
+ Combined view. Masks the gradient swatch with a tiled transparent
+ gap so the eye reads "dashed line" without losing the step-color
+ gradient underneath. */
+.dispatch-container .compare-legend-swatch.is-step-color.is-dashed {
+ -webkit-mask-image: repeating-linear-gradient(
+ 90deg,
+ #000 0 5px,
+ transparent 5px 9px
+ );
+ mask-image: repeating-linear-gradient(
+ 90deg,
+ #000 0 5px,
+ transparent 5px 9px
+ );
+}
+
/* 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). */
@@ -4451,6 +4468,18 @@
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
overflow: hidden;
min-width: 460px;
+ animation: dispatch-popup-in 0.18s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/* Soften the popup tip the same way — without this, the tip pops in at
+ full opacity while the wrapper fades, which reads as a jarring snap. */
+.dispatch-container .dispatch-popup .leaflet-popup-tip {
+ animation: dispatch-popup-in 0.18s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes dispatch-popup-in {
+ from { opacity: 0; transform: translateY(4px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
}
.dispatch-container .dispatch-popup .leaflet-popup-content {
@@ -5177,9 +5206,9 @@
.dispatch-container .ri-rider-item {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 12px;
width: 100%;
- padding: 10px 12px;
+ padding: 12px 14px;
border: 1px solid rgba(123, 31, 162, 0.12);
border-radius: 10px;
background: #fff;
@@ -5211,8 +5240,8 @@
}
.dispatch-container .ri-rider-dot {
- width: 12px;
- height: 12px;
+ width: 14px;
+ height: 14px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(15, 23, 42, 0.08);
@@ -5222,27 +5251,29 @@
flex: 1;
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: 3px;
min-width: 0;
}
.dispatch-container .ri-rider-name {
- font-size: 13px;
+ font-size: 15px;
font-weight: 700;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ letter-spacing: -0.01em;
}
.dispatch-container .ri-rider-meta {
- font-size: 11px;
- font-weight: 500;
+ font-size: 13px;
+ font-weight: 600;
color: #64748b;
}
.dispatch-container .ri-rider-arrow {
color: #7b1fa2;
+ font-size: 18px;
font-weight: 800;
opacity: 0.4;
transition: opacity 0.15s ease, transform 0.15s ease;
@@ -6095,16 +6126,17 @@
}
/* ============================================================
- Compare screen — new layout (takes over the body when Compare
- is open). 50% left column is a vertical stack: actual map on
- top, planned map on bottom. 50% right column is a scrollable
- data panel with deviations, per-step correctness, delivery
- times and profit.
+ Compare screen — unified layout. Compare mode takes over the
+ body: one big map fills the left column (with a Planned /
+ Actual / Combined view switcher pinned to its top-left), a
+ compact timeline + legend strip sits above the map, and the
+ scrollable data panel (deviations, per-step correctness,
+ delivery times, profit) anchors the right column.
============================================================ */
.dispatch-container #body.compare-mode {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 440px);
- grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 12px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
@@ -6115,22 +6147,31 @@
display: none !important;
}
-/* Explicit grid placement — the actual GPS map (#compare-map-wrap) takes
- the TOP-LEFT cell, the planned route map (#map-wrap) takes the
- BOTTOM-LEFT cell, and the data panel spans the entire right column.
- We use grid-row/grid-column rather than grid-template-areas so the
- placement can't be quietly broken by a later override of the parent's
- areas list. */
+/* Header strip — sits above the unified map (row 1, col 1) and
+ carries the rider title, the step timeline + load progress, and
+ the layer legend. The Sync toggle was removed when the second
+ map went away; the layer switcher is overlaid on the map itself. */
.dispatch-container #body.compare-mode #compare-map-wrap {
grid-column: 1;
grid-row: 1;
flex: none;
min-width: 0;
- min-height: 0;
margin: 0;
+ background: #ffffff;
border-radius: 14px;
box-shadow: 0 6px 24px rgba(15, 23, 42, 0.08),
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);
+}
+
+/* The header v2 block sat inside a flex column inside the old
+ compare-map-wrap. With the wrapper now sized to content, the
+ inner block doesn't need its own border-bottom anymore — the
+ wrapper's rounded card provides the visual containment. */
+.dispatch-container #body.compare-mode #compare-map-wrap .compare-header-v2 {
+ border-bottom: 0;
+ background: transparent;
}
.dispatch-container #body.compare-mode #map-wrap,
@@ -6148,8 +6189,64 @@
overflow: hidden;
}
-.dispatch-container #body.compare-mode .compare-planned-label {
+/* Layer switcher — pinned to the top-left corner of the unified
+ compare map. Segmented control: Actual | Planned | Combined.
+ Active button uses the same indigo→blue gradient as the Compare
+ pill in #ov-br so the operator can visually trace "Compare on"
+ to "this is the active layer". */
+.dispatch-container .compare-view-switcher {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ z-index: 600;
+ display: inline-flex;
+ align-items: stretch;
+ gap: 2px;
+ padding: 4px;
background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(8px);
+ border-radius: 10px;
+ box-shadow: 0 6px 20px rgba(15, 23, 42, 0.12),
+ 0 0 0 1px rgba(15, 23, 42, 0.08);
+ animation: compare-label-in 0.22s ease-out;
+}
+
+.dispatch-container .compare-view-switcher button {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ padding: 6px 14px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: #475569;
+ border-radius: 7px;
+ cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease,
+ box-shadow 0.15s ease, transform 0.15s ease;
+ white-space: nowrap;
+}
+
+.dispatch-container .compare-view-switcher button:hover {
+ background: rgba(99, 102, 241, 0.08);
+ color: #4338ca;
+}
+
+.dispatch-container .compare-view-switcher button:focus-visible {
+ outline: 2px solid rgba(99, 102, 241, 0.5);
+ outline-offset: 1px;
+}
+
+.dispatch-container .compare-view-switcher button.is-active {
+ background: linear-gradient(135deg, #6366f1, #3b82f6);
+ color: #ffffff;
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
+}
+
+.dispatch-container .compare-view-switcher button.is-active:hover {
+ color: #ffffff;
+ transform: translateY(-0.5px);
}
/* Data panel ------------------------------------------------- */
@@ -6427,14 +6524,14 @@
}
.dispatch-container .cdp-dev-num {
- width: 26px;
- height: 26px;
+ width: 30px;
+ height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
- font-size: 12px;
+ font-size: 14px;
font-weight: 800;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.6),
@@ -6446,11 +6543,11 @@
min-width: 0;
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 5px;
}
.dispatch-container .cdp-dev-title {
- font-size: 12px;
+ font-size: 14px;
font-weight: 700;
color: #0f172a;
overflow: hidden;
@@ -6461,14 +6558,14 @@
.dispatch-container .cdp-dev-meta {
display: flex;
flex-wrap: wrap;
- gap: 4px;
+ gap: 6px;
}
.dispatch-container .cdp-dev-chip {
display: inline-flex;
align-items: center;
- padding: 2px 7px;
- font-size: 10px;
+ padding: 3px 9px;
+ font-size: 12px;
font-weight: 700;
border-radius: 999px;
background: rgba(239, 68, 68, 0.1);
@@ -6521,14 +6618,14 @@
}
.dispatch-container .cdp-step-num {
- width: 30px;
- height: 30px;
+ width: 34px;
+ height: 34px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
- font-size: 13px;
+ font-size: 15px;
font-weight: 800;
flex-shrink: 0;
position: relative;
@@ -6541,14 +6638,14 @@
position: absolute;
bottom: -3px;
right: -3px;
- width: 14px;
- height: 14px;
+ width: 16px;
+ height: 16px;
border-radius: 50%;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
- font-size: 12px;
+ font-size: 13px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
}
.dispatch-container .cdp-step-check { color: #16a34a; }
@@ -6570,7 +6667,7 @@
}
.dispatch-container .cdp-step-title {
- font-size: 13px;
+ font-size: 15px;
font-weight: 700;
color: #0f172a;
overflow: hidden;
@@ -6583,8 +6680,8 @@
.dispatch-container .cdp-step-status {
display: inline-flex;
align-items: center;
- padding: 2px 7px;
- font-size: 9px;
+ padding: 3px 9px;
+ font-size: 11px;
font-weight: 800;
border-radius: 999px;
letter-spacing: 0.05em;
@@ -6593,7 +6690,7 @@
}
.dispatch-container .cdp-step-sub {
- font-size: 11px;
+ font-size: 13px;
font-weight: 600;
color: #94a3b8;
overflow: hidden;
@@ -6604,24 +6701,24 @@
.dispatch-container .cdp-step-deltas {
display: flex;
flex-wrap: wrap;
- gap: 6px 10px;
- margin-top: 2px;
+ gap: 6px 12px;
+ margin-top: 3px;
}
.dispatch-container .cdp-step-delta {
display: inline-flex;
align-items: center;
- gap: 3px;
- font-size: 11px;
+ gap: 4px;
+ font-size: 13px;
font-weight: 700;
color: #475569;
}
.dispatch-container .cdp-step-delta svg {
- font-size: 13px;
+ font-size: 15px;
color: #94a3b8;
}
.dispatch-container .cdp-step-delta small {
- font-size: 10px;
+ font-size: 12px;
font-weight: 700;
color: #94a3b8;
}
@@ -6631,12 +6728,14 @@
.dispatch-container .cdp-step-delta.is-under { color: #16a34a; }
.dispatch-container .cdp-step-delta.is-under svg { color: #16a34a; }
-/* Responsive — narrow screens collapse to a single column with the
- data panel below the two maps so the maps stay usable on tablets. */
+/* Responsive — narrow screens collapse to a single column. Header strip
+ stacks on top, then the unified map, then the data panel scrolls
+ below. The map gets a generous min-height so the layer-switcher
+ overlay and timeline stay usable on tablets. */
@media (max-width: 1100px) {
.dispatch-container #body.compare-mode {
grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(220px, 1fr) minmax(220px, 1fr) auto;
+ grid-template-rows: auto minmax(360px, 1fr) auto;
}
.dispatch-container #body.compare-mode #compare-map-wrap {
grid-column: 1;
@@ -6742,124 +6841,531 @@
color: #64748b;
}
-/* KPI row ---------------------------------------------------- */
-.dispatch-container .cdp-kpi-row {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
+/* Timing — clock-style timeline ----------------------------- */
+/* Premium "workday window" composition: two digital clock faces flank
+ a gradient track with the active-time centerpiece. Soft radial
+ wash on the container, "Started"/"Finished" captions tell the
+ narrative. Below: stats with mini-visualizations. */
+.dispatch-container .cdp-timing-section .cdp-section-head {
+ display: flex;
+ align-items: center;
gap: 8px;
}
-.dispatch-container .cdp-kpi {
- background: #fff;
- border: 1px solid rgba(15, 23, 42, 0.07);
- border-radius: 9px;
- padding: 8px 10px;
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
-}
-
-.dispatch-container .cdp-kpi-label {
+.dispatch-container .cdp-timing-active-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-left: auto;
+ padding: 2px 8px 2px 6px;
+ border-radius: 999px;
+ background: rgba(16, 185, 129, 0.1);
+ color: #16a34a;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
- color: #94a3b8;
}
-
-.dispatch-container .cdp-kpi-value {
- font-size: 15px;
- font-weight: 800;
- color: #0f172a;
- letter-spacing: -0.01em;
-}
-
-.dispatch-container .cdp-kpi-unit {
- font-size: 10px;
- font-weight: 700;
- color: #94a3b8;
- margin-left: 2px;
-}
-
-/* Highlights (best / worst) --------------------------------- */
-.dispatch-container .cdp-highlights {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
-}
-
-.dispatch-container .cdp-highlight {
- display: flex;
- gap: 10px;
- padding: 10px;
- border-radius: 10px;
- background: #fff;
- border: 1px solid rgba(15, 23, 42, 0.07);
- cursor: pointer;
- transition: transform 0.15s ease, box-shadow 0.15s ease;
-}
-.dispatch-container .cdp-highlight:hover {
- transform: translateY(-1px);
- box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
-}
-.dispatch-container .cdp-highlight.is-best {
- background: linear-gradient(180deg, #ecfdf5 0%, #fff 100%);
- border-color: rgba(16, 185, 129, 0.3);
-}
-.dispatch-container .cdp-highlight.is-worst {
- background: linear-gradient(180deg, #fef2f2 0%, #fff 100%);
- border-color: rgba(239, 68, 68, 0.3);
-}
-
-.dispatch-container .cdp-highlight-num {
- width: 28px;
- height: 28px;
+.dispatch-container .cdp-timing-active-pulse {
+ width: 6px;
+ height: 6px;
border-radius: 50%;
+ background: #10b981;
+ box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55);
+ animation: cdp-timing-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+@keyframes cdp-timing-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); }
+ 70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
+}
+
+.dispatch-container .cdp-timing-clock {
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr minmax(70px, 1.3fr) 1fr;
+ align-items: center;
+ gap: 8px;
+ padding: 18px 14px 16px;
+ background:
+ radial-gradient(120% 80% at 50% 0%, rgba(99, 102, 241, 0.07) 0%, transparent 60%),
+ linear-gradient(180deg, #fbfcff 0%, #fff 100%);
+ border-radius: 14px;
+ border: 1px solid rgba(15, 23, 42, 0.07);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
+}
+
+.dispatch-container .cdp-clock-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.dispatch-container .cdp-clock-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 10px;
+ font-weight: 800;
+ color: #64748b;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+.dispatch-container .cdp-clock-label svg {
+ font-size: 13px;
+}
+.dispatch-container .cdp-clock-card.is-start .cdp-clock-label svg { color: #10b981; }
+.dispatch-container .cdp-clock-card.is-end .cdp-clock-label svg { color: #f59e0b; }
+
+.dispatch-container .cdp-clock-face {
+ position: relative;
+ display: inline-flex;
+ align-items: baseline;
+ gap: 5px;
+ padding: 10px 14px;
+ border-radius: 12px;
+ background: linear-gradient(180deg, #1e293b 0%, #0b1220 100%);
+ box-shadow:
+ 0 2px 4px rgba(15, 23, 42, 0.18),
+ 0 8px 18px rgba(15, 23, 42, 0.12),
+ inset 0 1px 0 rgba(255, 255, 255, 0.07),
+ inset 0 -1px 0 rgba(0, 0, 0, 0.4);
+}
+.dispatch-container .cdp-clock-face::after {
+ content: '';
+ position: absolute;
+ inset: 1px;
+ border-radius: 11px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, transparent 50%);
+ pointer-events: none;
+}
+.dispatch-container .cdp-clock-card.is-start .cdp-clock-face {
+ border-top: 1px solid rgba(16, 185, 129, 0.4);
+}
+.dispatch-container .cdp-clock-card.is-end .cdp-clock-face {
+ border-top: 1px solid rgba(245, 158, 11, 0.4);
+}
+
+.dispatch-container .cdp-clock-time {
+ font-family: 'SF Mono', 'Roboto Mono', 'Menlo', 'Consolas', monospace;
+ font-size: 24px;
+ font-weight: 700;
+ color: #f1f5f9;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ line-height: 1;
+ text-shadow: 0 0 12px rgba(167, 195, 255, 0.18);
+}
+
+.dispatch-container .cdp-clock-period {
+ font-size: 10px;
+ font-weight: 800;
+ color: #cbd5e1;
+ letter-spacing: 0.1em;
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.dispatch-container .cdp-clock-caption {
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: #94a3b8;
+}
+.dispatch-container .cdp-clock-card.is-start .cdp-clock-caption { color: #16a34a; }
+.dispatch-container .cdp-clock-card.is-end .cdp-clock-caption { color: #d97706; }
+
+/* Timeline track — line stretches the full column with the duration
+ badge anchored on top. */
+.dispatch-container .cdp-clock-track {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 60px;
+ min-width: 70px;
+}
+
+.dispatch-container .cdp-clock-track-line {
+ position: absolute;
+ left: 4px;
+ right: 4px;
+ top: 50%;
+ height: 4px;
+ transform: translateY(-50%);
+ background: linear-gradient(90deg, #10b981 0%, #6366f1 50%, #f59e0b 100%);
+ border-radius: 999px;
+ box-shadow: 0 1px 2px rgba(99, 102, 241, 0.18);
+}
+
+.dispatch-container .cdp-clock-track-dot {
+ position: absolute;
+ top: 50%;
+ width: 11px;
+ height: 11px;
+ border-radius: 50%;
+ transform: translateY(-50%);
+ z-index: 1;
+}
+.dispatch-container .cdp-clock-track-dot.is-start {
+ left: 0;
+ background: #10b981;
+ box-shadow:
+ 0 0 0 2px #fff,
+ 0 0 0 5px rgba(16, 185, 129, 0.25);
+}
+.dispatch-container .cdp-clock-track-dot.is-end {
+ right: 0;
+ background: #f59e0b;
+ box-shadow:
+ 0 0 0 2px #fff,
+ 0 0 0 5px rgba(245, 158, 11, 0.25);
+}
+
+.dispatch-container .cdp-clock-duration {
+ position: relative;
+ z-index: 2;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0;
+ padding: 6px 10px 5px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid rgba(99, 102, 241, 0.2);
+ box-shadow:
+ 0 4px 12px rgba(15, 23, 42, 0.08),
+ 0 1px 3px rgba(99, 102, 241, 0.12);
+ white-space: nowrap;
+ min-width: 56px;
+}
+
+.dispatch-container .cdp-clock-duration-icon {
display: inline-flex;
align-items: center;
justify-content: center;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
color: #fff;
font-size: 12px;
- font-weight: 800;
- flex-shrink: 0;
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7),
- 0 1px 3px rgba(15, 23, 42, 0.15);
+ margin-bottom: 3px;
+ box-shadow: 0 2px 4px rgba(99, 102, 241, 0.35);
}
-.dispatch-container .cdp-highlight-body {
+.dispatch-container .cdp-clock-duration-val {
+ font-size: 14px;
+ font-weight: 800;
+ color: #0f172a;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+ line-height: 1.1;
+}
+.dispatch-container .cdp-clock-duration-sub {
+ font-size: 8px;
+ font-weight: 800;
+ color: #94a3b8;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ margin-top: 1px;
+}
+
+/* Supporting timing stats with mini-visualizations. */
+.dispatch-container .cdp-timing-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 10px;
+ margin-top: 12px;
+}
+
+.dispatch-container .cdp-timing-stat {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px;
+ background: #fff;
+ border-radius: 12px;
+ border: 1px solid rgba(15, 23, 42, 0.07);
+ transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
+}
+.dispatch-container .cdp-timing-stat:hover {
+ border-color: rgba(99, 102, 241, 0.3);
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.1);
+ transform: translateY(-1px);
+}
+
+.dispatch-container .cdp-timing-stat-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.dispatch-container .cdp-timing-stat-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(67, 56, 202, 0.1) 100%);
+ color: #4338ca;
+ font-size: 20px;
+ flex-shrink: 0;
+ box-shadow: inset 0 0 0 1px rgba(99, 102, 241, 0.12);
+}
+
+.dispatch-container .cdp-timing-stat-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.dispatch-container .cdp-timing-stat-value {
+ font-size: 18px;
+ font-weight: 800;
+ color: #0f172a;
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.1;
+}
+
+.dispatch-container .cdp-timing-stat-unit {
+ font-size: 11px;
+ font-weight: 700;
+ color: #94a3b8;
+ margin-left: 3px;
+}
+
+.dispatch-container .cdp-timing-stat-label {
+ font-size: 10px;
+ font-weight: 800;
+ color: #94a3b8;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+/* Mini-visualizations under each stat. */
+.dispatch-container .cdp-timing-stat-viz {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding-top: 8px;
+ border-top: 1px dashed rgba(15, 23, 42, 0.08);
+}
+
+.dispatch-container .cdp-timing-stat-viz-label {
+ font-size: 10px;
+ font-weight: 700;
+ color: #94a3b8;
+ margin-left: auto;
+ white-space: nowrap;
+}
+
+/* Stops-row — small dots, one per stop (capped at 12). */
+.dispatch-container .cdp-stops-dots {
+ flex-wrap: wrap;
+}
+.dispatch-container .cdp-stop-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
+ box-shadow: 0 1px 2px rgba(99, 102, 241, 0.3);
+ flex-shrink: 0;
+}
+
+/* Speed gauge — 0-60 scale bar with filled segment. */
+.dispatch-container .cdp-speed-gauge {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 4px;
+}
+
+.dispatch-container .cdp-speed-gauge-track {
+ position: relative;
+ height: 6px;
+ border-radius: 999px;
+ background: rgba(15, 23, 42, 0.06);
+ overflow: hidden;
+}
+
+.dispatch-container .cdp-speed-gauge-fill {
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #10b981 0%, #6366f1 60%, #f59e0b 100%);
+ box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3);
+ transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.dispatch-container .cdp-speed-gauge-scale {
+ display: flex;
+ justify-content: space-between;
+ font-size: 9px;
+ font-weight: 700;
+ color: #94a3b8;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.04em;
+}
+
+/* Highlights (best / worst) --------------------------------- */
+/* Full-width vertically-stacked cards. A 4px colored rail on the left
+ encodes good/bad. Inside: a label-icon header + a right-aligned step
+ chip, then the customer name as the headline, then metric pills. */
+.dispatch-container .cdp-highlights {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.dispatch-container .cdp-highlight {
+ position: relative;
+ display: flex;
+ align-items: stretch;
+ padding: 0;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid rgba(15, 23, 42, 0.07);
+ cursor: pointer;
+ overflow: hidden;
+ transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
+}
+.dispatch-container .cdp-highlight:hover {
+ transform: translateX(2px);
+ box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08);
+}
+
+.dispatch-container .cdp-highlight-rail {
+ width: 4px;
+ flex-shrink: 0;
+ align-self: stretch;
+}
+
+.dispatch-container .cdp-highlight-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
- gap: 2px;
+ gap: 8px;
+ padding: 12px 14px;
+}
+
+.dispatch-container .cdp-highlight.is-best {
+ background: linear-gradient(90deg, rgba(16, 185, 129, 0.07) 0%, #fff 65%);
+ border-color: rgba(16, 185, 129, 0.28);
+}
+.dispatch-container .cdp-highlight.is-best:hover {
+ border-color: rgba(16, 185, 129, 0.5);
+}
+.dispatch-container .cdp-highlight.is-best .cdp-highlight-rail {
+ background: linear-gradient(180deg, #10b981 0%, #16a34a 100%);
+}
+
+.dispatch-container .cdp-highlight.is-worst {
+ background: linear-gradient(90deg, rgba(239, 68, 68, 0.07) 0%, #fff 65%);
+ border-color: rgba(239, 68, 68, 0.28);
+}
+.dispatch-container .cdp-highlight.is-worst:hover {
+ border-color: rgba(239, 68, 68, 0.5);
+}
+.dispatch-container .cdp-highlight.is-worst .cdp-highlight-rail {
+ background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
+}
+
+.dispatch-container .cdp-highlight-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
}
.dispatch-container .cdp-highlight-label {
display: inline-flex;
align-items: center;
- gap: 4px;
- font-size: 9px;
+ gap: 6px;
+ font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
+ min-width: 0;
}
.dispatch-container .cdp-highlight.is-best .cdp-highlight-label { color: #16a34a; }
.dispatch-container .cdp-highlight.is-worst .cdp-highlight-label { color: #dc2626; }
+.dispatch-container .cdp-highlight-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 7px;
+ font-size: 14px;
+ color: #fff;
+ flex-shrink: 0;
+}
+.dispatch-container .cdp-highlight.is-best .cdp-highlight-chip {
+ background: linear-gradient(135deg, #10b981 0%, #16a34a 100%);
+ box-shadow: 0 1px 3px rgba(16, 185, 129, 0.35);
+}
+.dispatch-container .cdp-highlight.is-worst .cdp-highlight-chip {
+ background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
+ box-shadow: 0 1px 3px rgba(239, 68, 68, 0.35);
+}
+
+.dispatch-container .cdp-highlight-step-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 9px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ flex-shrink: 0;
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15);
+}
+
.dispatch-container .cdp-highlight-title {
- font-size: 12px;
+ font-size: 15px;
font-weight: 700;
color: #0f172a;
+ line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ letter-spacing: -0.01em;
}
.dispatch-container .cdp-highlight-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.dispatch-container .cdp-highlight-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px 9px;
+ border-radius: 999px;
font-size: 11px;
- font-weight: 600;
- color: #64748b;
+ font-weight: 800;
+ letter-spacing: 0.01em;
+ font-variant-numeric: tabular-nums;
+}
+.dispatch-container .cdp-highlight-pill.is-bad {
+ background: rgba(239, 68, 68, 0.12);
+ color: #dc2626;
+}
+.dispatch-container .cdp-highlight-pill.is-good {
+ background: rgba(16, 185, 129, 0.12);
+ color: #16a34a;
}
/* Trips breakdown ------------------------------------------- */
@@ -6946,11 +7452,11 @@
}
.dispatch-container .cdp-seq-status {
- font-size: 10px;
+ font-size: 12px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
- padding: 2px 8px;
+ padding: 3px 10px;
border-radius: 999px;
flex-shrink: 0;
}
@@ -7022,14 +7528,14 @@
}
.dispatch-container .cdp-seq-diff-num {
- width: 26px;
- height: 26px;
+ width: 30px;
+ height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
- font-size: 12px;
+ font-size: 14px;
font-weight: 800;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.7),
@@ -7045,7 +7551,7 @@
}
.dispatch-container .cdp-seq-diff-title {
- font-size: 12px;
+ font-size: 14px;
font-weight: 700;
color: #0f172a;
overflow: hidden;
@@ -7054,15 +7560,15 @@
}
.dispatch-container .cdp-seq-diff-sub {
- font-size: 11px;
+ font-size: 13px;
font-weight: 600;
color: #64748b;
}
.dispatch-container .cdp-seq-diff-tag {
- font-size: 11px;
+ font-size: 13px;
font-weight: 800;
- padding: 3px 8px;
+ padding: 3px 10px;
border-radius: 999px;
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
@@ -7073,7 +7579,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
- font-size: 12px;
+ font-size: 14px;
font-weight: 700;
color: #16a34a;
padding: 6px 10px;
@@ -7081,7 +7587,7 @@
border-radius: 8px;
}
.dispatch-container .cdp-seq-good svg {
- font-size: 16px;
+ font-size: 18px;
}
/* Cascade-aware sequence diff groups — when N consecutive shifted steps
@@ -7123,7 +7629,7 @@
.dispatch-container .cdp-seq-group-num-label {
position: relative;
color: #fff;
- font-size: 11px;
+ font-size: 13px;
font-weight: 800;
letter-spacing: -0.01em;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
@@ -7132,9 +7638,9 @@
.dispatch-container .cdp-seq-group-delta {
display: inline-flex;
align-items: center;
- padding: 1px 7px;
+ padding: 2px 8px;
margin-left: 4px;
- font-size: 11px;
+ font-size: 13px;
font-weight: 800;
border-radius: 999px;
background: rgba(99, 102, 241, 0.15);
@@ -7203,17 +7709,17 @@
border-radius: 8px;
}
.dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-num {
- width: 22px;
- height: 22px;
- font-size: 11px;
+ width: 26px;
+ height: 26px;
+ font-size: 13px;
}
.dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-title {
- font-size: 11.5px;
+ font-size: 13.5px;
}
.dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-sub {
- font-size: 10.5px;
+ font-size: 12.5px;
}
.dispatch-container .cdp-seq-diff.is-nested .cdp-seq-diff-tag {
- font-size: 10px;
- padding: 2px 6px;
+ font-size: 12px;
+ padding: 2px 8px;
}
\ No newline at end of file
diff --git a/src/pages/nearle/dispatch/Dispatch.js b/src/pages/nearle/dispatch/Dispatch.js
index 220cd30..eb85503 100644
--- a/src/pages/nearle/dispatch/Dispatch.js
+++ b/src/pages/nearle/dispatch/Dispatch.js
@@ -180,49 +180,10 @@ function CompareMapClickUnpin({ 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).
+// Captures the Leaflet map instance for the parent component via a ref. Kept
+// available even after the two-map Compare layout was unified into one map,
+// since future per-step imperative zoom logic still needs a handle on the
+// planned map instance.
function CaptureMap({ targetRef }) {
const map = useMap();
useEffect(() => {
@@ -233,43 +194,6 @@ function CaptureMap({ targetRef }) {
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
@@ -731,6 +655,11 @@ const Dispatch = ({
// marker; clicking the marker again unpins it. Stored in a ref because this
// only drives imperative leaflet calls — no re-render needed.
const pinnedPopupsRef = useRef(new Set());
+ // Short-lived close timer for the Compare drop-pin popup. Gives the
+ // cursor a ~120ms window to travel from the marker onto the popup
+ // without firing mouseout → closePopup mid-transit. Any new mouseover
+ // cancels a pending close, so the popup glides instead of flickering.
+ const compareHoverTimerRef = useRef(null);
const isControlled = selectedRiderId !== undefined;
const [clock, setClock] = useState('');
@@ -950,6 +879,12 @@ const Dispatch = ({
// per-rider). Kept as a single state flag so we don't entangle it with
// viewMode/focused* logic.
const [compareOpen, setCompareOpen] = useState(false);
+ // Which layer the unified compare map renders.
+ // 'combined' = planned (dashed) + actual GPS (solid) overlaid on one map
+ // 'planned' = planned route only
+ // 'actual' = actual GPS tracks only
+ // Driven by the segmented control overlaid on the compare map's top-left.
+ const [compareViewMode, setCompareViewMode] = useState('combined');
// Default-open toggle for the "Route sequence" section in the compare data
// panel — shows planned vs actual visit order with out-of-order steps flagged.
const [sequenceOpen, setSequenceOpen] = useState(true);
@@ -978,25 +913,14 @@ const Dispatch = ({
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.
+ // Compare UI — step focus on the unified compare map.
+ // focusedCompareStep: null = "overall" (whole day); 1..N = drill into
+ // that single delivery. The unified map zooms to that step's bounds
+ // and the data panel switches from day-summary to per-step delta.
+ // leftMapRef: Leaflet map instance captured via CaptureMap — kept so
+ // future per-step zoom logic can drive the map imperatively.
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);
// Stable Canvas renderer for the planned (left) map. Leaflet's default
// canvas (used when `preferCanvas` is true) only draws ~10% beyond the
@@ -1338,15 +1262,6 @@ const Dispatch = ({
: internalFocusedRider;
// Per-rider canvas renderer for the actual (right) map in Compare mode.
- // The actual map remounts on rider change (via key={`compare-${rider.id}`})
- // so we recreate the renderer in lock-step to keep its map binding fresh.
- // Same padding as the planned map (1.5 → ~4× viewport area) so polylines
- // don't visually break at the canvas edge during drag.
- const actualMapRenderer = useMemo(
- () => L.canvas({ padding: 1.5, tolerance: 5 }),
- [focusedRider?.id]
- );
-
// Single setter used by every interactive site in the UI. In uncontrolled mode it
// updates local state; in controlled mode it only notifies the parent.
const handleRiderFocus = useCallback(
@@ -1612,21 +1527,9 @@ const Dispatch = ({
useEffect(() => {
setFocusedCompareStep(null);
setExpandedSeqGroups(new Set());
+ setCompareViewMode('combined');
}, [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(() => {
@@ -2101,7 +2004,130 @@ const Dispatch = ({
);
+ // Shared order-popup body used by both the planned-route number markers
+ // and the Compare mode actual-track drop pins. Extracted so clicking a
+ // pin on either layer surfaces the exact same Timeline + Details + KM
+ // chips — operators learn the layout once and trust it everywhere.
+ const renderOrderPopupContent = (o) => {
+ const statusStyle = getStatusStyle(o.orderstatus);
+ return (
+ <>
+
+ >
+ );
+ };
+
const renderMarkers = () => {
+ // Compare "Actual GPS only" view: drop the planned order markers entirely.
+ // The actual-track drop pins rendered later on the same map carry the
+ // same step numbers and step-palette colors, so showing both creates
+ // duplicate, slightly-offset pins that clutter the view.
+ if (compareOpen && focusedRider && compareViewMode === 'actual') return null;
+
let ordersToRender = allOrders;
if (focusedZone) ordersToRender = focusedZone.orders;
if (focusedRider) ordersToRender = focusedRider.orders;
@@ -2194,111 +2220,7 @@ const Dispatch = ({
}}
>
);
@@ -2306,7 +2228,18 @@ const Dispatch = ({
};
const renderRoutes = () => {
+ // Compare "Actual GPS only" view hides EVERY planned polyline on the
+ // unified compare map — both the static rendering AND the Animate
+ // Routes per-pair segments. Without this gate the animation pours the
+ // planned route on top of the actual GPS in Actual-only mode, which
+ // is the opposite of what that view exists to show. The actual-track
+ // polylines render their own progressive draw via animatedActualProgress
+ // (see riderActualTracks.map inside the MapContainer), so the actual
+ // animation still plays correctly when planned is hidden.
+ const hidePlanned = compareOpen && focusedRider && compareViewMode === 'actual';
+
if (isAnimating) {
+ if (hidePlanned) return [];
return animatedSegments.map((s, i) => (
));
@@ -2314,6 +2247,7 @@ const Dispatch = ({
const routes = [];
const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null;
+ if (hidePlanned) return routes;
riders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return;
@@ -2357,7 +2291,16 @@ const Dispatch = ({
const weight = isKitchenView ? 7 : 6;
// Aerial fallback (OSRM permanently failed) is rendered dashed so it visually
// reads as an estimate vs. an actual routed road polyline.
- const dashArray = failed ? '8 6' : undefined;
+ // In Combined mode (planned + actual overlaid on a single map), the
+ // planned polyline renders dashed so the operator can visually tell
+ // it apart from the solid actual-GPS polyline drawn for the same step
+ // in the same step-palette color. OSRM-failed segments stay dashed
+ // either way — the existing aerial-fallback signal still wins.
+ const isCompareTargetForDash =
+ compareOpen && focusedRider && r.id === focusedRider.id;
+ const combinedPlannedDash =
+ isCompareTargetForDash && compareViewMode === 'combined' ? '6 5' : undefined;
+ const dashArray = failed ? '8 6' : combinedPlannedDash;
// Compare mode: recolor the planned polyline per step so the left
// (planned) map's segment for delivery X uses the same STEP_PALETTE
@@ -3602,15 +3545,6 @@ const Dispatch = ({