// Chart.js instance tracking let forecastChartInstance = null; let rainOverrideMode = 'auto'; // 'auto' | 'manual' let popOverrideMode = 'auto'; // 'auto' | 'manual' function setRainfallMode(mode) { rainOverrideMode = mode; const btnAuto = document.getElementById('rain-mode-auto'); const btnManual = document.getElementById('rain-mode-manual'); const slider = document.getElementById('rain-override'); const display = document.getElementById('rain-override-val'); if (mode === 'auto') { if (btnAuto) btnAuto.classList.add('active'); if (btnManual) btnManual.classList.remove('active'); if (slider) { slider.disabled = true; slider.value = 0; } rainValue = 0; if (display) display.textContent = 'Auto (Open-Meteo)'; } else { if (btnAuto) btnAuto.classList.remove('active'); if (btnManual) btnManual.classList.add('active'); if (slider) { slider.disabled = false; slider.value = slider.value > 0 ? slider.value : 15; } rainValue = slider ? slider.value : 15; if (display) display.textContent = `${rainValue} mm (Manual)`; } runPrediction(); } function setPopulationMode(mode) { popOverrideMode = mode; const btnAuto = document.getElementById('event-mode-auto'); const btnManual = document.getElementById('event-mode-manual'); const slider = document.getElementById('event-override'); const display = document.getElementById('event-override-val'); const defaultPop = KECAMATAN_DATABASE[selectedLocation]?.population_jiwa || 100000; if (mode === 'auto') { if (btnAuto) btnAuto.classList.add('active'); if (btnManual) btnManual.classList.remove('active'); if (slider) { slider.disabled = true; slider.value = defaultPop; } if (display) display.textContent = `Auto (${defaultPop.toLocaleString()} Jiwa)`; } else { if (btnAuto) btnAuto.classList.remove('active'); if (btnManual) btnManual.classList.add('active'); if (slider) { slider.disabled = false; } const currentVal = slider ? parseInt(slider.value) : defaultPop; if (display) display.textContent = `${currentVal.toLocaleString()} Jiwa (Manual)`; } runPrediction(); } function toggleAdvancedScenario() { const box = document.getElementById('advanced-scenario-box'); const btn = document.getElementById('advanced-scenario-toggle'); if (box && btn) { const isClosed = box.style.display === 'none'; box.style.display = isClosed ? 'block' : 'none'; btn.classList.toggle('open', isClosed); btn.setAttribute('aria-expanded', isClosed ? 'true' : 'false'); } } function updateContextBadge() { const locText = document.getElementById('context-location-text'); const horizText = document.getElementById('context-horizon-text'); const modText = document.getElementById('context-model-text'); const city = KECAMATAN_DATABASE[selectedLocation]?.city || 'DKI Jakarta'; if (locText) locText.textContent = `${selectedLocation} · ${city}`; if (horizText) { const days = forecastSlider ? forecastSlider.value : 7; horizText.textContent = `${days}-Day Horizon`; } if (modText) { const m = modelSelect ? modelSelect.value : 'gradient_boosting'; modText.textContent = m === 'chronos' ? 'Chronos-T5 Neural' : 'Stacking Regressor'; } } // Render Interactive Forecast Curve using Chart.js function renderForecastChart(results) { const canvas = document.getElementById('forecast-chart'); if (!canvas || typeof Chart === 'undefined') return; const labels = results.map(r => { const d = new Date(r.date); return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); }); const volumes = results.map(r => r.total_volume_ton); // Summary Statistics Calculation const total = volumes.reduce((a, b) => a + b, 0); const avg = total / volumes.length; let maxIdx = 0, minIdx = 0; volumes.forEach((v, i) => { if (v > volumes[maxIdx]) maxIdx = i; if (v < volumes[minIdx]) minIdx = i; }); const elTotal = document.getElementById('chart-total-vol'); const elAvg = document.getElementById('chart-avg-vol'); const elPeak = document.getElementById('chart-peak-day'); const elMin = document.getElementById('chart-min-day'); if (elTotal) elTotal.textContent = `${total.toFixed(2)} Tons`; if (elAvg) elAvg.textContent = `${avg.toFixed(2)} Tons/Day`; if (elPeak) elPeak.textContent = `${labels[maxIdx]} (${volumes[maxIdx].toFixed(1)} T)`; if (elMin) elMin.textContent = `${labels[minIdx]} (${volumes[minIdx].toFixed(1)} T)`; if (forecastChartInstance) { forecastChartInstance.destroy(); } const ctx = canvas.getContext('2d'); const gradient = ctx.createLinearGradient(0, 0, 0, 200); gradient.addColorStop(0, 'rgba(112, 173, 71, 0.35)'); gradient.addColorStop(1, 'rgba(112, 173, 71, 0.0)'); forecastChartInstance = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ label: 'Projected Waste Generation (Tons)', data: volumes, borderColor: '#70AD47', backgroundColor: gradient, borderWidth: 2.5, fill: true, tension: 0.35, pointBackgroundColor: volumes.map((v, i) => i === maxIdx ? '#F59E0B' : (i === minIdx ? '#22C55E' : '#70AD47')), pointBorderColor: '#070C08', pointBorderWidth: 2, pointRadius: volumes.map((v, i) => (i === maxIdx || i === minIdx) ? 6 : 4), pointHoverRadius: 7 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(13, 20, 15, 0.95)', titleColor: '#F8FAF9', bodyColor: '#00F0FF', borderColor: 'rgba(112, 173, 71, 0.4)', borderWidth: 1, padding: 10, callbacks: { label: function(ctx) { const val = ctx.parsed.y; const r = results[ctx.dataIndex]; return [`Volume: ${val.toFixed(2)} Tons`, `Risk Level: ${r.risk_status}`, `Suggested Trucks: ${r.recommended_trucks}`]; } } } }, scales: { x: { grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: '#88A385', font: { family: "'JetBrains Mono', monospace", size: 11 } } }, y: { grid: { color: 'rgba(255, 255, 255, 0.05)' }, ticks: { color: '#88A385', font: { family: "'JetBrains Mono', monospace", size: 11 }, callback: (v) => `${v} T` } } } } }); } // Coordinates and Map Data for all 44 Kecamatan of DKI Jakarta const KECAMATAN_DATABASE = { // 1. JAKARTA PUSAT (8 Kecamatan) "Menteng": {coords: [-6.1950, 106.8322], city: "Jakarta Pusat", radius: "1.2 km"}, "Senen": {coords: [-6.1822, 106.8452], city: "Jakarta Pusat", radius: "1.0 km"}, "Cempaka Putih": {coords: [-6.1802, 106.8686], city: "Jakarta Pusat", radius: "1.1 km"}, "Johar Baru": {coords: [-6.1866, 106.8572], city: "Jakarta Pusat", radius: "0.8 km"}, "Kemayoran": {coords: [-6.1628, 106.8438], city: "Jakarta Pusat", radius: "1.5 km"}, "Sawah Besar": {coords: [-6.1554, 106.8322], city: "Jakarta Pusat", radius: "1.2 km"}, "Tanah Abang": {coords: [-6.2104, 106.8122], city: "Jakarta Pusat", radius: "2.0 km"}, "Gambir": {coords: [-6.1764, 106.8190], city: "Jakarta Pusat", radius: "1.8 km"}, // 2. JAKARTA UTARA (6 Kecamatan) "Penjaringan": {coords: [-6.1264, 106.7822], city: "Jakarta Utara", radius: "2.5 km"}, "Tanjung Priok": {coords: [-6.1322, 106.8722], city: "Jakarta Utara", radius: "2.2 km"}, "Koja": {coords: [-6.1214, 106.9133], city: "Jakarta Utara", radius: "1.8 km"}, "Cilincing": {coords: [-6.1288, 106.9452], city: "Jakarta Utara", radius: "3.0 km"}, "Pademangan": {coords: [-6.1328, 106.8422], city: "Jakarta Utara", radius: "1.5 km"}, "Kelapa Gading": {coords: [-6.1552, 106.9022], city: "Jakarta Utara", radius: "2.0 km"}, // 3. JAKARTA BARAT (8 Kecamatan) "Cengkareng": {coords: [-6.1528, 106.7322], city: "Jakarta Barat", radius: "3.0 km"}, "Grogol Petamburan": {coords: [-6.1622, 106.7882], city: "Jakarta Barat", radius: "2.0 km"}, "Kalideres": {coords: [-6.1428, 106.7022], city: "Jakarta Barat", radius: "3.2 km"}, "Kebon Jeruk": {coords: [-6.1922, 106.7722], city: "Jakarta Barat", radius: "2.2 km"}, "Kembangan": {coords: [-6.1828, 106.7382], city: "Jakarta Barat", radius: "2.5 km"}, "Palmerah": {coords: [-6.2028, 106.7882], city: "Jakarta Barat", radius: "1.8 km"}, "Taman Sari": {coords: [-6.1454, 106.8182], city: "Jakarta Barat", radius: "1.2 km"}, "Tambora": {coords: [-6.1500, 106.8000], city: "Jakarta Barat", radius: "1.0 km"}, // 4. JAKARTA SELATAN (10 Kecamatan) "Cilandak": {coords: [-6.2928, 106.7922], city: "Jakarta Selatan", radius: "2.2 km"}, "Jagakarsa": {coords: [-6.3328, 106.8222], city: "Jakarta Selatan", radius: "2.5 km"}, "Kebayoran Baru": {coords: [-6.2422, 106.7982], city: "Jakarta Selatan", radius: "2.0 km"}, "Kebayoran Lama": {coords: [-6.2488, 106.7722], city: "Jakarta Selatan", radius: "2.4 km"}, "Mampang Prapatan": {coords: [-6.2522, 106.8182], city: "Jakarta Selatan", radius: "1.5 km"}, "Pancoran": {coords: [-6.2622, 106.8382], city: "Jakarta Selatan", radius: "1.6 km"}, "Pasar Minggu": {coords: [-6.2828, 106.8438], city: "Jakarta Selatan", radius: "2.5 km"}, "Pesanggrahan": {coords: [-6.2588, 106.7588], city: "Jakarta Selatan", radius: "2.0 km"}, "Setiabudi": {coords: [-6.2228, 106.8282], city: "Jakarta Selatan", radius: "1.8 km"}, "Tebet": {coords: [-6.2288, 106.8482], city: "Jakarta Selatan", radius: "2.0 km"}, // 5. JAKARTA TIMUR (10 Kecamatan) "Cakung": {coords: [-6.1828, 106.9482], city: "Jakarta Timur", radius: "3.5 km"}, "Cipayung": {coords: [-6.3128, 106.9022], city: "Jakarta Timur", radius: "2.8 km"}, "Ciracas": {coords: [-6.3228, 106.8782], city: "Jakarta Timur", radius: "2.2 km"}, "Duren Sawit": {coords: [-6.2228, 106.9282], city: "Jakarta Timur", radius: "3.0 km"}, "Jatinegara": {coords: [-6.2222, 106.8682], city: "Jakarta Timur", radius: "2.5 km"}, "Kramat Jati": {coords: [-6.2722, 106.8682], city: "Jakarta Timur", radius: "2.4 km"}, "Makasar": {coords: [-6.2622, 106.8782], city: "Jakarta Timur", radius: "2.0 km"}, "Matraman": {coords: [-6.2022, 106.8582], city: "Jakarta Timur", radius: "1.5 km"}, "Pasar Rebo": {coords: [-6.3122, 106.8522], city: "Jakarta Timur", radius: "2.0 km"}, "Pulo Gadung": {coords: [-6.1922, 106.8922], city: "Jakarta Timur", radius: "2.6 km"}, // 6. KEPULAUAN SERIBU (2 Kecamatan) "Kepulauan Seribu Utara": {coords: [-5.5722, 106.5522], city: "Kepulauan Seribu", radius: "8.0 km"}, "Kepulauan Seribu Selatan": {coords: [-5.7722, 106.6522], city: "Kepulauan Seribu", radius: "7.0 km"} }; const BANTARGEBANG_COORDS = [-6.3477, 106.9939]; // Dynamic backend routing (highly compatible with Vercel deployment) const API_BASE_URL = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" ? "" // Relative path on local environment : "https://alamdieng-waste-prediction-api.hf.space"; // Direct backend url on remote hosting // UI Elements const locationSelect = document.getElementById("location-select"); const modelSelect = document.getElementById("model-select"); const forecastSlider = document.getElementById("forecast-slider"); const forecastVal = document.getElementById("forecast-val"); const rainOverride = document.getElementById("rain-override"); const rainOverrideVal = document.getElementById("rain-override-val"); const eventOverride = document.getElementById("event-override"); const eventOverrideVal = document.getElementById("event-override-val"); const predictBtn = document.getElementById("predict-btn"); const exportBtn = document.getElementById("export-btn"); // Weather elements const weatherForecastText = document.getElementById("weather-forecast-text"); const weatherLocationText = document.getElementById("weather-location-text"); const weatherPrecip = document.getElementById("weather-precip"); const weatherAlert = document.getElementById("weather-alert"); const eventDescText = document.getElementById("event-desc-text"); // Stats elements const statTotalVolume = document.getElementById("stat-total-volume"); const statRiskStatus = document.getElementById("stat-risk-status"); const statTrucks = document.getElementById("stat-trucks"); // Metadata elements const statPeriodMeta = document.getElementById("stat-period-meta"); const statLocationMeta = document.getElementById("stat-location-meta"); // Composition elements const valOrganic = document.getElementById("val-organic"); const valPlastic = document.getElementById("val-plastic"); const valPaper = document.getElementById("val-paper"); const valGlass = document.getElementById("val-glass"); const valTextile = document.getElementById("val-textile"); const valMetal = document.getElementById("val-metal"); const barOrganic = document.getElementById("bar-organic"); const barPlastic = document.getElementById("bar-plastic"); const barPaper = document.getElementById("bar-paper"); const barGlass = document.getElementById("bar-glass"); const barTextile = document.getElementById("bar-textile"); const barMetal = document.getElementById("bar-metal"); // Logistics elements const logFleet = document.getElementById("log-fleet"); const logFleetSub = document.getElementById("log-fleet-sub"); const logManpower = document.getElementById("log-manpower"); const logManpowerSub = document.getElementById("log-manpower-sub"); const logDuration = document.getElementById("log-duration"); const logDurationSub = document.getElementById("log-duration-sub"); const logTruckLoads = document.getElementById("log-truck-loads"); const logTruckLoadsSub = document.getElementById("log-truck-loads-sub"); const logEfficiency = document.getElementById("log-efficiency"); const logEfficiencySub = document.getElementById("log-efficiency-sub"); const logConfidence = document.getElementById("log-confidence"); const logConfidenceSub = document.getElementById("log-confidence-sub"); // Timeline & Hourly const timelineList = document.getElementById("timeline-list"); const hourlySection = document.getElementById("hourly-section"); const hourlyGrid = document.getElementById("hourly-grid"); // State let selectedLocation = "Menteng"; let rainValue = 0; // 0 means Auto (Open-Meteo) let map; let mapMarkers = {}; let routeLine = null; // ========================================== // SPA MULTIPAGE ROUTING & SIDEBAR CONTROLS // ========================================== function switchPage(pageId) { document.querySelectorAll(".page-container").forEach(el => { el.classList.remove("active"); }); document.querySelectorAll(".nav-btn").forEach(el => { el.classList.remove("active"); }); const targetPage = document.getElementById(pageId); if (targetPage) { targetPage.classList.add("active"); } const targetBtns = document.querySelectorAll(`.nav-btn[data-target="${pageId}"]`); targetBtns.forEach(btn => { btn.classList.add("active"); }); // Auto-close mobile sidebar drawer on navigation const sidebar = document.getElementById("app-sidebar"); const backdrop = document.getElementById("sidebar-backdrop"); const toggleBtn = document.getElementById("mobile-toggle-btn"); if (sidebar && sidebar.classList.contains("open")) { sidebar.classList.remove("open"); if (backdrop) backdrop.classList.remove("active"); if (toggleBtn) toggleBtn.classList.remove("active"); } if (pageId === "page-news") { loadNewsFeed(); } else if (pageId === "page-alerts") { loadAlertsFeed(); } else if (pageId === "page-autopilot") { loadAutopilotFeed(); } else if (pageId === "page-predictor" && map) { setTimeout(() => { map.invalidateSize(); }, 250); } window.scrollTo({ top: 0, behavior: "smooth" }); } function toggleSidebar() { const sidebar = document.getElementById("app-sidebar"); const backdrop = document.getElementById("sidebar-backdrop"); const toggleBtn = document.getElementById("menu-toggle-btn") || document.getElementById("mobile-toggle-btn"); if (sidebar) { sidebar.classList.toggle("open"); if (backdrop) backdrop.classList.toggle("active"); if (toggleBtn) toggleBtn.classList.toggle("active"); } } window.switchPage = switchPage; window.toggleSidebar = toggleSidebar; // Dynamically Populate Dropdown on Startup function populateLocationDropdown() { if (!locationSelect) return; const currentVal = locationSelect.value || selectedLocation; locationSelect.innerHTML = ""; Object.keys(KECAMATAN_DATABASE).forEach(loc => { const opt = document.createElement("option"); opt.value = loc; opt.textContent = `${loc} (${KECAMATAN_DATABASE[loc].city})`; locationSelect.appendChild(opt); }); locationSelect.value = KECAMATAN_DATABASE[currentVal] ? currentVal : "Menteng"; } async function fetchKecamatanMetadata() { try { const res = await fetch(`${API_BASE_URL}/api/v1/kecamatan`); if (res.ok) { const jsonRes = await res.json(); if (jsonRes && jsonRes.data) { Object.keys(jsonRes.data).forEach(loc => { const item = jsonRes.data[loc]; KECAMATAN_DATABASE[loc] = { coords: [item.latitude, item.longitude], city: item.city, radius: item.radius || "2.0 km", population_jiwa: item.population_jiwa, normal_avg: item.normal_avg, warning_threshold: item.warning_threshold, critical_threshold: item.critical_threshold, zone: item.zone }; }); populateLocationDropdown(); } } } catch (e) { console.warn("Using local kecamatan database fallback", e); } } fetchKecamatanMetadata(); // Calculate Haversine Distance between two coordinate arrays [lat, lon] function getHaversineDistance(coords1, coords2) { const R = 6371; // Earth radius in km const dLat = (coords2[0] - coords1[0]) * Math.PI / 180; const dLon = (coords2[1] - coords1[1]) * Math.PI / 180; const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(coords1[0] * Math.PI / 180) * Math.cos(coords2[0] * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; } // Event Listeners for controls if (forecastSlider) { forecastSlider.addEventListener("input", (e) => { forecastVal.textContent = e.target.value; }); } if (rainOverride) { rainOverride.addEventListener("input", (e) => { const val = parseInt(e.target.value); rainValue = val; if (val === 0) { rainOverrideVal.textContent = "Auto (Open-Meteo)"; } else { rainOverrideVal.textContent = `${val} mm`; } updateRainAnimationIntensity(val); }); } if (eventOverride) { eventOverride.addEventListener("input", (e) => { const val = parseInt(e.target.value); if (eventOverrideVal) { eventOverrideVal.textContent = `${val.toLocaleString()} Jiwa`; } }); } if (locationSelect) { locationSelect.addEventListener("change", (e) => { selectedLocation = e.target.value; const pop = KECAMATAN_DATABASE[selectedLocation]?.population_jiwa || 100000; if (eventOverride) { eventOverride.value = pop; } if (eventOverrideVal) { eventOverrideVal.textContent = `${pop.toLocaleString()} Jiwa (BPS)`; } updateActiveMapMarker(selectedLocation); panToLocation(selectedLocation); fetchLiveWeather(selectedLocation); runPrediction(); }); } // Initialize Leaflet Map function initMap() { const mapEl = document.getElementById("map"); if (!mapEl) return; map = L.map('map', { zoomControl: true, attributionControl: false, maxZoom: 15, minZoom: 9 }).setView([-6.175, 106.825], 11.5); L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}', { maxZoom: 16, attribution: 'Esri, HERE, Garmin, (c) OpenStreetMap contributors' }).addTo(map); // Add Bantargebang disposal site marker const bantarIcon = L.divIcon({ className: 'leaflet-custom-marker bantar-marker', html: `
Bantargebang
`, iconSize: [24, 24], iconAnchor: [12, 12] }); L.marker(BANTARGEBANG_COORDS, { icon: bantarIcon }).addTo(map).bindPopup(`

