2025-12-21 17:31:51 +07:00
<!DOCTYPE html>
< html lang = "vi" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Prediction Interface - Land Classification</ 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 , #667eea 0 % , #764ba2 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 , #667eea 0 % , #764ba2 100 % );
color : white ;
padding : 30 px ;
text-align : center ;
}
. header h1 {
font-size : 2.5 em ;
margin-bottom : 10 px ;
}
. header p {
opacity : 0.9 ;
font-size : 1.1 em ;
}
. content {
padding : 30 px ;
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 30 px ;
}
# predictMap {
height : 500 px ;
border-radius : 10 px ;
box-shadow : 0 4 px 15 px rgba ( 0 , 0 , 0 , 0.1 );
}
. map-container {
grid-column : 1 / -1 ;
}
. map-instructions {
background : #e3f2fd ;
padding : 15 px ;
border-radius : 10 px ;
margin-bottom : 15 px ;
border-left : 4 px solid #2196f3 ;
}
. map-instructions h3 {
color : #1976d2 ;
margin-bottom : 8 px ;
}
. map-instructions p {
color : #555 ;
margin : 5 px 0 ;
}
. section {
margin-bottom : 30 px ;
padding : 20 px ;
background : #f8f9fa ;
border-radius : 10 px ;
}
. section h2 {
color : #667eea ;
margin-bottom : 15 px ;
font-size : 1.5 em ;
}
. 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 ;
transition : border-color 0.3 s ;
}
. form-group input : focus , . form-group select : focus {
outline : none ;
border-color : #667eea ;
}
. form-row {
display : grid ;
grid-template-columns : repeat ( auto - fit , minmax ( 200 px , 1 fr ));
gap : 15 px ;
}
. 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 ;
margin-right : 10 px ;
}
. btn-primary {
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
color : white ;
}
. btn-primary : hover {
transform : translateY ( -2 px );
box-shadow : 0 5 px 15 px rgba ( 102 , 126 , 234 , 0.4 );
}
. btn-success {
background : #28a745 ;
color : white ;
}
. btn-success : hover {
background : #218838 ;
}
. btn-secondary {
background : #6c757d ;
color : white ;
}
. btn : disabled {
opacity : 0.5 ;
cursor : not-allowed ;
}
. status-box {
padding : 20 px ;
background : white ;
border-radius : 10 px ;
border-left : 5 px solid #667eea ;
margin-bottom : 20 px ;
}
. status-box . success {
border-left-color : #28a745 ;
background : #d4edda ;
}
. status-box . error {
border-left-color : #dc3545 ;
background : #f8d7da ;
}
. status-box . predicting {
border-left-color : #ffc107 ;
background : #fff3cd ;
}
. progress {
height : 30 px ;
background : #e0e0e0 ;
border-radius : 15 px ;
overflow : hidden ;
margin : 10 px 0 ;
}
. progress-bar {
height : 100 % ;
background : linear-gradient ( 90 deg , #667eea 0 % , #764ba2 100 % );
width : 0 % ;
transition : width 0.3 s ;
display : flex ;
align-items : center ;
justify-content : center ;
color : white ;
font-weight : 600 ;
}
. metric-card {
background : white ;
padding : 15 px ;
border-radius : 10 px ;
box-shadow : 0 2 px 5 px rgba ( 0 , 0 , 0 , 0.1 );
text-align : center ;
}
. metric-card h4 {
color : #667eea ;
margin-bottom : 10 px ;
}
. metric-card . value {
font-size : 2 em ;
font-weight : bold ;
color : #333 ;
}
. 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-success {
background : #d4edda ;
border-left : 4 px solid #155724 ;
color : #155724 ;
}
. alert-danger {
background : #f8d7da ;
border-left : 4 px solid #721c24 ;
color : #721c24 ;
}
. predictions-list {
max-height : 400 px ;
overflow-y : auto ;
}
. prediction-item {
background : white ;
padding : 15 px ;
border-radius : 8 px ;
margin-bottom : 10 px ;
border-left : 4 px solid #667eea ;
display : flex ;
justify-content : space-between ;
align-items : center ;
}
. prediction-item : hover {
box-shadow : 0 2 px 10 px rgba ( 0 , 0 , 0 , 0.1 );
}
@ media ( max-width : 768px ) {
. content {
grid-template-columns : 1 fr ;
}
. form-row {
grid-template-columns : 1 fr ;
}
}
</ style >
</ head >
< body >
< div class = "container" >
< div class = "header" >
< h1 > 🗺️ Prediction Interface</ h1 >
< p > Phân loại đất cho khu vực mới sử dụng model đã train</ p >
</ div >
2025-12-22 07:20:41 +07:00
< 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 (Active)</ 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</ 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 >
<!-- Tab Navigation -->
< div style = "background: white; padding: 15px; border-bottom: 2px solid #e0e0e0; display: flex; gap: 10px; justify-content: center;" >
< button onclick = "switchPredictTab('prediction')" id = "tabPrediction" style = "padding: 10px 20px; background: #4facfe; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer;" >
🗺️ Prediction
</ button >
< button onclick = "switchPredictTab('ndvi')" id = "tabNDVI" style = "padding: 10px 20px; background: #ccc; color: #666; border: none; border-radius: 8px; font-weight: 600; cursor: pointer;" >
🌿 NDVI Analysis
</ button >
</ div >
<!-- Prediction Content -->
< div id = "predictionContent" class = "content" >
2025-12-21 17:31:51 +07:00
<!-- Map Section -->
< div class = "map-container" >
< div class = "map-instructions" >
< h3 > 📍 Chọn khu vực để predict</ h3 >
< p > ✏️ Click vào nút hình vuông bên phải để vẽ bbox</ p >
< p > 🖱️ Kéo và thả để tạo vùng muốn phân loại</ p >
< p > 🔄 Có thể chỉnh sửa sau khi vẽ</ p >
</ div >
< div id = "predictMap" ></ div >
</ div >
<!-- Model Selection -->
< div class = "section" >
< h2 > 🤖 Chọn Model</ h2 >
< div class = "form-group" >
< label for = "modelSelect" > Model đã train:</ label >
< select id = "modelSelect" >
< option value = "" > Đang tải...</ option >
</ select >
</ div >
<!-- Cache selection dropdown -->
< div class = "form-group" style = "margin-top:15px;" >
< label for = "cacheSelect" > Chọn cache dữ liệu đầu vào:</ label >
< select id = "cacheSelect" >
< option value = "" > -- Không dùng cache --</ option >
</ select >
</ div >
< div id = "modelInfo" style = "display: none; background: #e8f5e9; padding: 15px; border-radius: 8px; margin-top: 15px;" >
< h4 style = "color: #2e7d32; margin-bottom: 10px;" > 📊 Thông tin Model</ h4 >
< p >< strong > Type:</ strong > < span id = "modelType" > -</ span ></ p >
< p >< strong > Accuracy:</ strong > < span id = "modelAccuracy" > -</ span ></ p >
< p >< strong > Training Date:</ strong > < span id = "modelDate" > -</ span ></ p >
</ div >
</ div >
<!-- Time & Data Configuration -->
< div class = "section" >
< h2 > ⏰ Thời gian & Dữ liệu</ h2 >
< div class = "form-row" >
< div class = "form-group" >
< label for = "predStartDate" > Từ ngày:</ label >
< input type = "date" id = "predStartDate" value = "2023-03-01" >
</ div >
< div class = "form-group" >
< label for = "predEndDate" > Đến ngày:</ label >
< input type = "date" id = "predEndDate" value = "2023-05-31" >
</ div >
</ div >
< div class = "form-row" >
< div class = "form-group" >
< label for = "predMaxScenes" > Max Scenes:</ label >
< input type = "number" id = "predMaxScenes" value = "12" min = "1" max = "100" >
</ div >
< div class = "form-group" >
< label for = "predCloudCover" > Cloud Cover (%):</ label >
< input type = "number" id = "predCloudCover" value = "30" min = "0" max = "100" >
</ div >
</ div >
< div class = "form-group" >
< label for = "predResolution" > Resolution:</ label >
< select id = "predResolution" >
< option value = "10" > 10m (Chi tiết cao - Chậm)</ option >
< option value = "20" selected > 20m (Cân bằng)</ option >
</ select >
</ div >
2025-12-22 07:20:41 +07:00
< div class = "form-group" style = "margin-top: 20px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #2196F3;" >
< label style = "display: flex; align-items: center; cursor: pointer; margin: 0;" >
< input type = "checkbox" id = "exportNDVI" checked style = "width: 18px; height: 18px; margin-right: 10px;" >
< span style = "font-weight: 600; color: #1976d2;" > 🌿 Export NDVI Raster</ span >
</ label >
< div style = "font-size: 12px; color: #1976d2; margin-top: 8px; margin-left: 28px;" >
✅ Xuất ra file GeoTIFF chứa giá trị NDVI cho toàn bộ khu vực
</ div >
</ div >
< button class = "btn btn-primary" onclick = "startPrediction()" id = "predictBtn" style = "margin-top: 15px;" >
🚀 Start Prediction (với NDVI)
2025-12-21 17:31:51 +07:00
</ button >
</ div >
<!-- Status Section -->
< div class = "section" style = "grid-column: 1 / -1;" >
< h2 > 📊 Trạng thái Prediction</ h2 >
< div id = "predictionStatus" class = "status-box" style = "display: none;" >
< h3 > ⏳ Đang xử lý...</ h3 >
< p id = "predictionProgress" > Đang khởi tạo...</ p >
< div class = "progress" >
< div class = "progress-bar" id = "predictionProgressBar" > 0%</ div >
</ div >
</ div >
< div id = "predictionResult" style = "display: none;" >
< div class = "alert alert-success" >
< h3 > ✅ Prediction hoàn thành!</ h3 >
< p >< strong > Output file:</ strong > < span id = "resultFile" ></ span ></ p >
< p >< strong > Shape:</ strong > < span id = "resultShape" ></ span ></ p >
< p >< strong > Unique classes:</ strong > < span id = "resultClasses" ></ span ></ p >
<!-- PNG Preview -->
< div id = "pngPreviewContainer" style = "display: none; margin: 20px 0;" >
< h4 style = "margin-bottom: 10px;" > 🖼️ Preview:</ h4 >
< img id = "pngPreview" style = "max-width: 100%; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2);" />
</ div >
< div style = "margin-top: 15px;" >
< button class = "btn btn-success" onclick = "downloadPrediction()" >
💾 Download GeoTIFF
</ button >
< button class = "btn btn-secondary" onclick = "viewReport()" >
📄 View Report
</ button >
</ div >
</ div >
</ div >
< div id = "predictionError" class = "alert alert-danger" style = "display: none;" >
< h3 > ❌ Lỗi</ h3 >
< p id = "errorMessage" ></ p >
</ div >
</ div >
<!-- Previous Predictions -->
< div class = "section" style = "grid-column: 1 / -1;" >
< h2 > 📋 Predictions đã tạo</ h2 >
< div id = "predictionsList" class = "predictions-list" >
< p style = "text-align: center; color: #666;" > Đang tải...</ p >
</ div >
</ div >
</ div >
2025-12-22 07:20:41 +07:00
<!-- NDVI Analysis Content -->
< div id = "ndviContent" class = "content" style = "display: none;" >
<!-- NDVI Configuration -->
< div class = "section" >
< h2 > ⚙️ Cấu hình NDVI</ h2 >
< div class = "form-group" >
< label > Bbox (dùng bbox từ prediction hoặc nhập mới):</ label >
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: 10px;" >
< input type = "number" id = "ndviMinLon" placeholder = "Min Lon" step = "0.0001" >
< input type = "number" id = "ndviMinLat" placeholder = "Min Lat" step = "0.0001" >
< input type = "number" id = "ndviMaxLon" placeholder = "Max Lon" step = "0.0001" >
< input type = "number" id = "ndviMaxLat" placeholder = "Max Lat" step = "0.0001" >
</ div >
</ div >
< div class = "form-row" >
< div class = "form-group" >
< label for = "ndviStartDate" > Từ ngày:</ label >
< input type = "date" id = "ndviStartDate" value = "2023-01-01" >
</ div >
< div class = "form-group" >
< label for = "ndviEndDate" > Đến ngày:</ label >
< input type = "date" id = "ndviEndDate" value = "2023-12-31" >
</ div >
</ div >
< div class = "form-group" >
< label for = "ndviCloudCover" > Cloud Cover tối đa (%):</ label >
< input type = "number" id = "ndviCloudCover" value = "30" min = "0" max = "100" >
</ div >
< button class = "btn btn-primary" onclick = "calculateNDVI()" id = "ndviCalculateBtn" >
📊 Tính NDVI Time Series
</ button >
< button class = "btn btn-secondary" onclick = "usePredictionBbox()" style = "margin-left: 10px;" >
📍 Dùng Bbox từ Prediction
</ button >
</ div >
<!-- NDVI Results -->
< div class = "section" >
< h2 > 📈 Kết quả NDVI</ h2 >
< div id = "ndviLoading" style = "display: none; text-align: center; padding: 20px;" >
< p > ⏳ Đang tải dữ liệu và tính toán NDVI...</ p >
< div style = "margin-top: 10px; width: 100%; height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden;" >
< div id = "ndviProgressBar" style = "width: 0%; height: 100%; background: linear-gradient(90deg, #2ecc71, #27ae60); transition: width 0.3s;" ></ div >
</ div >
</ div >
< div id = "ndviResults" style = "display: none;" >
< div style = "display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; margin-bottom: 20px;" >
< div style = "background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;" >
< h4 style = "color: #666; font-size: 0.9em; margin-bottom: 5px;" > Số ảnh</ h4 >
< p id = "ndviStatImages" style = "color: #2ecc71; font-size: 1.5em; font-weight: bold;" > -</ p >
</ div >
< div style = "background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;" >
< h4 style = "color: #666; font-size: 0.9em; margin-bottom: 5px;" > NDVI TB</ h4 >
< p id = "ndviStatAvg" style = "color: #2ecc71; font-size: 1.5em; font-weight: bold;" > -</ p >
</ div >
< div style = "background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;" >
< h4 style = "color: #666; font-size: 0.9em; margin-bottom: 5px;" > NDVI Min</ h4 >
< p id = "ndviStatMin" style = "color: #2ecc71; font-size: 1.5em; font-weight: bold;" > -</ p >
</ div >
< div style = "background: #e8f5e9; padding: 15px; border-radius: 8px; border-left: 4px solid #2ecc71;" >
< h4 style = "color: #666; font-size: 0.9em; margin-bottom: 5px;" > NDVI Max</ h4 >
< p id = "ndviStatMax" style = "color: #2ecc71; font-size: 1.5em; font-weight: bold;" > -</ p >
</ div >
</ div >
< div style = "margin-top: 15px;" >
< button class = "btn btn-success" onclick = "downloadNDVIData()" > 💾 Download CSV</ button >
< button class = "btn btn-primary" onclick = "downloadNDVIChart()" style = "margin-left: 10px;" > 📸 Download Chart</ button >
</ div >
</ div >
< div id = "ndviError" class = "alert alert-danger" style = "display: none;" >
< h3 > ⚠️ Lỗi</ h3 >
< p id = "ndviErrorMessage" ></ p >
</ div >
</ div >
<!-- NDVI Chart -->
< div class = "section" id = "ndviChartSection" style = "grid-column: 1 / -1; display: none;" >
< h2 style = "color: #2ecc71; margin-bottom: 20px;" > 📊 NDVI Time Series</ h2 >
< canvas id = "ndviChart" ></ canvas >
</ div >
</ div >
2025-12-21 17:31:51 +07:00
</ div >
<!-- Scripts -->
< 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 >
2025-12-22 07:20:41 +07:00
< script src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" ></ script >
2025-12-21 17:31:51 +07:00
< script >
// Map setup
let map , drawnItems , drawControl ;
let selectedBbox = null ;
let currentPredictionFile = null ;
let currentReportFile = null ;
let statusCheckInterval = null ;
2025-12-22 07:20:41 +07:00
let ndviData = null ;
let ndviChart = null ;
2025-12-21 17:31:51 +07:00
// Initialize map
function initMap () {
map = L . map ( 'predictMap' ). setView ([ 9.5 , 105.9 ], 9 );
L . tileLayer ( 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' , {
attribution : '© OpenStreetMap contributors'
}). addTo ( map );
// Initialize drawing
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 );
// Handle drawing
map . on ( L . Draw . Event . CREATED , function ( event ) {
drawnItems . clearLayers ();
const layer = event . layer ;
drawnItems . addLayer ( layer );
const bounds = layer . getBounds ();
let bbox = {
min_lon : bounds . getWest (),
min_lat : bounds . getSouth (),
max_lon : bounds . getEast (),
max_lat : bounds . getNorth ()
};
// Validate bbox (must be within valid geographic coordinates)
if ( bbox . min_lon < - 180 || bbox . max_lon > 180 || bbox . min_lat < - 90 || bbox . max_lat > 90 ) {
alert ( '❌ Bbox không hợp lệ! Vui lòng vẽ trong phạm vi bản đồ hợp lệ.\nKinh độ: -180 đến 180, Vĩ độ: -90 đến 90' );
drawnItems . clearLayers ();
return ;
}
selectedBbox = bbox ;
// Cache bbox to localStorage
localStorage . setItem ( 'prediction_bbox' , JSON . stringify ( selectedBbox ));
console . log ( 'Selected bbox:' , selectedBbox );
});
// On load, restore bbox from cache if exists
const cachedBbox = localStorage . getItem ( 'prediction_bbox' );
if ( cachedBbox ) {
try {
const bbox = JSON . parse ( cachedBbox );
// Validate bbox before restoring
if ( bbox . min_lon < - 180 || bbox . max_lon > 180 ||
bbox . min_lat < - 90 || bbox . max_lat > 90 ) {
console . warn ( 'Cache bbox không hợp lệ, đã xóa:' , bbox );
localStorage . removeItem ( 'prediction_bbox' );
} else {
// Draw rectangle on map
const bounds = [
[ bbox . min_lat , bbox . min_lon ],
[ bbox . max_lat , bbox . max_lon ]
];
const rectangle = L . rectangle ( bounds , {
color : '#667eea' ,
weight : 3 ,
fillOpacity : 0.2
});
drawnItems . addLayer ( rectangle );
map . fitBounds ( bounds );
selectedBbox = bbox ;
}
} catch ( e ) {
console . warn ( 'Không thể khôi phục bbox từ cache:' , e );
localStorage . removeItem ( 'prediction_bbox' );
}
}
}
// Load models list
async function loadModels () {
try {
const response = await fetch ( '/api/models/list' );
const data = await response . json ();
const select = document . getElementById ( 'modelSelect' );
select . innerHTML = '<option value="">Chọn model...</option>' ;
// Chỉ lấy các file model thực sự (.joblib), loại bỏ các file có chứa '_info.joblib'
data . models
. filter ( m => m . filename . endsWith ( '.joblib' ) && ! m . filename . includes ( '_info.joblib' ))
. forEach ( model => {
const option = document . createElement ( 'option' );
option . value = model . filename ;
option . textContent = ` ${ model . filename } - ${ model . created } ` ;
option . dataset . info = JSON . stringify ( model . info );
select . appendChild ( option );
});
// Auto-select first model đúng
const firstJoblib = data . models . find ( m => m . filename . endsWith ( '.joblib' ) && ! m . filename . includes ( '_info.joblib' ));
if ( firstJoblib ) {
select . value = firstJoblib . filename ;
updateModelInfo ();
}
} catch ( error ) {
console . error ( 'Error loading models:' , error );
}
}
// Update model info display
function updateModelInfo () {
const select = document . getElementById ( 'modelSelect' );
const option = select . options [ select . selectedIndex ];
if ( option . dataset . info ) {
const info = JSON . parse ( option . dataset . info );
const infoDiv = document . getElementById ( 'modelInfo' );
document . getElementById ( 'modelType' ). textContent = info . model_type || 'N/A' ;
document . getElementById ( 'modelAccuracy' ). textContent = info . metrics ? . accuracy
? ( info . metrics . accuracy * 100 ). toFixed ( 2 ) + '%'
: 'N/A' ;
document . getElementById ( 'modelDate' ). textContent = info . training_date || 'N/A' ;
infoDiv . style . display = 'block' ;
} else {
document . getElementById ( 'modelInfo' ). style . display = 'none' ;
}
}
// Start prediction
async function startPrediction () {
if ( ! selectedBbox ) {
alert ( '❌ Vui lòng vẽ bbox trên bản đồ trước!' );
return ;
}
// Validate bbox before sending
if ( selectedBbox . min_lon < - 180 || selectedBbox . max_lon > 180 ||
selectedBbox . min_lat < - 90 || selectedBbox . max_lat > 90 ) {
alert ( '❌ Bbox không hợp lệ! Vui lòng vẽ lại trong phạm vi bản đồ hợp lệ.' );
drawnItems . clearLayers ();
selectedBbox = null ;
localStorage . removeItem ( 'prediction_bbox' );
return ;
}
const modelFilename = document . getElementById ( 'modelSelect' ). value ;
if ( ! modelFilename ) {
alert ( '❌ Vui lòng chọn model!' );
return ;
}
2025-12-22 07:20:41 +07:00
// Check if export NDVI is enabled
const exportNDVI = document . getElementById ( 'exportNDVI' ). checked ;
2025-12-21 17:31:51 +07:00
const config = {
model_filename : modelFilename ,
min_lon : selectedBbox . min_lon ,
min_lat : selectedBbox . min_lat ,
max_lon : selectedBbox . max_lon ,
max_lat : selectedBbox . max_lat ,
start_date : document . getElementById ( 'predStartDate' ). value ,
end_date : document . getElementById ( 'predEndDate' ). value ,
max_scenes : parseInt ( document . getElementById ( 'predMaxScenes' ). value ),
cloud_cover : parseInt ( document . getElementById ( 'predCloudCover' ). value ),
2025-12-22 07:20:41 +07:00
resolution : parseInt ( document . getElementById ( 'predResolution' ). value ),
export_ndvi : exportNDVI ,
export_classification : true
2025-12-21 17:31:51 +07:00
};
try {
document . getElementById ( 'predictBtn' ). disabled = true ;
document . getElementById ( 'predictionStatus' ). style . display = 'block' ;
document . getElementById ( 'predictionResult' ). style . display = 'none' ;
document . getElementById ( 'predictionError' ). style . display = 'none' ;
2025-12-22 07:20:41 +07:00
// Choose API endpoint based on NDVI export option
const endpoint = exportNDVI ? '/api/predict/with-ndvi' : '/api/prediction/start' ;
const response = await fetch ( endpoint , {
2025-12-21 17:31:51 +07:00
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( config )
});
const result = await response . json ();
if ( response . ok ) {
2025-12-22 07:20:41 +07:00
if ( exportNDVI ) {
// Direct result for NDVI prediction
document . getElementById ( 'predictionStatus' ). style . display = 'none' ;
document . getElementById ( 'predictionResult' ). style . display = 'block' ;
// Display results
const outputFiles = result . output_files . map ( f => f . path ). join ( ', ' );
document . getElementById ( 'resultFile' ). textContent = outputFiles ;
// Show NDVI stats
const statsHTML = `
<h4>🌿 NDVI Statistics:</h4>
<p>Mean: ${ result . ndvi_stats . mean . toFixed ( 3 ) } </p>
<p>Min: ${ result . ndvi_stats . min . toFixed ( 3 ) } </p>
<p>Max: ${ result . ndvi_stats . max . toFixed ( 3 ) } </p>
<p>Std: ${ result . ndvi_stats . std . toFixed ( 3 ) } </p>
<h4>📊 Class Distribution:</h4>
<p> ${ JSON . stringify ( result . class_distribution , null , 2 ) } </p>
` ;
document . getElementById ( 'resultShape' ). innerHTML = statsHTML ;
document . getElementById ( 'resultClasses' ). textContent = ` ${ result . n_scenes } scenes used` ;
currentPredictionFile = result . output_files . find ( f => f . type === 'classification' ) ? . path || '' ;
currentReportFile = null ;
document . getElementById ( 'predictBtn' ). disabled = false ;
// Reload predictions list
await loadPredictionsList ();
} else {
// Start monitoring status for regular prediction
startStatusCheck ();
}
2025-12-21 17:31:51 +07:00
} else {
throw new Error ( result . detail || 'Lỗi khi bắt đầu prediction' );
}
} catch ( error ) {
console . error ( 'Error starting prediction:' , error );
document . getElementById ( 'predictionError' ). style . display = 'block' ;
document . getElementById ( 'errorMessage' ). textContent = error . message ;
document . getElementById ( 'predictBtn' ). disabled = false ;
}
}
// Check prediction status
async function checkStatus () {
try {
const response = await fetch ( '/api/prediction/status' );
const status = await response . json ();
document . getElementById ( 'predictionProgress' ). textContent = status . progress ;
// Update progress bar (estimate based on message)
let progress = 0 ;
if ( status . progress . includes ( 'khởi' )) progress = 10 ;
else if ( status . progress . includes ( 'Sentinel-2' )) progress = 30 ;
else if ( status . progress . includes ( 'NDVI' )) progress = 50 ;
else if ( status . progress . includes ( 'Sentinel-1' )) progress = 60 ;
else if ( status . progress . includes ( 'features' )) progress = 70 ;
else if ( status . progress . includes ( 'dự đoán' )) progress = 80 ;
else if ( status . progress . includes ( 'lưu' )) progress = 90 ;
else if ( status . progress . includes ( 'Hoàn thành' )) progress = 100 ;
document . getElementById ( 'predictionProgressBar' ). style . width = progress + '%' ;
document . getElementById ( 'predictionProgressBar' ). textContent = progress + '%' ;
if ( ! status . is_predicting ) {
stopStatusCheck ();
document . getElementById ( 'predictBtn' ). disabled = false ;
if ( status . error ) {
document . getElementById ( 'predictionStatus' ). style . display = 'none' ;
document . getElementById ( 'predictionError' ). style . display = 'block' ;
document . getElementById ( 'errorMessage' ). textContent = status . error ;
} else if ( status . result ) {
document . getElementById ( 'predictionStatus' ). style . display = 'none' ;
document . getElementById ( 'predictionResult' ). style . display = 'block' ;
currentPredictionFile = status . result . output_file ;
currentReportFile = status . result . report_filename ;
document . getElementById ( 'resultFile' ). textContent = status . result . output_file ;
document . getElementById ( 'resultShape' ). textContent = status . result . shape . join ( ' x ' );
document . getElementById ( 'resultClasses' ). textContent = status . result . unique_classes . join ( ', ' );
// Show PNG preview if available
if ( status . result . png_file ) {
const pngFilename = status . result . png_file . split ( '/' ). pop ();
const previewImg = document . getElementById ( 'pngPreview' );
const previewContainer = document . getElementById ( 'pngPreviewContainer' );
previewImg . src = `/api/predictions/preview/ ${ pngFilename } ` ;
previewContainer . style . display = 'block' ;
}
// Reload predictions list
loadPredictionsList ();
}
}
} catch ( error ) {
console . error ( 'Error checking status:' , error );
}
}
// Start/stop status monitoring
function startStatusCheck () {
if ( statusCheckInterval ) clearInterval ( statusCheckInterval );
statusCheckInterval = setInterval ( checkStatus , 2000 );
}
function stopStatusCheck () {
if ( statusCheckInterval ) {
clearInterval ( statusCheckInterval );
statusCheckInterval = null ;
}
}
// Download prediction
function downloadPrediction () {
if ( currentPredictionFile ) {
const filename = currentPredictionFile . split ( '/' ). pop ();
window . location . href = `/api/predictions/download/ ${ filename } ` ;
}
}
// View report
function viewReport () {
if ( currentReportFile ) {
window . open ( `/api/reports/view/ ${ currentReportFile } ` , '_blank' );
}
}
// Load predictions list
async function loadPredictionsList () {
try {
const response = await fetch ( '/api/predictions/list' );
const data = await response . json ();
const listDiv = document . getElementById ( 'predictionsList' );
if ( data . predictions . length === 0 ) {
listDiv . innerHTML = '<p style="text-align: center; color: #666;">Chưa có prediction nào</p>' ;
return ;
}
2025-12-22 07:20:41 +07:00
listDiv . innerHTML = data . predictions . map ( pred => {
const isBatchJob = pred . is_batch_job ;
const batchInfo = pred . batch_metadata ;
let batchLabel = '' ;
if ( isBatchJob && batchInfo ) {
const timestamp = batchInfo . batch_timestamp ?
new Date ( batchInfo . batch_timestamp ). toLocaleString ( 'vi-VN' ) :
'N/A' ;
batchLabel = `
<div style="background: #fff3cd; padding: 5px 10px; border-radius: 5px; margin-top: 5px; border-left: 3px solid #ffc107;">
<small style="color: #856404;">
<strong>🚀 Batch Job:</strong> ${ batchInfo . batch_name || 'N/A' } <br>
<strong>ID:</strong> ${ batchInfo . batch_job_id || 'N/A' } |
<strong>Thời gian:</strong> ${ timestamp }
</small>
</div>
` ;
}
return `
<div class="prediction-item" style="border-left: ${ isBatchJob ? '4px solid #ffc107' : '4px solid #667eea' } ;">
<div style="flex: 1;">
<strong> ${ pred . filename } </strong>
${ isBatchJob ? '<span style="background: #ffc107; color: white; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; margin-left: 8px;">BATCH</span>' : '' }
<br>
<small style="color: #666;">
${ new Date ( pred . created ). toLocaleString ( 'vi-VN' ) } - ${ pred . size_mb } MB
</small>
${ batchLabel }
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="btn btn-success" style="padding: 8px 16px; font-size: 0.9em;"
onclick="window.location.href=' ${ pred . download_url } '">
💾 Download GeoTIFF
</button>
${ pred . has_preview ? `
<button class="btn btn-primary" style="padding: 8px 16px; font-size: 0.9em;"
onclick="window.open(' ${ pred . preview_url } ', '_blank')">
🖼️ View PNG
</button>
` : '' }
</div>
2025-12-21 17:31:51 +07:00
</div>
2025-12-22 07:20:41 +07:00
` ;
}). join ( '' );
2025-12-21 17:31:51 +07:00
} catch ( error ) {
console . error ( 'Error loading predictions:' , error );
}
}
// Load cache list
async function loadCacheList () {
try {
const response = await fetch ( '/api/cache/info' );
const data = await response . json ();
const select = document . getElementById ( 'cacheSelect' );
select . innerHTML = '<option value="">-- Không dùng cache --</option>' ;
2025-12-22 15:43:23 +07:00
// Store cache data globally for later use
window . cacheFiles = data . files || [];
2025-12-21 17:31:51 +07:00
if ( data . files && data . files . length > 0 ) {
data . files . forEach (( file , idx ) => {
if ( file . filename . startsWith ( 'prediction_input_' )) {
let label = `# ${ idx + 1 } | ${ file . filename } ` ;
if ( file . metadata && file . metadata . bbox ) {
label += ` | BBox: [ ${ file . metadata . bbox . join ( ', ' ) } ]` ;
}
if ( file . metadata && file . metadata . time_range ) {
label += ` | Time: ${ file . metadata . time_range } ` ;
}
select . innerHTML += `<option value=" ${ file . filename } "> ${ label } </option>` ;
}
});
}
} catch ( e ) {
console . warn ( 'Không thể tải danh sách cache:' , e );
}
}
2025-12-22 15:43:23 +07:00
// Apply cache preset - auto fill bbox and other params
function applyCachePreset () {
const selectValue = document . getElementById ( 'cacheSelect' ). value ;
if ( ! selectValue || ! window . cacheFiles ) return ;
// Find selected cache file
const cacheFile = window . cacheFiles . find ( f => f . filename === selectValue );
if ( ! cacheFile || ! cacheFile . metadata ) {
console . warn ( 'No metadata found for selected cache' );
return ;
}
const meta = cacheFile . metadata ;
// Auto-fill bbox from cache
if ( meta . bbox && meta . bbox . length === 4 ) {
const [ min_lon , min_lat , max_lon , max_lat ] = meta . bbox ;
// Set selectedBbox directly without drawing on map
selectedBbox = {
min_lon : min_lon ,
min_lat : min_lat ,
max_lon : max_lon ,
max_lat : max_lat
};
// Save to localStorage
localStorage . setItem ( 'prediction_bbox' , JSON . stringify ( selectedBbox ));
// Draw rectangle on map to visualize
if ( currentRectangle ) {
drawnItems . removeLayer ( currentRectangle );
}
const bounds = L . latLngBounds (
[ min_lat , min_lon ],
[ max_lat , max_lon ]
);
currentRectangle = L . rectangle ( bounds , {
color : '#4facfe' ,
weight : 3 ,
fillOpacity : 0.2
});
drawnItems . addLayer ( currentRectangle );
map . fitBounds ( bounds );
console . log ( `✅ Auto-applied bbox from cache: [ ${ min_lon } , ${ min_lat } , ${ max_lon } , ${ max_lat } ]` );
}
// Auto-fill time range if available
if ( meta . start_date ) {
document . getElementById ( 'predStartDate' ). value = meta . start_date ;
}
if ( meta . end_date ) {
document . getElementById ( 'predEndDate' ). value = meta . end_date ;
}
// Auto-fill other params
if ( meta . resolution ) {
document . getElementById ( 'predResolution' ). value = meta . resolution ;
}
if ( meta . max_scenes ) {
document . getElementById ( 'predMaxScenes' ). value = meta . max_scenes ;
}
if ( meta . cloud_cover ) {
document . getElementById ( 'predCloudCover' ). value = meta . cloud_cover ;
}
// Show notification
alert ( `✅ Đã áp dụng cache preset!\n\nBBox: [ ${ meta . bbox ? . join ( ', ' ) || 'N/A' } ]\nTime: ${ meta . start_date || '?' } → ${ meta . end_date || '?' } \n\nBạn có thể predict ngay mà không cần vẽ bbox!` );
}
2025-12-21 17:31:51 +07:00
// Initialize on page load
window . onload = function () {
initMap ();
loadModels ();
loadPredictionsList ();
loadCacheList ();
// Add event listener for model selection
document . getElementById ( 'modelSelect' ). addEventListener ( 'change' , updateModelInfo );
2025-12-22 15:43:23 +07:00
// Add event listener for cache selection
document . getElementById ( 'cacheSelect' ). addEventListener ( 'change' , applyCachePreset );
2025-12-21 17:31:51 +07:00
};
// Cleanup on page unload
window . onbeforeunload = function () {
stopStatusCheck ();
};
2025-12-22 07:20:41 +07:00
// ============ NDVI FUNCTIONS ============
// Switch between prediction and NDVI tabs
function switchPredictTab ( tab ) {
if ( tab === 'prediction' ) {
document . getElementById ( 'predictionContent' ). style . display = 'grid' ;
document . getElementById ( 'ndviContent' ). style . display = 'none' ;
document . getElementById ( 'tabPrediction' ). style . background = '#4facfe' ;
document . getElementById ( 'tabPrediction' ). style . color = 'white' ;
document . getElementById ( 'tabNDVI' ). style . background = '#ccc' ;
document . getElementById ( 'tabNDVI' ). style . color = '#666' ;
} else {
document . getElementById ( 'predictionContent' ). style . display = 'none' ;
document . getElementById ( 'ndviContent' ). style . display = 'grid' ;
document . getElementById ( 'tabPrediction' ). style . background = '#ccc' ;
document . getElementById ( 'tabPrediction' ). style . color = '#666' ;
document . getElementById ( 'tabNDVI' ). style . background = '#2ecc71' ;
document . getElementById ( 'tabNDVI' ). style . color = 'white' ;
}
}
// Use bbox from prediction form
function usePredictionBbox () {
if ( ! selectedBbox ) {
alert ( '⚠️ Chưa chọn bbox trong phần Prediction. Vui lòng vẽ bbox trên bản đồ trước!' );
return ;
}
document . getElementById ( 'ndviMinLon' ). value = selectedBbox [ 0 ]. toFixed ( 4 );
document . getElementById ( 'ndviMinLat' ). value = selectedBbox [ 1 ]. toFixed ( 4 );
document . getElementById ( 'ndviMaxLon' ). value = selectedBbox [ 2 ]. toFixed ( 4 );
document . getElementById ( 'ndviMaxLat' ). value = selectedBbox [ 3 ]. toFixed ( 4 );
alert ( '✅ Đã copy bbox từ Prediction!' );
}
// Calculate NDVI time series
async function calculateNDVI () {
const minLon = parseFloat ( document . getElementById ( 'ndviMinLon' ). value );
const minLat = parseFloat ( document . getElementById ( 'ndviMinLat' ). value );
const maxLon = parseFloat ( document . getElementById ( 'ndviMaxLon' ). value );
const maxLat = parseFloat ( document . getElementById ( 'ndviMaxLat' ). value );
const startDate = document . getElementById ( 'ndviStartDate' ). value ;
const endDate = document . getElementById ( 'ndviEndDate' ). value ;
const maxCloudCover = parseInt ( document . getElementById ( 'ndviCloudCover' ). value );
// Validate
if ( isNaN ( minLon ) || isNaN ( minLat ) || isNaN ( maxLon ) || isNaN ( maxLat )) {
showNDVIError ( 'Vui lòng nhập đầy đủ tọa độ bbox!' );
return ;
}
if ( ! startDate || ! endDate ) {
showNDVIError ( 'Vui lòng chọn khoảng thời gian!' );
return ;
}
// Show loading
document . getElementById ( 'ndviLoading' ). style . display = 'block' ;
document . getElementById ( 'ndviResults' ). style . display = 'none' ;
document . getElementById ( 'ndviChartSection' ). style . display = 'none' ;
document . getElementById ( 'ndviError' ). style . display = 'none' ;
document . getElementById ( 'ndviCalculateBtn' ). disabled = true ;
const config = {
bbox : [ minLon , minLat , maxLon , maxLat ],
start_date : startDate ,
end_date : endDate ,
max_cloud_cover : maxCloudCover ,
resolution : 20
};
try {
const response = await fetch ( '/api/ndvi/timeseries' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( config )
});
const result = await response . json ();
if ( response . ok ) {
ndviData = result ;
displayNDVIResults ( result );
} else {
throw new Error ( result . detail || 'Lỗi khi tính NDVI' );
}
} catch ( error ) {
console . error ( 'Error calculating NDVI:' , error );
showNDVIError ( error . message );
} finally {
document . getElementById ( 'ndviLoading' ). style . display = 'none' ;
document . getElementById ( 'ndviCalculateBtn' ). disabled = false ;
}
}
// Display NDVI results
function displayNDVIResults ( data ) {
document . getElementById ( 'ndviResults' ). style . display = 'block' ;
document . getElementById ( 'ndviChartSection' ). style . display = 'block' ;
// Update stats
document . getElementById ( 'ndviStatImages' ). textContent = data . n_images ;
document . getElementById ( 'ndviStatAvg' ). textContent = data . mean_ndvi . toFixed ( 3 );
document . getElementById ( 'ndviStatMin' ). textContent = data . min_ndvi . toFixed ( 3 );
document . getElementById ( 'ndviStatMax' ). textContent = data . max_ndvi . toFixed ( 3 );
// Create chart
createNDVIChart ( data );
}
// Create NDVI chart
function createNDVIChart ( data ) {
const ctx = document . getElementById ( 'ndviChart' ). getContext ( '2d' );
// Destroy existing chart
if ( ndviChart ) {
ndviChart . destroy ();
}
const dates = data . timeseries . map ( item => new Date ( item . date ). toLocaleDateString ( 'vi-VN' ));
const ndviValues = data . timeseries . map ( item => item . ndvi );
ndviChart = new Chart ( ctx , {
type : 'line' ,
data : {
labels : dates ,
datasets : [{
label : 'NDVI' ,
data : ndviValues ,
borderColor : '#2ecc71' ,
backgroundColor : 'rgba(46, 204, 113, 0.1)' ,
borderWidth : 2 ,
fill : true ,
tension : 0.4 ,
pointRadius : 4 ,
pointHoverRadius : 6 ,
pointBackgroundColor : '#2ecc71' ,
pointBorderColor : '#fff' ,
pointBorderWidth : 2
}]
},
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 : { display : false }
}
}
}
});
}
// Download NDVI data as CSV
function downloadNDVIData () {
if ( ! ndviData ) return ;
let csv = 'Date,NDVI\\n' ;
ndviData . timeseries . forEach ( item => {
csv += ` ${ item . date } , ${ item . ndvi } \\n` ;
});
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 NDVI chart as PNG
function downloadNDVIChart () {
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 NDVI error
function showNDVIError ( message ) {
document . getElementById ( 'ndviError' ). style . display = 'block' ;
document . getElementById ( 'ndviErrorMessage' ). textContent = message ;
}
2025-12-21 17:31:51 +07:00
</ script >
</ body >
</ html >