| |
| let forecastChartInstance = null; |
| let rainOverrideMode = 'auto'; |
| let popOverrideMode = 'auto'; |
|
|
| 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'; |
| } |
| } |
|
|
| |
| 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); |
|
|
| |
| 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` |
| } |
| } |
| } |
| } |
| }); |
| } |
|
|
| |
| const KECAMATAN_DATABASE = { |
| |
| "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"}, |
|
|
| |
| "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"}, |
|
|
| |
| "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"}, |
|
|
| |
| "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"}, |
|
|
| |
| "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"}, |
|
|
| |
| "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]; |
|
|
| |
| const API_BASE_URL = window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" |
| ? "" |
| : "https://alamdieng-waste-prediction-api.hf.space"; |
|
|
| |
| 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"); |
|
|
| |
| 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"); |
|
|
| |
| const statTotalVolume = document.getElementById("stat-total-volume"); |
| const statRiskStatus = document.getElementById("stat-risk-status"); |
| const statTrucks = document.getElementById("stat-trucks"); |
|
|
| |
| const statPeriodMeta = document.getElementById("stat-period-meta"); |
| const statLocationMeta = document.getElementById("stat-location-meta"); |
|
|
| |
| 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"); |
|
|
| |
| 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"); |
|
|
| |
| const timelineList = document.getElementById("timeline-list"); |
| const hourlySection = document.getElementById("hourly-section"); |
| const hourlyGrid = document.getElementById("hourly-grid"); |
|
|
| |
| let selectedLocation = "Menteng"; |
| let rainValue = 0; |
| let map; |
| let mapMarkers = {}; |
| let routeLine = null; |
|
|
| |
| |
| |
| 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"); |
| }); |
|
|
| |
| 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; |
|
|
| |
| 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(); |
|
|
| |
| function getHaversineDistance(coords1, coords2) { |
| const R = 6371; |
| 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; |
| } |
|
|
| |
| 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(); |
| }); |
| } |
|
|
| |
| 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); |
|
|
| |
| const bantarIcon = L.divIcon({ |
| className: 'leaflet-custom-marker bantar-marker', |
| html: `<div class="marker-pulse" style="background:#FF9900;opacity:0.25;"></div><div class="marker-core" style="background:#FF9900;border:2px solid #FFF;"></div><div class="marker-label" style="color:#FF9900;border-color:#FF9900;">Bantargebang</div>`, |
| iconSize: [24, 24], |
| iconAnchor: [12, 12] |
| }); |
| L.marker(BANTARGEBANG_COORDS, { icon: bantarIcon }).addTo(map).bindPopup(` |
| <div class="route-popup" style="border-left: 3px solid #FF9900;"> |
| <h3 style="color:#FF9900;">TPST BANTARGEBANG</h3> |
| <div>Disposal Facility (Bekasi)</div> |
| <div>Status: <b>Active & Calibrated</b></div> |
| </div> |
| `); |
|
|
| |
| Object.keys(KECAMATAN_DATABASE).forEach(loc => { |
| const data = KECAMATAN_DATABASE[loc]; |
| const customIcon = L.divIcon({ |
| className: 'leaflet-custom-marker', |
| html: `<div class="marker-pulse"></div><div class="marker-core"></div><div class="marker-label">${loc}</div>`, |
| 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()); |
| } |
| } |
| } |
|
|
| |
| 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; |
|
|
| |
| 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(` |
| <div class="route-popup"> |
| <h3>LOGISTICS DISPATCH ROUTE</h3> |
| <div>Origin: <b>${locName} (${KECAMATAN_DATABASE[locName]?.city || 'DKI Jakarta'})</b></div> |
| <div>Destination: <b>TPST Bantargebang (Bekasi)</b></div> |
| <div>Transit Distance: <b class="highlight">${roadDist.toFixed(1)} km</b></div> |
| <div>Est. Transit Time: <b class="highlight">${travelTimeHours.toFixed(1)} Hours</b></div> |
| </div> |
| `); |
|
|
| map.fitBounds([startCoords, BANTARGEBANG_COORDS], { |
| padding: [40, 40] |
| }); |
| } |
|
|
| |
| 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`; |
| |
| |
| 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(); |
| }); |
|
|
| |
| 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); |
| |
| 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"; |
| } |
| } |
| } |
|
|
| |
| 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); |
| |
| 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); |
| |
| |
| if (statTotalVolume) { |
| statTotalVolume.innerHTML = `${totalVolume.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} <span class="unit">Tons</span>`; |
| } |
| |
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| if (statTrucks) statTrucks.innerHTML = `${trucksCount} <span class="unit">Trucks</span>`; |
|
|
| |
| 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}%`; |
|
|
| |
| renderForecastChart(results); |
|
|
| |
| 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}%`; |
|
|
| |
| 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}%`; |
|
|
| |
| 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"; |
|
|
| |
| const eventDay = results.find(r => r.event_info !== null); |
| if (eventDay) { |
| if (eventDescText) eventDescText.innerHTML = `⚠️ <strong>${eventDay.event_info}</strong> 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)"; |
| } |
|
|
| |
| 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 = ` |
| <div class="cell-block ${intensityClass}" title="${hour.hour} — Vol: ${hour.estimated_volume_ton} Ton (${hour.risk_indicator} Risk)"></div> |
| <span class="cell-time">${hour.hour}</span> |
| `; |
| hourlyGrid.appendChild(cell); |
| }); |
| } |
|
|
| updateContextBadge(); |
| } |
|
|
| |
| 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"; |
| } |
| } |
|
|
| |
| |
| |
| async function loadNewsFeed() { |
| const newsGrid = document.getElementById("news-grid-list"); |
| if (!newsGrid) return; |
| newsGrid.innerHTML = '<div class="loading-news">Loading latest waste intelligence...</div>'; |
| |
| 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 = '<div class="loading-news">No news articles found.</div>'; |
| return; |
| } |
| news.forEach(item => { |
| const card = document.createElement("div"); |
| card.className = "news-card"; |
| card.innerHTML = ` |
| <div class="news-card-header"> |
| <span class="news-source">${item.source}</span> |
| <span class="news-date">${item.date_fetched || "2026-07-10"}</span> |
| </div> |
| <h3 class="news-title">${item.title}</h3> |
| <p class="news-summary">${item.summary}</p> |
| <a href="${item.url}" target="_blank" class="news-link">READ SOURCE <span>→</span></a> |
| `; |
| newsGrid.appendChild(card); |
| }); |
| } else { |
| newsGrid.innerHTML = '<div class="loading-news">Failed to fetch news from server.</div>'; |
| } |
| } catch (err) { |
| newsGrid.innerHTML = '<div class="loading-news">Error loading news feed.</div>'; |
| } |
| } |
|
|
| async function loadAlertsFeed() { |
| const alertsList = document.getElementById("alerts-grid-list"); |
| if (!alertsList) return; |
| alertsList.innerHTML = '<div class="loading-alerts">Evaluating regional alert parameters...</div>'; |
| |
| 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 = '<div class="loading-alerts">No active warnings. All systems green.</div>'; |
| return; |
| } |
| alertData.alerts.forEach(item => { |
| const row = document.createElement("div"); |
| row.className = "alert-row"; |
| row.innerHTML = ` |
| <span class="alert-date">${item.date}</span> |
| <span class="alert-location">${item.location}</span> |
| <span class="alert-badge ${item.status.toLowerCase()}">${item.status}</span> |
| <span class="alert-desc">${item.message} - Timbulan: <strong>${item.estimated_volume_ton.toFixed(1)} Ton</strong></span> |
| `; |
| 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 = '<div class="loading-alerts">Failed to load alerts.</div>'; |
| } |
| } catch (err) { |
| alertsList.innerHTML = '<div class="loading-alerts">Error loading alerts feed.</div>'; |
| } |
| } |
|
|
| 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 = '<div class="loading-news" style="padding:1rem;">Running neural models...</div>'; |
| 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')} <span class="unit">Tons</span>`; |
| autoTrucks.innerHTML = `${data.total_trucks.toLocaleString('en-US')} <span class="unit">Trucks (15T)</span>`; |
| |
| autoRiskList.innerHTML = ""; |
| data.top_kecamatan.forEach((item, index) => { |
| const card = document.createElement("div"); |
| card.className = "alert-row autopilot-row"; |
| card.innerHTML = ` |
| <span class="alert-date" style="font-weight:bold; color:var(--cyan);">#0${index+1}</span> |
| <span class="alert-location">${item.location}</span> |
| <span class="alert-badge ${item.status.toLowerCase()}">${item.status}</span> |
| <span class="alert-desc" style="font-size:0.8rem;">Coords: <strong>[${item.latitude.toFixed(4)}, ${item.longitude.toFixed(4)}]</strong> | Predicted: <strong>${item.volume_ton.toFixed(1)} Tons</strong> (${item.trucks} Trucks)</span> |
| `; |
| 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."); |
| } |
| } |
|
|
| |
| window.addEventListener("DOMContentLoaded", () => { |
| populateLocationDropdown(); |
| initMap(); |
| fetchLiveWeather(selectedLocation); |
| |
| |
| 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); |
| }); |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| 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"); |
| } |
| }); |
|
|
| |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| setTimeout(() => { |
| if (gameArea) { |
| gameArea.style.border = "1px solid var(--border-color)"; |
| gameArea.style.boxShadow = "none"; |
| } |
| }, 800); |
| |
| |
| gameItemIndex = (gameItemIndex + 1) % WASTE_ITEMS.length; |
| setTimeout(loadNextWasteItem, 1000); |
| } |
|
|
| |
| window.sortWaste = sortWaste; |
|
|
| |
| document.addEventListener("DOMContentLoaded", () => { |
| loadNextWasteItem(); |
| initCrisisStoryScroller(); |
| init3DScene(); |
| }); |
|
|
| |
| |
| |
| function initCrisisStoryScroller() { |
| const storyCards = document.querySelectorAll(".story-card"); |
| const tonsVal = document.getElementById("simulated-tons-val"); |
| |
| if (!storyCards.length || !tonsVal) return; |
|
|
| |
| const observerOptions = { |
| root: null, |
| rootMargin: "-25% 0px -40% 0px", |
| threshold: 0.1 |
| }; |
|
|
| const observer = new IntersectionObserver((entries) => { |
| entries.forEach(entry => { |
| if (entry.isIntersecting) { |
| |
| 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)"; |
| |
| |
| const targetHeight = entry.target.getAttribute("data-height"); |
| const targetTons = entry.target.getAttribute("data-tons"); |
| |
| |
| let colorHex = 0x548235; |
| if (targetHeight === "55") { |
| colorHex = 0xC59124; |
| } else if (targetHeight === "95") { |
| colorHex = 0xC53929; |
| } |
| |
| |
| update3DHeight(parseInt(targetHeight), colorHex); |
| |
| |
| 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; |
| const startTime = performance.now(); |
| |
| function update(now) { |
| const elapsed = now - startTime; |
| const progress = Math.min(elapsed / duration, 1); |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| let scene3D, camera3D, renderer3D; |
| let siloMesh, wasteMesh, garbageGroup; |
| let isTabActive = true; |
| let target3DHeight = 0.1; |
| 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; |
| |
| |
| scene3D = new THREE.Scene(); |
| |
| |
| camera3D = new THREE.PerspectiveCamera(45, width / height, 0.1, 100); |
| camera3D.position.set(0, 0.4, 3.5); |
| |
| |
| renderer3D = new THREE.WebGLRenderer({ antialias: true, alpha: true }); |
| renderer3D.setSize(width, height); |
| renderer3D.setPixelRatio(Math.min(window.devicePixelRatio, 2)); |
| container.appendChild(renderer3D.domElement); |
| |
| |
| 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); |
| |
| |
| 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); |
| |
| |
| const wasteGeo = new THREE.CylinderGeometry(0.66, 0.66, 2, 24, 1); |
| wasteGeo.translate(0, 1, 0); |
| |
| 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; |
| wasteMesh.scale.y = 0.1; |
| scene3D.add(wasteMesh); |
| |
| |
| 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, |
| flatShading: true, |
| transparent: true, |
| opacity: 0.85 |
| }); |
| const mesh = new THREE.Mesh(randomGeo, randomMat); |
| |
| |
| 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); |
| } |
| |
| |
| window.addEventListener("resize", () => { |
| if (!container) return; |
| const w = container.clientWidth; |
| const h = container.clientHeight; |
| camera3D.aspect = w / h; |
| camera3D.updateProjectionMatrix(); |
| renderer3D.setSize(w, h); |
| }); |
| |
| |
| animate3D(); |
| } |
|
|
| function update3DHeight(percent, colorHex) { |
| target3DHeight = Math.max(0.05, percent / 100); |
| targetColorHex = colorHex; |
| } |
|
|
| function animate3D() { |
| requestAnimationFrame(animate3D); |
| |
| |
| 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) { |
| |
| current3DHeight += (target3DHeight - current3DHeight) * 0.08; |
| wasteMesh.scale.y = current3DHeight; |
| |
| |
| wasteMesh.material.color.lerp(new THREE.Color(targetColorHex), 0.08); |
| |
| |
| if (garbageGroup) { |
| garbageGroup.children.forEach((child, idx) => { |
| |
| if (child.position.y > current3DHeight * 2) { |
| child.position.y -= 0.008; |
| } else if (child.position.y < 0.05) { |
| child.position.y += 0.008; |
| } |
| |
| child.position.y += Math.sin(Date.now() * 0.001 + idx) * 0.0005; |
| |
| child.rotation.x += 0.004; |
| child.rotation.y += 0.004; |
| }); |
| } |
| } |
| |
| |
| 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); |
| } |
|
|
| |
| 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"); |
| } |
| }; |
|
|
| |
| 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"; |
| } |
| }); |
|
|
| |
| 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(); |
| }); |
| } |
| }); |
|
|
|
|
| |
| |
| |
| function initAccumulationVisualizer() { |
| const container = document.getElementById('threejs-waste-container'); |
| const valDisplay = document.getElementById('simulated-tons-val'); |
| if (!container) return; |
|
|
| container.innerHTML = ` |
| <svg viewBox="0 0 200 240" style="width:100%; height:100%; overflow:visible;"> |
| <defs> |
| <linearGradient id="wasteGrad" x1="0%" y1="100%" x2="0%" y2="0%"> |
| <stop offset="0%" stop-color="#22C55E" stop-opacity="0.85" /> |
| <stop offset="55%" stop-color="#F59E0B" stop-opacity="0.9" /> |
| <stop offset="100%" stop-color="#EF4444" stop-opacity="0.95" /> |
| </linearGradient> |
| <linearGradient id="cylinderBg" x1="0%" y1="0%" x2="100%" y2="0%"> |
| <stop offset="0%" stop-color="rgba(255,255,255,0.03)" /> |
| <stop offset="50%" stop-color="rgba(255,255,255,0.08)" /> |
| <stop offset="100%" stop-color="rgba(255,255,255,0.02)" /> |
| </linearGradient> |
| </defs> |
| <ellipse cx="100" cy="30" rx="65" ry="18" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.5" stroke-dasharray="3,3" /> |
| <path d="M35,30 L35,200 A65,18 0 0,0 165,200 L165,30" fill="url(#cylinderBg)" stroke="rgba(255,255,255,0.2)" stroke-width="1.5" /> |
| <ellipse cx="100" cy="200" rx="65" ry="18" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1.5" /> |
| |
| <ellipse cx="100" cy="65" rx="65" ry="18" fill="none" stroke="rgba(239,68,68,0.3)" stroke-width="1" stroke-dasharray="2,2" /> |
| <ellipse cx="100" cy="115" rx="65" ry="18" fill="none" stroke="rgba(245,158,11,0.3)" stroke-width="1" stroke-dasharray="2,2" /> |
| <ellipse cx="100" cy="165" rx="65" ry="18" fill="none" stroke="rgba(34,197,94,0.3)" stroke-width="1" stroke-dasharray="2,2" /> |
| |
| <path id="waste-fluid-body" d="M35,200 L35,200 A65,18 0 0,0 165,200 L165,200 Z" fill="url(#wasteGrad)" opacity="0.8" style="transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);" /> |
| <ellipse id="waste-fluid-top" cx="100" cy="200" rx="65" ry="18" fill="#EF4444" opacity="0.9" style="transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);" /> |
| </svg> |
| `; |
|
|
| 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(); }); |
|
|