TPST BANTARGEBANG

Disposal Facility (Bekasi)
Status: Active & Calibrated
`); // Add Custom Location Markers for 44 Kecamatan Object.keys(KECAMATAN_DATABASE).forEach(loc => { const data = KECAMATAN_DATABASE[loc]; const customIcon = L.divIcon({ className: 'leaflet-custom-marker', html: `
${loc}
`, iconSize: [24, 24], iconAnchor: [12, 12] }); const marker = L.marker(data.coords, { icon: customIcon }).addTo(map); marker.on('click', () => { selectedLocation = loc; if (locationSelect) locationSelect.value = loc; updateActiveMapMarker(loc); panToLocation(loc); fetchLiveWeather(loc); runPrediction(); }); mapMarkers[loc] = marker; }); setTimeout(() => { updateActiveMapMarker(selectedLocation); }, 1000); } function updateActiveMapMarker(locName) { Object.keys(mapMarkers).forEach(loc => { const marker = mapMarkers[loc]; const el = marker.getElement(); if (el) { if (loc === locName) { el.classList.add("active"); } else { el.classList.remove("active"); } } }); } function panToLocation(locName) { const coords = KECAMATAN_DATABASE[locName]?.coords; if (coords && map) { map.panTo(coords); } } function updateMarkerRisk(locName, riskStatus) { const marker = mapMarkers[locName]; if (marker) { const el = marker.getElement(); if (el) { el.classList.remove("safe", "warning", "critical"); el.classList.add(riskStatus.toLowerCase()); } } } // Draw transit route to TPST Bantargebang function drawTransitRoute(locName) { const startCoords = KECAMATAN_DATABASE[locName]?.coords; if (!startCoords || !map) return; if (routeLine) { map.removeLayer(routeLine); } routeLine = L.polyline([startCoords, BANTARGEBANG_COORDS], { color: '#00F0FF', weight: 3.5, opacity: 0.8, dashArray: '6, 8', className: 'glowing-route' }).addTo(map); const directDist = getHaversineDistance(startCoords, BANTARGEBANG_COORDS); const roadDist = directDist * 1.35; const travelTimeHours = roadDist / 28.0; // Update floating map overlay const routeTarget = document.getElementById('route-target-name'); const routeDist = document.getElementById('route-distance-val'); const routeTime = document.getElementById('route-time-val'); if (routeTarget) routeTarget.textContent = `${locName} → TPST Bantargebang`; if (routeDist) routeDist.textContent = `${roadDist.toFixed(1)} km`; if (routeTime) routeTime.textContent = `${travelTimeHours.toFixed(1)} Hours`; routeLine.bindPopup(`

