PotholeIQ / index.html
Varun10000's picture
PotholeIQ: initial deployable build (Box + Supabase, HF Spaces Docker)
45e997f
Raw
History Blame Contribute Delete
109 kB
<!DOCTYPE html>
<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>
'use strict';
// ─── 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.cos1) * Math.cos2) * 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>