1429 lines
60 KiB
Python
1429 lines
60 KiB
Python
r"""
|
||
Generate Interactive CI Dashboard for SmartCane ESA Project
|
||
|
||
This script creates an interactive HTML dashboard with Folium/Leaflet showing:
|
||
1. Current RGB composite map
|
||
2. Current CI (Chlorophyll Index) map
|
||
3. Previous week CI map
|
||
4. Week-over-week CI change map
|
||
|
||
The dashboard supports layer toggling, zooming, panning, and hover tooltips.
|
||
|
||
Usage:
|
||
python generate_interactive_ci_dashboard.py [estate_name] [current_week] [output_dir]
|
||
|
||
Example:
|
||
cd "c:\Users\timon\Resilience BV\4020 SCane ESA DEMO - Documenten\General\4020 SCDEMO Team\4020 TechnicalData\WP3\smartcane_v2\smartcane" ; python python_scripts/generate_interactive_ci_dashboard.py esa --current-week 43 --previous-week 42 --output-dir output
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import argparse
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
from typing import Optional, Tuple
|
||
import warnings
|
||
|
||
try:
|
||
import numpy as np
|
||
import rasterio
|
||
from rasterio.plot import show
|
||
from rasterio.features import rasterize
|
||
from rasterio.transform import from_bounds
|
||
import folium
|
||
from folium import plugins
|
||
import geopandas as gpd
|
||
from shapely.geometry import shape
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.cm as cm
|
||
from matplotlib.colors import Normalize
|
||
except ImportError as e:
|
||
print(f"Error: Required package not found. {e}")
|
||
print("Install required packages with:")
|
||
print(" pip install numpy rasterio folium geopandas matplotlib")
|
||
sys.exit(1)
|
||
|
||
warnings.filterwarnings('ignore')
|
||
|
||
|
||
class InteractiveCIDashboard:
|
||
"""Generate interactive CI analysis dashboard using Folium."""
|
||
|
||
def __init__(self, estate_name: str, data_dir: str, output_dir: str):
|
||
"""
|
||
Initialize dashboard generator.
|
||
|
||
Args:
|
||
estate_name: Name of estate (e.g., 'esa', 'aura', 'simba')
|
||
data_dir: Base directory containing weekly mosaic data
|
||
output_dir: Directory to save generated HTML dashboard
|
||
"""
|
||
self.estate_name = estate_name.lower()
|
||
self.data_dir = Path(data_dir)
|
||
self.output_dir = Path(output_dir)
|
||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Data paths
|
||
self.weekly_mosaic_dir = self.data_dir / "weekly_mosaic"
|
||
|
||
# Try multiple paths for field boundaries (in order of preference)
|
||
self.field_boundaries_path = None
|
||
possible_paths = [
|
||
self.data_dir / "Data" / "pivot.geojson", # laravel_app/storage/app/esa/Data/pivot.geojson
|
||
self.data_dir / "pivot.geojson", # laravel_app/storage/app/esa/pivot.geojson
|
||
self.data_dir / ".." / ".." / "pivot.geojson", # Up from esa/weekly_mosaic to smartcane/
|
||
Path(__file__).parent.parent / "r_app" / "experiments" / "pivot.geojson", # r_app/experiments/pivot.geojson
|
||
Path(__file__).parent.parent.parent / "r_app" / "experiments" / "pivot.geojson", # One more level up
|
||
]
|
||
|
||
print(f" Looking for pivot.geojson...")
|
||
for path in possible_paths:
|
||
resolved = path.resolve()
|
||
print(f" Checking: {resolved}")
|
||
if resolved.exists():
|
||
self.field_boundaries_path = resolved
|
||
print(f" ✓ Found at: {resolved}")
|
||
break
|
||
|
||
if self.field_boundaries_path is None:
|
||
print(f" ⚠ WARNING: pivot.geojson not found in any expected location")
|
||
|
||
# Validate directories
|
||
if not self.weekly_mosaic_dir.exists():
|
||
raise FileNotFoundError(f"Weekly mosaic directory not found: {self.weekly_mosaic_dir}")
|
||
|
||
self.field_boundaries = None
|
||
self.ci_data = {}
|
||
self.rgb_data = {}
|
||
self.bounds = None
|
||
|
||
print(f"✓ Dashboard initialized for {self.estate_name}")
|
||
print(f" Data directory: {self.data_dir}")
|
||
print(f" Output directory: {self.output_dir}")
|
||
|
||
def load_field_boundaries(self) -> gpd.GeoDataFrame:
|
||
"""Load field boundaries from GeoJSON."""
|
||
if self.field_boundaries_path is None:
|
||
print(f"✗ Field boundaries path not set - file not found in initialization")
|
||
return None
|
||
|
||
if not self.field_boundaries_path.exists():
|
||
print(f"✗ Field boundaries file not found at {self.field_boundaries_path}")
|
||
return None
|
||
|
||
try:
|
||
print(f"Loading field boundaries from: {self.field_boundaries_path}")
|
||
gdf = gpd.read_file(str(self.field_boundaries_path))
|
||
print(f"✓ Loaded field boundaries: {len(gdf)} features")
|
||
print(f" CRS: {gdf.crs}")
|
||
print(f" Columns: {list(gdf.columns)}")
|
||
if len(gdf) > 0:
|
||
print(f" First feature: {gdf.iloc[0]['field']} / {gdf.iloc[0].get('sub_field', 'N/A')}")
|
||
return gdf
|
||
except Exception as e:
|
||
print(f"✗ Error loading field boundaries: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return None
|
||
|
||
def find_week_file(self, week: int, year: int = 2025) -> Optional[Path]:
|
||
"""Find the weekly mosaic file for a given week."""
|
||
filename = f"week_{week}_{year}.tif"
|
||
filepath = self.weekly_mosaic_dir / filename
|
||
|
||
if filepath.exists():
|
||
return filepath
|
||
else:
|
||
print(f"⚠ Week file not found: {filename}")
|
||
return None
|
||
|
||
def load_raster_bands(self, filepath: Path, bands: list) -> dict:
|
||
"""
|
||
Load specific bands from a raster file.
|
||
|
||
Args:
|
||
filepath: Path to raster file
|
||
bands: List of band names to extract (e.g., ['Red', 'Green', 'Blue', 'CI'])
|
||
|
||
Returns:
|
||
Dictionary with band names as keys and numpy arrays as values
|
||
"""
|
||
try:
|
||
with rasterio.open(filepath) as src:
|
||
# Get band indices based on names
|
||
band_data = {}
|
||
|
||
# Try to get band names from rasterio
|
||
all_bands = [src.descriptions[i] if src.descriptions[i] else str(i+1)
|
||
for i in range(src.count)]
|
||
|
||
print(f" Available bands: {all_bands}")
|
||
|
||
for band_name in bands:
|
||
# Try to find band by name
|
||
try:
|
||
if band_name in all_bands:
|
||
idx = all_bands.index(band_name) + 1
|
||
else:
|
||
# Try by index if name not found
|
||
idx = int(band_name) if band_name.isdigit() else None
|
||
if idx is None:
|
||
print(f" ⚠ Band '{band_name}' not found, skipping")
|
||
continue
|
||
|
||
band_data[band_name] = src.read(idx)
|
||
except Exception as e:
|
||
print(f" ⚠ Error reading band '{band_name}': {e}")
|
||
|
||
# Store metadata
|
||
self.bounds = src.bounds
|
||
self.crs = src.crs
|
||
self.transform = src.transform
|
||
|
||
return band_data
|
||
except Exception as e:
|
||
print(f"✗ Error loading raster {filepath}: {e}")
|
||
return {}
|
||
|
||
def normalize_raster(self, data: np.ndarray, vmin: Optional[float] = None,
|
||
vmax: Optional[float] = None, mask_invalid: bool = True) -> np.ndarray:
|
||
"""
|
||
Normalize raster data to 0-255 range for visualization.
|
||
|
||
Args:
|
||
data: Numpy array
|
||
vmin: Minimum value for normalization
|
||
vmax: Maximum value for normalization
|
||
mask_invalid: If True, set invalid values to 0 (will be transparent in image)
|
||
|
||
Returns:
|
||
Normalized array (0-255)
|
||
"""
|
||
# Create mask for invalid values
|
||
invalid_mask = ~np.isfinite(data)
|
||
|
||
# Remove invalid values for statistics
|
||
valid_data = data[~invalid_mask]
|
||
|
||
if len(valid_data) == 0:
|
||
return np.zeros_like(data, dtype=np.uint8)
|
||
|
||
if vmin is None:
|
||
vmin = np.percentile(valid_data, 2)
|
||
if vmax is None:
|
||
vmax = np.percentile(valid_data, 98)
|
||
|
||
# Normalize
|
||
normalized = np.clip((data - vmin) / (vmax - vmin + 1e-8) * 255, 0, 255)
|
||
|
||
# Set invalid values to 0 (will be transparent when converted to RGBA)
|
||
if mask_invalid:
|
||
normalized[invalid_mask] = 0
|
||
|
||
return normalized.astype(np.uint8)
|
||
|
||
def create_rgb_composite(self, r: np.ndarray, g: np.ndarray, b: np.ndarray) -> np.ndarray:
|
||
"""Create RGB composite from individual bands with transparency for NA values."""
|
||
# Create mask for invalid values (NA values in any band)
|
||
invalid_mask = ~(np.isfinite(r) & np.isfinite(g) & np.isfinite(b))
|
||
|
||
# Normalize each band
|
||
r_norm = self.normalize_raster(r, vmin=10, vmax=150, mask_invalid=False)
|
||
g_norm = self.normalize_raster(g, vmin=10, vmax=130, mask_invalid=False)
|
||
b_norm = self.normalize_raster(b, vmin=3, vmax=100, mask_invalid=False)
|
||
|
||
# Stack bands and add alpha channel
|
||
rgb = np.dstack([r_norm, g_norm, b_norm])
|
||
|
||
# Create RGBA with transparency for NA values
|
||
rgba = np.dstack([rgb, np.ones((*rgb.shape[:2], 1), dtype=np.uint8) * 255])
|
||
rgba[invalid_mask, 3] = 0 # Set alpha to 0 (transparent) for NA values
|
||
|
||
return rgba.astype(np.uint8)
|
||
|
||
def raster_to_image(self, data: np.ndarray, colormap: str = 'viridis') -> np.ndarray:
|
||
"""
|
||
Convert single-band raster to RGBA using colormap with transparency for NA values.
|
||
|
||
Args:
|
||
data: Single-band numpy array
|
||
colormap: Matplotlib colormap name
|
||
|
||
Returns:
|
||
RGBA image (H x W x 4) with alpha channel for NA masking
|
||
"""
|
||
# Create mask for invalid values
|
||
invalid_mask = ~np.isfinite(data)
|
||
|
||
# Get statistics from valid data only
|
||
valid_data = data[~invalid_mask]
|
||
if len(valid_data) == 0:
|
||
return np.ones((*data.shape, 4), dtype=np.uint8) * 255
|
||
|
||
vmin = np.percentile(valid_data, 2)
|
||
vmax = np.percentile(valid_data, 98)
|
||
|
||
# Normalize to 0-1 range
|
||
normalized = np.clip((data - vmin) / (vmax - vmin + 1e-8), 0, 1)
|
||
|
||
# Apply colormap
|
||
cmap = cm.get_cmap(colormap)
|
||
colored = cmap(normalized)
|
||
|
||
# Convert to 8-bit RGBA
|
||
rgba = (colored * 255).astype(np.uint8)
|
||
|
||
# Set alpha to 0 (transparent) for NA values
|
||
rgba[invalid_mask, 3] = 0
|
||
|
||
return rgba
|
||
|
||
def create_raster_image_url(self, data: np.ndarray, colormap: str = 'viridis',
|
||
fmt: str = 'png') -> str:
|
||
"""
|
||
Convert numpy array to base64 encoded image URL for Folium overlay.
|
||
Handles RGBA with transparency for NA masking.
|
||
|
||
Args:
|
||
data: Raster data
|
||
colormap: Colormap name
|
||
fmt: Image format ('png', 'jpeg')
|
||
|
||
Returns:
|
||
Base64 encoded data URL
|
||
"""
|
||
import io
|
||
import base64
|
||
from PIL import Image
|
||
|
||
# Convert to RGBA
|
||
if data.ndim == 3:
|
||
if data.shape[2] == 4:
|
||
# Already RGBA
|
||
rgba_data = data
|
||
elif data.shape[2] == 3:
|
||
# RGB - add alpha channel
|
||
alpha = np.ones((*data.shape[:2], 1), dtype=np.uint8) * 255
|
||
rgba_data = np.dstack([data, alpha])
|
||
else:
|
||
# Single band - apply colormap
|
||
rgba_data = self.raster_to_image(data, colormap)
|
||
else:
|
||
# Single band - apply colormap
|
||
rgba_data = self.raster_to_image(data, colormap)
|
||
|
||
# Convert to PIL Image with RGBA mode
|
||
img = Image.fromarray(rgba_data, mode='RGBA')
|
||
|
||
# Encode to base64
|
||
buffer = io.BytesIO()
|
||
img.save(buffer, format='PNG') # Always use PNG for transparency support
|
||
buffer.seek(0)
|
||
|
||
img_base64 = base64.b64encode(buffer.read()).decode()
|
||
data_url = f"data:image/png;base64,{img_base64}"
|
||
|
||
return data_url
|
||
|
||
def load_ci_and_rgb(self, week: int, week_label: str = "current") -> bool:
|
||
"""
|
||
Load CI and RGB data for a given week.
|
||
|
||
Args:
|
||
week: Week number
|
||
week_label: Label for storing data
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
filepath = self.find_week_file(week)
|
||
if filepath is None:
|
||
return False
|
||
|
||
print(f"Loading week {week} data...")
|
||
|
||
# Load all bands
|
||
bands_data = self.load_raster_bands(filepath, ['Red', 'Green', 'Blue', 'NIR', 'CI'])
|
||
|
||
if not bands_data:
|
||
return False
|
||
|
||
# Store RGB composite
|
||
if 'Red' in bands_data and 'Green' in bands_data and 'Blue' in bands_data:
|
||
self.rgb_data[week_label] = self.create_rgb_composite(
|
||
bands_data['Red'],
|
||
bands_data['Green'],
|
||
bands_data['Blue']
|
||
)
|
||
print(f" ✓ RGB composite created for {week_label}")
|
||
|
||
# Store CI data
|
||
if 'CI' in bands_data:
|
||
self.ci_data[week_label] = bands_data['CI']
|
||
print(f" ✓ CI data loaded for {week_label}")
|
||
|
||
return True
|
||
|
||
def create_base_map(self, center_lat: float = -26.75, center_lon: float = 31.78,
|
||
zoom_level: int = 14) -> folium.Map:
|
||
"""Create base Folium map with multiple tile options."""
|
||
map_obj = folium.Map(
|
||
location=[center_lat, center_lon],
|
||
zoom_start=zoom_level,
|
||
tiles=None # Don't add default tile
|
||
)
|
||
|
||
# Add multiple base layers
|
||
folium.TileLayer(
|
||
tiles='OpenStreetMap',
|
||
name='OpenStreetMap',
|
||
overlay=False,
|
||
control=True
|
||
).add_to(map_obj)
|
||
|
||
# Add Google Maps Satellite layer
|
||
folium.TileLayer(
|
||
tiles='https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||
attr='Google Satellite',
|
||
name='Google Satellite',
|
||
overlay=False,
|
||
control=True
|
||
).add_to(map_obj)
|
||
|
||
# Expose Leaflet map instance for custom JS (Leaflet Draw)
|
||
map_obj.get_root().html.add_child(
|
||
folium.Element('''
|
||
<script>
|
||
var mapExposed = false;
|
||
function exposeMap() {
|
||
if (!mapExposed) {
|
||
var maps = document.querySelectorAll('.folium-map');
|
||
if (maps.length > 0) {
|
||
for (var i = 0; i < maps.length; i++) {
|
||
if (maps[i]._leaflet_map) {
|
||
window._leaflet_map = maps[i]._leaflet_map;
|
||
window._leaflet_map._container.id = 'main-map-' + i;
|
||
mapExposed = true;
|
||
console.log('✓ Map exposed to window._leaflet_map');
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Try multiple times to expose map
|
||
setTimeout(exposeMap, 100);
|
||
setTimeout(exposeMap, 500);
|
||
setTimeout(exposeMap, 1000);
|
||
setTimeout(exposeMap, 2000);
|
||
setTimeout(exposeMap, 3000);
|
||
</script>
|
||
''')
|
||
)
|
||
|
||
return map_obj
|
||
|
||
def add_raster_layer(self, map_obj: folium.Map, data: np.ndarray,
|
||
bounds: Tuple, name: str, colormap: str = 'viridis',
|
||
opacity: float = 0.8) -> folium.Map:
|
||
"""
|
||
Add raster data as overlay layer on Folium map.
|
||
|
||
Args:
|
||
map_obj: Folium map object
|
||
data: Raster data (numpy array)
|
||
bounds: Raster bounds (west, south, east, north)
|
||
name: Layer name
|
||
colormap: Colormap for visualization
|
||
opacity: Layer opacity
|
||
|
||
Returns:
|
||
Updated map object
|
||
"""
|
||
try:
|
||
# Convert raster to image
|
||
if data.ndim == 3 and data.shape[2] == 3:
|
||
# Already RGB
|
||
image_url = self.create_raster_image_url(data, fmt='png')
|
||
else:
|
||
# Single band - apply colormap
|
||
image_url = self.create_raster_image_url(data, colormap=colormap, fmt='png')
|
||
|
||
# Add as image overlay
|
||
folium.raster_layers.ImageOverlay(
|
||
image=image_url,
|
||
bounds=[[bounds.bottom, bounds.left], [bounds.top, bounds.right]],
|
||
name=name,
|
||
opacity=opacity,
|
||
show=True
|
||
).add_to(map_obj)
|
||
|
||
print(f" ✓ Added layer: {name}")
|
||
return map_obj
|
||
except Exception as e:
|
||
print(f" ✗ Error adding raster layer '{name}': {e}")
|
||
return map_obj
|
||
|
||
def add_field_boundaries(self, map_obj: folium.Map, gdf: gpd.GeoDataFrame,
|
||
name: str = "Field Boundaries") -> folium.Map:
|
||
"""Add all field boundaries as a single toggle-able layer group with field labels on hover."""
|
||
try:
|
||
print(f" Creating field boundaries layer group with {len(gdf)} fields...")
|
||
|
||
# Create a feature group for all field boundaries
|
||
field_group = folium.FeatureGroup(name="Field Boundaries", show=True)
|
||
|
||
for idx, row in gdf.iterrows():
|
||
# Get field name
|
||
field_name = row.get('field', f"Field {idx}")
|
||
sub_field = row.get('sub_field', '')
|
||
label = f"{field_name} - {sub_field}" if sub_field else field_name
|
||
|
||
# Convert geometry to GeoJSON
|
||
geojson_data = json.loads(gpd.GeoSeries(row.geometry).to_json())
|
||
|
||
# Create style function with proper closure
|
||
def get_style(x, field_name=field_name):
|
||
return {
|
||
'color': '#333333',
|
||
'weight': 2,
|
||
'opacity': 0.8,
|
||
'fill': True,
|
||
'fillColor': '#ffffff',
|
||
'fillOpacity': 0.0, # Invisible fill, but makes hover area larger
|
||
'dashArray': '5, 5'
|
||
}
|
||
|
||
# Add field boundary to the feature group with better hover
|
||
folium.GeoJson(
|
||
geojson_data,
|
||
style_function=get_style,
|
||
highlight_function=lambda x: {
|
||
'fillColor': '#ffff00',
|
||
'fillOpacity': 0.1,
|
||
'weight': 3,
|
||
'color': '#ff6600'
|
||
},
|
||
tooltip=folium.Tooltip(label, sticky=False),
|
||
popup=folium.Popup(f'<b>{label}</b>', max_width=250)
|
||
).add_to(field_group)
|
||
|
||
# Add the feature group to the map
|
||
field_group.add_to(map_obj)
|
||
print(f" ✓ Added {len(gdf)} field boundaries with hover interaction in single layer group")
|
||
except Exception as e:
|
||
print(f" ✗ Error adding field boundaries: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
return map_obj
|
||
|
||
def calculate_ci_change(self, ci_current: np.ndarray, ci_previous: np.ndarray) -> np.ndarray:
|
||
"""
|
||
Calculate week-over-week CI change.
|
||
Only calculate change where BOTH current and previous have valid data.
|
||
"""
|
||
# Create mask for valid data in both weeks
|
||
valid_mask = np.isfinite(ci_current) & np.isfinite(ci_previous)
|
||
|
||
# Initialize result with NaN
|
||
change = np.full_like(ci_current, np.nan, dtype=np.float32)
|
||
|
||
# Calculate change only where both are valid
|
||
change[valid_mask] = ci_current[valid_mask] - ci_previous[valid_mask]
|
||
|
||
return change
|
||
|
||
def add_legend_and_descriptions(self, map_obj: folium.Map, current_week: int,
|
||
previous_week: int) -> folium.Map:
|
||
"""Add legend and layer descriptions to the map with collapsible sections."""
|
||
|
||
# Create legend HTML with collapsible sections
|
||
legend_html = '''
|
||
<style>
|
||
.legend-box {
|
||
position: fixed;
|
||
background-color: white;
|
||
border: 2px solid #666;
|
||
z-index: 9999;
|
||
border-radius: 5px;
|
||
font-family: Arial, sans-serif;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.legend-header {
|
||
background-color: #f5f5f5;
|
||
padding: 12px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid #ddd;
|
||
}
|
||
|
||
.legend-header:hover {
|
||
background-color: #efefef;
|
||
}
|
||
|
||
.legend-header h3 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.legend-toggle {
|
||
font-size: 16px;
|
||
color: #666;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.legend-content {
|
||
padding: 12px;
|
||
font-size: 12px;
|
||
max-height: 500px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.legend-content.collapsed {
|
||
display: none;
|
||
}
|
||
|
||
.legend-section {
|
||
margin-bottom: 15px;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.legend-section:last-child {
|
||
margin-bottom: 0;
|
||
padding-bottom: 0;
|
||
border-bottom: none;
|
||
}
|
||
|
||
.legend-section-title {
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.legend-section-text {
|
||
color: #666;
|
||
line-height: 1.4;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.color-bar {
|
||
display: flex;
|
||
height: 20px;
|
||
border-radius: 3px;
|
||
margin: 4px 0;
|
||
border: 1px solid #999;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.color-segment {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 9px;
|
||
font-weight: bold;
|
||
color: rgba(0,0,0,0.7);
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
border-bottom: 2px solid #ddd;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.tab-button {
|
||
padding: 8px 12px;
|
||
background-color: #f0f0f0;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
color: #666;
|
||
margin-right: 2px;
|
||
border-radius: 3px 3px 0 0;
|
||
}
|
||
|
||
.tab-button:hover {
|
||
background-color: #e0e0e0;
|
||
}
|
||
|
||
.tab-button.active {
|
||
background-color: #fff;
|
||
color: #333;
|
||
border-bottom: 2px solid #1a9850;
|
||
}
|
||
|
||
.tab-content {
|
||
display: none;
|
||
}
|
||
|
||
.tab-content.active {
|
||
display: block;
|
||
}
|
||
</style>
|
||
|
||
<!-- Title and Description Box -->
|
||
<div class="legend-box" style="top: 10px; left: 10px; width: 420px;">
|
||
<div class="legend-header">
|
||
<h3>SmartCane Interactive CI Dashboard</h3>
|
||
<span class="legend-toggle" onclick="toggleBox('title-box')">−</span>
|
||
</div>
|
||
<div id="title-box" class="legend-content">
|
||
|
||
<!-- Tabs -->
|
||
<div class="tabs">
|
||
<button class="tab-button active" onclick="switchTab(event, 'info-tab')">📊 Info</button>
|
||
<button class="tab-button" onclick="switchTab(event, 'nuances-tab')">⚙️ Data Nuances</button>
|
||
<button class="tab-button" onclick="switchTab(event, 'changes-tab')">📈 What Changes CI</button>
|
||
</div>
|
||
|
||
<!-- Info Tab -->
|
||
<div id="info-tab" class="tab-content active">
|
||
<div style="margin-bottom: 8px;">
|
||
<strong>Estate:</strong> ESA
|
||
</div>
|
||
<div style="margin-bottom: 8px;">
|
||
<strong>Week:</strong> %d vs %d
|
||
</div>
|
||
<div style="margin-bottom: 8px;">
|
||
<strong>Generated:</strong> %s
|
||
</div>
|
||
<div style="border-top: 1px solid #ddd; padding-top: 8px; margin-top: 8px; color: #666;">
|
||
<p style="margin: 0 0 6px 0;">This interactive map shows Chlorophyll Index (CI) data for sugarcane fields. CI is a vegetation index derived from satellite imagery that indicates plant health and vigor.</p>
|
||
<p style="margin: 0;">Use the <strong>layer selector (top right)</strong> to toggle between different map layers. Transparent areas = no data available.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Data Nuances Tab -->
|
||
<div id="nuances-tab" class="tab-content">
|
||
<div class="legend-section-title">☁️ Cloud Coverage</div>
|
||
<div class="legend-section-text" style="margin-bottom: 10px;">
|
||
<p style="margin: 0 0 6px 0;"><strong>White areas or holes:</strong> These are clouds or cloud shadows that could not be reliably processed. Planet satellite imagery (optical) cannot see through clouds.</p>
|
||
<p style="margin: 0 0 6px 0;"><strong>Current filtering:</strong> Basic cloud filtering is applied, but some clouds may remain.</p>
|
||
<p style="margin: 0;"><strong>Future improvement:</strong> Advanced cloud detection (OmniCloudMask) will be integrated to improve data quality.</p>
|
||
</div>
|
||
|
||
<div class="legend-section-title" style="margin-top: 10px;">🔍 Data Quality Notes</div>
|
||
<div class="legend-section-text">
|
||
<p style="margin: 0 0 6px 0;">• <strong>Resolution:</strong> 3m pixel size (accurate to 3m × 3m area on ground)</p>
|
||
<p style="margin: 0 0 6px 0;">• <strong>Frequency:</strong> Weekly composites from Planet satellite data</p>
|
||
<p style="margin: 0 0 6px 0;">• <strong>Temporal lag:</strong> May be 1-2 days behind current date due to processing</p>
|
||
<p style="margin: 0;"><strong>• NA values:</strong> Fields outside boundary or areas with data gaps appear transparent</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- What Changes CI Tab -->
|
||
<div id="changes-tab" class="tab-content">
|
||
<div class="legend-section-title">🌱 Factors That Increase CI</div>
|
||
<div class="legend-section-text" style="margin-bottom: 10px;">
|
||
<p style="margin: 0 0 4px 0;">✅ Normal crop growth (young to mature stage)</p>
|
||
<p style="margin: 0 0 4px 0;">✅ Adequate water availability (good irrigation)</p>
|
||
<p style="margin: 0 0 4px 0;">✅ Sufficient nutrient availability (N, P, K)</p>
|
||
<p style="margin: 0 0 4px 0;">✅ Favorable weather conditions</p>
|
||
<p style="margin: 0;✅ Canopy closure and full leaf development</p>
|
||
</div>
|
||
|
||
<div class="legend-section-title" style="margin-top: 10px;">🔴 Factors That Decrease CI</div>
|
||
<div class="legend-section-text" style="margin-bottom: 10px;">
|
||
<p style="margin: 0 0 4px 0;">❌ Drought stress or irrigation failure</p>
|
||
<p style="margin: 0 0 4px 0;">❌ Nutrient deficiency (especially nitrogen)</p>
|
||
<p style="margin: 0 0 4px 0;">❌ Disease or pest damage (rust, smut, borers)</p>
|
||
<p style="margin: 0 0 4px 0;">❌ Weed competition in young fields</p>
|
||
<p style="margin: 0 0 4px 0;">❌ Lodging (crop falling over)</p>
|
||
<p style="margin: 0;❌ Harvest operations (removing biomass)</p>
|
||
</div>
|
||
|
||
<div class="legend-section-title" style="margin-top: 10px;">⚡ Rapid Changes</div>
|
||
<div class="legend-section-text">
|
||
Large week-to-week changes may indicate: harvesting activity, major weather events, irrigation changes, or application of crop inputs. Always cross-check with field records.
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Legend Box -->
|
||
<div class="legend-box" style="bottom: 10px; left: 10px; width: 380px;">
|
||
<div class="legend-header">
|
||
<h3>Legend & Interpretation Guide</h3>
|
||
<span class="legend-toggle" onclick="toggleBox('legend-box')">−</span>
|
||
</div>
|
||
<div id="legend-box" class="legend-content">
|
||
|
||
<div class="legend-section">
|
||
<div class="legend-section-title">RGB Composite (Current Week)</div>
|
||
<div class="legend-section-text">
|
||
Natural color image showing actual field appearance. Green pixels = healthy vegetation, Brown/Red pixels = sparse vegetation or bare soil.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="legend-section">
|
||
<div class="legend-section-title">Chlorophyll Index (CI) - Current Week</div>
|
||
<div class="legend-section-text">
|
||
Measures plant chlorophyll content (plant health indicator). Higher values = healthier, more vigorous plants.
|
||
</div>
|
||
<div class="color-bar">
|
||
<div class="color-segment" style="background-color: #440154; color: white;">Bare (0-2)</div>
|
||
<div class="color-segment" style="background-color: #31688e; color: white;">Low (2-4)</div>
|
||
<div class="color-segment" style="background-color: #35b779; color: white;">Good (4-6)</div>
|
||
<div class="color-segment" style="background-color: #fde724; color: #000;">Excellent (6+)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="legend-section">
|
||
<div class="legend-section-title">CI - Previous Week</div>
|
||
<div class="legend-section-text">
|
||
Last week's Chlorophyll Index using the same color scale. Compare with current week to identify growth trends.
|
||
</div>
|
||
<div class="color-bar">
|
||
<div class="color-segment" style="background-color: #440154; color: white;">Bare</div>
|
||
<div class="color-segment" style="background-color: #31688e; color: white;">Low</div>
|
||
<div class="color-segment" style="background-color: #35b779; color: white;">Good</div>
|
||
<div class="color-segment" style="background-color: #fde724; color: #000;">Excellent</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="legend-section">
|
||
<div class="legend-section-title">📊 CI Change (Week-over-Week)</div>
|
||
<div class="legend-section-text">
|
||
Week-to-week difference in Chlorophyll Index. Shows where fields are improving or declining.
|
||
</div>
|
||
<div class="color-bar">
|
||
<div class="color-segment" style="background-color: #0d0887; color: white;">Large Decrease</div>
|
||
<div class="color-segment" style="background-color: #7e03a8; color: white;">Decrease</div>
|
||
<div class="color-segment" style="background-color: #cc4778; color: white;">Slight Change</div>
|
||
<div class="color-segment" style="background-color: #f89540; color: #000;">Increase</div>
|
||
<div class="color-segment" style="background-color: #fde724; color: #000;">Large Increase</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="legend-section">
|
||
<div class="legend-section-title">Field Boundaries</div>
|
||
<div class="legend-section-text">
|
||
Field polygons outlined in dashed dark lines. Use layer control (top right) to toggle field labels on/off.
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function toggleBox(boxId) {
|
||
const box = document.getElementById(boxId);
|
||
box.classList.toggle('collapsed');
|
||
|
||
// Update toggle symbol
|
||
const toggle = event.target;
|
||
if (box.classList.contains('collapsed')) {
|
||
toggle.textContent = '+';
|
||
} else {
|
||
toggle.textContent = '−';
|
||
}
|
||
}
|
||
|
||
function switchTab(evt, tabName) {
|
||
// Hide all tab contents
|
||
var tabContents = document.getElementsByClassName('tab-content');
|
||
for (var i = 0; i < tabContents.length; i++) {
|
||
tabContents[i].classList.remove('active');
|
||
}
|
||
|
||
// Remove active class from all tab buttons
|
||
var tabButtons = document.getElementsByClassName('tab-button');
|
||
for (var i = 0; i < tabButtons.length; i++) {
|
||
tabButtons[i].classList.remove('active');
|
||
}
|
||
|
||
// Show the current tab and mark button as active
|
||
document.getElementById(tabName).classList.add('active');
|
||
evt.currentTarget.classList.add('active');
|
||
}
|
||
</script>
|
||
''' % (current_week, previous_week, datetime.now().strftime('%Y-%m-%d %H:%M'))
|
||
|
||
map_obj.get_root().html.add_child(folium.Element(legend_html))
|
||
|
||
# Inject Turf.js for spatial operations and expose field GeoJSON to JS (if available)
|
||
try:
|
||
if self.field_boundaries is not None:
|
||
# Add Turf.js library
|
||
map_obj.get_root().html.add_child(folium.Element("<script src='https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js'></script>"))
|
||
|
||
# Serialize field boundaries GeoJSON and inject as window.fieldGeoJSON
|
||
try:
|
||
fb_geojson = self.field_boundaries.to_json()
|
||
map_obj.get_root().html.add_child(folium.Element(f"<script>window.fieldGeoJSON = {fb_geojson};</script>"))
|
||
except Exception as e:
|
||
print('⚠ Could not serialize field boundaries to GeoJSON for client-side lookup:', e)
|
||
except Exception:
|
||
# Non-fatal: continue without field injection
|
||
pass
|
||
|
||
# --- Coordinate Extraction Module (collapsible box, click-to-place with comments) ---
|
||
coord_html = '''
|
||
<style>
|
||
.coord-box {
|
||
position: fixed;
|
||
background-color: white;
|
||
border: 2px solid #666;
|
||
z-index: 9999;
|
||
border-radius: 5px;
|
||
font-family: Arial, sans-serif;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||
bottom: 10px;
|
||
right: 10px;
|
||
width: 360px;
|
||
padding: 12px;
|
||
}
|
||
.coord-title {
|
||
margin: 0 0 10px 0;
|
||
font-size: 14px;
|
||
color: #333;
|
||
font-weight: bold;
|
||
}
|
||
.coord-btn { margin: 4px 2px 4px 0; padding: 6px 10px; font-size: 11px; border-radius: 3px; border: 1px solid #aaa; background: #f0f0f0; cursor: pointer; font-weight: bold; }
|
||
.coord-btn:hover { background: #e0e0e0; }
|
||
.coord-btn.active { background: #4CAF50; color: white; border-color: #2d6b2f; }
|
||
.coord-input-group { margin: 8px 0; padding: 8px; background: #f9f9f9; border-radius: 3px; }
|
||
.coord-input-group label { display: block; margin-bottom: 4px; font-weight: bold; font-size: 11px; color: #333; }
|
||
.coord-input-group input { width: 100%; padding: 5px; font-size: 11px; border: 1px solid #ccc; border-radius: 3px; font-family: Arial; box-sizing: border-box; }
|
||
.coord-list { margin: 8px 0; padding: 8px; background: #f5f5f5; border-radius: 3px; font-size: 12px; max-height: 300px; overflow-y: auto; }
|
||
.coord-list h4 { margin: 0 0 6px 0; font-size: 12px; color: #333; }
|
||
.coord-list ul { padding: 0; list-style: none; margin: 0; }
|
||
.coord-list li { padding: 4px 0; font-size: 11px; color: #333; border-bottom: 1px solid #ddd; font-family: monospace; display: flex; justify-content: space-between; align-items: center; }
|
||
.coord-list li:last-child { border-bottom: none; }
|
||
.coord-list li strong { color: #1565c0; }
|
||
.coord-list li em { color: #d32f2f; font-style: italic; }
|
||
.coord-list li .coord-point-info { flex: 1; }
|
||
.coord-list li .coord-delete-btn { padding: 2px 6px; font-size: 10px; background: #ff6b6b; color: white; border: 1px solid #c92a2a; border-radius: 3px; cursor: pointer; margin-left: 8px; }
|
||
.coord-list li .coord-delete-btn:hover { background: #ff5252; }
|
||
</style>
|
||
<div class="coord-box">
|
||
<div class="coord-title">📍 Coordinate Extraction</div>
|
||
|
||
<div style="margin-bottom: 10px; padding: 8px; background: #e3f2fd; border-left: 3px solid #2196F3; font-size: 11px; color: #1565c0; border-radius: 2px;">
|
||
<strong>💡 Tip:</strong> Turn off "Field Boundaries" layer (top right) for easier point placement. Click "Place Points" to activate, then click on map areas you want to inspect. Export coordinates to send people to these locations.
|
||
</div>
|
||
|
||
<button class="coord-btn" id="coord-toggleDrawBtn" onclick="window.coordToggleDraw()">
|
||
➕ Place Points
|
||
</button>
|
||
<button class="coord-btn" onclick="window.coordExportGeoJSON()">
|
||
↓ GeoJSON
|
||
</button>
|
||
<button class="coord-btn" onclick="window.coordExportKML()">
|
||
↓ KML
|
||
</button>
|
||
<button class="coord-btn" onclick="window.coordClearPoints()" style="background: #ff6b6b; color: white; border-color: #c92a2a;">
|
||
🗑️ Clear
|
||
</button>
|
||
|
||
<div class="coord-input-group">
|
||
<label for="coord-field">Field name (optional):</label>
|
||
<input type="text" id="coord-field" placeholder="e.g., KHWC, 00F28..." />
|
||
</div>
|
||
|
||
<div class="coord-input-group">
|
||
<label for="coord-comment">Comment (optional):</label>
|
||
<input type="text" id="coord-comment" placeholder="e.g., weeds, irrigation issue, disease..." />
|
||
</div>
|
||
|
||
<div class="coord-list">
|
||
<h4>Points: <span id="coord-pointCount">0</span></h4>
|
||
<ul id="coord-points-list"></ul>
|
||
</div>
|
||
|
||
</div>
|
||
'''
|
||
# Inject script first (separate from HTML)
|
||
map_obj.get_root().html.add_child(folium.Element('''
|
||
<script>
|
||
// Initialize coordinate extraction functions
|
||
window.coordPoints = [];
|
||
window.coordDrawingMode = false;
|
||
window.coordMarkerGroup = null;
|
||
window.coordMapClickHandler = null;
|
||
|
||
// Function to find and setup the Leaflet map
|
||
window.coordFindMap = function() {
|
||
// Try multiple methods to find the map
|
||
var map = null;
|
||
|
||
// Method 1: Check window._leaflet_map (exposed earlier)
|
||
if (window._leaflet_map) {
|
||
map = window._leaflet_map;
|
||
console.log('✓ Found map via window._leaflet_map');
|
||
return map;
|
||
}
|
||
|
||
// Method 2: Search all window properties for map variables (Folium pattern: map_xxxxx)
|
||
if (!map) {
|
||
for (var prop in window) {
|
||
if (prop.startsWith('map_') && window[prop] && window[prop]._container) {
|
||
map = window[prop];
|
||
window._leaflet_map = map; // Cache it for future use
|
||
console.log('✓ Found map via window.' + prop);
|
||
return map;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Method 3: Check all elements with .folium-map class
|
||
if (!map) {
|
||
var mapContainers = document.querySelectorAll('.folium-map');
|
||
for (var i = 0; i < mapContainers.length; i++) {
|
||
if (mapContainers[i]._leaflet_id) {
|
||
// Iterate through all Leaflet layers to find the map
|
||
for (var leafletId in L._layers) {
|
||
var layer = L._layers[leafletId];
|
||
if (layer._container === mapContainers[i]) {
|
||
map = layer;
|
||
window._leaflet_map = map; // Cache it
|
||
console.log('✓ Found map via L._layers');
|
||
return map;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return map;
|
||
};
|
||
|
||
// Setup map click handler for point placement
|
||
window.coordSetupClickHandler = function(map) {
|
||
if (!map || window.coordMapClickHandler) return false;
|
||
|
||
// Create marker group if needed
|
||
if (!window.coordMarkerGroup) {
|
||
window.coordMarkerGroup = L.featureGroup().addTo(map);
|
||
console.log('✓ Created marker group');
|
||
}
|
||
|
||
// Initialize markers array to track marker references
|
||
if (!window.coordMarkers) {
|
||
window.coordMarkers = [];
|
||
}
|
||
|
||
// Setup click handler
|
||
window.coordMapClickHandler = function(e) {
|
||
if (!window.coordDrawingMode) return;
|
||
|
||
var latlng = e.latlng;
|
||
var fieldName = document.getElementById('coord-field').value || '';
|
||
var comment = document.getElementById('coord-comment').value || '';
|
||
|
||
// Add to points array (include field name if provided)
|
||
var pointIndex = window.coordPoints.length;
|
||
var ptObj = {lat: latlng.lat, lng: latlng.lng, comment: comment};
|
||
if (fieldName) ptObj.field = fieldName;
|
||
window.coordPoints.push(ptObj);
|
||
|
||
// Create marker with popup including field name
|
||
var marker = L.marker([latlng.lat, latlng.lng], { draggable: true });
|
||
var idx = pointIndex + 1;
|
||
var popupText = '<b>Point ' + idx + '</b><br>Lat: ' + latlng.lat.toFixed(6) + '<br>Lng: ' + latlng.lng.toFixed(6);
|
||
if (fieldName) popupText += '<br><strong>🏷️ Field:</strong> ' + fieldName;
|
||
if (comment) popupText += '<br><strong>📝 ' + comment + '</strong>';
|
||
|
||
marker.bindPopup(popupText);
|
||
marker.addTo(window.coordMarkerGroup);
|
||
|
||
// Store marker reference
|
||
window.coordMarkers[pointIndex] = marker;
|
||
|
||
// Clear input fields
|
||
document.getElementById('coord-field').value = '';
|
||
document.getElementById('coord-comment').value = '';
|
||
|
||
// Update UI
|
||
window.coordUpdatePointsList();
|
||
};
|
||
|
||
map.on('click', window.coordMapClickHandler);
|
||
console.log('✓ Map click handler setup complete');
|
||
return true;
|
||
};
|
||
|
||
// Toggle drawing mode
|
||
window.coordToggleDraw = function() {
|
||
// Try to find map
|
||
var map = window.coordFindMap();
|
||
|
||
if (!map) {
|
||
alert('Map is still loading. Please wait a moment and try again.');
|
||
console.log('✗ Map not found yet');
|
||
return;
|
||
}
|
||
|
||
// Setup click handler if not already done
|
||
if (!window.coordMapClickHandler) {
|
||
window.coordSetupClickHandler(map);
|
||
}
|
||
|
||
// Toggle mode
|
||
window.coordDrawingMode = !window.coordDrawingMode;
|
||
var btn = document.getElementById('coord-toggleDrawBtn');
|
||
|
||
if (window.coordDrawingMode) {
|
||
btn.classList.add('active');
|
||
btn.textContent = '⏸️ Stop Placing';
|
||
console.log('✓ Drawing mode ON');
|
||
} else {
|
||
btn.classList.remove('active');
|
||
btn.textContent = '➕ Place Points';
|
||
console.log('✓ Drawing mode OFF');
|
||
}
|
||
|
||
// Update point count
|
||
document.getElementById('coord-pointCount').textContent = window.coordPoints.length;
|
||
};
|
||
|
||
// Delete a specific point
|
||
window.coordDeletePoint = function(index) {
|
||
if (index < 0 || index >= window.coordPoints.length) return;
|
||
|
||
// Remove marker from map
|
||
if (window.coordMarkers && window.coordMarkers[index]) {
|
||
window.coordMarkerGroup.removeLayer(window.coordMarkers[index]);
|
||
}
|
||
|
||
// Remove from arrays
|
||
window.coordPoints.splice(index, 1);
|
||
window.coordMarkers.splice(index, 1);
|
||
|
||
// Update UI
|
||
window.coordUpdatePointsList();
|
||
|
||
console.log('✓ Deleted point ' + (index + 1));
|
||
};
|
||
|
||
// Update points list UI
|
||
window.coordUpdatePointsList = function() {
|
||
var list = document.getElementById('coord-points-list');
|
||
var count = document.getElementById('coord-pointCount');
|
||
if (!list || !count) return;
|
||
|
||
list.innerHTML = '';
|
||
count.textContent = window.coordPoints.length;
|
||
|
||
window.coordPoints.forEach(function(pt, i) {
|
||
var li = document.createElement('li');
|
||
var fieldText = pt.field ? ' <strong style="color: #1976d2;">[' + pt.field + ']</strong>' : '';
|
||
var commentText = pt.comment ? ' <em>' + pt.comment + '</em>' : '';
|
||
|
||
var infoSpan = document.createElement('span');
|
||
infoSpan.className = 'coord-point-info';
|
||
infoSpan.innerHTML = '<strong>P' + (i+1) + ':</strong> ' +
|
||
pt.lat.toFixed(6) + ', ' + pt.lng.toFixed(6) + fieldText + commentText;
|
||
|
||
var deleteBtn = document.createElement('button');
|
||
deleteBtn.className = 'coord-delete-btn';
|
||
deleteBtn.textContent = '✕';
|
||
deleteBtn.title = 'Delete this point';
|
||
deleteBtn.onclick = (function(idx) {
|
||
return function() { window.coordDeletePoint(idx); };
|
||
})(i);
|
||
|
||
li.appendChild(infoSpan);
|
||
li.appendChild(deleteBtn);
|
||
list.appendChild(li);
|
||
});
|
||
|
||
if (window.coordPoints.length === 0) {
|
||
var li = document.createElement('li');
|
||
li.style.color = '#999';
|
||
li.style.justifyContent = 'center';
|
||
li.innerHTML = 'No points yet';
|
||
list.appendChild(li);
|
||
}
|
||
};
|
||
|
||
// Export to GeoJSON
|
||
window.coordExportGeoJSON = function() {
|
||
if (window.coordPoints.length === 0) {
|
||
alert('No points to export');
|
||
return;
|
||
}
|
||
|
||
var geojson = {
|
||
type: 'FeatureCollection',
|
||
features: window.coordPoints.map(function(pt, i) {
|
||
return {
|
||
type: 'Feature',
|
||
geometry: {
|
||
type: 'Point',
|
||
coordinates: [pt.lng, pt.lat]
|
||
},
|
||
properties: {
|
||
name: 'Point ' + (i+1),
|
||
description: 'Lat: ' + pt.lat.toFixed(6) + ', Lng: ' + pt.lng.toFixed(6),
|
||
comment: pt.comment || '',
|
||
field: pt.field || ''
|
||
}
|
||
};
|
||
})
|
||
};
|
||
|
||
var blob = new Blob([JSON.stringify(geojson, null, 2)], {type: 'application/json'});
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'field_points_' + new Date().toISOString().split('T')[0] + '.geojson';
|
||
a.click();
|
||
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
|
||
|
||
console.log('✓ Exported ' + window.coordPoints.length + ' points to GeoJSON');
|
||
};
|
||
|
||
// Export to KML
|
||
window.coordExportKML = function() {
|
||
if (window.coordPoints.length === 0) {
|
||
alert('No points to export');
|
||
return;
|
||
}
|
||
|
||
var kml = '<?xml version="1.0" encoding="UTF-8"?>\\n' +
|
||
'<kml xmlns="http://www.opengis.net/kml/2.2">\\n' +
|
||
'<Document>\\n<name>Field Points</name>\\n';
|
||
|
||
window.coordPoints.forEach(function(pt, i) {
|
||
kml += '<Placemark>\\n<name>Point ' + (i+1) + '</name>\\n';
|
||
// Include field name and comment in description when present
|
||
var descParts = [];
|
||
if (pt.field) descParts.push('Field: ' + pt.field);
|
||
if (pt.comment) descParts.push(pt.comment);
|
||
if (descParts.length > 0) {
|
||
kml += '<description>' + descParts.join(' - ') + '</description>\\n';
|
||
}
|
||
kml += '<Point>\\n<coordinates>' + pt.lng + ',' + pt.lat + ',0</coordinates>\\n' +
|
||
'</Point>\\n</Placemark>\\n';
|
||
});
|
||
|
||
kml += '</Document>\\n</kml>';
|
||
|
||
var blob = new Blob([kml], {type: 'application/vnd.google-earth.kml+xml'});
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'field_points_' + new Date().toISOString().split('T')[0] + '.kml';
|
||
a.click();
|
||
setTimeout(function() { URL.revokeObjectURL(url); }, 1000);
|
||
|
||
console.log('✓ Exported ' + window.coordPoints.length + ' points to KML');
|
||
};
|
||
|
||
// Clear all points
|
||
window.coordClearPoints = function() {
|
||
if (window.coordPoints.length === 0) {
|
||
alert('No points to clear');
|
||
return;
|
||
}
|
||
|
||
if (confirm('Delete all ' + window.coordPoints.length + ' points?')) {
|
||
window.coordPoints = [];
|
||
window.coordMarkers = [];
|
||
if (window.coordMarkerGroup) {
|
||
window.coordMarkerGroup.clearLayers();
|
||
}
|
||
window.coordUpdatePointsList();
|
||
console.log('✓ Cleared all points');
|
||
}
|
||
};
|
||
|
||
// Initialize UI when DOM is ready
|
||
window.coordUpdatePointsList();
|
||
console.log('✓ Coordinate extraction module loaded');
|
||
</script>
|
||
'''))
|
||
|
||
# Then add the HTML element
|
||
map_obj.get_root().html.add_child(folium.Element(coord_html))
|
||
|
||
def generate_dashboard(self, current_week: int, previous_week: Optional[int] = None) -> str:
|
||
"""
|
||
Generate complete interactive dashboard.
|
||
|
||
Args:
|
||
current_week: Current week number
|
||
previous_week: Previous week number (default: current_week - 1)
|
||
|
||
Returns:
|
||
Path to generated HTML file
|
||
"""
|
||
if previous_week is None:
|
||
previous_week = current_week - 1
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"Generating Interactive CI Dashboard")
|
||
print(f"Estate: {self.estate_name}")
|
||
print(f"Current week: {current_week}, Previous week: {previous_week}")
|
||
print(f"{'='*60}\n")
|
||
|
||
# Load field boundaries
|
||
self.field_boundaries = self.load_field_boundaries()
|
||
|
||
# Load current week data
|
||
if not self.load_ci_and_rgb(current_week, "current"):
|
||
print(f"✗ Failed to load data for week {current_week}")
|
||
return None
|
||
|
||
# Load previous week data for comparison
|
||
if not self.load_ci_and_rgb(previous_week, "previous"):
|
||
print(f"⚠ Warning: Could not load data for week {previous_week}")
|
||
|
||
# Create base map
|
||
print("\nCreating map...")
|
||
map_obj = self.create_base_map()
|
||
|
||
# Add current RGB layer
|
||
if 'current' in self.rgb_data:
|
||
self.add_raster_layer(
|
||
map_obj,
|
||
self.rgb_data['current'],
|
||
self.bounds,
|
||
name="RGB Composite (Current Week)",
|
||
opacity=1.0
|
||
)
|
||
|
||
# Add current CI layer
|
||
if 'current' in self.ci_data:
|
||
self.add_raster_layer(
|
||
map_obj,
|
||
self.ci_data['current'],
|
||
self.bounds,
|
||
name="CI - Current Week (Week {})".format(current_week),
|
||
colormap='viridis',
|
||
opacity=1.0
|
||
)
|
||
|
||
# Add previous week CI layer
|
||
if 'previous' in self.ci_data:
|
||
self.add_raster_layer(
|
||
map_obj,
|
||
self.ci_data['previous'],
|
||
self.bounds,
|
||
name="CI - Previous Week (Week {})".format(previous_week),
|
||
colormap='viridis',
|
||
opacity=1.0
|
||
)
|
||
|
||
# Add CI change layer - using Plasma colormap
|
||
if 'current' in self.ci_data and 'previous' in self.ci_data:
|
||
ci_change = self.calculate_ci_change(
|
||
self.ci_data['current'],
|
||
self.ci_data['previous']
|
||
)
|
||
|
||
self.add_raster_layer(
|
||
map_obj,
|
||
ci_change,
|
||
self.bounds,
|
||
name="CI Change (Current - Previous)",
|
||
colormap='plasma', # Plasma for change visualization
|
||
opacity=1.0
|
||
)
|
||
|
||
# Add field boundaries
|
||
if self.field_boundaries is not None:
|
||
self.add_field_boundaries(map_obj, self.field_boundaries)
|
||
|
||
# Add layer control
|
||
folium.LayerControl(position='topright', collapsed=False).add_to(map_obj)
|
||
|
||
# Add legend and descriptions (includes title box)
|
||
self.add_legend_and_descriptions(map_obj, current_week, previous_week)
|
||
|
||
# Save map
|
||
output_file = self.output_dir / f"ci_dashboard_{self.estate_name}_w{current_week}.html"
|
||
map_obj.save(str(output_file))
|
||
|
||
print(f"\n{'='*60}")
|
||
print(f"✓ Dashboard generated successfully!")
|
||
print(f" Output: {output_file}")
|
||
print(f"{'='*60}\n")
|
||
|
||
return str(output_file)
|
||
|
||
|
||
def main():
|
||
"""Command-line interface."""
|
||
parser = argparse.ArgumentParser(
|
||
description='Generate interactive CI dashboard for SmartCane'
|
||
)
|
||
parser.add_argument(
|
||
'estate',
|
||
help='Estate name (e.g., esa, aura, simba)'
|
||
)
|
||
parser.add_argument(
|
||
'--current-week',
|
||
type=int,
|
||
default=None,
|
||
help='Current week number (default: current ISO week)'
|
||
)
|
||
parser.add_argument(
|
||
'--previous-week',
|
||
type=int,
|
||
default=None,
|
||
help='Previous week number (default: current_week - 1)'
|
||
)
|
||
parser.add_argument(
|
||
'--data-dir',
|
||
default='laravel_app/storage/app',
|
||
help='Base data directory containing weekly mosaic'
|
||
)
|
||
parser.add_argument(
|
||
'--output-dir',
|
||
default='output',
|
||
help='Output directory for HTML dashboard'
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Determine current week if not specified
|
||
if args.current_week is None:
|
||
args.current_week = datetime.now().isocalendar()[1]
|
||
|
||
if args.previous_week is None:
|
||
args.previous_week = args.current_week - 1
|
||
|
||
# Build full data path
|
||
data_dir = Path(args.data_dir) / args.estate
|
||
output_dir = Path(args.output_dir) / args.estate
|
||
|
||
try:
|
||
# Generate dashboard
|
||
dashboard = InteractiveCIDashboard(
|
||
estate_name=args.estate,
|
||
data_dir=str(data_dir),
|
||
output_dir=str(output_dir)
|
||
)
|
||
|
||
output_file = dashboard.generate_dashboard(
|
||
current_week=args.current_week,
|
||
previous_week=args.previous_week
|
||
)
|
||
|
||
if output_file:
|
||
print(f"\nTo view the dashboard, open:")
|
||
print(f" file:///{output_file}")
|
||
sys.exit(0)
|
||
else:
|
||
sys.exit(1)
|
||
|
||
except Exception as e:
|
||
print(f"\n✗ Error: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|