LOGISTICS DISPATCH ROUTE

Origin: ${locName} (${KECAMATAN_DATABASE[locName]?.city || 'DKI Jakarta'})
Destination: TPST Bantargebang (Bekasi)
Transit Distance: ${roadDist.toFixed(1)} km
Est. Transit Time: ${travelTimeHours.toFixed(1)} Hours
`); map.fitBounds([startCoords, BANTARGEBANG_COORDS], { padding: [40, 40] }); } // Fetch Live Weather from Open-Meteo with Timeout async function fetchLiveWeather(loc) { const coord = KECAMATAN_DATABASE[loc]; if (!coord) return; if (weatherForecastText) weatherForecastText.textContent = "Fetching..."; if (weatherPrecip) weatherPrecip.textContent = "0.0 mm"; if (weatherAlert) weatherAlert.textContent = "Checking..."; const url = `https://api.open-meteo.com/v1/forecast?latitude=${coord.coords[0]}&longitude=${coord.coords[1]}¤t_weather=true&daily=precipitation_sum&timezone=Asia/Jakarta&past_days=2`; // Set 1.5s timeout promise const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 1500) ); try { const fetchPromise = fetch(url).then(res => { if (!res.ok) throw new Error("HTTP Error"); return res.json(); }); // Race weather request with 1.5s timeout const data = await Promise.race([fetchPromise, timeoutPromise]); const temp = data.current_weather.temperature; const code = data.current_weather.weathercode; const dailyData = data.daily || {}; const precipList = dailyData.precipitation_sum || []; const precipToday = precipList[2] || 0; let cond = "Cloudy"; if (code === 0) cond = "Clear Sky"; else if (code > 0 && code < 4) cond = "Partly Cloudy"; else if (code >= 51 && code <= 67) cond = "Rainy"; else if (code >= 80 && code <= 82) cond = "Showers"; if (weatherForecastText) weatherForecastText.textContent = `${temp}°C - ${cond}`; if (weatherLocationText) weatherLocationText.textContent = `${loc} (${coord.city})`; if (weatherPrecip) weatherPrecip.textContent = `${precipToday.toFixed(1)} mm`; if (weatherAlert) { if (precipToday > 30) { weatherAlert.textContent = "HEAVY RAIN 🟡"; weatherAlert.className = "highlight text-warning"; } else if (precipToday > 50) { weatherAlert.textContent = "FLOOD DANGER 🔴"; weatherAlert.className = "highlight text-red"; } else { weatherAlert.textContent = "Normal conditions"; weatherAlert.className = "highlight"; } } } catch (err) { console.warn("Weather fetch timed out/failed. Using fallback forecast.", err); // Instant Fallback Weather Data const fallbackTemp = 28.5 + Math.random() * 3.0; const fallbackPrecip = 0.0; if (weatherForecastText) weatherForecastText.textContent = `${fallbackTemp.toFixed(1)}°C - Partly Cloudy`; if (weatherLocationText) weatherLocationText.textContent = `${loc} (${coord.city})`; if (weatherPrecip) weatherPrecip.textContent = `${fallbackPrecip.toFixed(1)} mm`; if (weatherAlert) { weatherAlert.textContent = "Normal conditions"; weatherAlert.className = "highlight"; } } } // Run prediction calling FastAPI backend async function runPrediction() { if (!predictBtn) return; predictBtn.disabled = true; predictBtn.querySelector(".btn-text").textContent = "PROCESSING FORECAST..."; const payload = { forecast_days: parseInt(forecastSlider.value), rainfall_mm: parseFloat(rainValue), jumlah_jiwa: parseInt(eventOverride.value), event_scale: 0, location: selectedLocation, model_type: modelSelect.value, granularity: forecastSlider.value <= 7 ? "hourly" : "daily" }; try { const response = await fetch(`${API_BASE_URL}/api/v1/predict`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (response.ok) { const resData = await response.json(); updateDashboardData(resData.data, resData.confidence_score, resData.message); // Show data status badge const badge = document.getElementById('data-status-badge'); if (badge) { badge.style.display = 'block'; const statusText = document.getElementById('data-status-text'); if (statusText && resData.model_version) { statusText.textContent = `FORECAST · Model: ${resData.model_version} · Training: ${resData.training_data_type || 'SYNTHETIC'}`; } } } else { console.error("API Error"); } } catch (err) { console.error(err); } finally { predictBtn.disabled = false; predictBtn.querySelector(".btn-text").textContent = "RUN PREDICTION"; } } function updateDashboardData(data, confScore, message) { const results = data.prediction_results; if (!results || results.length === 0) return; const totalVolume = results.reduce((acc, curr) => acc + curr.total_volume_ton, 0); // 1. KPI Decision Summary: Forecast Volume if (statTotalVolume) { statTotalVolume.innerHTML = `${totalVolume.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Tons`; } // 2. KPI Decision Summary: Risk Level let maxRisk = "SAFE"; results.forEach(r => { if (r.risk_status === "CRITICAL") maxRisk = "CRITICAL"; else if (r.risk_status === "WARNING" && maxRisk !== "CRITICAL") maxRisk = "WARNING"; }); if (statRiskStatus) { statRiskStatus.textContent = maxRisk; statRiskStatus.className = `kpi-status-badge ${maxRisk.toLowerCase()}`; } const baselineTon = KECAMATAN_DATABASE[selectedLocation]?.normal_avg || 100.0; if (statPeriodMeta) statPeriodMeta.textContent = `Next ${results.length} days (${results[0].date} to ${results[results.length - 1].date})`; if (statLocationMeta) statLocationMeta.textContent = `${selectedLocation} · Baseline: ${baselineTon.toFixed(1)} T/D`; updateMarkerRisk(selectedLocation, maxRisk); drawTransitRoute(selectedLocation); // Logistics breakdown const logPlan = data.logistics_plan || {}; const recFleet = logPlan.recommended_fleet || {}; const manpowerObj = logPlan.manpower_breakdown || {}; const colTimeObj = logPlan.collection_time || {}; const effObj = logPlan.operational_efficiency || {}; const relObj = logPlan.reliability || {}; const trucksCount = logPlan.trucks_needed || recFleet.recommended_trucks || Math.ceil(totalVolume / 14.25); const personnelCount = logPlan.manpower || manpowerObj.total_personnel || (trucksCount * 3); const durationHours = (logPlan.estimated_duration_hours !== undefined ? logPlan.estimated_duration_hours : (colTimeObj.adjusted_hours || 0.0)).toFixed(1); const truckLoadsVal = logPlan.required_truck_loads !== undefined ? Math.ceil(logPlan.required_truck_loads) : Math.ceil(totalVolume / 15.0); // 3. KPI Decision Summary: Suggested Fleet if (statTrucks) statTrucks.innerHTML = `${trucksCount} Trucks`; // 4. KPI Decision Summary: Forecast Readiness const readinessScore = relObj.score_percent !== undefined ? relObj.score_percent.toFixed(1) : (confScore * 100).toFixed(1); const elReadiness = document.getElementById("stat-readiness") || logConfidence; if (elReadiness) elReadiness.textContent = `${readinessScore}%`; // 5. Chart.js Forecast Timeline Curve renderForecastChart(results); // 6. Waste Composition Breakdown const totalOrganic = results.reduce((acc, curr) => acc + curr.organic_waste_ton, 0); const totalPlastic = results.reduce((acc, curr) => acc + curr.plastic_waste_ton, 0); const totalPaper = results.reduce((acc, curr) => acc + curr.paper_waste_ton, 0); const totalGlass = results.reduce((acc, curr) => acc + curr.glass_waste_ton, 0); const totalTextile = results.reduce((acc, curr) => acc + curr.textile_waste_ton, 0); const totalMetal = results.reduce((acc, curr) => acc + (curr.metal_waste_ton + curr.other_waste_ton), 0); if (valOrganic) valOrganic.textContent = `${totalOrganic.toFixed(2)} Ton`; if (valPlastic) valPlastic.textContent = `${totalPlastic.toFixed(2)} Ton`; if (valPaper) valPaper.textContent = `${totalPaper.toFixed(2)} Ton`; if (valGlass) valGlass.textContent = `${totalGlass.toFixed(2)} Ton`; if (valTextile) valTextile.textContent = `${totalTextile.toFixed(2)} Ton`; if (valMetal) valMetal.textContent = `${totalMetal.toFixed(2)} Ton`; const getPct = (val) => totalVolume > 0 ? (val / totalVolume) * 100 : 0; const pctOrganic = getPct(totalOrganic); const pctPlastic = getPct(totalPlastic); const pctPaper = getPct(totalPaper); const pctGlass = getPct(totalGlass); const pctTextile = getPct(totalTextile); const pctMetal = getPct(totalMetal); if (barOrganic) barOrganic.style.width = `${pctOrganic}%`; if (barPlastic) barPlastic.style.width = `${pctPlastic}%`; if (barPaper) barPaper.style.width = `${pctPaper}%`; if (barGlass) barGlass.style.width = `${pctGlass}%`; if (barTextile) barTextile.style.width = `${pctTextile}%`; if (barMetal) barMetal.style.width = `${pctMetal}%`; // Stacked Segmented Bar const segOrg = document.getElementById("seg-organic"); const segPla = document.getElementById("seg-plastic"); const segPap = document.getElementById("seg-paper"); const segGla = document.getElementById("seg-glass"); const segTex = document.getElementById("seg-textile"); const segMet = document.getElementById("seg-metal"); if (segOrg) segOrg.style.width = `${pctOrganic}%`; if (segPla) segPla.style.width = `${pctPlastic}%`; if (segPap) segPap.style.width = `${pctPaper}%`; if (segGla) segGla.style.width = `${pctGlass}%`; if (segTex) segTex.style.width = `${pctTextile}%`; if (segMet) segMet.style.width = `${pctMetal}%`; // 7. Operational Scenario Grid const elFleet = document.getElementById("log-fleet"); const elFleetSub = document.getElementById("log-fleet-sub"); const elManpower = document.getElementById("log-manpower"); const elManpowerSub = document.getElementById("log-manpower-sub"); const elDuration = document.getElementById("log-duration"); const elDurationSub = document.getElementById("log-duration-sub"); const elTruckLoads = document.getElementById("log-truck-loads"); const elTruckLoadsSub = document.getElementById("log-truck-loads-sub"); const elEfficiency = document.getElementById("log-efficiency"); const elEfficiencySub = document.getElementById("log-efficiency-sub"); if (elFleet) elFleet.textContent = `${trucksCount} Trucks`; if (elFleetSub) elFleetSub.textContent = "15 ton capacity / truck"; if (elManpower) elManpower.textContent = `${personnelCount} Personnel`; if (elManpowerSub) { const drivers = manpowerObj.drivers !== undefined ? manpowerObj.drivers : trucksCount; const collectors = manpowerObj.collectors !== undefined ? manpowerObj.collectors : (trucksCount * 2); elManpowerSub.textContent = `${drivers} drivers + ${collectors} collectors`; } if (elDuration) elDuration.textContent = `${durationHours} Hours`; if (elDurationSub) elDurationSub.textContent = "Throughput adjusted"; if (elTruckLoads) elTruckLoads.textContent = `~${truckLoadsVal} Loads`; if (elTruckLoadsSub) elTruckLoadsSub.textContent = "Volume ÷ 15T gross"; if (elEfficiency) elEfficiency.textContent = logPlan.efficiency_rate || effObj.display || "85% — Optimal"; if (elEfficiencySub) elEfficiencySub.textContent = effObj.status ? `Status: ${effObj.status}` : "Multi-factor operational index"; // 8. Event Schedule Update const eventDay = results.find(r => r.event_info !== null); if (eventDay) { if (eventDescText) eventDescText.innerHTML = `⚠️ ${eventDay.event_info} on ${eventDay.date}. Heavy crowd expected near site.`; const eBox = document.getElementById("event-box"); if (eBox) eBox.style.borderColor = "var(--status-warning)"; } else { if (eventDescText) eventDescText.textContent = "No major public events scheduled for this location in the forecast window."; const eBox = document.getElementById("event-box"); if (eBox) eBox.style.borderColor = "var(--border-subtle)"; } // 9. Hourly 24-hr Diurnal Risk Grid const hourlyDay = results[0]; if (hourlyDay && hourlyDay.hourly_breakdown && hourlyGrid) { hourlyGrid.innerHTML = ""; hourlyDay.hourly_breakdown.forEach(hour => { const cell = document.createElement("div"); cell.className = "hourly-cell"; let intensityClass = "low"; if (hour.risk_indicator === "MEDIUM") intensityClass = "medium"; else if (hour.risk_indicator === "HIGH") intensityClass = "high"; cell.innerHTML = `
${hour.hour} `; hourlyGrid.appendChild(cell); }); } updateContextBadge(); } // Request CSV from Backend API and download it async function runExport() { if (!exportBtn) return; exportBtn.disabled = true; exportBtn.querySelector(".btn-text").textContent = "EXPORTING..."; const payload = { forecast_days: parseInt(forecastSlider.value), rainfall_mm: parseFloat(rainValue), event_scale: parseInt(eventOverride.value), location: selectedLocation, model_type: modelSelect.value, granularity: forecastSlider.value <= 7 ? "hourly" : "daily" }; try { const response = await fetch("/api/v1/predict/csv", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (response.ok) { const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `waste_forecast_${selectedLocation.replace(/\s+/g, "_")}_${forecastSlider.value}d.csv`; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); } } catch (err) { console.error(err); } finally { exportBtn.disabled = false; exportBtn.querySelector(".btn-text").textContent = "EXPORT CSV"; } } // ========================================== // SPA ASYNC LOADERS (News, Alerts, Autopilot) // ========================================== async function loadNewsFeed() { const newsGrid = document.getElementById("news-grid-list"); if (!newsGrid) return; newsGrid.innerHTML = '
Loading latest waste intelligence...
'; try { const res = await fetch(`${API_BASE_URL}/api/v1/news`); if (res.ok) { const news = await res.json(); newsGrid.innerHTML = ""; if (news.length === 0) { newsGrid.innerHTML = '
No news articles found.
'; return; } news.forEach(item => { const card = document.createElement("div"); card.className = "news-card"; card.innerHTML = `
${item.source} ${item.date_fetched || "2026-07-10"}

