| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Pest Geospatial Analytics | AgriTech Suite</title> |
| <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=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> |
|
|
| <style> |
| :root { |
| --primary-green: #1a5d3a; |
| --accent-green: #198754; |
| --light-bg: #f8f9fa; |
| --text-dark: #212529; |
| --card-shadow: 0 10px 40px rgba(0, 0, 0, 0.05); |
| } |
| |
| body { |
| font-family: 'Outfit', sans-serif; |
| background-color: var(--light-bg); |
| color: var(--text-dark); |
| min-height: 100vh; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .analytics-header { |
| background-color: var(--primary-green); |
| color: white; |
| padding: 3rem 1rem 6rem; |
| text-align: center; |
| border-bottom-left-radius: 50% 20px; |
| border-bottom-right-radius: 50% 20px; |
| margin-bottom: 2rem; |
| } |
| |
| .header-title { |
| font-weight: 700; |
| font-size: 2rem; |
| text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .dashboard-container { |
| margin-top: -5rem; |
| padding: 0 2rem 3rem; |
| flex: 1; |
| display: flex; |
| justify-content: center; |
| } |
| |
| .dashboard-card { |
| background: white; |
| border-radius: 24px; |
| box-shadow: var(--card-shadow); |
| width: 100%; |
| max-width: 1200px; |
| overflow: hidden; |
| display: grid; |
| grid-template-columns: 320px 1fr; |
| min-height: 600px; |
| border: 1px solid #eef0f3; |
| } |
| |
| .sidebar-controls { |
| background: #fdfdfd; |
| border-right: 1px solid #f1f3f5; |
| padding: 2rem; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .sidebar-title { |
| font-size: 1rem; |
| font-weight: 700; |
| color: var(--primary-green); |
| margin-bottom: 2rem; |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| padding-bottom: 1rem; |
| border-bottom: 2px solid #f0f0f0; |
| } |
| |
| .form-group { |
| margin-bottom: 1.5rem; |
| } |
| |
| .form-label-custom { |
| font-size: 0.8rem; |
| font-weight: 600; |
| color: #6c757d; |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| margin-bottom: 0.5rem; |
| display: block; |
| } |
| |
| .form-select-custom { |
| width: 100%; |
| border: 1px solid #e2e8f0; |
| border-radius: 10px; |
| padding: 0.8rem 1rem; |
| font-size: 0.95rem; |
| font-weight: 500; |
| background-color: white; |
| transition: all 0.2s; |
| } |
| |
| .form-select-custom:focus { |
| border-color: var(--accent-green); |
| box-shadow: 0 0 0 4px rgba(25, 135, 84, 0.1); |
| outline: none; |
| } |
| |
| .form-select-custom option:disabled { |
| color: #adb5bd; |
| background-color: #f8f9fa; |
| } |
| |
| .btn-visualize { |
| background-color: var(--accent-green); |
| color: white; |
| border: none; |
| border-radius: 10px; |
| padding: 1rem; |
| font-weight: 600; |
| width: 100%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 0.5rem; |
| margin-top: auto; |
| transition: all 0.2s; |
| box-shadow: 0 4px 15px rgba(25, 135, 84, 0.2); |
| } |
| |
| .btn-visualize:hover:not(:disabled) { |
| background-color: #146c43; |
| transform: translateY(-2px); |
| box-shadow: 0 6px 20px rgba(25, 135, 84, 0.3); |
| } |
| |
| .btn-visualize:disabled { |
| opacity: 0.55; |
| cursor: not-allowed; |
| } |
| |
| .map-viewport { |
| background-color: #f8fafc; |
| padding: 2rem; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| position: relative; |
| } |
| |
| .map-frame { |
| background: white; |
| padding: 1rem; |
| border-radius: 16px; |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); |
| max-width: 100%; |
| } |
| |
| .map-image { |
| max-width: 100%; |
| max-height: 550px; |
| border-radius: 8px; |
| display: block; |
| } |
| |
| .result-meta { |
| position: absolute; |
| top: 1.5rem; |
| left: 1.5rem; |
| background: white; |
| padding: 0.5rem 1rem; |
| border-radius: 50px; |
| box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05); |
| display: flex; |
| gap: 1rem; |
| z-index: 5; |
| font-size: 0.85rem; |
| font-weight: 500; |
| color: var(--text-dark); |
| } |
| |
| .meta-item { |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| |
| .meta-item i { |
| color: var(--accent-green); |
| } |
| |
| .empty-state { |
| text-align: center; |
| color: #adb5bd; |
| } |
| |
| .empty-icon { |
| font-size: 4rem; |
| margin-bottom: 1rem; |
| color: #e9ecef; |
| } |
| |
| |
| .week-loading-badge { |
| display: inline-flex; |
| align-items: center; |
| gap: 0.4rem; |
| font-size: 0.75rem; |
| color: #6c757d; |
| margin-top: 0.3rem; |
| } |
| |
| .availability-hint { |
| font-size: 0.72rem; |
| color: #adb5bd; |
| margin-top: 0.25rem; |
| } |
| |
| @media (max-width: 992px) { |
| .dashboard-card { |
| grid-template-columns: 1fr; |
| } |
| |
| .sidebar-controls { |
| border-right: none; |
| border-bottom: 1px solid #f1f3f5; |
| } |
| |
| .map-image { |
| max-height: 400px; |
| } |
| } |
| |
| #loadingOverlay { |
| position: absolute; |
| inset: 0; |
| background: rgba(255, 255, 255, 0.9); |
| display: none; |
| justify-content: center; |
| align-items: center; |
| z-index: 50; |
| border-radius: 20px; |
| } |
| |
| |
| .select-spinner { |
| position: relative; |
| } |
| |
| .select-spinner::after { |
| content: ''; |
| position: absolute; |
| right: 2.5rem; |
| top: 50%; |
| transform: translateY(-50%); |
| width: 14px; |
| height: 14px; |
| border: 2px solid #dee2e6; |
| border-top-color: var(--accent-green); |
| border-radius: 50%; |
| animation: spin 0.6s linear infinite; |
| } |
| |
| @keyframes spin { |
| from { transform: translateY(-50%) rotate(0deg); } |
| to { transform: translateY(-50%) rotate(360deg); } |
| } |
| </style> |
| </head> |
|
|
| <body> |
|
|
| <div class="analytics-header"> |
| <h1 class="header-title">Geospatial Intelligence Dashboard</h1> |
| <p class="opacity-75">Analysis of pest distribution patterns over space and time</p> |
| </div> |
|
|
| <div class="dashboard-container"> |
| <div class="dashboard-card"> |
|
|
| <aside class="sidebar-controls"> |
| <div class="sidebar-title"> |
| <i class="bi bi-sliders2"></i> FILTER PARAMETERS |
| </div> |
|
|
| <form method="GET" action="/" id="analyticsForm"> |
|
|
| <div class="form-group"> |
| <label class="form-label-custom">Select Crop</label> |
| <select id="crop" name="crop" class="form-select-custom" onchange="onCropChange('')"> |
| <option value="">Choose Crop...</option> |
| {% for c in crops %} |
| <option value="{{ c }}" {% if selected_crop==c %}selected{% endif %}>{{ c }}</option> |
| {% endfor %} |
| </select> |
| </div> |
|
|
| <div class="form-group"> |
| <label class="form-label-custom">Select Pest</label> |
| <select id="pest" name="pest" class="form-select-custom" onchange="onPestOrYearChange()"> |
| <option value="">Choose Pest...</option> |
| </select> |
| </div> |
|
|
| <div class="row"> |
| <div class="col-6 form-group"> |
| <label class="form-label-custom">Year</label> |
| <select id="year" name="year" class="form-select-custom" onchange="onPestOrYearChange()"> |
| <option value="">Year...</option> |
| {% for y in years %} |
| <option value="{{ y }}" {% if selected_year==y %}selected{% endif %}>{{ y }}</option> |
| {% endfor %} |
| </select> |
| </div> |
| <div class="col-6 form-group"> |
| <label class="form-label-custom">Week</label> |
| <select id="week" name="week" class="form-select-custom" onchange="onWeekChange()"> |
| <option value="">Week...</option> |
| {% if selected_week %} |
| <option value="{{ selected_week }}" selected>Week {{ selected_week }}</option> |
| {% endif %} |
| </select> |
| <span id="weekHint" class="availability-hint"></span> |
| </div> |
| </div> |
|
|
| <div class="form-group mb-5"> |
| <label class="form-label-custom">Analysis Metric</label> |
| <select id="param" name="param" class="form-select-custom"> |
| <option value="">Select Metric...</option> |
| {% for code, label in params.items() %} |
| <option value="{{ code }}" {% if selected_param==code %}selected{% endif %}>{{ label }}</option> |
| {% endfor %} |
| </select> |
| <span id="paramHint" class="availability-hint"></span> |
| </div> |
|
|
| <button type="submit" class="btn-visualize" id="submitBtn"> |
| Generate Map <i class="bi bi-arrow-right-circle"></i> |
| </button> |
|
|
| </form> |
| </aside> |
|
|
| <main class="map-viewport"> |
|
|
| <div id="loadingOverlay"> |
| <div class="text-center"> |
| <div class="spinner-border text-success mb-3" role="status"></div> |
| <h5 class="text-muted">Rendering Geospatial Data...</h5> |
| </div> |
| </div> |
|
|
| {% if image_url %} |
| <div class="result-meta"> |
| <div class="meta-item"><i class="bi bi-calendar-event"></i> {{ selected_year }} (W{{ selected_week }})</div> |
| <div class="d-none d-md-flex meta-item text-muted">|</div> |
| <div class="meta-item"><i class="bi bi-bug"></i> {{ selected_pest }}</div> |
| </div> |
|
|
| <div class="map-frame" id="mapContainer"> |
| <img src="{{ image_url }}" alt="Heatmap Result" class="map-image" onerror="handleImageError(this)"> |
| <div id="dataNotAvailable" class="p-5 text-center d-none"> |
| <i class="bi bi-database-x fs-1 text-danger mb-3"></i> |
| <h5 class="text-secondary">Data Unavailable</h5> |
| <p class="text-muted small">No records found for this parameter combination.</p> |
| </div> |
| </div> |
|
|
| {% elif not data_available %} |
| <div class="empty-state"> |
| <div class="empty-icon"><i class="bi bi-database-x text-danger"></i></div> |
| <h4 class="text-secondary">Data Unavailable</h4> |
| <p class="text-muted">No image data found for the selected combination.<br>Try a different week, year, or metric.</p> |
| </div> |
|
|
| {% else %} |
| <div class="empty-state"> |
| <div class="empty-icon"><i class="bi bi-map"></i></div> |
| <h4>Map Visualization</h4> |
| <p>Configure filters on the left to generate insights.</p> |
| </div> |
| {% endif %} |
|
|
| </main> |
|
|
| </div> |
| </div> |
|
|
| |
| <div id="appData" |
| data-crop-to-pests='{{ crop_to_pests | tojson }}' |
| data-init-crop="{{ selected_crop | e }}" |
| data-init-pest="{{ selected_pest | e }}" |
| data-init-year="{{ selected_year | e }}" |
| data-init-week="{{ selected_week | e }}" |
| data-init-param="{{ selected_param | e }}" |
| style="display:none" |
| ></div> |
|
|
| <script> |
| const _appData = document.getElementById('appData').dataset; |
| const cropToPests = JSON.parse(_appData.cropToPests); |
| |
| |
| const INIT_CROP = _appData.initCrop; |
| const INIT_PEST = _appData.initPest; |
| const INIT_YEAR = _appData.initYear; |
| const INIT_WEEK = _appData.initWeek; |
| const INIT_PARAM = _appData.initParam; |
| |
| |
| let availabilityController = null; |
| |
| |
| function onCropChange(restorePest) { |
| const crop = document.getElementById("crop").value; |
| const pestSelect = document.getElementById("pest"); |
| |
| pestSelect.innerHTML = '<option value="">Choose Pest...</option>'; |
| |
| if (crop && cropToPests[crop]) { |
| cropToPests[crop].forEach(p => { |
| const opt = document.createElement("option"); |
| opt.value = p; |
| opt.textContent = p; |
| pestSelect.appendChild(opt); |
| }); |
| } |
| |
| |
| if (restorePest && restorePest !== "") { |
| pestSelect.value = restorePest; |
| } |
| |
| clearWeeks(); |
| clearParamAvailability(); |
| |
| |
| const year = document.getElementById("year").value; |
| if (crop && pestSelect.value && year) { |
| fetchWeeks(); |
| } |
| } |
| |
| |
| function onPestOrYearChange() { |
| clearWeeks(); |
| clearParamAvailability(); |
| const crop = document.getElementById("crop").value; |
| const pest = document.getElementById("pest").value; |
| const year = document.getElementById("year").value; |
| if (crop && pest && year) fetchWeeks(); |
| } |
| |
| |
| function onWeekChange() { |
| clearParamAvailability(); |
| const crop = document.getElementById("crop").value; |
| const pest = document.getElementById("pest").value; |
| const year = document.getElementById("year").value; |
| const week = document.getElementById("week").value; |
| if (crop && pest && year && week) checkParamAvailability(crop, pest, year, week); |
| } |
| |
| |
| function fetchWeeks(restoreWeek) { |
| const crop = document.getElementById("crop").value; |
| const pest = document.getElementById("pest").value; |
| const year = document.getElementById("year").value; |
| |
| const weekSelect = document.getElementById("week"); |
| const hint = document.getElementById("weekHint"); |
| |
| weekSelect.innerHTML = '<option value="">Checking availability...</option>'; |
| weekSelect.disabled = true; |
| hint.textContent = "Scanning weeks with data..."; |
| hint.style.color = "#6c757d"; |
| |
| fetch(`/fetch_weeks?crop=${encodeURIComponent(crop)}&pest=${encodeURIComponent(pest)}&year=${encodeURIComponent(year)}`) |
| .then(res => { |
| if (!res.ok) throw new Error("Network response was not ok"); |
| return res.json(); |
| }) |
| .then(data => { |
| weekSelect.innerHTML = '<option value="">Week...</option>'; |
| weekSelect.disabled = false; |
| |
| const weeks = Array.isArray(data.weeks) ? data.weeks : []; |
| |
| if (weeks.length > 0) { |
| weeks.forEach(w => { |
| const opt = document.createElement("option"); |
| opt.value = String(w); |
| opt.textContent = `Week ${w}`; |
| weekSelect.appendChild(opt); |
| }); |
| hint.textContent = `${weeks.length} week(s) with data available`; |
| hint.style.color = "#198754"; |
| |
| |
| const target = restoreWeek || INIT_WEEK; |
| if (target !== "" && weeks.map(String).includes(String(target))) { |
| weekSelect.value = String(target); |
| } |
| } else { |
| hint.textContent = "No data found for this combination"; |
| hint.style.color = "#dc3545"; |
| } |
| |
| |
| if (weekSelect.value) { |
| checkParamAvailability(crop, pest, year, weekSelect.value); |
| } |
| }) |
| .catch(() => { |
| weekSelect.innerHTML = '<option value="">Week...</option>'; |
| weekSelect.disabled = false; |
| hint.textContent = "Could not load weeks β check connection"; |
| hint.style.color = "#dc3545"; |
| }); |
| } |
| |
| |
| function checkParamAvailability(crop, pest, year, week) { |
| const paramSelect = document.getElementById("param"); |
| const hint = document.getElementById("paramHint"); |
| const submitBtn = document.getElementById("submitBtn"); |
| |
| |
| if (availabilityController) availabilityController.abort(); |
| availabilityController = new AbortController(); |
| |
| hint.textContent = "Checking metric availability..."; |
| hint.style.color = "#6c757d"; |
| submitBtn.disabled = true; |
| |
| fetch( |
| `/check_availability?crop=${encodeURIComponent(crop)}&pest=${encodeURIComponent(pest)}&year=${encodeURIComponent(year)}&week=${encodeURIComponent(week)}`, |
| { signal: availabilityController.signal } |
| ) |
| .then(res => { |
| if (!res.ok) throw new Error("Network response was not ok"); |
| return res.json(); |
| }) |
| .then(data => { |
| availabilityController = null; |
| const availability = data.availability || {}; |
| let availableCount = 0; |
| |
| Array.from(paramSelect.options).forEach(opt => { |
| if (!opt.value) return; |
| |
| const isAvailable = availability[opt.value] === true; |
| opt.disabled = !isAvailable; |
| |
| |
| opt.textContent = opt.textContent.replace(/\s*\(unavailable\)$/, ""); |
| |
| if (isAvailable) { |
| availableCount++; |
| } else { |
| opt.textContent += " (unavailable)"; |
| |
| if (opt.selected) { |
| opt.selected = false; |
| } |
| } |
| }); |
| |
| |
| if (paramSelect.selectedIndex > 0 && paramSelect.options[paramSelect.selectedIndex].disabled) { |
| paramSelect.value = ""; |
| } |
| |
| if (availableCount === 0) { |
| hint.textContent = "No metrics available for this week"; |
| hint.style.color = "#dc3545"; |
| submitBtn.disabled = true; |
| } else { |
| hint.textContent = `${availableCount} metric(s) available`; |
| hint.style.color = "#198754"; |
| submitBtn.disabled = false; |
| } |
| }) |
| .catch(err => { |
| if (err.name === "AbortError") return; |
| availabilityController = null; |
| hint.textContent = ""; |
| submitBtn.disabled = false; |
| }); |
| } |
| |
| |
| function clearWeeks() { |
| const weekSelect = document.getElementById("week"); |
| weekSelect.innerHTML = '<option value="">Week...</option>'; |
| weekSelect.disabled = false; |
| const hint = document.getElementById("weekHint"); |
| hint.textContent = ""; |
| } |
| |
| function clearParamAvailability() { |
| |
| if (availabilityController) { |
| availabilityController.abort(); |
| availabilityController = null; |
| } |
| const paramSelect = document.getElementById("param"); |
| Array.from(paramSelect.options).forEach(opt => { |
| opt.disabled = false; |
| opt.textContent = opt.textContent.replace(/\s*\(unavailable\)$/, ""); |
| }); |
| document.getElementById("paramHint").textContent = ""; |
| document.getElementById("submitBtn").disabled = false; |
| } |
| |
| function handleImageError(img) { |
| img.style.display = 'none'; |
| const unavailableEl = document.getElementById('dataNotAvailable'); |
| if (unavailableEl) unavailableEl.classList.remove('d-none'); |
| } |
| |
| |
| document.getElementById('analyticsForm').addEventListener('submit', (e) => { |
| const fields = ['crop', 'pest', 'year', 'week', 'param']; |
| const missing = fields.filter(id => !document.getElementById(id).value); |
| if (missing.length > 0) { |
| e.preventDefault(); |
| alert(`Please select: ${missing.join(', ')}`); |
| return; |
| } |
| document.getElementById('loadingOverlay').style.display = 'flex'; |
| }); |
| |
| |
| window.addEventListener('DOMContentLoaded', () => { |
| |
| |
| onCropChange(INIT_PEST); |
| |
| |
| |
| if (INIT_CROP && INIT_PEST && INIT_YEAR) { |
| fetchWeeks(INIT_WEEK); |
| } |
| |
| |
| |
| if (INIT_PARAM) { |
| document.getElementById("param").value = INIT_PARAM; |
| } |
| }); |
| </script> |
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> |
|
|
| </body> |
|
|
| </html> |
|
|