updated website and small things
This commit is contained in:
parent
fc7e5f1ee0
commit
dfa0aa900d
15
.renvignore
Normal file
15
.renvignore
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Ignore large Python experiment directories during renv dependency discovery
|
||||||
|
# These slow down startup and contain no R dependencies
|
||||||
|
|
||||||
|
laravel_app/
|
||||||
|
data_validation_tool/
|
||||||
|
python_app/harvest_detection_experiments/
|
||||||
|
python_app/experiments/
|
||||||
|
phase2_refinement/
|
||||||
|
webapps/
|
||||||
|
tools/
|
||||||
|
output/
|
||||||
|
renv/
|
||||||
|
*.py
|
||||||
|
*.ipynb
|
||||||
|
.git/
|
||||||
|
|
@ -31,6 +31,7 @@ suppressPackageStartupMessages({
|
||||||
library(readxl)
|
library(readxl)
|
||||||
library(here)
|
library(here)
|
||||||
library(furrr)
|
library(furrr)
|
||||||
|
library(future)
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. Process command line arguments
|
# 2. Process command line arguments
|
||||||
|
|
@ -118,19 +119,44 @@ main <- function() {
|
||||||
stop(e)
|
stop(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# 4. Generate date list for processing
|
# 4. Generate date list for processing
|
||||||
# ---------------------------------
|
# ---------------------------------
|
||||||
dates <- date_list(end_date, 7)
|
dates <- date_list(end_date, 7)
|
||||||
log_message(paste("Processing data for week", dates$week, "of", dates$year))
|
log_message(paste("Processing data for week", dates$week, "of", dates$year))
|
||||||
|
|
||||||
# 5. Find and filter raster files by date
|
# 5. Find and filter raster files by date - with grid size detection
|
||||||
# -----------------------------------
|
# -----------------------------------
|
||||||
log_message("Searching for raster files")
|
log_message("Searching for raster files")
|
||||||
|
|
||||||
# Check if tiles exist (Script 01 output)
|
# Check if tiles exist (Script 01 output) - detect grid size dynamically
|
||||||
tile_folder <- file.path("laravel_app", "storage", "app", project_dir, "daily_tiles_split")
|
tiles_split_base <- file.path("laravel_app", "storage", "app", project_dir, "daily_tiles_split")
|
||||||
|
|
||||||
|
# Detect grid size from daily_tiles_split folder structure
|
||||||
|
# Expected structure: daily_tiles_split/5x5/ or daily_tiles_split/10x10/ etc.
|
||||||
|
grid_size <- NA
|
||||||
|
if (dir.exists(tiles_split_base)) {
|
||||||
|
subfolders <- list.dirs(tiles_split_base, full.names = FALSE, recursive = FALSE)
|
||||||
|
# Look for grid size patterns like "5x5", "10x10", "20x20"
|
||||||
|
grid_patterns <- grep("^\\d+x\\d+$", subfolders, value = TRUE)
|
||||||
|
if (length(grid_patterns) > 0) {
|
||||||
|
grid_size <- grid_patterns[1] # Use first grid size found
|
||||||
|
log_message(paste("Detected grid size:", grid_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Construct tile folder path with grid size
|
||||||
|
if (!is.na(grid_size)) {
|
||||||
|
tile_folder <- file.path(tiles_split_base, grid_size)
|
||||||
|
} else {
|
||||||
|
tile_folder <- tiles_split_base
|
||||||
|
}
|
||||||
|
|
||||||
use_tiles <- dir.exists(tile_folder)
|
use_tiles <- dir.exists(tile_folder)
|
||||||
|
|
||||||
|
# Make grid_size available globally for other functions
|
||||||
|
assign("grid_size", grid_size, envir = .GlobalEnv)
|
||||||
|
|
||||||
tryCatch({
|
tryCatch({
|
||||||
if (use_tiles) {
|
if (use_tiles) {
|
||||||
# Use tile-based processing
|
# Use tile-based processing
|
||||||
|
|
@ -145,7 +171,8 @@ main <- function() {
|
||||||
field_boundaries_sf = field_boundaries_sf,
|
field_boundaries_sf = field_boundaries_sf,
|
||||||
daily_CI_vals_dir = daily_CI_vals_dir,
|
daily_CI_vals_dir = daily_CI_vals_dir,
|
||||||
cumulative_CI_vals_dir = cumulative_CI_vals_dir,
|
cumulative_CI_vals_dir = cumulative_CI_vals_dir,
|
||||||
merged_final_dir = merged_final
|
merged_final_dir = merged_final,
|
||||||
|
grid_size = grid_size
|
||||||
)
|
)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
1057
r_app/09c_field_analysis_weekly.R
Normal file
1057
r_app/09c_field_analysis_weekly.R
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,155 +0,0 @@
|
||||||
# Angata KPI Script Updates - 09_calculate_kpis_Angata.R
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
The script has been restructured to focus on **4 required KPIs** for Angata, with legacy KPIs disabled by default but retained for future use.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. **Script Configuration**
|
|
||||||
- **File**: `09_calculate_kpis_Angata.R`
|
|
||||||
- **Toggle Variable**: `ENABLE_LEGACY_KPIS` (default: `FALSE`)
|
|
||||||
- Set to `TRUE` to run the 6 original KPIs
|
|
||||||
- Set to `FALSE` for Angata's 4 KPIs only
|
|
||||||
|
|
||||||
### 2. **Angata KPIs (4 Required)**
|
|
||||||
|
|
||||||
#### KPI 1: **Area Change Summary** ✅ REAL DATA
|
|
||||||
- **File**: Embedded in script as `calculate_area_change_kpi()`
|
|
||||||
- **Method**: Compares current week CI to previous week CI
|
|
||||||
- **Classification**:
|
|
||||||
- **Improving areas**: Mean change > +0.5 CI units
|
|
||||||
- **Stable areas**: Mean change between -0.5 and +0.5 CI units
|
|
||||||
- **Declining areas**: Mean change < -0.5 CI units
|
|
||||||
- **Output**: Hectares, Acres, and % of farm for each category
|
|
||||||
- **Data Type**: REAL DATA (processed from satellite imagery)
|
|
||||||
|
|
||||||
#### KPI 2: **Germination Acreage** ✅ REAL DATA
|
|
||||||
- **Function**: `calculate_germination_acreage_kpi()`
|
|
||||||
- **Germination Phase Detection**:
|
|
||||||
- **Start germination**: When 10% of field's CI > 2
|
|
||||||
- **End germination**: When 70% of field's CI ≥ 2
|
|
||||||
- **Output**:
|
|
||||||
- Count of fields in germination phase
|
|
||||||
- Count of fields in post-germination phase
|
|
||||||
- Total acres and % of farm for each phase
|
|
||||||
- **Data Type**: REAL DATA (CI-based, calculated from satellite imagery)
|
|
||||||
|
|
||||||
#### KPI 3: **Harvested Acreage** ⚠️ DUMMY DATA
|
|
||||||
- **Function**: `calculate_harvested_acreage_kpi()`
|
|
||||||
- **Current Status**: Returns zero values with clear "DUMMY DATA - Detection TBD" label
|
|
||||||
- **TODO**: Implement harvesting detection logic
|
|
||||||
- Likely indicators: CI drops below 1.5, sudden backscatter change, etc.
|
|
||||||
- **Output Format**:
|
|
||||||
- Number of harvested fields
|
|
||||||
- Total acres
|
|
||||||
- % of farm
|
|
||||||
- Clearly marked as DUMMY DATA in output table
|
|
||||||
|
|
||||||
#### KPI 4: **Mature Acreage** ⚠️ DUMMY DATA
|
|
||||||
- **Function**: `calculate_mature_acreage_kpi()`
|
|
||||||
- **Current Status**: Returns zero values with clear "DUMMY DATA - Definition TBD" label
|
|
||||||
- **Concept**: Mature fields have high and stable CI for several weeks
|
|
||||||
- **TODO**: Implement stability-based maturity detection
|
|
||||||
- Calculate CI trend over last 3-4 weeks per field
|
|
||||||
- Stability metric: low CV over period, high CI relative to field max
|
|
||||||
- Threshold: e.g., field reaches 80%+ of max CI and stable for 3+ weeks
|
|
||||||
- **Output Format**:
|
|
||||||
- Number of mature fields
|
|
||||||
- Total acres
|
|
||||||
- % of farm
|
|
||||||
- Clearly marked as DUMMY DATA in output table
|
|
||||||
|
|
||||||
### 3. **Legacy KPIs (Disabled by Default)**
|
|
||||||
|
|
||||||
These original 6 KPIs are **disabled** but code is preserved for future use:
|
|
||||||
1. Field Uniformity Summary
|
|
||||||
2. TCH Forecasted
|
|
||||||
3. Growth Decline Index
|
|
||||||
4. Weed Presence Score
|
|
||||||
5. Gap Filling Score
|
|
||||||
|
|
||||||
To enable: Set `ENABLE_LEGACY_KPIS <- TRUE` in the script
|
|
||||||
|
|
||||||
### 4. **Output & Logging**
|
|
||||||
|
|
||||||
#### Console Output (STDOUT)
|
|
||||||
```
|
|
||||||
=== ANGATA KPI CALCULATION SUMMARY ===
|
|
||||||
Report Date: [date]
|
|
||||||
Current Week: [week]
|
|
||||||
Previous Week: [week]
|
|
||||||
Total Fields Analyzed: [count]
|
|
||||||
Project: [project_name]
|
|
||||||
Calculation Time: [timestamp]
|
|
||||||
Legacy KPIs Enabled: FALSE
|
|
||||||
|
|
||||||
--- REQUIRED ANGATA KPIs ---
|
|
||||||
|
|
||||||
1. Area Change Summary (REAL DATA):
|
|
||||||
[table]
|
|
||||||
|
|
||||||
2. Germination Acreage (CI-based, REAL DATA):
|
|
||||||
[table]
|
|
||||||
|
|
||||||
3. Harvested Acreage (DUMMY DATA - Detection TBD):
|
|
||||||
[table with "DUMMY" marker]
|
|
||||||
|
|
||||||
4. Mature Acreage (DUMMY DATA - Definition TBD):
|
|
||||||
[table with "DUMMY" marker]
|
|
||||||
|
|
||||||
=== ANGATA KPI CALCULATION COMPLETED ===
|
|
||||||
```
|
|
||||||
|
|
||||||
#### File Output (RDS)
|
|
||||||
- **Location**: `laravel_app/storage/app/[project]/reports/kpis/`
|
|
||||||
- **Filename**: `[project]_kpi_summary_tables_week[XX].rds`
|
|
||||||
- **Contents**:
|
|
||||||
- `area_change_summary`: Summary table
|
|
||||||
- `germination_summary`: Summary table
|
|
||||||
- `harvested_summary`: Summary table (DUMMY)
|
|
||||||
- `mature_summary`: Summary table (DUMMY)
|
|
||||||
- Field-level results for each KPI
|
|
||||||
- Metadata (report_date, weeks, total_fields, etc.)
|
|
||||||
|
|
||||||
### 5. **Data Clarity Markers**
|
|
||||||
|
|
||||||
All tables in output clearly indicate:
|
|
||||||
- **REAL DATA**: Derived from satellite CI measurements
|
|
||||||
- **DUMMY DATA - [TBD Item]**: Placeholder values; actual method to be implemented
|
|
||||||
|
|
||||||
This prevents misinterpretation of preliminary results.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Run Angata KPIs only (default, legacy disabled)
|
|
||||||
Rscript r_app/09_calculate_kpis_Angata.R 2025-11-27 7 angata
|
|
||||||
|
|
||||||
# With specific date
|
|
||||||
Rscript r_app/09_calculate_kpis_Angata.R 2025-11-20 7 angata
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Work
|
|
||||||
|
|
||||||
1. **Harvesting Detection**: Implement CI threshold + temporal pattern analysis
|
|
||||||
2. **Maturity Definition**: Define stability metrics and thresholds based on field CI ranges
|
|
||||||
3. **Legacy KPIs**: Adapt or retire based on Angata's needs
|
|
||||||
4. **Integration**: Connect outputs to reporting system (R Markdown, Word reports, etc.)
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
r_app/
|
|
||||||
├── 09_calculate_kpis_Angata.R (main script - UPDATED)
|
|
||||||
├── kpi_utils.R (optional - legacy functions)
|
|
||||||
├── crop_messaging_utils.R (dependencies)
|
|
||||||
├── parameters_project.R (project config)
|
|
||||||
└── growth_model_utils.R (optional)
|
|
||||||
|
|
||||||
Output:
|
|
||||||
└── laravel_app/storage/app/angata/reports/kpis/
|
|
||||||
└── angata_kpi_summary_tables_week[XX].rds
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
**Updated**: November 27, 2025
|
|
||||||
|
|
@ -653,21 +653,27 @@ process_ci_values <- function(dates, field_boundaries, merged_final_dir,
|
||||||
#' Process CI values from pre-split tiles (Script 01 output)
|
#' Process CI values from pre-split tiles (Script 01 output)
|
||||||
#'
|
#'
|
||||||
#' This function processes CI values from tiles instead of full-extent rasters.
|
#' This function processes CI values from tiles instead of full-extent rasters.
|
||||||
#' Tiles are created by Script 01 and stored in daily_tiles_split/[DATE]/ folders.
|
#' Tiles are created by Script 01 and stored in daily_tiles_split/[GRID_SIZE]/[DATE]/ folders.
|
||||||
#' For each field, it aggregates CI statistics from all tiles that intersect that field.
|
#' For each field, it aggregates CI statistics from all tiles that intersect that field.
|
||||||
|
#' Output follows the same grid structure: merged_final_tif/[GRID_SIZE]/[DATE]/
|
||||||
|
#'
|
||||||
|
#' NOTE: Processes dates SEQUENTIALLY but tiles WITHIN EACH DATE in parallel (furrr)
|
||||||
|
#' This avoids worker process communication issues while still getting good speedup.
|
||||||
#'
|
#'
|
||||||
#' @param dates List of dates from date_list()
|
#' @param dates List of dates from date_list()
|
||||||
#' @param tile_folder Path to the tile folder (daily_tiles_split)
|
#' @param tile_folder Path to the tile folder (daily_tiles_split/[GRID_SIZE])
|
||||||
#' @param field_boundaries Field boundaries as vector object
|
#' @param field_boundaries Field boundaries as vector object
|
||||||
#' @param field_boundaries_sf Field boundaries as SF object
|
#' @param field_boundaries_sf Field boundaries as SF object
|
||||||
#' @param daily_CI_vals_dir Directory to save daily CI values
|
#' @param daily_CI_vals_dir Directory to save daily CI values
|
||||||
#' @param cumulative_CI_vals_dir Directory to save cumulative CI values
|
#' @param cumulative_CI_vals_dir Directory to save cumulative CI values
|
||||||
#' @param merged_final_dir Directory to save processed tiles with CI band
|
#' @param merged_final_dir Base directory to save processed tiles with CI band
|
||||||
|
#' @param grid_size Grid size label (e.g., "5x5", "10x10") for output path structure
|
||||||
#' @return NULL (used for side effects)
|
#' @return NULL (used for side effects)
|
||||||
#'
|
#'
|
||||||
process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
field_boundaries_sf, daily_CI_vals_dir,
|
field_boundaries_sf, daily_CI_vals_dir,
|
||||||
cumulative_CI_vals_dir, merged_final_dir) {
|
cumulative_CI_vals_dir, merged_final_dir,
|
||||||
|
grid_size = NA) {
|
||||||
|
|
||||||
# Define path for combined CI data
|
# Define path for combined CI data
|
||||||
combined_ci_path <- here::here(cumulative_CI_vals_dir, "combined_CI_data.rds")
|
combined_ci_path <- here::here(cumulative_CI_vals_dir, "combined_CI_data.rds")
|
||||||
|
|
@ -691,11 +697,29 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
if (!file.exists(combined_ci_path)) {
|
if (!file.exists(combined_ci_path)) {
|
||||||
safe_log("combined_CI_data.rds does not exist. Creating new file with all available tile data.")
|
safe_log("combined_CI_data.rds does not exist. Creating new file with all available tile data.")
|
||||||
|
|
||||||
# Process all tile dates
|
# Process all tile dates SEQUENTIALLY but with parallel tile processing
|
||||||
|
# Tiles within each date are processed in parallel via extract_ci_from_tiles()
|
||||||
all_pivot_stats <- list()
|
all_pivot_stats <- list()
|
||||||
|
|
||||||
for (date in tile_dates) {
|
for (i in seq_along(tile_dates)) {
|
||||||
safe_log(paste("Processing tiles for date:", date))
|
date <- tile_dates[i]
|
||||||
|
|
||||||
|
# SKIP: Check if this date already has processed output tiles
|
||||||
|
if (!is.na(grid_size)) {
|
||||||
|
output_date_folder <- file.path(merged_final_dir, grid_size, date)
|
||||||
|
} else {
|
||||||
|
output_date_folder <- file.path(merged_final_dir, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir.exists(output_date_folder)) {
|
||||||
|
existing_tiles <- list.files(output_date_folder, pattern = "\\.tif$")
|
||||||
|
if (length(existing_tiles) > 0) {
|
||||||
|
safe_log(paste("[", i, "/", length(tile_dates), "] SKIP:", date, "- already has", length(existing_tiles), "tiles"))
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_log(paste("[", i, "/", length(tile_dates), "] Processing tiles for date:", date))
|
||||||
|
|
||||||
date_tile_dir <- file.path(tile_folder, date)
|
date_tile_dir <- file.path(tile_folder, date)
|
||||||
tile_files <- list.files(date_tile_dir, pattern = "\\.tif$", full.names = TRUE)
|
tile_files <- list.files(date_tile_dir, pattern = "\\.tif$", full.names = TRUE)
|
||||||
|
|
@ -705,15 +729,17 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
next
|
next
|
||||||
}
|
}
|
||||||
|
|
||||||
safe_log(paste(" Found", length(tile_files), "tiles for date", date))
|
safe_log(paste(" Found", length(tile_files), "tiles - processing in parallel"))
|
||||||
|
|
||||||
# Process all tiles for this date and aggregate to fields
|
# Process all tiles for this date and aggregate to fields
|
||||||
|
# Tiles are processed in parallel via furrr::future_map() inside extract_ci_from_tiles()
|
||||||
date_stats <- extract_ci_from_tiles(
|
date_stats <- extract_ci_from_tiles(
|
||||||
tile_files = tile_files,
|
tile_files = tile_files,
|
||||||
date = date,
|
date = date,
|
||||||
field_boundaries_sf = field_boundaries_sf,
|
field_boundaries_sf = field_boundaries_sf,
|
||||||
daily_CI_vals_dir = daily_CI_vals_dir,
|
daily_CI_vals_dir = daily_CI_vals_dir,
|
||||||
merged_final_tif_dir = merged_final_dir
|
merged_final_tif_dir = merged_final_dir,
|
||||||
|
grid_size = grid_size
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!is.null(date_stats)) {
|
if (!is.null(date_stats)) {
|
||||||
|
|
@ -735,13 +761,37 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
# Process only new dates
|
# Process only new dates SEQUENTIALLY but with parallel tile processing
|
||||||
safe_log("combined_CI_data.rds exists, adding new tile data.")
|
safe_log("combined_CI_data.rds exists, adding new tile data.")
|
||||||
|
|
||||||
|
if (length(dates_to_process) == 0) {
|
||||||
|
safe_log("No new dates to process", "WARNING")
|
||||||
|
return(invisible(NULL))
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_log(paste("Processing", length(dates_to_process), "new dates..."))
|
||||||
|
|
||||||
new_pivot_stats_list <- list()
|
new_pivot_stats_list <- list()
|
||||||
|
|
||||||
for (date in dates_to_process[1:2]) {
|
for (i in seq_along(dates_to_process)) {
|
||||||
safe_log(paste("Processing tiles for date:", date))
|
date <- dates_to_process[i]
|
||||||
|
|
||||||
|
# SKIP: Check if this date already has processed output tiles
|
||||||
|
if (!is.na(grid_size)) {
|
||||||
|
output_date_folder <- file.path(merged_final_dir, grid_size, date)
|
||||||
|
} else {
|
||||||
|
output_date_folder <- file.path(merged_final_dir, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir.exists(output_date_folder)) {
|
||||||
|
existing_tiles <- list.files(output_date_folder, pattern = "\\.tif$")
|
||||||
|
if (length(existing_tiles) > 0) {
|
||||||
|
safe_log(paste("[", i, "/", length(dates_to_process), "] SKIP:", date, "- already has", length(existing_tiles), "tiles"))
|
||||||
|
next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_log(paste("[", i, "/", length(dates_to_process), "] Processing tiles for date:", date))
|
||||||
|
|
||||||
date_tile_dir <- file.path(tile_folder, date)
|
date_tile_dir <- file.path(tile_folder, date)
|
||||||
tile_files <- list.files(date_tile_dir, pattern = "\\.tif$", full.names = TRUE)
|
tile_files <- list.files(date_tile_dir, pattern = "\\.tif$", full.names = TRUE)
|
||||||
|
|
@ -751,7 +801,7 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
next
|
next
|
||||||
}
|
}
|
||||||
|
|
||||||
safe_log(paste(" Found", length(tile_files), "tiles for date", date))
|
safe_log(paste(" Found", length(tile_files), "tiles - processing in parallel"))
|
||||||
|
|
||||||
# Extract CI from tiles for this date
|
# Extract CI from tiles for this date
|
||||||
date_stats <- extract_ci_from_tiles(
|
date_stats <- extract_ci_from_tiles(
|
||||||
|
|
@ -759,7 +809,8 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
date = date,
|
date = date,
|
||||||
field_boundaries_sf = field_boundaries_sf,
|
field_boundaries_sf = field_boundaries_sf,
|
||||||
daily_CI_vals_dir = daily_CI_vals_dir,
|
daily_CI_vals_dir = daily_CI_vals_dir,
|
||||||
merged_final_tif_dir = merged_final_dir
|
merged_final_tif_dir = merged_final_dir,
|
||||||
|
grid_size = grid_size
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!is.null(date_stats)) {
|
if (!is.null(date_stats)) {
|
||||||
|
|
@ -788,17 +839,18 @@ process_ci_values_from_tiles <- function(dates, tile_folder, field_boundaries,
|
||||||
#' 1. Loads tile
|
#' 1. Loads tile
|
||||||
#' 2. Creates/extracts CI band
|
#' 2. Creates/extracts CI band
|
||||||
#' 3. Creates output raster with Red, Green, Blue, NIR, CI bands
|
#' 3. Creates output raster with Red, Green, Blue, NIR, CI bands
|
||||||
#' 4. Saves to merged_final_tif_dir/[DATE]/ mirroring daily_tiles_split structure
|
#' 4. Saves to merged_final_tif_dir/[GRID_SIZE]/[DATE]/ mirroring daily_tiles_split structure
|
||||||
#' 5. Extracts field-level CI statistics
|
#' 5. Extracts field-level CI statistics
|
||||||
#' Returns statistics aggregated to field level.
|
#' Returns statistics aggregated to field level.
|
||||||
#'
|
#'
|
||||||
#' @param tile_file Path to a single tile TIF file
|
#' @param tile_file Path to a single tile TIF file
|
||||||
#' @param field_boundaries_sf Field boundaries as SF object
|
#' @param field_boundaries_sf Field boundaries as SF object
|
||||||
#' @param date Character string of the date (YYYY-MM-DD format)
|
#' @param date Character string of the date (YYYY-MM-DD format)
|
||||||
#' @param merged_final_tif_dir Directory to save processed tiles with CI band
|
#' @param merged_final_tif_dir Base directory to save processed tiles with CI band
|
||||||
|
#' @param grid_size Grid size label (e.g., "5x5", "10x10") for output path structure
|
||||||
#' @return Data frame with field CI statistics for this tile, or NULL if processing failed
|
#' @return Data frame with field CI statistics for this tile, or NULL if processing failed
|
||||||
#'
|
#'
|
||||||
process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_final_tif_dir) {
|
process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_final_tif_dir, grid_size = NA) {
|
||||||
tryCatch({
|
tryCatch({
|
||||||
tile_filename <- basename(tile_file)
|
tile_filename <- basename(tile_file)
|
||||||
safe_log(paste(" [TILE] Loading:", tile_filename))
|
safe_log(paste(" [TILE] Loading:", tile_filename))
|
||||||
|
|
@ -845,8 +897,14 @@ process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_fin
|
||||||
output_raster <- c(red_band, green_band, blue_band, nir_band, ci_band)
|
output_raster <- c(red_band, green_band, blue_band, nir_band, ci_band)
|
||||||
names(output_raster) <- c("Red", "Green", "Blue", "NIR", "CI")
|
names(output_raster) <- c("Red", "Green", "Blue", "NIR", "CI")
|
||||||
|
|
||||||
# Save processed tile to merged_final_tif_dir/[DATE]/ with same filename
|
# Save processed tile to merged_final_tif_dir/[GRID_SIZE]/[DATE]/ with same filename
|
||||||
date_dir <- file.path(merged_final_tif_dir, date)
|
# This mirrors the input structure: daily_tiles_split/[GRID_SIZE]/[DATE]/
|
||||||
|
if (!is.na(grid_size)) {
|
||||||
|
date_dir <- file.path(merged_final_tif_dir, grid_size, date)
|
||||||
|
} else {
|
||||||
|
date_dir <- file.path(merged_final_tif_dir, date)
|
||||||
|
}
|
||||||
|
|
||||||
if (!dir.exists(date_dir)) {
|
if (!dir.exists(date_dir)) {
|
||||||
dir.create(date_dir, recursive = TRUE, showWarnings = FALSE)
|
dir.create(date_dir, recursive = TRUE, showWarnings = FALSE)
|
||||||
}
|
}
|
||||||
|
|
@ -883,7 +941,7 @@ process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_fin
|
||||||
#' Given a set of tile files for a single date, this function:
|
#' Given a set of tile files for a single date, this function:
|
||||||
#' 1. Loads each tile IN PARALLEL using furrr
|
#' 1. Loads each tile IN PARALLEL using furrr
|
||||||
#' 2. Creates/extracts CI band
|
#' 2. Creates/extracts CI band
|
||||||
#' 3. Saves processed tile (Red, Green, Blue, NIR, CI) to merged_final_tif_dir/[DATE]/
|
#' 3. Saves processed tile (Red, Green, Blue, NIR, CI) to merged_final_tif_dir/[GRID_SIZE]/[DATE]/
|
||||||
#' 4. Calculates field statistics from CI band
|
#' 4. Calculates field statistics from CI band
|
||||||
#' 5. Aggregates field statistics across tiles
|
#' 5. Aggregates field statistics across tiles
|
||||||
#' 6. Saves individual date file (matching legacy workflow)
|
#' 6. Saves individual date file (matching legacy workflow)
|
||||||
|
|
@ -894,24 +952,43 @@ process_single_tile <- function(tile_file, field_boundaries_sf, date, merged_fin
|
||||||
#' @param date Character string of the date (YYYY-MM-DD format)
|
#' @param date Character string of the date (YYYY-MM-DD format)
|
||||||
#' @param field_boundaries_sf Field boundaries as SF object
|
#' @param field_boundaries_sf Field boundaries as SF object
|
||||||
#' @param daily_CI_vals_dir Directory to save individual date RDS files
|
#' @param daily_CI_vals_dir Directory to save individual date RDS files
|
||||||
#' @param merged_final_tif_dir Directory to save processed tiles with CI band (mirrors daily_tiles_split structure)
|
#' @param merged_final_tif_dir Base directory to save processed tiles with CI band
|
||||||
|
#' @param grid_size Grid size label (e.g., "5x5", "10x10") for output path structure
|
||||||
#' @return Data frame with field CI statistics for the date
|
#' @return Data frame with field CI statistics for the date
|
||||||
#'
|
#'
|
||||||
extract_ci_from_tiles <- function(tile_files, date, field_boundaries_sf, daily_CI_vals_dir = NULL, merged_final_tif_dir = NULL) {
|
extract_ci_from_tiles <- function(tile_files, date, field_boundaries_sf, daily_CI_vals_dir = NULL, merged_final_tif_dir = NULL, grid_size = NA) {
|
||||||
|
|
||||||
if (!inherits(field_boundaries_sf, "sf")) {
|
if (!inherits(field_boundaries_sf, "sf")) {
|
||||||
field_boundaries_sf <- sf::st_as_sf(field_boundaries_sf)
|
field_boundaries_sf <- sf::st_as_sf(field_boundaries_sf)
|
||||||
}
|
}
|
||||||
|
|
||||||
safe_log(paste(" Processing", length(tile_files), "tiles for date", date, "(parallel processing)"))
|
safe_log(paste(" Processing", length(tile_files), "tiles for date", date, "(3-tile parallel batch)"))
|
||||||
|
|
||||||
# Process tiles in parallel using furrr::future_map
|
# Windows-compatible parallelization: Process tiles in small batches
|
||||||
# This replaces the sequential for loop, processing 2-4 tiles simultaneously
|
# Use future_map with 3 workers - stable and efficient on Windows
|
||||||
stats_list <- furrr::future_map(
|
|
||||||
.x = tile_files,
|
# Set up minimal future plan (3 workers max)
|
||||||
.f = ~ process_single_tile(.x, field_boundaries_sf, date, merged_final_tif_dir),
|
future::plan(future::multisession, workers = 3)
|
||||||
.options = furrr::furrr_options(seed = TRUE)
|
|
||||||
)
|
# Process tiles using furrr with 2 workers
|
||||||
|
# Use retry logic for worker stability
|
||||||
|
stats_list <- tryCatch({
|
||||||
|
furrr::future_map(
|
||||||
|
tile_files,
|
||||||
|
~ process_single_tile(.x, field_boundaries_sf, date, merged_final_tif_dir, grid_size = grid_size),
|
||||||
|
.progress = FALSE,
|
||||||
|
.options = furrr::furrr_options(seed = TRUE)
|
||||||
|
)
|
||||||
|
}, error = function(e) {
|
||||||
|
safe_log(paste("Parallel processing failed:", e$message, "- falling back to sequential"), "WARNING")
|
||||||
|
# Fallback to sequential if parallel fails
|
||||||
|
lapply(
|
||||||
|
tile_files,
|
||||||
|
function(tile_file) {
|
||||||
|
process_single_tile(tile_file, field_boundaries_sf, date, merged_final_tif_dir, grid_size = grid_size)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
# Extract names and filter out NULL results (failed tiles)
|
# Extract names and filter out NULL results (failed tiles)
|
||||||
tile_names <- basename(tile_files)
|
tile_names <- basename(tile_files)
|
||||||
|
|
|
||||||
BIN
webapps.zip
BIN
webapps.zip
Binary file not shown.
|
|
@ -56,8 +56,9 @@
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 10px;
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-controls label {
|
.map-controls label {
|
||||||
|
|
@ -70,6 +71,19 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-controls-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls-group:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
@ -764,14 +778,32 @@
|
||||||
<div class="map-container">
|
<div class="map-container">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<label for="mapToggle">
|
<div class="map-controls-group">
|
||||||
<span class="toggle-label" id="mapTypeLabel">OSM</span>
|
<label for="mapToggle">
|
||||||
<div class="toggle-switch">
|
<span class="toggle-label" id="mapTypeLabel">OSM</span>
|
||||||
<input type="checkbox" id="mapToggle">
|
<div class="toggle-switch">
|
||||||
<span class="toggle-slider"></span>
|
<input type="checkbox" id="mapToggle">
|
||||||
</div>
|
<span class="toggle-slider"></span>
|
||||||
<span>🛰️</span>
|
</div>
|
||||||
</label>
|
<span>🛰️</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="map-controls-group">
|
||||||
|
<label for="labelsToggle">
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="labelsToggle" checked>
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
<span>Show Labels</span>
|
||||||
|
</label>
|
||||||
|
<label for="labelPositionToggle">
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="labelPositionToggle">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</div>
|
||||||
|
<span id="labelPositionText">Center</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -807,8 +839,11 @@
|
||||||
|
|
||||||
let currentLayer = 'osm';
|
let currentLayer = 'osm';
|
||||||
let geojsonLayer = null;
|
let geojsonLayer = null;
|
||||||
|
let labelsLayer = null;
|
||||||
let currentGeojsonData = null;
|
let currentGeojsonData = null;
|
||||||
let featuresList = [];
|
let featuresList = [];
|
||||||
|
let showLabels = true;
|
||||||
|
let labelPosition = 'center'; // 'center' or 'side'
|
||||||
|
|
||||||
// Toggle map layer
|
// Toggle map layer
|
||||||
const mapToggle = document.getElementById('mapToggle');
|
const mapToggle = document.getElementById('mapToggle');
|
||||||
|
|
@ -830,6 +865,31 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Toggle labels visibility
|
||||||
|
const labelsToggle = document.getElementById('labelsToggle');
|
||||||
|
labelsToggle.addEventListener('change', () => {
|
||||||
|
showLabels = labelsToggle.checked;
|
||||||
|
if (labelsLayer) {
|
||||||
|
if (showLabels) {
|
||||||
|
labelsLayer.addTo(map);
|
||||||
|
} else {
|
||||||
|
map.removeLayer(labelsLayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle label position
|
||||||
|
const labelPositionToggle = document.getElementById('labelPositionToggle');
|
||||||
|
const labelPositionText = document.getElementById('labelPositionText');
|
||||||
|
labelPositionToggle.addEventListener('change', () => {
|
||||||
|
labelPosition = labelPositionToggle.checked ? 'side' : 'center';
|
||||||
|
labelPositionText.textContent = labelPositionToggle.checked ? 'Side' : 'Center';
|
||||||
|
// Recreate labels with new position
|
||||||
|
if (currentGeojsonData) {
|
||||||
|
loadGeojson(currentGeojsonData, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Elements
|
// Elements
|
||||||
const geojsonInput = document.getElementById('geojsonFile');
|
const geojsonInput = document.getElementById('geojsonFile');
|
||||||
const fileNameDisplay = document.getElementById('fileName');
|
const fileNameDisplay = document.getElementById('fileName');
|
||||||
|
|
@ -863,6 +923,64 @@
|
||||||
setTimeout(() => el.classList.remove('active'), 5000);
|
setTimeout(() => el.classList.remove('active'), 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve label overlaps using collision detection algorithm
|
||||||
|
function resolveLabelOverlaps(labelPositions) {
|
||||||
|
const labelWidth = 150; // Approximate label width in pixels
|
||||||
|
const labelHeight = 30; // Approximate label height in pixels
|
||||||
|
const minDistance = Math.sqrt(labelWidth * labelWidth + labelHeight * labelHeight);
|
||||||
|
|
||||||
|
const adjustedPositions = labelPositions.map(pos => ({ ...pos }));
|
||||||
|
|
||||||
|
// Iteratively adjust positions to avoid overlaps
|
||||||
|
for (let iteration = 0; iteration < 5; iteration++) {
|
||||||
|
let hasAdjustment = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < adjustedPositions.length; i++) {
|
||||||
|
for (let j = i + 1; j < adjustedPositions.length; j++) {
|
||||||
|
const pos1 = adjustedPositions[i];
|
||||||
|
const pos2 = adjustedPositions[j];
|
||||||
|
|
||||||
|
const p1 = map.project(pos1.latlng);
|
||||||
|
const p2 = map.project(pos2.latlng);
|
||||||
|
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// If labels are too close, push them apart
|
||||||
|
if (distance < minDistance && distance > 0) {
|
||||||
|
hasAdjustment = true;
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const pushDistance = (minDistance - distance) / 2 + 5;
|
||||||
|
|
||||||
|
// Move labels away from each other
|
||||||
|
const offset1 = map.unproject([
|
||||||
|
p1.x - Math.cos(angle) * pushDistance,
|
||||||
|
p1.y - Math.sin(angle) * pushDistance
|
||||||
|
]);
|
||||||
|
const offset2 = map.unproject([
|
||||||
|
p2.x + Math.cos(angle) * pushDistance,
|
||||||
|
p2.y + Math.sin(angle) * pushDistance
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only adjust if original feature hasn't moved too far
|
||||||
|
const maxDrift = 0.003;
|
||||||
|
if (Math.abs(offset1.lat - pos1.originalLatlng.lat) < maxDrift) {
|
||||||
|
pos1.latlng = offset1;
|
||||||
|
}
|
||||||
|
if (Math.abs(offset2.lat - pos2.originalLatlng.lat) < maxDrift) {
|
||||||
|
pos2.latlng = offset2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAdjustment) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjustedPositions;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate area in square meters using Turf.js
|
// Calculate area in square meters using Turf.js
|
||||||
function getFeatureArea(feature) {
|
function getFeatureArea(feature) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -957,6 +1075,11 @@
|
||||||
map.removeLayer(geojsonLayer);
|
map.removeLayer(geojsonLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear previous labels
|
||||||
|
if (labelsLayer) {
|
||||||
|
map.removeLayer(labelsLayer);
|
||||||
|
}
|
||||||
|
|
||||||
currentGeojsonData = geojson;
|
currentGeojsonData = geojson;
|
||||||
featuresList = [];
|
featuresList = [];
|
||||||
|
|
||||||
|
|
@ -1018,6 +1141,86 @@
|
||||||
}
|
}
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Create labels layer
|
||||||
|
labelsLayer = L.featureGroup([]);
|
||||||
|
|
||||||
|
// First pass: collect all label positions
|
||||||
|
const labelPositions = [];
|
||||||
|
features.forEach((feature, index) => {
|
||||||
|
const props = feature.properties || {};
|
||||||
|
const fieldName = props.field || props.name || props.field_name || props.fieldName || props.id || `Field ${index + 1}`;
|
||||||
|
|
||||||
|
if (feature.geometry && feature.geometry.type !== 'Point') {
|
||||||
|
// Get centroid for polygon features
|
||||||
|
const bounds = L.geoJSON(feature).getBounds();
|
||||||
|
const center = bounds.getCenter();
|
||||||
|
|
||||||
|
let labelLatlng = center;
|
||||||
|
let iconAnchor = [0, 0];
|
||||||
|
|
||||||
|
// If side position, offset label to the top-left of the bounds
|
||||||
|
if (labelPosition === 'side') {
|
||||||
|
const ne = bounds.getNorthEast();
|
||||||
|
labelLatlng = L.latLng(ne.lat, ne.lng);
|
||||||
|
iconAnchor = [-10, -30];
|
||||||
|
}
|
||||||
|
|
||||||
|
labelPositions.push({
|
||||||
|
fieldName,
|
||||||
|
latlng: labelLatlng,
|
||||||
|
originalLatlng: labelLatlng,
|
||||||
|
iconAnchor,
|
||||||
|
isPoint: false
|
||||||
|
});
|
||||||
|
} else if (feature.geometry && feature.geometry.type === 'Point') {
|
||||||
|
// For points, add label above the marker
|
||||||
|
const latlng = L.GeoJSON.coordsToLatLng(feature.geometry.coordinates);
|
||||||
|
labelPositions.push({
|
||||||
|
fieldName,
|
||||||
|
latlng: latlng,
|
||||||
|
originalLatlng: latlng,
|
||||||
|
iconAnchor: [0, 30],
|
||||||
|
isPoint: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply collision detection to resolve overlaps
|
||||||
|
const adjustedPositions = resolveLabelOverlaps(labelPositions);
|
||||||
|
|
||||||
|
// Second pass: create markers with adjusted positions
|
||||||
|
adjustedPositions.forEach((pos) => {
|
||||||
|
const label = L.marker(pos.latlng, {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'field-label',
|
||||||
|
html: `<div style="
|
||||||
|
background: rgba(102, 126, 234, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
border: 2px solid white;
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
">${pos.fieldName}</div>`,
|
||||||
|
iconSize: [null, null],
|
||||||
|
iconAnchor: pos.iconAnchor
|
||||||
|
}),
|
||||||
|
interactive: false
|
||||||
|
});
|
||||||
|
labelsLayer.addLayer(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showLabels) {
|
||||||
|
labelsLayer.addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
// Fit bounds
|
// Fit bounds
|
||||||
const bounds = geojsonLayer.getBounds();
|
const bounds = geojsonLayer.getBounds();
|
||||||
map.fitBounds(bounds, { padding: [50, 50] });
|
map.fitBounds(bounds, { padding: [50, 50] });
|
||||||
|
|
|
||||||
|
|
@ -162,25 +162,48 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle login form
|
// Handle login form
|
||||||
document.getElementById('loginForm').addEventListener('submit', function(e) {
|
document.getElementById('loginForm').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
const correctPassword = 'Activity3-Quaking4-Unashamed5-Penholder6';
|
|
||||||
const errorMessage = document.getElementById('errorMessage');
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
const button = this.querySelector('button');
|
||||||
|
|
||||||
if (password === correctPassword) {
|
// Disable button during request
|
||||||
// Store authentication in session
|
button.disabled = true;
|
||||||
sessionStorage.setItem('authenticated', 'true');
|
button.textContent = 'Verifying...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send password to Netlify function for server-side verification
|
||||||
|
const response = await fetch('/.netlify/functions/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
|
||||||
// Redirect to main apps page
|
const data = await response.json();
|
||||||
window.location.href = 'index.html';
|
|
||||||
} else {
|
if (response.ok) {
|
||||||
// Show error message
|
// Authentication successful
|
||||||
errorMessage.textContent = 'Invalid password. Please try again.';
|
sessionStorage.setItem('authenticated', 'true');
|
||||||
|
window.location.href = 'index.html';
|
||||||
|
} else {
|
||||||
|
// Authentication failed
|
||||||
|
errorMessage.textContent = data.error || 'Invalid password. Please try again.';
|
||||||
|
errorMessage.classList.add('show');
|
||||||
|
document.getElementById('password').value = '';
|
||||||
|
document.getElementById('password').focus();
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = 'Access Tools';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
errorMessage.textContent = 'Connection error. Please check your internet and try again.';
|
||||||
errorMessage.classList.add('show');
|
errorMessage.classList.add('show');
|
||||||
document.getElementById('password').value = '';
|
button.disabled = false;
|
||||||
document.getElementById('password').focus();
|
button.textContent = 'Access Tools';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
11
webapps/netlify.toml
Normal file
11
webapps/netlify.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[build]
|
||||||
|
command = "# No build needed"
|
||||||
|
functions = "netlify/functions"
|
||||||
|
publish = "."
|
||||||
|
|
||||||
|
# Environment variables - Set these in Netlify UI (Site settings > Build & deploy > Environment)
|
||||||
|
# DO NOT commit actual values to git
|
||||||
|
[env]
|
||||||
|
SMARTCANE_PASSWORD = "set_in_netlify_ui"
|
||||||
|
GOOGLE_SHEET_ID = "1ZHEIyhupNDHVd1EScBn0DnuiAzMFoZcAPZm3U65abkY"
|
||||||
|
GOOGLE_SHEET_PASSWORD = "optional_if_needed"
|
||||||
52
webapps/netlify/functions/get-mills.js
Normal file
52
webapps/netlify/functions/get-mills.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Netlify Function to fetch Google Sheets data securely
|
||||||
|
* Credentials are stored as environment variables, not exposed to frontend
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.handler = async (event) => {
|
||||||
|
try {
|
||||||
|
// Read credentials from environment variables (set in Netlify UI)
|
||||||
|
const sheetId = process.env.GOOGLE_SHEET_ID;
|
||||||
|
const sheetPassword = process.env.GOOGLE_SHEET_PASSWORD; // If needed for authentication
|
||||||
|
|
||||||
|
if (!sheetId) {
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
body: JSON.stringify({ error: 'GOOGLE_SHEET_ID not configured' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the export URL
|
||||||
|
const csvUrl = `https://docs.google.com/spreadsheets/d/${sheetId}/export?format=csv`;
|
||||||
|
|
||||||
|
// Fetch the CSV from Google Sheets
|
||||||
|
const response = await fetch(csvUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
statusCode: response.status,
|
||||||
|
body: JSON.stringify({ error: `Google Sheets returned ${response.status}` })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = await response.text();
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv',
|
||||||
|
'Cache-Control': 'max-age=300' // Cache for 5 minutes
|
||||||
|
},
|
||||||
|
body: csv
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Google Sheet:', error);
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: 'Failed to fetch data',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
66
webapps/netlify/functions/login.js
Normal file
66
webapps/netlify/functions/login.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
/**
|
||||||
|
* Netlify Function for secure login
|
||||||
|
* Password is stored as environment variable on server, never exposed to frontend
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.handler = async (event) => {
|
||||||
|
// Only allow POST requests
|
||||||
|
if (event.httpMethod !== 'POST') {
|
||||||
|
return {
|
||||||
|
statusCode: 405,
|
||||||
|
body: JSON.stringify({ error: 'Method not allowed' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse request body
|
||||||
|
const body = JSON.parse(event.body);
|
||||||
|
const submittedPassword = body.password;
|
||||||
|
|
||||||
|
if (!submittedPassword) {
|
||||||
|
return {
|
||||||
|
statusCode: 400,
|
||||||
|
body: JSON.stringify({ error: 'Password required' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get correct password from environment variable (set in Netlify UI)
|
||||||
|
const correctPassword = process.env.SMARTCANE_PASSWORD;
|
||||||
|
|
||||||
|
if (!correctPassword) {
|
||||||
|
console.error('SMARTCANE_PASSWORD environment variable not set');
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
body: JSON.stringify({ error: 'Server configuration error' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare passwords
|
||||||
|
if (submittedPassword === correctPassword) {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {
|
||||||
|
'Set-Cookie': `auth_token=smartcane_${Date.now()}; Path=/; SameSite=Strict; Secure; HttpOnly`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Login successful'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Add delay to prevent brute force
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
body: JSON.stringify({ error: 'Invalid password' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
body: JSON.stringify({ error: 'Server error' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -225,6 +225,10 @@ function initMap() {
|
||||||
// Load CSV data
|
// Load CSV data
|
||||||
loadMillsData();
|
loadMillsData();
|
||||||
|
|
||||||
|
// Initialize Google Sheets auto-refresh
|
||||||
|
initGoogleSheetsAutoRefresh();
|
||||||
|
showGoogleSheetsSetup();
|
||||||
|
|
||||||
// Attach mode button listeners
|
// Attach mode button listeners
|
||||||
attachModeListeners();
|
attachModeListeners();
|
||||||
|
|
||||||
|
|
@ -277,11 +281,22 @@ function updateMeasurementPanel() {
|
||||||
// Load mills from CSV
|
// Load mills from CSV
|
||||||
async function loadMillsData() {
|
async function loadMillsData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('sugar_cane_factories_africa.csv');
|
// Try to load from Google Sheets first
|
||||||
if (!response.ok) {
|
let csvText = await fetchGoogleSheetData();
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
|
// Fallback to local CSV if Google Sheets fails
|
||||||
|
if (!csvText) {
|
||||||
|
console.log('Falling back to local CSV file...');
|
||||||
|
const response = await fetch('sugar_cane_factories_africa.csv');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
csvText = await response.text();
|
||||||
|
showNotification('Using local data (Google Sheet unavailable)', 'warning');
|
||||||
|
} else {
|
||||||
|
showNotification('✓ Data loaded from Google Sheet', 'success');
|
||||||
}
|
}
|
||||||
const csvText = await response.text();
|
|
||||||
parseCSV(csvText);
|
parseCSV(csvText);
|
||||||
console.log('Mills loaded:', mills.length, mills.slice(0, 2));
|
console.log('Mills loaded:', mills.length, mills.slice(0, 2));
|
||||||
renderMills();
|
renderMills();
|
||||||
|
|
@ -289,11 +304,10 @@ async function loadMillsData() {
|
||||||
updateLegend();
|
updateLegend();
|
||||||
console.log('Legend updated');
|
console.log('Legend updated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading CSV:', error);
|
console.error('Error loading mills data:', error);
|
||||||
// Show a notification to user
|
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #ff6b6b; color: white; padding: 15px 20px; border-radius: 5px; z-index: 9999;';
|
notification.style.cssText = 'position: fixed; top: 20px; right: 20px; background: #ff6b6b; color: white; padding: 15px 20px; border-radius: 5px; z-index: 9999;';
|
||||||
notification.textContent = '⚠️ Could not load mill data. Make sure sugar_cane_factories_africa.csv is in the same folder.';
|
notification.textContent = '⚠️ Could not load mill data. Check console for details.';
|
||||||
document.body.appendChild(notification);
|
document.body.appendChild(notification);
|
||||||
setTimeout(() => notification.remove(), 5000);
|
setTimeout(() => notification.remove(), 5000);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
151
webapps/sugar_mill_locator/google-sheets-config.js
Normal file
151
webapps/sugar_mill_locator/google-sheets-config.js
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
// Google Sheets Configuration
|
||||||
|
// This file connects to your Google Sheet for live data updates
|
||||||
|
|
||||||
|
const GOOGLE_SHEETS_CONFIG = {
|
||||||
|
// Your Google Sheet ID (from the URL)
|
||||||
|
SHEET_ID: '1ZHEIyhupNDHVd1EScBn0DnuiAzMFoZcAPZm3U65abkY',
|
||||||
|
|
||||||
|
// The sheet name or gid (the sheet tab you want to read from)
|
||||||
|
// If using gid=220881066, you can reference it this way
|
||||||
|
SHEET_NAME: 'Sheet1', // Change this to your actual sheet name if different
|
||||||
|
|
||||||
|
// Auto-refresh interval in milliseconds (5 minutes = 300000ms)
|
||||||
|
REFRESH_INTERVAL: 300000,
|
||||||
|
|
||||||
|
// Enable auto-refresh (set to false to disable)
|
||||||
|
AUTO_REFRESH_ENABLED: true
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data from Google Sheet via Netlify Function
|
||||||
|
* The function keeps credentials secret on the server
|
||||||
|
*/
|
||||||
|
async function fetchGoogleSheetData() {
|
||||||
|
try {
|
||||||
|
// Call Netlify function instead of Google Sheets directly
|
||||||
|
// This keeps the Sheet ID and password hidden from browser dev tools
|
||||||
|
const response = await fetch('/.netlify/functions/get-mills');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvText = await response.text();
|
||||||
|
console.log('✓ Data fetched from Netlify Function (Google Sheet)');
|
||||||
|
return csvText;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data from Netlify Function:', error);
|
||||||
|
showNotification('Could not fetch data. Check your connection.', 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize auto-refresh of data from Google Sheet
|
||||||
|
*/
|
||||||
|
function initGoogleSheetsAutoRefresh() {
|
||||||
|
if (!GOOGLE_SHEETS_CONFIG.AUTO_REFRESH_ENABLED) {
|
||||||
|
console.log('Google Sheets auto-refresh is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Auto-refresh enabled (every ${GOOGLE_SHEETS_CONFIG.REFRESH_INTERVAL / 1000 / 60} minutes)`);
|
||||||
|
|
||||||
|
// Refresh immediately on load
|
||||||
|
// (already done in initApp)
|
||||||
|
|
||||||
|
// Then refresh periodically
|
||||||
|
setInterval(async () => {
|
||||||
|
console.log('🔄 Refreshing data from Google Sheet...');
|
||||||
|
const csvData = await fetchGoogleSheetData();
|
||||||
|
|
||||||
|
if (csvData) {
|
||||||
|
// Clear old markers
|
||||||
|
Object.values(millMarkers).forEach(marker => map.removeLayer(marker));
|
||||||
|
millMarkers = {};
|
||||||
|
|
||||||
|
// Parse new data
|
||||||
|
parseCSV(csvData);
|
||||||
|
|
||||||
|
// Render updated mills
|
||||||
|
renderMills();
|
||||||
|
updateLegend();
|
||||||
|
|
||||||
|
// Reapply filters
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
console.log(`✓ Updated ${mills.length} mills from Google Sheet`);
|
||||||
|
showNotification(`Map updated with latest data (${mills.length} mills)`, 'success');
|
||||||
|
}
|
||||||
|
}, GOOGLE_SHEETS_CONFIG.REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show notification to user
|
||||||
|
*/
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const colors = {
|
||||||
|
'success': '#4CAF50',
|
||||||
|
'warning': '#FF9800',
|
||||||
|
'error': '#F44336',
|
||||||
|
'info': '#2196F3'
|
||||||
|
};
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: ${colors[type] || colors.info};
|
||||||
|
color: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
notification.textContent = message;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.transition = 'opacity 0.3s ease';
|
||||||
|
notification.style.opacity = '0';
|
||||||
|
setTimeout(() => notification.remove(), 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide setup instructions to the user
|
||||||
|
*/
|
||||||
|
function showGoogleSheetsSetup() {
|
||||||
|
console.log(`
|
||||||
|
╔════════════════════════════════════════════════════════════╗
|
||||||
|
║ Google Sheets Integration Setup Instructions ║
|
||||||
|
╠════════════════════════════════════════════════════════════╣
|
||||||
|
║ ║
|
||||||
|
║ 1. Your Google Sheet is configured and ready! ║
|
||||||
|
║ ║
|
||||||
|
║ 2. Share the sheet with your colleagues: ║
|
||||||
|
║ - Click "Share" in the top-right ║
|
||||||
|
║ - Add their email addresses ║
|
||||||
|
║ - They must have "Editor" access ║
|
||||||
|
║ ║
|
||||||
|
║ 3. Column headers required (case-sensitive): ║
|
||||||
|
║ - Mill/Factory Name (or similar) ║
|
||||||
|
║ - Country ║
|
||||||
|
║ - Latitude ║
|
||||||
|
║ - Longitude ║
|
||||||
|
║ - Crushing Capacity (optional) ║
|
||||||
|
║ - Annual Sugar Production (optional) ║
|
||||||
|
║ - Notes (optional) ║
|
||||||
|
║ - Data Year (optional) ║
|
||||||
|
║ ║
|
||||||
|
║ 4. The map will automatically update every 5 minutes ║
|
||||||
|
║ with new data from the sheet ║
|
||||||
|
║ ║
|
||||||
|
║ 5. To change refresh interval, edit: ║
|
||||||
|
║ GOOGLE_SHEETS_CONFIG.REFRESH_INTERVAL ║
|
||||||
|
║ ║
|
||||||
|
╚════════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -619,6 +619,7 @@
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet-measure@3.1.0/dist/leaflet-measure.umd.js"></script>
|
<script src="https://unpkg.com/leaflet-measure@3.1.0/dist/leaflet-measure.umd.js"></script>
|
||||||
|
<script src="google-sheets-config.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue