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 {
2026-02-15 19:47:58 +07:00
font-family : - apple-system , BlinkMacSystemFont , 'Segoe UI' , 'Roboto' , 'Oxygen' , 'Ubuntu' , 'Cantarell' , sans-serif ;
2025-12-21 17:31:51 +07:00
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
2026-02-15 19:47:58 +07:00
background-attachment : fixed ;
2025-12-21 17:31:51 +07:00
padding : 20 px ;
min-height : 100 vh ;
}
. container {
max-width : 1400 px ;
margin : 0 auto ;
2026-02-15 19:47:58 +07:00
background : rgba ( 255 , 255 , 255 , 0.95 );
backdrop-filter : blur ( 20 px );
border-radius : 24 px ;
box-shadow : 0 25 px 80 px rgba ( 0 , 0 , 0 , 0.2 ), 0 0 0 1 px rgba ( 255 , 255 , 255 , 0.1 );
2025-12-21 17:31:51 +07:00
overflow : hidden ;
}
. header {
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
color : white ;
2026-02-15 19:47:58 +07:00
padding : 40 px 30 px ;
2025-12-21 17:31:51 +07:00
text-align : center ;
2026-02-15 19:47:58 +07:00
position : relative ;
overflow : hidden ;
}
. header :: before {
content : '' ;
position : absolute ;
top : -50 % ;
right : -50 % ;
width : 200 % ;
height : 200 % ;
background : radial-gradient ( circle , rgba ( 255 , 255 , 255 , 0.1 ) 0 % , transparent 70 % );
animation : headerGlow 8 s ease-in-out infinite ;
}
@ keyframes headerGlow {
0 %, 100 % { transform : translate ( 0 , 0 ); }
50 % { transform : translate ( -20 % , -20 % ); }
2025-12-21 17:31:51 +07:00
}
. header h1 {
2026-02-15 19:47:58 +07:00
font-size : 2.8 em ;
margin-bottom : 12 px ;
font-weight : 700 ;
position : relative ;
z-index : 1 ;
text-shadow : 0 2 px 20 px rgba ( 0 , 0 , 0 , 0.2 );
2025-12-21 17:31:51 +07:00
}
. header p {
2026-02-15 19:47:58 +07:00
opacity : 0.95 ;
font-size : 1.15 em ;
position : relative ;
z-index : 1 ;
font-weight : 400 ;
2025-12-21 17:31:51 +07:00
}
. content {
padding : 30 px ;
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 30 px ;
}
# predictMap {
height : 500 px ;
2026-02-15 19:47:58 +07:00
border-radius : 16 px ;
box-shadow : 0 8 px 30 px rgba ( 0 , 0 , 0 , 0.12 );
border : 1 px solid rgba ( 0 , 0 , 0 , 0.1 );
2025-12-21 17:31:51 +07:00
}
. map-container {
grid-column : 1 / -1 ;
}
. map-instructions {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #e3f2fd 0 % , #f0f7ff 100 % );
padding : 20 px ;
border-radius : 16 px ;
margin-bottom : 20 px ;
border-left : 5 px solid #2196f3 ;
box-shadow : 0 4 px 15 px rgba ( 33 , 150 , 243 , 0.1 );
transition : all 0.3 s ease ;
}
. map-instructions : hover {
box-shadow : 0 6 px 25 px rgba ( 33 , 150 , 243 , 0.15 );
transform : translateX ( 3 px );
2025-12-21 17:31:51 +07:00
}
. map-instructions h3 {
color : #1976d2 ;
2026-02-15 19:47:58 +07:00
margin-bottom : 10 px ;
font-weight : 700 ;
font-size : 1.1 em ;
2025-12-21 17:31:51 +07:00
}
. map-instructions p {
color : #555 ;
2026-02-15 19:47:58 +07:00
margin : 6 px 0 ;
line-height : 1.6 ;
2025-12-21 17:31:51 +07:00
}
. section {
margin-bottom : 30 px ;
2026-02-15 19:47:58 +07:00
padding : 28 px ;
background : linear-gradient ( 135 deg , #f8f9fa 0 % , #ffffff 100 % );
border-radius : 16 px ;
border : 1 px solid rgba ( 0 , 0 , 0 , 0.06 );
box-shadow : 0 4 px 20 px rgba ( 0 , 0 , 0 , 0.04 );
transition : all 0.3 s ease ;
}
. section : hover {
box-shadow : 0 8 px 30 px rgba ( 102 , 126 , 234 , 0.12 );
transform : translateY ( -2 px );
2025-12-21 17:31:51 +07:00
}
. section h2 {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
-webkit- background-clip : text ;
-webkit- text-fill-color : transparent ;
background-clip : text ;
margin-bottom : 20 px ;
font-size : 1.6 em ;
font-weight : 700 ;
display : flex ;
align-items : center ;
gap : 10 px ;
2025-12-21 17:31:51 +07:00
}
. form-group {
2026-02-15 19:47:58 +07:00
margin-bottom : 20 px ;
position : relative ;
2025-12-21 17:31:51 +07:00
}
. form-group label {
display : block ;
2026-02-15 19:47:58 +07:00
margin-bottom : 8 px ;
color : #374151 ;
2025-12-21 17:31:51 +07:00
font-weight : 600 ;
2026-02-15 19:47:58 +07:00
font-size : 0.95 em ;
letter-spacing : 0.01 em ;
2025-12-21 17:31:51 +07:00
}
. form-group input , . form-group select {
width : 100 % ;
2026-02-15 19:47:58 +07:00
padding : 12 px 16 px ;
border : 2 px solid #e5e7eb ;
border-radius : 12 px ;
2025-12-21 17:31:51 +07:00
font-size : 1 em ;
2026-02-15 19:47:58 +07:00
transition : all 0.3 s ease ;
background : white ;
font-family : inherit ;
}
. form-group input : hover , . form-group select : hover {
border-color : #d1d5db ;
2025-12-21 17:31:51 +07:00
}
. form-group input : focus , . form-group select : focus {
outline : none ;
border-color : #667eea ;
2026-02-15 19:47:58 +07:00
box-shadow : 0 0 0 4 px rgba ( 102 , 126 , 234 , 0.1 );
transform : translateY ( -1 px );
2025-12-21 17:31:51 +07:00
}
. form-row {
display : grid ;
grid-template-columns : repeat ( auto - fit , minmax ( 200 px , 1 fr ));
gap : 15 px ;
}
. btn {
2026-02-15 19:47:58 +07:00
padding : 14 px 32 px ;
2025-12-21 17:31:51 +07:00
border : none ;
2026-02-15 19:47:58 +07:00
border-radius : 12 px ;
2025-12-21 17:31:51 +07:00
font-size : 1 em ;
font-weight : 600 ;
cursor : pointer ;
2026-02-15 19:47:58 +07:00
transition : all 0.3 s cubic-bezier ( 0.4 , 0 , 0.2 , 1 );
2025-12-21 17:31:51 +07:00
margin-right : 10 px ;
2026-02-15 19:47:58 +07:00
position : relative ;
overflow : hidden ;
font-family : inherit ;
}
. btn :: before {
content : '' ;
position : absolute ;
top : 50 % ;
left : 50 % ;
width : 0 ;
height : 0 ;
border-radius : 50 % ;
background : rgba ( 255 , 255 , 255 , 0.3 );
transform : translate ( -50 % , -50 % );
transition : width 0.6 s , height 0.6 s ;
}
. btn : hover :: before {
width : 300 px ;
height : 300 px ;
2025-12-21 17:31:51 +07:00
}
. btn-primary {
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
color : white ;
2026-02-15 19:47:58 +07:00
box-shadow : 0 4 px 15 px rgba ( 102 , 126 , 234 , 0.3 );
2025-12-21 17:31:51 +07:00
}
. btn-primary : hover {
2026-02-15 19:47:58 +07:00
transform : translateY ( -3 px );
box-shadow : 0 8 px 25 px rgba ( 102 , 126 , 234 , 0.5 );
}
. btn-primary : active {
transform : translateY ( -1 px );
2025-12-21 17:31:51 +07:00
}
. btn-success {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #10b981 0 % , #059669 100 % );
2025-12-21 17:31:51 +07:00
color : white ;
2026-02-15 19:47:58 +07:00
box-shadow : 0 4 px 15 px rgba ( 16 , 185 , 129 , 0.3 );
2025-12-21 17:31:51 +07:00
}
. btn-success : hover {
2026-02-15 19:47:58 +07:00
transform : translateY ( -3 px );
box-shadow : 0 8 px 25 px rgba ( 16 , 185 , 129 , 0.5 );
2025-12-21 17:31:51 +07:00
}
. btn-secondary {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #6b7280 0 % , #4b5563 100 % );
2025-12-21 17:31:51 +07:00
color : white ;
2026-02-15 19:47:58 +07:00
box-shadow : 0 4 px 15 px rgba ( 107 , 114 , 128 , 0.3 );
}
. btn-secondary : hover {
transform : translateY ( -3 px );
box-shadow : 0 8 px 25 px rgba ( 107 , 114 , 128 , 0.5 );
2025-12-21 17:31:51 +07:00
}
. btn : disabled {
opacity : 0.5 ;
cursor : not-allowed ;
2026-02-15 19:47:58 +07:00
transform : none !important ;
2025-12-21 17:31:51 +07:00
}
. 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 {
2026-02-15 19:47:58 +07:00
height : 32 px ;
background : linear-gradient ( to right , #e5e7eb , #f3f4f6 );
border-radius : 16 px ;
2025-12-21 17:31:51 +07:00
overflow : hidden ;
2026-02-15 19:47:58 +07:00
margin : 12 px 0 ;
box-shadow : inset 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.08 );
border : 1 px solid rgba ( 0 , 0 , 0 , 0.05 );
2025-12-21 17:31:51 +07:00
}
. progress-bar {
height : 100 % ;
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 90 deg , #667eea 0 % , #764ba2 50 % , #667eea 100 % );
background-size : 200 % 100 % ;
animation : shimmer 2 s infinite ;
2025-12-21 17:31:51 +07:00
width : 0 % ;
transition : width 0.3 s ;
display : flex ;
align-items : center ;
justify-content : center ;
color : white ;
2026-02-15 19:47:58 +07:00
font-weight : 700 ;
font-size : 0.9 em ;
box-shadow : 0 2 px 8 px rgba ( 102 , 126 , 234 , 0.4 );
}
@ keyframes shimmer {
0 % { background-position : 200 % 0 ; }
100 % { background-position : -200 % 0 ; }
2025-12-21 17:31:51 +07:00
}
. metric-card {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #ffffff 0 % , #f9fafb 100 % );
padding : 24 px ;
border-radius : 16 px ;
box-shadow : 0 4 px 20 px rgba ( 0 , 0 , 0 , 0.06 );
2025-12-21 17:31:51 +07:00
text-align : center ;
2026-02-15 19:47:58 +07:00
border : 1 px solid rgba ( 0 , 0 , 0 , 0.05 );
transition : all 0.3 s ease ;
}
. metric-card : hover {
transform : translateY ( -5 px );
box-shadow : 0 8 px 30 px rgba ( 102 , 126 , 234 , 0.15 );
2025-12-21 17:31:51 +07:00
}
. metric-card h4 {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
-webkit- background-clip : text ;
-webkit- text-fill-color : transparent ;
background-clip : text ;
margin-bottom : 12 px ;
font-weight : 700 ;
font-size : 1 em ;
2025-12-21 17:31:51 +07:00
}
. metric-card . value {
2026-02-15 19:47:58 +07:00
font-size : 2.2 em ;
font-weight : 800 ;
background : linear-gradient ( 135 deg , #667eea 0 % , #764ba2 100 % );
-webkit- background-clip : text ;
-webkit- text-fill-color : transparent ;
background-clip : text ;
2025-12-21 17:31:51 +07:00
}
. 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 {
2026-02-15 19:47:58 +07:00
background : linear-gradient ( 135 deg , #ffffff 0 % , #fafbfc 100 % );
padding : 20 px ;
border-radius : 14 px ;
margin-bottom : 12 px ;
border-left : 5 px solid #667eea ;
2025-12-21 17:31:51 +07:00
display : flex ;
justify-content : space-between ;
align-items : center ;
2026-02-15 19:47:58 +07:00
box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.04 );
transition : all 0.3 s cubic-bezier ( 0.4 , 0 , 0.2 , 1 );
2025-12-21 17:31:51 +07:00
}
. prediction-item : hover {
2026-02-15 19:47:58 +07:00
box-shadow : 0 8 px 25 px rgba ( 102 , 126 , 234 , 0.15 );
transform : translateX ( 5 px );
border-left-width : 7 px ;
2025-12-21 17:31:51 +07:00
}
@ 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 >
2026-02-15 19:47:58 +07:00
< div style = "background: rgba(255,255,255,0.8); backdrop-filter: blur(10px); padding: 18px; display: flex; gap: 12px; flex-wrap: wrap; justify-content: center; border-bottom: 1px solid rgba(0,0,0,0.08); box-shadow: 0 2px 10px rgba(0,0,0,0.03);" >
< a href = "/" style = "padding: 12px 24px; background: linear-gradient(135deg, #667eea, #764ba2); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);" onmouseover = "this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(102, 126, 234, 0.3)'" onmouseout = "this.style.transform=''; this.style.boxShadow='0 4px 12px rgba(102, 126, 234, 0.2)'" > 🏠 Trang Chủ</ a >
< a href = "/training" style = "padding: 12px 24px; background: linear-gradient(135deg, #f093fb, #f5576c); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 4px 12px rgba(240, 147, 251, 0.2);" onmouseover = "this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(240, 147, 251, 0.3)'" onmouseout = "this.style.transform=''; this.style.boxShadow='0 4px 12px rgba(240, 147, 251, 0.2)'" > 🎓 Training</ a >
< a href = "/cloud-training" style = "padding: 12px 24px; background: linear-gradient(135deg, #00bcd4, #0097a7); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 4px 12px rgba(0, 188, 212, 0.2);" onmouseover = "this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(0, 188, 212, 0.3)'" onmouseout = "this.style.transform=''; this.style.boxShadow='0 4px 12px rgba(0, 188, 212, 0.2)'" > 🌥️ Cloud Removal</ a >
< a href = "/prediction" style = "padding: 12px 24px; background: linear-gradient(135deg, #4facfe, #00f2fe); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 6px 20px rgba(79, 172, 254, 0.4); transform: translateY(-2px);" > 🗺️ Prediction (Active)</ a >
< a href = "/batch" style = "padding: 12px 24px; background: linear-gradient(135deg, #764ba2, #667eea); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 4px 12px rgba(118, 75, 162, 0.2);" onmouseover = "this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(118, 75, 162, 0.3)'" onmouseout = "this.style.transform=''; this.style.boxShadow='0 4px 12px rgba(118, 75, 162, 0.2)'" > 🚀 Batch Processing</ a >
< a href = "/ndvi" style = "padding: 12px 24px; background: linear-gradient(135deg, #2ecc71, #27ae60); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 4px 12px rgba(46, 204, 113, 0.2);" onmouseover = "this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(46, 204, 113, 0.3)'" onmouseout = "this.style.transform=''; this.style.boxShadow='0 4px 12px rgba(46, 204, 113, 0.2)'" > 🌿 NDVI Analysis</ a >
< a href = "/reports" style = "padding: 12px 24px; background: linear-gradient(135deg, #ff6b6b, #ee5a6f); color: white; border-radius: 12px; text-decoration: none; font-weight: 600; transition: all 0.3s; box-shadow: 0 4px 12px rgba(255, 107, 107, 0.2);" onmouseover = "this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 20px rgba(255, 107, 107, 0.3)'" onmouseout = "this.style.transform=''; this.style.boxShadow='0 4px 12px rgba(255, 107, 107, 0.2)'" > 📝 Reports</ a >
2025-12-22 07:20:41 +07:00
</ div >
<!-- Tab Navigation -->
2026-02-15 19:47:58 +07:00
< div style = "background: linear-gradient(to bottom, rgba(255,255,255,0.9), rgba(248,250,252,0.9)); backdrop-filter: blur(10px); padding: 20px; border-bottom: 1px solid rgba(0,0,0,0.08); display: flex; gap: 12px; justify-content: center;" >
< button onclick = "switchPredictTab('prediction')" id = "tabPrediction" style = "padding: 14px 32px; background: linear-gradient(135deg, #4facfe, #00f2fe); color: white; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: all 0.3s; box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3); font-size: 1.05em;" >
2025-12-22 07:20:41 +07:00
🗺️ Prediction
</ button >
2026-02-15 19:47:58 +07:00
< button onclick = "switchPredictTab('ndvi')" id = "tabNDVI" style = "padding: 14px 32px; background: linear-gradient(135deg, #e5e7eb, #d1d5db); color: #6b7280; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: all 0.3s; box-shadow: 0 2px 8px rgba(0,0,0,0.08); font-size: 1.05em;" >
2025-12-22 07:20:41 +07:00
🌿 NDVI Analysis
</ button >
</ div >
<!-- Prediction Content -->
< div id = "predictionContent" class = "content" >
2026-01-03 22:35:33 +07:00
<!-- Province Selection Section -->
< div class = "section" style = "grid-column: 1 / -1;" >
< h2 > 🗺️ Chọn Khu Vực Prediction</ h2 >
< div class = "form-group" style = "margin-bottom: 20px;" >
< label >
< strong > 🗺️ Chọn theo Tỉnh Thành:</ strong >
< span style = "color: #999; font-size: 13px; font-weight: normal;" > (Hoặc vẽ bbox thủ công bên dưới)</ span >
</ label >
<!-- Toggle between 63 and 32 provinces -->
< div style = "margin-bottom: 10px; display: flex; gap: 10px; align-items: center;" >
< button type = "button" id = "btnPred63Provinces" onclick = "switchPredProvinceList('63')" style = "padding: 8px 16px; background: #667eea; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;" > 63 Tỉnh (Cũ)</ button >
< button type = "button" id = "btnPred32Provinces" onclick = "switchPredProvinceList('32')" style = "padding: 8px 16px; background: #f0f0f0; color: #333; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;" > 32 Tỉnh (Sau sáp nhập)</ button >
< span id = "predProvinceListMode" style = "color: #667eea; font-weight: bold;" > Danh sách: 63 tỉnh</ span >
</ div >
< select id = "predProvinceSelect" style = "padding: 12px; width: 100%; border: 2px solid #ddd; border-radius: 8px; font-size: 14px; cursor: pointer;" >
< option value = "" > -- Chọn tỉnh thành để tải bbox tự động --</ option >
</ select >
</ div >
<!-- Region Filter -->
< div class = "form-group" style = "margin-bottom: 20px;" >
< label >< strong > 🌍 Lọc theo Vùng:</ strong ></ label >
< div id = "predRegionFilterContainer" style = "display: flex; gap: 10px; flex-wrap: wrap;" >
< button type = "button" class = "pred-region-filter-btn" data-region = "all" style = "padding: 8px 16px; background: #667eea; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;" > Tất cả</ button >
<!-- Dynamic region buttons will be added here -->
</ div >
</ div >
</ div >
2025-12-21 17:31:51 +07:00
<!-- Map Section -->
< div class = "map-container" >
< div class = "map-instructions" >
2026-01-03 22:35:33 +07:00
< h3 > 💡 Hướng dẫn:</ h3 >
< p > 📍 Chọn tỉnh thành ở trên để tự động điền bbox, hoặc sử dụng công cụ vẽ hình chữ nhật trên bản đồ</ p >
2025-12-21 17:31:51 +07:00
< p > 🔄 Có thể chỉnh sửa sau khi vẽ</ p >
</ div >
< div id = "predictMap" ></ div >
2026-01-06 12:25:42 +07:00
<!-- Prediction Cache Section -->
< div class = "section" style = "margin-top: 20px;" >
< h3 > 💾 Cache Predictions (Tự động lưu)</ h3 >
< p style = "color: #666; font-size: 0.9em; margin-bottom: 10px;" > Cache được tự động tạo sau mỗi lần predict thành công</ p >
< div style = "display: flex; gap: 10px; margin-bottom: 15px;" >
< select id = "predCacheSelect" style = "flex: 1; padding: 10px; border: 2px solid #e0e0e0; border-radius: 5px;" >
< option value = "" > -- Chọn cache để phục hồi cấu hình --</ option >
</ select >
< button onclick = "loadPredictionCache()" class = "btn btn-success" > 📂 Phục hồi</ button >
< button onclick = "refreshPredCache()" class = "btn btn-secondary" title = "Làm mới danh sách" > 🔄</ button >
</ div >
< div id = "predCacheList" style = "max-height: 200px; overflow-y: auto; background: white; border-radius: 5px; padding: 10px;" ></ div >
</ div >
2025-12-21 17:31:51 +07:00
</ div >
<!-- Model Selection -->
< div class = "section" >
< h2 > 🤖 Chọn Model</ h2 >
< div class = "form-group" >
< label for = "modelSelect" > Model đã train:</ label >
2026-02-15 19:47:58 +07:00
< div style = "display: flex; gap: 10px; align-items: center; margin-bottom: 10px;" >
< select id = "modelSelect" style = "flex: 1;" >
< option value = "" > Đang tải...</ option >
</ select >
< button onclick = "loadModels(); return false;" style = "padding: 10px 15px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; white-space: nowrap;" >
🔄 Refresh
</ button >
</ div >
<!-- Upload Land Classification Model -->
< div style = "margin-top: 15px; padding: 10px; background: #f5f5f5; border-radius: 6px;" >
< input type = "file" id = "landModelUpload" accept = ".pth,.pkl,.joblib,.h5,.keras" style = "display: none;" onchange = "showLandMetadataForm()" >
< button onclick = "document.getElementById('landModelUpload').click()" style = "padding: 8px 15px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;" >
📤 Upload Land Classification Model
</ button >
< span id = "uploadLandStatus" style = "margin-left: 10px; font-size: 12px; color: #666;" ></ span >
<!-- Land Model Metadata Form -->
< div id = "landMetadataForm" style = "display: none; margin-top: 15px; padding: 15px; background: white; border: 2px solid #4CAF50; border-radius: 6px;" >
< h4 style = "margin: 0 0 10px 0; color: #2e7d32;" > 📝 Model Metadata</ h4 >
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px;" >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Model Type:</ label >
< select id = "uploadModelType" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
< option value = "mobilenet" > MobileNet</ option >
< option value = "cnn" > CNN</ option >
< option value = "swin" > Swin Transformer</ option >
< option value = "xgboost" > XGBoost</ option >
< option value = "random_forest" > Random Forest</ option >
< option value = "other" > Other</ option >
</ select >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Epoch:</ label >
< input type = "number" id = "uploadLandEpoch" min = "0" placeholder = "e.g., 100" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Train Accuracy (%):</ label >
< input type = "number" id = "uploadTrainAcc" step = "0.01" min = "0" max = "100" placeholder = "e.g., 95.5" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Val Accuracy (%):</ label >
< input type = "number" id = "uploadValAcc" step = "0.01" min = "0" max = "100" placeholder = "e.g., 93.2" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Train Loss:</ label >
< input type = "number" id = "uploadLandTrainLoss" step = "0.0001" min = "0" placeholder = "e.g., 0.12" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Val Loss:</ label >
< input type = "number" id = "uploadLandValLoss" step = "0.0001" min = "0" placeholder = "e.g., 0.18" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Number of Classes:</ label >
< input type = "number" id = "uploadNumClasses" min = "2" placeholder = "e.g., 10" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Input Size:</ label >
< input type = "number" id = "uploadInputSize" min = "16" placeholder = "e.g., 64" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
</ div >
< div style = "margin-bottom: 10px;" >
< label style = "font-size: 12px; color: #666; display: block; margin-bottom: 3px;" > Description (optional):</ label >
< input type = "text" id = "uploadLandDescription" placeholder = "e.g., Trained on Mekong Delta dataset" style = "width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;" >
</ div >
< div style = "display: flex; gap: 10px;" >
< button onclick = "uploadLandModelWithMetadata()" style = "flex: 1; padding: 8px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;" >
✅ Upload with Metadata
</ button >
< button onclick = "cancelLandUpload()" style = "padding: 8px 15px; background: #f44336; color: white; border: none; border-radius: 4px; cursor: pointer;" >
❌ Cancel
</ button >
</ div >
</ div >
</ div >
</ div >
2025-12-21 17:31:51 +07:00
</ 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 >
2026-02-15 19:47:58 +07:00
<!-- Date Range -->
< div style = "background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); padding: 20px; border-radius: 12px; margin-bottom: 20px; border: 1px solid #bae6fd;" >
< h3 style = "margin: 0 0 15px 0; color: #0369a1; font-size: 1.1em; font-weight: 600; display: flex; align-items: center; gap: 8px;" >
📅 Khoảng thời gian
</ h3 >
< div class = "form-row" >
< div class = "form-group" style = "margin-bottom: 0;" >
< label for = "predStartDate" > Từ ngày:</ label >
< input type = "date" id = "predStartDate" value = "2023-03-01" >
</ div >
< div class = "form-group" style = "margin-bottom: 0;" >
< label for = "predEndDate" > Đến ngày:</ label >
< input type = "date" id = "predEndDate" value = "2023-05-31" >
</ div >
2025-12-21 17:31:51 +07:00
</ div >
</ div >
2026-02-15 19:47:58 +07:00
<!-- Data Parameters -->
< div style = "background: linear-gradient(135deg, #fef3f2 0%, #fee2e2 100%); padding: 20px; border-radius: 12px; margin-bottom: 20px; border: 1px solid #fecaca;" >
< h3 style = "margin: 0 0 15px 0; color: #b91c1c; font-size: 1.1em; font-weight: 600; display: flex; align-items: center; gap: 8px;" >
🛰️ Tham số dữ liệu vệ tinh
</ h3 >
< div class = "form-row" >
< div class = "form-group" style = "margin-bottom: 0;" >
< label for = "predMaxScenes" >
Số lượng ảnh tối đa:
< span style = "font-size: 0.85em; color: #666; font-weight: 400;" > (1-100)</ span >
</ label >
< input type = "number" id = "predMaxScenes" value = "12" min = "1" max = "100" >
</ div >
< div class = "form-group" style = "margin-bottom: 0;" >
< label for = "predCloudCover" >
Độ phủ mây tối đa:
< span style = "font-size: 0.85em; color: #666; font-weight: 400;" > (0-100%)</ span >
</ label >
< input type = "number" id = "predCloudCover" value = "30" min = "0" max = "100" >
</ div >
2025-12-21 17:31:51 +07:00
</ div >
2026-02-15 19:47:58 +07:00
< div class = "form-group" style = "margin: 15px 0 0 0;" >
< label for = "predResolution" > Độ phân giải không gian:</ label >
< select id = "predResolution" >
< option value = "10" > 10m (Chi tiết cao - Chậm hơn)</ option >
< option value = "20" selected > 20m (Cân bằng - Khuyến nghị)</ option >
</ select >
2025-12-21 17:31:51 +07:00
</ div >
</ div >
2026-01-26 13:44:55 +07:00
<!-- Cloud Removal Configuration -->
2026-02-15 19:47:58 +07:00
< div style = "background: linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%); padding: 20px; border-radius: 12px; margin-bottom: 20px; border: 1px solid #99f6e4;" >
< h3 style = "margin: 0 0 15px 0; color: #0f766e; font-size: 1.1em; font-weight: 600; display: flex; align-items: center; gap: 8px;" >
🌥️ Loại bỏ mây
</ h3 >
< div class = "form-group" style = "margin-bottom: 12px;" >
< label for = "cloudRemovalMethod" > Phương pháp:</ label >
< select id = "cloudRemovalMethod" onchange = "handleCloudMethodChange()" >
< option value = "none" > 🚫 Không xử lý - Giữ nguyên dữ liệu gốc</ option >
< option value = "classic" > Classic - 3 bước (Temporal + Median + Spatial)</ option >
< option value = "hybrid" selected > Hybrid (Classical + ML KNN) - Khuyến nghị</ option >
< option value = "temporal_only" > Temporal Only - Nhanh nhất</ option >
< option value = "median_composite" > Median Composite</ option >
< option value = "ml_knn" > ML K-Nearest Neighbors</ option >
< option value = "ml_rf" > ML Random Forest</ option >
< option value = "deep" > Deep Learning U-Net - Tốt nhất</ option >
</ select >
</ div >
2026-01-26 13:44:55 +07:00
2026-02-15 19:47:58 +07:00
<!-- Deep Learning Model Selection -->
< div id = "cloudModelSelection" style = "display: none; margin-top: 15px; padding: 15px; background: rgba(255,255,255,0.7); border-radius: 8px;" >
< label for = "cloudModelSelect" style = "font-weight: 600; color: #0f766e; margin-bottom: 8px; display: block;" >
📦 Chọn model đã train:
2026-01-26 13:44:55 +07:00
</ label >
2026-02-15 19:47:58 +07:00
< div style = "display: flex; gap: 10px; margin-bottom: 12px;" >
< select id = "cloudModelSelect" style = "flex: 1;" >
< option value = "" > Đang tải models...</ option >
2026-01-26 13:44:55 +07:00
</ select >
2026-02-15 19:47:58 +07:00
< button onclick = "loadCloudRemovalModels(); return false;" class = "btn btn-secondary" style = "padding: 10px 16px; margin: 0; font-size: 0.9em;" >
🔄
2026-01-26 13:44:55 +07:00
</ button >
</ div >
2026-02-15 19:47:58 +07:00
<!-- Upload Model -->
< div style = "padding: 12px; background: rgba(76, 175, 80, 0.1); border-radius: 6px; border: 1px dashed #4CAF50;" >
2026-01-26 13:44:55 +07:00
< input type = "file" id = "cloudModelUpload" accept = ".pth" style = "display: none;" onchange = "showMetadataForm()" >
2026-02-15 19:47:58 +07:00
< button onclick = "document.getElementById('cloudModelUpload').click()" class = "btn btn-success" style = "padding: 10px 16px; margin: 0; font-size: 0.9em;" >
📤 Upload Model (.pth)
2026-01-26 13:44:55 +07:00
</ button >
2026-02-15 19:47:58 +07:00
< span id = "uploadCloudStatus" style = "margin-left: 10px; font-size: 0.85em;" ></ span >
2026-01-26 13:44:55 +07:00
2026-02-15 19:47:58 +07:00
<!-- Metadata Form -->
< div id = "cloudMetadataForm" style = "display: none; margin-top: 12px; padding: 12px; background: white; border-radius: 6px; border: 1px solid #4CAF50;" >
< h4 style = "margin: 0 0 10px 0; color: #2e7d32; font-size: 0.95em;" > 📝 Thông tin Model</ h4 >
< div style = "display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;" >
2026-01-26 13:44:55 +07:00
< div >
2026-02-15 19:47:58 +07:00
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Epoch:</ label >
< input type = "number" id = "uploadEpoch" min = "0" placeholder = "50" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
</ div >
< div >
2026-02-15 19:47:58 +07:00
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Val Loss:</ label >
< input type = "number" id = "uploadValLoss" step = "0.0001" min = "0" placeholder = "0.0134" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
</ div >
< div >
2026-02-15 19:47:58 +07:00
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Train Loss:</ label >
< input type = "number" id = "uploadTrainLoss" step = "0.0001" min = "0" placeholder = "0.0142" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
</ div >
< div >
2026-02-15 19:47:58 +07:00
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Channels In:</ label >
< input type = "number" id = "uploadInChannels" min = "1" placeholder = "6" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
</ div >
< div >
2026-02-15 19:47:58 +07:00
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Channels Out:</ label >
< input type = "number" id = "uploadOutChannels" min = "1" placeholder = "4" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
</ div >
< div >
2026-02-15 19:47:58 +07:00
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Use S1:</ label >
< select id = "uploadUseS1" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
< option value = "true" > Yes</ option >
< option value = "false" > No</ option >
</ select >
</ div >
</ div >
2026-02-15 19:47:58 +07:00
< div style = "margin-bottom: 8px;" >
< label style = "font-size: 0.8em; color: #666; display: block; margin-bottom: 3px;" > Description:</ label >
< input type = "text" id = "uploadDescription" placeholder = "Optional notes" style = "width: 100%; padding: 6px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;" >
2026-01-26 13:44:55 +07:00
</ div >
2026-02-15 19:47:58 +07:00
< div style = "display: flex; gap: 8px;" >
< button onclick = "uploadCloudModelWithMetadata()" class = "btn btn-success" style = "flex: 1; padding: 8px; margin: 0; font-size: 0.85em;" >
✅ Upload
2026-01-26 13:44:55 +07:00
</ button >
2026-02-15 19:47:58 +07:00
< button onclick = "cancelUpload()" class = "btn btn-secondary" style = "padding: 8px 12px; margin: 0; font-size: 0.85em;" >
❌
2026-01-26 13:44:55 +07:00
</ button >
</ div >
</ div >
</ div >
</ div >
2026-02-15 19:47:58 +07:00
< div style = "font-size: 0.85em; color: #0f766e; margin-top: 10px; padding: 8px; background: rgba(255,255,255,0.5); border-radius: 6px;" >
💡 < strong > Hybrid</ strong > cân bằng tốc độ và chất lượng. < strong > Deep Learning</ strong > cho kết quả tốt nhất nhưng cần model đã train.
2026-01-26 13:44:55 +07:00
</ div >
</ div >
2026-02-15 19:47:58 +07:00
<!-- Processing Options -->
< div style = "background: linear-gradient(135deg, #fef9f3 0%, #fed7aa 100%); padding: 20px; border-radius: 12px; margin-bottom: 20px; border: 1px solid #fdba74;" >
< h3 style = "margin: 0 0 15px 0; color: #c2410c; font-size: 1.1em; font-weight: 600; display: flex; align-items: center; gap: 8px;" >
⚙️ Tùy chọn xử lý
</ h3 >
< div class = "form-group" style = "margin-bottom: 15px;" >
< label style = "display: flex; align-items: flex-start; cursor: pointer; margin: 0;" >
< input type = "checkbox" id = "useGpuPred" checked style = "width: 20px; height: 20px; margin-right: 12px; margin-top: 2px; cursor: pointer;" >
< div >
< div style = "font-weight: 600; color: #c2410c; margin-bottom: 4px;" >
🚀 Sử dụng GPU cho Deep Learning Models
</ div >
< div style = "font-size: 0.85em; color: #9a3412;" >
Tăng tốc CNN/Swin-UNet (cần GPU có CUDA)
</ div >
</ div >
</ label >
2026-01-03 22:35:33 +07:00
</ div >
2026-02-15 19:47:58 +07:00
< div class = "form-group" style = "margin-bottom: 0;" >
< label style = "display: flex; align-items: flex-start; cursor: pointer; margin: 0;" >
< input type = "checkbox" id = "exportNDVI" checked style = "width: 20px; height: 20px; margin-right: 12px; margin-top: 2px; cursor: pointer;" >
< div >
< div style = "font-weight: 600; color: #c2410c; margin-bottom: 4px;" >
🌿 Xuất NDVI Raster
</ div >
< div style = "font-size: 0.85em; color: #9a3412;" >
Tạo file GeoTIFF chứa chỉ số NDVI cho toàn bộ khu vực
</ div >
</ div >
</ label >
2025-12-22 07:20:41 +07:00
</ div >
</ div >
2026-02-15 19:47:58 +07:00
< button class = "btn btn-primary" onclick = "startPrediction()" id = "predictBtn" style = "margin-top: 5px; width: 100%; font-size: 1.1em; padding: 16px;" >
🚀 Bắt đầu phân loại (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;" >
2026-01-05 11:19:34 +07:00
<!-- Province Selection for NDVI -->
< div class = "section" style = "grid-column: 1 / -1;" >
< h2 > 🗺️ Chọn Khu Vực NDVI Analysis</ h2 >
< div class = "form-group" style = "margin-bottom: 20px;" >
< label >
< strong > 🗺️ Chọn theo Tỉnh Thành:</ strong >
< span style = "color: #999; font-size: 13px; font-weight: normal;" > (Hoặc vẽ bbox thủ công bên dưới)</ span >
</ label >
<!-- Toggle between 63 and 32 provinces -->
< div style = "margin-bottom: 10px; display: flex; gap: 10px; align-items: center;" >
< button type = "button" id = "btnNDVI63Provinces" onclick = "switchNDVIProvinceList('63')" style = "padding: 8px 16px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;" > 63 Tỉnh (Cũ)</ button >
< button type = "button" id = "btnNDVI32Provinces" onclick = "switchNDVIProvinceList('32')" style = "padding: 8px 16px; background: #f0f0f0; color: #333; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;" > 32 Tỉnh (Sau sáp nhập)</ button >
< span id = "ndviProvinceListMode" style = "color: #2ecc71; font-weight: bold;" > Danh sách: 63 tỉnh</ span >
</ div >
< select id = "ndviProvinceSelect" style = "padding: 12px; width: 100%; border: 2px solid #ddd; border-radius: 8px; font-size: 14px; cursor: pointer;" >
< option value = "" > -- Chọn tỉnh thành để tải bbox tự động --</ option >
</ select >
</ div >
<!-- Region Filter for NDVI -->
< div class = "form-group" style = "margin-bottom: 20px;" >
< label >< strong > 🌍 Lọc theo Vùng:</ strong ></ label >
< div id = "ndviRegionFilterContainer" style = "display: flex; gap: 10px; flex-wrap: wrap;" >
< button type = "button" class = "ndvi-region-filter-btn" data-region = "all" style = "padding: 8px 16px; background: #2ecc71; color: white; border: none; border-radius: 6px; cursor: pointer; font-weight: 600;" > Tất cả</ button >
<!-- Dynamic region buttons will be added here -->
</ div >
</ div >
</ div >
<!-- Model Selection for NDVI -->
< div class = "section" >
< h2 > 🤖 Chọn Model để Predict</ h2 >
< div class = "form-group" >
< label for = "ndviModelSelect" > Model đã train:</ label >
< select id = "ndviModelSelect" style = "padding: 10px; width: 100%; border: 2px solid #ddd; border-radius: 8px; font-size: 14px; cursor: pointer;" >
< option value = "" > Đang tải...</ option >
</ select >
</ div >
< div id = "ndviModelInfo" 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 = "ndviModelType" > -</ span ></ p >
< p >< strong > Accuracy:</ strong > < span id = "ndviModelAccuracy" > -</ span ></ p >
< p >< strong > Training Date:</ strong > < span id = "ndviModelDate" > -</ span ></ p >
</ div >
< div class = "form-group" style = "margin-top: 15px; padding: 15px; background: #fff3e0; border-radius: 8px; border-left: 4px solid #ff9800;" >
< label style = "display: flex; align-items: center; cursor: pointer; margin: 0;" >
< input type = "checkbox" id = "useGpuNDVI" checked style = "width: 18px; height: 18px; margin-right: 10px;" >
< span style = "font-weight: 600; color: #e65100;" > 🚀 Sử dụng GPU (Deep Learning Models)</ span >
</ label >
< div style = "font-size: 12px; color: #e65100; margin-top: 8px; margin-left: 28px;" >
⚡ Tăng tốc prediction cho CNN/Swin-UNet models (yêu cầu GPU khả dụng)
</ div >
</ div >
</ div >
2025-12-22 07:20:41 +07:00
<!-- NDVI Configuration -->
< div class = "section" >
2025-12-24 13:57:08 +07:00
2026-01-05 11:19:34 +07:00
< h2 > ⚙️ Cấu hình NDVI để so sánh thay đổi</ h2 >
<!-- Quick Presets -->
< div class = "form-group" style = "margin-bottom: 20px; padding: 15px; background: #e8f5e9; border-radius: 8px; border-left: 4px solid #4caf50;" >
< label style = "color: #2e7d32; font-weight: 700; margin-bottom: 10px;" > 🎯 Quick Presets (Khu vực có dữ liệu tốt)</ label >
< select id = "ndviQuickPreset" onchange = "applyNDVIPreset()" style = "padding: 10px; width: 100%; border: 2px solid #4caf50; border-radius: 6px; font-size: 14px; cursor: pointer;" >
< option value = "" > -- Chọn preset để áp dụng tự động --</ option >
< option value = "mekong_dry" > 🌾 Đồng bằng Cửu Long - Mùa khô (Jan-Apr 2024)</ option >
< option value = "hanoi_dry" > 🏙️ Hà Nội - Mùa khô (Feb-Apr 2024)</ option >
< option value = "danang_dry" > 🏖️ Đà Nẵng - Mùa khô (Jan-Mar 2024)</ option >
< option value = "mekong_2023" > 🌾 Đồng bằng Cửu Long - Full năm 2023</ option >
< option value = "small_test" > ⚡ Test nhanh - Khu vực nhỏ (500 points)</ option >
</ select >
< div style = "font-size: 12px; color: #2e7d32; margin-top: 8px;" > 💡 Các preset này đã được kiểm tra và có dữ liệu tốt, ít mây</ div >
</ div >
2025-12-24 13:57:08 +07:00
<!-- NDVI Map for selecting bbox -->
2025-12-22 07:20:41 +07:00
< div class = "form-group" >
2025-12-24 13:57:08 +07:00
< label > Chọn bbox trên bản đồ hoặc nhập tọa độ:</ label >
< div id = "ndviMap" style = "height: 300px; border-radius: 10px; margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);" ></ div >
2025-12-22 07:20:41 +07:00
< 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 >
2025-12-24 13:57:08 +07:00
< div style = "font-size: 12px; color: #1976d2; margin-top: 8px;" > ✏️ Vẽ hình chữ nhật trên bản đồ để chọn bbox NDVI, các ô tọa độ sẽ tự động cập nhật.</ div >
2025-12-22 07:20:41 +07:00
</ 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 >
2026-01-05 11:19:34 +07:00
< div class = "form-group" >
< label for = "ndviMaxScenes" > Số scenes tối đa:</ label >
< input type = "number" id = "ndviMaxScenes" value = "12" min = "1" max = "100" >
< div style = "font-size: 12px; color: #666; margin-top: 5px;" > Giới hạn số lượng ảnh vệ tinh xử lý. Ít scenes = nhanh hơn nhưng ít dữ liệu.</ div >
</ div >
< div class = "form-group" >
< label for = "ndviResolution" > Resolution:</ label >
< select id = "ndviResolution" >
< option value = "10" > 10m (Chi tiết cao - Chậm)</ option >
< option value = "20" selected > 20m (Cân bằng)</ option >
</ select >
</ div >
< div class = "form-group" >
< label for = "ndviSamplePoints" > Số điểm dự đoán (sample):</ label >
< input type = "number" id = "ndviSamplePoints" value = "1000" min = "100" max = "10000" step = "100" >
< div style = "font-size: 12px; color: #666; margin-top: 5px;" > Số điểm ngẫu nhiên để predict và tính NDVI time series. Nhiều điểm = chính xác hơn nhưng chậm hơn.</ div >
</ div >
< button class = "btn btn-primary" onclick = "calculateNDVIPrediction()" id = "ndviCalculateBtn" >
📊 Predict & Tính NDVI Time Series
2025-12-22 07:20:41 +07:00
</ 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 ;
2025-12-24 13:57:08 +07:00
let ndviMap , ndviDrawnItems , ndviDrawControl ;
2025-12-21 17:31:51 +07:00
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
2026-01-03 22:35:33 +07:00
// Province data
let allPredProvinces = {};
let allPredProvincesMerged = {};
let currentPredProvinceMode = '63' ; // '63' or '32'
let currentPredProvinceName = '' ;
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 );
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 ();
let bbox = {
min_lon : bounds . getWest (),
min_lat : bounds . getSouth (),
max_lon : bounds . getEast (),
max_lat : bounds . getNorth ()
};
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 ;
localStorage . setItem ( 'prediction_bbox' , JSON . stringify ( selectedBbox ));
console . log ( 'Selected bbox:' , selectedBbox );
});
2025-12-24 13:57:08 +07:00
// Restore bbox from cache if exists
2025-12-21 17:31:51 +07:00
const cachedBbox = localStorage . getItem ( 'prediction_bbox' );
if ( cachedBbox ) {
try {
const bbox = JSON . parse ( cachedBbox );
2025-12-24 13:57:08 +07:00
if ( bbox . min_lon < - 180 || bbox . max_lon > 180 || bbox . min_lat < - 90 || bbox . max_lat > 90 ) {
2025-12-21 17:31:51 +07:00
console . warn ( 'Cache bbox không hợp lệ, đã xóa:' , bbox );
localStorage . removeItem ( 'prediction_bbox' );
} else {
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' );
}
}
2025-12-24 13:57:08 +07:00
// NDVI Map setup
ndviMap = L . map ( 'ndviMap' ). setView ([ 9.5 , 105.9 ], 9 );
L . tileLayer ( 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' , {
attribution : '© OpenStreetMap contributors'
}). addTo ( ndviMap );
ndviDrawnItems = new L . FeatureGroup ();
ndviMap . addLayer ( ndviDrawnItems );
ndviDrawControl = new L . Control . Draw ({
draw : {
rectangle : true ,
polygon : false ,
circle : false ,
marker : false ,
polyline : false ,
circlemarker : false
},
edit : {
featureGroup : ndviDrawnItems ,
remove : true
}
});
ndviMap . addControl ( ndviDrawControl );
ndviMap . on ( L . Draw . Event . CREATED , function ( event ) {
ndviDrawnItems . clearLayers ();
const layer = event . layer ;
ndviDrawnItems . addLayer ( layer );
const bounds = layer . getBounds ();
// Update NDVI bbox inputs
document . getElementById ( 'ndviMinLon' ). value = bounds . getWest (). toFixed ( 4 );
document . getElementById ( 'ndviMinLat' ). value = bounds . getSouth (). toFixed ( 4 );
document . getElementById ( 'ndviMaxLon' ). value = bounds . getEast (). toFixed ( 4 );
document . getElementById ( 'ndviMaxLat' ). value = bounds . getNorth (). toFixed ( 4 );
});
// Sync NDVI bbox inputs to map
[ 'ndviMinLon' , 'ndviMinLat' , 'ndviMaxLon' , 'ndviMaxLat' ]. forEach ( id => {
document . getElementById ( id ). addEventListener ( 'change' , function () {
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 );
if ( ! isNaN ( minLon ) && ! isNaN ( minLat ) && ! isNaN ( maxLon ) && ! isNaN ( maxLat )) {
ndviDrawnItems . clearLayers ();
const bounds = [
[ minLat , minLon ],
[ maxLat , maxLon ]
];
const rectangle = L . rectangle ( bounds , {
color : '#2ecc71' ,
weight : 3 ,
fillOpacity : 0.2
});
ndviDrawnItems . addLayer ( rectangle );
ndviMap . fitBounds ( bounds );
}
});
});
2025-12-21 17:31:51 +07:00
}
// 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 );
}
2026-01-26 13:44:55 +07:00
// Load cloud removal models as well
await loadCloudRemovalModels ();
}
// Load cloud removal models
async function loadCloudRemovalModels () {
try {
console . log ( '[Cloud Models] Loading cloud removal models...' );
const response = await fetch ( '/api/cloud-removal/models' );
const data = await response . json ();
console . log ( '[Cloud Models] Received data:' , data );
const select = document . getElementById ( 'cloudModelSelect' );
select . innerHTML = '<option value="">No model (will use classical methods)</option>' ;
if ( data . models && data . models . length > 0 ) {
console . log ( `[Cloud Models] Found ${ data . models . length } models` );
data . models . forEach ( model => {
const option = document . createElement ( 'option' );
option . value = model . filename ;
// Handle missing metadata gracefully
const epoch = model . epoch || 'N/A' ;
const loss = model . val_loss ? model . val_loss . toFixed ( 4 ) : 'N/A' ;
option . textContent = ` ${ model . filename } (Epoch ${ epoch } , Loss: ${ loss } )` ;
select . appendChild ( option );
console . log ( `[Cloud Models] Added: ${ model . filename } ` );
});
// Auto-select first model
select . value = data . models [ 0 ]. filename ;
console . log ( '[Cloud Models] Auto-selected:' , data . models [ 0 ]. filename );
} else {
console . log ( '[Cloud Models] No models found' );
}
} catch ( error ) {
console . error ( '[Cloud Models] Error loading cloud removal models:' , error );
// Show user-friendly error
const select = document . getElementById ( 'cloudModelSelect' );
select . innerHTML = '<option value="">Error loading models - check console</option>' ;
}
}
// Handle cloud removal method change
function handleCloudMethodChange () {
const method = document . getElementById ( 'cloudRemovalMethod' ). value ;
const modelSelection = document . getElementById ( 'cloudModelSelection' );
if ( method === 'deep' ) {
modelSelection . style . display = 'block' ;
} else {
modelSelection . style . display = 'none' ;
}
}
// Upload cloud removal model
async function uploadCloudModel () {
const fileInput = document . getElementById ( 'cloudModelUpload' );
const file = fileInput . files [ 0 ];
const statusSpan = document . getElementById ( 'uploadCloudStatus' );
if ( ! file ) {
return ;
}
if ( ! file . name . endsWith ( '.pth' )) {
statusSpan . textContent = '❌ Only .pth files allowed' ;
statusSpan . style . color = 'red' ;
return ;
}
statusSpan . textContent = '⏳ Uploading...' ;
statusSpan . style . color = '#2196F3' ;
try {
const formData = new FormData ();
formData . append ( 'file' , file );
const response = await fetch ( '/api/cloud-removal/upload' , {
method : 'POST' ,
body : formData
});
const result = await response . json ();
if ( response . ok ) {
statusSpan . textContent = `✅ Uploaded: ${ result . filename } ( ${ result . size_mb } MB)` ;
statusSpan . style . color = '#4CAF50' ;
// Reload cloud removal models list
await loadCloudRemovalModels ();
// Auto-select the newly uploaded model
document . getElementById ( 'cloudModelSelect' ). value = result . filename ;
} else {
statusSpan . textContent = `❌ ${ result . detail || 'Upload failed' } ` ;
statusSpan . style . color = 'red' ;
}
} catch ( error ) {
statusSpan . textContent = `❌ Error: ${ error . message } ` ;
statusSpan . style . color = 'red' ;
}
// Clear file input
fileInput . value = '' ;
}
// Show metadata form after file selection
function showMetadataForm () {
const fileInput = document . getElementById ( 'cloudModelUpload' );
const file = fileInput . files [ 0 ];
const statusSpan = document . getElementById ( 'uploadCloudStatus' );
const metadataForm = document . getElementById ( 'cloudMetadataForm' );
if ( ! file ) {
return ;
}
if ( ! file . name . endsWith ( '.pth' )) {
statusSpan . textContent = '❌ Only .pth files allowed' ;
statusSpan . style . color = 'red' ;
fileInput . value = '' ;
return ;
}
// Show form
metadataForm . style . display = 'block' ;
statusSpan . textContent = `📁 Selected: ${ file . name } ( ${ ( file . size / ( 1024 * 1024 )). toFixed ( 2 ) } MB)` ;
statusSpan . style . color = '#2196F3' ;
}
// Upload with metadata
async function uploadCloudModelWithMetadata () {
const fileInput = document . getElementById ( 'cloudModelUpload' );
const file = fileInput . files [ 0 ];
const statusSpan = document . getElementById ( 'uploadCloudStatus' );
const metadataForm = document . getElementById ( 'cloudMetadataForm' );
if ( ! file ) {
alert ( 'No file selected' );
return ;
}
// Get values from form with defaults for empty fields
const epoch = document . getElementById ( 'uploadEpoch' ). value || '0' ;
const valLoss = document . getElementById ( 'uploadValLoss' ). value || '0' ;
const trainLoss = document . getElementById ( 'uploadTrainLoss' ). value || '0' ;
const inChannels = document . getElementById ( 'uploadInChannels' ). value || '6' ;
const outChannels = document . getElementById ( 'uploadOutChannels' ). value || '4' ;
const useS1 = document . getElementById ( 'uploadUseS1' ). value || 'true' ;
const description = document . getElementById ( 'uploadDescription' ). value || '' ;
// Debug log
console . log ( '[Upload] Form values:' , {
epoch , valLoss , trainLoss , inChannels , outChannels , useS1 , description
});
// Confirm upload
if ( ! confirm ( `Upload ${ file . name } with metadata?\nEpoch: ${ epoch } \nVal Loss: ${ valLoss } \nTrain Loss: ${ trainLoss } ` )) {
return ;
}
statusSpan . textContent = '⏳ Uploading with metadata...' ;
statusSpan . style . color = '#2196F3' ;
try {
const formData = new FormData ();
formData . append ( 'file' , file );
formData . append ( 'epoch' , epoch );
formData . append ( 'val_loss' , valLoss );
formData . append ( 'train_loss' , trainLoss );
formData . append ( 'in_channels' , inChannels );
formData . append ( 'out_channels' , outChannels );
formData . append ( 'use_s1' , useS1 );
formData . append ( 'description' , description );
console . log ( '[Upload] Sending FormData...' );
const response = await fetch ( '/api/cloud-removal/upload' , {
method : 'POST' ,
body : formData
});
const result = await response . json ();
console . log ( '[Upload] Response:' , result );
if ( response . ok ) {
statusSpan . textContent = `✅ Uploaded: ${ result . filename } ( ${ result . size_mb } MB) - Epoch ${ result . metadata ? . epoch || 0 } ` ;
statusSpan . style . color = '#4CAF50' ;
// Hide form
metadataForm . style . display = 'none' ;
// Clear form
document . getElementById ( 'uploadEpoch' ). value = '' ;
document . getElementById ( 'uploadValLoss' ). value = '' ;
document . getElementById ( 'uploadTrainLoss' ). value = '' ;
document . getElementById ( 'uploadInChannels' ). value = '' ;
document . getElementById ( 'uploadOutChannels' ). value = '' ;
document . getElementById ( 'uploadUseS1' ). value = 'true' ;
document . getElementById ( 'uploadDescription' ). value = '' ;
// Reload cloud removal models list
await loadCloudRemovalModels ();
// Auto-select the newly uploaded model
document . getElementById ( 'cloudModelSelect' ). value = result . filename ;
console . log ( '[Upload] Success:' , result );
} else {
statusSpan . textContent = `❌ ${ result . detail || 'Upload failed' } ` ;
statusSpan . style . color = 'red' ;
}
} catch ( error ) {
statusSpan . textContent = `❌ Error: ${ error . message } ` ;
statusSpan . style . color = 'red' ;
console . error ( '[Upload] Error:' , error );
}
// Clear file input
fileInput . value = '' ;
}
// Cancel upload
function cancelUpload () {
const fileInput = document . getElementById ( 'cloudModelUpload' );
const statusSpan = document . getElementById ( 'uploadCloudStatus' );
const metadataForm = document . getElementById ( 'cloudMetadataForm' );
// Clear and hide
fileInput . value = '' ;
metadataForm . style . display = 'none' ;
statusSpan . textContent = '' ;
2025-12-21 17:31:51 +07:00
}
2026-02-15 19:47:58 +07:00
// ===== LAND CLASSIFICATION MODEL UPLOAD =====
// Show land model metadata form
function showLandMetadataForm () {
const fileInput = document . getElementById ( 'landModelUpload' );
const file = fileInput . files [ 0 ];
const statusSpan = document . getElementById ( 'uploadLandStatus' );
const metadataForm = document . getElementById ( 'landMetadataForm' );
if ( ! file ) {
return ;
}
// Show form
metadataForm . style . display = 'block' ;
statusSpan . textContent = `📁 Selected: ${ file . name } ( ${ ( file . size / ( 1024 * 1024 )). toFixed ( 2 ) } MB)` ;
statusSpan . style . color = '#2196F3' ;
}
// Upload land model with metadata
async function uploadLandModelWithMetadata () {
const fileInput = document . getElementById ( 'landModelUpload' );
const file = fileInput . files [ 0 ];
const statusSpan = document . getElementById ( 'uploadLandStatus' );
const metadataForm = document . getElementById ( 'landMetadataForm' );
if ( ! file ) {
alert ( 'No file selected' );
return ;
}
// Get values from form with defaults
const modelType = document . getElementById ( 'uploadModelType' ). value || 'mobilenet' ;
const epoch = document . getElementById ( 'uploadLandEpoch' ). value || '0' ;
const trainAcc = document . getElementById ( 'uploadTrainAcc' ). value || '0' ;
const valAcc = document . getElementById ( 'uploadValAcc' ). value || '0' ;
const trainLoss = document . getElementById ( 'uploadLandTrainLoss' ). value || '0' ;
const valLoss = document . getElementById ( 'uploadLandValLoss' ). value || '0' ;
const numClasses = document . getElementById ( 'uploadNumClasses' ). value || '10' ;
const inputSize = document . getElementById ( 'uploadInputSize' ). value || '64' ;
const description = document . getElementById ( 'uploadLandDescription' ). value || '' ;
// Debug log
console . log ( '[Land Upload] Form values:' , {
modelType , epoch , trainAcc , valAcc , trainLoss , valLoss , numClasses , inputSize , description
});
// Confirm upload
if ( ! confirm ( `Upload ${ file . name } ?\nType: ${ modelType } \nEpoch: ${ epoch } \nVal Acc: ${ valAcc } %` )) {
return ;
}
statusSpan . textContent = '⏳ Uploading with metadata...' ;
statusSpan . style . color = '#2196F3' ;
try {
const formData = new FormData ();
formData . append ( 'file' , file );
formData . append ( 'model_type' , modelType );
formData . append ( 'epoch' , epoch );
formData . append ( 'train_accuracy' , trainAcc );
formData . append ( 'val_accuracy' , valAcc );
formData . append ( 'train_loss' , trainLoss );
formData . append ( 'val_loss' , valLoss );
formData . append ( 'num_classes' , numClasses );
formData . append ( 'input_size' , inputSize );
formData . append ( 'description' , description );
console . log ( '[Land Upload] Sending FormData...' );
const response = await fetch ( '/api/land-classification/upload' , {
method : 'POST' ,
body : formData
});
const result = await response . json ();
console . log ( '[Land Upload] Response:' , result );
if ( response . ok ) {
statusSpan . textContent = `✅ Uploaded: ${ result . filename } ( ${ result . size_mb } MB)` ;
statusSpan . style . color = '#4CAF50' ;
// Hide form
metadataForm . style . display = 'none' ;
// Clear form
document . getElementById ( 'uploadModelType' ). value = 'mobilenet' ;
document . getElementById ( 'uploadLandEpoch' ). value = '' ;
document . getElementById ( 'uploadTrainAcc' ). value = '' ;
document . getElementById ( 'uploadValAcc' ). value = '' ;
document . getElementById ( 'uploadLandTrainLoss' ). value = '' ;
document . getElementById ( 'uploadLandValLoss' ). value = '' ;
document . getElementById ( 'uploadNumClasses' ). value = '' ;
document . getElementById ( 'uploadInputSize' ). value = '' ;
document . getElementById ( 'uploadLandDescription' ). value = '' ;
// Reload models list
await loadModels ();
// Try to auto-select the newly uploaded model
setTimeout (() => {
const modelSelect = document . getElementById ( 'modelSelect' );
for ( let i = 0 ; i < modelSelect . options . length ; i ++ ) {
if ( modelSelect . options [ i ]. text . includes ( result . filename )) {
modelSelect . selectedIndex = i ;
updateModelInfo ();
break ;
}
}
}, 500 );
console . log ( '[Land Upload] Success:' , result );
} else {
statusSpan . textContent = `❌ ${ result . detail || 'Upload failed' } ` ;
statusSpan . style . color = 'red' ;
}
} catch ( error ) {
statusSpan . textContent = `❌ Error: ${ error . message } ` ;
statusSpan . style . color = 'red' ;
console . error ( '[Land Upload] Error:' , error );
}
// Clear file input
fileInput . value = '' ;
}
// Cancel land model upload
function cancelLandUpload () {
const fileInput = document . getElementById ( 'landModelUpload' );
const statusSpan = document . getElementById ( 'uploadLandStatus' );
const metadataForm = document . getElementById ( 'landMetadataForm' );
// Clear and hide
fileInput . value = '' ;
metadataForm . style . display = 'none' ;
statusSpan . textContent = '' ;
}
// ===== END LAND CLASSIFICATION UPLOAD =====
2025-12-21 17:31:51 +07:00
// Update model info display
function updateModelInfo () {
const select = document . getElementById ( 'modelSelect' );
const option = select . options [ select . selectedIndex ];
2026-01-03 22:35:33 +07:00
if ( option && option . dataset && option . dataset . info ) {
try {
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' ;
} catch ( e ) {
console . warn ( 'Error parsing model info:' , e );
document . getElementById ( 'modelInfo' ). style . display = 'none' ;
}
2025-12-21 17:31:51 +07:00
} 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 ;
2026-01-03 22:35:33 +07:00
const useGpu = document . getElementById ( 'useGpuPred' ). checked ;
2025-12-22 07:20:41 +07:00
2026-01-26 13:44:55 +07:00
// Get cloud removal configuration
const cloudRemovalMethod = document . getElementById ( 'cloudRemovalMethod' ). value ;
const cloudRemovalConfig = {
method : cloudRemovalMethod
};
// If deep learning method is selected, include model filename
if ( cloudRemovalMethod === 'deep' ) {
const cloudModelFilename = document . getElementById ( 'cloudModelSelect' ). value ;
if ( cloudModelFilename ) {
cloudRemovalConfig . model_filename = cloudModelFilename ;
} else {
alert ( '⚠️ Deep learning method selected but no model chosen. Will fall back to hybrid method.' );
cloudRemovalConfig . method = 'hybrid' ;
}
}
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 ),
2026-01-03 22:35:33 +07:00
use_gpu : useGpu ,
2025-12-22 07:20:41 +07:00
export_ndvi : exportNDVI ,
2026-01-26 13:44:55 +07:00
export_classification : true ,
cloud_removal_method : cloudRemovalConfig . method ,
cloud_removal_model : cloudRemovalConfig . model_filename || null
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
2026-01-06 12:25:42 +07:00
// ============ PREDICTION CACHE FUNCTIONS (Auto-save) ============
// Load prediction cache list
async function loadPredCacheList () {
try {
const response = await fetch ( '/api/prediction/cache/list' );
const data = await response . json ();
const select = document . getElementById ( 'predCacheSelect' );
const listDiv = document . getElementById ( 'predCacheList' );
select . innerHTML = '<option value="">-- Chọn cache để phục hồi cấu hình --</option>' ;
listDiv . innerHTML = '' ;
if ( data . caches && data . caches . length > 0 ) {
data . caches . forEach ( cache => {
// Add to select dropdown
const option = document . createElement ( 'option' );
option . value = cache . filename ;
option . textContent = cache . display_name ;
option . dataset . config = JSON . stringify ( cache . config );
select . appendChild ( option );
// Add to list
const item = document . createElement ( 'div' );
item . style . cssText = 'background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid #4facfe;' ;
const cfg = cache . config ;
item . innerHTML = `
<div style="flex: 1;">
<strong style="color: #667eea;">📍 [ ${ cfg . bbox [ 0 ]. toFixed ( 2 ) } , ${ cfg . bbox [ 1 ]. toFixed ( 2 ) } → ${ cfg . bbox [ 2 ]. toFixed ( 2 ) } , ${ cfg . bbox [ 3 ]. toFixed ( 2 ) } ]</strong><br>
<small style="color: #666;">📅 ${ cfg . start_date } → ${ cfg . end_date } </small><br>
<small style="color: #999;">🔧 Resolution: ${ cfg . resolution } m | Scenes: ${ cfg . max_scenes } | Cloud: ${ cfg . cloud_cover } %</small><br>
<small style="color: #999;">⏰ ${ cache . created || 'N/A' } </small>
</div>
<div style="display: flex; gap: 5px;">
<button onclick="applyPredCache(' ${ cache . filename } ')" class="btn btn-success" style="padding: 5px 10px; font-size: 0.9em;">📂</button>
<button onclick="deletePredCache(' ${ cache . filename } ')" class="btn btn-secondary" style="padding: 5px 10px; font-size: 0.9em;">🗑️</button>
</div>
` ;
listDiv . appendChild ( item );
});
} else {
listDiv . innerHTML = '<p style="color: #999; text-align: center; padding: 20px;">Chưa có cache nào. Cache sẽ tự động được tạo sau khi predict thành công.</p>' ;
}
} catch ( error ) {
console . error ( 'Error loading prediction cache:' , error );
}
}
// Refresh prediction cache list
function refreshPredCache () {
loadPredCacheList ();
}
// Apply prediction cache from filename
function applyPredCache ( filename ) {
const select = document . getElementById ( 'predCacheSelect' );
// Find option by filename
for ( let i = 0 ; i < select . options . length ; i ++ ) {
if ( select . options [ i ]. value === filename ) {
select . selectedIndex = i ;
loadPredictionCache ();
return ;
}
}
}
// Load prediction cache and restore all parameters
function loadPredictionCache () {
const select = document . getElementById ( 'predCacheSelect' );
const selectedOption = select . options [ select . selectedIndex ];
if ( ! selectedOption || ! selectedOption . value || ! selectedOption . dataset . config ) {
alert ( '⚠️ Vui lòng chọn cache từ danh sách!' );
return ;
}
const config = JSON . parse ( selectedOption . dataset . config );
// Update selectedBbox
selectedBbox = {
min_lon : config . min_lon ,
min_lat : config . min_lat ,
max_lon : config . max_lon ,
max_lat : config . max_lat
};
// Save to localStorage
localStorage . setItem ( 'prediction_bbox' , JSON . stringify ( selectedBbox ));
// Update ALL form fields
if ( config . start_date ) document . getElementById ( 'predStartDate' ). value = config . start_date ;
if ( config . end_date ) document . getElementById ( 'predEndDate' ). value = config . end_date ;
if ( config . max_scenes ) document . getElementById ( 'predMaxScenes' ). value = config . max_scenes ;
if ( config . cloud_cover ) document . getElementById ( 'predCloudCover' ). value = config . cloud_cover ;
if ( config . resolution ) document . getElementById ( 'predResolution' ). value = config . resolution ;
// Select model if available
if ( config . model_filename ) {
const modelSelect = document . getElementById ( 'modelSelect' );
for ( let i = 0 ; i < modelSelect . options . length ; i ++ ) {
if ( modelSelect . options [ i ]. value === config . model_filename ) {
modelSelect . selectedIndex = i ;
updateModelInfo ();
break ;
}
}
}
// Draw rectangle on map
const bbox = config . bbox ;
const bounds = [[ bbox [ 1 ], bbox [ 0 ]], [ bbox [ 3 ], bbox [ 2 ]]];
// Remove previous rectangle
drawnItems . clearLayers ();
// Add new rectangle
const rectangle = L . rectangle ( bounds , {
color : '#4facfe' ,
weight : 3 ,
fillOpacity : 0.2
});
drawnItems . addLayer ( rectangle );
// Fit map to bounds
map . fitBounds ( bounds , { padding : [ 50 , 50 ] });
alert ( `✅ Đã phục hồi cấu hình từ cache!\n\n📍 Bbox: [ ${ bbox . map ( n => n . toFixed ( 4 )). join ( ', ' ) } ]\n📅 Time: ${ config . start_date } → ${ config . end_date } \n🔧 Resolution: ${ config . resolution } m, Scenes: ${ config . max_scenes } , Cloud: ${ config . cloud_cover } %` );
}
// Delete prediction cache
async function deletePredCache ( filename ) {
if ( ! confirm ( `Bạn có chắc muốn xóa cache này?\n\nFile: ${ filename } ` )) {
return ;
}
try {
const response = await fetch ( `/api/prediction/cache/delete/ ${ filename } ` , {
method : 'DELETE'
});
const result = await response . json ();
if ( result . success ) {
alert ( `✅ ${ result . message } ` );
loadPredCacheList (); // Reload list
} else {
alert ( `❌ Lỗi: ${ result . detail || 'Unknown error' } ` );
}
} catch ( error ) {
console . error ( 'Error deleting cache:' , error );
alert ( `❌ Lỗi khi xóa cache: ${ error . message } ` );
}
}
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
2026-01-03 22:35:33 +07:00
// === PROVINCE SELECTION FUNCTIONS FOR PREDICTION ===
// Load provinces list (both 63 and 32)
async function loadPredProvinces () {
try {
// Load 63 provinces
const response63 = await fetch ( '/api/provinces/by-region' );
allPredProvinces = await response63 . json ();
// Load 32 merged provinces
const response32 = await fetch ( '/api/provinces-32/by-region' );
allPredProvincesMerged = await response32 . json ();
// Default to 63 provinces
populatePredRegionButtons ();
populatePredProvinceSelect ();
} catch ( error ) {
console . error ( 'Error loading provinces:' , error );
document . getElementById ( 'predProvinceSelect' ). innerHTML = '<option value="">Lỗi tải danh sách tỉnh</option>' ;
}
}
// Switch between 63 and 32 province lists
function switchPredProvinceList ( mode ) {
currentPredProvinceMode = mode ;
// Update button styles
const btn63 = document . getElementById ( 'btnPred63Provinces' );
const btn32 = document . getElementById ( 'btnPred32Provinces' );
const modeLabel = document . getElementById ( 'predProvinceListMode' );
if ( mode === '63' ) {
btn63 . style . background = '#667eea' ;
btn63 . style . color = 'white' ;
btn32 . style . background = '#f0f0f0' ;
btn32 . style . color = '#333' ;
modeLabel . textContent = 'Danh sách: 63 tỉnh' ;
} else {
btn63 . style . background = '#f0f0f0' ;
btn63 . style . color = '#333' ;
btn32 . style . background = '#667eea' ;
btn32 . style . color = 'white' ;
modeLabel . textContent = 'Danh sách: 32 tỉnh (sau sáp nhập)' ;
}
// Update region filter buttons
populatePredRegionButtons ();
// Reload province list
populatePredProvinceSelect ();
}
// Populate region filter buttons
function populatePredRegionButtons () {
const container = document . getElementById ( 'predRegionFilterContainer' );
// Keep the "Tất cả" button
const allButton = container . querySelector ( '[data-region="all"]' );
container . innerHTML = '' ;
container . appendChild ( allButton );
// Get regions from current data
const provinceData = currentPredProvinceMode === '63' ? allPredProvinces : allPredProvincesMerged ;
const regions = Object . keys ( provinceData );
// Add button for each region
regions . forEach ( region => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'pred-region-filter-btn' ;
button . dataset . region = region ;
button . textContent = region ;
button . style . cssText = 'padding: 8px 16px; background: #f0f0f0; color: #333; border: none; border-radius: 6px; cursor: pointer;' ;
container . appendChild ( button );
});
// Re-setup event listeners
setupPredRegionFilters ();
}
// Populate province select dropdown
function populatePredProvinceSelect ( filterRegion = 'all' ) {
const select = document . getElementById ( 'predProvinceSelect' );
select . innerHTML = '<option value="">-- Chọn tỉnh thành để tải bbox tự động --</option>' ;
// Choose which province list to use
const provinceData = currentPredProvinceMode === '63' ? allPredProvinces : allPredProvincesMerged ;
// Get all regions dynamically from data
const regions = Object . keys ( provinceData );
regions . forEach ( region => {
if ( filterRegion !== 'all' && filterRegion !== region ) {
return ;
}
const provinces = provinceData [ region ];
if ( ! provinces || provinces . length === 0 ) return ;
const optgroup = document . createElement ( 'optgroup' );
optgroup . label = ` ${ region } ( ${ provinces . length } tỉnh)` ;
provinces . forEach ( province => {
const option = document . createElement ( 'option' );
option . value = province . name ;
// For merged provinces, show additional info
if ( currentPredProvinceMode === '32' && province . merged_from ) {
option . textContent = ` ${ province . name } ( ${ province . merged_from . join ( ', ' ) } )` ;
} else {
option . textContent = ` ${ province . name } - ${ province . name_en || '' } ` ;
}
option . dataset . bbox = JSON . stringify ( province . bbox );
optgroup . appendChild ( option );
});
select . appendChild ( optgroup );
});
}
// Handle province selection for prediction
function onPredProvinceSelect ( event ) {
const select = event . target ;
const selectedOption = select . options [ select . selectedIndex ];
if ( ! selectedOption . value ) {
currentPredProvinceName = '' ;
return ;
}
const provinceName = selectedOption . value ;
const bbox = JSON . parse ( selectedOption . dataset . bbox );
currentPredProvinceName = provinceName ;
// Update selectedBbox
selectedBbox = {
min_lon : bbox [ 0 ],
min_lat : bbox [ 1 ],
max_lon : bbox [ 2 ],
max_lat : bbox [ 3 ]
};
// Save to localStorage
localStorage . setItem ( 'prediction_bbox' , JSON . stringify ( selectedBbox ));
// Draw rectangle on map
const bounds = [[ bbox [ 1 ], bbox [ 0 ]], [ bbox [ 3 ], bbox [ 2 ]]];
// Remove previous rectangle
drawnItems . clearLayers ();
// Add new rectangle
const rectangle = L . rectangle ( bounds , {
color : '#667eea' ,
weight : 3 ,
fillOpacity : 0.2
});
drawnItems . addLayer ( rectangle );
// Fit map to bounds
map . fitBounds ( bounds , { padding : [ 50 , 50 ] });
// Show notification
console . log ( `✅ Đã chọn tỉnh: ${ provinceName } ` );
alert ( `✅ Đã chọn tỉnh: ${ provinceName } \n\nBbox: [ ${ bbox . join ( ', ' ) } ]` );
}
// Handle region filter for prediction
function setupPredRegionFilters () {
const filterButtons = document . querySelectorAll ( '.pred-region-filter-btn' );
filterButtons . forEach ( btn => {
btn . addEventListener ( 'click' , function () {
// Update active button style
filterButtons . forEach ( b => {
b . style . background = '#f0f0f0' ;
b . style . color = '#333' ;
b . style . fontWeight = 'normal' ;
});
this . style . background = '#667eea' ;
this . style . color = 'white' ;
this . style . fontWeight = '600' ;
// Filter provinces
const region = this . dataset . region ;
populatePredProvinceSelect ( region );
});
});
}
2026-01-05 11:19:34 +07:00
// === NDVI PROVINCE SELECTION FUNCTIONS ===
let allNDVIProvinces = {};
let allNDVIProvincesMerged = {};
let currentNDVIProvinceMode = '63' ; // '63' or '32'
// Switch between 63 and 32 province lists for NDVI
function switchNDVIProvinceList ( mode ) {
currentNDVIProvinceMode = mode ;
// Update button styles
const btn63 = document . getElementById ( 'btnNDVI63Provinces' );
const btn32 = document . getElementById ( 'btnNDVI32Provinces' );
const modeLabel = document . getElementById ( 'ndviProvinceListMode' );
if ( mode === '63' ) {
btn63 . style . background = '#2ecc71' ;
btn63 . style . color = 'white' ;
btn32 . style . background = '#f0f0f0' ;
btn32 . style . color = '#333' ;
modeLabel . textContent = 'Danh sách: 63 tỉnh' ;
} else {
btn63 . style . background = '#f0f0f0' ;
btn63 . style . color = '#333' ;
btn32 . style . background = '#2ecc71' ;
btn32 . style . color = 'white' ;
modeLabel . textContent = 'Danh sách: 32 tỉnh (sau sáp nhập)' ;
}
// Update region filter buttons
populateNDVIRegionButtons ();
// Reload province list
populateNDVIProvinceSelect ();
}
// Populate region filter buttons for NDVI
function populateNDVIRegionButtons () {
const container = document . getElementById ( 'ndviRegionFilterContainer' );
// Keep the "Tất cả" button
const allButton = container . querySelector ( '[data-region="all"]' );
container . innerHTML = '' ;
container . appendChild ( allButton );
// Get regions from current data
const provinceData = currentNDVIProvinceMode === '63' ? allNDVIProvinces : allNDVIProvincesMerged ;
const regions = Object . keys ( provinceData );
// Add button for each region
regions . forEach ( region => {
const button = document . createElement ( 'button' );
button . type = 'button' ;
button . className = 'ndvi-region-filter-btn' ;
button . dataset . region = region ;
button . textContent = region ;
button . style . cssText = 'padding: 8px 16px; background: #f0f0f0; color: #333; border: none; border-radius: 6px; cursor: pointer;' ;
container . appendChild ( button );
});
// Re-setup event listeners
setupNDVIRegionFilters ();
}
// Populate province select dropdown for NDVI
function populateNDVIProvinceSelect ( filterRegion = 'all' ) {
const select = document . getElementById ( 'ndviProvinceSelect' );
select . innerHTML = '<option value="">-- Chọn tỉnh thành để tải bbox tự động --</option>' ;
// Choose which province list to use
const provinceData = currentNDVIProvinceMode === '63' ? allNDVIProvinces : allNDVIProvincesMerged ;
// Get all regions dynamically from data
const regions = Object . keys ( provinceData );
regions . forEach ( region => {
if ( filterRegion !== 'all' && filterRegion !== region ) {
return ;
}
const provinces = provinceData [ region ];
if ( ! provinces || provinces . length === 0 ) return ;
const optgroup = document . createElement ( 'optgroup' );
optgroup . label = ` ${ region } ( ${ provinces . length } tỉnh)` ;
provinces . forEach ( province => {
const option = document . createElement ( 'option' );
option . value = province . name ;
// For merged provinces, show additional info
if ( currentNDVIProvinceMode === '32' && province . merged_from ) {
option . textContent = ` ${ province . name } ( ${ province . merged_from . join ( ', ' ) } )` ;
} else {
option . textContent = ` ${ province . name } - ${ province . name_en || '' } ` ;
}
option . dataset . bbox = JSON . stringify ( province . bbox );
optgroup . appendChild ( option );
});
select . appendChild ( optgroup );
});
}
// Handle province selection for NDVI
function onNDVIProvinceSelect ( 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 );
// Update NDVI bbox inputs
document . getElementById ( 'ndviMinLon' ). value = bbox [ 0 ]. toFixed ( 4 );
document . getElementById ( 'ndviMinLat' ). value = bbox [ 1 ]. toFixed ( 4 );
document . getElementById ( 'ndviMaxLon' ). value = bbox [ 2 ]. toFixed ( 4 );
document . getElementById ( 'ndviMaxLat' ). value = bbox [ 3 ]. toFixed ( 4 );
// Draw rectangle on NDVI map
const bounds = [[ bbox [ 1 ], bbox [ 0 ]], [ bbox [ 3 ], bbox [ 2 ]]];
// Remove previous rectangle
ndviDrawnItems . clearLayers ();
// Add new rectangle
const rectangle = L . rectangle ( bounds , {
color : '#2ecc71' ,
weight : 3 ,
fillOpacity : 0.2
});
ndviDrawnItems . addLayer ( rectangle );
// Fit map to bounds
ndviMap . fitBounds ( bounds , { padding : [ 50 , 50 ] });
// Show notification
console . log ( `✅ NDVI - Đã chọn tỉnh: ${ provinceName } ` );
alert ( `✅ Đã chọn tỉnh cho NDVI Analysis: ${ provinceName } \n\nBbox: [ ${ bbox . join ( ', ' ) } ]` );
}
// Handle region filter for NDVI
function setupNDVIRegionFilters () {
const filterButtons = document . querySelectorAll ( '.ndvi-region-filter-btn' );
filterButtons . forEach ( btn => {
btn . addEventListener ( 'click' , function () {
// Update active button style
filterButtons . forEach ( b => {
b . style . background = '#f0f0f0' ;
b . style . color = '#333' ;
b . style . fontWeight = 'normal' ;
});
this . style . background = '#2ecc71' ;
this . style . color = 'white' ;
this . style . fontWeight = '600' ;
// Filter provinces
const region = this . dataset . region ;
populateNDVIProvinceSelect ( region );
});
});
}
// Load NDVI provinces data
async function loadNDVIProvinces () {
try {
// Reuse data from prediction if already loaded
if ( Object . keys ( allPredProvinces ). length > 0 ) {
allNDVIProvinces = allPredProvinces ;
allNDVIProvincesMerged = allPredProvincesMerged ;
} else {
// Load fresh
const response63 = await fetch ( '/api/provinces/by-region' );
allNDVIProvinces = await response63 . json ();
const response32 = await fetch ( '/api/provinces-32/by-region' );
allNDVIProvincesMerged = await response32 . json ();
}
// Populate NDVI province UI
populateNDVIRegionButtons ();
populateNDVIProvinceSelect ();
} catch ( error ) {
console . error ( 'Error loading NDVI provinces:' , error );
document . getElementById ( 'ndviProvinceSelect' ). innerHTML = '<option value="">Lỗi tải danh sách tỉnh</option>' ;
}
}
// Load models for NDVI
async function loadNDVIModels () {
try {
const response = await fetch ( '/api/models/list' );
const data = await response . json ();
const select = document . getElementById ( 'ndviModelSelect' );
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 ;
if ( model . info ) {
option . dataset . info = JSON . stringify ( model . info );
}
select . appendChild ( option );
});
// Auto-select first model
const firstJoblib = data . models . find ( m => m . filename . endsWith ( '.joblib' ) && ! m . filename . includes ( '_info.joblib' ));
if ( firstJoblib ) {
select . value = firstJoblib . filename ;
updateNDVIModelInfo ();
}
} catch ( error ) {
console . error ( 'Error loading NDVI models:' , error );
}
}
// Update NDVI model info display
function updateNDVIModelInfo () {
const select = document . getElementById ( 'ndviModelSelect' );
const option = select . options [ select . selectedIndex ];
if ( option && option . dataset && option . dataset . info ) {
try {
const info = JSON . parse ( option . dataset . info );
const infoDiv = document . getElementById ( 'ndviModelInfo' );
document . getElementById ( 'ndviModelType' ). textContent = info . model_type || 'N/A' ;
document . getElementById ( 'ndviModelAccuracy' ). textContent = info . metrics ? . accuracy
? ( info . metrics . accuracy * 100 ). toFixed ( 2 ) + '%'
: 'N/A' ;
document . getElementById ( 'ndviModelDate' ). textContent = info . training_date || 'N/A' ;
infoDiv . style . display = 'block' ;
} catch ( e ) {
console . warn ( 'Error parsing NDVI model info:' , e );
document . getElementById ( 'ndviModelInfo' ). style . display = 'none' ;
}
} else {
document . getElementById ( 'ndviModelInfo' ). style . display = 'none' ;
}
}
2025-12-21 17:31:51 +07:00
// Initialize on page load
window . onload = function () {
initMap ();
loadModels ();
loadPredictionsList ();
loadCacheList ();
2026-01-06 12:25:42 +07:00
loadPredCacheList (); // Load prediction cache list
2026-01-03 22:35:33 +07:00
loadPredProvinces (); // Load provinces list
2026-01-05 11:19:34 +07:00
loadNDVIProvinces (); // Load NDVI provinces list
loadNDVIModels (); // Load models for NDVI
2026-01-03 22:35:33 +07:00
2025-12-21 17:31:51 +07:00
// Add event listener for model selection
document . getElementById ( 'modelSelect' ). addEventListener ( 'change' , updateModelInfo );
2026-01-05 11:19:34 +07:00
document . getElementById ( 'ndviModelSelect' ). addEventListener ( 'change' , updateNDVIModelInfo );
2025-12-22 15:43:23 +07:00
// Add event listener for cache selection
document . getElementById ( 'cacheSelect' ). addEventListener ( 'change' , applyCachePreset );
2026-01-03 22:35:33 +07:00
// Add event listener for province selection
document . getElementById ( 'predProvinceSelect' ). addEventListener ( 'change' , onPredProvinceSelect );
2026-01-05 11:19:34 +07:00
document . getElementById ( 'ndviProvinceSelect' ). addEventListener ( 'change' , onNDVIProvinceSelect );
2026-01-03 22:35:33 +07:00
setupPredRegionFilters ();
2026-01-05 11:19:34 +07:00
setupNDVIRegionFilters ();
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' ;
2026-01-05 11:19:34 +07:00
// Invalidate map size when switching back to prediction tab
setTimeout (() => {
if ( map ) map . invalidateSize ();
}, 100 );
2025-12-22 07:20:41 +07:00
} 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' ;
2026-01-05 11:19:34 +07:00
// Fix NDVI map rendering issue when tab is shown
setTimeout (() => {
if ( ndviMap ) ndviMap . invalidateSize ();
}, 100 );
2025-12-22 07:20:41 +07:00
}
}
// 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 ;
}
2026-01-05 11:19:34 +07:00
document . getElementById ( 'ndviMinLon' ). value = selectedBbox . min_lon . toFixed ( 4 );
document . getElementById ( 'ndviMinLat' ). value = selectedBbox . min_lat . toFixed ( 4 );
document . getElementById ( 'ndviMaxLon' ). value = selectedBbox . max_lon . toFixed ( 4 );
document . getElementById ( 'ndviMaxLat' ). value = selectedBbox . max_lat . toFixed ( 4 );
2025-12-22 07:20:41 +07:00
alert ( '✅ Đã copy bbox từ Prediction!' );
}
2026-01-05 11:19:34 +07:00
// Calculate NDVI time series with prediction
async function calculateNDVIPrediction () {
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 );
const maxScenes = parseInt ( document . getElementById ( 'ndviMaxScenes' ). value );
const modelFilename = document . getElementById ( 'ndviModelSelect' ). value ;
const useGpu = document . getElementById ( 'useGpuNDVI' ). checked ;
const resolution = parseInt ( document . getElementById ( 'ndviResolution' ). value );
const samplePoints = parseInt ( document . getElementById ( 'ndviSamplePoints' ). 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 ;
}
if ( ! modelFilename ) {
showNDVIError ( 'Vui lòng chọn model để predict!' );
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 = {
model_filename : modelFilename ,
bbox : [ minLon , minLat , maxLon , maxLat ],
start_date : startDate ,
end_date : endDate ,
max_cloud_cover : maxCloudCover ,
max_scenes : maxScenes ,
resolution : resolution ,
sample_points : samplePoints ,
use_gpu : useGpu
};
try {
const response = await fetch ( '/api/ndvi/predict-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 predict NDVI' );
}
} catch ( error ) {
console . error ( 'Error calculating NDVI with prediction:' , error );
showNDVIError ( error . message );
} finally {
document . getElementById ( 'ndviLoading' ). style . display = 'none' ;
document . getElementById ( 'ndviCalculateBtn' ). disabled = false ;
}
}
// Calculate NDVI time series (legacy - without prediction)
2025-12-22 07:20:41 +07:00
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' ));
2026-01-05 11:19:34 +07:00
const ndviValues = data . timeseries . map ( item => item . mean_ndvi );
2025-12-22 07:20:41 +07:00
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 ;
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 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 ;
}
2026-01-05 11:19:34 +07:00
// Apply NDVI preset
function applyNDVIPreset () {
const preset = document . getElementById ( 'ndviQuickPreset' ). value ;
if ( ! preset ) return ;
const presets = {
'mekong_dry' : {
name : 'Đồng bằng Cửu Long - Mùa khô' ,
bbox : [ 105.6 , 9.3 , 106.2 , 9.8 ],
start_date : '2024-01-01' ,
end_date : '2024-04-30' ,
cloud_cover : 30 ,
max_scenes : 15 ,
sample_points : 1000
},
'hanoi_dry' : {
name : 'Hà Nội - Mùa khô' ,
bbox : [ 105.7 , 20.9 , 105.9 , 21.1 ],
start_date : '2024-02-01' ,
end_date : '2024-04-30' ,
cloud_cover : 40 ,
max_scenes : 12 ,
sample_points : 800
},
'danang_dry' : {
name : 'Đà Nẵng - Mùa khô' ,
bbox : [ 107.9 , 15.9 , 108.3 , 16.2 ],
start_date : '2024-01-01' ,
end_date : '2024-03-31' ,
cloud_cover : 35 ,
max_scenes : 12 ,
sample_points : 800
},
'mekong_2023' : {
name : 'Đồng bằng Cửu Long - Full 2023' ,
bbox : [ 105.6 , 9.3 , 106.2 , 9.8 ],
start_date : '2023-01-01' ,
end_date : '2023-12-31' ,
cloud_cover : 40 ,
max_scenes : 20 ,
sample_points : 1000
},
'small_test' : {
name : 'Test nhanh - Khu vực nhỏ' ,
bbox : [ 105.8 , 9.5 , 105.9 , 9.6 ],
start_date : '2024-01-01' ,
end_date : '2024-03-31' ,
cloud_cover : 50 ,
max_scenes : 10 ,
sample_points : 500
}
};
const config = presets [ preset ];
if ( ! config ) return ;
// Apply bbox
document . getElementById ( 'ndviMinLon' ). value = config . bbox [ 0 ];
document . getElementById ( 'ndviMinLat' ). value = config . bbox [ 1 ];
document . getElementById ( 'ndviMaxLon' ). value = config . bbox [ 2 ];
document . getElementById ( 'ndviMaxLat' ). value = config . bbox [ 3 ];
// Apply dates
document . getElementById ( 'ndviStartDate' ). value = config . start_date ;
document . getElementById ( 'ndviEndDate' ). value = config . end_date ;
// Apply other params
document . getElementById ( 'ndviCloudCover' ). value = config . cloud_cover ;
document . getElementById ( 'ndviMaxScenes' ). value = config . max_scenes ;
document . getElementById ( 'ndviSamplePoints' ). value = config . sample_points ;
// Draw bbox on map
ndviDrawnItems . clearLayers ();
const bounds = [[ config . bbox [ 1 ], config . bbox [ 0 ]], [ config . bbox [ 3 ], config . bbox [ 2 ]]];
const rectangle = L . rectangle ( bounds , {
color : '#2ecc71' ,
weight : 3 ,
fillOpacity : 0.2
});
ndviDrawnItems . addLayer ( rectangle );
ndviMap . fitBounds ( bounds , { padding : [ 50 , 50 ] });
alert ( `✅ Đã áp dụng preset: ${ config . name } \n\nBbox: [ ${ config . bbox . join ( ', ' ) } ]\nThời gian: ${ config . start_date } → ${ config . end_date } \nSample points: ${ config . sample_points } ` );
}
2025-12-21 17:31:51 +07:00
</ script >
</ body >
</ html >