Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Crew Dispatch & Routing Command Center</title> | |
| <meta name="description" content="Operations control dashboard for routing, assigning, and dispatching road maintenance crew groups." /> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" /> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" /> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script> | |
| <script src="gis.js"></script> | |
| <style> | |
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 1. COLOR VARIABLES & GENERAL RESET | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| :root { | |
| --bg-dark: #070D1A; | |
| --panel-bg: rgba(13, 21, 37, 0.85); | |
| --panel-border: rgba(255, 255, 255, 0.08); | |
| --text-main: #FFFFFF; | |
| --text-muted: #94A3B8; | |
| --text-dim: #64748B; | |
| --color-blue: #3B82F6; | |
| --color-blue-glow: rgba(59, 130, 246, 0.35); | |
| --color-accent: #8B5CF6; | |
| --radius-lg: 16px; | |
| --radius-md: 10px; | |
| --font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| } | |
| *, *::before, *::after { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 2. PREMIUM NAVIGATION BAR | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .console-nav { | |
| background: rgba(10, 17, 31, 0.95); | |
| border-bottom: 1px solid var(--panel-border); | |
| height: 64px; | |
| display: flex; | |
| align-items: center; | |
| position: relative; | |
| z-index: 1000; | |
| backdrop-filter: blur(12px); | |
| flex-shrink: 0; | |
| } | |
| .console-nav__inner { | |
| width: 100%; | |
| max-width: 1500px; | |
| margin: 0 auto; | |
| padding: 0 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| } | |
| .console-nav__brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .console-nav__icon { | |
| width: 36px; | |
| height: 36px; | |
| background: linear-gradient(135deg, #0061D5, #004FA8); | |
| border-radius: 8px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| box-shadow: 0 4px 16px rgba(0, 97, 213, 0.35); | |
| } | |
| .console-nav__title { | |
| font-size: 15px; | |
| font-weight: 800; | |
| color: var(--text-main); | |
| letter-spacing: -0.01em; | |
| } | |
| .console-nav__sub { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| margin-top: 1px; | |
| } | |
| .console-nav__right { | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| } | |
| .nav-links { | |
| display: flex; | |
| gap: 6px; | |
| } | |
| .nav-link { | |
| padding: 8px 14px; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| transition: all 0.2s ease; | |
| } | |
| .nav-link:hover { | |
| background: rgba(255, 255, 255, 0.05); | |
| color: var(--text-main); | |
| } | |
| .nav-link.active { | |
| background: rgba(59, 130, 246, 0.15); | |
| color: #60A5FA; | |
| font-weight: 600; | |
| border: 1px solid rgba(59, 130, 246, 0.25); | |
| } | |
| .console-conn-badge { | |
| display: flex; | |
| align-items: center; | |
| gap: 7px; | |
| padding: 6px 14px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| background: rgba(16, 185, 129, 0.1); | |
| color: #34D399; | |
| border: 1px solid rgba(52, 211, 153, 0.25); | |
| } | |
| .console-conn-dot { | |
| width: 7px; | |
| height: 7px; | |
| border-radius: 50%; | |
| background: currentColor; | |
| animation: pulse-dot 1.8s infinite; | |
| } | |
| @keyframes pulse-dot { | |
| 0%, 100% { opacity: 1; transform: scale(1); } | |
| 50% { opacity: 0.4; transform: scale(0.85); } | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 3. MAIN LAYOUT GRID | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .dashboard-container { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 360px 1fr; | |
| position: relative; | |
| height: calc(100vh - 64px); | |
| overflow: hidden; | |
| } | |
| @media (max-width: 960px) { | |
| .dashboard-container { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: 350px 1fr; | |
| } | |
| } | |
| /* ββ Left Sidebar (Command Panel) ββ */ | |
| .sidebar { | |
| background: #0D1527; | |
| border-right: 1px solid var(--panel-border); | |
| display: flex; | |
| flex-direction: column; | |
| overflow-y: auto; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| .sidebar-section { | |
| padding: 20px 24px; | |
| border-bottom: 1px solid var(--panel-border); | |
| } | |
| .sidebar-section:last-child { | |
| border-bottom: none; | |
| flex: 1; | |
| } | |
| .sidebar-title { | |
| font-size: 11px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--text-dim); | |
| margin-bottom: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| /* Metric Grid */ | |
| .metrics-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| } | |
| .metric-card { | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1px solid rgba(255, 255, 255, 0.04); | |
| border-radius: var(--radius-md); | |
| padding: 12px 14px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .metric-card::before { | |
| content: ''; | |
| position: absolute; | |
| left: 0; top: 0; bottom: 0; | |
| width: 3px; | |
| background: var(--color-blue); | |
| } | |
| .metric-card.accent::before { | |
| background: var(--color-accent); | |
| } | |
| .metric-label { | |
| font-size: 10px; | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| margin-bottom: 4px; | |
| } | |
| .metric-val { | |
| font-size: 20px; | |
| font-weight: 800; | |
| color: var(--text-main); | |
| } | |
| /* Deployed Crews Stop Schedules */ | |
| .crew-list { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| .crew-card { | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| border-radius: var(--radius-md); | |
| overflow: hidden; | |
| transition: all 0.2s ease; | |
| } | |
| .crew-card:hover { | |
| border-color: rgba(255,255,255,0.12); | |
| } | |
| .crew-header { | |
| padding: 10px 14px; | |
| background: rgba(255, 255, 255, 0.02); | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.04); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| } | |
| .crew-info { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .crew-color-indicator { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| box-shadow: 0 0 8px currentColor; | |
| } | |
| .crew-name { | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| } | |
| .crew-meta { | |
| font-size: 11px; | |
| font-weight: 600; | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| background: rgba(255,255,255,0.05); | |
| color: var(--text-muted); | |
| } | |
| .crew-stops { | |
| padding: 10px 14px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| max-height: 240px; | |
| overflow-y: auto; | |
| } | |
| .stop-item { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 10px; | |
| padding: 6px 8px; | |
| border-radius: 6px; | |
| font-size: 12px; | |
| cursor: pointer; | |
| transition: all 0.15s ease; | |
| background: rgba(255,255,255,0.01); | |
| border: 1px solid transparent; | |
| } | |
| .stop-item:hover { | |
| background: rgba(255, 255, 255, 0.06); | |
| border-color: rgba(255, 255, 255, 0.08); | |
| } | |
| .stop-badge { | |
| font-size: 10px; | |
| font-weight: 800; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: var(--text-dim); | |
| color: var(--bg-dark); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| margin-top: 1px; | |
| } | |
| .stop-info { | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| .stop-id { | |
| font-family: monospace; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .stop-address { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| margin-top: 1px; | |
| } | |
| /* Global action buttons */ | |
| .action-panel { | |
| padding: 16px 24px; | |
| background: rgba(10, 17, 31, 0.4); | |
| border-top: 1px solid var(--panel-border); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .btn { | |
| width: 100%; | |
| padding: 11px 16px; | |
| border-radius: var(--radius-md); | |
| font-size: 13px; | |
| font-weight: 700; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%); | |
| color: #fff; | |
| box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3); | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 6px 18px rgba(59, 130, 246, 0.45); | |
| } | |
| .btn-secondary { | |
| background: rgba(255, 255, 255, 0.05); | |
| color: var(--text-main); | |
| border: 1px solid var(--panel-border); | |
| } | |
| .btn-secondary:hover { | |
| background: rgba(255, 255, 255, 0.09); | |
| border-color: rgba(255, 255, 255, 0.15); | |
| } | |
| .btn:disabled { | |
| opacity: 0.55; | |
| cursor: not-allowed; | |
| transform: none ; | |
| box-shadow: none ; | |
| } | |
| .dispatch-status-note { | |
| font-size: 11px; | |
| line-height: 1.5; | |
| color: var(--text-muted); | |
| padding: 10px 12px; | |
| border-radius: 10px; | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid rgba(255, 255, 255, 0.06); | |
| } | |
| /* ββ Right Panel (Interactive Leaflet Map) ββ */ | |
| .map-container-outer { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #dispatch-map { | |
| width: 100%; | |
| height: 100%; | |
| background: var(--bg-dark); | |
| z-index: 1; | |
| } | |
| /* Map overlays and absolute badges */ | |
| .map-badge { | |
| position: absolute; | |
| top: 20px; | |
| right: 20px; | |
| z-index: 999; | |
| background: rgba(13, 21, 37, 0.9); | |
| backdrop-filter: blur(10px); | |
| border: 1.5px solid rgba(255, 255, 255, 0.08); | |
| border-radius: var(--radius-md); | |
| padding: 10px 14px; | |
| pointer-events: none; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.4); | |
| } | |
| .map-badge-header { | |
| font-size: 9px; | |
| font-weight: 800; | |
| color: var(--text-dim); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .map-badge-val { | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .map-badge-dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: #10B981; | |
| } | |
| .map-hint-banner { | |
| position: absolute; | |
| bottom: 24px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 999; | |
| background: rgba(13, 21, 37, 0.95); | |
| backdrop-filter: blur(8px); | |
| border: 1.5px solid rgba(59, 130, 246, 0.25); | |
| color: #93C5FD; | |
| padding: 10px 20px; | |
| border-radius: 30px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); | |
| pointer-events: none; | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 4. DIALOG & MOCK MODALS (SETUP OVERLAY) | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .setup-overlay { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 9999; | |
| background: rgba(5, 10, 20, 0.85); | |
| backdrop-filter: blur(16px); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px; | |
| transition: all 0.3s ease; | |
| } | |
| .setup-card { | |
| background: #0E1628; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| width: 100%; | |
| max-width: 580px; | |
| border-radius: var(--radius-lg); | |
| overflow: hidden; | |
| box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6); | |
| animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); | |
| } | |
| @keyframes zoomIn { | |
| from { opacity: 0; transform: scale(0.95); } | |
| to { opacity: 1; transform: scale(1); } | |
| } | |
| .setup-header { | |
| padding: 28px 32px; | |
| border-bottom: 1px solid var(--panel-border); | |
| background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%); | |
| } | |
| .setup-title { | |
| font-size: 20px; | |
| font-weight: 800; | |
| color: #fff; | |
| margin-bottom: 6px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .setup-desc { | |
| font-size: 13px; | |
| color: var(--text-muted); | |
| line-height: 1.5; | |
| } | |
| .setup-body { | |
| padding: 28px 32px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 24px; | |
| } | |
| .setup-section-label { | |
| font-size: 11px; | |
| font-weight: 800; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: var(--text-dim); | |
| margin-bottom: 10px; | |
| } | |
| /* Wards Checkbox Grid */ | |
| .wards-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 8px; | |
| } | |
| @media (max-width: 480px) { | |
| .wards-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .ward-checkbox-card { | |
| background: rgba(255, 255, 255, 0.02); | |
| border: 1.5px solid rgba(255, 255, 255, 0.05); | |
| border-radius: var(--radius-md); | |
| padding: 10px 14px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| cursor: pointer; | |
| transition: all 0.15s ease; | |
| user-select: none; | |
| } | |
| .ward-checkbox-card:hover { | |
| background: rgba(255,255,255,0.05); | |
| border-color: rgba(255,255,255,0.1); | |
| } | |
| .ward-checkbox-card.checked { | |
| background: rgba(59, 130, 246, 0.08); | |
| border-color: rgba(59, 130, 246, 0.4); | |
| } | |
| .ward-checkbox-card input { | |
| display: none; | |
| } | |
| .ward-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| } | |
| .ward-label { | |
| font-size: 13px; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| } | |
| .ward-checkbox-card.checked .ward-label { | |
| color: var(--text-main); | |
| } | |
| /* Crew Group Slider */ | |
| .slider-wrap { | |
| background: rgba(255,255,255,0.01); | |
| border: 1px solid rgba(255,255,255,0.04); | |
| border-radius: var(--radius-md); | |
| padding: 16px 20px; | |
| } | |
| .slider-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| } | |
| .slider-val-label { | |
| font-size: 16px; | |
| font-weight: 800; | |
| color: var(--color-blue); | |
| text-shadow: 0 0 10px var(--color-blue-glow); | |
| } | |
| .crew-slider { | |
| width: 100%; | |
| -webkit-appearance: none; | |
| appearance: none; | |
| height: 6px; | |
| border-radius: 5px; | |
| background: rgba(255, 255, 255, 0.1); | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| .crew-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 18px; | |
| height: 18px; | |
| border-radius: 50%; | |
| background: #3B82F6; | |
| box-shadow: 0 0 10px rgba(59, 130, 246, 0.8); | |
| transition: transform 0.1s ease; | |
| } | |
| .crew-slider::-webkit-slider-thumb:hover { | |
| transform: scale(1.15); | |
| } | |
| .setup-footer { | |
| padding: 20px 32px 28px; | |
| border-top: 1px solid var(--panel-border); | |
| display: flex; | |
| gap: 12px; | |
| } | |
| /* ββ Popups and toasts ββ */ | |
| .leaflet-popup-content-wrapper { | |
| background: #0E1628 ; | |
| color: #fff ; | |
| border: 1.5px solid rgba(255,255,255,0.1) ; | |
| border-radius: 12px ; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5) ; | |
| } | |
| .leaflet-popup-tip { | |
| background: #0E1628 ; | |
| border: 1px solid rgba(255,255,255,0.1) ; | |
| } | |
| .popup-title { | |
| font-size: 13px; | |
| font-weight: 800; | |
| color: var(--text-main); | |
| margin-bottom: 4px; | |
| font-family: monospace; | |
| } | |
| .popup-desc { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| line-height: 1.4; | |
| margin-bottom: 8px; | |
| } | |
| .popup-btn-danger { | |
| padding: 6px 12px; | |
| background: #EF4444; | |
| color: #fff; | |
| font-size: 11px; | |
| font-weight: 700; | |
| border-radius: 6px; | |
| border: none; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| gap: 4px; | |
| transition: all 0.15s; | |
| } | |
| .popup-btn-danger:hover { | |
| background: #DC2626; | |
| } | |
| .popup-add-form { | |
| padding: 4px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .popup-add-title { | |
| font-size: 12px; | |
| font-weight: 700; | |
| color: #fff; | |
| } | |
| .popup-select { | |
| padding: 5px 8px; | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 6px; | |
| color: #fff; | |
| font-size: 11px; | |
| outline: none; | |
| } | |
| .popup-btn-success { | |
| padding: 6px 12px; | |
| background: #10B981; | |
| color: #fff; | |
| font-size: 11px; | |
| font-weight: 700; | |
| border-radius: 6px; | |
| border: none; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| } | |
| .popup-btn-success:hover { | |
| background: #059669; | |
| } | |
| /* Print Dispatch routes sheet */ | |
| .print-overlay { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 99999; | |
| background: #070D1A; | |
| color: #fff; | |
| padding: 40px; | |
| overflow-y: auto; | |
| display: none; | |
| } | |
| .print-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 2px solid var(--panel-border); | |
| padding-bottom: 16px; | |
| margin-bottom: 24px; | |
| } | |
| .print-logo { | |
| font-size: 22px; | |
| font-weight: 800; | |
| color: #fff; | |
| } | |
| .print-meta { | |
| font-size: 12px; | |
| color: var(--text-muted); | |
| } | |
| .print-crew-section { | |
| margin-bottom: 32px; | |
| background: rgba(255,255,255,0.02); | |
| border: 1px solid var(--panel-border); | |
| border-radius: 12px; | |
| padding: 20px; | |
| } | |
| .print-crew-title { | |
| font-size: 16px; | |
| font-weight: 800; | |
| margin-bottom: 12px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .print-stops-table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 13px; | |
| } | |
| .print-stops-table th, .print-stops-table td { | |
| padding: 10px; | |
| border-bottom: 1px solid var(--panel-border); | |
| text-align: left; | |
| } | |
| .print-stops-table th { | |
| font-weight: 700; | |
| color: var(--text-muted); | |
| } | |
| .print-actions { | |
| display: flex; | |
| gap: 12px; | |
| } | |
| /* Notification Toasts */ | |
| .toast { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| z-index: 10000; | |
| background: rgba(13, 21, 37, 0.95); | |
| border: 1.5px solid rgba(255, 255, 255, 0.08); | |
| border-radius: var(--radius-md); | |
| padding: 14px 20px; | |
| color: #fff; | |
| font-size: 13px; | |
| font-weight: 600; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5); | |
| transform: translateY(150%); | |
| opacity: 0; | |
| transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| pointer-events: none; | |
| max-width: 380px; | |
| } | |
| .toast.show { | |
| transform: translateY(0); | |
| opacity: 1; | |
| } | |
| .toast.success { border-color: rgba(52, 211, 153, 0.4); background: rgba(6, 95, 70, 0.95); } | |
| .toast.error { border-color: rgba(239, 68, 68, 0.4); background: rgba(155, 28, 28, 0.95); } | |
| .toast.info { border-color: rgba(59, 130, 246, 0.4); background: rgba(30, 58, 138, 0.95); } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 1. NAVIGATION BAR | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <nav class="console-nav"> | |
| <div class="console-nav__inner"> | |
| <div class="console-nav__brand"> | |
| <div class="console-nav__icon"> | |
| <svg width="18" height="18" viewBox="0 0 20 20" fill="none"> | |
| <path d="M3 14h14M3 10h14M5 6h10" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/> | |
| <circle cx="10" cy="16" r="1.5" fill="#fff"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="console-nav__title">Ops Control Room</div> | |
| <div class="console-nav__sub" id="nav-city-label">City Road Operations</div> | |
| </div> | |
| </div> | |
| <div class="console-nav__right"> | |
| <div class="nav-links"> | |
| <a href="index.html" class="nav-link">Submit Report</a> | |
| <a href="track.html" class="nav-link">Track Case</a> | |
| <a href="ops-review.html" class="nav-link">Ops Review</a> | |
| <a href="deploy-crew.html" class="nav-link active">Deploy Crews</a> | |
| <a href="crew-closeout.html" class="nav-link">Crew Closeout</a> | |
| <a href="settings.html" class="nav-link">Settings</a> | |
| </div> | |
| <div class="console-conn-badge"> | |
| <span class="console-conn-dot"></span> | |
| <span>Live Dispatch</span> | |
| </div> | |
| </div> | |
| </div> | |
| </nav> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 2. MAIN CONTROL PANEL LAYOUT | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="dashboard-container"> | |
| <!-- SIDEBAR: COMMAND CENTER --> | |
| <div class="sidebar"> | |
| <!-- Metrics overview --> | |
| <div class="sidebar-section"> | |
| <div class="sidebar-title">Global Operations Metrics</div> | |
| <div class="metrics-grid"> | |
| <div class="metric-card"> | |
| <div class="metric-label">Active Wards</div> | |
| <div class="metric-val" id="metric-wards">0</div> | |
| </div> | |
| <div class="metric-card accent"> | |
| <div class="metric-label">Deployed Crews</div> | |
| <div class="metric-val" id="metric-crews">0</div> | |
| </div> | |
| <div class="metric-card accent"> | |
| <div class="metric-label">Potholes in region</div> | |
| <div class="metric-val" id="metric-potholes">0</div> | |
| </div> | |
| <div class="metric-card"> | |
| <div class="metric-label">Avg workload</div> | |
| <div class="metric-val" id="metric-workload">0</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Deployed crew groups --> | |
| <div class="sidebar-section" style="overflow-y: auto;"> | |
| <div class="sidebar-title" style="margin-bottom: 16px;"> | |
| <span>Active Crew Routes</span> | |
| <span style="font-size: 10px; font-weight: 500; color: var(--text-dim);">Optimized TSP paths</span> | |
| </div> | |
| <div class="crew-list" id="crew-list-container"> | |
| <!-- Crew cards populated by JS --> | |
| <div style="color: var(--text-dim); text-align: center; padding: 20px; font-size: 12px;">Crews not yet deployed.</div> | |
| </div> | |
| </div> | |
| <!-- Actions --> | |
| <div class="action-panel"> | |
| <button class="btn btn-primary" id="btn-send-to-crew"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 2L11 13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> | |
| Send Final Routes to Crews | |
| </button> | |
| <button class="btn btn-primary" id="btn-reconfigure"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> | |
| Modify Coverage | |
| </button> | |
| <button class="btn btn-secondary" id="btn-export-dispatch"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> | |
| Export Route Sheets | |
| </button> | |
| <a class="btn btn-secondary" href="crew-closeout.html" style="text-decoration:none;display:flex;align-items:center;justify-content:center;gap:8px;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg> | |
| Open Crew Closeout | |
| </a> | |
| <div class="dispatch-status-note" id="dispatch-status-note">Finalize the crew groups, then send the route package to the field crews.</div> | |
| </div> | |
| </div> | |
| <!-- MAP VIEWPORT --> | |
| <div class="map-container-outer"> | |
| <div id="dispatch-map"></div> | |
| <!-- On-map status badge --> | |
| <div class="map-badge" id="map-status-overlay" style="display: none;"> | |
| <div class="map-badge-header">Active Deployment Scope</div> | |
| <div class="map-badge-val"> | |
| <span class="map-badge-dot" id="map-status-dot"></span> | |
| <span id="map-status-text">Philadelphia Control</span> | |
| </div> | |
| </div> | |
| <!-- User assistance banner --> | |
| <div class="map-hint-banner"> | |
| <span>Click anywhere on the map to <strong>Add a Pothole</strong>. Numbered markers show stop order for each crew route.</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 3. SETUP CONFIGURATION OVERLAY MODAL | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="setup-overlay" id="setup-modal"> | |
| <div class="setup-card"> | |
| <div class="setup-header"> | |
| <h2 class="setup-title"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#60A5FA" stroke-width="2.5"><polygon points="12 2 2 7 12 12 22 7 12 2/-"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg> | |
| Crew Deployment Configuration | |
| </h2> | |
| <p class="setup-desc">Initialize maintenance zones, configure crew groupings, and auto-generate optimized route schedules to patch pothole incidents.</p> | |
| </div> | |
| <div class="setup-body"> | |
| <!-- Ward Checklist --> | |
| <div> | |
| <div class="setup-section-label">Select City Wards / Districts to Cover</div> | |
| <div class="wards-grid" id="wards-checkbox-grid"> | |
| <!-- Dynamic ward selectors populated from gis.js PHILLY_TEN_REGIONS --> | |
| </div> | |
| <div style="margin-top: 10px; display: flex; gap: 8px;"> | |
| <button class="btn btn-secondary btn-sm" id="btn-select-all-wards" style="width: auto; padding: 4px 10px; font-size: 11px;">Select All</button> | |
| <button class="btn btn-secondary btn-sm" id="btn-deselect-all-wards" style="width: auto; padding: 4px 10px; font-size: 11px;">Clear All</button> | |
| </div> | |
| </div> | |
| <!-- Crew slider --> | |
| <div> | |
| <div class="slider-header"> | |
| <div class="setup-section-label" style="margin-bottom: 0;">Number of Crew Groups to Dispatch</div> | |
| <div class="slider-val-label" id="slider-crew-count-val">3 Groups</div> | |
| </div> | |
| <div class="slider-wrap"> | |
| <input type="range" class="crew-slider" id="crew-count-slider" min="1" max="5" value="3" /> | |
| <div style="display: flex; justify-content: space-between; margin-top: 8px; font-size: 10px; color: var(--text-dim); font-weight: 700;"> | |
| <span>1 CREW</span> | |
| <span>2</span> | |
| <span>3</span> | |
| <span>4</span> | |
| <span>5 CREWS</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="setup-footer"> | |
| <button class="btn btn-primary" id="btn-launch-deployment" style="flex: 1;"> | |
| π Initialize Operations Command | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 4. EXPORT / DISPATCH SHEET MODAL | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="print-overlay" id="print-sheet-modal"> | |
| <div class="print-header"> | |
| <div> | |
| <h1 class="print-logo">City Crew Routing Dispatch Sheet</h1> | |
| <div class="print-meta" id="print-meta-text">Generated on...</div> | |
| </div> | |
| <div class="print-actions"> | |
| <button class="btn btn-primary" style="width:auto;" onclick="window.print()">Print Sheet</button> | |
| <button class="btn btn-secondary" style="width:auto;" id="btn-close-print">Close View</button> | |
| </div> | |
| </div> | |
| <div id="print-routes-container"> | |
| <!-- Populate crew sheets dynamically --> | |
| </div> | |
| </div> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 5. NOTIFICATION TOAST | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <div class="toast" id="toast-notify"> | |
| <span id="toast-text">Dispatch system active</span> | |
| </div> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 6. CORE JAVASCRIPT DISPATCH CONTROLLER | |
| βββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <script src="backend-api.js?v=20260604b"></script> | |
| <script> | |
| ; | |
| // ββ Preloaded Demo Potholes (from ops-review.html) βββββββββββββββββββββββ | |
| const MOCK_CASES = [ | |
| { caseId: 'PHL-20260520-DM001', location: { lat: 39.980, lng: -75.160 }, address: '1400 N Broad St, Philadelphia, PA 19121', ward: 'Lower North', aiResult: 'Large' }, | |
| { caseId: 'PHL-20260521-DM002', location: { lat: 39.954, lng: -75.188 }, address: '3200 Chestnut St, Philadelphia, PA 19104', ward: 'West', aiResult: 'Medium' }, | |
| { caseId: 'PHL-20260521-DM003', location: { lat: 39.942, lng: -75.150 }, address: '500 S 5th St, Philadelphia, PA 19147', ward: 'South', aiResult: 'Small' }, | |
| { caseId: 'PHL-20260522-DM004', location: { lat: 39.954, lng: -75.175 }, address: '2100 Market St, Philadelphia, PA 19103', ward: 'Central', aiResult: 'Medium' }, | |
| { caseId: 'PHL-20260522-DM005', location: { lat: 40.035, lng: -75.215 }, address: '700 Ridge Ave, Philadelphia, PA 19128', ward: 'Northwest', aiResult: 'Medium' }, | |
| // Added high density incidents to make routing feel extra awesome | |
| { caseId: 'PHL-ADD-001', location: { lat: 39.952, lng: -75.162 }, address: 'S Broad St & Market St', ward: 'Central', aiResult: 'Large' }, | |
| { caseId: 'PHL-ADD-002', location: { lat: 39.961, lng: -75.171 }, address: 'Benjamin Franklin Pkwy', ward: 'Central', aiResult: 'Small' }, | |
| { caseId: 'PHL-ADD-003', location: { lat: 39.938, lng: -75.181 }, address: 'Washington Ave & S 22nd St', ward: 'South', aiResult: 'Large' }, | |
| { caseId: 'PHL-ADD-004', location: { lat: 39.931, lng: -75.155 }, address: 'Mifflin St & S 6th St', ward: 'South', aiResult: 'Medium' }, | |
| { caseId: 'PHL-ADD-005', location: { lat: 39.972, lng: -75.195 }, address: 'Fairmount Park West', ward: 'West', aiResult: 'Medium' }, | |
| { caseId: 'PHL-ADD-006', location: { lat: 39.965, lng: -75.215 }, address: 'Girard Ave & N 44th St', ward: 'West', aiResult: 'Large' }, | |
| { caseId: 'PHL-ADD-007', location: { lat: 40.015, lng: -75.155 }, address: 'N Broad St & W Hunting Park Ave', ward: 'North', aiResult: 'Large' }, | |
| { caseId: 'PHL-ADD-008', location: { lat: 40.032, lng: -75.148 }, address: 'N 5th St & W Olney Ave', ward: 'North', aiResult: 'Medium' }, | |
| { caseId: 'PHL-ADD-009', location: { lat: 40.082, lng: -75.025 }, address: 'Roosevelt Blvd & Holme Ave', ward: 'Northeast', aiResult: 'Large' }, | |
| { caseId: 'PHL-ADD-010', location: { lat: 40.061, lng: -75.058 }, address: 'Oxford Ave & Levick St', ward: 'Northeast', aiResult: 'Small' } | |
| ]; | |
| // Harmonious colors for crew routes | |
| const CREW_STYLES = [ | |
| { name: 'Crew Alpha', color: '#3B82F6', text: '#93C5FD', bg: 'rgba(59, 130, 246, 0.15)' }, | |
| { name: 'Crew Bravo', color: '#10B981', text: '#A7F3D0', bg: 'rgba(16, 185, 129, 0.15)' }, | |
| { name: 'Crew Charlie',color: '#8B5CF6', text: '#C4B5FD', bg: 'rgba(139, 92, 246, 0.15)' }, | |
| { name: 'Crew Delta', color: '#F97316', text: '#FED7AA', bg: 'rgba(249, 115, 22, 0.15)' }, | |
| { name: 'Crew Echo', color: '#EC4899', text: '#FBCFE8', bg: 'rgba(236, 72, 153, 0.15)' } | |
| ]; | |
| // Philadelphia Planning Regions bounds | |
| const REGION_BOUNDS = { | |
| 'Northeast': [[40.04, -75.07], [40.14, -74.96]], | |
| 'Northwest': [[40.02, -75.28], [40.14, -75.17]], | |
| 'North': [[40.02, -75.17], [40.14, -75.07]], | |
| 'Lower North': [[39.97, -75.20], [40.02, -75.14]], | |
| 'West': [[39.94, -75.28], [40.04, -75.18]], | |
| 'River Wards': [[39.95, -75.09], [40.02, -74.96]], | |
| 'Central': [[39.94, -75.18], [40.02, -75.09]], | |
| 'South': [[39.90, -75.20], [39.95, -75.10]], | |
| 'Southwest': [[39.86, -75.28], [39.94, -75.15]], | |
| 'East': [[39.86, -75.10], [39.95, -74.96]] | |
| }; | |
| // ββ Global App State βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let activePotholes = []; // Master active pothole objects | |
| let selectedWards = []; // Wards chosen by user | |
| let numCrews = 3; // Deployed crew group count | |
| let requestedFocusCaseId = ''; | |
| let map = null; // Leaflet map reference | |
| let mapMarkers = []; // Active Leaflet marker objects | |
| let mapOverlayWards = []; // Active Leaflet rectangle overlays | |
| let mapPolylines = []; // Active Leaflet route polylines | |
| let crewAssignments = []; // Array of assignments: { crewIdx, potholes: [], route: [] } | |
| let shouldAutoLaunchFromRequest = false; | |
| let googleMapsSdkPromise = null; | |
| let routeRenderToken = 0; | |
| const MAX_GOOGLE_ROUTE_STOPS = 25; | |
| const DISPATCH_STORAGE = { | |
| adHoc: 'dispatch_ad_hoc_potholes', | |
| removed: 'dispatch_removed_case_ids', | |
| wards: 'dispatch_selected_wards', | |
| crews: 'dispatch_num_crews', | |
| seed: 'ops_review_dispatch_seed_cases', | |
| plan: 'dispatch_last_sent_plan', | |
| }; | |
| // DOM selectors shortcut | |
| const $ = id => document.getElementById(id); | |
| function sanitizeDispatchUi() { | |
| document.title = 'Crew Dispatch Command Center'; | |
| const hint = document.querySelector('.map-hint-banner span'); | |
| if (hint) { | |
| hint.innerHTML = 'Click anywhere on the map to <strong>Add a Pothole</strong>. Numbered markers show each crew route order.'; | |
| } | |
| const launchBtn = $('btn-launch-deployment'); | |
| if (launchBtn) { | |
| launchBtn.textContent = 'Initialize Operations Command'; | |
| } | |
| } | |
| function sanitizeDispatchPopupUi() { | |
| const popupButtons = Array.from(document.querySelectorAll('.leaflet-popup-content button')); | |
| popupButtons.forEach((button) => { | |
| if (/enable ward/i.test(button.textContent)) button.textContent = 'Enable Ward and Add'; | |
| if (/add to dispatch/i.test(button.textContent)) button.textContent = 'Add to Dispatch'; | |
| }); | |
| } | |
| function getRouteAssignmentLookup(assignments) { | |
| const lookup = new Map(); | |
| (Array.isArray(assignments) ? assignments : []).forEach((assign) => { | |
| const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length]; | |
| assign.route.forEach((pothole, stopIndex) => { | |
| lookup.set(pothole.caseId, { | |
| crewIdx: assign.crewIdx, | |
| crewName: style.name, | |
| crewColor: style.color, | |
| crewBg: style.bg, | |
| stopIndex: stopIndex + 1, | |
| totalStops: assign.route.length, | |
| }); | |
| }); | |
| }); | |
| return lookup; | |
| } | |
| function refreshDispatchStatusNote() { | |
| const note = $('dispatch-status-note'); | |
| const sendButton = $('btn-send-to-crew'); | |
| if (!note || !sendButton) return; | |
| const activeRouteCount = crewAssignments.reduce((count, assign) => count + assign.route.length, 0); | |
| sendButton.disabled = activeRouteCount === 0; | |
| const plan = readJsonStorage(DISPATCH_STORAGE.plan, null); | |
| if (plan && plan.sentAt) { | |
| note.textContent = `Last sent ${new Date(plan.sentAt).toLocaleString()} for ${plan.crews.length} crew group${plan.crews.length === 1 ? '' : 's'} across ${plan.totalStops} pothole stop${plan.totalStops === 1 ? '' : 's'}.`; | |
| } else if (activeRouteCount > 0) { | |
| note.textContent = `Routes ready: ${crewAssignments.filter((assign) => assign.route.length).length} active crew group${crewAssignments.filter((assign) => assign.route.length).length === 1 ? '' : 's'} and ${activeRouteCount} stop${activeRouteCount === 1 ? '' : 's'}.`; | |
| } else { | |
| note.textContent = 'Finalize the crew groups, then send the route package to the field crews.'; | |
| } | |
| } | |
| // Toast helper | |
| function showToast(msg, type = 'info') { | |
| const t = $('toast-notify'); | |
| const txt = $('toast-text'); | |
| txt.textContent = msg; | |
| t.className = `toast show ${type}`; | |
| setTimeout(() => { t.classList.remove('show'); }, 3000); | |
| } | |
| function readJsonStorage(key, fallback) { | |
| try { | |
| const raw = localStorage.getItem(key); | |
| return raw ? JSON.parse(raw) : fallback; | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| function writeJsonStorage(key, value) { | |
| localStorage.setItem(key, JSON.stringify(value)); | |
| } | |
| function getPersistedDispatchState() { | |
| return { | |
| adHocPotholes: readJsonStorage(DISPATCH_STORAGE.adHoc, []), | |
| removedCaseIds: readJsonStorage(DISPATCH_STORAGE.removed, []), | |
| selectedWards: readJsonStorage(DISPATCH_STORAGE.wards, null), | |
| numCrews: parseInt(localStorage.getItem(DISPATCH_STORAGE.crews) || '3', 10), | |
| }; | |
| } | |
| function getRequestedDispatchState() { | |
| const params = new URLSearchParams(window.location.search); | |
| const validWardNames = new Set(window.PHILLY_TEN_REGIONS.map((region) => region.name)); | |
| const wards = (params.get('wards') || '') | |
| .split(',') | |
| .map((value) => value.trim()) | |
| .filter((value) => validWardNames.has(value)); | |
| const crews = parseInt(params.get('crews') || '', 10); | |
| return { | |
| wards: Array.from(new Set(wards)), | |
| numCrews: Number.isFinite(crews) ? Math.min(5, Math.max(1, crews)) : null, | |
| focusCaseId: (params.get('focus') || '').trim(), | |
| }; | |
| } | |
| function getGoogleMapsJsApiKey() { | |
| return (localStorage.getItem('google_maps_js_api_key') || '').trim(); | |
| } | |
| async function loadGoogleMapsSdk() { | |
| if (window.google?.maps?.DirectionsService) { | |
| return window.google.maps; | |
| } | |
| const apiKey = getGoogleMapsJsApiKey(); | |
| if (!apiKey) return null; | |
| if (googleMapsSdkPromise) return googleMapsSdkPromise; | |
| googleMapsSdkPromise = new Promise((resolve, reject) => { | |
| const existingScript = document.getElementById('google-maps-sdk'); | |
| if (existingScript) { | |
| existingScript.addEventListener('load', () => resolve(window.google?.maps || null), { once: true }); | |
| existingScript.addEventListener('error', () => reject(new Error('Google Maps script failed to load.')), { once: true }); | |
| return; | |
| } | |
| const script = document.createElement('script'); | |
| script.id = 'google-maps-sdk'; | |
| script.async = true; | |
| script.defer = true; | |
| script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(apiKey)}&v=weekly`; | |
| script.onload = () => resolve(window.google?.maps || null); | |
| script.onerror = () => reject(new Error('Google Maps script failed to load.')); | |
| document.head.appendChild(script); | |
| }).catch((error) => { | |
| console.warn('[Dispatch] could not load Google Maps SDK:', error.message); | |
| googleMapsSdkPromise = null; | |
| return null; | |
| }); | |
| return googleMapsSdkPromise; | |
| } | |
| function getRouteLabelPoint(pathPoints) { | |
| if (!Array.isArray(pathPoints) || !pathPoints.length) return null; | |
| return pathPoints[Math.floor((pathPoints.length - 1) / 2)]; | |
| } | |
| async function requestGoogleRoadRoute(routeStops, directionsService) { | |
| if (!Array.isArray(routeStops) || routeStops.length < 2 || !directionsService || !window.google?.maps) { | |
| return null; | |
| } | |
| if (routeStops.length > MAX_GOOGLE_ROUTE_STOPS) { | |
| console.warn(`[Dispatch] skipping Google road route for ${routeStops.length} stops; limit is ${MAX_GOOGLE_ROUTE_STOPS}.`); | |
| return null; | |
| } | |
| const mapsApi = window.google.maps; | |
| const origin = routeStops[0].location; | |
| const destination = routeStops[routeStops.length - 1].location; | |
| const waypoints = routeStops.slice(1, -1).map((stop) => ({ | |
| location: new mapsApi.LatLng(stop.location.lat, stop.location.lng), | |
| stopover: true, | |
| })); | |
| return new Promise((resolve, reject) => { | |
| directionsService.route({ | |
| origin: new mapsApi.LatLng(origin.lat, origin.lng), | |
| destination: new mapsApi.LatLng(destination.lat, destination.lng), | |
| waypoints, | |
| optimizeWaypoints: false, | |
| provideRouteAlternatives: false, | |
| travelMode: mapsApi.TravelMode.DRIVING, | |
| }, (result, status) => { | |
| if (status !== 'OK' || !result?.routes?.length) { | |
| reject(new Error(`Directions status ${status || 'UNKNOWN'}`)); | |
| return; | |
| } | |
| const overviewPath = result.routes[0].overview_path || []; | |
| const pathPoints = overviewPath.map((point) => [point.lat(), point.lng()]); | |
| resolve(pathPoints.length ? pathPoints : null); | |
| }); | |
| }); | |
| } | |
| // Free road routing via the public OSRM demo server β no API key required. | |
| async function requestOsrmRoadRoute(routeStops) { | |
| if (!Array.isArray(routeStops) || routeStops.length < 2) return null; | |
| if (routeStops.length > MAX_GOOGLE_ROUTE_STOPS) { | |
| console.warn(`[Dispatch] skipping OSRM road route for ${routeStops.length} stops; limit is ${MAX_GOOGLE_ROUTE_STOPS}.`); | |
| return null; | |
| } | |
| const coords = routeStops | |
| .map((stop) => `${Number(stop.location.lng).toFixed(6)},${Number(stop.location.lat).toFixed(6)}`) | |
| .join(';'); | |
| const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson&steps=false`; | |
| const controller = new AbortController(); | |
| const timer = setTimeout(() => controller.abort(), 8000); | |
| try { | |
| const response = await fetch(url, { signal: controller.signal }); | |
| if (!response.ok) throw new Error(`OSRM HTTP ${response.status}`); | |
| const payload = await response.json(); | |
| const geometry = payload?.routes?.[0]?.geometry?.coordinates; | |
| if (!Array.isArray(geometry) || geometry.length < 2) return null; | |
| return geometry.map(([lng, lat]) => [lat, lng]); | |
| } finally { | |
| clearTimeout(timer); | |
| } | |
| } | |
| async function resolveAssignmentPath(assign, directionsService) { | |
| const fallbackPath = assign.route.map((p) => [p.location.lat, p.location.lng]); | |
| if (assign.route.length < 2) { | |
| return { pathPoints: fallbackPath, source: 'single-stop' }; | |
| } | |
| if (directionsService) { | |
| try { | |
| const googlePath = await requestGoogleRoadRoute(assign.route, directionsService); | |
| if (googlePath?.length) { | |
| return { pathPoints: googlePath, source: 'road-route' }; | |
| } | |
| } catch (error) { | |
| console.warn(`[Dispatch] could not load Google road route for ${assign.route[0]?.caseId || 'route'}:`, error.message); | |
| } | |
| } | |
| try { | |
| const osrmPath = await requestOsrmRoadRoute(assign.route); | |
| if (osrmPath?.length) { | |
| return { pathPoints: osrmPath, source: 'road-route' }; | |
| } | |
| } catch (error) { | |
| console.warn(`[Dispatch] could not load OSRM road route for ${assign.route[0]?.caseId || 'route'}:`, error.message); | |
| } | |
| return { pathPoints: fallbackPath, source: 'straight-line' }; | |
| } | |
| function normalizeDispatchPothole(source) { | |
| const report = source?.report || source || {}; | |
| const location = report.location || source?.location || null; | |
| const lat = Number(location && location.lat); | |
| const lng = Number(location && location.lng); | |
| if (!source?.caseId || !Number.isFinite(lat) || !Number.isFinite(lng)) return null; | |
| const fallbackArea = report.areaLabel || source?.areaLabel || ''; | |
| const rawMapping = report.gisMapping || { | |
| ward: report.ward || source?.ward || '', | |
| district: report.district || source?.district || '', | |
| zone: report.maintenanceZone || source?.maintenanceZone || '', | |
| area: fallbackArea, | |
| matched: !!(report.gisMapping && report.gisMapping.matched), | |
| configured: !!(report.gisMapping && report.gisMapping.configured), | |
| }; | |
| const resolved = window.resolvePreferredGISMapping | |
| ? window.resolvePreferredGISMapping(lat, lng, rawMapping, fallbackArea) | |
| : normalizeGISMapping(rawMapping, fallbackArea); | |
| const ward = resolved.ward || resolved.area || 'Unclassified'; | |
| return { | |
| caseId: source.caseId, | |
| location: { lat, lng }, | |
| address: report.address || source?.address || `Incident ${source.caseId}`, | |
| ward, | |
| aiResult: report.aiResult || report.aiPrediction || source?.aiResult || 'Medium', | |
| aiReviewStatus: report.aiReviewStatus || source?.aiReviewStatus || '', | |
| duplicateStatus: report.duplicateStatus || source?.duplicateStatus || (report.enrichment?.duplicate?.isDuplicate ? 'duplicate' : 'new'), | |
| isDuplicate: !!(report.isDuplicate || source?.isDuplicate || report.enrichment?.duplicate?.isDuplicate), | |
| status: String(source?.status || report.workflowStatus || 'Submitted').trim() || 'Submitted', | |
| supervisorApproved: report.supervisor?.isPothole === true, | |
| sourceType: source?.sourceType || (source?.report ? 'submitted' : 'mock'), | |
| gisMapping: resolved, | |
| }; | |
| } | |
| function isDispatchEligiblePothole(item) { | |
| if (!item) return false; | |
| const status = String(item.status || '').trim(); | |
| const reviewStatus = String(item.aiReviewStatus || '').toLowerCase(); | |
| const isRejected = reviewStatus === 'invalid_image' | |
| || reviewStatus === 'invalid_image_face' | |
| || reviewStatus === 'manual_rejected'; | |
| const isDuplicateChild = item.isDuplicate || String(item.duplicateStatus || '').toLowerCase() === 'duplicate'; | |
| if (isRejected || isDuplicateChild) return false; | |
| if (item.sourceType === 'ops-review-demo') { | |
| return status !== 'Resolved' && status !== 'Rejected'; | |
| } | |
| if (item.sourceType === 'mock' || item.sourceType === 'ad-hoc') { | |
| return true; | |
| } | |
| const isWorkflowClosed = status === 'Resolved' || status === 'Rejected'; | |
| const isApprovedOperationalCase = item.supervisorApproved || status === 'Assigned' || status === 'In Progress'; | |
| return !isWorkflowClosed && isApprovedOperationalCase; | |
| } | |
| // ββ Initialization & Setup Modal βββββββββββββββββββββββββββββββββββββββββ | |
| function initApp() { | |
| // Set City Title | |
| const cityName = localStorage.getItem('city_name') || 'Philadelphia'; | |
| $('nav-city-label').textContent = `${cityName} Road Operations`; | |
| const persisted = getPersistedDispatchState(); | |
| const requested = getRequestedDispatchState(); | |
| const defaultWards = ['Central', 'West', 'South']; | |
| const configuredWards = requested.wards.length | |
| ? requested.wards | |
| : (Array.isArray(persisted.selectedWards) && persisted.selectedWards.length | |
| ? persisted.selectedWards | |
| : defaultWards); | |
| requestedFocusCaseId = requested.focusCaseId; | |
| // Build Wards Checklist | |
| const checkboxGrid = $('wards-checkbox-grid'); | |
| checkboxGrid.innerHTML = window.PHILLY_TEN_REGIONS.map((w, idx) => { | |
| const tone = window.getGISBadgeTone({ matched: true }); | |
| const isPreselected = configuredWards.includes(w.name); | |
| return ` | |
| <div class="ward-checkbox-card ${isPreselected ? 'checked' : ''}" data-ward="${w.name}" onclick="toggleWardCheckbox(this)"> | |
| <input type="checkbox" value="${w.name}" ${isPreselected ? 'checked' : ''} /> | |
| <span class="ward-dot" style="background:${tone.dot};"></span> | |
| <span class="ward-label">${w.name} Region</span> | |
| </div>`; | |
| }).join(''); | |
| // Wire setup modal events | |
| $('crew-count-slider').addEventListener('input', (e) => { | |
| $('slider-crew-count-val').textContent = `${e.target.value} Group${e.target.value === '1' ? '' : 's'}`; | |
| }); | |
| const initialCrewCount = requested.numCrews || persisted.numCrews || 3; | |
| $('crew-count-slider').value = String(Math.min(5, Math.max(1, initialCrewCount))); | |
| $('slider-crew-count-val').textContent = `${$('crew-count-slider').value} Group${$('crew-count-slider').value === '1' ? '' : 's'}`; | |
| $('btn-select-all-wards').addEventListener('click', () => { | |
| document.querySelectorAll('.ward-checkbox-card').forEach(card => { | |
| card.classList.add('checked'); | |
| card.querySelector('input').checked = true; | |
| }); | |
| }); | |
| $('btn-deselect-all-wards').addEventListener('click', () => { | |
| document.querySelectorAll('.ward-checkbox-card').forEach(card => { | |
| card.classList.remove('checked'); | |
| card.querySelector('input').checked = false; | |
| }); | |
| }); | |
| $('btn-launch-deployment').addEventListener('click', launchCommandCenter); | |
| $('btn-send-to-crew').addEventListener('click', sendRoutesToCrew); | |
| $('btn-reconfigure').addEventListener('click', () => { | |
| $('setup-modal').style.display = 'flex'; | |
| }); | |
| $('btn-export-dispatch').addEventListener('click', showPrintPreview); | |
| $('btn-close-print').addEventListener('click', () => { | |
| $('print-sheet-modal').style.display = 'none'; | |
| }); | |
| // Load Master Incident pool | |
| loadMasterPotholes(); | |
| // Start Leaflet Map center on City Hall | |
| initLeafletMap(); | |
| sanitizeDispatchUi(); | |
| refreshDispatchStatusNote(); | |
| shouldAutoLaunchFromRequest = requested.wards.length > 0 || !!requested.focusCaseId; | |
| if (shouldAutoLaunchFromRequest) { | |
| launchCommandCenter(); | |
| showToast('Loaded crew routes from Ops Review.', 'info'); | |
| } | |
| } | |
| function toggleWardCheckbox(element) { | |
| const checkbox = element.querySelector('input'); | |
| checkbox.checked = !checkbox.checked; | |
| element.classList.toggle('checked', checkbox.checked); | |
| } | |
| function loadMasterPotholes() { | |
| const persisted = getPersistedDispatchState(); | |
| const removedIds = new Set(persisted.removedCaseIds || []); | |
| const seededCases = readJsonStorage(DISPATCH_STORAGE.seed, []); | |
| let localCases = []; | |
| try { | |
| localCases = JSON.parse(localStorage.getItem('submitted_cases') || '[]'); | |
| } catch (err) { | |
| console.warn('Error reading custom submitted cases from localStorage:', err); | |
| } | |
| const normalizedLocal = localCases | |
| .map(normalizeDispatchPothole) | |
| .filter(Boolean); | |
| const normalizedSeed = (Array.isArray(seededCases) ? seededCases : []) | |
| .map((item) => normalizeDispatchPothole({ ...item, sourceType: item.sourceType || 'ops-review-seed' })) | |
| .filter(Boolean); | |
| const normalizedMock = MOCK_CASES | |
| .map((item) => normalizeDispatchPothole({ ...item, sourceType: 'mock' })) | |
| .filter(Boolean); | |
| const normalizedAdHoc = (persisted.adHocPotholes || []) | |
| .map((item) => normalizeDispatchPothole({ ...item, sourceType: 'ad-hoc' })) | |
| .filter(Boolean); | |
| const baseCases = normalizedSeed.length | |
| ? normalizedSeed | |
| : (normalizedLocal.length ? normalizedLocal : normalizedMock); | |
| const merged = [...baseCases, ...normalizedAdHoc]; | |
| const deduped = []; | |
| const seen = new Set(); | |
| merged.forEach((item) => { | |
| if (!item || seen.has(item.caseId) || removedIds.has(item.caseId)) return; | |
| seen.add(item.caseId); | |
| deduped.push(item); | |
| }); | |
| activePotholes = deduped.filter((item) => isDispatchEligiblePothole(item)); | |
| } | |
| function initLeafletMap() { | |
| const PHILLY_CENTER = [39.9526, -75.1652]; | |
| map = L.map('dispatch-map', { | |
| zoomControl: false, | |
| attributionControl: false | |
| }).setView(PHILLY_CENTER, 12); | |
| // Deep dark sleek tile layer | |
| L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { | |
| maxZoom: 20 | |
| }).addTo(map); | |
| // Setup click-to-add event | |
| map.on('click', handleMapClickToAdd); | |
| } | |
| // ββ Setup Dialog Trigger: Launch Center ββββββββββββββββββββββββββββββββββ | |
| function launchCommandCenter() { | |
| // Gather inputs | |
| selectedWards = []; | |
| document.querySelectorAll('.ward-checkbox-card input:checked').forEach(cb => { | |
| selectedWards.push(cb.value); | |
| }); | |
| if (!selectedWards.length) { | |
| alert('Please select at least 1 ward region to cover.'); | |
| return; | |
| } | |
| numCrews = parseInt($('crew-count-slider').value, 10); | |
| writeJsonStorage(DISPATCH_STORAGE.wards, selectedWards); | |
| localStorage.setItem(DISPATCH_STORAGE.crews, String(numCrews)); | |
| // Hide modal | |
| $('setup-modal').style.display = 'none'; | |
| // Update sidebar metrics | |
| $('metric-wards').textContent = selectedWards.length; | |
| $('metric-crews').textContent = numCrews; | |
| // Refresh the live pothole pool before dispatching | |
| loadMasterPotholes(); | |
| // Draw map layouts and run routing | |
| recalculateDispatch(); | |
| refreshDispatchStatusNote(); | |
| showToast(`Command Center Activated: ${selectedWards.length} Wards covered by ${numCrews} Crews!`, 'success'); | |
| } | |
| // ββ CORE DISPATCH ROUTING PIPELINE (Clustering & TSP) ββββββββββββββββββββ | |
| async function sendRoutesToCrew() { | |
| const activeAssignments = crewAssignments.filter((assign) => assign.route.length); | |
| if (!activeAssignments.length) { | |
| showToast('No finalized routes are available to send yet.', 'error'); | |
| return; | |
| } | |
| const sendButton = $('btn-send-to-crew'); | |
| const originalSendButtonMarkup = sendButton ? sendButton.innerHTML : ''; | |
| if (sendButton) { | |
| sendButton.disabled = true; | |
| sendButton.textContent = 'Syncing Dispatch...'; | |
| } | |
| const sentAt = new Date().toISOString(); | |
| const planId = `DSP-${sentAt.slice(0, 10).replace(/-/g, '')}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`; | |
| const assignmentLookup = getRouteAssignmentLookup(activeAssignments); | |
| const planPayload = { | |
| planId, | |
| sentAt, | |
| selectedWards: [...selectedWards], | |
| numCrews, | |
| totalStops: activeAssignments.reduce((count, assign) => count + assign.route.length, 0), | |
| crews: activeAssignments.map((assign) => { | |
| const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length]; | |
| return { | |
| crewIdx: assign.crewIdx, | |
| crewName: style.name, | |
| crewColor: style.color, | |
| totalStops: assign.route.length, | |
| route: assign.route.map((pothole, stopIndex) => ({ | |
| caseId: pothole.caseId, | |
| stopIndex: stopIndex + 1, | |
| address: pothole.address, | |
| ward: pothole.ward, | |
| aiResult: pothole.aiResult, | |
| location: pothole.location, | |
| sourceType: pothole.sourceType || 'submitted', | |
| })), | |
| }; | |
| }), | |
| }; | |
| const applyDispatchMetadata = (cases) => { | |
| if (!Array.isArray(cases)) return []; | |
| return cases.map((item) => { | |
| const routeMeta = assignmentLookup.get(item?.caseId); | |
| if (!routeMeta) return item; | |
| const nextReport = { ...(item.report || {}) }; | |
| nextReport.dispatchPlan = { | |
| planId, | |
| sentAt, | |
| crewIdx: routeMeta.crewIdx, | |
| crewName: routeMeta.crewName, | |
| stopIndex: routeMeta.stopIndex, | |
| totalStops: routeMeta.totalStops, | |
| selectedWards: [...selectedWards], | |
| numCrews, | |
| }; | |
| nextReport.assignedTo = routeMeta.crewName; | |
| nextReport.crewName = routeMeta.crewName; | |
| nextReport.workflowStage = 'crew_dispatch'; | |
| nextReport.workflowStatus = 'Assigned'; | |
| nextReport.workflowQueue = 'Field Operations'; | |
| nextReport.workflowNextAction = 'Crew should repair the pothole and submit closeout proof.'; | |
| nextReport.workflowLastUpdatedAt = sentAt; | |
| return { | |
| ...item, | |
| status: 'Assigned', | |
| report: nextReport, | |
| }; | |
| }); | |
| }; | |
| try { | |
| const submittedCases = readJsonStorage('submitted_cases', []); | |
| const updatedSubmittedCases = applyDispatchMetadata(submittedCases); | |
| localStorage.setItem('submitted_cases', JSON.stringify(updatedSubmittedCases)); | |
| const seededCases = readJsonStorage(DISPATCH_STORAGE.seed, []); | |
| writeJsonStorage(DISPATCH_STORAGE.seed, applyDispatchMetadata(seededCases)); | |
| writeJsonStorage(DISPATCH_STORAGE.plan, planPayload); | |
| let syncedCases = []; | |
| let skippedCaseIds = []; | |
| if (window.PotholeBackend?.submitDispatchPlan) { | |
| const response = await window.PotholeBackend.submitDispatchPlan(planPayload); | |
| syncedCases = Array.isArray(response?.cases) ? response.cases : []; | |
| skippedCaseIds = Array.isArray(response?.skippedCaseIds) ? response.skippedCaseIds : []; | |
| } | |
| if (syncedCases.length && window.PotholeBackend?.mergeCaseLists) { | |
| const mergedSubmittedCases = window.PotholeBackend.mergeCaseLists( | |
| readJsonStorage('submitted_cases', []), | |
| syncedCases | |
| ); | |
| localStorage.setItem('submitted_cases', JSON.stringify(mergedSubmittedCases)); | |
| writeJsonStorage(DISPATCH_STORAGE.seed, mergedSubmittedCases); | |
| loadMasterPotholes(); | |
| recalculateDispatch(); | |
| } | |
| refreshDispatchStatusNote(); | |
| const syncedCount = syncedCases.length; | |
| const skippedCount = skippedCaseIds.length; | |
| let summary = `Sent ${planPayload.totalStops} routed pothole stop${planPayload.totalStops === 1 ? '' : 's'} to ${activeAssignments.length} crew group${activeAssignments.length === 1 ? '' : 's'}.`; | |
| if (syncedCount || skippedCount) { | |
| summary += ` Database synced ${syncedCount} case${syncedCount === 1 ? '' : 's'}`; | |
| if (skippedCount) { | |
| summary += ` and skipped ${skippedCount} local-only stop${skippedCount === 1 ? '' : 's'}.`; | |
| } else { | |
| summary += '.'; | |
| } | |
| } | |
| showToast(summary, 'success'); | |
| } catch (error) { | |
| console.error('[Dispatch] failed to send route plan:', error); | |
| showToast(`Could not sync the final crew dispatch package: ${error.message}`, 'error'); | |
| } finally { | |
| if (sendButton) { | |
| sendButton.disabled = false; | |
| sendButton.innerHTML = originalSendButtonMarkup; | |
| } | |
| } | |
| } | |
| function recalculateDispatch() { | |
| // 1. Filter active incidents that fall within the covered Wards | |
| const coveredIncidents = activePotholes.filter(p => selectedWards.includes(p.ward)); | |
| $('metric-potholes').textContent = coveredIncidents.length; | |
| // Average workload calculation | |
| const avgWorkload = coveredIncidents.length ? (coveredIncidents.length / numCrews).toFixed(1) : 0; | |
| $('metric-workload').textContent = avgWorkload; | |
| // 2. Perform Partitioning & Routing | |
| if (coveredIncidents.length === 0) { | |
| // Clear routes, redraw Wards, and exit | |
| drawWardsOverlay(); | |
| plotPotholeMarkers(coveredIncidents, []); | |
| clearRoutes(); | |
| renderCrewSidebar([]); | |
| refreshDispatchStatusNote(); | |
| return; | |
| } | |
| // K-Means Clustering on Latitude/Longitude | |
| const clusters = partitionPotholes(coveredIncidents, numCrews); | |
| // TSP Routing for each cluster | |
| crewAssignments = []; | |
| clusters.forEach((potholesInCluster, crewIdx) => { | |
| if (!potholesInCluster.length) { | |
| crewAssignments.push({ crewIdx, potholes: [], route: [] }); | |
| return; | |
| } | |
| // Solve TSP with Nearest Neighbor | |
| const tspRoute = solveTspNearestNeighbor(potholesInCluster); | |
| crewAssignments.push({ | |
| crewIdx, | |
| potholes: potholesInCluster, | |
| route: tspRoute | |
| }); | |
| }); | |
| // 3. Render map UI elements | |
| drawWardsOverlay(); | |
| plotPotholeMarkers(coveredIncidents, crewAssignments); | |
| drawRoadRouteLines(crewAssignments).catch((error) => { | |
| console.warn('[Dispatch] route rendering failed:', error); | |
| }); | |
| // 4. Update sidebar list stops | |
| renderCrewSidebar(crewAssignments); | |
| refreshDispatchStatusNote(); | |
| // Zoom map to fit all active markers or active region bounds | |
| fitMapToActiveCoverage(coveredIncidents); | |
| focusRequestedDispatchCase(); | |
| // Update map badge | |
| $('map-status-overlay').style.display = 'flex'; | |
| $('map-status-text').textContent = selectedWards.length === 10 ? 'All Wards Covered' : `${selectedWards.length} Zones Monitored`; | |
| } | |
| // K-Means implementation | |
| function partitionPotholes(potholes, k) { | |
| // If we have fewer potholes than crews, assign 1 pothole per cluster | |
| if (potholes.length <= k) { | |
| const clusters = Array.from({ length: k }, () => []); | |
| potholes.forEach((p, idx) => { | |
| clusters[idx].push(p); | |
| }); | |
| return clusters; | |
| } | |
| // Initialize centroids at random pothole coords | |
| let centroids = []; | |
| const shuffled = [...potholes].sort(() => 0.5 - Math.random()); | |
| for (let i = 0; i < k; i++) { | |
| centroids.push({ lat: shuffled[i].location.lat, lng: shuffled[i].location.lng }); | |
| } | |
| let clusters = Array.from({ length: k }, () => []); | |
| let converged = false; | |
| let iterations = 0; | |
| while (!converged && iterations < 15) { | |
| iterations++; | |
| // Clear clusters | |
| clusters = Array.from({ length: k }, () => []); | |
| // Assign potholes to closest centroid | |
| potholes.forEach(p => { | |
| let minDist = Infinity; | |
| let bestCentroid = 0; | |
| centroids.forEach((c, idx) => { | |
| const dist = getLatLlngDistance(p.location.lat, p.location.lng, c.lat, c.lng); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| bestCentroid = idx; | |
| } | |
| }); | |
| clusters[bestCentroid].push(p); | |
| }); | |
| // Recompute centroids | |
| let newCentroids = []; | |
| let shifted = false; | |
| clusters.forEach((cList, idx) => { | |
| if (cList.length === 0) { | |
| // Keep previous centroid if no items assigned | |
| newCentroids.push(centroids[idx]); | |
| return; | |
| } | |
| let sumLat = 0, sumLng = 0; | |
| cList.forEach(p => { | |
| sumLat += p.location.lat; | |
| sumLng += p.location.lng; | |
| }); | |
| const meanLat = sumLat / cList.length; | |
| const meanLng = sumLng / cList.length; | |
| // Check shift | |
| if (Math.abs(meanLat - centroids[idx].lat) > 0.0001 || Math.abs(meanLng - centroids[idx].lng) > 0.0001) { | |
| shifted = true; | |
| } | |
| newCentroids.push({ lat: meanLat, lng: meanLng }); | |
| }); | |
| centroids = newCentroids; | |
| if (!shifted) converged = true; | |
| } | |
| return clusters; | |
| } | |
| // TSP Nearest Neighbor Implementation | |
| function solveTspNearestNeighbor(potholes) { | |
| if (potholes.length <= 1) return [...potholes]; | |
| const unvisited = [...potholes]; | |
| const route = []; | |
| // Determine starting point - closest to centroid or simply the first element | |
| let current = unvisited.splice(0, 1)[0]; | |
| route.push(current); | |
| while (unvisited.length > 0) { | |
| let nearestIdx = 0; | |
| let minDist = Infinity; | |
| unvisited.forEach((p, idx) => { | |
| const dist = getLatLlngDistance(current.location.lat, current.location.lng, p.location.lat, p.location.lng); | |
| if (dist < minDist) { | |
| minDist = dist; | |
| nearestIdx = idx; | |
| } | |
| }); | |
| current = unvisited.splice(nearestIdx, 1)[0]; | |
| route.push(current); | |
| } | |
| return route; | |
| } | |
| // Geographic Euclidean Distance approximation (sufficient for K-Means and local TSP) | |
| function getLatLlngDistance(lat1, lng1, lat2, lng2) { | |
| const dLat = lat1 - lat2; | |
| const dLng = lng1 - lng2; | |
| return Math.sqrt(dLat * dLat + dLng * dLng); | |
| } | |
| // ββ DRAW GIS OVERLAYS & MAP MARKERS ββββββββββββββββββββββββββββββββββββββ | |
| function drawWardsOverlay() { | |
| // Clear old overlays | |
| mapOverlayWards.forEach(layer => map.removeLayer(layer)); | |
| mapOverlayWards = []; | |
| selectedWards.forEach(wName => { | |
| const bounds = REGION_BOUNDS[wName]; | |
| if (!bounds) return; | |
| const regionData = window.PHILLY_TEN_REGIONS.find(r => r.name === wName); | |
| const tone = window.getGISBadgeTone({ matched: true }); | |
| const layer = L.rectangle(bounds, { | |
| color: tone.dot, | |
| weight: 2, | |
| dashArray: '4, 8', | |
| fillOpacity: 0.04, | |
| fillColor: tone.dot, | |
| interactive: false | |
| }).addTo(map); | |
| mapOverlayWards.push(layer); | |
| }); | |
| } | |
| function plotPotholeMarkers(potholes, assignments = crewAssignments) { | |
| // Clear old markers | |
| mapMarkers.forEach(m => map.removeLayer(m)); | |
| mapMarkers = []; | |
| const routeLookup = getRouteAssignmentLookup(assignments); | |
| potholes.forEach(p => { | |
| const sizeColors = { Large: '#EF4444', Medium: '#F59E0B', Small: '#10B981' }; | |
| const severityColor = sizeColors[p.aiResult] || '#3B82F6'; | |
| const routeMeta = routeLookup.get(p.caseId); | |
| const isFocused = p.caseId === requestedFocusCaseId; | |
| const markerSize = routeMeta ? (isFocused ? 30 : 24) : (isFocused ? 20 : 14); | |
| const routeColor = routeMeta ? routeMeta.crewColor : severityColor; | |
| const markerGlow = isFocused | |
| ? `0 0 0 4px rgba(255,255,255,0.95), 0 0 0 9px ${routeColor}55, 0 0 18px ${routeColor}` | |
| : `0 0 10px ${routeColor}`; | |
| const focusCallout = isFocused | |
| ? `<div style="margin-top:8px;padding:6px 8px;border-radius:8px;background:rgba(59,130,246,0.12);border:1px solid rgba(59,130,246,0.28);color:#60A5FA;font-size:11px;font-weight:700;">Selected from Ops Review</div>` | |
| : ''; | |
| const routeSummary = routeMeta | |
| ? `<b>Crew Route:</b> ${routeMeta.crewName} Β· Stop ${routeMeta.stopIndex} of ${routeMeta.totalStops}<br>` | |
| : ''; | |
| const routeBadge = routeMeta | |
| ? `<div style="margin-top:8px;padding:6px 8px;border-radius:8px;background:${routeMeta.crewBg};border:1px solid ${routeMeta.crewColor}40;color:${routeMeta.crewColor};font-size:11px;font-weight:700;">${routeMeta.crewName} Β· Stop ${routeMeta.stopIndex}</div>` | |
| : ''; | |
| const markerHtml = routeMeta | |
| ? ` | |
| <div style="position:relative;width:${markerSize}px;height:${markerSize}px;"> | |
| <div style="width:${markerSize}px;height:${markerSize}px;background:${routeMeta.crewColor};border:2px solid #fff;border-radius:50%;box-shadow:${markerGlow};display:flex;align-items:center;justify-content:center;color:#07111F;font-size:${isFocused ? 12 : 10}px;font-weight:900;"> | |
| ${routeMeta.stopIndex} | |
| </div> | |
| <span style="position:absolute;right:-2px;bottom:-2px;width:10px;height:10px;border-radius:50%;background:${severityColor};border:1.5px solid #fff;"></span> | |
| </div> | |
| ` | |
| : `<div style="width:${markerSize}px;height:${markerSize}px;background:${severityColor};border:2px solid #fff;border-radius:50%;box-shadow:${markerGlow}"></div>`; | |
| const pinIcon = L.divIcon({ | |
| className: '', | |
| html: markerHtml, | |
| iconSize: [markerSize, markerSize], | |
| iconAnchor: [markerSize / 2, markerSize / 2] | |
| }); | |
| const popupContent = ` | |
| <div class="popup-title">${p.caseId}</div> | |
| <div class="popup-desc"> | |
| <b>Region:</b> ${p.ward} Region<br> | |
| <b>Address:</b> ${p.address}<br> | |
| ${routeSummary}<b>AI Severity:</b> <span style="color:${severityColor};font-weight:800;">${p.aiResult}</span> | |
| </div> | |
| ${routeBadge} | |
| ${focusCallout} | |
| <button class="popup-btn-danger" onclick="removePothole('${p.caseId}')"> | |
| Remove from Route | |
| </button> | |
| `; | |
| const marker = L.marker([p.location.lat, p.location.lng], { icon: pinIcon }) | |
| .addTo(map) | |
| .bindPopup(popupContent); | |
| // Reference caseId inside marker to allow highlight search | |
| marker.caseId = p.caseId; | |
| marker.routeMeta = routeMeta; | |
| mapMarkers.push(marker); | |
| }); | |
| } | |
| function focusRequestedDispatchCase() { | |
| if (!requestedFocusCaseId) return; | |
| const marker = mapMarkers.find((item) => item.caseId === requestedFocusCaseId); | |
| if (!marker) return; | |
| marker.openPopup(); | |
| map.setView(marker.getLatLng(), Math.max(map.getZoom(), 15), { animate: true, duration: 0.6 }); | |
| requestedFocusCaseId = ''; | |
| } | |
| async function drawRoadRouteLines(assignments) { | |
| const renderToken = ++routeRenderToken; | |
| clearRoutes(); | |
| const mapsApi = await loadGoogleMapsSdk(); | |
| const directionsService = mapsApi?.DirectionsService ? new mapsApi.DirectionsService() : null; | |
| for (const assign of assignments) { | |
| if (assign.route.length < 2) continue; | |
| const { pathPoints, source } = await resolveAssignmentPath(assign, directionsService); | |
| if (renderToken !== routeRenderToken) return; | |
| if (!Array.isArray(pathPoints) || pathPoints.length < 2) continue; | |
| const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length]; | |
| const glowLine = L.polyline(pathPoints, { | |
| color: style.color, | |
| weight: 10, | |
| opacity: 0.18, | |
| lineCap: 'round', | |
| lineJoin: 'round' | |
| }).addTo(map); | |
| const polyline = L.polyline(pathPoints, { | |
| color: style.color, | |
| weight: 5, | |
| opacity: 0.95, | |
| lineCap: 'round', | |
| lineJoin: 'round' | |
| }).addTo(map); | |
| const pathElement = polyline.getElement(); | |
| if (pathElement) { | |
| pathElement.style.filter = `drop-shadow(0px 0px 4px ${style.color})`; | |
| pathElement.animate( | |
| [ | |
| { strokeDashoffset: '0' }, | |
| { strokeDashoffset: '-20' } | |
| ], | |
| { | |
| duration: 1000, | |
| iterations: Infinity, | |
| easing: 'linear' | |
| } | |
| ); | |
| } | |
| const midPoint = getRouteLabelPoint(pathPoints); | |
| if (!midPoint) continue; | |
| const routeLabel = L.marker(midPoint, { | |
| interactive: false, | |
| icon: L.divIcon({ | |
| className: '', | |
| html: `<div style="padding:4px 10px;border-radius:999px;background:rgba(7,13,26,0.9);border:1px solid ${style.color}55;color:${style.color};font-size:10px;font-weight:800;box-shadow:0 8px 18px rgba(0,0,0,0.25);white-space:nowrap;">${style.name} · ${assign.route.length} stop${assign.route.length === 1 ? '' : 's'}${source === 'road-route' ? ' · Road Route' : ''}</div>`, | |
| iconSize: [180, 24], | |
| iconAnchor: [90, 12] | |
| }) | |
| }).addTo(map); | |
| mapPolylines.push(glowLine, polyline, routeLabel); | |
| } | |
| } | |
| function clearRoutes() { | |
| mapPolylines.forEach(line => map.removeLayer(line)); | |
| mapPolylines = []; | |
| } | |
| function fitMapToActiveCoverage(potholes) { | |
| if (potholes.length > 1) { | |
| const points = potholes.map(p => [p.location.lat, p.location.lng]); | |
| map.fitBounds(L.latLngBounds(points), { padding: [40, 40] }); | |
| } else if (selectedWards.length === 1 && REGION_BOUNDS[selectedWards[0]]) { | |
| map.fitBounds(REGION_BOUNDS[selectedWards[0]], { padding: [20, 20] }); | |
| } else { | |
| // Philly center zoom | |
| map.setView([39.9526, -75.1652], 12); | |
| } | |
| } | |
| // ββ SIDEBAR RENDERER stops βββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderCrewSidebar(assignments) { | |
| const container = $('crew-list-container'); | |
| if (!assignments.length) { | |
| container.innerHTML = '<div style="color: var(--text-dim); text-align: center; padding: 20px; font-size: 12px;">No active routes. Select wards to cover.</div>'; | |
| return; | |
| } | |
| container.innerHTML = assignments.map(assign => { | |
| const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length]; | |
| const numStops = assign.route.length; | |
| const totalWork = numStops ? `${numStops} Stop${numStops === 1 ? '' : 's'}` : 'Idle'; | |
| return ` | |
| <div class="crew-card"> | |
| <div class="crew-header" onclick="toggleCrewCardCollapse(this)"> | |
| <div class="crew-info"> | |
| <span class="crew-color-indicator" style="background:${style.color};color:${style.color};"></span> | |
| <span class="crew-name">${style.name}</span> | |
| </div> | |
| <span class="crew-meta" style="color:${style.color};background:${style.bg};">${totalWork}</span> | |
| </div> | |
| <div class="crew-stops"> | |
| ${numStops === 0 ? ` | |
| <div style="font-size:11px;color:var(--text-dim);padding:8px 10px;">Idle β no potholes assigned in this zone.</div> | |
| ` : assign.route.map((p, idx) => ` | |
| <div class="stop-item" onmouseenter="highlightMarker('${p.caseId}', true)" onmouseleave="highlightMarker('${p.caseId}', false)" onclick="zoomToStop([${p.location.lat}, ${p.location.lng}])"> | |
| <span class="stop-badge" style="background:${style.color};">${idx + 1}</span> | |
| <div class="stop-info"> | |
| <div class="stop-id">${p.caseId}</div> | |
| <div class="stop-address">${p.address}</div> | |
| </div> | |
| </div> | |
| `).join('')} | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } | |
| function toggleCrewCardCollapse(headerElement) { | |
| const card = headerElement.parentElement; | |
| const stops = card.querySelector('.crew-stops'); | |
| if (stops.style.display === 'none') { | |
| stops.style.display = 'flex'; | |
| } else { | |
| stops.style.display = 'none'; | |
| } | |
| } | |
| // ββ INTERACTIVE ROUTE ACTIONS (Highlight/Zoom) βββββββββββββββββββββββββββ | |
| function highlightMarker(caseId, isHighlight) { | |
| const marker = mapMarkers.find(m => m.caseId === caseId); | |
| if (!marker) return; | |
| const element = marker.getElement(); | |
| if (!element) return; | |
| const sizeColors = { Large: '#EF4444', Medium: '#F59E0B', Small: '#10B981' }; | |
| const pothole = activePotholes.find(p => p.caseId === caseId); | |
| const color = sizeColors[pothole?.aiResult] || '#3B82F6'; | |
| if (isHighlight) { | |
| element.style.transform += ' scale(1.4)'; | |
| element.style.filter = `drop-shadow(0px 0px 8px ${color})`; | |
| element.style.zIndex = '999999'; | |
| } else { | |
| element.style.transform = element.style.transform.replace(' scale(1.4)', ''); | |
| element.style.filter = `drop-shadow(0px 0px 4px ${color})`; | |
| element.style.zIndex = '1000'; | |
| } | |
| } | |
| function zoomToStop(coords) { | |
| map.setView(coords, 16, { animate: true, duration: 0.6 }); | |
| } | |
| // ββ MAP CLICK INTERACTIVITY: ADD POTHOLE ββββββββββββββββββββββββββββββββββ | |
| function handleMapClickToAdd(e) { | |
| const { lat, lng } = e.latlng; | |
| // 1. Resolve coordinate planning ward | |
| const resolvedGIS = window.resolvePreferredGISMapping | |
| ? window.resolvePreferredGISMapping(lat, lng, {}, '') | |
| : window.resolvePhiladelphiaTenRegion(lat, lng); | |
| if (!resolvedGIS || !resolvedGIS.ward) { | |
| showToast('Click point is outside Philadelphia municipal boundaries!', 'error'); | |
| return; | |
| } | |
| const clickedWard = resolvedGIS.ward; | |
| // 2. Check if the clicked ward is currently covered/selected | |
| if (!selectedWards.includes(clickedWard)) { | |
| // Beautiful on-map popover telling them they must enable this ward first | |
| const popupContent = ` | |
| <div class="popup-add-form" style="max-width:210px;"> | |
| <div class="popup-add-title" style="color:#EF4444;">Ward Region Not Covered</div> | |
| <p style="font-size:11px;color:#94A3B8;line-height:1.4;margin-bottom:6px;"> | |
| This location is inside the <b>${clickedWard} District</b>, which is currently unassigned. | |
| </p> | |
| <button class="popup-btn-success" style="background:#3B82F6;" onclick="activateAndAddPothole('${clickedWard}', ${lat}, ${lng})"> | |
| β Enable Ward & Add | |
| </button> | |
| </div> | |
| `; | |
| L.popup() | |
| .setLatLng([lat, lng]) | |
| .setContent(popupContent) | |
| .openOn(map); | |
| setTimeout(sanitizeDispatchPopupUi, 0); | |
| return; | |
| } | |
| // 3. Inside covered ward: offer standard add form popup | |
| const tempId = `PHL-M-${Math.random().toString(36).substring(2, 6).toUpperCase()}`; | |
| const formContent = ` | |
| <div class="popup-add-form" id="map-add-pothole-form"> | |
| <div class="popup-add-title">Add Incident to Route</div> | |
| <p style="font-size:10px;color:#94A3B8;margin-bottom:4px;"><b>Region:</b> ${clickedWard}</p> | |
| <div style="display:flex;flex-direction:column;gap:4px;"> | |
| <label style="font-size:9px;font-weight:700;color:#64748B;">Pothole Severity</label> | |
| <select class="popup-select" id="add-pothole-severity"> | |
| <option value="Medium">Medium β Moderate</option> | |
| <option value="Large">Large β Major Hazard</option> | |
| <option value="Small">Small β Minor</option> | |
| </select> | |
| </div> | |
| <button class="popup-btn-success" style="margin-top:6px;" onclick="confirmAddPothole('${tempId}', '${clickedWard}', ${lat}, ${lng})"> | |
| π Add to Dispatch | |
| </button> | |
| </div> | |
| `; | |
| L.popup() | |
| .setLatLng([lat, lng]) | |
| .setContent(formContent) | |
| .openOn(map); | |
| setTimeout(sanitizeDispatchPopupUi, 0); | |
| } | |
| // Enable an inactive ward and automatically launch standard add pothole popup | |
| window.activateAndAddPothole = function(wardName, lat, lng) { | |
| // Toggle ward checkbox checked | |
| const card = document.querySelector(`.ward-checkbox-card[data-ward="${wardName}"]`); | |
| if (card) { | |
| card.classList.add('checked'); | |
| card.querySelector('input').checked = true; | |
| } | |
| if (!selectedWards.includes(wardName)) { | |
| selectedWards.push(wardName); | |
| } | |
| writeJsonStorage(DISPATCH_STORAGE.wards, selectedWards); | |
| $('metric-wards').textContent = selectedWards.length; | |
| // Close map popup, wait 150ms, open the add popup | |
| map.closePopup(); | |
| setTimeout(() => { | |
| handleMapClickToAdd({ latlng: { lat, lng } }); | |
| }, 150); | |
| }; | |
| window.confirmAddPothole = function(caseId, wardName, lat, lng) { | |
| const severity = $('add-pothole-severity').value; | |
| // Attempt reverse address lookup simulated or hardcoded | |
| const address = `Incident at ${lat.toFixed(4)}Β°N, ${lng.toFixed(4)}Β°W (${wardName})`; | |
| const newPothole = { | |
| caseId, | |
| location: { lat, lng }, | |
| address, | |
| ward: wardName, | |
| aiResult: severity, | |
| sourceType: 'ad-hoc', | |
| gisMapping: normalizeGISMapping({ ward: wardName, area: wardName, matched: true, configured: true }, wardName), | |
| }; | |
| // Add to master | |
| activePotholes.push(newPothole); | |
| const persisted = getPersistedDispatchState(); | |
| persisted.adHocPotholes.push(newPothole); | |
| writeJsonStorage(DISPATCH_STORAGE.adHoc, persisted.adHocPotholes); | |
| writeJsonStorage(DISPATCH_STORAGE.removed, persisted.removedCaseIds.filter(id => id !== caseId)); | |
| // Close popup | |
| map.closePopup(); | |
| // Recalculate everything | |
| recalculateDispatch(); | |
| showToast(`Added pothole ${caseId} to routes in ${wardName} Region!`, 'success'); | |
| }; | |
| // ββ REMOVE POTHOLE ACTION ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| window.removePothole = function(caseId) { | |
| activePotholes = activePotholes.filter(p => p.caseId !== caseId); | |
| const persisted = getPersistedDispatchState(); | |
| const keptAdHoc = (persisted.adHocPotholes || []).filter(p => p.caseId !== caseId); | |
| const removedIds = new Set(persisted.removedCaseIds || []); | |
| removedIds.add(caseId); | |
| writeJsonStorage(DISPATCH_STORAGE.adHoc, keptAdHoc); | |
| writeJsonStorage(DISPATCH_STORAGE.removed, Array.from(removedIds)); | |
| // Close map popup | |
| map.closePopup(); | |
| // Recalculate | |
| recalculateDispatch(); | |
| showToast(`Cleared pothole ${caseId} from routing queue!`, 'info'); | |
| }; | |
| // ββ PRINT ROUTE DIRECTIVES OVERLAY SHEET ββββββββββββββββββββββββββββββββββ | |
| function showPrintPreview() { | |
| const overlay = $('print-sheet-modal'); | |
| const container = $('print-routes-container'); | |
| const meta = $('print-meta-text'); | |
| meta.textContent = `Generated on ${new Date().toLocaleString()} Β· Active Wards: ${selectedWards.join(', ')} Β· Teams Deployed: ${numCrews}`; | |
| let html = ''; | |
| crewAssignments.forEach(assign => { | |
| const style = CREW_STYLES[assign.crewIdx % CREW_STYLES.length]; | |
| const numStops = assign.route.length; | |
| html += ` | |
| <div class="print-crew-section"> | |
| <h2 class="print-crew-title" style="color:${style.color};"> | |
| <span style="display:inline-block;width:12px;height:12px;background:${style.color};border-radius:50%;"></span> | |
| ${style.name} Dispatch Routing (${numStops} Stop${numStops === 1 ? '' : 's'}) | |
| </h2> | |
| ${numStops === 0 ? ` | |
| <p style="font-size:13px;color:var(--text-muted);">No incident stops assigned to this crew group. Status: STANDBY.</p> | |
| ` : ` | |
| <table class="print-stops-table"> | |
| <thead> | |
| <tr> | |
| <th style="width: 80px;">Seq Stop</th> | |
| <th style="width: 160px;">Case Incident ID</th> | |
| <th>Staging Address Location</th> | |
| <th style="width: 120px;">AI Severity</th> | |
| <th style="width: 120px;">Verification</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${assign.route.map((p, idx) => ` | |
| <tr> | |
| <td style="font-weight:800;">Stop #${idx + 1}</td> | |
| <td style="font-family:monospace;font-weight:700;">${p.caseId}</td> | |
| <td>${p.address}</td> | |
| <td style="font-weight:700;color:${p.aiResult==='Large'?'#EF4444':(p.aiResult==='Medium'?'#F59E0B':'#10B981')}">${p.aiResult} Severity</td> | |
| <td style="color:#64748B;">[ ] Patched</td> | |
| </tr> | |
| `).join('')} | |
| </tbody> | |
| </table> | |
| `} | |
| </div> | |
| `; | |
| }); | |
| container.innerHTML = html; | |
| overlay.style.display = 'block'; | |
| } | |
| // Boot Command Center | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| try { | |
| await window.PotholeBackend?.hydrateCasesIntoLocalStorage?.('submitted_cases'); | |
| } catch (err) { | |
| console.warn('[Dispatch] backend hydration failed:', err); | |
| } | |
| initApp(); | |
| if (!shouldAutoLaunchFromRequest) { | |
| $('setup-modal').style.display = 'flex'; | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |