Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Report a Pothole - City Road Reporting Portal</title> | |
| <meta name="description" content="Submit a pothole report with photo upload, GPS verification, and real-time case tracking." /> | |
| <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@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" /> | |
| <style> | |
| /* ── Reset & Base ─────────────────────────────────────────────────────── */ | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background: #F4F6F9; | |
| color: #1A1A2E; | |
| min-height: 100vh; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| /* ── Navigation ───────────────────────────────────────────────────────── */ | |
| #app-nav { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| z-index: 100; | |
| background: #fff; | |
| border-bottom: 1px solid #E1E4E8; | |
| height: 64px; | |
| display: flex; | |
| align-items: center; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.06); | |
| } | |
| .nav-wrap { | |
| width: 100%; | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| padding: 0 24px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| } | |
| .nav-brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| text-decoration: none; | |
| color: inherit; | |
| } | |
| .brand-icon { | |
| width: 36px; | |
| height: 36px; | |
| background: #0061D5; | |
| border-radius: 8px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .brand-city { font-size: 13px; font-weight: 700; color: #1A1A2E; } | |
| .brand-sys { font-size: 11px; color: #6B7280; } | |
| .nav-menu { display: flex; align-items: center; gap: 4px; } | |
| .nav-item { | |
| padding: 8px 14px; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| color: #6B7280; | |
| text-decoration: none; | |
| transition: all 0.15s; | |
| } | |
| .nav-item:hover { background: #F4F6F9; color: #1A1A2E; } | |
| .nav-item.active { background: #EFF4FF; color: #0061D5; font-weight: 600; } | |
| .nav-item--settings { border: 1px solid #E1E4E8; margin-left: 8px; } | |
| .nav-item--dev { border-style: dashed; } | |
| /* ── Hero ─────────────────────────────────────────────────────────────── */ | |
| .hero { | |
| background: linear-gradient(135deg, #0C1B33 0%, #1A3A6B 100%); | |
| color: #fff; | |
| padding: 64px 24px; | |
| text-align: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .hero::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background-image: | |
| linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px); | |
| background-size: 40px 40px; | |
| pointer-events: none; | |
| } | |
| .hero-inner { | |
| position: relative; | |
| max-width: 680px; | |
| margin: 0 auto; | |
| } | |
| .hero-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.15); | |
| border-radius: 50px; | |
| padding: 6px 16px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| letter-spacing: 0.04em; | |
| margin-bottom: 24px; | |
| backdrop-filter: blur(8px); | |
| } | |
| .hero-title { | |
| font-size: clamp(2rem, 5vw, 3.2rem); | |
| font-weight: 800; | |
| line-height: 1.1; | |
| margin-bottom: 16px; | |
| letter-spacing: -0.02em; | |
| } | |
| .hero-sub { | |
| font-size: 1.05rem; | |
| color: rgba(255,255,255,0.75); | |
| max-width: 520px; | |
| margin: 0 auto 32px; | |
| line-height: 1.7; | |
| } | |
| .hero-badges { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| justify-content: center; | |
| } | |
| .hero-badges span { | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.15); | |
| border-radius: 50px; | |
| padding: 6px 14px; | |
| font-size: 12px; | |
| font-weight: 500; | |
| } | |
| /* ── Main ─────────────────────────────────────────────────────────────── */ | |
| .main { | |
| max-width: 760px; | |
| margin: 0 auto; | |
| padding: 40px 20px 80px; | |
| } | |
| /* ── Steps bar ────────────────────────────────────────────────────────── */ | |
| .steps-bar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 32px; | |
| } | |
| .step-item { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .step-circle { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| border: 2px solid #E1E4E8; | |
| background: #fff; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: #9CA3AF; | |
| transition: all 0.3s; | |
| } | |
| .step-num { display: block; } | |
| .step-label { | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: #9CA3AF; | |
| white-space: nowrap; | |
| transition: color 0.3s; | |
| } | |
| .step-item.active .step-circle { | |
| background: #0061D5; | |
| border-color: #0061D5; | |
| color: #fff; | |
| box-shadow: 0 0 0 4px rgba(0,97,213,0.15); | |
| } | |
| .step-item.active .step-label { color: #0061D5; font-weight: 600; } | |
| .step-item.completed .step-circle { background: #00875A; border-color: #00875A; color: #fff; } | |
| .step-item.completed .step-label { color: #00875A; } | |
| .step-line { | |
| flex: 1; | |
| height: 2px; | |
| background: #E1E4E8; | |
| margin: 0 12px 24px; | |
| max-width: 80px; | |
| transition: background 0.3s; | |
| } | |
| .step-line.done { background: #00875A; } | |
| /* ── Wizard card ──────────────────────────────────────────────────────── */ | |
| .wizard-card { | |
| background: #fff; | |
| border-radius: 16px; | |
| border: 1px solid #E1E4E8; | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.06); | |
| padding: 40px; | |
| } | |
| /* ── Panel ────────────────────────────────────────────────────────────── */ | |
| .panel-header { margin-bottom: 28px; } | |
| .panel-tag { | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: #0061D5; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin-bottom: 8px; | |
| } | |
| .panel-title { font-size: 1.5rem; font-weight: 700; color: #1A1A2E; margin-bottom: 8px; } | |
| .panel-sub { font-size: 14px; color: #6B7280; line-height: 1.6; } | |
| .panel-footer { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-top: 28px; | |
| padding-top: 24px; | |
| border-top: 1px solid #F0F2F5; | |
| } | |
| /* ── Upload zone ──────────────────────────────────────────────────────── */ | |
| .upload-zone { | |
| border: 2px dashed #D1D9E0; | |
| border-radius: 12px; | |
| background: #FAFBFC; | |
| min-height: 200px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .upload-zone:hover { border-color: #0061D5; background: #F0F5FF; } | |
| .upload-zone.drag-over { | |
| border-color: #0061D5; | |
| background: #EBF2FF; | |
| transform: scale(1.01); | |
| box-shadow: 0 0 0 4px rgba(0,97,213,0.1); | |
| } | |
| .upload-prompt { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 40px 20px; | |
| text-align: center; | |
| } | |
| .upload-icon { transition: transform 0.2s; } | |
| .upload-zone:hover .upload-icon { transform: translateY(-4px); } | |
| .upload-text { font-size: 15px; font-weight: 600; color: #1A1A2E; } | |
| .upload-hint { font-size: 12px; color: #9CA3AF; } | |
| .upload-preview { | |
| width: 100%; | |
| height: 100%; | |
| min-height: 200px; | |
| position: relative; | |
| } | |
| .upload-preview img { | |
| width: 100%; | |
| height: 280px; | |
| object-fit: cover; | |
| border-radius: 10px; | |
| } | |
| .change-photo-btn { | |
| position: absolute; | |
| top: 12px; | |
| right: 12px; | |
| background: rgba(0,0,0,0.6); | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| padding: 8px 14px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| backdrop-filter: blur(4px); | |
| font-family: inherit; | |
| } | |
| .change-photo-btn:hover { background: rgba(0,0,0,0.8); } | |
| /* ── Photo meta ───────────────────────────────────────────────────────── */ | |
| .meta-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; } | |
| .file-name { font-size: 13px; color: #6B7280; font-weight: 500; } | |
| .info-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 14px; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| margin-top: 12px; | |
| } | |
| .info-pill--success { background: #E3FCEF; color: #006644; border: 1px solid rgba(0,135,90,0.2); } | |
| .info-pill--warn { background: #FFFAE6; color: #974F0C; border: 1px solid rgba(255,153,31,0.3); } | |
| /* ── AI panel ─────────────────────────────────────────────────────────── */ | |
| .ai-panel { | |
| margin-top: 16px; | |
| border: 1px solid #E1E4E8; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| } | |
| .ai-loading { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 16px 20px; | |
| background: #F8F9FF; | |
| font-size: 14px; | |
| color: #0061D5; | |
| font-weight: 500; | |
| } | |
| .spinner-sm { | |
| width: 18px; | |
| height: 18px; | |
| border: 2px solid rgba(0,97,213,0.2); | |
| border-top-color: #0061D5; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| flex-shrink: 0; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ── AI result card ───────────────────────────────────────────────────── */ | |
| .ai-result { padding: 20px; } | |
| .ai-stage { display: flex; align-items: flex-start; gap: 14px; padding: 14px 0; } | |
| .ai-stage + .ai-stage { border-top: 1px solid #F0F2F5; } | |
| .ai-stage-icon { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .ai-stage-icon.pass { background: #E3FCEF; color: #00875A; } | |
| .ai-stage-icon.fail { background: #FFEBE6; color: #DE350B; } | |
| .ai-stage-label { font-size: 11px; font-weight: 700; color: #9CA3AF; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; } | |
| .ai-stage-value { font-size: 14px; font-weight: 700; color: #1A1A2E; } | |
| .ai-stage-value.pass { color: #00875A; } | |
| .ai-stage-value.fail { color: #DE350B; } | |
| .ai-stage-note { font-size: 12px; color: #6B7280; margin-top: 4px; } | |
| .size-pill { | |
| display: inline-block; | |
| padding: 4px 12px; | |
| border-radius: 6px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| margin-right: 8px; | |
| } | |
| .size-small { background: #E3FCEF; color: #006644; } | |
| .size-medium { background: #FFFAE6; color: #974F0C; } | |
| .size-large { background: #FFEBE6; color: #BF2600; } | |
| .ai-bars { padding: 14px 0 0; border-top: 1px solid #F0F2F5; margin-top: 4px; } | |
| .ai-bar-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 8px; | |
| } | |
| .ai-bar-label { font-size: 12px; color: #6B7280; width: 54px; font-weight: 500; } | |
| .ai-bar-track { flex: 1; height: 6px; background: #F0F2F5; border-radius: 3px; overflow: hidden; } | |
| .ai-bar-fill { height: 100%; border-radius: 3px; transition: width 0.6s ease; } | |
| .ai-bar-pct { font-size: 12px; color: #6B7280; width: 40px; text-align: right; } | |
| .ai-bar-row.winner .ai-bar-label { color: #1A1A2E; font-weight: 700; } | |
| .ai-bar-row.winner .ai-bar-pct { font-weight: 700; } | |
| .ai-reject-warning { | |
| margin: 12px 20px; | |
| padding: 10px 14px; | |
| background: #FFEBE6; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| color: #BF2600; | |
| font-weight: 500; | |
| } | |
| .ai-compact { | |
| display: flex; | |
| align-items: flex-start; | |
| gap: 12px; | |
| padding: 16px 20px; | |
| background: #F8F9FF; | |
| } | |
| .ai-compact--success { background: #F0FDF4; } | |
| .ai-compact--warning { background: #FFFAE6; } | |
| .ai-compact--neutral { background: #F8F9FF; } | |
| .ai-compact__dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| margin-top: 6px; | |
| flex-shrink: 0; | |
| background: #0061D5; | |
| } | |
| .ai-compact--success .ai-compact__dot { background: #00875A; } | |
| .ai-compact--warning .ai-compact__dot { background: #F4A300; } | |
| .ai-compact__title { | |
| font-size: 14px; | |
| font-weight: 700; | |
| color: #1A1A2E; | |
| margin-bottom: 2px; | |
| } | |
| .ai-compact__message { | |
| font-size: 13px; | |
| color: #6B7280; | |
| line-height: 1.5; | |
| } | |
| /* ── GPS status ───────────────────────────────────────────────────────── */ | |
| .gps-status-bar { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| margin-bottom: 14px; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .gps-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 14px; | |
| border-radius: 8px; | |
| background: #F4F6F9; | |
| border: 1px solid #E1E4E8; | |
| font-size: 13px; | |
| font-weight: 500; | |
| color: #6B7280; | |
| } | |
| .gps-pill.verified { background: #E3FCEF; border-color: rgba(0,135,90,0.3); color: #006644; } | |
| .gps-pill.mismatch { background: #FFFAE6; border-color: rgba(255,153,31,0.3); color: #974F0C; } | |
| .gps-dot { width: 8px; height: 8px; border-radius: 50%; background: #9CA3AF; } | |
| .gps-pill.verified .gps-dot { background: #00875A; } | |
| .gps-pill.mismatch .gps-dot { background: #FF991F; } | |
| /* ── Map ──────────────────────────────────────────────────────────────── */ | |
| .map-container { | |
| height: 380px; | |
| border-radius: 12px; | |
| overflow: hidden; | |
| border: 1px solid #E1E4E8; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.06); | |
| margin-top: 16px; | |
| margin-bottom: 24px; | |
| } | |
| .map-note { font-size: 12px; color: #9CA3AF; margin-top: 10px; margin-bottom: 24px; } | |
| /* ── Location card ────────────────────────────────────────────────────── */ | |
| .location-card { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| padding: 16px; | |
| background: #F8F9FF; | |
| border: 1px solid #D1DEF8; | |
| border-radius: 12px; | |
| margin-top: 12px; | |
| } | |
| .location-details { flex: 1; } | |
| .location-address { font-size: 14px; font-weight: 600; color: #1A1A2E; } | |
| .location-coords { font-size: 12px; color: #6B7280; margin-top: 2px; font-family: monospace; } | |
| .ward-badge { | |
| background: #0061D5; | |
| color: #fff; | |
| padding: 4px 12px; | |
| border-radius: 50px; | |
| font-size: 12px; | |
| font-weight: 700; | |
| white-space: nowrap; | |
| } | |
| /* ── Toggle ───────────────────────────────────────────────────────────── */ | |
| .anon-toggle-row { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 16px; | |
| background: #F8F9FF; | |
| border: 1px solid #E1E4E8; | |
| border-radius: 12px; | |
| } | |
| .toggle-title { font-size: 14px; font-weight: 600; color: #1A1A2E; } | |
| .toggle-sub { font-size: 12px; color: #6B7280; margin-top: 2px; } | |
| .toggle { position: relative; display: inline-block; width: 44px; height: 24px; } | |
| .toggle input { opacity: 0; width: 0; height: 0; } | |
| .toggle-slider { | |
| position: absolute; | |
| inset: 0; | |
| background: #E1E4E8; | |
| border-radius: 24px; | |
| cursor: pointer; | |
| transition: 0.3s; | |
| } | |
| .toggle-slider::before { | |
| content: ''; | |
| position: absolute; | |
| width: 18px; | |
| height: 18px; | |
| left: 3px; | |
| top: 3px; | |
| background: #fff; | |
| border-radius: 50%; | |
| transition: 0.3s; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2); | |
| } | |
| .toggle input:checked + .toggle-slider { background: #0061D5; } | |
| .toggle input:checked + .toggle-slider::before { transform: translateX(20px); } | |
| /* ── Fields ───────────────────────────────────────────────────────────── */ | |
| .field-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } | |
| .field-group { display: flex; flex-direction: column; gap: 6px; } | |
| .field-label { font-size: 13px; font-weight: 600; color: #1A1A2E; } | |
| .opt { font-size: 11px; font-weight: 400; color: #9CA3AF; margin-left: 4px; } | |
| .field-input { | |
| padding: 10px 14px; | |
| border: 1.5px solid #E1E4E8; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| background: #fff; | |
| color: #1A1A2E; | |
| outline: none; | |
| transition: border-color 0.15s, box-shadow 0.15s; | |
| width: 100%; | |
| font-family: inherit; | |
| } | |
| .field-input:focus { | |
| border-color: #0061D5; | |
| box-shadow: 0 0 0 3px rgba(0,97,213,0.12); | |
| } | |
| .field-textarea { resize: vertical; min-height: 100px; } | |
| /* ── Summary box ──────────────────────────────────────────────────────── */ | |
| .summary-box { | |
| background: #F8F9FF; | |
| border: 1px solid #D1DEF8; | |
| border-radius: 12px; | |
| padding: 16px 20px; | |
| margin-top: 20px; | |
| } | |
| .summary-title { | |
| font-size: 12px; | |
| font-weight: 700; | |
| color: #0061D5; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| margin-bottom: 12px; | |
| } | |
| .summary-row { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 6px 0; | |
| border-bottom: 1px solid #E8EDFF; | |
| font-size: 13px; | |
| } | |
| .summary-row:last-child { border-bottom: none; } | |
| .summary-key { color: #6B7280; font-weight: 500; } | |
| .summary-val { font-weight: 600; color: #1A1A2E; text-align: right; max-width: 60%; } | |
| .terms { font-size: 12px; color: #9CA3AF; margin-top: 16px; line-height: 1.6; } | |
| /* ── Buttons ──────────────────────────────────────────────────────────── */ | |
| .btn-primary { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 20px; | |
| background: #0061D5; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-decoration: none; | |
| font-family: inherit; | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| background: #0052B3; | |
| transform: translateY(-1px); | |
| box-shadow: 0 4px 12px rgba(0,97,213,0.3); | |
| } | |
| .btn-primary:disabled { background: #B0C4DE; cursor: not-allowed; transform: none; box-shadow: none; } | |
| .btn-secondary { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 16px; | |
| background: #fff; | |
| color: #1A1A2E; | |
| border: 1.5px solid #E1E4E8; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-family: inherit; | |
| } | |
| .btn-secondary:hover { border-color: #0061D5; color: #0061D5; background: #F0F5FF; } | |
| .btn-sm { padding: 6px 12px; font-size: 12px; } | |
| .btn-ghost { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 10px 16px; | |
| background: transparent; | |
| color: #6B7280; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-family: inherit; | |
| } | |
| .btn-ghost:hover { background: #F4F6F9; color: #1A1A2E; } | |
| .btn-submit { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 12px 28px; | |
| background: #0061D5; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 15px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| font-family: inherit; | |
| } | |
| .btn-submit:hover:not(:disabled) { | |
| background: #0052B3; | |
| transform: translateY(-1px); | |
| box-shadow: 0 6px 16px rgba(0,97,213,0.35); | |
| } | |
| .btn-submit:disabled { background: #B0C4DE; cursor: not-allowed; } | |
| /* ── Success screen ───────────────────────────────────────────────────── */ | |
| .success-screen { max-width: 580px; margin: 40px auto; } | |
| .success-card { | |
| background: #fff; | |
| border-radius: 16px; | |
| border: 1px solid #E1E4E8; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.08); | |
| padding: 48px 40px; | |
| text-align: center; | |
| } | |
| .success-icon { margin: 0 auto 24px; } | |
| .checkmark-svg { width: 72px; height: 72px; } | |
| .checkmark-circle { | |
| stroke: #00875A; | |
| stroke-width: 2; | |
| stroke-dasharray: 160; | |
| stroke-dashoffset: 160; | |
| animation: drawCircle 0.5s ease forwards 0.1s; | |
| } | |
| .checkmark-check { | |
| stroke: #00875A; | |
| stroke-width: 3; | |
| stroke-linecap: round; | |
| stroke-linejoin: round; | |
| stroke-dasharray: 50; | |
| stroke-dashoffset: 50; | |
| animation: drawCheck 0.3s ease forwards 0.7s; | |
| } | |
| @keyframes drawCircle { to { stroke-dashoffset: 0; } } | |
| @keyframes drawCheck { to { stroke-dashoffset: 0; } } | |
| .success-title { font-size: 1.75rem; font-weight: 800; color: #1A1A2E; margin-bottom: 12px; } | |
| .success-sub { font-size: 14px; color: #6B7280; line-height: 1.7; max-width: 400px; margin: 0 auto 28px; } | |
| .case-id-box { | |
| background: #F8F9FF; | |
| border: 1px solid #D1DEF8; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 28px; | |
| position: relative; | |
| } | |
| .case-id-label { | |
| font-size: 11px; | |
| font-weight: 700; | |
| color: #0061D5; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| margin-bottom: 8px; | |
| } | |
| .case-id-value { | |
| font-size: 1.3rem; | |
| font-weight: 800; | |
| color: #1A1A2E; | |
| font-family: monospace; | |
| letter-spacing: 0.04em; | |
| } | |
| .copy-btn { | |
| position: absolute; | |
| top: 16px; | |
| right: 16px; | |
| padding: 6px 12px; | |
| background: #0061D5; | |
| color: #fff; | |
| border: none; | |
| border-radius: 6px; | |
| font-size: 12px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| font-family: inherit; | |
| } | |
| .copy-btn:hover { background: #0052B3; } | |
| .success-pipeline { text-align: left; margin-bottom: 28px; } | |
| .pipeline-step { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 10px 0; | |
| border-bottom: 1px solid #F0F2F5; | |
| font-size: 14px; | |
| color: #6B7280; | |
| } | |
| .pipeline-step:last-child { border-bottom: none; } | |
| .pipeline-step.done { color: #006644; font-weight: 600; } | |
| .pip-dot { width: 10px; height: 10px; border-radius: 50%; background: #E1E4E8; flex-shrink: 0; } | |
| .pipeline-step.done .pip-dot { background: #00875A; } | |
| .success-actions { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; } | |
| /* ── Toast ────────────────────────────────────────────────────────────── */ | |
| .toast { | |
| position: fixed; | |
| bottom: 24px; | |
| right: 24px; | |
| background: #1A1A2E; | |
| color: #fff; | |
| padding: 14px 20px; | |
| border-radius: 10px; | |
| font-size: 14px; | |
| font-weight: 500; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.2); | |
| transform: translateY(120%); | |
| opacity: 0; | |
| transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); | |
| z-index: 9999; | |
| max-width: 360px; | |
| } | |
| .toast.show { transform: translateY(0); opacity: 1; } | |
| .toast.success { background: #00875A; } | |
| .toast.error { background: #DE350B; } | |
| .toast.warning { background: #FF991F; } | |
| /* ── How it works ─────────────────────────────────────────────────────── */ | |
| .how-section { | |
| background: #fff; | |
| border-top: 1px solid #E1E4E8; | |
| padding: 60px 24px; | |
| } | |
| .how-inner { max-width: 1000px; margin: 0 auto; text-align: center; } | |
| .how-tag { font-size: 12px; font-weight: 700; color: #0061D5; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 12px; } | |
| .how-title { font-size: 1.8rem; font-weight: 800; color: #1A1A2E; margin-bottom: 40px; } | |
| .how-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; } | |
| .how-card { background: #F8F9FF; border: 1px solid #E8EDFF; border-radius: 12px; padding: 24px; text-align: left; } | |
| .how-num { font-size: 12px; font-weight: 700; color: #0061D5; margin-bottom: 12px; } | |
| .how-name { font-size: 15px; font-weight: 700; color: #1A1A2E; margin-bottom: 8px; } | |
| .how-desc { font-size: 13px; color: #6B7280; line-height: 1.6; } | |
| .how-icon-wrap { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin-bottom: 16px; | |
| } | |
| /* ── Footer ───────────────────────────────────────────────────────────── */ | |
| .portal-footer { background: #0C1B33; color: rgba(255,255,255,0.6); padding: 32px 24px; } | |
| .footer-inner { | |
| max-width: 1100px; | |
| margin: 0 auto; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-wrap: wrap; | |
| gap: 16px; | |
| } | |
| .footer-brand { font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.8); } | |
| .footer-links { display: flex; gap: 24px; } | |
| .footer-links a { | |
| color: rgba(255,255,255,0.5); | |
| font-size: 13px; | |
| text-decoration: none; | |
| transition: color 0.15s; | |
| } | |
| .footer-links a:hover { color: rgba(255,255,255,0.9); } | |
| /* ── Responsive ───────────────────────────────────────────────────────── */ | |
| @media (max-width: 640px) { | |
| .wizard-card { padding: 24px 16px; } | |
| .how-grid { grid-template-columns: 1fr 1fr; } | |
| .field-row-2 { grid-template-columns: 1fr; } | |
| .steps-bar { gap: 0; } | |
| .step-line { max-width: 40px; margin: 0 4px 24px; } | |
| .hero-title { font-size: 1.8rem; } | |
| .success-card { padding: 32px 20px; } | |
| } | |
| @media (max-width: 420px) { | |
| .how-grid { grid-template-columns: 1fr; } | |
| .nav-menu { gap: 0; } | |
| .nav-item { padding: 6px 8px; font-size: 13px; } | |
| .hero-pill { font-size: 11px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <style>body{padding-top:64px}</style> | |
| <!-- ── NAVIGATION ──────────────────────────────────────────────────────── --> | |
| <header id="app-nav"> | |
| <div class="nav-wrap"> | |
| <a class="nav-brand" href="#"> | |
| <div class="brand-icon"> | |
| <svg width="20" height="20" 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="brand-city" id="nav-city">City Road Reporting</div> | |
| <div class="brand-sys">Pothole Reporting Portal</div> | |
| </div> | |
| </a> | |
| <nav class="nav-menu"> | |
| <a href="#" class="nav-item active">Submit Report</a> | |
| <a href="dashcam-demo.html" class="nav-item">Dashcam Mode</a> | |
| <a href="track.html" class="nav-item">Track Case</a> | |
| <a href="settings.html" class="nav-item nav-item--settings">⚙ Settings</a> | |
| <a href="ops-review.html" class="nav-item nav-item--settings nav-item--dev" id="dev-ops-link" style="display:none;">Dev Ops</a> | |
| </nav> | |
| </div> | |
| </header> | |
| <!-- ── HERO ────────────────────────────────────────────────────────────── --> | |
| <section class="hero"> | |
| <div class="hero-inner"> | |
| <h1 class="hero-title">Report a Road Pothole</h1> | |
| <p class="hero-sub">Upload a photo, verify location, and submit. The portal records live coordinates and uses official GIS routing when a boundary layer is configured.</p> | |
| </div> | |
| </section> | |
| <!-- ── MAIN ────────────────────────────────────────────────────────────── --> | |
| <main class="main"> | |
| <!-- Step indicator --> | |
| <div class="steps-bar"> | |
| <div class="step-item active" data-step="1"> | |
| <div class="step-circle"><span class="step-num">1</span></div> | |
| <span class="step-label">Upload Photo</span> | |
| </div> | |
| <div class="step-line" id="step-line-1"></div> | |
| <div class="step-item" data-step="2"> | |
| <div class="step-circle"><span class="step-num">2</span></div> | |
| <span class="step-label">Verify Location</span> | |
| </div> | |
| <div class="step-line" id="step-line-2"></div> | |
| <div class="step-item" data-step="3"> | |
| <div class="step-circle"><span class="step-num">3</span></div> | |
| <span class="step-label">Submit Report</span> | |
| </div> | |
| </div> | |
| <!-- Wizard card --> | |
| <div class="wizard-card"> | |
| <!-- ── STEP 1 ── --> | |
| <div class="step-panel" id="step-1"> | |
| <div class="panel-header"> | |
| <div class="panel-tag">Step 1 of 3</div> | |
| <h2 class="panel-title">Upload a Photo</h2> | |
| <p class="panel-sub">Take or upload a clear photo of the pothole. GPS will be extracted automatically from the image.</p> | |
| </div> | |
| <!-- Upload zone --> | |
| <div class="upload-zone" id="upload-zone"> | |
| <div class="upload-prompt" id="upload-prompt"> | |
| <div class="upload-icon"> | |
| <svg width="40" height="40" viewBox="0 0 40 40" fill="none"> | |
| <rect width="40" height="40" rx="10" fill="#EFF4FF"/> | |
| <path d="M10 16h3l2-3h10l2 3h3a2 2 0 012 2v12a2 2 0 01-2 2H10a2 2 0 01-2-2V18a2 2 0 012-2z" stroke="#0061D5" stroke-width="1.8" fill="none"/> | |
| <circle cx="20" cy="22" r="4" stroke="#0061D5" stroke-width="1.8"/> | |
| </svg> | |
| </div> | |
| <div class="upload-text">Click or drag a photo here</div> | |
| <div class="upload-hint">Supports JPG, PNG, HEIC · Max 50MB</div> | |
| <button type="button" class="btn-primary" id="btn-browse" | |
| onclick="event.stopPropagation(); document.getElementById('file-input').click()"> | |
| Choose Photo | |
| </button> | |
| <button type="button" class="btn-secondary" id="btn-camera" | |
| onclick="event.stopPropagation(); document.getElementById('file-input').click()"> | |
| Take Photo | |
| </button> | |
| </div> | |
| <div class="upload-preview" id="upload-preview" style="display:none"> | |
| <img id="photo-preview" alt="Pothole photo preview" /> | |
| <button class="change-photo-btn" id="btn-change" | |
| onclick="event.stopPropagation(); document.getElementById('file-input').click()"> | |
| Change Photo | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Hidden file input (outside upload zone to avoid conflicts) --> | |
| <input type="file" id="file-input" accept="image/*" capture="environment" style="display:none"> | |
| <!-- Meta: shown after photo selected --> | |
| <div id="photo-meta" style="display:none"> | |
| <div class="meta-row"> | |
| <span class="file-name" id="file-name"></span> | |
| </div> | |
| <div class="info-pill info-pill--success" id="exif-found" style="display:none"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> | |
| GPS found in photo: <strong id="exif-coords"></strong> | |
| </div> | |
| <div class="info-pill info-pill--warn" id="exif-missing" style="display:none"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> | |
| No GPS in photo — set location manually in Step 2 | |
| </div> | |
| <!-- AI analysis panel --> | |
| <div class="ai-panel" id="ai-panel" style="display:none"> | |
| <div class="ai-loading" id="ai-loading"> | |
| <div class="spinner-sm"></div> | |
| <span>AI is analysing your photo…</span> | |
| </div> | |
| <div id="ai-result-card" style="display:none"></div> | |
| </div> | |
| </div> | |
| <div class="panel-footer"> | |
| <div></div> | |
| <button class="btn-primary" id="btn-next-1" disabled> | |
| Continue to Location | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg> | |
| </button> | |
| </div> | |
| </div><!-- /step-1 --> | |
| <!-- ── STEP 2 ── --> | |
| <div class="step-panel" id="step-2" style="display:none"> | |
| <div class="panel-header"> | |
| <div class="panel-tag">Step 2 of 3</div> | |
| <h2 class="panel-title">Verify Location</h2> | |
| <p class="panel-sub">Confirm where the pothole is. We've pre-loaded any GPS from your photo. Adjust the pin as needed.</p> | |
| </div> | |
| <div class="gps-status-bar"> | |
| <div class="gps-pill" id="gps-pill"> | |
| <div class="gps-dot"></div> | |
| <span id="gps-pill-text">Waiting for location…</span> | |
| </div> | |
| <button class="btn-secondary btn-sm" id="btn-device-loc"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/> | |
| <line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/> | |
| <line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/> | |
| </svg> | |
| Use My Location | |
| </button> | |
| </div> | |
| <div class="map-container" id="map-container"></div> | |
| <p class="map-note">📌 Click on the map to drop or move the pin to the exact pothole location</p> | |
| <div class="location-card" id="location-card" style="display:none"> | |
| <div> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#0061D5" stroke-width="2"> | |
| <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/> | |
| <circle cx="12" cy="10" r="3"/> | |
| </svg> | |
| </div> | |
| <div class="location-details"> | |
| <div class="location-address" id="location-address">Resolving address…</div> | |
| <div class="location-coords" id="location-coords"></div> | |
| <div id="gis-mapped-district" style="font-size: 11.5px; font-weight: 700; color: #475569; margin-top: 4px; display: none;"></div> | |
| </div> | |
| <div class="ward-badge" id="ward-badge" style="display:none"></div> | |
| </div> | |
| <div class="panel-footer"> | |
| <button class="btn-ghost" id="btn-back-2">← Back</button> | |
| <button class="btn-primary" id="btn-next-2" disabled> | |
| Continue to Details | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg> | |
| </button> | |
| </div> | |
| </div><!-- /step-2 --> | |
| <!-- ── STEP 3 ── --> | |
| <div class="step-panel" id="step-3" style="display:none"> | |
| <div class="panel-header"> | |
| <div class="panel-tag">Step 3 of 3</div> | |
| <h2 class="panel-title">Reporter Details</h2> | |
| <p class="panel-sub">Add your name and contact details to receive the ticket number and repair updates. You can still submit anonymously if you prefer.</p> | |
| </div> | |
| <div class="anon-toggle-row"> | |
| <div> | |
| <div class="toggle-title">Submit Anonymously</div> | |
| <div class="toggle-sub">Turn this off to include your name and contact details</div> | |
| </div> | |
| <label class="toggle"> | |
| <input type="checkbox" id="anon-toggle" checked> | |
| <span class="toggle-slider"></span> | |
| </label> | |
| </div> | |
| <div id="contact-fields" style="margin-top:1rem"> | |
| <div class="field-row-2"> | |
| <div class="field-group"> | |
| <label class="field-label">Your Name <span class="opt">For tracking</span></label> | |
| <input type="text" class="field-input" id="field-name" placeholder="Full name" autocomplete="name"> | |
| </div> | |
| <div class="field-group"> | |
| <label class="field-label">Email or Phone <span class="opt">For status updates</span></label> | |
| <input type="text" class="field-input" id="field-contact" placeholder="Email address or phone number" autocomplete="email"> | |
| </div> | |
| </div> | |
| <p class="panel-sub" style="margin-top:0.75rem">Complaint tracking is easier when we can send you a ticket number and repair updates.</p> | |
| </div> | |
| <div class="field-group" style="margin-top:1.25rem"> | |
| <label class="field-label">Description <span class="opt">Optional</span></label> | |
| <textarea class="field-input field-textarea" id="field-description" rows="4" | |
| placeholder="Describe the pothole — size, depth, how long it's been there…"></textarea> | |
| </div> | |
| <div class="field-group" style="margin-top:1rem"> | |
| <label class="field-label">Case ID</label> | |
| <div class="info-pill info-pill--success" id="case-id-pill">Case ID will be assigned when you submit</div> | |
| <p class="panel-sub" style="margin-top:0.5rem">Use this reference to check the report later. Engineers can use it to review the AI details.</p> | |
| </div> | |
| <!-- Summary box (populated by JS) --> | |
| <div class="summary-box" id="summary-box"></div> | |
| <p class="terms">By submitting, you confirm this is an accurate report of a road hazard in <span id="terms-city">your city</span>.</p> | |
| <div class="panel-footer"> | |
| <button class="btn-ghost" id="btn-back-3">← Back</button> | |
| <button class="btn-submit" id="btn-submit"> | |
| <span id="submit-label">Submit Report</span> | |
| <div class="spinner-sm" id="submit-spinner" style="display:none"></div> | |
| </button> | |
| </div> | |
| </div><!-- /step-3 --> | |
| </div><!-- /wizard-card --> | |
| <!-- ── SUCCESS SCREEN ── --> | |
| <div class="success-screen" id="success-screen" style="display:none"> | |
| <div class="success-card"> | |
| <div class="success-icon"> | |
| <svg viewBox="0 0 52 52" class="checkmark-svg"> | |
| <circle class="checkmark-circle" cx="26" cy="26" r="25" fill="none"/> | |
| <path class="checkmark-check" fill="none" d="M14 27l8 8 16-16"/> | |
| </svg> | |
| </div> | |
| <h2 class="success-title">Report Submitted!</h2> | |
| <p class="success-sub"> | |
| Your report has been submitted successfully. Thank you for reporting this to the | |
| <span id="success-dept">Department of Streets</span>. | |
| </p> | |
| <div class="case-id-box"> | |
| <div class="case-id-label">Reference Number</div> | |
| <div class="case-id-value" id="success-case-id">—</div> | |
| <button class="copy-btn" id="btn-copy-case">Copy</button> | |
| </div> | |
| <div class="success-pipeline"> | |
| <div class="pipeline-step done"><div class="pip-dot"></div><span>Submitted ✓</span></div> | |
| <div class="pipeline-step pending" id="pipeline-enrichment-step"><div class="pip-dot"></div><span>Under Review</span></div> | |
| <div class="pipeline-step pending"><div class="pip-dot"></div><span>In Progress</span></div> | |
| <div class="pipeline-step pending"><div class="pip-dot"></div><span>Resolved</span></div> | |
| </div> | |
| <div class="success-actions"> | |
| <a class="btn-primary" href="track.html" id="btn-track-case">Track This Case</a> | |
| <button class="btn-ghost" id="btn-new-report">Submit Another</button> | |
| </div> | |
| </div> | |
| </div> | |
| </main><!-- /main --> | |
| <!-- ── HOW IT WORKS ─────────────────────────────────────────────────────── --> | |
| <section class="how-section"> | |
| <div class="how-inner"> | |
| <div class="how-tag">How It Works</div> | |
| <h2 class="how-title">From Photo to Repair — Automated</h2> | |
| <div class="how-grid"> | |
| <div class="how-card"> | |
| <div class="how-icon-wrap" style="background:#EFF4FF"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#0061D5" stroke-width="1.8"> | |
| <rect x="3" y="3" width="18" height="18" rx="3"/> | |
| <circle cx="8.5" cy="8.5" r="1.5"/> | |
| <polyline points="21 15 16 10 5 21"/> | |
| </svg> | |
| </div> | |
| <div class="how-num">01</div> | |
| <div class="how-name">Upload Photo</div> | |
| <div class="how-desc">Take or upload a photo of the pothole. AI immediately verifies it's a real pothole and classifies severity.</div> | |
| </div> | |
| <div class="how-card"> | |
| <div class="how-icon-wrap" style="background:#FFF3E0"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#F4A300" stroke-width="1.8"> | |
| <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/> | |
| <circle cx="12" cy="10" r="3"/> | |
| </svg> | |
| </div> | |
| <div class="how-num">02</div> | |
| <div class="how-name">GPS Verified</div> | |
| <div class="how-desc">EXIF data is extracted from the photo and cross-checked with your device location for pinpoint accuracy.</div> | |
| </div> | |
| <div class="how-card"> | |
| <div class="how-icon-wrap" style="background:#F0FDF4"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#26C281" stroke-width="1.8"> | |
| <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/> | |
| </svg> | |
| </div> | |
| <div class="how-num">03</div> | |
| <div class="how-name">AI Routing</div> | |
| <div class="how-desc">Box AI scores severity, checks for duplicates, and adds GIS routing when official boundary data is available.</div> | |
| </div> | |
| <div class="how-card"> | |
| <div class="how-icon-wrap" style="background:#FDF4FF"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#8B5CF6" stroke-width="1.8"> | |
| <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/> | |
| <circle cx="9" cy="7" r="4"/> | |
| <path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75"/> | |
| </svg> | |
| </div> | |
| <div class="how-num">04</div> | |
| <div class="how-name">Engineer Review</div> | |
| <div class="how-desc">District engineer approves and dispatches a repair crew. Track the status of your case anytime.</div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ── FOOTER ───────────────────────────────────────────────────────────── --> | |
| <footer class="portal-footer"> | |
| <div class="footer-inner"> | |
| <div class="footer-brand" id="footer-brand">© 2026 Varun. All rights reserved. Proprietary & Confidential.</div> | |
| <nav class="footer-links"> | |
| <a href="#">Submit Report</a> | |
| <a href="dashcam-demo.html">Dashcam Mode</a> | |
| <a href="track.html">Track Case</a> | |
| <a href="settings.html">Settings</a> | |
| </nav> | |
| </div> | |
| </footer> | |
| <!-- ── TOAST ────────────────────────────────────────────────────────────── --> | |
| <div class="toast" id="toast"></div> | |
| <!-- ── EXTERNAL CDN LIBRARIES ──────────────────────────────────────────── --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/exifr/dist/lite.umd.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@teachablemachine/image@latest/dist/teachablemachine-image.min.js"></script> | |
| <!-- ── BOX UPLOAD (external) ───────────────────────────────────────────── --> | |
| <script src="gis.js"></script> | |
| <script src="backend-api.js?v=20260602a"></script> | |
| <script src="box-workflow.js?v=20260602a"></script> | |
| <script src="box-upload.js?v=20260602a"></script> | |
| <script src="enrichment.js?v=20260602a"></script> | |
| <!-- ── INLINE APPLICATION SCRIPT ──────────────────────────────────────── --> | |
| <script> | |
| ; | |
| // ─── CONFIG ─────────────────────────────────────────────────────────────── | |
| const CITY = localStorage.getItem('city_name') || ''; | |
| const DEPT = localStorage.getItem('dept_name') || 'Department of Streets'; | |
| const TM_URL = (localStorage.getItem('tm_model_url') || 'https://teachablemachine.withgoogle.com/models/3HQTi9DMo/').replace(/\/?$/, '/'); | |
| const CASE_PREFIX = (CITY.replace(/[^A-Za-z]/g, '').slice(0, 3) || 'POT').toUpperCase(); | |
| const POTHOLE_THRESHOLD = 0.72; | |
| const POTHOLE_MARGIN = 0.18; | |
| const ROAD_LIKE_THRESHOLD = 0.45; | |
| const IS_ENGINEER_VIEW = false; | |
| // ─── STATE ──────────────────────────────────────────────────────────────── | |
| const state = { | |
| photoFile: null, | |
| exifGPS: null, | |
| deviceGPS: null, | |
| confirmedGPS: null, | |
| ward: null, | |
| district: null, | |
| maintenanceZone: null, | |
| address: null, | |
| areaLabel: null, | |
| gisMapping: null, | |
| caseId: null, | |
| aiResult: null, | |
| aiConf: null, | |
| aiPrediction: null, | |
| aiReviewStatus: null, | |
| aiReviewMessage: null, | |
| aiRoadScore: null, | |
| aiPredictions: [], | |
| isPothole: false, | |
| photoDataUrl: null, | |
| photoDataReady: Promise.resolve(null), | |
| faceDetected: false, | |
| overrideFace: false | |
| }; | |
| function getLocationSourceLabel(source) { | |
| switch (String(source || '').trim().toLowerCase()) { | |
| case 'exif': return 'Extracted from photo EXIF GPS'; | |
| case 'device': return 'Device GPS selected'; | |
| case 'manual': return 'Manual pin selected'; | |
| case 'selected': return 'Manual pin selected'; | |
| case 'unknown': | |
| case '': | |
| return ''; | |
| default: | |
| return String(source || '').trim(); | |
| } | |
| } | |
| function getGpsVerificationStatus() { | |
| const location = state.confirmedGPS; | |
| const exif = state.exifGPS; | |
| if (!location || !exif) return 'none'; | |
| if (![location.lat, location.lng, exif.lat, exif.lng].every(Number.isFinite)) return 'none'; | |
| return haversine(location.lat, location.lng, exif.lat, exif.lng) < 150 ? 'verified' : 'mismatch'; | |
| } | |
| function getConfirmedLocationSourceLabel() { | |
| const explicitLabel = getLocationSourceLabel(state.confirmedGPS?.source); | |
| const verification = getGpsVerificationStatus(); | |
| if (explicitLabel) { | |
| if (/manual pin selected/i.test(explicitLabel) && verification === 'verified') { | |
| return 'Manual pin selected - verified with photo EXIF GPS'; | |
| } | |
| if (/device gps selected/i.test(explicitLabel) && verification === 'verified') { | |
| return 'Device GPS selected - verified with photo EXIF GPS'; | |
| } | |
| return explicitLabel; | |
| } | |
| if (state.confirmedGPS && Number.isFinite(state.confirmedGPS.lat) && Number.isFinite(state.confirmedGPS.lng)) { | |
| return verification === 'verified' | |
| ? 'Manual pin selected - verified with photo EXIF GPS' | |
| : 'Manual pin selected'; | |
| } | |
| return 'None'; | |
| } | |
| // ─── TOAST ──────────────────────────────────────────────────────────────── | |
| let _toastTimer = null; | |
| function toast(msg, type = 'info') { | |
| const el = document.getElementById('toast'); | |
| if (!el) return; | |
| if (_toastTimer) clearTimeout(_toastTimer); | |
| el.textContent = msg; | |
| el.className = 'toast ' + type + ' show'; | |
| _toastTimer = setTimeout(() => el.classList.remove('show'), 3500); | |
| } | |
| // ─── STEP NAVIGATION ────────────────────────────────────────────────────── | |
| let currentStep = 1; | |
| function goToStep(n) { | |
| // Toggle panels | |
| [1, 2, 3].forEach(i => { | |
| const p = document.getElementById('step-' + i); | |
| if (p) p.style.display = (i === n) ? 'block' : 'none'; | |
| }); | |
| // Update step circles | |
| document.querySelectorAll('.step-item').forEach(el => { | |
| const s = parseInt(el.dataset.step, 10); | |
| el.classList.remove('active', 'completed'); | |
| if (s < n) el.classList.add('completed'); | |
| if (s === n) el.classList.add('active'); | |
| }); | |
| // Update connecting lines | |
| const line1 = document.getElementById('step-line-1'); | |
| const line2 = document.getElementById('step-line-2'); | |
| if (line1) line1.classList.toggle('done', n > 1); | |
| if (line2) line2.classList.toggle('done', n > 2); | |
| // Update step-num text for completed steps (show ✓) | |
| document.querySelectorAll('.step-item').forEach(el => { | |
| const s = parseInt(el.dataset.step, 10); | |
| const num = el.querySelector('.step-num'); | |
| if (!num) return; | |
| num.textContent = (s < n) ? '✓' : String(s); | |
| }); | |
| currentStep = n; | |
| if (n === 2) { | |
| if (!window._mapReady) { | |
| initMap(); | |
| } else if (leafletMap) { | |
| setTimeout(() => { | |
| leafletMap.invalidateSize(); | |
| }, 150); | |
| } | |
| } | |
| if (n === 3) buildSummary(); | |
| } | |
| // ─── UPLOAD ─────────────────────────────────────────────────────────────── | |
| let tmModel = null; | |
| function initUpload() { | |
| const zone = document.getElementById('upload-zone'); | |
| const input = document.getElementById('file-input'); | |
| if (!zone || !input) return; | |
| // Click zone → open file dialog (except when clicking the button itself) | |
| zone.addEventListener('click', e => { | |
| if (['btn-browse', 'btn-camera', 'btn-change'].includes(e.target.id)) return; | |
| input.click(); | |
| }); | |
| // Drag-and-drop | |
| zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); }); | |
| zone.addEventListener('dragleave', e => { | |
| if (!zone.contains(e.relatedTarget)) zone.classList.remove('drag-over'); | |
| }); | |
| zone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| zone.classList.remove('drag-over'); | |
| const f = e.dataTransfer.files[0]; | |
| if (f && f.type.startsWith('image/')) handleFile(f); | |
| else toast('Please drop an image file', 'warning'); | |
| }); | |
| input.addEventListener('change', () => { | |
| if (input.files[0]) handleFile(input.files[0]); | |
| input.value = ''; | |
| }); | |
| } | |
| function generatePhotoPreviewDataUrl(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onerror = () => reject(new Error('Could not read uploaded image.')); | |
| reader.onload = function(e) { | |
| const img = new Image(); | |
| img.onerror = () => reject(new Error('Could not render uploaded image.')); | |
| img.onload = function() { | |
| const maxDim = 400; | |
| let w = img.width; | |
| let h = img.height; | |
| if (w > maxDim || h > maxDim) { | |
| if (w > h) { | |
| h = Math.round((h * maxDim) / w); | |
| w = maxDim; | |
| } else { | |
| w = Math.round((w * maxDim) / h); | |
| h = maxDim; | |
| } | |
| } | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = w; | |
| canvas.height = h; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) { | |
| reject(new Error('Canvas preview context unavailable.')); | |
| return; | |
| } | |
| ctx.drawImage(img, 0, 0, w, h); | |
| resolve(canvas.toDataURL('image/jpeg', 0.7)); | |
| }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| } | |
| async function handleFile(file) { | |
| state.photoFile = file; | |
| state.aiResult = null; | |
| state.aiConf = null; | |
| state.aiPrediction = null; | |
| state.aiReviewStatus = null; | |
| state.aiReviewMessage = null; | |
| state.aiRoadScore = null; | |
| state.aiPredictions = []; | |
| state.isPothole = false; | |
| state.photoDataUrl = null; | |
| state.photoDataReady = Promise.resolve(null); | |
| state.faceDetected = false; | |
| state.overrideFace = false; | |
| // Show preview | |
| const preview = document.getElementById('photo-preview'); | |
| preview.src = URL.createObjectURL(file); | |
| document.getElementById('upload-prompt').style.display = 'none'; | |
| document.getElementById('upload-preview').style.display = 'block'; | |
| // File name | |
| document.getElementById('photo-meta').style.display = 'block'; | |
| document.getElementById('file-name').textContent = | |
| file.name + ' (' + (file.size / 1024 / 1024).toFixed(2) + ' MB)'; | |
| // Disable next until AI confirms | |
| document.getElementById('btn-next-1').disabled = true; | |
| // Generate a lightweight Base64 thumbnail for local persistence | |
| try { | |
| state.photoDataReady = generatePhotoPreviewDataUrl(file) | |
| .then((dataUrl) => { | |
| state.photoDataUrl = dataUrl; | |
| return dataUrl; | |
| }) | |
| .catch((err) => { | |
| console.warn('[handleFile] Failed to generate thumbnail:', err); | |
| state.photoDataUrl = null; | |
| return null; | |
| }); | |
| } catch (err) { | |
| console.warn('[handleFile] Failed to generate thumbnail:', err); | |
| } | |
| await extractEXIF(file); | |
| await runAI(file); | |
| } | |
| async function extractEXIF(file) { | |
| const foundEl = document.getElementById('exif-found'); | |
| const missEl = document.getElementById('exif-missing'); | |
| foundEl.style.display = 'none'; | |
| missEl.style.display = 'none'; | |
| try { | |
| if (typeof exifr === 'undefined') throw new Error('exifr not loaded'); | |
| const gps = await exifr.gps(file); | |
| if (gps && gps.latitude != null && gps.longitude != null) { | |
| state.exifGPS = { lat: gps.latitude, lng: gps.longitude }; | |
| document.getElementById('exif-coords').textContent = | |
| gps.latitude.toFixed(5) + ', ' + gps.longitude.toFixed(5); | |
| foundEl.style.display = 'inline-flex'; | |
| } else { | |
| throw new Error('no gps'); | |
| } | |
| } catch (e) { | |
| state.exifGPS = null; | |
| missEl.style.display = 'inline-flex'; | |
| } | |
| } | |
| async function loadTMModel() { | |
| try { | |
| if (typeof tmImage === 'undefined') return; | |
| tmModel = await tmImage.load(TM_URL + 'model.json', TM_URL + 'metadata.json'); | |
| console.log('[AI] Model loaded:', TM_URL); | |
| } catch (e) { | |
| console.warn('[AI] Model load failed:', e); | |
| tmModel = null; | |
| } | |
| } | |
| function assessRoadLikelihood(img) { | |
| const canvas = document.createElement('canvas'); | |
| const size = 64; | |
| canvas.width = size; | |
| canvas.height = size; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| if (!ctx) { | |
| return { ratio: 1, passes: true }; | |
| } | |
| ctx.drawImage(img, 0, 0, size, size); | |
| let roadLikePixels = 0; | |
| let sampledPixels = 0; | |
| const { data } = ctx.getImageData(0, 0, size, size); | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| const max = Math.max(r, g, b); | |
| const min = Math.min(r, g, b); | |
| const brightness = (r + g + b) / 3; | |
| const saturation = max === 0 ? 0 : (max - min) / max; | |
| // Road surfaces are usually low-saturation and mid-dark to mid-light. | |
| const looksRoadLike = saturation < 0.35 && brightness > 35 && brightness < 225; | |
| if (looksRoadLike) roadLikePixels += 1; | |
| sampledPixels += 1; | |
| } | |
| const ratio = sampledPixels ? roadLikePixels / sampledPixels : 0; | |
| return { | |
| ratio, | |
| passes: ratio >= ROAD_LIKE_THRESHOLD, | |
| }; | |
| } | |
| async function detectFaces(img) { | |
| // Prefer the browser FaceDetector API when available | |
| try { | |
| if ('FaceDetector' in window) { | |
| const detector = new FaceDetector(); | |
| const faces = await detector.detect(img); | |
| return faces && faces.length ? faces.length : 0; | |
| } | |
| } catch (e) { | |
| console.warn('FaceDetector failed:', e.message || e); | |
| } | |
| // Fallback: simple skin-tone scan on a downsampled canvas | |
| try { | |
| const canvas = document.createElement('canvas'); | |
| const size = 64; | |
| canvas.width = size; canvas.height = size; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| ctx.drawImage(img, 0, 0, size, size); | |
| const { data } = ctx.getImageData(0, 0, size, size); | |
| let skin = 0, total = 0; | |
| for (let i = 0; i < data.length; i += 4) { | |
| const r = data[i], g = data[i+1], b = data[i+2]; | |
| // simple skin color heuristic in RGB | |
| const condition = r > 95 && g > 40 && b > 20 && (r > g) && (r > b) && (Math.max(r,g,b) - Math.min(r,g,b) > 15); | |
| if (condition) skin++; | |
| total++; | |
| } | |
| const ratio = total ? skin / total : 0; | |
| // If >5% of the image is skin-like (and an absolute count), treat as possible person/face | |
| const skinCountThreshold = Math.max(20, Math.round(0.05 * total)); | |
| return (ratio > 0.05 && skin > skinCountThreshold) ? 1 : 0; | |
| } catch (e) { | |
| console.warn('face fallback failed', e); | |
| return 0; | |
| } | |
| } | |
| function renderRejectResult(sorted, message) { | |
| const resultCard = document.getElementById('ai-result-card'); | |
| if (!resultCard) return; | |
| resultCard.innerHTML = ` | |
| <div class="ai-result"> | |
| <div class="ai-stage"> | |
| <div class="ai-stage-icon fail"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <line x1="15" y1="9" x2="9" y2="15"/> | |
| <line x1="9" y1="9" x2="15" y2="15"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="ai-stage-label">Stage 1 — Pothole Verification</div> | |
| <div class="ai-stage-value fail">Not Recognised as a Pothole</div> | |
| <div class="ai-stage-note">${message}</div> | |
| </div> | |
| </div> | |
| <div class="ai-bars"> | |
| ${sorted.map(p => { | |
| const w = (p.probability * 100).toFixed(1); | |
| return `<div class="ai-bar-row"> | |
| <span class="ai-bar-label">${p.className}</span> | |
| <div class="ai-bar-track"><div class="ai-bar-fill" style="width:${w}%;background:#CBD5E0"></div></div> | |
| <span class="ai-bar-pct">${w}%</span> | |
| </div>`; | |
| }).join('')} | |
| </div> | |
| </div> | |
| <div class="ai-reject-warning"> | |
| ⚠ Please upload a clear, close-up photo of an actual road pothole to continue. | |
| </div>`; | |
| } | |
| function renderPublicAiStatus({ title, tone, message }) { | |
| const resultCard = document.getElementById('ai-result-card'); | |
| if (!resultCard) return; | |
| // For public users show a simple friendly completion message rather than internals. | |
| if (!IS_ENGINEER_VIEW) { | |
| resultCard.style.display = 'block'; | |
| const toneClass = tone === 'success' ? 'success' : tone === 'warning' ? 'warning' : 'neutral'; | |
| resultCard.innerHTML = ` | |
| <div class="ai-compact ai-compact--${toneClass}"> | |
| <div class="ai-compact__dot"></div> | |
| <div class="ai-compact__body"> | |
| <div class="ai-compact__title">AI analysis complete</div> | |
| <div class="ai-compact__message">Thank you for submitting the photo. Click "Continue to Location" to proceed.</div> | |
| </div> | |
| </div>`; | |
| return; | |
| } | |
| const toneClass = tone === 'success' ? 'success' : tone === 'warning' ? 'warning' : 'neutral'; | |
| resultCard.innerHTML = ` | |
| <div class="ai-compact ai-compact--${toneClass}"> | |
| <div class="ai-compact__dot"></div> | |
| <div class="ai-compact__body"> | |
| <div class="ai-compact__title">${title}</div> | |
| <div class="ai-compact__message">${message}</div> | |
| </div> | |
| </div>`; | |
| } | |
| function renderEngineerAiResult({ isPothole, bestClass, confidence, predictions, roadCheck }) { | |
| const resultCard = document.getElementById('ai-result-card'); | |
| if (!resultCard) return; | |
| const sizeColors = { Small: '#26C281', Medium: '#F4A300', Large: '#E53E3E' }; | |
| const pct = (confidence * 100).toFixed(1); | |
| if (!isPothole) { | |
| renderRejectResult( | |
| predictions, | |
| `Road-likeness score: ${(roadCheck.ratio * 100).toFixed(1)}% (minimum: ${(ROAD_LIKE_THRESHOLD * 100).toFixed(0)}%).` | |
| ); | |
| return; | |
| } | |
| const color = sizeColors[bestClass] || '#0061D5'; | |
| resultCard.className = ''; | |
| resultCard.innerHTML = ` | |
| <div class="ai-result"> | |
| <div class="ai-stage"> | |
| <div class="ai-stage-icon pass"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <polyline points="9 12 11 14 15 10"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="ai-stage-label">Stage 1 — Pothole Verification</div> | |
| <div class="ai-stage-value pass">Pothole Confirmed ✓</div> | |
| </div> | |
| </div> | |
| <div class="ai-stage"> | |
| <div class="ai-stage-icon" style="background:${color}20;color:${color}"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="ai-stage-label">Stage 2 — Size Classification</div> | |
| <div class="ai-stage-value" style="color:${color}"> | |
| <span class="size-pill size-${bestClass.toLowerCase()}">${bestClass}</span> | |
| ${bestClass} Pothole — ${pct}% confidence | |
| </div> | |
| </div> | |
| </div> | |
| <div class="ai-bars"> | |
| ${predictions.map(p => { | |
| const w = (p.probability * 100).toFixed(1); | |
| const isWin = p.className === bestClass; | |
| const c = sizeColors[p.className] || '#0061D5'; | |
| return `<div class="ai-bar-row${isWin ? ' winner' : ''}"> | |
| <span class="ai-bar-label">${p.className}</span> | |
| <div class="ai-bar-track"> | |
| <div class="ai-bar-fill" style="width:${w}%;background:${isWin ? c : '#CBD5E0'}"></div> | |
| </div> | |
| <span class="ai-bar-pct" style="${isWin ? `color:${c};font-weight:700` : ''}">${w}%</span> | |
| </div>`; | |
| }).join('')} | |
| </div> | |
| </div>`; | |
| } | |
| async function runAI(file) { | |
| const panel = document.getElementById('ai-panel'); | |
| const loading = document.getElementById('ai-loading'); | |
| const resultCard = document.getElementById('ai-result-card'); | |
| panel.style.display = 'block'; | |
| loading.style.display = 'flex'; | |
| resultCard.style.display = 'none'; | |
| try { | |
| const img = new Image(); | |
| const url = URL.createObjectURL(file); | |
| img.src = url; | |
| await new Promise((res, rej) => { img.onload = res; img.onerror = rej; }); | |
| // quick face check so we can route people-in-frame uploads to manual review | |
| const facesFound = await detectFaces(img); | |
| state.faceDetected = !!facesFound; | |
| state.overrideFace = !!facesFound; | |
| const roadCheck = assessRoadLikelihood(img); // catches non-road photos → suspect YOLO false positives | |
| URL.revokeObjectURL(url); | |
| // ── REAL-TIME pothole detection — open-source YOLOv8 model on the backend. ── | |
| // Decides whether it is genuinely a pothole AND its size, from the actual image. | |
| const buf = await file.arrayBuffer(); | |
| const resp = await fetch('/api/classify', { | |
| method: 'POST', | |
| headers: { 'Content-Type': file.type || 'image/jpeg' }, | |
| body: buf, | |
| }); | |
| const det = await resp.json(); | |
| if (!det || det.ok === false || det.error) { | |
| throw new Error(det && det.error ? det.error : 'Detection service unavailable'); | |
| } | |
| const conf = det.confidence || 0; | |
| const size = det.size || 'Medium'; // Small | Medium | Large (from box area) | |
| const sorted = (det.boxes || []) | |
| .map(b => ({ className: 'Pothole', probability: b.conf })) | |
| .sort((a, b) => b.probability - a.probability); | |
| state.aiDetection = det; // raw detection (boxes, area fraction) | |
| state.aiPredictions = sorted; | |
| state.aiRoadScore = roadCheck.ratio; | |
| loading.style.display = 'none'; | |
| resultCard.style.display = 'block'; | |
| const YOLO_AUTO_CONFIRM = 0.50; // >= -> auto-confirm, below -> manual review | |
| if (!det.isPothole) { | |
| // The model did NOT detect a pothole in the image. | |
| state.isPothole = false; | |
| state.aiResult = null; | |
| state.aiPrediction = 'No pothole detected'; | |
| state.aiReviewStatus = 'invalid_image'; | |
| state.aiReviewMessage = 'The AI model did not detect a pothole in this image.'; | |
| state.aiConf = conf; | |
| document.getElementById('btn-next-1').disabled = false; | |
| if (IS_ENGINEER_VIEW) { | |
| renderEngineerAiResult({ | |
| isPothole: false, | |
| bestClass: 'No pothole detected', | |
| confidence: conf, | |
| predictions: sorted, | |
| roadCheck: { ratio: 0, passes: false }, | |
| }); | |
| } else { | |
| renderPublicAiStatus({ | |
| title: 'No pothole detected', | |
| tone: 'warn', | |
| message: 'Our AI did not detect a pothole here. You can still submit if you believe this is one.', | |
| }); | |
| } | |
| } else if (facesFound) { | |
| state.isPothole = true; | |
| state.aiResult = size; | |
| state.aiPrediction = `${size} pothole`; | |
| state.aiReviewStatus = 'manual_review'; | |
| state.aiReviewMessage = `A person was detected in the frame, so this report was routed for manual review. Detected a ${size.toLowerCase()} pothole (${(conf * 100).toFixed(0)}%).`; | |
| state.aiConf = conf; | |
| document.getElementById('btn-next-1').disabled = false; | |
| if (IS_ENGINEER_VIEW) { | |
| renderEngineerAiResult({ | |
| isPothole: true, | |
| bestClass: `${size} pothole`, | |
| confidence: conf, | |
| predictions: sorted, | |
| roadCheck: { ratio: 1, passes: true }, | |
| }); | |
| } else { | |
| resultCard.style.display = 'none'; | |
| } | |
| } else if (conf < YOLO_AUTO_CONFIRM) { | |
| state.isPothole = true; | |
| state.aiResult = size; | |
| state.aiPrediction = `${size} pothole`; | |
| state.aiReviewStatus = 'manual_review'; | |
| state.aiReviewMessage = `AI detected a possible ${size.toLowerCase()} pothole at ${(conf * 100).toFixed(0)}% confidence — routed for manual review.`; | |
| state.aiConf = conf; | |
| document.getElementById('btn-next-1').disabled = false; | |
| if (IS_ENGINEER_VIEW) { | |
| renderEngineerAiResult({ | |
| isPothole: true, | |
| bestClass: `${size} pothole (low confidence)`, | |
| confidence: conf, | |
| predictions: sorted, | |
| roadCheck: { ratio: 1, passes: true }, | |
| }); | |
| } else { | |
| renderPublicAiStatus({ | |
| title: 'AI check complete', | |
| tone: 'success', | |
| message: `Possible ${size.toLowerCase()} pothole flagged for review. The report can continue to submission.`, | |
| }); | |
| } | |
| } else if (!roadCheck.passes) { | |
| state.isPothole = true; | |
| state.aiResult = size; | |
| state.aiPrediction = `${size} pothole`; | |
| state.aiReviewStatus = 'manual_review'; | |
| state.aiReviewMessage = `AI flagged a ${size.toLowerCase()} pothole (${(conf * 100).toFixed(0)}%) but the photo doesn't look like a road surface (${(roadCheck.ratio * 100).toFixed(0)}%) — routed for manual review to rule out a false positive.`; | |
| state.aiConf = conf; | |
| document.getElementById('btn-next-1').disabled = false; | |
| if (IS_ENGINEER_VIEW) { | |
| renderEngineerAiResult({ isPothole: true, bestClass: `${size} pothole — verify (not road-like)`, confidence: conf, predictions: sorted, roadCheck }); | |
| } else { | |
| renderPublicAiStatus({ title: 'AI check complete', tone: 'warn', message: 'Possible pothole detected, but the photo may not be a road surface — routed for review. You can still submit.' }); | |
| } | |
| } else { | |
| state.isPothole = true; | |
| state.aiResult = size; | |
| state.aiPrediction = `${size} pothole`; | |
| state.aiReviewStatus = 'auto_confirmed'; | |
| state.aiReviewMessage = ''; | |
| state.aiConf = conf; | |
| document.getElementById('btn-next-1').disabled = false; | |
| if (IS_ENGINEER_VIEW) { | |
| renderEngineerAiResult({ | |
| isPothole: true, | |
| bestClass: `${size} pothole`, | |
| confidence: conf, | |
| predictions: sorted, | |
| roadCheck: { ratio: 1, passes: true }, | |
| }); | |
| } else { | |
| renderPublicAiStatus({ | |
| title: 'AI check complete', | |
| tone: 'success', | |
| message: `${size} pothole detected at ${(conf * 100).toFixed(0)}% confidence. The report can continue to submission.`, | |
| }); | |
| } | |
| } | |
| } catch (e) { | |
| loading.style.display = 'none'; | |
| resultCard.style.display = 'block'; | |
| resultCard.innerHTML = ` | |
| <div class="ai-result"> | |
| <div class="ai-stage"> | |
| <div class="ai-stage-icon fail"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> | |
| <circle cx="12" cy="12" r="10"/> | |
| <line x1="12" y1="8" x2="12" y2="12"/> | |
| <line x1="12" y1="16" x2="12.01" y2="16"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <div class="ai-stage-label">AI Analysis</div> | |
| <div class="ai-stage-value fail">AI Unavailable</div> | |
| <div class="ai-stage-note">${e.message}. You can still submit manually.</div> | |
| </div> | |
| </div> | |
| </div>`; | |
| // Allow next even without AI | |
| document.getElementById('btn-next-1').disabled = false; | |
| } | |
| } | |
| // ─── MAP (Step 2) ───────────────────────────────────────────────────────── | |
| let leafletMap = null; | |
| let exifMarker = null; | |
| let deviceMarker = null; | |
| let pinMarker = null; | |
| let leafletRegionOverlay = null; | |
| function clearLeafletRegionOverlay() { | |
| if (leafletMap && leafletRegionOverlay) { | |
| leafletMap.removeLayer(leafletRegionOverlay); | |
| leafletRegionOverlay = null; | |
| } | |
| } | |
| function renderGISBadge(mapping) { | |
| const badge = document.getElementById('ward-badge'); | |
| const detail = document.getElementById('gis-mapped-district'); | |
| const normalized = normalizeGISMapping(mapping, state.areaLabel); | |
| const tone = getGISBadgeTone(normalized); | |
| const primaryLabel = normalized.matched | |
| ? (normalized.ward || normalized.district || normalized.label) | |
| : normalized.area; | |
| if (badge) { | |
| if (primaryLabel) { | |
| badge.textContent = primaryLabel; | |
| badge.style.display = 'block'; | |
| badge.style.background = tone.background; | |
| badge.style.color = tone.color; | |
| badge.style.border = `1.5px solid ${tone.border}`; | |
| } else { | |
| badge.style.display = 'none'; | |
| } | |
| } | |
| if (detail) { | |
| if (normalized.matched) { | |
| detail.innerHTML = `<span style="color:${tone.color}">Location confirmed ✓</span>`; | |
| detail.style.display = 'block'; | |
| } else if (normalized.area) { | |
| detail.innerHTML = `Area detected from reverse geocoding: <span style="color:${tone.color}">${normalized.area}</span>. Official ward routing is pending GIS setup.`; | |
| detail.style.display = 'block'; | |
| } else { | |
| detail.textContent = 'Official GIS ward routing is not configured yet.'; | |
| detail.style.display = 'block'; | |
| } | |
| } | |
| } | |
| function renderLeafletGISOverlay(mapping) { | |
| clearLeafletRegionOverlay(); | |
| if (!leafletMap || !mapping || !mapping.matched || !mapping.bounds) return; | |
| const tone = getGISBadgeTone(mapping); | |
| leafletRegionOverlay = L.rectangle(mapping.bounds, { | |
| color: tone.color, | |
| weight: 2, | |
| fillOpacity: 0.08, | |
| fillColor: tone.color, | |
| interactive: false | |
| }).addTo(leafletMap); | |
| leafletMap.fitBounds(mapping.bounds, { padding: [12, 12] }); | |
| } | |
| function initMap() { | |
| window._mapReady = true; | |
| const center = state.exifGPS | |
| ? [state.exifGPS.lat, state.exifGPS.lng] | |
| : [39.9526, -75.1652]; | |
| const zoom = state.exifGPS ? 16 : 13; | |
| leafletMap = L.map('map-container').setView(center, zoom); | |
| L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
| attribution: '© OpenStreetMap contributors', | |
| maxZoom: 19 | |
| }).addTo(leafletMap); | |
| setTimeout(() => { | |
| if (leafletMap) leafletMap.invalidateSize(); | |
| }, 200); | |
| if (state.exifGPS) { | |
| placePin(state.exifGPS.lat, state.exifGPS.lng, 'exif'); | |
| updateGPSPill('verified', '📷 EXIF GPS loaded from photo'); | |
| confirmLocation(state.exifGPS.lat, state.exifGPS.lng, 'exif'); | |
| } else { | |
| updateGPSPill('neutral', 'No GPS in photo — use device location or click map'); | |
| } | |
| leafletMap.on('click', e => { | |
| placePin(e.latlng.lat, e.latlng.lng, 'manual'); | |
| confirmLocation(e.latlng.lat, e.latlng.lng, 'manual'); | |
| }); | |
| document.getElementById('btn-device-loc').addEventListener('click', getDeviceLoc); | |
| } | |
| function makeDivIcon(color) { | |
| return L.divIcon({ | |
| className: '', | |
| html: `<div style="width:20px;height:20px;background:${color};border:3px solid #fff;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3)"></div>`, | |
| iconSize: [20, 20], iconAnchor: [10, 10] | |
| }); | |
| } | |
| function placePin(lat, lng, type) { | |
| const colorMap = { exif: '#0061D5', device: '#00875A', manual: '#DE350B' }; | |
| const icon = makeDivIcon(colorMap[type] || '#0061D5'); | |
| if (type === 'exif') { | |
| if (exifMarker) leafletMap.removeLayer(exifMarker); | |
| exifMarker = L.marker([lat, lng], { icon }).addTo(leafletMap) | |
| .bindPopup('📷 EXIF Location').openPopup(); | |
| } else if (type === 'device') { | |
| if (deviceMarker) leafletMap.removeLayer(deviceMarker); | |
| deviceMarker = L.marker([lat, lng], { icon }).addTo(leafletMap) | |
| .bindPopup('📱 Your Device').openPopup(); | |
| } else { | |
| if (pinMarker) leafletMap.removeLayer(pinMarker); | |
| pinMarker = L.marker([lat, lng], { icon, draggable: true }).addTo(leafletMap) | |
| .bindPopup('📌 Pothole Location'); | |
| pinMarker.on('dragend', e => { | |
| const ll = e.target.getLatLng(); | |
| confirmLocation(ll.lat, ll.lng, 'manual'); | |
| }); | |
| } | |
| } | |
| function getDeviceLoc() { | |
| if (!navigator.geolocation) { toast('Geolocation not supported', 'error'); return; } | |
| toast('Getting your location…'); | |
| navigator.geolocation.getCurrentPosition(pos => { | |
| const { latitude: lat, longitude: lng } = pos.coords; | |
| state.deviceGPS = { lat, lng }; | |
| placePin(lat, lng, 'device'); | |
| leafletMap.setView([lat, lng], 16); | |
| confirmLocation(lat, lng, 'device'); | |
| toast('Location found!', 'success'); | |
| }, () => { | |
| toast('Could not get location. Click the map instead.', 'warning'); | |
| updateGPSPill('neutral', '📌 Device location unavailable — click map to pin'); | |
| }, { enableHighAccuracy: true, timeout: 10000 }); | |
| } | |
| function crossValidate() { | |
| if (!state.exifGPS || !state.confirmedGPS) { | |
| if (state.confirmedGPS) { | |
| updateGPSPill('neutral', getLocationSourceLabel(state.confirmedGPS.source) || 'Location selected'); | |
| } | |
| return; | |
| } | |
| const d = haversine(state.exifGPS.lat, state.exifGPS.lng, state.confirmedGPS.lat, state.confirmedGPS.lng); | |
| if (d < 150) { | |
| updateGPSPill('verified', `✓ GPS Verified — EXIF & selected match (${Math.round(d)}m apart)`); | |
| } else { | |
| updateGPSPill('neutral', getLocationSourceLabel(state.confirmedGPS.source) || 'Location selected'); | |
| } | |
| } | |
| function updateGPSPill(type, text) { | |
| const pill = document.getElementById('gps-pill'); | |
| pill.className = 'gps-pill' + (type === 'verified' ? ' verified' : type === 'mismatch' ? ' mismatch' : ''); | |
| document.getElementById('gps-pill-text').textContent = text; | |
| } | |
| async function confirmLocation(lat, lng, source='manual') { | |
| state.confirmedGPS = { lat, lng, source }; | |
| document.getElementById('location-card').style.display = 'flex'; | |
| document.getElementById('location-coords').textContent = lat.toFixed(6) + ', ' + lng.toFixed(6); | |
| document.getElementById('btn-next-2').disabled = false; | |
| // Cross-validate selected location against photo EXIF GPS | |
| crossValidate(); | |
| // Reverse geocode & dynamic ward/boundingbox resolving | |
| try { | |
| let data = null; | |
| let addr = lat.toFixed(5) + ', ' + lng.toFixed(5); | |
| if (typeof reverseGeocodeDetailed === 'function') { | |
| data = await reverseGeocodeDetailed(lat, lng); | |
| addr = data.address || addr; | |
| } else { | |
| const r = await fetch( | |
| `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`, | |
| { headers: { 'Accept-Language': 'en', 'User-Agent': 'PotholePortal/1.0' } } | |
| ); | |
| data = await r.json(); | |
| addr = data.display_name || addr; | |
| } | |
| state.address = addr; | |
| document.getElementById('location-address').textContent = addr.split(',').slice(0, 3).join(','); | |
| // Dynamically extract real-time suburb/neighborhood name from Google or OSM reverse geocoding. | |
| const details = data?.address || {}; | |
| const detectedWard = data?.areaLabel || details.suburb || details.neighbourhood || details.city_district || details.quarter || details.city || details.county || 'Metro Region'; | |
| state.ward = detectedWard; | |
| const wb = document.getElementById('ward-badge'); | |
| const gisEl = document.getElementById('gis-mapped-district'); | |
| // Check if coordinates fall inside Philadelphia bounds | |
| const isPhilly = lat >= 39.86 && lat <= 40.14 && lng >= -75.28 && lng <= -74.96; | |
| if (detectedWard) { | |
| const color = isPhilly ? (REGION_COLORS[detectedWard] || '#0061D5') : '#0D9488'; | |
| wb.textContent = detectedWard; | |
| wb.style.display = 'block'; | |
| wb.style.background = `${color}15`; | |
| wb.style.color = color; | |
| wb.style.border = `1.5px solid ${color}30`; | |
| if (gisEl) { | |
| gisEl.innerHTML = `🗺️ <span style="color:${color}">GIS Mapped Region: ${detectedWard} Planning District</span>`; | |
| gisEl.style.display = 'block'; | |
| } | |
| // Draw dynamic Leaflet region overlays | |
| if (leafletMap) { | |
| if (window._leafletRegionLayers && window._leafletRegionLayers.length) { | |
| window._leafletRegionLayers.forEach(layer => leafletMap.removeLayer(layer)); | |
| } | |
| window._leafletRegionLayers = []; | |
| if (isPhilly) { | |
| // Philadelphia layout: draw 10 planning districts | |
| for (const [wName, bounds] of Object.entries(REGION_BOUNDS)) { | |
| const isActive = wName === detectedWard; | |
| const rColor = REGION_COLORS[wName] || '#6B7280'; | |
| const layer = L.rectangle(bounds, { | |
| color: isActive ? rColor : '#94A3B8', | |
| weight: isActive ? 2.5 : 1, | |
| dashArray: isActive ? '' : '3, 5', | |
| fillOpacity: isActive ? 0.14 : 0.02, | |
| fillColor: isActive ? rColor : '#94A3B8', | |
| interactive: false | |
| }).addTo(leafletMap); | |
| window._leafletRegionLayers.push(layer); | |
| } | |
| } else if (data?.boundingbox) { | |
| // Global coordinates (e.g. Bangalore): draw dynamic local suburb polygon bounds from OSM | |
| const bbox = data.boundingbox.map(Number); | |
| const localBounds = [[bbox[0], bbox[2]], [bbox[1], bbox[3]]]; | |
| const layer = L.rectangle(localBounds, { | |
| color: '#0D9488', | |
| weight: 2.5, | |
| fillOpacity: 0.14, | |
| fillColor: '#0D9488', | |
| interactive: false | |
| }).addTo(leafletMap); | |
| window._leafletRegionLayers.push(layer); | |
| leafletMap.fitBounds(localBounds, { padding: [12, 12] }); | |
| } | |
| } | |
| } else { | |
| wb.style.display = 'none'; | |
| if (gisEl) gisEl.style.display = 'none'; | |
| if (leafletMap && window._leafletRegionLayers && window._leafletRegionLayers.length) { | |
| window._leafletRegionLayers.forEach(layer => leafletMap.removeLayer(layer)); | |
| window._leafletRegionLayers = []; | |
| } | |
| } | |
| if (typeof buildSummary === 'function') buildSummary(); | |
| } catch (e) { | |
| document.getElementById('location-address').textContent = lat.toFixed(5) + ', ' + lng.toFixed(5); | |
| } | |
| } | |
| function detectWardLocal(lat, lng) { | |
| if (lat > 40.04 && lng > -75.07) return 'Northeast'; | |
| if (lat > 40.02 && lng < -75.17) return 'Northwest'; | |
| if (lat > 40.02 && lng >= -75.17 && lng <= -75.07) return 'North'; | |
| if (lat >= 39.97 && lat <= 40.02 && lng >= -75.20 && lng <= -75.14) return 'Lower North'; | |
| if (lat >= 39.94 && lat <= 40.04 && lng < -75.18) return 'West'; | |
| if (lat >= 39.95 && lat <= 40.02 && lng > -75.09) return 'River Wards'; | |
| if (lat >= 39.94 && lat <= 40.02 && lng >= -75.18 && lng <= -75.09) return 'Central'; | |
| if (lat >= 39.90 && lat <= 39.95 && lng >= -75.20 && lng <= -75.10) return 'South'; | |
| if (lat < 39.94 && lng < -75.15) return 'Southwest'; | |
| if (lat < 39.95 && lng > -75.10) return 'East'; | |
| return null; | |
| } | |
| function haversine(lat1, lon1, lat2, lon2) { | |
| const R = 6371000; | |
| const φ1 = lat1 * Math.PI / 180; | |
| const φ2 = lat2 * Math.PI / 180; | |
| const Δφ = (lat2 - lat1) * Math.PI / 180; | |
| const Δλ = (lon2 - lon1) * Math.PI / 180; | |
| const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2; | |
| return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); | |
| } | |
| // ─── STEP 3 ─────────────────────────────────────────────────────────────── | |
| function buildSummary() { | |
| const box = document.getElementById('summary-box'); | |
| const locStr = state.address | |
| ? state.address.split(',').slice(0, 2).join(',') | |
| : (state.confirmedGPS | |
| ? state.confirmedGPS.lat.toFixed(5) + ', ' + state.confirmedGPS.lng.toFixed(5) | |
| : 'Not set'); | |
| const aiStr = state.aiReviewStatus === 'invalid_image' | |
| ? (IS_ENGINEER_VIEW ? 'Not a road surface' : 'Pending review') | |
| : (state.aiReviewStatus === 'manual_review' | |
| ? (IS_ENGINEER_VIEW ? `Manual review needed${state.aiPrediction ? ` · ${state.aiPrediction}` : ''}` : 'Pending review') | |
| : (state.aiResult | |
| ? state.aiResult + ' Pothole (' + (state.aiConf ? (state.aiConf * 100).toFixed(0) + '%' : '') + ')' | |
| : (state.isPothole ? 'Pothole (no size)' : 'Pending analysis'))); | |
| const gpsStr = getConfirmedLocationSourceLabel(); | |
| const isAnon = document.getElementById('anon-toggle')?.checked ?? true; | |
| const reporterName = document.getElementById('field-name')?.value.trim() || 'Not provided'; | |
| const reporterContact = document.getElementById('field-contact')?.value.trim() || 'Not provided'; | |
| const color = REGION_COLORS[state.ward] || '#6B7280'; | |
| const wardSummaryVal = state.ward | |
| ? `<span style="background:${color}15;color:${color};border:1.5px solid ${color}30;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;">${state.ward}</span>` | |
| : 'Unknown'; | |
| const rows = [ | |
| ['Location', locStr], | |
| ['District / Ward', wardSummaryVal], | |
| ['Reporter', isAnon ? 'Anonymous submission' : `${reporterName} · ${reporterContact}`], | |
| ['Case ID', state.caseId || 'Will be assigned on submit'], | |
| ['Review Status', state.aiReviewStatus === 'invalid_image' ? 'Image rejected' : state.aiReviewStatus === 'manual_review' ? 'Manual review needed' : state.aiReviewStatus === 'auto_confirmed' ? 'Auto-confirmed' : 'Pending'], | |
| ['AI Classification', aiStr], | |
| ['GPS Source', gpsStr || 'Manual pin'], | |
| ['Photo', state.photoFile ? state.photoFile.name : '—'], | |
| ]; | |
| box.innerHTML = ` | |
| <div class="summary-title">Report Summary</div> | |
| ${rows.map(([k, v]) => ` | |
| <div class="summary-row"> | |
| <span class="summary-key">${k}</span> | |
| <span class="summary-val">${v}</span> | |
| </div>`).join('')}`; | |
| box.insertAdjacentHTML('beforeend', `<div class="summary-note">Use the case ID to track this report later. Engineers can review full AI details in the authority view.</div>`); | |
| } | |
| // ─── SUBMIT ─────────────────────────────────────────────────────────────── | |
| function generateCaseId() { | |
| const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, ''); | |
| const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); | |
| return `PHL-${datePart}-${randomPart}`; | |
| } | |
| async function handleSubmit() { | |
| const btn = document.getElementById('btn-submit'); | |
| const label = document.getElementById('submit-label'); | |
| const spinner = document.getElementById('submit-spinner'); | |
| btn.disabled = true; | |
| label.textContent = 'Submitting…'; | |
| spinner.style.display = 'inline-block'; | |
| const isAnon = document.getElementById('anon-toggle').checked; | |
| const name = isAnon ? '' : (document.getElementById('field-name').value.trim() || ''); | |
| const contact = isAnon ? '' : (document.getElementById('field-contact').value.trim() || ''); | |
| const description = document.getElementById('field-description').value.trim() || ''; | |
| if (!state.photoDataUrl && state.photoDataReady) { | |
| try { await state.photoDataReady; } catch (err) { console.warn('[Submit] preview data unavailable:', err); } | |
| } | |
| const caseId = state.caseId || generateCaseId(); | |
| state.caseId = caseId; | |
| const normalizedGIS = normalizeGISMapping(state.gisMapping, state.areaLabel); | |
| const metadata = { | |
| caseId, | |
| submittedAt: new Date().toISOString(), | |
| city: CITY, | |
| department: DEPT, | |
| description, | |
| anonymous: isAnon, | |
| reporterName: name, | |
| reporterContact: contact, | |
| photoFileName: state.photoFile ? state.photoFile.name : '', | |
| location: state.confirmedGPS, | |
| locationSource: getConfirmedLocationSourceLabel(), | |
| exifGPS: state.exifGPS ? { lat: state.exifGPS.lat, lng: state.exifGPS.lng } : null, | |
| address: state.address, | |
| ward: normalizedGIS.ward || '', | |
| district: normalizedGIS.district || '', | |
| maintenanceZone: normalizedGIS.zone || '', | |
| areaLabel: normalizedGIS.area || '', | |
| gisMapping: normalizedGIS, | |
| duplicateStatus: 'pending', | |
| weatherStatus: 'pending', | |
| potholeSize: (state.aiResult || state.aiPrediction || '').toLowerCase(), // small|medium|large (template enum) | |
| aiResult: state.aiResult, | |
| aiPrediction: state.aiPrediction, | |
| aiReviewStatus: state.aiReviewStatus, | |
| aiReviewMessage: state.aiReviewMessage || '', | |
| aiRoadScore: state.aiRoadScore, | |
| aiPredictions: state.aiPredictions, | |
| aiConfidence: state.aiConf != null ? parseFloat(state.aiConf.toFixed(4)) : null | |
| }; | |
| // record face-detection / override info for retraining and ops review | |
| metadata.faceDetected = state.aiReviewStatus === 'invalid_image_face' || !!state.faceDetected; | |
| metadata.faceOverride = !!state.overrideFace; | |
| // ── Run severity/weather/duplicate/district enrichment so the uploaded JSON | |
| // is COMPLETE (Box Extract, Doc Gen, and the metadata template need these). ── | |
| try { | |
| if (window.Enrichment && metadata.location) { | |
| let allCases = []; | |
| try { const r = await fetch('/api/cases'); const j = await r.json(); allCases = j.cases || []; } catch (_) {} | |
| const enr = await Enrichment.runEnrichment({ report: metadata }, allCases); | |
| const wx = String(enr.severityBreakdown?.weather?.riskLevel || '').toLowerCase(); | |
| const sevScore = enr.severityScore; | |
| const sevLevel = String(enr.severityLevel || '').toLowerCase(); // low|medium|high|critical (exact template enum) | |
| metadata.severityScore = sevScore; | |
| metadata.severityScore0100 = sevScore; | |
| metadata.severityLevel = sevLevel; | |
| metadata.district = enr.district?.district || metadata.district || ''; | |
| metadata.maintenanceZone = enr.district?.maintenanceZone || metadata.maintenanceZone || ''; | |
| metadata.weatherRisk = (wx === 'high' || wx === 'critical') ? 'Large' : (wx === 'moderate' || wx === 'medium') ? 'Medium' : 'Low'; // Low|Medium|Large (exact template enum) | |
| metadata.weatherStatus = wx || 'low'; | |
| metadata.duplicateStatus = enr.duplicate?.isDuplicate ? 'duplicate' : 'new'; | |
| metadata.duplicateCount = enr.duplicate?.duplicateCount || 0; | |
| metadata.linkedCaseIds = (enr.duplicate?.linkedCaseIds || []).join(', '); | |
| metadata.priorNoticeScore = enr.severityBreakdown?.priorNotice?.score ?? 0; | |
| metadata.trafficScore = enr.severityBreakdown?.traffic?.score ?? 0; | |
| metadata.damageScore = enr.severityBreakdown?.damage?.score ?? 0; | |
| metadata.weatherScore = enr.severityBreakdown?.weather?.score ?? 0; | |
| metadata.priorNoticeDetail = enr.severityBreakdown?.priorNotice?.detail || ''; | |
| metadata.trafficDetail = enr.severityBreakdown?.traffic?.detail || ''; | |
| metadata.damageDetail = enr.severityBreakdown?.damage?.detail || ''; | |
| metadata.weatherDetail = enr.severityBreakdown?.weather?.detail || ''; | |
| metadata.enrichment = enr; | |
| } | |
| } catch (e) { console.warn('Enrichment failed (uploading basic metadata):', e); } | |
| try { | |
| // BoxUpload is provided by box-upload.js | |
| const result = await submitToBox(state.photoFile, metadata); | |
| if (result && result.success === false) { | |
| throw new Error(result.error || 'Upload failed'); | |
| } | |
| metadata.caseId = result.caseId || metadata.caseId || caseId; | |
| metadata.photoFileId = result.photoFileId || metadata.photoFileId || ''; | |
| metadata.sidecarFileId = result.sidecarFileId || metadata.sidecarFileId || ''; | |
| state.caseId = metadata.caseId; | |
| const persistedReport = result.sidecarPayload | |
| ? { | |
| ...result.sidecarPayload, | |
| photoFileId: metadata.photoFileId, | |
| sidecarFileId: metadata.sidecarFileId, | |
| } | |
| : { ...metadata }; | |
| // Persist to localStorage history | |
| const history = JSON.parse(localStorage.getItem('submitted_cases') || '[]'); | |
| const caseRecord = { | |
| caseId: metadata.caseId, | |
| timestamp: metadata.submittedAt, | |
| ts: metadata.submittedAt, | |
| status: 'Submitted', | |
| report: persistedReport | |
| }; | |
| history.push(caseRecord); | |
| localStorage.setItem('submitted_cases', JSON.stringify(history)); | |
| window.PotholeBackend?.upsertCase(caseRecord).catch(err => { | |
| console.warn('[Submit] backend sync failed:', err); | |
| }); | |
| showSuccessScreen(metadata.caseId); | |
| } catch (err) { | |
| console.error('[Submit] failed:', err); | |
| const msg = err.message || String(err); | |
| const isAuth = /token|auth|unauthori[sz]ed|credential/i.test(msg); | |
| const isNetwork = /failed to fetch|network|cors|load failed/i.test(msg); | |
| if (isAuth || isNetwork) { | |
| // Fallback: save locally and mark upload as deferred so submissions still work without Box/network. | |
| try { | |
| metadata.uploadDeferred = true; | |
| const history = JSON.parse(localStorage.getItem('submitted_cases') || '[]'); | |
| const deferredRecord = { | |
| caseId: metadata.caseId || caseId, | |
| timestamp: metadata.submittedAt, | |
| ts: metadata.submittedAt, | |
| status: 'Submitted', | |
| report: metadata | |
| }; | |
| history.push(deferredRecord); | |
| localStorage.setItem('submitted_cases', JSON.stringify(history)); | |
| window.PotholeBackend?.upsertCase(deferredRecord).catch(syncErr => { | |
| console.warn('[Submit] deferred backend sync failed:', syncErr); | |
| }); | |
| toast(isAuth ? 'Saved locally. Configure Box in Settings to sync later.' : 'Saved locally. Box upload is unavailable right now.', 'info'); | |
| showSuccessScreen(metadata.caseId || caseId); | |
| return; | |
| } catch (saveErr) { | |
| console.error('[Submit] local save failed:', saveErr); | |
| toast('Submission failed: ' + (saveErr.message || String(saveErr)), 'error'); | |
| } | |
| } else { | |
| toast('Submission failed: ' + msg, 'error'); | |
| } | |
| btn.disabled = false; | |
| label.textContent = 'Submit Report'; | |
| spinner.style.display = 'none'; | |
| } | |
| } | |
| function showSuccessScreen(caseId) { | |
| // Hide wizard | |
| document.querySelector('.steps-bar').style.display = 'none'; | |
| document.querySelector('.wizard-card').style.display = 'none'; | |
| const screen = document.getElementById('success-screen'); | |
| screen.style.display = 'block'; | |
| const aiPipelineSteps = Array.from(screen.querySelectorAll('.success-pipeline .pipeline-step span')) | |
| .filter((el) => /AI analysed/i.test(el.textContent)); | |
| if (aiPipelineSteps.length) { | |
| aiPipelineSteps[0].textContent = 'AI analysed successfully'; | |
| aiPipelineSteps.slice(1).forEach((el) => el.closest('.pipeline-step')?.remove()); | |
| } | |
| document.getElementById('success-case-id').textContent = caseId; | |
| document.getElementById('success-dept').textContent = CITY + ' ' + DEPT; | |
| document.getElementById('btn-track-case').href = 'track.html?case=' + encodeURIComponent(caseId); | |
| document.getElementById('btn-copy-case').addEventListener('click', () => { | |
| navigator.clipboard.writeText(caseId).then( | |
| () => toast('Copied!', 'success'), | |
| () => toast('Could not copy', 'error') | |
| ); | |
| }); | |
| document.getElementById('btn-new-report').addEventListener('click', () => { | |
| location.reload(); | |
| }); | |
| } | |
| // ─── BUTTON WIRING ──────────────────────────────────────────────────────── | |
| function wireButtons() { | |
| document.getElementById('btn-next-1').addEventListener('click', () => { | |
| if (!state.photoFile) { toast('Please upload a photo first.', 'warning'); return; } | |
| goToStep(2); | |
| }); | |
| document.getElementById('btn-next-2').addEventListener('click', () => { | |
| if (!state.confirmedGPS) { toast('Please confirm a location first.', 'warning'); return; } | |
| goToStep(3); | |
| }); | |
| document.getElementById('btn-back-2').addEventListener('click', () => goToStep(1)); | |
| document.getElementById('btn-back-3').addEventListener('click', () => goToStep(2)); | |
| document.getElementById('btn-submit').addEventListener('click', handleSubmit); | |
| // Anonymous toggle | |
| document.getElementById('anon-toggle').addEventListener('change', function () { | |
| const contactFields = document.getElementById('contact-fields'); | |
| const nameField = document.getElementById('field-name'); | |
| const contactField = document.getElementById('field-contact'); | |
| if (contactFields) contactFields.style.opacity = this.checked ? '0.72' : '1'; | |
| if (nameField) nameField.disabled = this.checked; | |
| if (contactField) contactField.disabled = this.checked; | |
| if (typeof buildSummary === 'function') buildSummary(); | |
| }); | |
| } | |
| // ─── LOCATION DATA (inline equivalents of location.js helpers) ──────────── | |
| // These are already provided by location.js (loaded before box-upload.js) but | |
| // we define local fallbacks so this file is truly self-contained if needed. | |
| // ─── INIT ───────────────────────────────────────────────────────────────── | |
| function init() { | |
| // Inject city name only if configured in settings; leave blank otherwise | |
| ['nav-city', 'hero-city', 'terms-city'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| if (CITY) el.textContent = CITY; else el.style.display = 'none'; | |
| }); | |
| state.caseId = generateCaseId(); | |
| const caseIdPill = document.getElementById('case-id-pill'); | |
| if (caseIdPill) caseIdPill.textContent = `Case ID: ${state.caseId}`; | |
| const footer = document.getElementById('footer-brand'); | |
| if (footer) footer.textContent = CITY ? ('City of ' + CITY + ' · Pothole Reporting Portal') : 'Pothole Reporting Portal'; | |
| const sd = document.getElementById('success-dept'); | |
| if (sd) sd.textContent = CITY ? (CITY + ' ' + DEPT) : DEPT; | |
| document.getElementById('btn-next-1').disabled = true; | |
| document.getElementById('btn-next-2').disabled = true; | |
| initUpload(); | |
| wireButtons(); | |
| const isLocalDev = ['localhost', '127.0.0.1', '::1'].includes(location.hostname); | |
| const devOpsLink = document.getElementById('dev-ops-link'); | |
| if (isLocalDev && devOpsLink) { | |
| window.addEventListener('keydown', (event) => { | |
| if (event.ctrlKey && event.altKey && event.key.toLowerCase() === 'o') { | |
| devOpsLink.style.display = 'inline-flex'; | |
| } | |
| }); | |
| } | |
| // Hide the "How It Works" explanatory section for public users | |
| try { const how = document.querySelector('.how-section'); if (how && !IS_ENGINEER_VIEW) how.style.display = 'none'; } catch(e){} | |
| const anonToggle = document.getElementById('anon-toggle'); | |
| const nameField = document.getElementById('field-name'); | |
| const contactField = document.getElementById('field-contact'); | |
| const contactFields = document.getElementById('contact-fields'); | |
| if (anonToggle) { | |
| const isAnon = anonToggle.checked; | |
| if (contactFields) contactFields.style.opacity = isAnon ? '0.72' : '1'; | |
| if (nameField) nameField.disabled = isAnon; | |
| if (contactField) contactField.disabled = isAnon; | |
| } | |
| goToStep(1); | |
| // Pre-load AI model in background | |
| loadTMModel().catch(() => {}); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| function getCitizenGISSummary() { | |
| const mapping = normalizeGISMapping(state.gisMapping, state.areaLabel); | |
| const tone = getGISBadgeTone(mapping); | |
| if (!mapping.label && !mapping.area) { | |
| return { | |
| label: 'GIS Mapping', | |
| value: '<span style="color:#9CA3AF;">Pending configuration</span>' | |
| }; | |
| } | |
| const text = mapping.matched ? mapping.label : mapping.area; | |
| const label = mapping.matched ? 'Ward / District' : 'Area'; | |
| const value = `<span style="background:${tone.background};color:${tone.color};border:1.5px solid ${tone.border};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;">${text}</span>`; | |
| return { label, value }; | |
| } | |
| async function confirmLocation(lat, lng, source='manual') { | |
| state.confirmedGPS = { lat, lng, source }; | |
| document.getElementById('location-card').style.display = 'flex'; | |
| document.getElementById('location-coords').textContent = lat.toFixed(6) + ', ' + lng.toFixed(6); | |
| document.getElementById('btn-next-2').disabled = false; | |
| crossValidate(); | |
| let resolvedAddress = lat.toFixed(5) + ', ' + lng.toFixed(5); | |
| let fallbackArea = ''; | |
| try { | |
| if (typeof reverseGeocodeDetailed === 'function') { | |
| const data = await reverseGeocodeDetailed(lat, lng); | |
| resolvedAddress = data.address || resolvedAddress; | |
| fallbackArea = data.areaLabel || fallbackArea; | |
| } else { | |
| const response = await fetch( | |
| `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`, | |
| { headers: { 'Accept-Language': 'en', 'User-Agent': 'PotholePortal/1.0' } } | |
| ); | |
| const data = await response.json(); | |
| resolvedAddress = data.display_name || resolvedAddress; | |
| fallbackArea = getAreaLabelFromAddress(data.address || {}); | |
| } | |
| } catch (error) { | |
| console.warn('[Location] reverse geocode failed:', error); | |
| } | |
| state.address = resolvedAddress; | |
| state.areaLabel = fallbackArea || ''; | |
| document.getElementById('location-address').textContent = resolvedAddress.split(',').slice(0, 3).join(','); | |
| state.gisMapping = await resolveGISMapping(lat, lng, { fallbackArea }); | |
| state.ward = state.gisMapping.ward || null; | |
| state.district = state.gisMapping.district || null; | |
| state.maintenanceZone = state.gisMapping.zone || null; | |
| renderGISBadge(state.gisMapping); | |
| renderLeafletGISOverlay(state.gisMapping); | |
| buildSummary(); | |
| } | |
| function buildSummary() { | |
| const box = document.getElementById('summary-box'); | |
| const locStr = state.address | |
| ? state.address.split(',').slice(0, 2).join(',') | |
| : (state.confirmedGPS | |
| ? state.confirmedGPS.lat.toFixed(5) + ', ' + state.confirmedGPS.lng.toFixed(5) | |
| : 'Not set'); | |
| const aiStr = state.aiReviewStatus === 'invalid_image' | |
| ? (IS_ENGINEER_VIEW ? 'Not a road surface' : 'Pending review') | |
| : (state.aiReviewStatus === 'manual_review' | |
| ? (IS_ENGINEER_VIEW ? `Manual review needed${state.aiPrediction ? ` · ${state.aiPrediction}` : ''}` : 'Pending review') | |
| : (state.aiResult | |
| ? state.aiResult + ' Pothole (' + (state.aiConf ? (state.aiConf * 100).toFixed(0) + '%' : '') + ')' | |
| : (state.isPothole ? 'Pothole (no size)' : 'Pending analysis'))); | |
| const gpsStr = getConfirmedLocationSourceLabel(); | |
| const isAnon = document.getElementById('anon-toggle')?.checked ?? true; | |
| const reporterName = document.getElementById('field-name')?.value.trim() || 'Not provided'; | |
| const reporterContact = document.getElementById('field-contact')?.value.trim() || 'Not provided'; | |
| const gisSummary = getCitizenGISSummary(); | |
| const rows = [ | |
| ['Location', locStr], | |
| [gisSummary.label, gisSummary.value], | |
| ['Reporter', isAnon ? 'Anonymous submission' : `${reporterName} · ${reporterContact}`], | |
| ['Case ID', state.caseId || 'Will be assigned on submit'], | |
| ['Review Status', state.aiReviewStatus === 'invalid_image' ? 'Image rejected' : state.aiReviewStatus === 'manual_review' ? 'Manual review needed' : state.aiReviewStatus === 'auto_confirmed' ? 'Auto-confirmed' : 'Pending'], | |
| ['AI Classification', aiStr], | |
| ['GPS Source', gpsStr || 'Manual pin'], | |
| ['Photo', state.photoFile ? state.photoFile.name : '-'], | |
| ]; | |
| box.innerHTML = ` | |
| <div class="summary-title">Report Summary</div> | |
| ${rows.map(([k, v]) => ` | |
| <div class="summary-row"> | |
| <span class="summary-key">${k}</span> | |
| <span class="summary-val">${v}</span> | |
| </div>`).join('')}`; | |
| box.insertAdjacentHTML('beforeend', `<div class="summary-note">Use the case ID to track this report later. Official ward routing appears once a GIS boundary layer is configured.</div>`); | |
| } | |
| function generateCaseId() { | |
| const datePart = new Date().toISOString().slice(0, 10).replace(/-/g, ''); | |
| const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase(); | |
| return `${CASE_PREFIX}-${datePart}-${randomPart}`; | |
| } | |
| const _originalHandleSubmit = handleSubmit; | |
| handleSubmit = async function handleSubmitWithGIS() { | |
| const normalizedGIS = normalizeGISMapping(state.gisMapping, state.areaLabel); | |
| state.gisMapping = normalizedGIS; | |
| state.ward = normalizedGIS.ward || null; | |
| state.district = normalizedGIS.district || null; | |
| state.maintenanceZone = normalizedGIS.zone || null; | |
| return _originalHandleSubmit(); | |
| }; | |
| </script> | |
| </body> | |
| </html> | |