hoàn thành chức năng predict ndvi time series analysis
This commit is contained in:
+90
-48
@@ -4230,6 +4230,26 @@ async def ndvi_predict_timeseries(config: NDVIPredictionConfig):
|
|||||||
print(f" Mean NDVI: {result['mean_ndvi']:.3f}")
|
print(f" Mean NDVI: {result['mean_ndvi']:.3f}")
|
||||||
print(f" NDVI range: [{result['min_ndvi']:.3f}, {result['max_ndvi']:.3f}]")
|
print(f" NDVI range: [{result['min_ndvi']:.3f}, {result['max_ndvi']:.3f}]")
|
||||||
print(f"{'='*70}\n")
|
print(f"{'='*70}\n")
|
||||||
|
|
||||||
|
# Auto-save prediction cache (Sentinel-2 data + metadata) similar to rice predict
|
||||||
|
try:
|
||||||
|
cache_config = {
|
||||||
|
"min_lon": config.bbox[0],
|
||||||
|
"min_lat": config.bbox[1],
|
||||||
|
"max_lon": config.bbox[2],
|
||||||
|
"max_lat": config.bbox[3],
|
||||||
|
"start_date": config.start_date,
|
||||||
|
"end_date": config.end_date,
|
||||||
|
"max_scenes": config.max_scenes,
|
||||||
|
"cloud_cover": config.max_cloud_cover,
|
||||||
|
"resolution": config.resolution,
|
||||||
|
"model_filename": config.model_filename
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_result = save_prediction_cache_sync(cache_config, s2_data)
|
||||||
|
print(f"[NDVI TIMESERIES][CACHE] Saved cache: {cache_result.get('filename')} (with data: {s2_data is not None})")
|
||||||
|
except Exception as cache_exc:
|
||||||
|
print(f"[NDVI TIMESERIES][CACHE ERROR] {cache_exc}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -4380,10 +4400,17 @@ async def ndvi_forecast(config: NDVIForecastConfig):
|
|||||||
|
|
||||||
# Mask cloud pixels
|
# Mask cloud pixels
|
||||||
scl = s2_data['SCL']
|
scl = s2_data['SCL']
|
||||||
cloud_mask = ~np.isin(scl, [3, 8, 9, 10, 0, 1])
|
# cloud_mask: shape (time, y, x) if scl is xarray.DataArray, else (time, y, x) ndarray
|
||||||
|
# If scl is xarray.DataArray, cloud_mask will be xarray.DataArray, else numpy ndarray
|
||||||
|
if hasattr(scl, 'isel'):
|
||||||
|
cloud_mask = ~np.isin(scl, [3, 8, 9, 10, 0, 1]) # xarray.DataArray
|
||||||
|
else:
|
||||||
|
# scl is numpy ndarray, so cloud_mask is ndarray
|
||||||
|
cloud_mask = ~np.isin(scl, [3, 8, 9, 10, 0, 1])
|
||||||
|
|
||||||
# LAND-TYPE-SPECIFIC FORECASTING
|
# LAND-TYPE-SPECIFIC FORECASTING
|
||||||
use_ml_classification = config.model_filename is not None
|
use_ml_classification = config.model_filename is not None
|
||||||
|
force_simple_forecast = False
|
||||||
|
|
||||||
if use_ml_classification:
|
if use_ml_classification:
|
||||||
print(f"\n🤖 Using ML model for land-type-specific forecasting...")
|
print(f"\n🤖 Using ML model for land-type-specific forecasting...")
|
||||||
@@ -4447,10 +4474,8 @@ async def ndvi_forecast(config: NDVIForecastConfig):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if len(point_features) == 0:
|
if len(point_features) == 0:
|
||||||
raise HTTPException(
|
print("[NDVI FORECAST][FALLBACK] No valid classification points. Falling back to simple seasonal forecast.")
|
||||||
status_code=404,
|
force_simple_forecast = True
|
||||||
detail=f"Không tìm thấy điểm hợp lệ để phân loại trong khu vực này. Thử: (1) Chọn khu vực lớn hơn, (2) Tăng historical_months, (3) Giảm max_cloud_cover, hoặc (4) Chọn khu vực có dữ liệu vệ tinh tốt hơn. Đã thử {config.sample_points} điểm ngẫu nhiên nhưng tất cả đều bị masked (mây/nước)."
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"✅ Extracted features for {len(point_features)} valid points")
|
print(f"✅ Extracted features for {len(point_features)} valid points")
|
||||||
|
|
||||||
@@ -4480,8 +4505,13 @@ async def ndvi_forecast(config: NDVIForecastConfig):
|
|||||||
month = time_val.month
|
month = time_val.month
|
||||||
|
|
||||||
# Get valid pixels for this time step
|
# Get valid pixels for this time step
|
||||||
mask_t = cloud_mask.isel(time=time_idx)
|
if hasattr(cloud_mask, 'isel'):
|
||||||
|
mask_t = cloud_mask.isel(time=time_idx)
|
||||||
|
else:
|
||||||
|
# Wrap numpy mask to xarray with same coords/dims as ndvi slice
|
||||||
|
ndvi_slice = ndvi.isel(time=time_idx)
|
||||||
|
mask_t = xr.DataArray(mask_t := cloud_mask[time_idx], coords=ndvi_slice.coords, dims=ndvi_slice.dims)
|
||||||
|
|
||||||
ndvi_t = ndvi.isel(time=time_idx).where(mask_t)
|
ndvi_t = ndvi.isel(time=time_idx).where(mask_t)
|
||||||
ndwi_t = ndwi.isel(time=time_idx).where(mask_t)
|
ndwi_t = ndwi.isel(time=time_idx).where(mask_t)
|
||||||
ndbi_t = ndbi.isel(time=time_idx).where(mask_t)
|
ndbi_t = ndbi.isel(time=time_idx).where(mask_t)
|
||||||
@@ -4539,55 +4569,67 @@ async def ndvi_forecast(config: NDVIForecastConfig):
|
|||||||
}
|
}
|
||||||
|
|
||||||
print(f"✅ Calculated patterns for {len(land_type_seasonal_stats)} land types")
|
print(f"✅ Calculated patterns for {len(land_type_seasonal_stats)} land types")
|
||||||
|
|
||||||
|
if len(land_type_seasonal_stats) == 0:
|
||||||
|
print("[NDVI FORECAST][FALLBACK] No seasonal patterns. Falling back to simple seasonal forecast.")
|
||||||
|
force_simple_forecast = True
|
||||||
|
|
||||||
# Generate forecast using land-type-weighted average
|
# Generate forecast using land-type-weighted average
|
||||||
print(f"\n🔮 Generating land-type-specific forecast...")
|
if force_simple_forecast:
|
||||||
|
print("[NDVI FORECAST][FALLBACK] Skipping ML forecast, will use simple seasonal averaging.")
|
||||||
forecast_timeseries = []
|
else:
|
||||||
current_date = forecast_start
|
print(f"\n🔮 Generating land-type-specific forecast...")
|
||||||
|
|
||||||
# Calculate land type weights
|
|
||||||
total_points = len(predictions)
|
|
||||||
land_type_weights = {lt: np.sum(predictions == lt) / total_points
|
|
||||||
for lt in unique_types}
|
|
||||||
|
|
||||||
while current_date <= forecast_end:
|
|
||||||
month = current_date.month
|
|
||||||
|
|
||||||
# Aggregate forecast across all land types (weighted)
|
forecast_timeseries = []
|
||||||
weighted_forecast = {
|
current_date = forecast_start
|
||||||
'ndvi_mean': 0, 'ndvi_min': 0, 'ndvi_max': 0, 'ndvi_std': 0,
|
|
||||||
'ndvi_range': 0, 'ndwi_mean': 0, 'ndbi_mean': 0, 'evi_mean': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
land_type_contributions = {}
|
# Calculate land type weights
|
||||||
|
total_points = len(predictions)
|
||||||
|
if total_points == 0:
|
||||||
|
print("[NDVI FORECAST][FALLBACK] Zero valid classified points. Switching to simple seasonal forecast.")
|
||||||
|
force_simple_forecast = True
|
||||||
|
else:
|
||||||
|
land_type_weights = {lt: np.sum(predictions == lt) / total_points
|
||||||
|
for lt in unique_types}
|
||||||
|
|
||||||
for land_type, weight in land_type_weights.items():
|
if not force_simple_forecast:
|
||||||
if land_type in land_type_seasonal_stats and month in land_type_seasonal_stats[land_type]:
|
while current_date <= forecast_end:
|
||||||
stats = land_type_seasonal_stats[land_type][month]
|
month = current_date.month
|
||||||
|
|
||||||
land_type_contributions[int(land_type)] = {
|
# Aggregate forecast across all land types (weighted)
|
||||||
**stats,
|
weighted_forecast = {
|
||||||
'weight': float(weight)
|
'ndvi_mean': 0, 'ndvi_min': 0, 'ndvi_max': 0, 'ndvi_std': 0,
|
||||||
|
'ndvi_range': 0, 'ndwi_mean': 0, 'ndbi_mean': 0, 'evi_mean': 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for key in weighted_forecast:
|
land_type_contributions = {}
|
||||||
weighted_forecast[key] += stats[key] * weight
|
|
||||||
|
for land_type, weight in land_type_weights.items():
|
||||||
if land_type_contributions:
|
if land_type in land_type_seasonal_stats and month in land_type_seasonal_stats[land_type]:
|
||||||
forecast_data = {
|
stats = land_type_seasonal_stats[land_type][month]
|
||||||
'date': current_date.strftime('%Y-%m-%d'),
|
|
||||||
'is_forecast': True,
|
land_type_contributions[int(land_type)] = {
|
||||||
'land_type_specific': land_type_contributions,
|
**stats,
|
||||||
**weighted_forecast
|
'weight': float(weight)
|
||||||
}
|
}
|
||||||
forecast_timeseries.append(forecast_data)
|
|
||||||
|
for key in weighted_forecast:
|
||||||
current_date += relativedelta(months=1)
|
weighted_forecast[key] += stats[key] * weight
|
||||||
|
|
||||||
method_used = "Land-Type-Specific Forecasting (ML-Enhanced)"
|
if land_type_contributions:
|
||||||
|
forecast_data = {
|
||||||
else:
|
'date': current_date.strftime('%Y-%m-%d'),
|
||||||
|
'is_forecast': True,
|
||||||
|
'land_type_specific': land_type_contributions,
|
||||||
|
**weighted_forecast
|
||||||
|
}
|
||||||
|
forecast_timeseries.append(forecast_data)
|
||||||
|
|
||||||
|
current_date += relativedelta(months=1)
|
||||||
|
|
||||||
|
method_used = "Land-Type-Specific Forecasting (ML-Enhanced)"
|
||||||
|
|
||||||
|
if force_simple_forecast or not use_ml_classification:
|
||||||
# Simple seasonal averaging (fallback)
|
# Simple seasonal averaging (fallback)
|
||||||
print(f"\n📈 Calculating simple seasonal patterns (no ML)...")
|
print(f"\n📈 Calculating simple seasonal patterns (no ML)...")
|
||||||
|
|
||||||
|
|||||||
+87
-22
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>NDVI Time Series Analysis</title>
|
<title>NDVI Time Series Predicts</title>
|
||||||
|
|
||||||
<!-- Leaflet CSS -->
|
<!-- Leaflet CSS -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
@@ -131,6 +131,22 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.region-filter-btn {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
color: #666;
|
||||||
|
border: none;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-filter-btn.active {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
#map {
|
#map {
|
||||||
height: 400px;
|
height: 400px;
|
||||||
@@ -199,7 +215,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>🌿 NDVI Time Series Analysis</h1>
|
<h1>🌿 NDVI Time Series Predicts</h1>
|
||||||
<p>Phân tích chỉ số thực vật theo thời gian từ dữ liệu Sentinel-2</p>
|
<p>Phân tích chỉ số thực vật theo thời gian từ dữ liệu Sentinel-2</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,7 +224,7 @@
|
|||||||
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
|
<a href="/training" style="padding: 10px 20px; background: #f093fb; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🎓 Training</a>
|
||||||
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
|
<a href="/prediction" style="padding: 10px 20px; background: #4facfe; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🗺️ Prediction</a>
|
||||||
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
|
<a href="/batch" style="padding: 10px 20px; background: #764ba2; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🚀 Batch Processing</a>
|
||||||
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Analysis (Active)</a>
|
<a href="/ndvi" style="padding: 10px 20px; background: #2ecc71; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">🌿 NDVI Predicts (Active)</a>
|
||||||
<a href="/reports" style="padding: 10px 20px; background: #ff6b6b; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">📝 Reports</a>
|
<a href="/reports" style="padding: 10px 20px; background: #ff6b6b; color: white; border-radius: 8px; text-decoration: none; font-weight: 600;">📝 Reports</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -251,7 +267,7 @@
|
|||||||
|
|
||||||
<!-- Region Filters -->
|
<!-- Region Filters -->
|
||||||
<div id="regionFilterContainer" style="display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;">
|
<div id="regionFilterContainer" style="display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px;">
|
||||||
<button type="button" class="region-filter-btn" data-region="all" style="padding: 3px 10px; background: #667eea; color: white; border: none; border-radius: 15px; font-size: 0.85em; cursor: pointer;">Tất cả</button>
|
<button type="button" class="region-filter-btn active" data-region="all">Tất cả</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select id="provinceSelect" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 5px;">
|
<select id="provinceSelect" style="width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 5px;">
|
||||||
@@ -397,7 +413,18 @@
|
|||||||
let allProvincesMerged = {};
|
let allProvincesMerged = {};
|
||||||
let currentProvinceName = '';
|
let currentProvinceName = '';
|
||||||
let currentProvinceMode = '63';
|
let currentProvinceMode = '63';
|
||||||
|
let currentRegionFilter = 'all';
|
||||||
let currentAnalysisMode = 'historical';
|
let currentAnalysisMode = 'historical';
|
||||||
|
|
||||||
|
// Check if date range goes into the future
|
||||||
|
function isFutureRange(startDate, endDate) {
|
||||||
|
if (!startDate || !endDate) return false;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
return start > today || end > today;
|
||||||
|
}
|
||||||
|
|
||||||
// Switch between historical and forecast mode
|
// Switch between historical and forecast mode
|
||||||
function switchMode(mode) {
|
function switchMode(mode) {
|
||||||
@@ -453,6 +480,25 @@
|
|||||||
|
|
||||||
// Execute analysis based on current mode
|
// Execute analysis based on current mode
|
||||||
async function executeAnalysis() {
|
async function executeAnalysis() {
|
||||||
|
const startDate = document.getElementById('startDate').value;
|
||||||
|
const endDate = document.getElementById('endDate').value;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
showError('Vui lòng chọn khoảng thời gian!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user picks a future range while in historical mode, switch to forecast automatically
|
||||||
|
if (currentAnalysisMode === 'historical' && isFutureRange(startDate, endDate)) {
|
||||||
|
switchMode('forecast');
|
||||||
|
const methodAlert = document.getElementById('methodAlert');
|
||||||
|
methodAlert.innerHTML = '<strong>🔄 Đã chuyển sang chế độ Dự đoán tương lai</strong><br>' +
|
||||||
|
'Khoảng thời gian nằm trong tương lai, hệ thống tự dùng mô hình forecast để tránh lỗi "No Sentinel-2 data".';
|
||||||
|
document.getElementById('errorContainer').style.display = 'none';
|
||||||
|
await forecastNDVI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (currentAnalysisMode === 'historical') {
|
if (currentAnalysisMode === 'historical') {
|
||||||
await predictNDVI();
|
await predictNDVI();
|
||||||
} else {
|
} else {
|
||||||
@@ -682,10 +728,11 @@
|
|||||||
|
|
||||||
// Update stats - handle both n_images (historical) and n_forecast_points (forecast)
|
// Update stats - handle both n_images (historical) and n_forecast_points (forecast)
|
||||||
const nImages = data.n_images || data.n_forecast_points || 0;
|
const nImages = data.n_images || data.n_forecast_points || 0;
|
||||||
|
const safeStat = (val) => (val === undefined || val === null || Number.isNaN(val) ? '-' : Number(val).toFixed(3));
|
||||||
document.getElementById('statImages').textContent = nImages;
|
document.getElementById('statImages').textContent = nImages;
|
||||||
document.getElementById('statAvgNDVI').textContent = data.mean_ndvi.toFixed(3);
|
document.getElementById('statAvgNDVI').textContent = safeStat(data.mean_ndvi);
|
||||||
document.getElementById('statMinNDVI').textContent = data.min_ndvi.toFixed(3);
|
document.getElementById('statMinNDVI').textContent = safeStat(data.min_ndvi);
|
||||||
document.getElementById('statMaxNDVI').textContent = data.max_ndvi.toFixed(3);
|
document.getElementById('statMaxNDVI').textContent = safeStat(data.max_ndvi);
|
||||||
|
|
||||||
// Create chart
|
// Create chart
|
||||||
createChart(data);
|
createChart(data);
|
||||||
@@ -699,6 +746,21 @@
|
|||||||
if (ndviChart) {
|
if (ndviChart) {
|
||||||
ndviChart.destroy();
|
ndviChart.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data.timeseries || data.timeseries.length === 0) {
|
||||||
|
showError('Không có dữ liệu để vẽ biểu đồ.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickValue = (item, keys) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = item[key];
|
||||||
|
if (val !== undefined && val !== null && !Number.isNaN(val)) {
|
||||||
|
return Number(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Format dates with more detail (dd/MM/yyyy)
|
// Format dates with more detail (dd/MM/yyyy)
|
||||||
const dates = data.timeseries.map(item => {
|
const dates = data.timeseries.map(item => {
|
||||||
@@ -708,9 +770,9 @@
|
|||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
return `${day}/${month}/${year}`;
|
return `${day}/${month}/${year}`;
|
||||||
});
|
});
|
||||||
const ndviValues = data.timeseries.map(item => item.mean_ndvi);
|
const ndviValues = data.timeseries.map(item => pickValue(item, ['mean_ndvi', 'ndvi_mean', 'ndvi']));
|
||||||
const minValues = data.timeseries.map(item => item.min_ndvi);
|
const minValues = data.timeseries.map(item => pickValue(item, ['min_ndvi', 'ndvi_min']));
|
||||||
const maxValues = data.timeseries.map(item => item.max_ndvi);
|
const maxValues = data.timeseries.map(item => pickValue(item, ['max_ndvi', 'ndvi_max']));
|
||||||
|
|
||||||
ndviChart = new Chart(ctx, {
|
ndviChart = new Chart(ctx, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@@ -837,7 +899,7 @@
|
|||||||
allProvinces = await response63.json();
|
allProvinces = await response63.json();
|
||||||
allProvincesMerged = await response32.json();
|
allProvincesMerged = await response32.json();
|
||||||
|
|
||||||
populateProvinceSelect();
|
populateProvinceSelect(currentRegionFilter);
|
||||||
populateRegionButtons();
|
populateRegionButtons();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading provinces:', error);
|
console.error('Error loading provinces:', error);
|
||||||
@@ -951,10 +1013,19 @@
|
|||||||
button.className = 'region-filter-btn';
|
button.className = 'region-filter-btn';
|
||||||
button.dataset.region = region;
|
button.dataset.region = region;
|
||||||
button.textContent = region;
|
button.textContent = region;
|
||||||
button.style.cssText = 'padding: 3px 10px; background: #e0e0e0; color: #666; border: none; border-radius: 15px; font-size: 0.85em; cursor: pointer;';
|
if (region === currentRegionFilter) {
|
||||||
|
button.classList.add('active');
|
||||||
|
}
|
||||||
container.appendChild(button);
|
container.appendChild(button);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-apply active state for "Tất cả"
|
||||||
|
if (currentRegionFilter === 'all') {
|
||||||
|
allButton.classList.add('active');
|
||||||
|
} else {
|
||||||
|
allButton.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
setupRegionFilters();
|
setupRegionFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,16 +1035,10 @@
|
|||||||
|
|
||||||
filterButtons.forEach(btn => {
|
filterButtons.forEach(btn => {
|
||||||
btn.addEventListener('click', function() {
|
btn.addEventListener('click', function() {
|
||||||
filterButtons.forEach(b => {
|
filterButtons.forEach(b => b.classList.remove('active'));
|
||||||
b.style.background = '#e0e0e0';
|
this.classList.add('active');
|
||||||
b.style.color = '#666';
|
currentRegionFilter = this.dataset.region;
|
||||||
});
|
populateProvinceSelect(currentRegionFilter);
|
||||||
|
|
||||||
this.style.background = '#27ae60';
|
|
||||||
this.style.color = 'white';
|
|
||||||
|
|
||||||
const region = this.dataset.region;
|
|
||||||
populateProvinceSelect(region);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user