${item.title}

${item.summary}

READ SOURCE `; newsGrid.appendChild(card); }); } else { newsGrid.innerHTML = '
Failed to fetch news from server.
'; } } catch (err) { newsGrid.innerHTML = '
Error loading news feed.
'; } } async function loadAlertsFeed() { const alertsList = document.getElementById("alerts-grid-list"); if (!alertsList) return; alertsList.innerHTML = '
Evaluating regional alert parameters...
'; try { const res = await fetch(`${API_BASE_URL}/api/v1/alerts`); if (res.ok) { const alertData = await res.json(); alertsList.innerHTML = ""; if (alertData.alerts.length === 0) { alertsList.innerHTML = '
No active warnings. All systems green.
'; return; } alertData.alerts.forEach(item => { const row = document.createElement("div"); row.className = "alert-row"; row.innerHTML = ` ${item.date} ${item.location} ${item.status} ${item.message} - Timbulan: ${item.estimated_volume_ton.toFixed(1)} Ton `; row.addEventListener("click", () => { selectedLocation = item.location; if (locationSelect) locationSelect.value = item.location; updateActiveMapMarker(item.location); panToLocation(item.location); fetchLiveWeather(item.location); switchPage("page-predictor"); setTimeout(() => { runPrediction(); }, 500); }); alertsList.appendChild(row); }); } else { alertsList.innerHTML = '
Failed to load alerts.
'; } } catch (err) { alertsList.innerHTML = '
Error loading alerts feed.
'; } } async function loadAutopilotFeed() { const logContainer = document.getElementById("autopilot-log"); const autoVol = document.getElementById("auto-total-volume"); const autoTrucks = document.getElementById("auto-total-trucks"); const autoRiskList = document.getElementById("auto-risk-list"); if (!logContainer || !autoRiskList) return; autoVol.textContent = "Calculating..."; autoTrucks.textContent = "Calculating..."; autoRiskList.innerHTML = '
Running neural models...
'; logContainer.innerHTML = ""; const addLog = (msg) => { const time = new Date().toLocaleTimeString('en-US', { hour12: false }); const p = document.createElement("div"); p.textContent = `[${time}] ${msg}`; logContainer.appendChild(p); logContainer.scrollTop = logContainer.scrollHeight; }; addLog("Aeterna Neural Core Initialized."); await new Promise(r => setTimeout(r, 600)); addLog("Connecting to Open-Meteo Geolocation nodes..."); await new Promise(r => setTimeout(r, 600)); addLog("Weather models ready. Scanning 44 sub-districts..."); await new Promise(r => setTimeout(r, 800)); try { const res = await fetch(`${API_BASE_URL}/api/v1/autopilot`); if (res.ok) { const data = await res.json(); addLog("Executing GBR forward inference pass on 44 regions..."); await new Promise(r => setTimeout(r, 800)); addLog(`Forecasting complete. Total active events today: ${data.event_today ? data.event_today : "0"}`); await new Promise(r => setTimeout(r, 500)); autoVol.innerHTML = `${data.total_volume_ton.toLocaleString('en-US')} Tons`; autoTrucks.innerHTML = `${data.total_trucks.toLocaleString('en-US')} Trucks (15T)`; autoRiskList.innerHTML = ""; data.top_kecamatan.forEach((item, index) => { const card = document.createElement("div"); card.className = "alert-row autopilot-row"; card.innerHTML = ` #0${index+1} ${item.location} ${item.status} Coords: [${item.latitude.toFixed(4)}, ${item.longitude.toFixed(4)}] | Predicted: ${item.volume_ton.toFixed(1)} Tons (${item.trucks} Trucks) `; card.addEventListener("click", () => { selectedLocation = item.location; if (locationSelect) locationSelect.value = item.location; updateActiveMapMarker(item.location); panToLocation(item.location); fetchLiveWeather(item.location); switchPage("page-predictor"); setTimeout(() => { runPrediction(); }, 500); }); autoRiskList.appendChild(card); }); addLog(`DKI Jakarta daily forecast compiled: ${data.total_volume_ton} Tons.`); addLog(`Logistics dispatch size set to ${data.total_trucks} crew trucks.`); addLog("Autonomous fleet routing to TPST Bantargebang optimized via Haversine."); } else { addLog("CRITICAL ERROR: Failed to communicate with prediction nodes."); } } catch (err) { addLog("CRITICAL ERROR: Connection timed out."); } } // Attach Event Listeners on DOM load window.addEventListener("DOMContentLoaded", () => { populateLocationDropdown(); initMap(); fetchLiveWeather(selectedLocation); // Wire SPA Navigation document.querySelectorAll(".nav-btn").forEach(btn => { btn.addEventListener("click", () => { const target = btn.getAttribute("data-target"); switchPage(target); }); }); if (predictBtn) predictBtn.addEventListener("click", runPrediction); if (exportBtn) exportBtn.addEventListener("click", runExport); setTimeout(runPrediction, 1000); }); // ========================================== // BACKGROUND CANVAS: INTERACTIVE RAIN EFFECT // ========================================== const canvas = document.getElementById("rain-canvas"); const ctx = canvas.getContext("2d"); let width = canvas.width = window.innerWidth; let height = canvas.height = window.innerHeight; window.addEventListener("resize", () => { width = canvas.width = window.innerWidth; height = canvas.height = window.innerHeight; }); let drops = []; let particles = []; let maxPrecip = 0; function updateRainAnimationIntensity(precipVal) { maxPrecip = precipVal; } class DataParticle { constructor() { this.reset(); } reset() { this.x = Math.random() * width; this.y = Math.random() * height; this.size = Math.random() * 2 + 1; this.speedX = Math.random() * 0.4 - 0.2; this.speedY = Math.random() * -0.5 - 0.2; this.alpha = Math.random() * 0.5 + 0.1; } update() { this.x += this.speedX; this.y += this.speedY; if (this.y < 0 || this.x < 0 || this.x > width) { this.reset(); this.y = height; } } draw() { ctx.fillStyle = `rgba(5, 150, 105, ${this.alpha * 0.4})`; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); } } class RainDrop { constructor() { this.reset(); } reset() { this.x = Math.random() * width; this.y = Math.random() * -100 - 10; this.length = Math.random() * 15 + 10; this.speed = Math.random() * 12 + 15; this.weight = Math.random() * 1 + 0.5; this.alpha = Math.random() * 0.3 + 0.1; } update() { this.y += this.speed; if (this.y > height) { this.reset(); } } draw() { ctx.strokeStyle = `rgba(5, 150, 105, ${this.alpha * 0.4})`; ctx.lineWidth = this.weight; ctx.beginPath(); ctx.moveTo(this.x, this.y); ctx.lineTo(this.x + (maxPrecip * 0.05), this.y + this.length); ctx.stroke(); } } for (let i = 0; i < 60; i++) { particles.push(new DataParticle()); } for (let i = 0; i < 150; i++) { drops.push(new RainDrop()); } function animate() { ctx.clearRect(0, 0, width, height); if (maxPrecip === 0) { particles.forEach(p => { p.update(); p.draw(); }); } else { const activeCount = Math.min(Math.floor(maxPrecip * 1.5), 150); for (let i = 0; i < activeCount; i++) { drops[i].update(); drops[i].draw(); } } requestAnimationFrame(animate); } animate(); // ========================================== // CUSTOM CYBER HUD CURSOR // ========================================== const cursorDot = document.getElementById("cursor-dot"); const cursorRing = document.getElementById("cursor-ring"); let mouseX = -100; let mouseY = -100; let ringX = -100; let ringY = -100; document.addEventListener("mousemove", (e) => { mouseX = e.clientX; mouseY = e.clientY; if (cursorDot && cursorDot.style.display !== "block") { cursorDot.style.display = "block"; cursorRing.style.display = "block"; } }); function animateCursor() { const lerpFactor = 0.15; ringX += (mouseX - ringX) * lerpFactor; ringY += (mouseY - ringY) * lerpFactor; if (cursorDot) { cursorDot.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0) translate3d(-50%, -50%, 0)`; } if (cursorRing) { cursorRing.style.transform = `translate3d(${ringX}px, ${ringY}px, 0) translate3d(-50%, -50%, 0)`; } requestAnimationFrame(animateCursor); } animateCursor(); // Mouse hover scaling state document.addEventListener("mouseover", (e) => { if (cursorRing && ( e.target.tagName === "BUTTON" || e.target.tagName === "A" || e.target.tagName === "SELECT" || e.target.tagName === "INPUT" || e.target.classList.contains("leaflet-interactive") || e.target.closest("button") || e.target.closest("a") )) { cursorRing.classList.add("hover-state"); } }); document.addEventListener("mouseout", (e) => { if (cursorRing && ( e.target.tagName === "BUTTON" || e.target.tagName === "A" || e.target.tagName === "SELECT" || e.target.tagName === "INPUT" || e.target.classList.contains("leaflet-interactive") || e.target.closest("button") || e.target.closest("a") )) { cursorRing.classList.remove("hover-state"); } }); // ========================================== // INTERACTIVE ECO-SORTER SIMULATOR (EDUCATION GAME) // ========================================== const WASTE_ITEMS = [ { name: "Botol Plastik PET", category: "inorganic", icon: "🍼", desc: "Botol air mineral kosong berbahan plastik PET. Butuh waktu sekitar 450 tahun untuk terurai alami!" }, { name: "Sisa Makanan / Apel", category: "organic", icon: "🍎", desc: "Sampah organik sisa makanan. Mudah terurai dalam 1-2 minggu dan sangat cocok diolah jadi kompos." }, { name: "Baterai Bekas", category: "hazardous", icon: "hazardous", desc: "Mengandung bahan kimia berbahaya seperti litium atau kadmium (B3). Harus dipilah khusus!" }, { name: "Kardus Bekas", category: "inorganic", icon: "📦", desc: "Kertas/kardus kering yang dapat didaur ulang menjadi bubur kertas baru." }, { name: "Botol Kaca", category: "inorganic", icon: "🫙", desc: "Material kaca. Membutuhkan waktu lebih dari 1 juta tahun untuk hancur secara alami di alam." }, { name: "Lampu Neon Rusak", category: "hazardous", icon: "hazardous", desc: "Lampu kaca bekas yang mengandung gas merkuri berbahaya. Masuk kategori limbah B3." }, { name: "Daun Kering", category: "organic", icon: "🍂", desc: "Limbah organik kebun. Dapat dikeringkan atau ditimbun untuk menyuburkan tanah." }, { name: "Masker Medis Bekas", category: "hazardous", icon: "hazardous", desc: "Limbah medis rumah tangga yang berpotensi menularkan penyakit. Masuk kategori limbah B3." }, { name: "Kulit Pisang", category: "organic", icon: "🍌", desc: "Sampah dapur basah organik. Mengandung nutrisi mikro alami yang baik untuk tanaman." } ]; let gameScore = 0; let gameItemIndex = 0; function loadNextWasteItem() { const item = WASTE_ITEMS[gameItemIndex]; const iconEl = document.getElementById("game-item-icon"); const nameEl = document.getElementById("game-item-name"); const descEl = document.getElementById("game-item-desc"); if (iconEl && nameEl && descEl) { iconEl.textContent = item.icon; nameEl.textContent = item.name; descEl.textContent = item.desc; // Add a nice cyber flash animation on load iconEl.style.transform = "scale(1.2)"; setTimeout(() => { iconEl.style.transform = "scale(1)"; }, 150); } } function sortWaste(chosenCategory) { const item = WASTE_ITEMS[gameItemIndex]; const feedbackEl = document.getElementById("game-feedback"); const scoreEl = document.getElementById("game-score"); const gameArea = document.querySelector(".game-area"); if (chosenCategory === item.category) { gameScore += 10; if (feedbackEl) { feedbackEl.textContent = "BENAR! +10 Poin"; feedbackEl.style.color = "#4ade80"; } if (gameArea) { gameArea.style.border = "1px solid #4ade80"; gameArea.style.boxShadow = "0 0 20px rgba(74, 222, 128, 0.3)"; } } else { gameScore = Math.max(0, gameScore - 5); let correctText = item.category === "organic" ? "ORGANIK" : item.category === "inorganic" ? "ANORGANIK" : "BAHAYA (B3)"; if (feedbackEl) { feedbackEl.textContent = `SALAH! Kategori Asli: ${correctText}`; feedbackEl.style.color = "#fb7185"; } if (gameArea) { gameArea.style.border = "1px solid #fb7185"; gameArea.style.boxShadow = "0 0 20px rgba(251, 113, 133, 0.3)"; } } if (scoreEl) scoreEl.textContent = gameScore; // Add visual feedback timeout setTimeout(() => { if (gameArea) { gameArea.style.border = "1px solid var(--border-color)"; gameArea.style.boxShadow = "none"; } }, 800); // Go to next item gameItemIndex = (gameItemIndex + 1) % WASTE_ITEMS.length; setTimeout(loadNextWasteItem, 1000); } // Bind to window to allow HTML inline onclick calls window.sortWaste = sortWaste; // Initialize the game automatically document.addEventListener("DOMContentLoaded", () => { loadNextWasteItem(); initCrisisStoryScroller(); init3DScene(); }); // ========================================== // CINEMATIC CRISIS STORYTELLING SCROLLER // ========================================== function initCrisisStoryScroller() { const storyCards = document.querySelectorAll(".story-card"); const tonsVal = document.getElementById("simulated-tons-val"); if (!storyCards.length || !tonsVal) return; // Use IntersectionObserver to detect which card is currently active/visible in the center of screen const observerOptions = { root: null, rootMargin: "-25% 0px -40% 0px", // Focus on the middle of the screen threshold: 0.1 }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { // Highlight active card storyCards.forEach(c => { c.style.borderColor = "var(--border-color)"; c.style.background = "var(--bg-panel)"; c.style.boxShadow = "none"; }); entry.target.style.borderColor = "var(--cyan)"; entry.target.style.background = "rgba(84, 130, 53, 0.03)"; entry.target.style.boxShadow = "0 4px 15px rgba(84, 130, 53, 0.05)"; // Get parameters const targetHeight = entry.target.getAttribute("data-height"); const targetTons = entry.target.getAttribute("data-tons"); // Determine 3D color based on stage height let colorHex = 0x548235; // Soft green (stage 1) if (targetHeight === "55") { colorHex = 0xC59124; // Soft Gold Amber (stage 2) } else if (targetHeight === "95") { colorHex = 0xC53929; // Soft Crimson Red (stage 3) } // Update Three.js 3D silo height and color update3DHeight(parseInt(targetHeight), colorHex); // Animate tons text value counter animateTonsCounter(parseInt(tonsVal.textContent.replace(/,/g, "")), parseInt(targetTons)); } }); }, observerOptions); storyCards.forEach(card => observer.observe(card)); } function animateTonsCounter(start, end) { const tonsVal = document.getElementById("simulated-tons-val"); if (!tonsVal) return; const duration = 800; // ms const startTime = performance.now(); function update(now) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // Ease out quadratic const easeProgress = progress * (2 - progress); const current = Math.round(start + (end - start) * easeProgress); tonsVal.textContent = `${current.toLocaleString('en-US')} Tons`; if (progress < 1) { requestAnimationFrame(update); } } requestAnimationFrame(update); } // ========================================== // THREE.JS 3D LANDFILL OVERLOAD VISUALIZER // ========================================== let scene3D, camera3D, renderer3D; let siloMesh, wasteMesh, garbageGroup; let isTabActive = true; let target3DHeight = 0.1; // 10% initially let current3DHeight = 0.1; let targetColorHex = 0x548235; window.addEventListener("blur", () => { isTabActive = false; }); window.addEventListener("focus", () => { isTabActive = true; }); function init3DScene() { const container = document.getElementById("threejs-waste-container"); if (!container) return; const width = container.clientWidth; const height = container.clientHeight; // Scene scene3D = new THREE.Scene(); // Camera camera3D = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); camera3D.position.set(0, 0.4, 3.5); // Renderer renderer3D = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer3D.setSize(width, height); renderer3D.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // optimize mobile container.appendChild(renderer3D.domElement); // Lights const ambientLight = new THREE.AmbientLight(0xffffff, 0.85); scene3D.add(ambientLight); const pointLight = new THREE.PointLight(0xffffff, 0.6, 50); pointLight.position.set(2, 4, 3); scene3D.add(pointLight); // 1. Silo Outer Wireframe Cylinder const siloGeo = new THREE.CylinderGeometry(0.7, 0.7, 2, 16, 1, true); const siloMat = new THREE.MeshBasicMaterial({ color: 0x548235, wireframe: true, transparent: true, opacity: 0.18 }); siloMesh = new THREE.Mesh(siloGeo, siloMat); scene3D.add(siloMesh); // 2. Liquid Waste Cylindrical Fill const wasteGeo = new THREE.CylinderGeometry(0.66, 0.66, 2, 24, 1); wasteGeo.translate(0, 1, 0); // Translate origin pivot to bottom const wasteMat = new THREE.MeshPhongMaterial({ color: 0x548235, transparent: true, opacity: 0.7, shininess: 40, flatShading: true }); wasteMesh = new THREE.Mesh(wasteGeo, wasteMat); wasteMesh.position.y = -1.0; // Place bottom of liquid at bottom of silo wasteMesh.scale.y = 0.1; scene3D.add(wasteMesh); // 3. Floating low-poly garbage elements inside liquid garbageGroup = new THREE.Group(); garbageGroup.position.y = -1.0; scene3D.add(garbageGroup); const geometries = [ new THREE.DodecahedronGeometry(0.07), new THREE.BoxGeometry(0.08, 0.08, 0.08), new THREE.TetrahedronGeometry(0.08) ]; for (let i = 0; i < 12; i++) { const randomGeo = geometries[Math.floor(Math.random() * geometries.length)]; const randomMat = new THREE.MeshPhongMaterial({ color: 0x475569, // Slate color flatShading: true, transparent: true, opacity: 0.85 }); const mesh = new THREE.Mesh(randomGeo, randomMat); // Random placement inside silo cylinder range mesh.position.set( (Math.random() - 0.5) * 0.8, Math.random() * 1.8, (Math.random() - 0.5) * 0.8 ); mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0); garbageGroup.add(mesh); } // Resize support window.addEventListener("resize", () => { if (!container) return; const w = container.clientWidth; const h = container.clientHeight; camera3D.aspect = w / h; camera3D.updateProjectionMatrix(); renderer3D.setSize(w, h); }); // Run optimized loop animate3D(); } function update3DHeight(percent, colorHex) { target3DHeight = Math.max(0.05, percent / 100); targetColorHex = colorHex; } function animate3D() { requestAnimationFrame(animate3D); // OPTIMIZATION: Do not render if tab is out of focus or if Home page is hidden const homePage = document.getElementById("page-home"); const container = document.getElementById("threejs-waste-container"); if (!isTabActive || !homePage || !homePage.classList.contains("active") || !container || container.offsetParent === null) { return; } if (wasteMesh) { // Smoothly scale height towards target (lerp) current3DHeight += (target3DHeight - current3DHeight) * 0.08; wasteMesh.scale.y = current3DHeight; // Smoothly interpolate liquid color (lerp) wasteMesh.material.color.lerp(new THREE.Color(targetColorHex), 0.08); // Float particles up and down inside current liquid boundaries if (garbageGroup) { garbageGroup.children.forEach((child, idx) => { // Keep inside fluid vertical bounds if (child.position.y > current3DHeight * 2) { child.position.y -= 0.008; } else if (child.position.y < 0.05) { child.position.y += 0.008; } // Bobbing effect child.position.y += Math.sin(Date.now() * 0.001 + idx) * 0.0005; child.rotation.x += 0.004; child.rotation.y += 0.004; }); } } // Rotate models slowly if (siloMesh) siloMesh.rotation.y += 0.002; if (wasteMesh) wasteMesh.rotation.y -= 0.0015; if (garbageGroup) garbageGroup.rotation.y += 0.001; renderer3D.render(scene3D, camera3D); } // Operational Logistics Plan Explainability Accordion window.toggleLogisticsExplainability = function() { const box = document.getElementById("logistics-explain-box"); const btn = document.getElementById("btn-how-calculated"); if (!box) return; if (box.style.display === "none" || box.style.display === "") { box.style.display = "block"; if (btn) btn.classList.add("active"); } else { box.style.display = "none"; if (btn) btn.classList.remove("active"); } }; // Model Information Modal handlers window.openModelInfoModal = function() { const modal = document.getElementById("model-info-modal"); if (modal) modal.style.display = "flex"; }; window.closeModelInfoModal = function() { const modal = document.getElementById("model-info-modal"); if (modal) modal.style.display = "none"; }; window.addEventListener("click", function(e) { const modal = document.getElementById("model-info-modal"); if (modal && e.target === modal) { modal.style.display = "none"; } }); // Horizon pill buttons setup document.addEventListener("DOMContentLoaded", () => { document.querySelectorAll(".horizon-pill").forEach(btn => { btn.addEventListener("click", (e) => { document.querySelectorAll(".horizon-pill").forEach(p => p.classList.remove("active")); btn.classList.add("active"); const days = parseInt(btn.getAttribute("data-days")); if (forecastSlider) forecastSlider.value = days; if (forecastVal) forecastVal.textContent = days; updateContextBadge(); runPrediction(); }); }); if (modelSelect) { modelSelect.addEventListener("change", () => { updateContextBadge(); runPrediction(); }); } }); // ========================================== // ACCUMULATION TOWER VISUALIZER (PAGE-HOME) // ========================================== function initAccumulationVisualizer() { const container = document.getElementById('threejs-waste-container'); const valDisplay = document.getElementById('simulated-tons-val'); if (!container) return; container.innerHTML = ` `; window.setTowerAccumulation = function(tons) { const maxTons = 9059; const pct = Math.min(1, Math.max(0.08, tons / maxTons)); const fillHeight = 170 * pct; const topY = 200 - fillHeight; const body = document.getElementById('waste-fluid-body'); const topEl = document.getElementById('waste-fluid-top'); if (body && topEl) { body.setAttribute('d', `M35,${topY} L35,200 A65,18 0 0,0 165,200 L165,${topY} A65,18 0 0,1 35,${topY} Z`); topEl.setAttribute('cy', topY); if (pct > 0.8) topEl.setAttribute('fill', '#EF4444'); else if (pct > 0.4) topEl.setAttribute('fill', '#F59E0B'); else topEl.setAttribute('fill', '#22C55E'); } if (valDisplay) { valDisplay.textContent = `${tons.toLocaleString('en-US')} Tons`; } }; window.setTowerAccumulation(9059); document.querySelectorAll('.story-card-hud').forEach(card => { card.addEventListener('mouseenter', () => { const tons = parseInt(card.getAttribute('data-tons')) || 9059; window.setTowerAccumulation(tons); }); }); } document.addEventListener('DOMContentLoaded', () => { initAccumulationVisualizer(); });