2025-12-22 07:20:41 +07:00
<!DOCTYPE html>
< html lang = "vi" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > NDVI Time Series Analysis</ title >
<!-- Leaflet CSS -->
< link rel = "stylesheet" href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
< link rel = "stylesheet" href = "https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
< style >
* {
margin : 0 ;
padding : 0 ;
box-sizing : border-box ;
}
body {
font-family : 'Segoe UI' , Tahoma , Geneva , Verdana , sans-serif ;
background : linear-gradient ( 135 deg , #2ecc71 0 % , #27ae60 100 % );
padding : 20 px ;
min-height : 100 vh ;
}
. container {
max-width : 1400 px ;
margin : 0 auto ;
background : white ;
border-radius : 20 px ;
box-shadow : 0 20 px 60 px rgba ( 0 , 0 , 0 , 0.3 );
overflow : hidden ;
}
. header {
background : linear-gradient ( 135 deg , #2ecc71 0 % , #27ae60 100 % );
color : white ;
padding : 30 px ;
text-align : center ;
}
. header h1 {
font-size : 2.5 em ;
margin-bottom : 10 px ;
}
. nav-buttons {
background : white ;
padding : 15 px ;
display : flex ;
gap : 10 px ;
flex-wrap : wrap ;
justify-content : center ;
border-bottom : 2 px solid #e0e0e0 ;
}
. nav-btn {
padding : 10 px 20 px ;
border : none ;
border-radius : 8 px ;
font-size : 1 em ;
cursor : pointer ;
transition : all 0.3 s ;
text-decoration : none ;
display : inline-block ;
}
. content {
padding : 30 px ;
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 30 px ;
}
. section {
background : #f8f9fa ;
padding : 20 px ;
border-radius : 10 px ;
}
. section h2 {
color : #27ae60 ;
margin-bottom : 15 px ;
}
. form-group {
margin-bottom : 15 px ;
}
. form-group label {
display : block ;
margin-bottom : 5 px ;
color : #333 ;
font-weight : 600 ;
}
. form-group input , . form-group select {
width : 100 % ;
padding : 10 px ;
border : 2 px solid #e0e0e0 ;
border-radius : 5 px ;
font-size : 1 em ;
}
. btn {
padding : 12 px 30 px ;
border : none ;
border-radius : 5 px ;
font-size : 1 em ;
font-weight : 600 ;
cursor : pointer ;
transition : all 0.3 s ;
}
. btn-primary {
background : linear-gradient ( 135 deg , #2ecc71 0 % , #27ae60 100 % );
color : white ;
}
. btn-success {
background : #28a745 ;
color : white ;
}
. btn : hover {
transform : translateY ( -2 px );
box-shadow : 0 5 px 15 px rgba ( 0 , 0 , 0 , 0.3 );
}
. btn : disabled {
opacity : 0.5 ;
cursor : not-allowed ;
}
# map {
height : 400 px ;
border-radius : 10 px ;
margin-top : 15 px ;
}
# chartContainer {
background : white ;
padding : 20 px ;
border-radius : 10 px ;
grid-column : 1 / -1 ;
}
. stats-grid {
display : grid ;
grid-template-columns : repeat ( auto - fit , minmax ( 200 px , 1 fr ));
gap : 15 px ;
margin-top : 20 px ;
}
. stat-card {
background : white ;
padding : 15 px ;
border-radius : 8 px ;
border-left : 4 px solid #27ae60 ;
}
. stat-card h3 {
color : #666 ;
font-size : 0.9 em ;
margin-bottom : 5 px ;
}
. stat-card p {
color : #27ae60 ;
font-size : 1.5 em ;
font-weight : bold ;
}
. loading {
text-align : center ;
padding : 20 px ;
color : #666 ;
}
. alert {
padding : 15 px ;
border-radius : 5 px ;
margin-bottom : 20 px ;
}
. alert-info {
background : #d1ecf1 ;
border-left : 4 px solid #0c5460 ;
color : #0c5460 ;
}
. alert-warning {
background : #fff3cd ;
border-left : 4 px solid #856404 ;
color : #856404 ;
}
</ style >
</ head >
< body >
< div class = "container" >
< div class = "header" >
< h1 > 🌿 NDVI Time Series Analysis</ h1 >
< p > Phân tích chỉ số thực vật theo thời gian từ dữ liệu Sentinel-2</ p >
</ div >
< div style = "background: white; padding: 15px; display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; border-bottom: 2px solid #e0e0e0;" >
< a href = "/" style = "padding: 10px 20px; background: #667eea; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;" > 🏠 Trang Chủ</ a >
< a href = "/training" style = "padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;" > 🎓 Training</ a >
< a href = "/prediction" style = "padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;" > 🗺️ Prediction</ a >
< a href = "/batch" style = "padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;" > 🚀 Batch Processing</ a >
< a href = "/ndvi" style = "padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;" > 🌿 NDVI Analysis (Active)</ a >
2025-12-22 15:43:23 +07:00
< a href = "/reports" style = "padding: 10px 20px; background: #ff6b6b; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;" > 📝 Reports</ a >
2025-12-22 07:20:41 +07:00
</ div >
< div class = "content" >
<!-- Configuration Section -->
< div class = "section" >
2026-01-05 11:19:34 +07:00
< h2 > ⚙️ Cấu hình dự đoán NDVI</ h2 >
< div class = "form-group" >
< label > 🎯 Chế độ:</ label >
< div style = "display: flex; gap: 10px; margin-bottom: 10px;" >
< button type = "button" class = "btn btn-sm" id = "btnHistoricalMode" onclick = "switchMode('historical')" style = "padding: 8px 20px; background: #667eea; color: white; border: none; border-radius: 5px;" > 📊 Phân tích lịch sử</ button >
< button type = "button" class = "btn btn-sm" id = "btnForecastMode" onclick = "switchMode('forecast')" style = "padding: 8px 20px; background: #ccc; color: #666; border: none; border-radius: 5px;" > 🔮 Dự đoán tương lai</ button >
</ div >
< div id = "modeDescription" style = "font-size: 0.85em; color: #666; margin-top: 5px;" > 📊 Sử dụng ML model để phân tích NDVI từ dữ liệu vệ tinh lịch sử</ div >
</ div >
< div class = "form-group" id = "modelSelectGroup" >
< label for = "modelSelect" > 🤖 Chọn Model (tùy chọn):</ label >
< select id = "modelSelect" class = "form-control" >
< option value = "" > Đang tải models...</ option >
</ select >
< small style = "color: #666; font-size: 0.85em;" > 💡 Chọn model để tăng độ chính xác với land-type-specific forecasting</ small >
</ div >
< div class = "form-group" id = "historicalMonthsGroup" style = "display: none;" >
< label for = "historicalMonths" > 📅 Số tháng lịch sử để tính pattern:</ label >
< input type = "number" id = "historicalMonths" value = "12" min = "6" max = "36" step = "3" >
< small style = "color: #666;" > ⚠️ Khuyến nghị 12-18 tháng. Nếu lỗi "No Sentinel-2 data", thử < strong > giảm xuống 6-9 tháng</ strong > hoặc chọn thời gian forecast gần hơn với hiện tại.</ small >
</ div >
<!-- Province Selection -->
< div class = "form-group" >
< label > 🗺️ Chọn tỉnh thành nhanh:</ label >
< div style = "display: flex; gap: 10px; margin-bottom: 10px;" >
< button type = "button" class = "btn btn-sm" id = "btn63Provinces" onclick = "switchProvinceList('63')" style = "padding: 5px 15px; background: #667eea; color: white; border: none; border-radius: 5px;" > 63 tỉnh</ button >
< button type = "button" class = "btn btn-sm" id = "btn32Provinces" onclick = "switchProvinceList('32')" style = "padding: 5px 15px; background: #ccc; color: #666; border: none; border-radius: 5px;" > 32 tỉnh (mới)</ button >
< span id = "provinceListMode" style = "align-self: center; font-size: 0.9em; color: #666;" > Danh sách: 63 tỉnh</ span >
</ div >
<!-- Region Filters -->
< div id = "regionFilterContainer" style = "display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;" >
< button type = "button" class = "region-filter-btn" data-region = "all" style = "padding: 3px 10px; background: #667eea; color: white; border: none; border-radius: 15px; font-size: 0.85em; cursor: pointer;" > Tất cả</ button >
</ div >
< select id = "provinceSelect" style = "width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 5px;" >
< option value = "" > -- Chọn tỉnh thành --</ option >
</ select >
< div id = "provinceDisplay" style = "margin-top: 5px; color: #667eea; font-weight: 600;" ></ div >
</ div >
2025-12-22 07:20:41 +07:00
< div class = "form-group" >
< label > Bbox (vẽ trên bản đồ hoặc nhập thủ công):</ label >
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: 10px;" >
< input type = "number" id = "minLon" placeholder = "Min Longitude" step = "0.0001" value = "105.8" >
< input type = "number" id = "minLat" placeholder = "Min Latitude" step = "0.0001" value = "9.4" >
< input type = "number" id = "maxLon" placeholder = "Max Longitude" step = "0.0001" value = "106.0" >
< input type = "number" id = "maxLat" placeholder = "Max Latitude" step = "0.0001" value = "9.6" >
</ div >
2026-01-05 11:19:34 +07:00
< div id = "bboxDisplay" style = "margin-top: 5px; font-size: 0.9em; color: #666;" ></ div >
2025-12-22 07:20:41 +07:00
</ div >
< div class = "form-group" >
2026-01-05 11:19:34 +07:00
< label id = "timePeriodLabel" > Khoảng thời gian:</ label >
2025-12-22 07:20:41 +07:00
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: 10px;" >
2026-01-05 11:19:34 +07:00
< input type = "date" id = "startDate" value = "2024-01-01" >
< input type = "date" id = "endDate" value = "2024-12-31" >
2025-12-22 07:20:41 +07:00
</ div >
2026-01-05 11:19:34 +07:00
< small id = "timeHint" style = "color: #666; font-size: 0.85em;" > 📅 Chọn thời gian trong quá khứ</ small >
2025-12-22 07:20:41 +07:00
</ div >
< div class = "form-group" >
< label for = "maxCloudCover" > Cloud Cover tối đa (%):</ label >
< input type = "number" id = "maxCloudCover" value = "30" min = "0" max = "100" >
</ div >
2026-01-05 11:19:34 +07:00
< div class = "form-group" >
< label for = "maxScenes" > Số lượng ảnh tối đa:</ label >
< input type = "number" id = "maxScenes" value = "12" min = "1" max = "50" >
</ div >
< div class = "form-group" >
< label for = "samplePoints" > Số điểm mẫu để dự đoán:</ label >
< input type = "number" id = "samplePoints" value = "500" min = "100" max = "5000" step = "100" >
</ div >
2025-12-22 07:20:41 +07:00
< div class = "form-group" >
< label for = "resolution" > Độ phân giải (m):</ label >
< select id = "resolution" >
< option value = "10" > 10m (chi tiết cao)</ option >
< option value = "20" selected > 20m (cân bằng)</ option >
< option value = "60" > 60m (nhanh)</ option >
</ select >
</ div >
2026-01-05 11:19:34 +07:00
< div class = "alert alert-info" id = "methodAlert" >
< strong > 🤖 Phương pháp:</ strong > Sử dụng ML model để phân tích NDVI từ dữ liệu vệ tinh< br >
< strong > 💡 Lưu ý:</ strong > Model extract features từ Sentinel-2, sau đó phân tích NDVI theo thời gian.< br >
< strong > 📊 Dữ liệu:</ strong > Cần dữ liệu vệ tinh lịch sử để tính toán.
2025-12-22 07:20:41 +07:00
</ div >
2026-01-05 11:19:34 +07:00
< button class = "btn btn-primary" onclick = "executeAnalysis()" id = "calculateBtn" >
📊 Phân tích NDVI Time Series
2025-12-22 07:20:41 +07:00
</ button >
<!-- Map for selecting bbox -->
< div id = "map" ></ div >
</ div >
<!-- Results Section -->
< div class = "section" >
< h2 > 📈 Kết quả phân tích</ h2 >
< div id = "loadingIndicator" style = "display: none;" class = "loading" >
< p > ⏳ Đang tải dữ liệu và tính toán NDVI...</ p >
< div style = "margin-top: 10px;" >
< div style = "width: 100%; height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden;" >
< div id = "progressBar" style = "width: 0%; height: 100%; background: linear-gradient(90deg, #2ecc71, #27ae60); transition: width 0.3s;" ></ div >
</ div >
< p id = "progressText" style = "margin-top: 5px; font-size: 0.9em;" ></ p >
</ div >
</ div >
< div id = "resultsContainer" style = "display: none;" >
< div class = "stats-grid" >
< div class = "stat-card" >
< h3 > Số lượng ảnh</ h3 >
< p id = "statImages" > -</ p >
</ div >
< div class = "stat-card" >
< h3 > NDVI trung bình</ h3 >
< p id = "statAvgNDVI" > -</ p >
</ div >
< div class = "stat-card" >
< h3 > NDVI min</ h3 >
< p id = "statMinNDVI" > -</ p >
</ div >
< div class = "stat-card" >
< h3 > NDVI max</ h3 >
< p id = "statMaxNDVI" > -</ p >
</ div >
</ div >
< div style = "margin-top: 20px;" >
< button class = "btn btn-success" onclick = "downloadData()" >
💾 Download CSV
</ button >
< button class = "btn btn-primary" onclick = "downloadChart()" style = "margin-left: 10px;" >
📸 Download Chart (PNG)
</ button >
</ div >
</ div >
< div id = "errorContainer" style = "display: none;" class = "alert alert-warning" >
< strong > ⚠️ Lỗi:</ strong > < span id = "errorMessage" ></ span >
</ div >
</ div >
<!-- Chart Section -->
< div id = "chartContainer" style = "display: none;" >
< h2 style = "color: #27ae60; margin-bottom: 20px;" > 📊 NDVI Time Series</ h2 >
2026-01-05 11:19:34 +07:00
< div style = "background: white; padding: 20px; border-radius: 10px;" >
< canvas id = "ndviChart" ></ canvas >
</ div >
<!-- Chart as Image Preview -->
< div id = "chartImageContainer" style = "margin-top: 20px; display: none;" >
< h3 style = "color: #27ae60; margin-bottom: 10px;" > 📸 Chart Preview (Image)</ h3 >
< img id = "chartImage" style = "width: 100%; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.1);" />
</ div >
2025-12-22 07:20:41 +07:00
</ div >
</ div >
</ div >
2026-01-05 11:19:34 +07:00
<!-- Leaflet JS -->
2025-12-22 07:20:41 +07:00
< script src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" ></ script >
< script src = "https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js" ></ script >
< script src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" ></ script >
< script >
2026-01-05 11:19:34 +07:00
let map , drawnItems , drawControl , currentRectangle ;
2025-12-22 07:20:41 +07:00
let ndviData = null ;
let ndviChart = null ;
2026-01-05 11:19:34 +07:00
let availableModels = [];
let allProvinces = {};
let allProvincesMerged = {};
let currentProvinceName = '' ;
let currentProvinceMode = '63' ;
let currentAnalysisMode = 'historical' ;
// Switch between historical and forecast mode
function switchMode ( mode ) {
currentAnalysisMode = mode ;
const btnHistorical = document . getElementById ( 'btnHistoricalMode' );
const btnForecast = document . getElementById ( 'btnForecastMode' );
const modeDesc = document . getElementById ( 'modeDescription' );
const modelGroup = document . getElementById ( 'modelSelectGroup' );
const historicalGroup = document . getElementById ( 'historicalMonthsGroup' );
const methodAlert = document . getElementById ( 'methodAlert' );
const calculateBtn = document . getElementById ( 'calculateBtn' );
const timeLabel = document . getElementById ( 'timePeriodLabel' );
const timeHint = document . getElementById ( 'timeHint' );
if ( mode === 'historical' ) {
btnHistorical . style . background = '#667eea' ;
btnHistorical . style . color = 'white' ;
btnForecast . style . background = '#ccc' ;
btnForecast . style . color = '#666' ;
modeDesc . textContent = '📊 Sử dụng ML model để phân tích NDVI từ dữ liệu vệ tinh lịch sử' ;
modelGroup . style . display = 'block' ;
historicalGroup . style . display = 'none' ;
methodAlert . innerHTML = '<strong>🤖 Phương pháp:</strong> Sử dụng ML model để phân tích NDVI từ dữ liệu vệ tinh<br>' +
'<strong>💡 Lưu ý:</strong> Model extract features từ Sentinel-2, sau đó phân tích NDVI theo thời gian.<br>' +
'<strong>📊 Dữ liệu:</strong> Cần dữ liệu vệ tinh lịch sử để tính toán.' ;
calculateBtn . innerHTML = '📊 Phân tích NDVI Time Series' ;
timeLabel . textContent = 'Khoảng thời gian phân tích:' ;
timeHint . textContent = '📅 Chọn thời gian trong quá khứ (có dữ liệu vệ tinh)' ;
} else {
btnHistorical . style . background = '#ccc' ;
btnHistorical . style . color = '#666' ;
btnForecast . style . background = '#27ae60' ;
btnForecast . style . color = 'white' ;
modeDesc . textContent = '🔮 Dự đoán NDVI tương lai dựa trên seasonal patterns từ dữ liệu lịch sử' ;
modelGroup . style . display = 'block' ;
historicalGroup . style . display = 'block' ;
methodAlert . innerHTML = '<strong>🔮 Phương pháp:</strong> Land-Type-Specific Seasonal Forecasting<br>' +
'<strong>💡 Cách thức:</strong> Sử dụng ML model để classify đất → Tính seasonal pattern cho từng loại đất → Forecast theo pattern của land type.<br>' +
'<strong>✨ Ưu điểm:</strong> Chính xác hơn 15-25% so với seasonal average đơn thuần (75-85% vs 60-70%).<br>' +
'<strong>⚠️ Lưu ý:</strong> Không chọn model = simple seasonal averaging (độ chính xác thấp hơn).' ;
calculateBtn . innerHTML = '🔮 Dự đoán NDVI Tương Lai' ;
timeLabel . textContent = 'Khoảng thời gian dự đoán:' ;
timeHint . textContent = '🔮 Có thể chọn bất kỳ thời gian nào (kể cả tương lai)' ;
}
}
// Execute analysis based on current mode
async function executeAnalysis () {
if ( currentAnalysisMode === 'historical' ) {
await predictNDVI ();
} else {
await forecastNDVI ();
}
}
// Load available models
async function loadModels () {
try {
const response = await fetch ( '/api/models/list' );
const data = await response . json ();
availableModels = data . models || [];
const modelSelect = document . getElementById ( 'modelSelect' );
modelSelect . innerHTML = '<option value="">-- Chọn model --</option>' ;
availableModels . forEach ( model => {
const option = document . createElement ( 'option' );
option . value = model . filename ;
option . textContent = ` ${ model . filename } ( ${ model . feature_mode || 'unknown' } mode, ${ model . accuracy } % accuracy)` ;
modelSelect . appendChild ( option );
});
} catch ( error ) {
console . error ( 'Error loading models:' , error );
document . getElementById ( 'modelSelect' ). innerHTML = '<option value="">Lỗi tải models</option>' ;
}
}
2025-12-22 07:20:41 +07:00
// Initialize map
function initMap () {
map = L . map ( 'map' ). setView ([ 9.5 , 105.9 ], 9 );
L . tileLayer ( 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' , {
attribution : '© OpenStreetMap contributors'
}). addTo ( map );
drawnItems = new L . FeatureGroup ();
map . addLayer ( drawnItems );
drawControl = new L . Control . Draw ({
draw : {
rectangle : true ,
polygon : false ,
circle : false ,
marker : false ,
polyline : false ,
circlemarker : false
},
edit : {
featureGroup : drawnItems ,
remove : true
}
});
map . addControl ( drawControl );
map . on ( L . Draw . Event . CREATED , function ( event ) {
drawnItems . clearLayers ();
const layer = event . layer ;
drawnItems . addLayer ( layer );
const bounds = layer . getBounds ();
document . getElementById ( 'minLon' ). value = bounds . getWest (). toFixed ( 4 );
document . getElementById ( 'minLat' ). value = bounds . getSouth (). toFixed ( 4 );
document . getElementById ( 'maxLon' ). value = bounds . getEast (). toFixed ( 4 );
document . getElementById ( 'maxLat' ). value = bounds . getNorth (). toFixed ( 4 );
});
}
2026-01-05 11:19:34 +07:00
// Forecast NDVI for future dates
async function forecastNDVI () {
const modelFilename = document . getElementById ( 'modelSelect' ). value ;
const minLon = parseFloat ( document . getElementById ( 'minLon' ). value );
const minLat = parseFloat ( document . getElementById ( 'minLat' ). value );
const maxLon = parseFloat ( document . getElementById ( 'maxLon' ). value );
const maxLat = parseFloat ( document . getElementById ( 'maxLat' ). value );
const startDate = document . getElementById ( 'startDate' ). value ;
const endDate = document . getElementById ( 'endDate' ). value ;
const historicalMonths = parseInt ( document . getElementById ( 'historicalMonths' ). value );
const resolution = parseInt ( document . getElementById ( 'resolution' ). value );
const maxCloudCover = parseInt ( document . getElementById ( 'maxCloudCover' ). value );
const samplePoints = parseInt ( document . getElementById ( 'samplePoints' ). value );
// Validate inputs
if ( isNaN ( minLon ) || isNaN ( minLat ) || isNaN ( maxLon ) || isNaN ( maxLat )) {
showError ( 'Vui lòng nhập đầy đủ tọa độ bbox!' );
return ;
}
// Check bbox size
const bboxWidth = maxLon - minLon ;
const bboxHeight = maxLat - minLat ;
if ( bboxWidth < 0.01 || bboxHeight < 0.01 ) {
showError ( '⚠️ Bbox quá nhỏ! Vui lòng chọn khu vực lớn hơn (ít nhất 0.01 độ). Bbox nhỏ có thể không có đủ điểm hợp lệ để phân tích.' );
return ;
}
if ( ! startDate || ! endDate ) {
showError ( 'Vui lòng chọn khoảng thời gian!' );
return ;
}
// Show loading
document . getElementById ( 'loadingIndicator' ). style . display = 'block' ;
document . getElementById ( 'resultsContainer' ). style . display = 'none' ;
document . getElementById ( 'chartContainer' ). style . display = 'none' ;
document . getElementById ( 'errorContainer' ). style . display = 'none' ;
document . getElementById ( 'calculateBtn' ). disabled = true ;
const config = {
bbox : [ minLon , minLat , maxLon , maxLat ],
forecast_start_date : startDate ,
forecast_end_date : endDate ,
historical_months : historicalMonths ,
resolution : resolution ,
max_cloud_cover : maxCloudCover ,
model_filename : modelFilename || null ,
sample_points : samplePoints
};
try {
const response = await fetch ( '/api/ndvi/forecast' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( config )
});
const result = await response . json ();
if ( response . ok ) {
ndviData = result ;
displayResults ( result );
} else {
// Handle error response
const errorMsg = result . detail || result . message || JSON . stringify ( result );
showError ( errorMsg );
}
} catch ( error ) {
console . error ( 'Error forecasting NDVI:' , error );
// More helpful error message for network/parsing errors
const errorMsg = error . message || error . toString ();
showError ( `Lỗi kết nối hoặc xử lý: ${ errorMsg } . Vui lòng kiểm tra console để biết chi tiết.` );
} finally {
document . getElementById ( 'loadingIndicator' ). style . display = 'none' ;
document . getElementById ( 'calculateBtn' ). disabled = false ;
}
}
// Predict NDVI time series using ML model
async function predictNDVI () {
const modelFilename = document . getElementById ( 'modelSelect' ). value ;
2025-12-22 07:20:41 +07:00
const minLon = parseFloat ( document . getElementById ( 'minLon' ). value );
const minLat = parseFloat ( document . getElementById ( 'minLat' ). value );
const maxLon = parseFloat ( document . getElementById ( 'maxLon' ). value );
const maxLat = parseFloat ( document . getElementById ( 'maxLat' ). value );
const startDate = document . getElementById ( 'startDate' ). value ;
const endDate = document . getElementById ( 'endDate' ). value ;
const maxCloudCover = parseInt ( document . getElementById ( 'maxCloudCover' ). value );
2026-01-05 11:19:34 +07:00
const maxScenes = parseInt ( document . getElementById ( 'maxScenes' ). value );
const samplePoints = parseInt ( document . getElementById ( 'samplePoints' ). value );
2025-12-22 07:20:41 +07:00
const resolution = parseInt ( document . getElementById ( 'resolution' ). value );
// Validate inputs
2026-01-05 11:19:34 +07:00
if ( ! modelFilename ) {
showError ( 'Vui lòng chọn model để dự đoán!' );
return ;
}
2025-12-22 07:20:41 +07:00
if ( isNaN ( minLon ) || isNaN ( minLat ) || isNaN ( maxLon ) || isNaN ( maxLat )) {
showError ( 'Vui lòng nhập đầy đủ tọa độ bbox!' );
return ;
}
if ( ! startDate || ! endDate ) {
showError ( 'Vui lòng chọn khoảng thời gian!' );
return ;
}
// Show loading
document . getElementById ( 'loadingIndicator' ). style . display = 'block' ;
document . getElementById ( 'resultsContainer' ). style . display = 'none' ;
document . getElementById ( 'chartContainer' ). style . display = 'none' ;
document . getElementById ( 'errorContainer' ). style . display = 'none' ;
document . getElementById ( 'calculateBtn' ). disabled = true ;
const config = {
2026-01-05 11:19:34 +07:00
model_filename : modelFilename ,
2025-12-22 07:20:41 +07:00
bbox : [ minLon , minLat , maxLon , maxLat ],
start_date : startDate ,
end_date : endDate ,
max_cloud_cover : maxCloudCover ,
2026-01-05 11:19:34 +07:00
max_scenes : maxScenes ,
resolution : resolution ,
sample_points : samplePoints ,
use_gpu : false
2025-12-22 07:20:41 +07:00
};
try {
2026-01-05 11:19:34 +07:00
const response = await fetch ( '/api/ndvi/predict-timeseries' , {
2025-12-22 07:20:41 +07:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( config )
});
const result = await response . json ();
if ( response . ok ) {
ndviData = result ;
displayResults ( result );
} else {
2026-01-05 11:19:34 +07:00
throw new Error ( result . detail || 'Lỗi khi dự đoán NDVI' );
2025-12-22 07:20:41 +07:00
}
} catch ( error ) {
2026-01-05 11:19:34 +07:00
console . error ( 'Error predicting NDVI:' , error );
2025-12-22 07:20:41 +07:00
showError ( error . message );
} finally {
document . getElementById ( 'loadingIndicator' ). style . display = 'none' ;
document . getElementById ( 'calculateBtn' ). disabled = false ;
}
}
// Display results
function displayResults ( data ) {
2026-01-05 11:19:34 +07:00
console . log ( 'Displaying results:' , data );
2025-12-22 07:20:41 +07:00
document . getElementById ( 'resultsContainer' ). style . display = 'block' ;
document . getElementById ( 'chartContainer' ). style . display = 'block' ;
2026-01-05 11:19:34 +07:00
// Update stats - handle both n_images (historical) and n_forecast_points (forecast)
const nImages = data . n_images || data . n_forecast_points || 0 ;
document . getElementById ( 'statImages' ). textContent = nImages ;
2025-12-22 07:20:41 +07:00
document . getElementById ( 'statAvgNDVI' ). textContent = data . mean_ndvi . toFixed ( 3 );
document . getElementById ( 'statMinNDVI' ). textContent = data . min_ndvi . toFixed ( 3 );
document . getElementById ( 'statMaxNDVI' ). textContent = data . max_ndvi . toFixed ( 3 );
// Create chart
createChart ( data );
}
// Create NDVI time series chart
function createChart ( data ) {
const ctx = document . getElementById ( 'ndviChart' ). getContext ( '2d' );
// Destroy existing chart if any
if ( ndviChart ) {
ndviChart . destroy ();
}
2026-01-05 11:19:34 +07:00
// Format dates with more detail (dd/MM/yyyy)
const dates = data . timeseries . map ( item => {
const d = new Date ( item . date );
const day = d . getDate (). toString (). padStart ( 2 , '0' );
const month = ( d . getMonth () + 1 ). toString (). padStart ( 2 , '0' );
const year = d . getFullYear ();
return ` ${ day } / ${ month } / ${ year } ` ;
});
const ndviValues = data . timeseries . map ( item => item . mean_ndvi );
const minValues = data . timeseries . map ( item => item . min_ndvi );
const maxValues = data . timeseries . map ( item => item . max_ndvi );
2025-12-22 07:20:41 +07:00
ndviChart = new Chart ( ctx , {
type : 'line' ,
data : {
labels : dates ,
datasets : [{
2026-01-05 11:19:34 +07:00
label : 'NDVI Mean' ,
2025-12-22 07:20:41 +07:00
data : ndviValues ,
borderColor : '#27ae60' ,
2026-01-05 11:19:34 +07:00
backgroundColor : 'rgba(46, 204, 113, 0.2)' ,
borderWidth : 3 ,
2025-12-22 07:20:41 +07:00
fill : true ,
tension : 0.4 ,
2026-01-05 11:19:34 +07:00
pointRadius : 5 ,
pointHoverRadius : 8 ,
2025-12-22 07:20:41 +07:00
pointBackgroundColor : '#27ae60' ,
pointBorderColor : '#fff' ,
pointBorderWidth : 2
2026-01-05 11:19:34 +07:00
}, {
label : 'NDVI Min' ,
data : minValues ,
borderColor : '#e74c3c' ,
backgroundColor : 'rgba(231, 76, 60, 0.1)' ,
borderWidth : 2 ,
borderDash : [ 5 , 5 ],
fill : false ,
tension : 0.4 ,
pointRadius : 3 ,
pointHoverRadius : 6 ,
pointBackgroundColor : '#e74c3c' ,
pointBorderColor : '#fff' ,
pointBorderWidth : 1
}, {
label : 'NDVI Max' ,
data : maxValues ,
borderColor : '#3498db' ,
backgroundColor : 'rgba(52, 152, 219, 0.1)' ,
borderWidth : 2 ,
borderDash : [ 5 , 5 ],
fill : false ,
tension : 0.4 ,
pointRadius : 3 ,
pointHoverRadius : 6 ,
pointBackgroundColor : '#3498db' ,
pointBorderColor : '#fff' ,
pointBorderWidth : 1
2025-12-22 07:20:41 +07:00
}]
},
options : {
responsive : true ,
maintainAspectRatio : true ,
plugins : {
title : {
display : true ,
text : 'NDVI Time Series - Biến động chỉ số thực vật theo thời gian' ,
font : {
size : 16 ,
weight : 'bold'
}
},
legend : {
display : true ,
position : 'top'
},
tooltip : {
callbacks : {
label : function ( context ) {
return `NDVI: ${ context . parsed . y . toFixed ( 3 ) } ` ;
}
}
}
},
scales : {
y : {
beginAtZero : false ,
title : {
display : true ,
text : 'NDVI Value'
},
grid : {
color : 'rgba(0, 0, 0, 0.05)'
}
},
x : {
title : {
display : true ,
text : 'Ngày'
},
grid : {
2026-01-05 11:19:34 +07:00
display : true ,
color : 'rgba(0, 0, 0, 0.05)'
},
ticks : {
maxRotation : 45 ,
minRotation : 45 ,
autoSkip : true ,
maxTicksLimit : 20 ,
font : {
size : 10
}
2025-12-22 07:20:41 +07:00
}
}
}
}
});
2026-01-05 11:19:34 +07:00
// Convert chart to image and display
setTimeout (() => {
const chartImage = document . getElementById ( 'chartImage' );
const chartImageContainer = document . getElementById ( 'chartImageContainer' );
chartImage . src = ndviChart . toBase64Image ();
chartImageContainer . style . display = 'block' ;
}, 500 );
}
// Load provinces list
async function loadProvinces () {
try {
const [ response63 , response32 ] = await Promise . all ([
fetch ( '/api/provinces/by-region' ),
fetch ( '/api/provinces-32/by-region' )
]);
allProvinces = await response63 . json ();
allProvincesMerged = await response32 . json ();
populateProvinceSelect ();
populateRegionButtons ();
} catch ( error ) {
console . error ( 'Error loading provinces:' , error );
}
}
// Switch between 63 and 32 province lists
function switchProvinceList ( mode ) {
currentProvinceMode = mode ;
const btn63 = document . getElementById ( 'btn63Provinces' );
const btn32 = document . getElementById ( 'btn32Provinces' );
const modeLabel = document . getElementById ( 'provinceListMode' );
if ( mode === '63' ) {
btn63 . style . background = '#667eea' ;
btn63 . style . color = 'white' ;
btn32 . style . background = '#ccc' ;
btn32 . style . color = '#666' ;
modeLabel . textContent = 'Danh sách: 63 tỉnh' ;
} else {
btn63 . style . background = '#ccc' ;
btn63 . style . color = '#666' ;
btn32 . style . background = '#667eea' ;
btn32 . style . color = 'white' ;
modeLabel . textContent = 'Danh sách: 32 tỉnh (sáp nhập)' ;
}
populateRegionButtons ();
populateProvinceSelect ();
}
// Populate province select dropdown
function populateProvinceSelect ( filterRegion = 'all' ) {
const select = document . getElementById ( 'provinceSelect' );
select . innerHTML = '<option value="">-- Chọn tỉnh thành --</option>' ;
const provinceData = currentProvinceMode === '63' ? allProvinces : allProvincesMerged ;
const regions = Object . keys ( provinceData );
regions . forEach ( region => {
if ( filterRegion !== 'all' && region !== filterRegion ) return ;
const optgroup = document . createElement ( 'optgroup' );
optgroup . label = region ;
provinceData [ region ]. forEach ( prov => {
const option = document . createElement ( 'option' );
option . value = prov . name ;
option . textContent = prov . name ;
option . dataset . bbox = JSON . stringify ( prov . bbox );
optgroup . appendChild ( option );
});
select . appendChild ( optgroup );
});
2025-12-22 07:20:41 +07:00
}
2026-01-05 11:19:34 +07:00
// Handle province selection
function onProvinceSelect ( event ) {
const select = event . target ;
const selectedOption = select . options [ select . selectedIndex ];
if ( ! selectedOption . value ) return ;
const provinceName = selectedOption . value ;
const bbox = JSON . parse ( selectedOption . dataset . bbox );
currentProvinceName = provinceName ;
// Update bbox inputs
document . getElementById ( 'minLon' ). value = bbox [ 0 ];
document . getElementById ( 'minLat' ). value = bbox [ 1 ];
document . getElementById ( 'maxLon' ). value = bbox [ 2 ];
document . getElementById ( 'maxLat' ). value = bbox [ 3 ];
// Update displays
document . getElementById ( 'bboxDisplay' ). textContent =
`Lon: ${ bbox [ 0 ] } → ${ bbox [ 2 ] } , Lat: ${ bbox [ 1 ] } → ${ bbox [ 3 ] } ` ;
document . getElementById ( 'provinceDisplay' ). textContent = `📍 ${ provinceName } ` ;
// Draw rectangle on map
const bounds = [[ bbox [ 1 ], bbox [ 0 ]], [ bbox [ 3 ], bbox [ 2 ]]];
if ( currentRectangle ) {
drawnItems . removeLayer ( currentRectangle );
}
currentRectangle = L . rectangle ( bounds , {
color : '#27ae60' ,
weight : 3 ,
fillOpacity : 0.2
});
drawnItems . addLayer ( currentRectangle );
map . fitBounds ( bounds , { padding : [ 50 , 50 ] });
}
// Populate region filter buttons
function populateRegionButtons () {
const container = document . getElementById ( 'regionFilterContainer' );
const allButton = container . querySelector ( '[data-region="all"]' );
container . innerHTML = '' ;
container . appendChild ( allButton );
const provinceData = currentProvinceMode === '63' ? allProvinces : allProvincesMerged ;
const regions = Object . keys ( provinceData );
regions . forEach ( region => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'region-filter-btn' ;
button . dataset . region = region ;
button . textContent = region ;
button . style . cssText = 'padding: 3px 10px; background: #e0e0e0; color: #666; border: none; border-radius: 15px; font-size: 0.85em; cursor: pointer;' ;
container . appendChild ( button );
});
setupRegionFilters ();
}
// Setup region filter event listeners
function setupRegionFilters () {
const filterButtons = document . querySelectorAll ( '.region-filter-btn' );
filterButtons . forEach ( btn => {
btn . addEventListener ( 'click' , function () {
filterButtons . forEach ( b => {
b . style . background = '#e0e0e0' ;
b . style . color = '#666' ;
});
this . style . background = '#27ae60' ;
this . style . color = 'white' ;
const region = this . dataset . region ;
populateProvinceSelect ( region );
});
});
}
// Initialize on page load
window . onload = function () {
initMap ();
loadModels ();
loadProvinces ();
document . getElementById ( 'provinceSelect' ). addEventListener ( 'change' , onProvinceSelect );
};
2025-12-22 07:20:41 +07:00
// Download data as CSV
function downloadData () {
if ( ! ndviData ) return ;
2026-01-05 11:19:34 +07:00
let csv = 'Date,Mean_NDVI,Min_NDVI,Max_NDVI,Std_NDVI,Valid_Points\n' ;
2025-12-22 07:20:41 +07:00
ndviData . timeseries . forEach ( item => {
2026-01-05 11:19:34 +07:00
csv += ` ${ item . date } , ${ item . mean_ndvi } , ${ item . min_ndvi } , ${ item . max_ndvi } , ${ item . std_ndvi } , ${ item . n_valid_points } \n` ;
2025-12-22 07:20:41 +07:00
});
const blob = new Blob ([ csv ], { type : 'text/csv' });
const url = window . URL . createObjectURL ( blob );
const a = document . createElement ( 'a' );
a . href = url ;
a . download = `ndvi_timeseries_ ${ new Date (). toISOString (). split ( 'T' )[ 0 ] } .csv` ;
a . click ();
window . URL . revokeObjectURL ( url );
}
// Download chart as PNG
function downloadChart () {
if ( ! ndviChart ) return ;
const url = ndviChart . toBase64Image ();
const a = document . createElement ( 'a' );
a . href = url ;
a . download = `ndvi_chart_ ${ new Date (). toISOString (). split ( 'T' )[ 0 ] } .png` ;
a . click ();
}
// Show error message
function showError ( message ) {
document . getElementById ( 'errorContainer' ). style . display = 'block' ;
document . getElementById ( 'errorMessage' ). textContent = message ;
}
</ script >
</ body >
</ html >