Spaces:
Sleeping
Sleeping
Download app.R from diegoellissoto/Biodiversity_Access: direct link, hf CLI and curl.
- Browser
- Download file 361 kB
-
https://huggingface.co/spaces/diegoellissoto/Biodiversity_Access/resolve/main/app.R
- Command line
-
hf download hf://spaces/diegoellissoto/Biodiversity_Access/app.R
-
curl -L -o app.R https://huggingface.co/spaces/diegoellissoto/Biodiversity_Access/resolve/main/app.R
361 kB
| # Currently downloaded the geomestry of San Francsico every time, pretty unefficient | |
| ############################################################################### | |
| # Shiny App: San Francisco Biodiversity Access Decision Support Tool | |
| # AUDITED citywide 100 m reference β path-safe app + conservation/displacement context UI | |
| # Base app + multimodal transit + EJ + access scores + spider plot | |
| # | |
| # Scoring invariant: | |
| # ONE user isochrone -> ONE raw metric row -> ONE same-mode Γ same-time | |
| # citywide reference distribution. No pooling/averaging of user isochrones. | |
| ############################################################################### | |
| # Next steps: | |
| # Color code spiderploy by access-community partney-transportation and human movement-habitats (NDVI, Greenspace), species richness, sampling density of GBIF records (both are biodiversity),envrionmental justice (CalRnviro AND SF Screening Tool) | |
| # Make spiderplot labels in boly and legend bigger | |
| # Run the backgroun calculation for each isochrone | |
| # WHAT to do about EJ layers in presidio and golden gate park, etc | |
| # ============================================================================= | |
| # PACKAGES | |
| # ============================================================================= | |
| # require(shinyjs) # commented: useShinyjs() enabled features the app never calls | |
| library(shiny) | |
| library(shinydashboard) | |
| library(leaflet) | |
| library(leaflet.extras) | |
| library(mapboxapi) | |
| library(dplyr) | |
| library(tidyr) | |
| library(readr) | |
| library(purrr) | |
| library(tibble) | |
| library(stringr) | |
| library(forcats) | |
| library(lubridate) | |
| library(ggplot2) | |
| # library(tidycensus) # commented: unused in app (CBG data is precomputed) -- speeds startup | |
| library(sf) | |
| library(DT) | |
| # library(RColorBrewer) # commented: brewer.pal only appears in dead if(FALSE) blocks | |
| library(terra) | |
| # Commented out: no detectable use in the app, and loading them added ~1-2 s to | |
| # startup (mapview/sjPlot/sjlabelled each pull large dependency trees). Re-enable | |
| # the relevant line if you start using one. | |
| # library(data.table) | |
| # library(mapview) | |
| # library(sjPlot) | |
| # library(sjlabelled) | |
| library(bslib) | |
| library(shinycssloaders) | |
| library(DBI) | |
| library(duckdb) | |
| library(dbplyr) | |
| # Called only via pkg:: at runtime, so each namespace loads on first use -- no | |
| # startup attach needed (keeps their dependency trees off startup, ~0.5-1 s): | |
| # gtfsrouter -- transit routing (gtfsrouter::gtfs_route / gtfs_isochrone) | |
| # tidytransit -- headway computation, fallback only (tidytransit::*) | |
| # fmsb -- spider/radar plot (fmsb::radarchart) | |
| # library(gtfsrouter) | |
| # library(tidytransit) | |
| # library(fmsb) | |
| library(scales) | |
| # ============================================================================= | |
| # PROJECT SETUP | |
| # ============================================================================= | |
| # Resolve the project root automatically. | |
| # | |
| # This app can now be launched either as: | |
| # shiny::runApp("app_with_strict_single_active_corridor_isochrone_AUDITED_READY_UI_POLISHED_RADAR_CLEAR.R") | |
| # or: | |
| # shiny::runApp("code/app_with_strict_single_active_corridor_isochrone_AUDITED_READY_UI_POLISHED_RADAR_CLEAR.R") | |
| # | |
| # All scientific inputs remain project-root-relative (code/, data/, outputs/). | |
| .app_start_dir <- normalizePath(getwd(), winslash = "/", mustWork = TRUE) | |
| if (file.exists(file.path(.app_start_dir, "code", "setup_unified.R"))) { | |
| .project_root <- .app_start_dir | |
| } else if ( | |
| basename(.app_start_dir) == "code" && | |
| file.exists(file.path(dirname(.app_start_dir), "code", "setup_unified.R")) | |
| ) { | |
| .project_root <- dirname(.app_start_dir) | |
| } else { | |
| stop( | |
| "Could not locate the BAI_100m project root. Expected code/setup_unified.R ", | |
| "either in the current working directory or one directory above it." | |
| ) | |
| } | |
| setwd(.project_root) | |
| # Keep scientific data paths absolute for the lifetime of the Shiny process. | |
| # Shiny may reset getwd() to the directory containing the app file after startup; | |
| # absolute paths prevent lazy metric loaders from suddenly looking under code/. | |
| project_path <- function(...) { | |
| normalizePath( | |
| file.path(.project_root, ...), | |
| winslash = "/", | |
| mustWork = FALSE | |
| ) | |
| } | |
| .ISO_SPECIES_CELL_RDS <- project_path( | |
| "outputs", "results", "gbif_cell_species_100m.rds" | |
| ) | |
| .ISO_ROUTE_CELL_RDS <- project_path( | |
| "outputs", "results", "muni_route_cells_100m.rds" | |
| ) | |
| Sys.setenv( | |
| ISO_METRIC_GRID_GPKG = project_path( | |
| "outputs", "results", "grid_results_100m.gpkg" | |
| ), | |
| ISOCHRONE_REFERENCE_RDS = project_path( | |
| "outputs", "results", "isochrone_basi_100m.rds" | |
| ) | |
| ) | |
| # Fail early with an informative message rather than surfacing the same missing | |
| # relative-path error from multiple Shiny outputs. | |
| .required_harmonized_files <- c( | |
| grid = Sys.getenv("ISO_METRIC_GRID_GPKG"), | |
| reference = Sys.getenv("ISOCHRONE_REFERENCE_RDS"), | |
| species_lookup = .ISO_SPECIES_CELL_RDS, | |
| route_lookup = .ISO_ROUTE_CELL_RDS | |
| ) | |
| .missing_harmonized_files <- .required_harmonized_files[ | |
| !file.exists(.required_harmonized_files) | |
| ] | |
| if (length(.missing_harmonized_files) > 0) { | |
| stop( | |
| "Required harmonized BAI files were not found:\n", | |
| paste0(" - ", .missing_harmonized_files, collapse = "\n"), | |
| "\nProject root resolved as: ", .project_root | |
| ) | |
| } | |
| # setup_unified.R checks local files first, falling back to HuggingFace | |
| # downloads, and loads everything the app needs at startup. The two expensive | |
| # products (CBG x greenspace coverage, transit timetable) are precomputed in | |
| # code/prep/ and just read here, so startup stays fast. | |
| # Load the shared metric definition BEFORE the audited metric helper. | |
| source("code/iso_metric_definitions.R") | |
| source("code/setup_unified.R") | |
| # --- transit fix: the timetable must carry a transfers table ---------------- | |
| # gtfs_traveltimes() refuses to run without one, and gtfs_transfer_table() has | |
| # to be applied to the RAW feed BEFORE gtfs_timetable(). setup_unified.R caches | |
| # a timetable built without transfers, which is the second reason transit | |
| # returns nothing. Prefer a transfers-enriched cache and build it once if absent. | |
| # The merged feed ships a transfers.txt of its own (31 rows), so testing | |
| # is.null(transfers) is not enough - it passes while the distance-generated | |
| # inter-operator transfers are missing. The prep script tags its output with a | |
| # "build" attribute; anything without that tag needs enriching here. | |
| if (!is.null(gtfs_router) && | |
| (is.null(attr(gtfs_router, "build")) || is.null(gtfs_router$transfers))) { | |
| gtfs_router <- tryCatch({ | |
| enriched <- file.path(cache_dir, | |
| paste0("gtfs_timetable_monday_", GTFS_SCOPE, "_transfers.rds")) | |
| if (file.exists(enriched)) { | |
| readRDS(enriched) | |
| } else { | |
| raw <- gtfsrouter::extract_gtfs(gtfs_zip_path, quiet = TRUE) | |
| raw <- gtfsrouter::gtfs_transfer_table(raw, d_limit = 200, min_transfer_time = 120) | |
| tt <- gtfsrouter::gtfs_timetable(raw, day = "Monday", quiet = TRUE) | |
| tryCatch(saveRDS(tt, enriched), error = function(e) NULL) | |
| tt | |
| } | |
| }, error = function(e) { | |
| warning("could not add a GTFS transfers table; transit will be unavailable: ", | |
| conditionMessage(e)) | |
| gtfs_router | |
| }) | |
| } | |
| if (!is.null(gtfs_router) && is.null(gtfs_router$transfers)) { | |
| message("[app] GTFS timetable has no transfers table - transit modes will return nothing.") | |
| } | |
| # --- loud checks for the three things that fail SILENTLY in this stack ------- | |
| # (1) no transfers table -> gtfs_traveltimes() returns nothing, no error | |
| # (2) expired calendar.txt -> empty isochrones, no error | |
| # (3) a feed whose service_ids miss the routing day -> empty timetable, no error | |
| gtfs_health <- local({ | |
| out <- list(ok = TRUE, notes = character()) | |
| say <- function(...) { out$notes <<- c(out$notes, paste0(...)); out$ok <<- FALSE } | |
| if (is.null(gtfs_router)) { say("no GTFS timetable at all"); return(out) } | |
| if (is.null(gtfs_router$transfers) || !nrow(gtfs_router$transfers)) | |
| say("timetable has no transfers table; gtfs_traveltimes() will return nothing") | |
| if (is.null(gtfs_router$timetable) || !nrow(gtfs_router$timetable)) | |
| say("timetable is EMPTY; every transit isochrone will come back blank") | |
| # every operator in the merged feed must survive into the routing day | |
| ops_feed <- unique(sub("_.*", "", gtfs_router$stops$stop_id)) | |
| served <- gtfs_router$stops$stop_id[unique(c(gtfs_router$timetable$departure_station, | |
| gtfs_router$timetable$arrival_station))] | |
| ops_live <- unique(sub("_.*", "", served)) | |
| if (length(setdiff(ops_feed, ops_live))) | |
| say("operators in the feed but absent from the Monday timetable: ", | |
| paste(setdiff(ops_feed, ops_live), collapse = ", ")) | |
| # calendar coverage: expired feeds still route by weekday, but the schedule is stale | |
| cal <- try(gtfs_router$calendar, silent = TRUE) | |
| if (!inherits(cal, "try-error") && !is.null(cal) && "end_date" %in% names(cal)) { | |
| cal <- as.data.frame(cal) | |
| cal$op <- sub("_.*", "", cal$service_id) | |
| cal$end <- as.Date(as.character(cal$end_date), "%Y%m%d") | |
| stale <- stats::aggregate(end ~ op, cal, max) | |
| stale <- stale[stale$end < Sys.Date(), ] | |
| if (nrow(stale)) | |
| say("schedule EXPIRED for: ", | |
| paste(sprintf("%s (ended %s)", stale$op, stale$end), collapse = ", "), | |
| " - routing still works by weekday, but on an out-of-date timetable") | |
| } | |
| out | |
| }) | |
| if (!gtfs_health$ok) for (n in gtfs_health$notes) message("[app][GTFS] ", n) | |
| # Which mode x time combinations have a precomputed reference? add_reference_bai() | |
| # needs same-mode, same-time percentiles and never falls back, so a time budget | |
| # with no reference yields BAI = NA with no message. Make that visible instead. | |
| REFERENCE_TIMES <- tryCatch({ | |
| rp <- "outputs/results/isochrone_basi_100m.rds" | |
| if (file.exists(rp)) sort(unique(readRDS(rp)$time_min)) else numeric(0) | |
| }, error = function(e) numeric(0)) | |
| message("[app] BAI reference available for time budgets: ", | |
| paste(REFERENCE_TIMES, collapse = ", "), " min") | |
| bd <- attr(gtfs_router, "build") | |
| if (!is.null(bd)) message("[app] routing on ", format(bd$route_date, "%A %d %b %Y"), | |
| " (last date inside every feed's service window)") | |
| message("[app] GTFS scope: ", GTFS_SCOPE, " | stops: ", | |
| if (is.null(gtfs_stops_sf)) 0 else nrow(gtfs_stops_sf), | |
| " | transfers: ", if (is.null(gtfs_router$transfers)) 0 else nrow(gtfs_router$transfers)) | |
| # ============================================================================= | |
| # GLOBAL CONFIG | |
| # ============================================================================= | |
| # Local dev only: .env is git-ignored and holds MAPBOX_TOKEN. Deployed | |
| # environments (rsconnect/Docker) set the env var directly, so this is a no-op there. | |
| if (file.exists(".env")) readRenviron(".env") | |
| mapbox_token <- Sys.getenv("MAPBOX_TOKEN") | |
| if (!nzchar(mapbox_token)) { | |
| stop("MAPBOX_TOKEN environment variable is not set.") | |
| } | |
| theme <- bs_theme( | |
| bootswatch = "minty", | |
| base_font = font_google("Roboto"), | |
| heading_font = font_google("Roboto Slab"), | |
| bg = "#f3f8f5", | |
| fg = "#3d5c4a" | |
| ) | |
| # ============================================================================= | |
| # OPTIONAL ENVIRONMENT / EQUITY LAYERS + GTFS | |
| # ============================================================================= | |
| # cenv_sf, sf_ej_sf, gtfs_stops_sf, gtfs_routes_sf, gtfs_router, | |
| # gtfs_zip_path, gtfs_stop_headways, transit_iso_cache are all loaded | |
| # by setup_local.R (sourced above). | |
| # ============================================================================= | |
| # GBIF UI VALUES FROM PARQUET | |
| # ============================================================================= | |
| con_temp <- dbConnect(duckdb::duckdb(), dbdir = ":memory:") | |
| dbExecute(con_temp, "INSTALL spatial; LOAD spatial;") | |
| dbExecute(con_temp, "INSTALL httpfs; LOAD httpfs;") | |
| gbif_tab_temp <- tbl(con_temp, glue("read_parquet('{gbif_parquet}')")) | |
| gbif_classes <- gbif_tab_temp |> | |
| distinct(class) |> | |
| collect() |> | |
| pull(class) |> | |
| sort() | |
| gbif_families <- gbif_tab_temp |> | |
| distinct(family) |> | |
| collect() |> | |
| pull(family) |> | |
| sort() | |
| dbDisconnect(con_temp, shutdown = TRUE) | |
| rm(con_temp, gbif_tab_temp) | |
| # ============================================================================= | |
| # HELPERS | |
| # ============================================================================= | |
| pretty_mode <- function(x) { | |
| dplyr::case_when( | |
| x == "driving" ~ "Driving", | |
| x == "walking" ~ "Walking", | |
| x == "cycling" ~ "Cycling", | |
| x == "driving-traffic" ~ "Driving-Traffic", | |
| x == "transit" ~ "Transit (optimistic)", | |
| x == "walk_transit" ~ "Walk + Transit (conservative)", | |
| TRUE ~ tools::toTitleCase(x) | |
| ) | |
| } | |
| mode_palette <- c( | |
| "Driving" = "#4393C3", | |
| "Walking" = "#74C476", | |
| "Cycling" = "#FD8D3C", | |
| "Driving-Traffic" = "#9E9AC8", | |
| "Transit" = "#D6604D", | |
| "Walk-Transit" = "#E6AB02" | |
| ) | |
| # Consistent conceptual colors used across radar labels, metric cards and | |
| # explanatory legends. These colors describe BAI DIMENSION GROUPS, not | |
| # transportation modes or comparison points. | |
| BAI_GROUP_COLORS <- c( | |
| access = "#2f6fb0", | |
| biodiversity = "#c26a00", | |
| green_environment = "#2f855a", | |
| equity = "#7a58a6" | |
| ) | |
| bai_dimension_group_legend_ui <- function() { | |
| tags$div( | |
| style = paste0( | |
| "display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); ", | |
| "gap:7px; margin:8px 0 10px 0;" | |
| ), | |
| tags$div( | |
| style = paste0( | |
| "border-left:5px solid ", BAI_GROUP_COLORS[["access"]], | |
| "; background:#f5f8fc; border-radius:7px; padding:7px 9px;" | |
| ), | |
| tags$b(style = paste0("color:", BAI_GROUP_COLORS[["access"]], ";"), "ACCESS"), | |
| tags$br(), | |
| tags$small("Transit access Β· Muni route access") | |
| ), | |
| tags$div( | |
| style = paste0( | |
| "border-left:5px solid ", BAI_GROUP_COLORS[["biodiversity"]], | |
| "; background:#fff8ef; border-radius:7px; padding:7px 9px;" | |
| ), | |
| tags$b(style = paste0("color:", BAI_GROUP_COLORS[["biodiversity"]], ";"), "BIODIVERSITY EVIDENCE"), | |
| tags$br(), | |
| tags$small("Species richness Β· GBIF sampling density") | |
| ), | |
| tags$div( | |
| style = paste0( | |
| "border-left:5px solid ", BAI_GROUP_COLORS[["green_environment"]], | |
| "; background:#f1f8f3; border-radius:7px; padding:7px 9px;" | |
| ), | |
| tags$b(style = paste0("color:", BAI_GROUP_COLORS[["green_environment"]], ";"), "GREEN ENVIRONMENT"), | |
| tags$br(), | |
| tags$small("Vegetation NDVI Β· Greenspace cover") | |
| ), | |
| tags$div( | |
| style = paste0( | |
| "border-left:5px solid ", BAI_GROUP_COLORS[["equity"]], | |
| "; background:#f7f3fb; border-radius:7px; padding:7px 9px;" | |
| ), | |
| tags$b(style = paste0("color:", BAI_GROUP_COLORS[["equity"]], ";"), "EQUITY CONTEXT"), | |
| tags$br(), | |
| tags$small("SF EJ context Β· outward = lower burden") | |
| ) | |
| ) | |
| } | |
| scale01 <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || all(is.na(x))) return(rep(NA_real_, length(x))) | |
| rng <- range(x, na.rm = TRUE) | |
| if (!is.finite(rng[1]) || !is.finite(rng[2])) return(rep(NA_real_, length(x))) | |
| if ((rng[2] - rng[1]) == 0) return(rep(0.5, length(x))) | |
| (x - rng[1]) / (rng[2] - rng[1]) | |
| } | |
| # Muffle only sf's common overlay warning about non-geometry attributes being | |
| # treated as spatially constant. Other warnings remain visible. | |
| quiet_sf_overlay <- function(expr) { | |
| withCallingHandlers( | |
| expr, | |
| warning = function(w) { | |
| if (grepl( | |
| "attribute variables are assumed to be spatially constant throughout all geometries", | |
| conditionMessage(w), | |
| fixed = TRUE | |
| )) { | |
| invokeRestart("muffleWarning") | |
| } | |
| } | |
| ) | |
| } | |
| ecdf01 <- function(x, ref) { | |
| ref <- ref[is.finite(ref)] | |
| if (length(ref) == 0) return(rep(NA_real_, length(x))) | |
| f <- ecdf(ref) | |
| out <- f(x) | |
| out[is.na(x)] <- NA_real_ | |
| out | |
| } | |
| standardize_iso_sf <- function(x, mode_name, time_min) { | |
| if (is.null(x) || nrow(x) == 0) return(NULL) | |
| x <- st_as_sf(x) | |
| st_sf( | |
| mode = mode_name, | |
| time = as.numeric(time_min), | |
| geometry = st_geometry(x), | |
| crs = st_crs(x) | |
| ) | |
| } | |
| choose_existing_sf_object <- function(candidates) { | |
| for (nm in candidates) { | |
| if (exists(nm, inherits = TRUE)) { | |
| obj <- get(nm, inherits = TRUE) | |
| if (inherits(obj, "sf")) return(obj) | |
| } | |
| } | |
| NULL | |
| } | |
| # Convert the FeatureCollection emitted by leaflet.extras into sf geometry. | |
| # Polygon drawings represent habitat patches; line drawings represent proposed | |
| # corridors and are buffered to the width selected in the scenario controls. | |
| # A temporary GeoJSON file lets GDAL handle GeoJSON nesting without adding | |
| # geojsonio as another app dependency. | |
| draw_features_to_sf <- function(feature_collection) { | |
| if (is.null(feature_collection) || | |
| is.null(feature_collection$features) || | |
| length(feature_collection$features) == 0) { | |
| return(NULL) | |
| } | |
| tmp_geojson <- tempfile(fileext = ".geojson") | |
| on.exit(unlink(tmp_geojson), add = TRUE) | |
| json_txt <- jsonlite::toJSON( | |
| feature_collection, | |
| auto_unbox = TRUE, | |
| null = "null", | |
| digits = NA | |
| ) | |
| writeLines(json_txt, tmp_geojson, useBytes = TRUE) | |
| out <- tryCatch( | |
| suppressWarnings(st_read(tmp_geojson, quiet = TRUE)), | |
| error = function(e) NULL | |
| ) | |
| if (is.null(out) || !inherits(out, "sf") || nrow(out) == 0) return(NULL) | |
| if (is.na(st_crs(out))) { | |
| st_crs(out) <- 4326 | |
| } else { | |
| out <- st_transform(out, 4326) | |
| } | |
| out <- out |> | |
| st_zm(drop = TRUE, what = "ZM") |> | |
| st_make_valid() | |
| geom_type <- as.character(st_geometry_type(out)) | |
| out <- out[ | |
| geom_type %in% c("POLYGON", "MULTIPOLYGON", "LINESTRING", "MULTILINESTRING"), | |
| , drop = FALSE | |
| ] | |
| if (nrow(out) == 0) return(NULL) | |
| out | |
| } | |
| safe_vect_gbif_intersection <- function(poly_i) { | |
| out <- tryCatch({ | |
| # browser() | |
| if (exists("vect_gbif")) { | |
| st_as_sf(intersect(vect_gbif, vect(poly_i))) | |
| } else if (exists("sf_gbif")) { | |
| st_intersection(sf_gbif, poly_i) | |
| } else { | |
| NULL | |
| } | |
| }, error = function(e) NULL) | |
| out | |
| } | |
| safe_biodiv_hotspots <- function() { | |
| choose_existing_sf_object(c("biodiv_hotspots", "hotspots")) | |
| } | |
| safe_biodiv_coldspots <- function() { | |
| choose_existing_sf_object(c("biodiv_coldspots", "coldspots")) | |
| } | |
| safe_partner_orgs <- function() { | |
| choose_existing_sf_object(c( | |
| "partner_orgs_sf", | |
| "partner_organizations_sf", | |
| "community_orgs_sf", | |
| "community_organizations_sf", | |
| "partner_orgs", | |
| "community_orgs", | |
| "sf_partner_orgs", | |
| "community_sites_sf" | |
| )) | |
| } | |
| walk_speed_m_per_min <- 80 | |
| build_first_mile_walkshed <- function(location_sf, walk_minutes, mapbox_token) { | |
| if (is.null(walk_minutes) || walk_minutes <= 0) return(NULL) | |
| tryCatch( | |
| mb_isochrone( | |
| location_sf, | |
| time = walk_minutes, | |
| profile = "walking", | |
| access_token = mapbox_token | |
| ) |> | |
| st_as_sf() |> | |
| st_make_valid() |> | |
| st_transform(4326), | |
| error = function(e) NULL | |
| ) | |
| } | |
| get_nearest_stops_by_distance <- function(location_sf, gtfs_stops_sf, walk_minutes, max_n = 5) { | |
| max_dist_m <- walk_minutes * walk_speed_m_per_min | |
| loc_proj <- st_transform(location_sf, 3857) | |
| stops_proj <- st_transform(gtfs_stops_sf, 3857) | |
| dists <- as.numeric(st_distance(loc_proj, stops_proj)) | |
| keep <- which(dists <= max_dist_m) | |
| if (length(keep) == 0) { | |
| keep <- order(dists)[seq_len(min(max_n, length(dists)))] | |
| } | |
| gtfs_stops_sf[keep, ] |> | |
| mutate( | |
| dist_to_origin_m = round(dists[keep], 1), | |
| walk_time_to_origin_min = pmax(0, round(dists[keep] / walk_speed_m_per_min, 1)) | |
| ) |> | |
| arrange(dist_to_origin_m) | |
| } | |
| get_walk_accessible_stops <- function(location_sf, walk_minutes, gtfs_stops_sf, mapbox_token) { | |
| if (is.null(walk_minutes) || walk_minutes <= 0) return(NULL) | |
| walk_iso <- build_first_mile_walkshed(location_sf, walk_minutes, mapbox_token) | |
| if (!is.null(walk_iso) && nrow(walk_iso) > 0) { | |
| stops_in_walkshed <- tryCatch( | |
| st_intersection(gtfs_stops_sf, st_union(walk_iso)), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(stops_in_walkshed) && nrow(stops_in_walkshed) > 0) { | |
| dists <- as.numeric(st_distance( | |
| st_transform(location_sf, 3857), | |
| st_transform(stops_in_walkshed, 3857) | |
| )) | |
| return( | |
| stops_in_walkshed |> | |
| mutate( | |
| access_method = "mapbox_walkshed", | |
| dist_to_origin_m = round(dists, 1), | |
| walk_time_to_origin_min = pmax(0, round(dists / walk_speed_m_per_min, 1)) | |
| ) |> | |
| arrange(walk_time_to_origin_min, dist_to_origin_m) | |
| ) | |
| } | |
| } | |
| fallback_stops <- get_nearest_stops_by_distance( | |
| location_sf = location_sf, | |
| gtfs_stops_sf = gtfs_stops_sf, | |
| walk_minutes = walk_minutes, | |
| max_n = 5 | |
| ) | |
| if (!is.null(fallback_stops) && nrow(fallback_stops) > 0) { | |
| fallback_stops <- fallback_stops |> | |
| mutate(access_method = "distance_fallback") | |
| } | |
| fallback_stops | |
| } | |
| extract_transit_minutes <- function(iso_result, dep_secs) { | |
| if (is.null(iso_result) || nrow(iso_result) == 0) return(numeric(0)) | |
| if ("travel_time" %in% names(iso_result)) { | |
| return(as.numeric(iso_result$travel_time) / 60) | |
| } | |
| if ("duration" %in% names(iso_result)) { | |
| return(as.numeric(iso_result$duration) / 60) | |
| } | |
| if ("time" %in% names(iso_result)) { | |
| return(as.numeric(iso_result$time) / 60) | |
| } | |
| if ("arrival_time" %in% names(iso_result)) { | |
| return((as.numeric(iso_result$arrival_time) - dep_secs) / 60) | |
| } | |
| rep(NA_real_, nrow(iso_result)) | |
| } | |
| build_last_mile_walkshed <- function( | |
| reachable_sf, | |
| remaining_walk_col = "remaining_walk_min", | |
| mapbox_token, | |
| walk_from_stop_cap_min = 8, | |
| max_stops = 12 | |
| ) { | |
| if (is.null(reachable_sf) || nrow(reachable_sf) == 0) return(NULL) | |
| if (!(remaining_walk_col %in% names(reachable_sf))) return(NULL) | |
| rs <- reachable_sf |> | |
| mutate( | |
| remaining_walk_min = as.numeric(.data[[remaining_walk_col]]), | |
| remaining_walk_min = pmin(remaining_walk_min, walk_from_stop_cap_min) | |
| ) |> | |
| filter(is.finite(remaining_walk_min), remaining_walk_min > 0.5) | |
| if (nrow(rs) == 0) return(NULL) | |
| if ("n_departures_peak" %in% names(rs)) { | |
| rs <- rs |> | |
| arrange(desc(n_departures_peak), desc(remaining_walk_min)) | |
| } else if ("mean_headway_min" %in% names(rs)) { | |
| rs <- rs |> | |
| arrange(mean_headway_min, desc(remaining_walk_min)) | |
| } else { | |
| rs <- rs |> | |
| arrange(desc(remaining_walk_min)) | |
| } | |
| rs <- rs |> | |
| slice_head(n = max_stops) | |
| walk_polys <- list() | |
| for (i in seq_len(nrow(rs))) { | |
| stop_i <- rs[i, ] | |
| walk_t <- floor(as.numeric(stop_i$remaining_walk_min[[1]])) | |
| if (!is.finite(walk_t) || walk_t <= 0) next | |
| iso_i <- tryCatch( | |
| mb_isochrone( | |
| stop_i, | |
| time = walk_t, | |
| profile = "walking", | |
| access_token = mapbox_token | |
| ), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(iso_i) && nrow(iso_i) > 0) { | |
| walk_polys[[length(walk_polys) + 1]] <- st_as_sf(iso_i) | |
| } | |
| } | |
| if (length(walk_polys) == 0) return(NULL) | |
| walk_geom <- dplyr::bind_rows(walk_polys) |> | |
| st_as_sf() |> | |
| st_make_valid() |> | |
| st_union() | |
| st_sf(geometry = walk_geom, crs = 4326) | |
| } | |
| # --------------------------------------------------------------------------- | |
| # TRANSIT COMPATIBILITY SHIM | |
| # --------------------------------------------------------------------------- | |
| # gtfsrouter::gtfs_isochrone() does NOT exist in gtfsrouter 0.1.4 -- the package | |
| # exports gtfs_traveltimes() and nothing else for routing. Every call to it | |
| # therefore throws, and because each call site wraps the call in | |
| # tryCatch(error = function(e) NULL) the failure is swallowed: Transit and | |
| # Walk + Transit silently return no isochrone instead of reporting an error. | |
| # The namespace is locked, so the missing function cannot be injected into | |
| # gtfsrouter; the call sites have to be routed through this shim instead. | |
| # | |
| # The shim keeps the gtfs_isochrone() argument names and returns `duration` as | |
| # NUMERIC SECONDS, which is what the existing extract_transit_minutes() already | |
| # expects (it divides by 60). gtfs_traveltimes() itself returns duration as an | |
| # "HH:MM:SS" STRING, where as.numeric() yields NA -- converting it here is what | |
| # keeps the downstream code working unchanged. | |
| .hms_to_seconds <- function(x) { | |
| if (is.numeric(x)) return(as.numeric(x)) | |
| if (inherits(x, "difftime")) return(as.numeric(x, units = "secs")) | |
| x <- as.character(x) | |
| out <- rep(NA_real_, length(x)) | |
| ok <- !is.na(x) & grepl("^[0-9]+:[0-9]{2}:[0-9]{2}$", x) | |
| if (any(ok)) { | |
| p <- do.call(rbind, strsplit(x[ok], ":", fixed = TRUE)) | |
| out[ok] <- as.numeric(p[, 1]) * 3600 + as.numeric(p[, 2]) * 60 + as.numeric(p[, 3]) | |
| } | |
| out | |
| } | |
| gtfs_isochrone_compat <- function(gtfs, from, start_time, end_time, | |
| from_is_id = TRUE, | |
| departure_window_secs = 1800) { | |
| if (is.null(gtfs)) return(NULL) | |
| start_time <- as.numeric(start_time) | |
| end_time <- as.numeric(end_time) | |
| max_tt_secs <- end_time - start_time | |
| if (!is.finite(max_tt_secs) || max_tt_secs <= 0) return(NULL) | |
| tt <- tryCatch( | |
| gtfsrouter::gtfs_traveltimes( | |
| gtfs, | |
| from = as.character(from), | |
| from_is_id = isTRUE(from_is_id), | |
| start_time_limits = c(start_time, start_time + departure_window_secs), | |
| max_traveltime = ceiling(max_tt_secs) | |
| ), | |
| error = function(e) { | |
| warning("gtfs_traveltimes failed from stop ", from, ": ", conditionMessage(e)) | |
| NULL | |
| } | |
| ) | |
| if (is.null(tt) || !nrow(tt) || !("stop_id" %in% names(tt))) return(NULL) | |
| dur_secs <- .hms_to_seconds(tt$duration) | |
| keep <- is.finite(dur_secs) & dur_secs <= max_tt_secs | |
| if (!any(keep)) return(NULL) | |
| out <- data.frame( | |
| stop_id = as.character(tt$stop_id)[keep], | |
| duration = dur_secs[keep], # numeric seconds, as the callers expect | |
| stringsAsFactors = FALSE | |
| ) | |
| if ("stop_lon" %in% names(tt)) out$stop_lon <- tt$stop_lon[keep] | |
| if ("stop_lat" %in% names(tt)) out$stop_lat <- tt$stop_lat[keep] | |
| out[!duplicated(out$stop_id), , drop = FALSE] | |
| } | |
| # --------------------------------------------------------------------------- | |
| # REALISTIC TRANSIT ISOCHRONE | |
| # --------------------------------------------------------------------------- | |
| # The original Transit mode drew the CONVEX HULL of the stops reachable from the | |
| # single nearest stop. A hull measures how far apart the outermost stops are, | |
| # not what you can reach: a few far-flung stops on fast radial corridors fill in | |
| # a huge triangle of land no route serves, while dense inner-city service with | |
| # fifty nearby stops draws a small one. Measured from two origins at 15 min, | |
| # Telegraph Hill reached MORE of the network (52 stops vs 43) yet got a hull less | |
| # than half the size (1.41 vs 3.03 km2). | |
| # | |
| # This is the standard construction instead - walk to nearby stops, ride, then | |
| # walk out from each destination stop with whatever time remains, and union the | |
| # pieces, giving the characteristic "beads along corridors" shape: | |
| # | |
| # reachable = walkshed(origin, T) | |
| # U { walkshed(stop s, T - access(s0) - ride(s0 -> s)) } | |
| # | |
| # Walking speed is 60 m/min, calibrated rather than assumed: the median Mapbox | |
| # walking-network isochrone at 5/10/15 min has an equal-area radius implying | |
| # 62.3 / 60.1 / 59.4 m/min, so a straight-line 80 m/min would overstate the | |
| # reachable area by about (80/60)^2 = 1.8x. Buffers are computed in UTM | |
| # (ISO_METRIC_CRS), not Web Mercator, which at this latitude would inflate every | |
| # distance by roughly 1/cos(37.8) = 1.27x. | |
| build_transit_isochrone_union <- function( | |
| location_sf, total_time_min, dep_secs, gtfs_stops_sf, gtfs_router, | |
| walk_m_per_min = 60, max_access_min = 10, max_board_stops = 8, | |
| charge_wait = FALSE | |
| ) { | |
| if (is.null(gtfs_router) || is.null(gtfs_stops_sf)) return(NULL) | |
| if (!is.finite(total_time_min) || total_time_min <= 0) return(NULL) | |
| loc_m <- st_transform(location_sf, ISO_METRIC_CRS) | |
| stops_m <- st_transform(gtfs_stops_sf, ISO_METRIC_CRS) | |
| # you can always just walk, so the origin's own walkshed is always included | |
| parts <- list(st_geometry(st_buffer(loc_m, total_time_min * walk_m_per_min))) | |
| access_min <- as.numeric(st_distance(loc_m, stops_m)) / walk_m_per_min | |
| # Expected wait for the first vehicle. A rider who turns up without consulting | |
| # a timetable waits, on average, half the headway. The median Muni headway is | |
| # 15 min, so this charges about 7.5 min of a 15 min budget - which is why the | |
| # paper reports it as a separate variant rather than folding it in silently. | |
| # Waiting at any TRANSFER is already inside gtfs_traveltimes()'s duration, so | |
| # only the boarding wait is added here. | |
| wait_min <- rep(0, nrow(stops_m)) | |
| if (isTRUE(charge_wait)) { | |
| hw <- suppressWarnings(as.numeric(gtfs_stops_sf$mean_headway_min)) | |
| if (all(is.na(hw))) { | |
| warning("no headway data on gtfs_stops_sf; expected wait not charged") | |
| } else { | |
| hw[is.na(hw)] <- stats::median(hw, na.rm = TRUE) # stops with no data | |
| wait_min <- hw / 2 | |
| } | |
| } | |
| board <- which(access_min + wait_min <= total_time_min & | |
| access_min <= min(max_access_min, total_time_min)) | |
| if (length(board) > 0) { | |
| # nearest few boarding stops only: routing every stop within walking range | |
| # would make one map click take tens of seconds and barely move the shape. | |
| # | |
| # But distance alone is not enough once the feed is multi-operator. The 8 | |
| # nearest stops in central San Francisco are all Muni bus stops: the median | |
| # block group that can walk to BART has 49 stops within 600 m and ranks its | |
| # BART station 30th. Of 150 block groups with a BART stop inside 800 m, only | |
| # 22 had one among the 8 nearest. Boarding by distance therefore discards | |
| # rail entirely and silently cancels the BART/Caltrain merge in exactly the | |
| # neighbourhoods it exists to fix. So: the nearest few overall, PLUS the | |
| # nearest stop of each operator in range. On a single-operator feed the | |
| # second part adds nothing, so this is safe either way. | |
| op_of <- function(x) { | |
| p <- sub("_.*", "", as.character(x)) | |
| ifelse(p %in% c("muni", "bart", "cal"), p, "other") | |
| } | |
| board <- board[order(access_min[board])] | |
| ops <- op_of(gtfs_stops_sf$stop_id[board]) | |
| board <- unique(c(head(board, max_board_stops), | |
| board[!duplicated(ops)])) | |
| sid_all <- as.character(gtfs_stops_sf$stop_id) | |
| best <- rep(Inf, length(sid_all)) # best total time to reach each stop | |
| for (bi in board) { | |
| remaining <- total_time_min - access_min[bi] - wait_min[bi] | |
| if (remaining <= 0) next | |
| rr <- gtfs_isochrone_compat( | |
| gtfs = gtfs_router, from = sid_all[bi], start_time = dep_secs, | |
| end_time = dep_secs + remaining * 60, from_is_id = TRUE | |
| ) | |
| if (is.null(rr) || !nrow(rr)) next | |
| idx <- match(as.character(rr$stop_id), sid_all) | |
| ok <- !is.na(idx) | |
| if (!any(ok)) next | |
| cost <- access_min[bi] + wait_min[bi] + rr$duration[ok] / 60 | |
| best[idx[ok]] <- pmin(best[idx[ok]], cost) | |
| } | |
| egress_min <- total_time_min - best | |
| keep <- which(is.finite(egress_min) & egress_min > 0) | |
| if (length(keep) > 0) { | |
| parts <- append(parts, list(st_geometry( | |
| st_buffer(stops_m[keep, ], egress_min[keep] * walk_m_per_min)))) | |
| } | |
| } | |
| out <- st_union(do.call(c, parts)) | |
| if (length(out) == 0) return(NULL) | |
| st_transform(out, 4326) | |
| } | |
| build_walk_transit_isochrone <- function( | |
| location_sf, total_time_min, dep_secs, | |
| walk_to_stop_min, walk_from_stop_min, | |
| gtfs_stops_sf, gtfs_router, mapbox_token, | |
| departure_window_min = 10, | |
| departure_step_min = 5, | |
| max_last_mile_stops = 12, | |
| include_first_mile_polygon = TRUE | |
| ) { | |
| if (is.null(gtfs_router)) return(NULL) | |
| if (is.null(total_time_min) || total_time_min <= 0) return(NULL) | |
| if (is.null(walk_to_stop_min) || walk_to_stop_min <= 0) return(NULL) | |
| first_mile_walkshed <- build_first_mile_walkshed( | |
| location_sf = location_sf, | |
| walk_minutes = walk_to_stop_min, | |
| mapbox_token = mapbox_token | |
| ) | |
| origin_walk_stops <- get_walk_accessible_stops( | |
| location_sf = location_sf, | |
| walk_minutes = walk_to_stop_min, | |
| gtfs_stops_sf = gtfs_stops_sf, | |
| mapbox_token = mapbox_token | |
| ) | |
| if (is.null(origin_walk_stops) || nrow(origin_walk_stops) == 0) { | |
| return(NULL) | |
| } | |
| origin_walk_stops <- origin_walk_stops |> | |
| mutate(stop_id_chr = as.character(stop_id)) | |
| departure_offsets_min <- seq( | |
| from = 0, | |
| to = max(0, departure_window_min), | |
| by = max(1, departure_step_min) | |
| ) | |
| reachable_rows <- list() | |
| for (i in seq_len(nrow(origin_walk_stops))) { | |
| sid <- as.character(origin_walk_stops$stop_id_chr[[i]]) | |
| first_mile_time_i <- as.numeric(origin_walk_stops$walk_time_to_origin_min[[i]]) | |
| first_mile_time_i <- min(first_mile_time_i, walk_to_stop_min, na.rm = TRUE) | |
| if (!is.finite(first_mile_time_i) || first_mile_time_i >= total_time_min) next | |
| for (wait_offset_min in departure_offsets_min) { | |
| remaining_budget_before_transit <- total_time_min - first_mile_time_i - wait_offset_min | |
| if (!is.finite(remaining_budget_before_transit) || remaining_budget_before_transit <= 0) next | |
| start_time_i <- dep_secs + wait_offset_min * 60 | |
| end_time_i <- start_time_i + remaining_budget_before_transit * 60 | |
| iso_result <- tryCatch( | |
| gtfs_isochrone_compat( | |
| gtfs = gtfs_router, | |
| from = sid, | |
| start_time = start_time_i, | |
| end_time = end_time_i, | |
| from_is_id = TRUE | |
| ), | |
| error = function(e) NULL | |
| ) | |
| if (is.null(iso_result) || nrow(iso_result) == 0 || !("stop_id" %in% names(iso_result))) next | |
| transit_min <- extract_transit_minutes(iso_result, start_time_i) | |
| res_i <- iso_result |> | |
| mutate( | |
| stop_id_chr = as.character(stop_id), | |
| origin_stop_id = sid, | |
| first_mile_walk_min = first_mile_time_i, | |
| wait_time_min = wait_offset_min, | |
| transit_time_min = transit_min | |
| ) | |
| if (!("transit_time_min" %in% names(res_i)) || all(is.na(res_i$transit_time_min))) { | |
| res_i$transit_time_min <- remaining_budget_before_transit | |
| } | |
| reachable_rows[[length(reachable_rows) + 1]] <- res_i | |
| } | |
| } | |
| if (length(reachable_rows) == 0) return(NULL) | |
| reachable_tbl <- dplyr::bind_rows(reachable_rows) |> | |
| mutate(total_pre_lastmile_min = first_mile_walk_min + wait_time_min + transit_time_min) |> | |
| group_by(stop_id_chr) |> | |
| summarise( | |
| first_mile_walk_min = min(first_mile_walk_min, na.rm = TRUE), | |
| wait_time_min = min(wait_time_min, na.rm = TRUE), | |
| transit_time_min = min(transit_time_min, na.rm = TRUE), | |
| best_total_pre_lastmile_min = min(total_pre_lastmile_min, na.rm = TRUE), | |
| .groups = "drop" | |
| ) |> | |
| mutate( | |
| remaining_walk_min = total_time_min - best_total_pre_lastmile_min, | |
| remaining_walk_min = pmin(remaining_walk_min, walk_from_stop_min) | |
| ) |> | |
| filter(is.finite(remaining_walk_min), remaining_walk_min > 0.5) | |
| if (nrow(reachable_tbl) == 0) return(NULL) | |
| reachable_sf <- gtfs_stops_sf |> | |
| mutate(stop_id_chr = as.character(stop_id)) |> | |
| inner_join(reachable_tbl, by = "stop_id_chr") | |
| if (nrow(reachable_sf) == 0) return(NULL) | |
| last_mile_walkshed <- build_last_mile_walkshed( | |
| reachable_sf = reachable_sf, | |
| remaining_walk_col = "remaining_walk_min", | |
| mapbox_token = mapbox_token, | |
| walk_from_stop_cap_min = walk_from_stop_min, | |
| max_stops = max_last_mile_stops | |
| ) | |
| if (is.null(last_mile_walkshed) || nrow(last_mile_walkshed) == 0) return(NULL) | |
| final_geom <- st_union(st_geometry(last_mile_walkshed)) | |
| if (include_first_mile_polygon && !is.null(first_mile_walkshed) && nrow(first_mile_walkshed) > 0) { | |
| final_geom <- st_union(final_geom, st_union(st_geometry(first_mile_walkshed))) | |
| } | |
| final_sf <- st_sf(geometry = final_geom, crs = 4326) |> | |
| st_make_valid() | |
| iso_sf <- st_sf( | |
| mode = "walk_transit", | |
| time = as.numeric(total_time_min), | |
| geometry = st_geometry(final_sf), | |
| crs = 4326 | |
| ) | |
| standardize_iso_sf(iso_sf, mode_name = "walk_transit", time_min = total_time_min) | |
| } | |
| # Shared isochrone -> metrics -> BAI -> radar functions. Both the Isochrone | |
| # Explorer tab and the Isochrone Comparer tab call these, so the scoring logic | |
| # lives in one place. (Reads the setup_unified.R globals + helpers above; the | |
| # per-session gbif_tab is passed in as an argument; percentile reference scoring is added below.) | |
| # | |
| # local = TRUE is required: runApp() evaluates this app.R inside its own app | |
| # environment, so mapbox_token and the helper functions above live there, not in | |
| # the global env. Sourcing with the default (local = FALSE) would define these | |
| # functions in the global env, where they could not see mapbox_token/pretty_mode/ | |
| # etc. -> "object 'mapbox_token' not found". local = TRUE defines them right here | |
| # in the app env, alongside the helpers they call. | |
| # code/iso_metrics_AUDITED.R carries the SAME broken gtfsrouter::gtfs_isochrone() | |
| # call for the pure Transit mode. It is loaded here with that one call rewritten | |
| # to the shim above, so this file stays the only thing that changes on disk. | |
| # Equivalent to source("code/iso_metrics_AUDITED.R", local = TRUE) otherwise. | |
| .iso_lines <- readLines("code/iso_metrics_AUDITED.R", warn = FALSE) | |
| # (a) Replace the whole Transit block with a call to the realistic builder | |
| # above. Line-based splice rather than a text substitution, because the block | |
| # spans nested braces that no single regex matches safely. | |
| .b0 <- grep("^ stop_dists <- st_distance\\(location_sf, gtfs_stops_sf\\)$", .iso_lines) | |
| .b1 <- grep("^ if \\(!is.null\\(iso_poly\\)\\) \\{$", .iso_lines) | |
| if (length(.b0) == 1 && length(.b1) >= 1 && any(.b1 > .b0)) { | |
| .b1 <- .b1[.b1 > .b0][1] | |
| .iso_lines <- c( | |
| .iso_lines[seq_len(.b0 - 1L)], | |
| " dep_secs <- as.numeric(transit_hour) * 3600", | |
| "", | |
| " for (t in times) {", | |
| " # transit_iso_cache is bypassed on purpose: it stores the old convex", | |
| " # hulls keyed on the nearest stop, which are the wrong geometry.", | |
| " iso_poly <- tryCatch(", | |
| " build_transit_isochrone_union(", | |
| " location_sf = location_sf, total_time_min = t, dep_secs = dep_secs,", | |
| " gtfs_stops_sf = gtfs_stops_sf, gtfs_router = gtfs_router,", | |
| " charge_wait = isTRUE(getOption(\"bas.transit_charge_wait\", FALSE))),", | |
| " error = function(e) {", | |
| " warning(\"transit isochrone failed: \", conditionMessage(e)); NULL })", | |
| "", | |
| .iso_lines[seq(.b1, length(.iso_lines))] | |
| ) | |
| } else { | |
| message("[app] could not locate the Transit block in code/iso_metrics_AUDITED.R - left unchanged.") | |
| } | |
| # (b) safety net: any remaining call to the function that does not exist in | |
| # gtfsrouter 0.1.4 still routes through the shim. | |
| .iso_lines <- gsub("gtfsrouter::gtfs_isochrone(", "gtfs_isochrone_compat(", | |
| .iso_lines, fixed = TRUE) | |
| # Evaluated at top level, so the definitions land in this app environment - | |
| # exactly where source(..., local = TRUE) would have put them. | |
| eval(parse(text = paste(.iso_lines, collapse = "\n"))) | |
| rm(.iso_lines, .b0, .b1) | |
| # The audited helper is sourced into this app environment. Replace only its two | |
| # lazy lookup loaders so they keep using the absolute project-root paths above | |
| # even if Shiny later changes the working directory. | |
| load_iso_species_cell_lookup <- function() { | |
| if (exists("species", envir = .iso_metric_cache, inherits = FALSE)) { | |
| return(get("species", envir = .iso_metric_cache, inherits = FALSE)) | |
| } | |
| path <- .ISO_SPECIES_CELL_RDS | |
| if (!file.exists(path)) { | |
| warning( | |
| "Missing ", path, ". Run corrected 03_compute_isochrone_basi.R before ", | |
| "using definition-matched biodiversity percentiles." | |
| ) | |
| result <- NULL | |
| } else { | |
| result <- readRDS(path) | |
| ver <- attr(result, "iso_metric_definition_version") | |
| if (is.null(ver) || !identical(ver, ISO_METRIC_DEFINITION_VERSION)) { | |
| stop("GBIF cell lookup definition version does not match the Shiny metric definition.") | |
| } | |
| } | |
| assign("species", result, envir = .iso_metric_cache) | |
| result | |
| } | |
| load_iso_route_cell_lookup <- function() { | |
| if (exists("routes", envir = .iso_metric_cache, inherits = FALSE)) { | |
| return(get("routes", envir = .iso_metric_cache, inherits = FALSE)) | |
| } | |
| path <- .ISO_ROUTE_CELL_RDS | |
| if (!file.exists(path)) { | |
| warning( | |
| "Missing ", path, ". Run corrected 03_compute_isochrone_basi.R before ", | |
| "using definition-matched route-access percentiles." | |
| ) | |
| result <- NULL | |
| } else { | |
| result <- readRDS(path) | |
| ver <- attr(result, "iso_metric_definition_version") | |
| if (is.null(ver) || !identical(ver, ISO_METRIC_DEFINITION_VERSION)) { | |
| stop("Muni route-cell lookup definition version does not match the Shiny metric definition.") | |
| } | |
| } | |
| assign("routes", result, envir = .iso_metric_cache) | |
| result | |
| } | |
| # ============================================================================= | |
| # PRECOMPUTED ISOCHRONE REFERENCE PERCENTILES β AUDITED | |
| # ============================================================================= | |
| # ONE user-generated isochrone -> ONE independently calculated raw metric row -> | |
| # ONE reference distribution with exactly the SAME mode and SAME time. | |
| # | |
| # The corrected Step-5 reference and compute_iso_metrics() share the definition | |
| # version declared in code/iso_metric_definitions.R. Old reference files are | |
| # rejected rather than silently mixing incompatible metric definitions. | |
| iso_reference_path <- Sys.getenv( | |
| "ISOCHRONE_REFERENCE_RDS", | |
| unset = file.path("outputs", "results", "isochrone_basi_100m.rds") | |
| ) | |
| normalize_reference_mode <- function(x) { | |
| x <- tolower(trimws(as.character(x))) | |
| x <- gsub("_", "-", x, fixed = TRUE) | |
| x <- gsub("\\s+", "-", x) | |
| dplyr::case_when( | |
| x %in% c("walk", "walking") ~ "walking", | |
| x %in% c("bike", "bicycle", "cycling") ~ "cycling", | |
| x %in% c("drive", "driving") ~ "driving", | |
| x %in% c("driving-traffic", "driving-with-traffic") ~ "driving-traffic", | |
| x %in% c("walk-transit", "walk+transit", "walk-plus-transit") ~ "walk-transit", | |
| TRUE ~ x | |
| ) | |
| } | |
| pick_first_column <- function(x, candidates) { | |
| hit <- candidates[candidates %in% names(x)] | |
| if (length(hit) == 0) return(NA_character_) | |
| hit[[1]] | |
| } | |
| # External-observation percentile convention. | |
| # ------------------------------------------- | |
| # A user-generated isochrone is NOT one of the fixed reference observations. | |
| # Therefore its percentile is the fraction of finite reference values it | |
| # strictly outperforms, with denominator n (not n-1): | |
| # higher-is-better: 100 * #{ref < x} / n | |
| # lower-is-better : 100 * #{ref > x} / n | |
| # This has an immediate interpretation: "the user value is better than X% of | |
| # the matching precomputed reference isochrones." Ties are not counted as wins. | |
| percent_rank_against_reference <- function(x, reference_values, direction = "higher") { | |
| x <- suppressWarnings(as.numeric(x)) | |
| ref <- suppressWarnings(as.numeric(reference_values)) | |
| ref <- ref[is.finite(ref)] | |
| if (length(x) != 1 || !is.finite(x) || length(ref) == 0) return(NA_real_) | |
| out <- if (identical(direction, "lower")) { | |
| 100 * sum(ref > x) / length(ref) | |
| } else { | |
| 100 * sum(ref < x) / length(ref) | |
| } | |
| pmin(100, pmax(0, out)) | |
| } | |
| # Separate helper for QA only: reproduce the stored dplyr::percent_rank() of an | |
| # observation already INCLUDED in the reference sample. Step 5 uses n-1 and | |
| # min-rank tie behavior, and lower-better metrics are stored as 100 - percent_rank. | |
| percent_rank_existing_reference <- function(x, reference_values, direction = "higher") { | |
| x <- suppressWarnings(as.numeric(x)) | |
| ref <- suppressWarnings(as.numeric(reference_values)) | |
| ref <- ref[is.finite(ref)] | |
| if (length(x) != 1 || !is.finite(x) || length(ref) < 2) return(NA_real_) | |
| direct <- 100 * sum(ref < x) / (length(ref) - 1) | |
| direct <- pmin(100, pmax(0, direct)) | |
| if (identical(direction, "lower")) 100 - direct else direct | |
| } | |
| # Strict app raw -> corrected Step-5 raw mapping. There are intentionally no | |
| # fallback aliases to older incompatible reference columns. | |
| iso_reference_metric_spec <- list( | |
| GBIF_Species = list( | |
| raw = "n_species_accessed", pct = "pctile_n_species_accessed", | |
| out = "pctile_GBIF_Species", axis = "Biodiversity_Potential_std", | |
| label = "Total species richness", direction = "higher" | |
| ), | |
| Bird_Species = list( | |
| raw = "n_birds_accessed", pct = "pctile_n_birds_accessed", | |
| out = "pctile_Bird_Species", axis = NA_character_, | |
| label = "Bird species richness", direction = "higher" | |
| ), | |
| Mammal_Species = list( | |
| raw = "n_mammals_accessed", pct = "pctile_n_mammals_accessed", | |
| out = "pctile_Mammal_Species", axis = NA_character_, | |
| label = "Mammal species richness", direction = "higher" | |
| ), | |
| Plant_Species = list( | |
| raw = "n_plants_accessed", pct = "pctile_n_plants_accessed", | |
| out = "pctile_Plant_Species", axis = NA_character_, | |
| label = "Plant species richness", direction = "higher" | |
| ), | |
| GBIF_Records = list( | |
| raw = "n_records_accessed", pct = "pctile_n_records_accessed", | |
| out = "pctile_GBIF_Records", axis = NA_character_, | |
| label = "GBIF records", direction = "higher" | |
| ), | |
| SamplingDensity_km2 = list( | |
| raw = "sampling_density_km2", pct = "pctile_sampling_density_km2", | |
| out = "pctile_SamplingDensity_km2", axis = "Observation_Intensity_std", | |
| label = "GBIF sampling density", direction = "higher" | |
| ), | |
| MeanNDVI = list( | |
| raw = "mean_ndvi", pct = "pctile_mean_ndvi", | |
| out = "pctile_MeanNDVI", axis = "Environmental_Quality_std", | |
| label = "Mean NDVI", direction = "higher" | |
| ), | |
| Greenspace_percent = list( | |
| raw = "greenspace_pct", pct = "pctile_greenspace_pct", | |
| out = "pctile_Greenspace_percent", axis = "Greenspace_Cover_std", | |
| label = "Greenspace cover", direction = "higher" | |
| ), | |
| Transit_Stops = list( | |
| raw = "n_stops_accessed", pct = "pctile_n_stops_accessed", | |
| out = "pctile_Transit_Stops", axis = NA_character_, | |
| label = "Transit stops", direction = "higher" | |
| ), | |
| Transit_Access_Score = list( | |
| raw = "transit_access_score", pct = "pctile_transit_access_score", | |
| out = "pctile_Transit_Access_Score", axis = "Mobility_Access_std", | |
| label = "Transit stop density", direction = "higher" | |
| ), | |
| Unique_Muni_Routes = list( | |
| raw = "unique_muni_routes", pct = "pctile_unique_muni_routes", | |
| out = "pctile_Unique_Muni_Routes", axis = "Route_Access_std", | |
| label = "Muni route access", direction = "higher" | |
| ), | |
| Nearest_Stop_m = list( | |
| raw = "nearest_stop_m", pct = "pctile_nearest_stop_m", | |
| out = "pctile_Nearest_Stop_m", axis = NA_character_, | |
| label = "Nearest-stop distance", direction = "lower" | |
| ), | |
| CalEnviro_CIscore = list( | |
| raw = "calenviro_ci_score", pct = "pctile_calenviro_ci_score", | |
| out = "pctile_CalEnviro_CIscore", axis = NA_character_, | |
| label = "CalEnviroScreen burden", direction = "lower" | |
| ), | |
| CalEnviro_Traffic_Pctl = list( | |
| raw = "calenviro_traffic_pct", pct = "pctile_calenviro_traffic_pct", | |
| out = "pctile_CalEnviro_Traffic_Pctl", axis = NA_character_, | |
| label = "Traffic burden percentile", direction = "lower" | |
| ), | |
| SF_EJ_Score = list( | |
| raw = "sf_ej_score", pct = "pctile_sf_ej_score", | |
| out = "pctile_SF_EJ_Score", axis = "Equity_Context_std", | |
| label = "SF EJ context", direction = "lower" | |
| ) | |
| ) | |
| build_iso_reference_metric_map <- function(spec = iso_reference_metric_spec) { | |
| dplyr::bind_rows(lapply(names(spec), function(app_col) { | |
| s <- spec[[app_col]] | |
| data.frame( | |
| app_col = app_col, | |
| ref_raw_col = s$raw, | |
| ref_pct_col = s$pct, | |
| out_col = s$out, | |
| n_out_col = paste0("nref_", app_col), | |
| axis_col = s$axis, | |
| label = s$label, | |
| direction = s$direction, | |
| stringsAsFactors = FALSE | |
| ) | |
| })) | |
| } | |
| load_iso_reference <- function(path = iso_reference_path) { | |
| if (!file.exists(path)) { | |
| warning( | |
| "Precomputed isochrone reference not found: ", path, | |
| ". Raw metrics can still be calculated, but same-mode Γ same-time ", | |
| "percentiles are unavailable." | |
| ) | |
| return(NULL) | |
| } | |
| ref <- tryCatch(readRDS(path), error = function(e) { | |
| warning("Could not read precomputed isochrone reference: ", e$message) | |
| NULL | |
| }) | |
| if (is.null(ref)) return(NULL) | |
| if (inherits(ref, "sf")) ref <- sf::st_drop_geometry(ref) | |
| ref <- as.data.frame(ref) | |
| required_identity <- c("mode", "time_min", "reference_schema_version") | |
| if (!all(required_identity %in% names(ref))) { | |
| warning( | |
| "Isochrone reference is an OLD/INCOMPATIBLE schema. Missing: ", | |
| paste(setdiff(required_identity, names(ref)), collapse = ", "), | |
| ". Rerun 03_compute_isochrone_basi_AUDITED.R (or the validated recovery output); do not use this RDS for scoring." | |
| ) | |
| return(NULL) | |
| } | |
| versions <- unique(na.omit(as.character(ref$reference_schema_version))) | |
| if (length(versions) != 1 || !identical(versions[[1]], ISO_METRIC_DEFINITION_VERSION)) { | |
| warning( | |
| "Isochrone reference metric-definition version mismatch. Expected '", | |
| ISO_METRIC_DEFINITION_VERSION, "' but found '", paste(versions, collapse = ", "), | |
| "'. Rerun corrected Step 5." | |
| ) | |
| return(NULL) | |
| } | |
| ref$mode <- normalize_reference_mode(ref$mode) | |
| ref$time_min <- suppressWarnings(as.numeric(ref$time_min)) | |
| metric_map <- build_iso_reference_metric_map() | |
| required_raw <- unique(metric_map$ref_raw_col) | |
| missing_raw <- setdiff(required_raw, names(ref)) | |
| if (length(missing_raw) > 0) { | |
| warning( | |
| "Corrected isochrone reference is missing required raw metrics: ", | |
| paste(missing_raw, collapse = ", "), | |
| ". Percentile scoring is disabled rather than substituting another metric." | |
| ) | |
| return(NULL) | |
| } | |
| counts <- ref |> | |
| dplyr::filter(is.finite(time_min)) |> | |
| dplyr::count(mode, time_min, name = "n_reference") |> | |
| dplyr::arrange(mode, time_min) | |
| # This audited reference was validated against the complete SF 100 m origin | |
| # grid: 24,182 origins in each supported mode Γ time group. | |
| expected_counts <- tidyr::crossing( | |
| mode = c("walking", "cycling", "driving"), | |
| time_min = c(5, 10, 15) | |
| ) |> | |
| dplyr::left_join(counts, by = c("mode", "time_min")) | |
| bad_counts <- is.na(expected_counts$n_reference) | | |
| expected_counts$n_reference != 24182L | |
| if (any(bad_counts)) { | |
| warning( | |
| "Isochrone reference is incomplete. Expected 24,182 origin rows in every ", | |
| "walking/cycling/driving Γ 5/10/15-minute group. Scoring is disabled. ", | |
| "Rerun the validated Step-5 recovery/output before launching the app." | |
| ) | |
| return(NULL) | |
| } | |
| message("Loaded corrected same-mode Γ same-time isochrone reference: ", path) | |
| if (nrow(counts) > 0) { | |
| message(paste0( | |
| "Reference groups: ", | |
| paste(paste0(counts$mode, " ", counts$time_min, " min n=", counts$n_reference), collapse = "; ") | |
| )) | |
| } | |
| ref | |
| } | |
| iso_reference_metric_map <- build_iso_reference_metric_map() | |
| iso_reference <- load_iso_reference() | |
| # Cache each exact mode Γ time reference subset. No other group may enter a row's | |
| # percentile calculation. | |
| .iso_reference_group_cache <- new.env(parent = emptyenv()) | |
| get_iso_reference_group <- function(mode, time_min, ref = iso_reference) { | |
| if (is.null(ref)) return(NULL) | |
| mode <- normalize_reference_mode(mode) | |
| time_min <- suppressWarnings(as.numeric(time_min)) | |
| if (length(mode) != 1 || is.na(mode) || length(time_min) != 1 || !is.finite(time_min)) return(NULL) | |
| if (!(mode %in% c("walking", "cycling", "driving")) || !(time_min %in% c(5, 10, 15))) { | |
| return(NULL) | |
| } | |
| key <- paste0(mode, "__", time_min) | |
| if (exists(key, envir = .iso_reference_group_cache, inherits = FALSE)) { | |
| return(get(key, envir = .iso_reference_group_cache, inherits = FALSE)) | |
| } | |
| g <- ref[ | |
| ref$mode == mode & is.finite(ref$time_min) & abs(ref$time_min - time_min) < 1e-9, | |
| , drop = FALSE | |
| ] | |
| if (nrow(g) > 0) { | |
| stopifnot( | |
| dplyr::n_distinct(g$mode) == 1L, | |
| dplyr::n_distinct(g$time_min) == 1L, | |
| all(g$mode == mode), | |
| all(abs(g$time_min - time_min) < 1e-9) | |
| ) | |
| } | |
| assign(key, g, envir = .iso_reference_group_cache) | |
| g | |
| } | |
| # Same seven-axis BAI distribution for the fixed citywide reference group. | |
| # | |
| # IMPORTANT: | |
| # - one mode Γ one travel time only; | |
| # - no isochrone groups are pooled; | |
| # - only reference rows with all seven stored percentile dimensions contribute; | |
| # - the USER BAI is treated as an external observation and ranked using the | |
| # same strict-outperformance convention as every other live percentile. | |
| .reference_bai_pct_cols <- c( | |
| "pctile_transit_access_score", | |
| "pctile_unique_muni_routes", | |
| "pctile_n_species_accessed", | |
| "pctile_sampling_density_km2", | |
| "pctile_mean_ndvi", | |
| "pctile_greenspace_pct", | |
| "pctile_sf_ej_score" | |
| ) | |
| .iso_reference_bai_cache <- new.env(parent = emptyenv()) | |
| get_reference_bai_distribution <- function(mode, time_min, ref = iso_reference) { | |
| mode <- normalize_reference_mode(mode) | |
| time_min <- suppressWarnings(as.numeric(time_min)) | |
| if (length(mode) != 1 || is.na(mode) || | |
| length(time_min) != 1 || !is.finite(time_min)) { | |
| return(numeric(0)) | |
| } | |
| if (!(mode %in% c("walking", "cycling", "driving")) || | |
| !(time_min %in% c(5, 10, 15))) { | |
| return(numeric(0)) | |
| } | |
| key <- paste0("bai__", mode, "__", time_min) | |
| if (exists(key, envir = .iso_reference_bai_cache, inherits = FALSE)) { | |
| return(get(key, envir = .iso_reference_bai_cache, inherits = FALSE)) | |
| } | |
| g <- get_iso_reference_group(mode, time_min, ref = ref) | |
| result <- numeric(0) | |
| if (!is.null(g) && nrow(g) > 0 && | |
| all(.reference_bai_pct_cols %in% names(g))) { | |
| mat <- as.matrix(g[, .reference_bai_pct_cols, drop = FALSE]) | |
| storage.mode(mat) <- "numeric" | |
| complete <- rowSums(is.finite(mat)) == length(.reference_bai_pct_cols) | |
| if (any(complete)) { | |
| # Stored reference percentiles are on 0β100; BAI itself is on 0β1. | |
| result <- rowMeans(mat[complete, , drop = FALSE]) / 100 | |
| result <- result[is.finite(result)] | |
| } | |
| } | |
| assign(key, result, envir = .iso_reference_bai_cache) | |
| result | |
| } | |
| validate_iso_reference_percentiles <- function( | |
| ref = iso_reference, | |
| metric_map = iso_reference_metric_map, | |
| tolerance_pct_points = 0.11 | |
| ) { | |
| if (is.null(ref) || nrow(metric_map) == 0) return(invisible(NULL)) | |
| expected_groups <- tidyr::crossing( | |
| mode = c("walking", "cycling", "driving"), | |
| time_min = c(5, 10, 15) | |
| ) | |
| group_check <- ref |> | |
| dplyr::count(mode, time_min, name = "n") |> | |
| dplyr::right_join(expected_groups, by = c("mode", "time_min")) | |
| if (any(is.na(group_check$n))) warning("One or more expected mode Γ time reference groups are missing.") | |
| diagnostics <- list() | |
| for (j in seq_len(nrow(metric_map))) { | |
| m <- metric_map[j, , drop = FALSE] | |
| raw_col <- m$ref_raw_col[[1]] | |
| pct_col <- m$ref_pct_col[[1]] | |
| if (!(raw_col %in% names(ref)) || !(pct_col %in% names(ref))) next | |
| # Use the first complete standard group for a stored-percentile reproduction | |
| # check. This is deliberately separate from external user scoring. | |
| for (k in seq_len(nrow(expected_groups))) { | |
| g <- get_iso_reference_group(expected_groups$mode[k], expected_groups$time_min[k], ref) | |
| if (is.null(g) || nrow(g) < 3) next | |
| raw <- suppressWarnings(as.numeric(g[[raw_col]])) | |
| stored <- suppressWarnings(as.numeric(g[[pct_col]])) | |
| ok <- is.finite(raw) & is.finite(stored) | |
| raw <- raw[ok]; stored <- stored[ok] | |
| if (length(raw) < 3) next | |
| ord <- order(raw) | |
| picks <- unique(c(ord[1], ord[ceiling(length(ord)/2)], ord[length(ord)])) | |
| pred <- vapply( | |
| raw[picks], percent_rank_existing_reference, numeric(1), | |
| reference_values = raw, direction = m$direction[[1]] | |
| ) | |
| err <- max(abs(pred - stored[picks]), na.rm = TRUE) | |
| diagnostics[[length(diagnostics) + 1L]] <- data.frame( | |
| metric = m$app_col[[1]], mode = expected_groups$mode[k], | |
| time_min = expected_groups$time_min[k], max_abs_error = err | |
| ) | |
| break | |
| } | |
| } | |
| diagnostics <- dplyr::bind_rows(diagnostics) | |
| if (nrow(diagnostics) > 0 && any(diagnostics$max_abs_error > tolerance_pct_points)) { | |
| bad <- diagnostics$max_abs_error > tolerance_pct_points | |
| warning( | |
| "Stored Step-5 percentile reproduction exceeded tolerance for: ", | |
| paste0(diagnostics$metric[bad], " (", round(diagnostics$max_abs_error[bad], 3), " pp)", collapse = "; ") | |
| ) | |
| } | |
| invisible(diagnostics) | |
| } | |
| iso_reference_validation <- validate_iso_reference_percentiles() | |
| score_iso_metrics_against_reference <- function( | |
| metrics, | |
| ref = iso_reference, | |
| metric_map = iso_reference_metric_map | |
| ) { | |
| if (is.null(metrics) || nrow(metrics) == 0) return(metrics) | |
| out <- metrics | |
| old_attrs <- attributes(metrics) | |
| mode_col <- pick_first_column(out, c("mode", "Mode")) | |
| time_col <- pick_first_column(out, c("time_min", "time", "Time")) | |
| for (nm in unique(metric_map$out_col)) out[[nm]] <- NA_real_ | |
| for (nm in unique(metric_map$n_out_col)) out[[nm]] <- NA_integer_ | |
| out$Reference_Mode <- NA_character_ | |
| out$Reference_Time_min <- NA_real_ | |
| out$Reference_N <- NA_integer_ | |
| out$Reference_Status <- NA_character_ | |
| metric_version_ok <- "MetricDefinitionVersion" %in% names(out) && | |
| all(as.character(out$MetricDefinitionVersion) == ISO_METRIC_DEFINITION_VERSION) | |
| if (!metric_version_ok) { | |
| out$Reference_Status <- paste0( | |
| "N/A β raw metric definition is not ", ISO_METRIC_DEFINITION_VERSION | |
| ) | |
| } else if (is.na(mode_col) || is.na(time_col) || is.null(ref)) { | |
| out$Reference_Status <- if (is.null(ref)) { | |
| "No compatible corrected isochrone reference loaded" | |
| } else { | |
| "Metrics table lacks mode/time columns" | |
| } | |
| } else { | |
| for (i in seq_len(nrow(out))) { | |
| mode_i <- normalize_reference_mode(out[[mode_col]][[i]]) | |
| time_i <- suppressWarnings(as.numeric(out[[time_col]][[i]])) | |
| out$Reference_Mode[[i]] <- mode_i | |
| out$Reference_Time_min[[i]] <- time_i | |
| ref_i <- get_iso_reference_group(mode_i, time_i, ref = ref) | |
| if (is.null(ref_i) || nrow(ref_i) == 0) { | |
| out$Reference_Status[[i]] <- paste0( | |
| "N/A β no precomputed ", pretty_mode(mode_i), " ", time_i, "-minute reference" | |
| ) | |
| next | |
| } | |
| out$Reference_N[[i]] <- nrow(ref_i) | |
| out$Reference_Status[[i]] <- paste0( | |
| "Citywide SF reference: ", nrow(ref_i), " precomputed 100 m origins Β· ", | |
| pretty_mode(mode_i), " Β· ", time_i, " min" | |
| ) | |
| for (j in seq_len(nrow(metric_map))) { | |
| m <- metric_map[j, , drop = FALSE] | |
| app_col <- m$app_col[[1]] | |
| ref_col <- m$ref_raw_col[[1]] | |
| out_col <- m$out_col[[1]] | |
| n_out_col <- m$n_out_col[[1]] | |
| if (!(app_col %in% names(out)) || !(ref_col %in% names(ref_i))) next | |
| x <- suppressWarnings(as.numeric(out[[app_col]][[i]])) | |
| ref_values <- suppressWarnings(as.numeric(ref_i[[ref_col]])) | |
| out[[n_out_col]][[i]] <- sum(is.finite(ref_values)) | |
| out[[out_col]][[i]] <- round( | |
| percent_rank_against_reference( | |
| x, ref_values, direction = m$direction[[1]] | |
| ), | |
| 1 | |
| ) | |
| } | |
| } | |
| } | |
| custom_attr_names <- setdiff(names(old_attrs), c("names", "row.names", "class")) | |
| for (nm in custom_attr_names) attr(out, nm) <- old_attrs[[nm]] | |
| attr(out, "iso_reference_scored") <- TRUE | |
| out | |
| } | |
| # Full seven-axis BAI only. Missing dimensions are explicit; BAI is NOT silently | |
| # redefined as a mean over a smaller denominator. | |
| add_reference_bai <- function(metrics) { | |
| if (is.null(metrics) || nrow(metrics) == 0) return(metrics) | |
| out <- metrics | |
| axis_from_pct <- c( | |
| Mobility_Access_std = "pctile_Transit_Access_Score", | |
| Route_Access_std = "pctile_Unique_Muni_Routes", | |
| Biodiversity_Potential_std = "pctile_GBIF_Species", | |
| Observation_Intensity_std = "pctile_SamplingDensity_km2", | |
| Environmental_Quality_std = "pctile_MeanNDVI", | |
| Greenspace_Cover_std = "pctile_Greenspace_percent", | |
| Equity_Context_std = "pctile_SF_EJ_Score" | |
| ) | |
| for (axis in names(axis_from_pct)) { | |
| pct_col <- axis_from_pct[[axis]] | |
| out[[axis]] <- if (pct_col %in% names(out)) { | |
| suppressWarnings(as.numeric(out[[pct_col]])) / 100 | |
| } else NA_real_ | |
| } | |
| axis_cols <- names(axis_from_pct) | |
| axis_mat <- as.matrix(out[, axis_cols, drop = FALSE]) | |
| storage.mode(axis_mat) <- "numeric" | |
| n_components <- rowSums(is.finite(axis_mat)) | |
| complete <- n_components == length(axis_cols) | |
| out$BAI <- NA_real_ | |
| if (any(complete)) { | |
| out$BAI[complete] <- rowMeans(axis_mat[complete, , drop = FALSE]) | |
| } | |
| out$BAI_n_components <- as.integer(n_components) | |
| out$BAI_reference_complete <- complete | |
| out$BAI_reference_system <- paste0( | |
| "same mode Γ same time precomputed 100 m isochrones; metric definition ", | |
| ISO_METRIC_DEFINITION_VERSION | |
| ) | |
| # A BAI score (e.g. 51/100) is NOT itself a percentile. Rank that complete | |
| # seven-axis BAI against the distribution of complete seven-axis BAIs from the | |
| # exact same citywide mode Γ time reference group. | |
| out$pctile_BAI <- NA_real_ | |
| out$nref_BAI <- NA_integer_ | |
| mode_col <- pick_first_column(out, c("mode", "Mode")) | |
| time_col <- pick_first_column(out, c("time_min", "time", "Time")) | |
| if (!is.na(mode_col) && !is.na(time_col)) { | |
| for (i in seq_len(nrow(out))) { | |
| if (!isTRUE(complete[[i]]) || !is.finite(out$BAI[[i]])) next | |
| mode_i <- normalize_reference_mode(out[[mode_col]][[i]]) | |
| time_i <- suppressWarnings(as.numeric(out[[time_col]][[i]])) | |
| ref_bai <- get_reference_bai_distribution(mode_i, time_i) | |
| if (length(ref_bai) == 0) next | |
| out$nref_BAI[[i]] <- length(ref_bai) | |
| out$pctile_BAI[[i]] <- round( | |
| percent_rank_against_reference( | |
| out$BAI[[i]], | |
| ref_bai, | |
| direction = "higher" | |
| ), | |
| 1 | |
| ) | |
| } | |
| } | |
| out | |
| } | |
| format_pct <- function(x, digits = 1) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || !is.finite(x)) return("N/A") | |
| paste0(round(x, digits), "th percentile") | |
| } | |
| iso_metric_row_label <- function(df, i) { | |
| mode_col <- pick_first_column(df, c("Mode", "mode")) | |
| time_col <- pick_first_column(df, c("Time", "time", "time_min")) | |
| mode_i <- if (!is.na(mode_col)) as.character(df[[mode_col]][[i]]) else "Isochrone" | |
| time_i <- if (!is.na(time_col)) suppressWarnings(as.numeric(df[[time_col]][[i]])) else NA_real_ | |
| if (is.finite(time_i)) paste0(mode_i, " β ", time_i, " min") else mode_i | |
| } | |
| # ggplot2's coord_polar() treats the transform as non-linear, so it "munches" | |
| # straight Cartesian segments (geom_polygon/geom_line edges) into curved arcs | |
| # once projected onto the circle β that's what produces the spiral look on a | |
| # radar chart. Overriding is_linear = TRUE tells the polar grid to keep edges | |
| # as straight chords between vertices instead of arcing them. | |
| coord_radar <- function(theta = "x", start = 0, direction = 1, clip = "off") { | |
| theta <- match.arg(theta, c("x", "y")) | |
| r <- if (theta == "x") "y" else "x" | |
| ggplot2::ggproto( | |
| "CoordRadar", | |
| ggplot2::CoordPolar, | |
| theta = theta, | |
| r = r, | |
| start = start, | |
| direction = sign(direction), | |
| clip = clip, | |
| is_linear = function(coord) TRUE | |
| ) | |
| } | |
| # Radar plot for independently scored isochrones. Each polygon/line is one | |
| # isochrone row; there is no averaging or pooling across mode/time combinations. | |
| # | |
| # plot_style controls presentation only: | |
| # "standard" = Explorer / multiple independent isochrones | |
| # "point_compare"= Point A vs Point B | |
| # "before_after" = existing conditions vs planned conservation action | |
| draw_reference_radar <- function( | |
| bai_rows, | |
| labels = NULL, | |
| colors = NULL, | |
| title = "Citywide Biodiversity Access Profile", | |
| subtitle = NULL, | |
| plot_style = c("standard", "point_compare", "before_after") | |
| ) { | |
| plot_style <- match.arg(plot_style) | |
| if (is.null(bai_rows)) { | |
| plot.new(); title("No isochrone scores available."); return(invisible(NULL)) | |
| } | |
| if (is.data.frame(bai_rows)) { | |
| rows <- lapply(seq_len(nrow(bai_rows)), function(i) bai_rows[i, , drop = FALSE]) | |
| } else if (is.list(bai_rows)) { | |
| rows <- bai_rows | |
| } else { | |
| plot.new(); title("No isochrone scores available."); return(invisible(NULL)) | |
| } | |
| rows <- rows[vapply(rows, function(x) !is.null(x) && nrow(x) > 0, logical(1))] | |
| if (length(rows) == 0) { | |
| plot.new(); title("No isochrone scores available."); return(invisible(NULL)) | |
| } | |
| if (is.null(labels)) { | |
| labels <- vapply(rows, function(x) iso_metric_row_label(x, 1), character(1)) | |
| } | |
| if (length(labels) != length(rows)) labels <- paste0("Isochrone ", seq_along(rows)) | |
| # Short enough to read around a radar, but explicit about what each spoke means. | |
| axis_labels <- c( | |
| Mobility_Access_std = "Transit\naccess β", | |
| Route_Access_std = "Muni route\naccess β", | |
| Biodiversity_Potential_std = "Total species\nrichness β", | |
| Observation_Intensity_std = "GBIF sampling\ndensity β", | |
| Environmental_Quality_std = "Vegetation\nNDVI β", | |
| Greenspace_Cover_std = "Greenspace\ncover β", | |
| Equity_Context_std = "Equity context β\n(lower EJ burden)" | |
| ) | |
| # Pair related BAI dimensions visually so stakeholders can immediately see | |
| # which spokes belong together conceptually. | |
| axis_group_colors <- c( | |
| Mobility_Access_std = BAI_GROUP_COLORS[["access"]], | |
| Route_Access_std = BAI_GROUP_COLORS[["access"]], | |
| Biodiversity_Potential_std = BAI_GROUP_COLORS[["biodiversity"]], | |
| Observation_Intensity_std = BAI_GROUP_COLORS[["biodiversity"]], | |
| Environmental_Quality_std = BAI_GROUP_COLORS[["green_environment"]], | |
| Greenspace_Cover_std = BAI_GROUP_COLORS[["green_environment"]], | |
| Equity_Context_std = BAI_GROUP_COLORS[["equity"]] | |
| ) | |
| all_df <- dplyr::bind_rows(lapply(seq_along(rows), function(i) { | |
| x <- rows[[i]] | |
| x$.Series <- labels[[i]] | |
| x | |
| })) | |
| available_axes <- names(axis_labels)[vapply(names(axis_labels), function(a) { | |
| a %in% names(all_df) && any(is.finite(suppressWarnings(as.numeric(all_df[[a]])))) | |
| }, logical(1))] | |
| if (length(available_axes) < 3) { | |
| plot.new() | |
| title("Not enough comparable same-mode Γ same-time reference dimensions for a radar plot.") | |
| return(invisible(NULL)) | |
| } | |
| long <- all_df |> | |
| dplyr::select(.Series, dplyr::all_of(available_axes)) |> | |
| tidyr::pivot_longer( | |
| cols = dplyr::all_of(available_axes), | |
| names_to = "Axis", | |
| values_to = "Score" | |
| ) |> | |
| dplyr::mutate( | |
| Score = suppressWarnings(as.numeric(Score)) * 100, | |
| Axis_key = Axis, | |
| Axis = factor( | |
| Axis, | |
| levels = available_axes, | |
| labels = unname(axis_labels[available_axes]) | |
| ) | |
| ) |> | |
| dplyr::filter(is.finite(Score)) | |
| if (nrow(long) == 0) { | |
| plot.new(); title("No comparable percentile dimensions available."); return(invisible(NULL)) | |
| } | |
| axis_label_df <- long |> | |
| dplyr::distinct(Axis_key, Axis) |> | |
| dplyr::mutate( | |
| axis_label = as.character(Axis), | |
| axis_color = unname(axis_group_colors[Axis_key]), | |
| label_y = 108.5 | |
| ) | |
| axis_label_layers <- lapply(seq_len(nrow(axis_label_df)), function(i) { | |
| ggplot2::annotate( | |
| "text", | |
| x = axis_label_df$Axis[[i]], | |
| y = axis_label_df$label_y[[i]], | |
| label = axis_label_df$axis_label[[i]], | |
| color = axis_label_df$axis_color[[i]], | |
| size = 4.0, | |
| fontface = "bold", | |
| lineheight = 1.0 | |
| ) | |
| }) | |
| if (is.null(colors)) { | |
| pal <- scales::hue_pal()(length(unique(long$.Series))) | |
| names(pal) <- unique(long$.Series) | |
| } else { | |
| pal <- rep(colors, length.out = length(labels)) | |
| names(pal) <- labels | |
| } | |
| if (is.null(subtitle)) { | |
| subtitle <- paste0( | |
| "Citywide percentile within the SAME mode Γ travel-time reference. ", | |
| "Center = 0th percentile; outer ring = 100th percentile." | |
| ) | |
| } | |
| base_plot <- ggplot2::ggplot( | |
| long, | |
| ggplot2::aes( | |
| x = Axis, | |
| y = Score, | |
| group = .Series, | |
| color = .Series, | |
| fill = .Series | |
| ) | |
| ) | |
| if (identical(plot_style, "before_after") && length(labels) >= 2) { | |
| baseline_label <- labels[[1]] | |
| proposal_label <- labels[[2]] | |
| # Keep both full profiles visible, but deliberately mute them. The actual | |
| # intervention effect is overlaid only on dimensions whose percentile changed. | |
| base_plot <- base_plot + | |
| ggplot2::geom_polygon( | |
| data = long |> dplyr::filter(.Series == baseline_label), | |
| fill = NA, | |
| linewidth = 1.15, | |
| linetype = "22", | |
| alpha = 0.55, | |
| na.rm = TRUE | |
| ) + | |
| ggplot2::geom_polygon( | |
| data = long |> dplyr::filter(.Series == proposal_label), | |
| linewidth = 1.15, | |
| alpha = 0.035, | |
| na.rm = TRUE | |
| ) + | |
| ggplot2::geom_point( | |
| data = long, | |
| size = 2.0, | |
| alpha = 0.35, | |
| stroke = 0.7, | |
| na.rm = TRUE | |
| ) | |
| # Determine changed BAI dimensions. Connectivity is intentionally NOT added | |
| # here because it is a separate structural outcome rather than an eighth BAI axis. | |
| delta_df <- long |> | |
| dplyr::select(.Series, Axis_key, Axis, Score) |> | |
| tidyr::pivot_wider(names_from = .Series, values_from = Score) | |
| if (all(c(baseline_label, proposal_label) %in% names(delta_df))) { | |
| delta_df <- delta_df |> | |
| dplyr::mutate( | |
| baseline_score = .data[[baseline_label]], | |
| proposal_score = .data[[proposal_label]], | |
| delta = proposal_score - baseline_score, | |
| delta_label = dplyr::if_else( | |
| is.finite(delta) & abs(delta) >= 0.1, | |
| sprintf("Ξ %+.1f pp", delta), | |
| NA_character_ | |
| ) | |
| ) |> | |
| dplyr::filter(!is.na(delta_label)) | |
| if (nrow(delta_df) > 0) { | |
| # A thick radial segment marks the exact BAI spoke that changed. | |
| base_plot <- base_plot + | |
| ggplot2::geom_segment( | |
| data = delta_df, | |
| ggplot2::aes( | |
| x = Axis, | |
| xend = Axis, | |
| y = baseline_score, | |
| yend = proposal_score | |
| ), | |
| inherit.aes = FALSE, | |
| color = "#238b45", | |
| linewidth = 3.0, | |
| lineend = "round", | |
| alpha = 0.95, | |
| na.rm = TRUE | |
| ) + | |
| ggplot2::geom_point( | |
| data = delta_df, | |
| ggplot2::aes(x = Axis, y = baseline_score), | |
| inherit.aes = FALSE, | |
| shape = 21, | |
| size = 3.8, | |
| stroke = 1.2, | |
| fill = "white", | |
| color = "#636363", | |
| na.rm = TRUE | |
| ) + | |
| ggplot2::geom_point( | |
| data = delta_df, | |
| ggplot2::aes(x = Axis, y = proposal_score), | |
| inherit.aes = FALSE, | |
| shape = 21, | |
| size = 4.8, | |
| stroke = 1.4, | |
| fill = "#74c476", | |
| color = "#1b7837", | |
| na.rm = TRUE | |
| ) + | |
| ggplot2::geom_label( | |
| data = delta_df, | |
| ggplot2::aes( | |
| x = Axis, | |
| y = pmin(98, pmax(baseline_score, proposal_score) + 7), | |
| label = delta_label | |
| ), | |
| inherit.aes = FALSE, | |
| size = 3.6, | |
| fontface = "bold", | |
| label.size = 0.25, | |
| fill = "#f3fbf5", | |
| color = "#1b7837", | |
| alpha = 0.98, | |
| na.rm = TRUE | |
| ) | |
| } | |
| } | |
| } else if (identical(plot_style, "point_compare")) { | |
| base_plot <- base_plot + | |
| ggplot2::geom_polygon(alpha = 0.035, linewidth = 1.55, na.rm = TRUE) + | |
| ggplot2::geom_point(size = 3.2, stroke = 1.0, na.rm = TRUE) | |
| } else { | |
| base_plot <- base_plot + | |
| ggplot2::geom_polygon(alpha = 0.045, linewidth = 1.35, na.rm = TRUE) + | |
| ggplot2::geom_point(size = 3.0, stroke = 0.9, na.rm = TRUE) | |
| } | |
| print( | |
| base_plot + | |
| axis_label_layers + | |
| coord_radar(clip = "off") + | |
| ggplot2::scale_y_continuous( | |
| limits = c(0, 112), | |
| breaks = c(0, 25, 50, 75, 100), | |
| labels = c("0", "25", "50", "75", "100"), | |
| expand = ggplot2::expansion(mult = c(0, 0.03)) | |
| ) + | |
| ggplot2::scale_color_manual(values = pal, name = NULL) + | |
| ggplot2::scale_fill_manual(values = pal, name = NULL) + | |
| ggplot2::labs( | |
| title = title, | |
| subtitle = subtitle, | |
| caption = paste0( | |
| "Farther outward = higher / more favourable citywide percentile. ", | |
| "Species = more unique GBIF species; greenspace = more cover; NDVI = greener vegetation. ", | |
| "Paired spoke colors: blue = access, orange = biodiversity, green = vegetation/greenspace, purple = equity context. ", | |
| "Equity is inverted once: farther outward = lower SF EJ burden. ", | |
| if (identical(plot_style, "before_after")) { | |
| "Only changed BAI spokes are highlighted in green; connectivity is reported separately." | |
| } else { | |
| "" | |
| } | |
| ), | |
| x = NULL, | |
| y = NULL | |
| ) + | |
| ggplot2::theme_minimal(base_size = 13) + | |
| ggplot2::theme( | |
| plot.background = ggplot2::element_rect(fill = "white", color = NA), | |
| panel.background = ggplot2::element_rect(fill = "white", color = NA), | |
| panel.grid.major = ggplot2::element_line(color = "#d8e2dc", linewidth = 0.55), | |
| panel.grid.minor = ggplot2::element_blank(), | |
| axis.text.x = ggplot2::element_blank(), | |
| axis.text.y = ggplot2::element_text( | |
| size = 9.5, | |
| color = "#6b766f", | |
| face = "bold" | |
| ), | |
| axis.ticks = ggplot2::element_blank(), | |
| legend.position = "top", | |
| legend.justification = "center", | |
| legend.text = ggplot2::element_text(size = 11, face = "bold"), | |
| legend.key.width = grid::unit(1.5, "cm"), | |
| plot.title = ggplot2::element_text( | |
| face = "bold", | |
| hjust = 0.5, | |
| size = 16, | |
| color = "#294337", | |
| margin = ggplot2::margin(b = 5) | |
| ), | |
| plot.subtitle = ggplot2::element_text( | |
| hjust = 0.5, | |
| size = 10.5, | |
| color = "#596861", | |
| margin = ggplot2::margin(b = 8) | |
| ), | |
| plot.caption = ggplot2::element_text( | |
| hjust = 0, | |
| size = 9.5, | |
| color = "#596861", | |
| margin = ggplot2::margin(t = 10) | |
| ), | |
| plot.margin = ggplot2::margin(18, 36, 22, 36) | |
| ) | |
| ) | |
| } | |
| # Project the existing OSM greenspace once per app process, not once per user | |
| # session. Scenario calculations use a spatial index and union only polygons near | |
| # the user's drawing. | |
| osm_greenspace_3857 <- tryCatch( | |
| osm_greenspace |> st_make_valid() |> st_transform(3857), | |
| error = function(e) NULL | |
| ) | |
| # Stakeholder-facing coverage metadata. This is the complete mapped OSM | |
| # greenspace layer loaded for San Francisco by setup_unified.R. Scenario | |
| # calculations query this citywide layer and then display only the patches near | |
| # a user's proposal. | |
| osm_greenspace_feature_count <- if (!is.null(osm_greenspace_3857)) { | |
| nrow(osm_greenspace_3857) | |
| } else { | |
| 0L | |
| } | |
| # Transport-mode choices, shared by the Explorer checkboxes and the Comparer | |
| # single-select dropdowns so both tabs offer the same modes. | |
| transport_mode_choices <- list( | |
| "Driving" = "driving", | |
| "Walking" = "walking", | |
| "Cycling" = "cycling", | |
| "Driving with Traffic" = "driving-traffic", | |
| "Transit - optimistic (no wait)" = "transit", | |
| "Walk + Transit - conservative" = "walk_transit" | |
| ) | |
| # ============================================================================= | |
| # UI | |
| # ============================================================================= | |
| ui <- dashboardPage( | |
| skin = "green", | |
| dashboardHeader(title = "SF Biodiversity Access Tool"), | |
| dashboardSidebar( | |
| sidebarMenu(id = "tabs", | |
| menuItem("Isochrone Explorer", tabName = "isochrone", icon = icon("map-marker-alt")), | |
| menuItem("Isochrone Comparer", tabName = "comparer", icon = icon("balance-scale")), | |
| menuItem("Green Corridor Planner", tabName = "corridor", icon = icon("project-diagram")), | |
| menuItem("Green Investment & Displacement Context", tabName = "displacement", icon = icon("home")), | |
| menuItem("About", tabName = "about", icon = icon("info-circle")) | |
| ) | |
| ), | |
| dashboardBody( | |
| tags$head( | |
| tags$link(rel = "stylesheet", type = "text/css", href = "app_pastel.css"), | |
| tags$style(HTML(" | |
| /* ================================================================ | |
| UI polish only β no server, scoring, reactive, or metric changes | |
| ================================================================ */ | |
| :root { | |
| --sf-green: #5d8d70; | |
| --sf-green-dark: #35664e; | |
| --sf-green-soft: #dff1e6; | |
| --sf-green-border: #b9dfc8; | |
| --sf-blue-soft: #dceafb; | |
| --sf-blue-border: #b5d3ee; | |
| --sf-gold-soft: #fff0d5; | |
| --sf-gold-border: #efd29a; | |
| --sf-bg: #f4f9f6; | |
| --sf-card: #ffffff; | |
| --sf-text: #263a31; | |
| --sf-muted: #66766e; | |
| --sf-sidebar: #1f2c33; | |
| --sf-sidebar-active: #273941; | |
| --sf-shadow: 0 4px 16px rgba(39, 72, 56, 0.08); | |
| } | |
| html, body, .wrapper { | |
| background: var(--sf-bg) !important; | |
| } | |
| body { | |
| color: var(--sf-text); | |
| font-size: 14px; | |
| } | |
| /* Header */ | |
| .main-header .logo { | |
| background: var(--sf-green) !important; | |
| color: #fff !important; | |
| font-weight: 700; | |
| letter-spacing: 0.15px; | |
| border-right: 1px solid rgba(255,255,255,0.12); | |
| } | |
| .main-header .navbar { | |
| background: linear-gradient(90deg, #5f8f72 0%, #3d6f57 100%) !important; | |
| box-shadow: 0 1px 8px rgba(22, 51, 38, 0.20); | |
| } | |
| .main-header .navbar .sidebar-toggle:hover { | |
| background: rgba(255,255,255,0.09) !important; | |
| } | |
| /* Sidebar */ | |
| .main-sidebar, | |
| .left-side { | |
| background: var(--sf-sidebar) !important; | |
| box-shadow: 2px 0 10px rgba(16, 34, 27, 0.08); | |
| } | |
| .sidebar-menu > li > a { | |
| color: #c9d4d8 !important; | |
| padding: 13px 15px; | |
| border-left: 3px solid transparent; | |
| transition: background-color .15s ease, color .15s ease, border-color .15s ease; | |
| } | |
| .sidebar-menu > li > a:hover { | |
| color: #fff !important; | |
| background: #25343b !important; | |
| border-left-color: #86b999; | |
| } | |
| .sidebar-menu > li.active > a, | |
| .sidebar-menu > li.active > a:hover { | |
| color: #fff !important; | |
| background: var(--sf-sidebar-active) !important; | |
| border-left-color: #72b48a !important; | |
| font-weight: 600; | |
| } | |
| .sidebar-menu > li > a > .fa, | |
| .sidebar-menu > li > a > .fas, | |
| .sidebar-menu > li > a > .far { | |
| width: 20px; | |
| } | |
| /* Main page */ | |
| .content-wrapper, | |
| .right-side { | |
| background: var(--sf-bg) !important; | |
| } | |
| .content { | |
| padding: 16px 18px 28px 18px !important; | |
| } | |
| /* Cards / shinydashboard boxes */ | |
| .box { | |
| background: var(--sf-card); | |
| border: 1px solid #dbe8e1 !important; | |
| border-top: 1px solid #dbe8e1 !important; | |
| border-radius: 12px !important; | |
| box-shadow: var(--sf-shadow); | |
| overflow: hidden; | |
| margin-bottom: 18px; | |
| } | |
| .box-header { | |
| padding: 12px 14px 11px 14px !important; | |
| border-bottom: 1px solid #e6eee9; | |
| } | |
| .box-header .box-title { | |
| font-size: 17px; | |
| line-height: 1.3; | |
| font-weight: 600; | |
| color: #294337; | |
| } | |
| .box-body { | |
| padding: 14px !important; | |
| } | |
| .box.box-success { | |
| border-color: var(--sf-green-border) !important; | |
| } | |
| .box.box-success > .box-header { | |
| background: var(--sf-green-soft) !important; | |
| color: #28543b !important; | |
| border-bottom-color: var(--sf-green-border) !important; | |
| } | |
| .box.box-primary, | |
| .box.box-info { | |
| border-color: var(--sf-blue-border) !important; | |
| } | |
| .box.box-primary > .box-header, | |
| .box.box-info > .box-header { | |
| background: var(--sf-blue-soft) !important; | |
| color: #294f74 !important; | |
| border-bottom-color: var(--sf-blue-border) !important; | |
| } | |
| .box.box-warning { | |
| border-color: var(--sf-gold-border) !important; | |
| } | |
| .box.box-warning > .box-header { | |
| background: var(--sf-gold-soft) !important; | |
| color: #745720 !important; | |
| border-bottom-color: var(--sf-gold-border) !important; | |
| } | |
| /* Inputs */ | |
| .form-group { | |
| margin-bottom: 15px; | |
| } | |
| .control-label, | |
| .shiny-input-container > label { | |
| font-weight: 600; | |
| color: #31463b; | |
| margin-bottom: 6px; | |
| } | |
| .form-control, | |
| .selectize-input, | |
| .selectize-control.single .selectize-input { | |
| min-height: 38px; | |
| border: 1px solid #d4dfd9 !important; | |
| border-radius: 8px !important; | |
| box-shadow: none !important; | |
| background: #fff; | |
| } | |
| .form-control:focus, | |
| .selectize-input.focus { | |
| border-color: #86b89a !important; | |
| box-shadow: 0 0 0 3px rgba(93, 141, 112, 0.10) !important; | |
| } | |
| .checkbox label, | |
| .radio label { | |
| line-height: 1.55; | |
| } | |
| .checkbox input[type='checkbox'], | |
| .radio input[type='radio'] { | |
| margin-top: 4px; | |
| } | |
| .irs--shiny .irs-bar, | |
| .irs--shiny .irs-single, | |
| .irs--shiny .irs-from, | |
| .irs--shiny .irs-to { | |
| background: var(--sf-green) !important; | |
| border-color: var(--sf-green) !important; | |
| } | |
| .irs--shiny .irs-handle { | |
| border-color: var(--sf-green) !important; | |
| } | |
| /* Buttons */ | |
| .btn { | |
| border-radius: 8px !important; | |
| font-weight: 600; | |
| padding: 8px 13px; | |
| transition: transform .08s ease, box-shadow .12s ease, background-color .12s ease; | |
| } | |
| .btn:hover { | |
| box-shadow: 0 3px 9px rgba(31, 63, 49, 0.12); | |
| } | |
| .btn:active { | |
| transform: translateY(1px); | |
| } | |
| .btn-success { | |
| background: #2faa67 !important; | |
| border-color: #2b9e60 !important; | |
| } | |
| .btn-success:hover, | |
| .btn-success:focus { | |
| background: #28985b !important; | |
| border-color: #258d55 !important; | |
| } | |
| .btn-default { | |
| background: #fff !important; | |
| border-color: #d5dfda !important; | |
| color: #3e5047 !important; | |
| } | |
| /* Leaflet / map panels */ | |
| .leaflet { | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| .leaflet-container { | |
| background: #eef3f0; | |
| font-family: inherit; | |
| } | |
| .leaflet-control-layers, | |
| .leaflet-bar { | |
| border: 1px solid rgba(50, 76, 63, 0.16) !important; | |
| border-radius: 7px !important; | |
| box-shadow: 0 2px 8px rgba(32, 57, 44, 0.10) !important; | |
| } | |
| .leaflet-control-layers { | |
| padding: 8px 10px; | |
| } | |
| /* Wells, alerts, and informational blocks */ | |
| .well { | |
| background: #fbfdfc !important; | |
| border: 1px solid #dbe7e0 !important; | |
| border-radius: 10px !important; | |
| box-shadow: none !important; | |
| } | |
| .alert { | |
| border-radius: 9px; | |
| border-width: 1px; | |
| } | |
| /* Tables */ | |
| .table { | |
| background: #fff; | |
| } | |
| .table > thead > tr > th { | |
| border-bottom: 2px solid #dce7e1; | |
| color: #365044; | |
| font-weight: 600; | |
| } | |
| .table-hover > tbody > tr:hover { | |
| background-color: #f3f8f5; | |
| } | |
| table.dataTable { | |
| border-collapse: separate !important; | |
| border-spacing: 0; | |
| } | |
| .dataTables_wrapper .dataTables_filter input, | |
| .dataTables_wrapper .dataTables_length select { | |
| border: 1px solid #d4dfd9; | |
| border-radius: 7px; | |
| padding: 5px 7px; | |
| background: #fff; | |
| } | |
| /* Typography and whitespace */ | |
| h1, h2, h3, h4, h5 { | |
| color: #294337; | |
| } | |
| .help-block, | |
| .text-muted, | |
| small { | |
| color: var(--sf-muted); | |
| } | |
| hr { | |
| border-top-color: #e3ece7; | |
| } | |
| /* Loading message */ | |
| #loading { | |
| color: var(--sf-green-dark) !important; | |
| background: #edf7f1; | |
| border: 1px solid #cfe5d8; | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| font-size: 14px !important; | |
| font-weight: 600; | |
| } | |
| /* Slightly more breathing room between major rows */ | |
| .row { | |
| margin-bottom: 2px; | |
| } | |
| /* Mobile/tablet polish */ | |
| @media (max-width: 991px) { | |
| .content { | |
| padding: 12px !important; | |
| } | |
| /* Responsive fallback for inline scenario summary grids. */ | |
| .box-body [style*='grid-template-columns:repeat(4'] { | |
| grid-template-columns: repeat(2, minmax(0, 1fr)) !important; | |
| } | |
| .box-body [style*='grid-template-columns:repeat(3'] { | |
| grid-template-columns: 1fr !important; | |
| } | |
| .box { | |
| margin-bottom: 14px; | |
| } | |
| .box-header .box-title { | |
| font-size: 16px; | |
| } | |
| } | |
| ")) | |
| ), | |
| theme = theme, | |
| # useShinyjs(), # removed with library(shinyjs) -- no shinyjs features are used | |
| div(id = "loading", style = "display:none; font-size:20px; color:red;", "Calculating..."), | |
| tabItems( | |
| tabItem( | |
| tabName = "isochrone", | |
| fluidRow( | |
| box( | |
| title = "Controls", status = "success", solidHeader = TRUE, width = 3, | |
| radioButtons( | |
| "location_choice", | |
| "Select Location Method:", | |
| choices = c("Address (Geocode)" = "address", "Click on Map" = "map_click"), | |
| selected = "map_click" | |
| ), | |
| conditionalPanel( | |
| condition = "input.location_choice == 'address'", | |
| mapboxGeocoderInput( | |
| inputId = "geocoder", | |
| placeholder = "Search for an address", | |
| access_token = mapbox_token | |
| ) | |
| ), | |
| checkboxGroupInput( | |
| "transport_modes", | |
| "Select Transportation Modes:", | |
| choices = list( | |
| "Driving" = "driving", | |
| "Walking" = "walking", | |
| "Cycling" = "cycling", | |
| "Driving with Traffic" = "driving-traffic", | |
| "Transit - optimistic (no wait)" = "transit", | |
| "Walk + Transit - conservative" = "walk_transit" | |
| ), | |
| selected = c("driving", "walking") | |
| ), | |
| conditionalPanel( | |
| condition = "input.transport_modes.includes('transit') || input.transport_modes.includes('walk_transit')", | |
| sliderInput( | |
| "transit_hour", | |
| "Transit Departure Hour (24h):", | |
| min = 5, max = 22, value = 9, step = 1, post = ":00" | |
| ), | |
| sliderInput( | |
| "transit_departure_window_min", | |
| "Transit departure flexibility window (minutes):", | |
| min = 0, max = 20, value = 10, step = 5 | |
| ), | |
| helpText("Several departures after the selected time can be evaluated for walk + transit."), | |
| checkboxInput( | |
| "transit_charge_wait", | |
| "Charge the expected wait (half the headway at the boarding stop)", | |
| value = FALSE | |
| ), | |
| helpText( | |
| "Off by default, matching the paper's turn-up-and-go upper bound. Switching it on ", | |
| "reproduces the paper's scheduled variant: the median Muni headway is 15 minutes, so ", | |
| "this costs about 7.5 minutes of a 15-minute budget." | |
| ), | |
| tags$div( | |
| style = paste("background:#eef4fb; border-left:4px solid #2166ac;", | |
| "padding:9px 11px; margin-top:9px; font-size:12px; line-height:1.5;"), | |
| tags$b("The two transit modes bracket the answer - they are not duplicates."), | |
| tags$ul( | |
| style = "margin:6px 0 0 0; padding-left:18px;", | |
| tags$li(tags$b("Transit - optimistic. "), | |
| "Walk up to 10 min to a stop, ride, then walk out with ALL remaining time. ", | |
| "No waiting is charged. Straight-line walking at a calibrated 60 m/min. ", | |
| "Fast, no API cost. ", tags$i("This is the method used in the paper.")), | |
| tags$li(tags$b("Walk + Transit - conservative. "), | |
| "First and last mile each capped by your sliders above (default 5 min), ", | |
| "only the 12 best last-mile stops, best departure searched across the window, ", | |
| "and real street-network walksheds from Mapbox. Slower and uses API calls.") | |
| ), | |
| tags$div(style = "margin-top:6px; color:#44515c;", | |
| "Measured at 15 min, the optimistic mode returns roughly 2-4x the area. ", | |
| "The truth sits between them: treat them as an upper and a lower bound.") | |
| ) | |
| ), | |
| conditionalPanel( | |
| condition = "input.transport_modes.includes('walk_transit')", | |
| sliderInput( | |
| "walk_to_stop_min", | |
| "First-mile walking budget (minutes):", | |
| min = 1, max = 20, value = 5, step = 1 | |
| ), | |
| sliderInput( | |
| "walk_from_stop_min", | |
| "Maximum last-mile walking budget (minutes):", | |
| min = 0, max = 20, value = 5, step = 1 | |
| ) | |
| ), | |
| checkboxGroupInput( | |
| "iso_times", | |
| "Select Isochrone Times (minutes):", | |
| choices = list("5" = 5, "10" = 10, "15" = 15), | |
| selected = c(5, 10) | |
| ), | |
| actionButton("generate_iso", "Generate Isochrones", icon = icon("play")), | |
| actionButton("clear_map", "Clear", icon = icon("times")), | |
| tags$hr(), | |
| actionButton( | |
| "evaluate_green_intervention", | |
| "Evaluate a green intervention within these isochrones", | |
| icon = icon("seedling"), | |
| class = "btn-success", | |
| style = "width:100%; white-space:normal;" | |
| ), | |
| helpText("Opens the Green Corridor Planner and carries the selected location and any generated isochrones with you."), | |
| conditionalPanel( | |
| condition = "output.iso_ready", | |
| tags$hr(style = "margin:12px 0;"), | |
| downloadButton( | |
| "download_explorer_report", | |
| "Download Explorer HTML report card", | |
| icon = icon("file-alt"), | |
| class = "btn-primary btn-sm", | |
| style = "width:100%; white-space:normal;" | |
| ) | |
| ) | |
| ), | |
| box( | |
| title = "Map", status = "success", solidHeader = TRUE, width = 9, | |
| leafletOutput("isoMap", height = 600) %>% withSpinner(type = 8, color = "#28a745") | |
| ) | |
| ), | |
| fluidRow( | |
| box(title = "Biodiversity Access Score", status = "warning", solidHeader = TRUE, width = 4, uiOutput("bioScoreBox")), | |
| box(title = "Transit Access Score", status = "primary", solidHeader = TRUE, width = 4, uiOutput("transitScoreBox")), | |
| box(title = "Isochrone-Benchmarked Biodiversity Access Index", width = 4, uiOutput("biodiversityAccessIndexBox")) | |
| ), | |
| fluidRow( | |
| box(title = "Closest Greenspace", status = "success", solidHeader = TRUE, width = 6, uiOutput("closestGreenspaceUI")), | |
| box(title = "Closest RSF Program", status = "success", solidHeader = TRUE, width = 6, uiOutput("closestRSFProgramUI")) | |
| ), | |
| # Spider plot is hidden until isochrones exist (output.iso_ready), so the | |
| # large box doesn't sit empty before anything is generated. | |
| conditionalPanel( | |
| condition = "output.iso_ready", | |
| fluidRow( | |
| box( | |
| title = "Citywide Biodiversity Access Profile β BAI Dimensions", | |
| status = "warning", solidHeader = TRUE, width = 12, collapsible = TRUE, | |
| tags$div( | |
| style = "display:flex; gap:8px; flex-wrap:wrap; margin-bottom:7px;", | |
| tags$span( | |
| style = "background:#eef6f1; border:1px solid #cfe2d6; border-radius:999px; padding:4px 9px; font-weight:600;", | |
| "Center = 0th percentile" | |
| ), | |
| tags$span( | |
| style = "background:#eef6f1; border:1px solid #cfe2d6; border-radius:999px; padding:4px 9px; font-weight:600;", | |
| "Outer ring = 100th percentile" | |
| ), | |
| tags$span( | |
| style = "background:#fff7e8; border:1px solid #ead7ad; border-radius:999px; padding:4px 9px; font-weight:600;", | |
| "Farther out = higher / more favourable" | |
| ) | |
| ), | |
| p( | |
| "Each polygon is one independently scored isochrone. The spoke labels state exactly what is being ranked. ", | |
| "Species richness uses the total number of unique GBIF species. Equity is oriented so farther outward means lower EJ burden. ", | |
| "Percentiles are always calculated against the matching SF mode Γ travel-time reference; profiles are never averaged or pooled." | |
| ), | |
| plotOutput("radarPlot", height = "700px") %>% withSpinner(type = 8, color = "#f0ad4e"), | |
| bai_dimension_group_legend_ui() | |
| ) | |
| ) | |
| ), | |
| conditionalPanel( | |
| condition = "output.iso_ready", | |
| fluidRow( | |
| box( | |
| title = "Data Confidence / Observation Coverage", | |
| status = "warning", | |
| solidHeader = TRUE, | |
| width = 12, | |
| collapsible = TRUE, | |
| collapsed = TRUE, | |
| uiOutput("dataCoverageBox") | |
| ) | |
| ) | |
| ), | |
| conditionalPanel( | |
| condition = "output.iso_ready", | |
| fluidRow( | |
| box( | |
| title = "BAI Dimensions β Raw Values + Citywide Percentiles", | |
| status = "primary", | |
| solidHeader = TRUE, | |
| width = 12, | |
| collapsible = TRUE, | |
| uiOutput("baiDimensionDetailsBox") | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Summary Data", | |
| status = "success", | |
| solidHeader = TRUE, | |
| width = 12, | |
| collapsible = TRUE, | |
| collapsed = TRUE, | |
| DTOutput("dataTable") %>% withSpinner(type = 8, color = "#28a745") | |
| ) | |
| ), | |
| # fluidRow( | |
| # box( | |
| # title = "Biodiversity & Transit Metrics by Mode", | |
| # status = "primary", solidHeader = TRUE, width = 12, | |
| # plotOutput("transitMetricsPlot", height = "450px") %>% withSpinner(type = 8, color = "#005B95") | |
| # ) | |
| # ) | |
| # ), | |
| fluidRow( | |
| box( | |
| title = "Biodiversity & Transit Metrics by Mode", | |
| status = "primary", | |
| solidHeader = TRUE, | |
| width = 12, | |
| collapsible = TRUE, | |
| collapsed = TRUE, | |
| plotOutput("transitMetricsPlot", height = "450px") %>% withSpinner(type = 8, color = "#005B95") | |
| ) | |
| ) | |
| ), | |
| # ===================================================================== | |
| # Isochrone Comparer tab | |
| # ===================================================================== | |
| # Two independent points, one isochrone each (single mode + time), shown | |
| # side by side. Nothing here computes until the user clicks "Compare" | |
| # (cmp_results is an eventReactive), and the outputs only render once the | |
| # tab is opened (Shiny suspends hidden outputs) -- so users who never open | |
| # this tab pay no cost. All scoring reuses the shared iso_metrics_AUDITED.R functions. | |
| tabItem( | |
| tabName = "comparer", | |
| fluidRow( | |
| box( | |
| width = 12, status = "warning", solidHeader = FALSE, | |
| h4("Compare two locations side by side"), | |
| p("Place two points (click a map or search an address), pick one transport mode and travel time for each, then press Compare. ", | |
| "Each point is scored independently against its own matching mode Γ time reference before the spider plot and difference table are shown."), | |
| conditionalPanel( | |
| condition = "output.cmp_ready", | |
| downloadButton( | |
| "download_comparer_report", | |
| "Download Comparer HTML report card", | |
| icon = icon("file-alt"), | |
| class = "btn-primary btn-sm" | |
| ) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| # --- Point A picker --- | |
| box( | |
| title = "Point A", status = "success", solidHeader = TRUE, width = 6, | |
| mapboxGeocoderInput( | |
| inputId = "cmp_geocoder_a", | |
| placeholder = "Search an address (Point A)", | |
| access_token = mapbox_token | |
| ), | |
| leafletOutput("cmp_map_a", height = 400) %>% withSpinner(type = 8, color = "#1b9e77"), | |
| helpText("Click the map or search an address to place Point A."), | |
| fluidRow( | |
| column(7, selectInput("cmp_mode_a", "Mode:", choices = transport_mode_choices, selected = "walking", selectize = FALSE)), | |
| column(5, selectInput("cmp_time_a", "Time (min):", choices = c(5, 10, 15), selected = 10, selectize = FALSE)) | |
| ) | |
| ), | |
| # --- Point B picker --- | |
| box( | |
| title = "Point B", status = "primary", solidHeader = TRUE, width = 6, | |
| mapboxGeocoderInput( | |
| inputId = "cmp_geocoder_b", | |
| placeholder = "Search an address (Point B)", | |
| access_token = mapbox_token | |
| ), | |
| leafletOutput("cmp_map_b", height = 400) %>% withSpinner(type = 8, color = "#2c7fb8"), | |
| helpText("Click the map or search an address to place Point B."), | |
| fluidRow( | |
| column(7, selectInput("cmp_mode_b", "Mode:", choices = transport_mode_choices, selected = "walking", selectize = FALSE)), | |
| column(5, selectInput("cmp_time_b", "Time (min):", choices = c(5, 10, 15), selected = 10, selectize = FALSE)) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| column( | |
| width = 12, align = "center", | |
| actionButton("cmp_compare", "Compare", icon = icon("balance-scale"), | |
| class = "btn-success btn-lg", | |
| style = "padding: 12px 45px; font-size: 20px; font-weight: 600;"), | |
| ) | |
| ), | |
| # Results stay hidden until Compare has produced something (output.cmp_ready), | |
| # so the spider boxes don't sit empty before the first comparison. | |
| conditionalPanel( | |
| condition = "output.cmp_ready", | |
| fluidRow( | |
| box( | |
| title = "Biodiversity Access Profile β Point A vs Point B", status = "warning", solidHeader = TRUE, | |
| width = 12, collapsible = TRUE, | |
| tags$div( | |
| style = "display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:6px;", | |
| tags$span( | |
| style = "background:#e9f6f1; border:1px solid #bfe0d2; border-radius:999px; padding:4px 9px; color:#146b51; font-weight:700;", | |
| "Point A" | |
| ), | |
| tags$span("vs"), | |
| tags$span( | |
| style = "background:#edf5fb; border:1px solid #c8dfee; border-radius:999px; padding:4px 9px; color:#27688d; font-weight:700;", | |
| "Point B" | |
| ), | |
| tags$span( | |
| style = "margin-left:6px; color:#66766e;", | |
| "Farther outward = higher / more favourable percentile" | |
| ) | |
| ), | |
| p( | |
| "Each point is ranked independently against the SF reference matching its own selected mode and travel time. ", | |
| "The chart compares relative citywide standing, while the difference table below pairs the raw values with their citywide percentiles." | |
| ), | |
| plotOutput("cmp_radar", height = "680px") %>% withSpinner(type = 8, color = "#f0ad4e"), | |
| bai_dimension_group_legend_ui() | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Difference (Point B β Point A)", status = "warning", solidHeader = TRUE, width = 12, | |
| p("Point B minus Point A for each metric. Percentile differences are percentile-point differences after each point has been independently benchmarked against its own matching mode Γ time reference. Raw values are also retained."), | |
| DTOutput("cmp_diff_table") %>% withSpinner(type = 8, color = "#f0ad4e") | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ===================================================================== | |
| # Green Corridor Planner tab | |
| # ===================================================================== | |
| tabItem( | |
| tabName = "corridor", | |
| fluidRow( | |
| box( | |
| width = 12, status = "primary", solidHeader = FALSE, | |
| h4(icon("project-diagram"), " Green Corridor Planner"), | |
| p( | |
| "Draw a proposed street corridor, habitat patch, pocket park, or other green intervention. ", | |
| "The planner compares it with mapped existing San Francisco greenspace, identifies potential habitat links, ", | |
| "and estimates nearby population and equity context." | |
| ), | |
| tags$div( | |
| style = "background:#fff3cd; border-left:4px solid #f0ad4e; padding:9px 11px;", | |
| tags$b("Screening tool: "), | |
| "Connectivity is based on a user-selected distance rule. It does not yet model species-specific movement, road barriers, vegetation quality, land ownership, or project feasibility." | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Scenario Controls", status = "success", solidHeader = TRUE, width = 3, | |
| textInput( | |
| "corridor_scenario_name", | |
| "Scenario name:", | |
| value = "Green corridor scenario", | |
| placeholder = "e.g., Mission pollinator corridor" | |
| ), | |
| uiOutput("corridorLocationStatus"), | |
| fluidRow( | |
| column( | |
| 12, | |
| actionButton( | |
| "use_explorer_location", | |
| "Use location + isochrones from Explorer", | |
| icon = icon("map-marker-alt"), | |
| class = "btn-success btn-sm", | |
| style = "width:100%; white-space:normal; margin-bottom:5px;" | |
| ), | |
| actionButton( | |
| "return_to_isochrone", | |
| "Return to Isochrone Explorer", | |
| icon = icon("arrow-left"), | |
| class = "btn-default btn-sm", | |
| style = "width:100%; white-space:normal;" | |
| ) | |
| ) | |
| ), | |
| uiOutput("corridorIsochroneSelectorUI"), | |
| tags$hr(), | |
| tags$div( | |
| style = "background:#e8f5e9; border-left:4px solid #2e7d32; padding:9px 10px; margin-bottom:10px;", | |
| tags$b("Uses existing San Francisco greenspace: "), | |
| paste0( | |
| "Your proposal is checked against ", | |
| scales::comma(osm_greenspace_feature_count), | |
| " mapped OSM greenspace polygons loaded for the city." | |
| ) | |
| ), | |
| tags$p( | |
| style = "margin-bottom:4px;", | |
| tags$b("1. Draw the intervention"), | |
| tags$br(), | |
| tags$small("Use the line tool on the map for a street corridor or the polygon tool for a park, garden, or habitat patch.") | |
| ), | |
| selectInput( | |
| "corridor_width_m", | |
| "For a line, choose the total planted width:", | |
| choices = c( | |
| "2 m β narrow planting strip" = 2, | |
| "5 m β street trees or pollinator verge" = 5, | |
| "10 m β planted median or green street" = 10, | |
| "20 m β broad multi-layer corridor" = 20, | |
| "40 m β large conceptual corridor" = 40 | |
| ), | |
| selected = 10, | |
| selectize = FALSE | |
| ), | |
| uiOutput("corridorWidthPreviewUI"), | |
| actionButton( | |
| "inspect_corridor_width", | |
| "Zoom in to inspect width", | |
| icon = icon("search-plus"), | |
| class = "btn-info btn-sm", | |
| style = "width:100%; white-space:normal; margin-top:5px;" | |
| ), | |
| helpText("This setting affects line drawings only. Polygon drawings already have an area and ignore this control."), | |
| tags$p( | |
| style = "margin:10px 0 4px 0;", | |
| tags$b("2. Choose the connection rule") | |
| ), | |
| selectInput( | |
| "connectivity_gap_m", | |
| "When should two habitat patches count as connected?", | |
| choices = c( | |
| "Very close β 25 m" = 25, | |
| "Small stepping-stone gap β 50 m" = 50, | |
| "Neighborhood planning screen β 100 m" = 100, | |
| "Broad planning screen β 250 m" = 250, | |
| "City-scale exploratory screen β 500 m" = 500, | |
| "Very broad exploratory screen β 1,000 m" = 1000 | |
| ), | |
| selected = 100, | |
| selectize = FALSE | |
| ), | |
| helpText("This is a structural planning threshold, not a species-specific movement distance. The 500 m and 1,000 m options are broad exploratory screens and may connect many urban patches."), | |
| tags$p( | |
| style = "margin:10px 0 4px 0;", | |
| tags$b("3. View the result") | |
| ), | |
| actionButton( | |
| "show_corridor_result", | |
| "Show corridor result", | |
| icon = icon("project-diagram"), | |
| class = "btn-primary btn-sm" | |
| ), | |
| actionButton( | |
| "clear_proposed_greenspace", | |
| "Clear drawing", | |
| icon = icon("eraser"), | |
| class = "btn-default btn-sm" | |
| ), | |
| conditionalPanel( | |
| condition = "output.proposal_ready", | |
| tags$div( | |
| style = "margin-top:10px; padding:9px 10px; background:#eef6fb; border-left:4px solid #1976d2;", | |
| tags$b("Export this scenario"), | |
| tags$br(), | |
| tags$small("The report card is a self-contained HTML file. Open it in a browser and choose Print β Save as PDF if needed."), | |
| downloadButton( | |
| "download_corridor_report", | |
| "Download Corridor HTML report card", | |
| icon = icon("file-alt"), | |
| class = "btn-primary btn-sm", | |
| style = "width:100%; white-space:normal; margin-top:7px;" | |
| ) | |
| ) | |
| ), | |
| tags$hr(style = "margin:12px 0;"), | |
| checkboxInput( | |
| "show_beneficiary_catchment", | |
| "Show population-estimate area (purple, optional)", | |
| value = FALSE | |
| ), | |
| conditionalPanel( | |
| condition = "input.show_beneficiary_catchment", | |
| sliderInput( | |
| "beneficiary_walk_min", | |
| "Assumed walking time:", | |
| min = 5, max = 15, value = 10, step = 5, post = " min" | |
| ), | |
| helpText("This purple area is used only to estimate nearby population and equity context. It is not habitat and does not affect connectivity.") | |
| ), | |
| tags$div( | |
| style = "background:#f7f9fb; border:1px solid #d7dde3; padding:9px 10px; margin:10px 0; font-size:12px;", | |
| tags$b("Map colors"), | |
| tags$div(style="margin-top:5px;", tags$span(style="display:inline-block;width:12px;height:12px;background:#81c784;border:1px solid #2e7d32;margin-right:6px;"), "Green = existing mapped greenspace"), | |
| tags$div(tags$span(style="display:inline-block;width:12px;height:12px;background:#9fa8da;border:2px solid #3949ab;margin-right:6px;"), "Active analysis isochrone = the only access area used for scenario BAI"), | |
| tags$div(tags$span(style="display:inline-block;width:12px;height:12px;background:#42a5f5;border:1px solid #0d47a1;margin-right:6px;"), "Blue = your proposed intervention"), | |
| tags$div(tags$span(style="display:inline-block;width:12px;height:12px;background:#ffb74d;border:1px solid #e65100;margin-right:6px;"), "Orange = existing greenspaces reached"), | |
| tags$div(tags$span(style="display:inline-block;width:12px;height:12px;background:#ce93d8;border:1px dashed #6a1b9a;margin-right:6px;"), "Purple = optional population-estimate area") | |
| ), | |
| uiOutput("greenspaceScenarioSummary") | |
| ), | |
| box( | |
| title = "Corridor Scenario Map", status = "success", solidHeader = TRUE, width = 9, | |
| leafletOutput("corridorMap", height = 680) %>% withSpinner(type = 8, color = "#28a745") | |
| ) | |
| ), | |
| conditionalPanel( | |
| condition = "output.proposal_ready", | |
| fluidRow( | |
| box( | |
| title = "What This Proposal Connects", | |
| status = "primary", solidHeader = TRUE, width = 7, collapsible = TRUE, | |
| uiOutput("corridorConnectivitySummary") | |
| ), | |
| box( | |
| title = "Existing Greenspaces Reached", | |
| status = "warning", solidHeader = TRUE, width = 5, collapsible = TRUE, collapsed = TRUE, | |
| p("These are existing mapped greenspaces within the selected connection rule. They are highlighted in orange and numbered on the map."), | |
| DTOutput("linkedHabitatTable") | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Potentially Reached Residents", | |
| status = "success", solidHeader = TRUE, width = 12, collapsible = TRUE, | |
| uiOutput("greenspaceBeneficiarySummary") | |
| ) | |
| ) | |
| ), | |
| conditionalPanel( | |
| condition = "output.proposal_ready && !output.iso_ready", | |
| fluidRow( | |
| box( | |
| width = 12, status = "warning", solidHeader = FALSE, | |
| tags$b("Connectivity results are available now. "), | |
| "To compare the proposal with the Biodiversity Access Index, first generate isochrones in the Isochrone Explorer, then use ", | |
| tags$em("Evaluate a green intervention within these isochrones"), "." | |
| ) | |
| ) | |
| ), | |
| conditionalPanel( | |
| condition = "output.iso_ready && output.proposal_ready", | |
| fluidRow( | |
| box( | |
| title = "Planned Conservation Action β BAI Before vs After", | |
| status = "warning", solidHeader = TRUE, width = 12, collapsible = TRUE, | |
| uiOutput("corridorImpactSummary"), | |
| uiOutput("corridorBaiComparisonNote"), | |
| plotOutput("corridorRadarPlot", height = "650px") %>% withSpinner(type = 8, color = "#f0ad4e"), | |
| bai_dimension_group_legend_ui(), | |
| tags$small( | |
| style = "display:block; color:#66766e; margin-top:4px;", | |
| "Connectivity is displayed alongside the BAI as a separate structural-connectivity outcome; it is not inserted as an eighth BAI axis." | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Detailed BAI Change β Selected Isochrone", | |
| status = "warning", solidHeader = TRUE, width = 12, collapsible = TRUE, collapsed = TRUE, | |
| p("Detailed standardized BAI dimensions for the selected Explorer isochrone (0β100 scale)."), | |
| DTOutput("greenspaceScenarioDiffTable") %>% withSpinner(type = 8, color = "#f0ad4e") | |
| ) | |
| ) | |
| ) | |
| ), | |
| tabItem( | |
| tabName = "displacement", | |
| fluidRow( | |
| box( | |
| title = "Green Investment & Displacement Context", | |
| status = "warning", | |
| solidHeader = TRUE, | |
| width = 12, | |
| tags$div( | |
| style = paste0( | |
| "background:#fff7e8; border-left:5px solid #d6a542; ", | |
| "border-radius:8px; padding:10px 12px; margin-bottom:10px;" | |
| ), | |
| tags$b("Context screen β not a green-gentrification risk score. "), | |
| "This tab places greenspace access and SF environmental-justice context side by side. ", | |
| "The current app does not contain the housing-market variables needed to estimate displacement risk." | |
| ), | |
| p( | |
| "Use this view to identify places where a greening opportunity overlaps with greater environmental-justice burden, ", | |
| "and therefore where community governance and anti-displacement safeguards may deserve explicit consideration. ", | |
| "Do not interpret the plot as evidence that a proposed greening action will cause displacement." | |
| ), | |
| conditionalPanel( | |
| condition = "output.iso_ready", | |
| downloadButton( | |
| "download_displacement_report", | |
| "Download Context HTML report card", | |
| icon = icon("file-alt"), | |
| class = "btn-primary btn-sm" | |
| ) | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Selected Isochrone Context", | |
| status = "success", | |
| solidHeader = TRUE, | |
| width = 12, | |
| uiOutput("displacementContextSummary") | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "Greenspace Access Gap Γ EJ Burden Context", | |
| status = "primary", | |
| solidHeader = TRUE, | |
| width = 8, | |
| collapsible = TRUE, | |
| p( | |
| "Each point is one independently scored isochrone. ", | |
| "Farther right means a larger relative greenspace-access gap; farther up means greater EJ burden context. ", | |
| "The 50/50 guide lines are descriptive visual references, not validated risk thresholds." | |
| ), | |
| plotOutput("displacementContextPlot", height = "540px") %>% | |
| withSpinner(type = 8, color = "#5d8d70") | |
| ), | |
| box( | |
| title = "What Is Still Needed for Displacement-Risk Screening?", | |
| status = "warning", | |
| solidHeader = TRUE, | |
| width = 4, | |
| tags$p( | |
| "A defensible displacement or green-gentrification screen should add housing and tenure indicators rather than infer risk from EJ or income alone." | |
| ), | |
| tags$ul( | |
| tags$li("Renter share / tenure"), | |
| tags$li("Rent burden"), | |
| tags$li("Eviction notices or displacement events"), | |
| tags$li("Recent rent and home-value change"), | |
| tags$li("Subsidized / permanently affordable housing"), | |
| tags$li("Housing production and loss"), | |
| tags$li("An established displacement-vulnerability classification") | |
| ), | |
| tags$div( | |
| style = "background:#f4f4f4; border-radius:7px; padding:9px;", | |
| tags$b("Current interpretation: "), | |
| "equity-sensitive greening context, not predicted displacement." | |
| ) | |
| ) | |
| ), | |
| fluidRow( | |
| box( | |
| title = "NGO / Grant Reporting Language", | |
| status = "success", | |
| solidHeader = TRUE, | |
| width = 12, | |
| uiOutput("ngoReportingSummary") | |
| ) | |
| ) | |
| ), | |
| tabItem( | |
| tabName = "about", | |
| # ββ Logo banner ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| column( | |
| width = 12, | |
| style = "text-align: center; padding: 20px 0 8px 0;", | |
| imageOutput("combine_logo", height = "auto") | |
| ) | |
| ), | |
| # ββ Hero tagline βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| column( | |
| width = 12, | |
| style = "text-align: center; padding: 4px 40px 16px 40px;", | |
| tags$h3( | |
| style = "color: #2e8b57; font-style: italic; font-weight: 400;", | |
| "Exploring equitable access to urban biodiversity across San Francisco" | |
| ) | |
| ) | |
| ), | |
| # ββ Green corridor conceptual framework ββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("project-diagram"), " Green Corridor Scenario Planner"), | |
| status = "primary", solidHeader = TRUE, width = 12, collapsible = TRUE, | |
| p( | |
| "The corridor workflow links three questions: where habitat is fragmented, where a proposed ", | |
| "corridor or stepping-stone intervention could be placed, and how ecological connectivity and ", | |
| "equitable human access change under that scenario." | |
| ), | |
| tags$img( | |
| src = "green_corridor_concept.png", | |
| alt = "Conceptual framework showing a fragmented baseline landscape, a proposed green corridor, and ecological and human-access outcomes.", | |
| style = "display:block; width:100%; height:auto; margin:10px auto 4px auto; border:1px solid #d9e5dc; border-radius:6px;" | |
| ), | |
| tags$small( | |
| style = "color:#666;", | |
| "Conceptual values shown in the figure are illustrative; the app reports results calculated from the user's drawing and selected thresholds." | |
| ) | |
| ) | |
| ), | |
| # ββ Row 1: Tool overview + team + GitHub βββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("leaf"), " About This Tool"), | |
| status = "success", solidHeader = TRUE, width = 8, | |
| p( | |
| "The ", strong("SF Biodiversity Access Decision Support Tool"), | |
| " is an interactive web application developed by the ", | |
| strong("Reimagining San Francisco (RSF) Data Working Group"), | |
| " to investigate how equitably San Francisco residents can reach urban biodiversity ", | |
| "depending on their transportation options and socioeconomic context." | |
| ), | |
| p( | |
| "Users select any location in San Francisco β by clicking the map or geocoding an address β ", | |
| "choose one or more transport modes and travel-time thresholds, and the app generates ", | |
| tags$em("isochrones"), | |
| " (reachable-area polygons). Within each isochrone the app computes biodiversity, ", | |
| "greenspace, transit, socioeconomic, environmental quality, and equity metrics, and ", | |
| "synthesises them into the ", strong("Biodiversity Access Index (BAI)"), "." | |
| ), | |
| tags$hr(), | |
| fluidRow( | |
| column(6, | |
| tags$b(icon("users"), " Team"), | |
| tags$ul( | |
| style = "padding-left: 18px; margin-top: 6px;", | |
| tags$li("Diego Ellis Soto"), | |
| tags$li("Avery Hill"), | |
| tags$li("Lizzy Edson"), | |
| tags$li("Γlvaro Casanova"), | |
| tags$li("Christopher J. Schell"), | |
| tags$li("Carl Boettiger"), | |
| tags$li("Rebecca Johnson") | |
| ) | |
| ), | |
| column(6, | |
| tags$b(icon("university"), " Institutions"), | |
| tags$ul( | |
| style = "padding-left: 18px; margin-top: 6px;", | |
| tags$li("UC Berkeley β ESPM"), | |
| tags$li("California Academy of Sciences") | |
| ), | |
| tags$br(), | |
| tags$b(icon("envelope"), " Contact"), | |
| tags$p( | |
| style = "margin-top: 4px;", | |
| tags$a( | |
| href = "mailto:diego.ellissoto@berkeley.edu", | |
| "diego.ellissoto@berkeley.edu" | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| box( | |
| title = tagList(icon("seedling"), " Reimagining San Francisco"), | |
| status = "success", solidHeader = TRUE, width = 4, | |
| p( | |
| "Reimagining San Francisco is an initiative integrating ecological, social, and ", | |
| "technological dimensions to shape a sustainable future for the Bay Area. The RSF Data ", | |
| "Working Group co-develops frameworks that bring together multiple sources of ", | |
| "socio-ecological biodiversity information for decision-support." | |
| ), | |
| tags$a( | |
| href = "https://www.calacademy.org", target = "_blank", | |
| icon("external-link-alt"), " California Academy of Sciences" | |
| ), | |
| tags$hr(), | |
| tags$b(icon("code-branch"), " Source Code"), | |
| tags$p( | |
| style = "margin-top: 6px;", | |
| tags$a( | |
| href = "https://github.com/diego-ellis-soto/SF_biodiv_access_shiny", | |
| target = "_blank", | |
| style = "font-size: 14px;", | |
| icon("github"), " diego-ellis-soto/SF_biodiv_access_shiny" | |
| ) | |
| ), | |
| tags$div( | |
| style = "background-color: #d4edda; border-left: 4px solid #28a745; padding: 8px 10px; margin-top: 8px; font-size: 12px;", | |
| icon("info-circle"), " The full source code, setup scripts, and data pipeline are publicly available on GitHub." | |
| ) | |
| ) | |
| ), | |
| # ββ Row 2: Why biodiversity access matters βββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("globe-americas"), " Why Biodiversity Access Matters"), | |
| status = "success", solidHeader = TRUE, width = 12, | |
| fluidRow( | |
| column(6, | |
| p( | |
| "Access to urban biodiversity is deeply unequal. Legacies of redlining, disinvestment, ", | |
| "and car-centric planning have concentrated green, biodiverse spaces in wealthier ", | |
| "neighbourhoods, while lower-income and environmental-justice communities often face ", | |
| "longer travel distances and fewer transit options to reach them." | |
| ), | |
| p( | |
| "Areas with higher biodiversity support essential ecosystem services β pollinators, ", | |
| "carbon sequestration, urban heat mitigation β and provide documented cultural, ", | |
| "recreational, and mental health benefits to local residents." | |
| ) | |
| ), | |
| column(6, | |
| p( | |
| "Cities are complex socio-ecological systems shaped by ongoing human pressures and ", | |
| "historical decisions. The RSF initiative integrates multiple facets of biodiversity ", | |
| "with variables used by city planners, public health practitioners, and equity advocates ", | |
| "to support a more integrative, justice-oriented lens for urban sustainability." | |
| ), | |
| p( | |
| "This tool is designed to make those inequities visible, quantifiable, and actionable β ", | |
| "surfacing where the gaps are largest and where investment could make the greatest difference." | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ββ Row 3: How to use ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("map-marked-alt"), " How to Use"), | |
| status = "success", solidHeader = TRUE, width = 12, | |
| fluidRow( | |
| column(6, | |
| tags$ol( | |
| style = "font-size: 14px; line-height: 1.85;", | |
| tags$li( | |
| strong("Go to the Isochrone Explorer tab"), " in the left sidebar." | |
| ), | |
| tags$li( | |
| strong("Pick a location."), | |
| tags$ul( | |
| style = "margin-top: 4px;", | |
| tags$li(strong("Click on Map:"), " click anywhere in SF; a red marker confirms selection."), | |
| tags$li(strong("Address (Geocode):"), " type a street address in the search box.") | |
| ) | |
| ), | |
| tags$li( | |
| strong("Select transport modes."), | |
| " Driving, Walking, Cycling, and Driving with Traffic use Mapbox. ", | |
| "Transit and Walk + Transit use the SF Muni GTFS timetable. ", | |
| "Extra sliders appear for departure hour and first/last-mile walking budgets." | |
| ), | |
| tags$li( | |
| strong("Choose time budgets"), " β 5, 10, and/or 15 minutes." | |
| ), | |
| tags$li( | |
| strong("Click Generate Isochrones."), | |
| " Shaded polygons appear for each mode Γ time combination. ", | |
| "Click a polygon for a summary popup." | |
| ) | |
| ) | |
| ), | |
| column(6, | |
| tags$ol( | |
| start = 6, | |
| style = "font-size: 14px; line-height: 1.85;", | |
| tags$li( | |
| strong("Toggle map layers"), " (top-right layer control) to overlay income, ", | |
| "species richness, greenspace, CalEnviroScreen, EJ communities, transit routes, and NDVI." | |
| ), | |
| tags$li( | |
| strong("Explore the panels below the map:"), | |
| tags$ul( | |
| style = "margin-top: 4px;", | |
| tags$li(strong("Score boxes:"), " biodiversity access percentile, transit density, and BAI."), | |
| tags$li(strong("Closest Greenspace:"), " nearest OSM green area, distance, and % greenspace cover."), | |
| tags$li(strong("Spider / Radar Plot:"), " independently benchmarked per-isochrone profiles; mode Γ time combinations are never averaged or pooled."), | |
| tags$li(strong("Summary Table:"), " full per-isochrone metrics."), | |
| tags$li(strong("Metric Plots:"), " species, population, GBIF institutions, and transit by mode.") | |
| ) | |
| ), | |
| tags$li( | |
| strong("Green Corridor Planner tab"), " β draw a corridor or habitat patch, identify existing greenspaces reached, and compare the proposal with Explorer isochrones." | |
| ), | |
| tags$li( | |
| strong("Click Clear"), " to reset the map for a new query." | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ββ Row 4: Map layers ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("layer-group"), " Map Layers"), | |
| status = "primary", solidHeader = TRUE, width = 12, | |
| p( | |
| style = "font-size: 13px; color: #555;", | |
| "All layers are toggled in the map's layer-control panel (top-right). The default base map is the OpenStreetMap street map; you can switch to National Geographic, CartoDB Positron or Satellite. CartoDB now requires an API key and may render blank without one." | |
| ), | |
| tags$table( | |
| class = "table table-hover table-sm", | |
| style = "font-size: 13px;", | |
| tags$thead( | |
| tags$tr( | |
| tags$th("Layer"), tags$th("Description"), tags$th("Source") | |
| ) | |
| ), | |
| tags$tbody( | |
| tags$tr(tags$td(icon("dollar-sign"), " Income"), | |
| tags$td("Median household income per census block group"), | |
| tags$td("ACS 5-yr")), | |
| tags$tr(tags$td(icon("tree"), " Greenspace"), | |
| tags$td("OSM parks, gardens, and public green areas"), | |
| tags$td("OpenStreetMap")), | |
| tags$tr(tags$td(icon("ruler"), " Greenspace Distance"), | |
| tags$td("Raster showing distance (m) to the nearest greenspace pixel"), | |
| tags$td("Derived from OSM")), | |
| tags$tr(tags$td(icon("map"), " RSF Program Projects"), | |
| tags$td("Partner project polygons from the Reimagining SF Initiative"), | |
| tags$td("RSF Initiative")), | |
| tags$tr(tags$td(icon("ruler"), " RSF Program Distance"), | |
| tags$td("Raster showing distance (m) to the nearest RSF program polygon"), | |
| tags$td("RSF Initiative")), | |
| tags$tr(tags$td(icon("fire"), " Hotspots (KnowBR)"), | |
| tags$td("Block groups with anomalously high species richness relative to sampling effort"), | |
| tags$td("KnowBR / GBIF")), | |
| tags$tr(tags$td(icon("snowflake"), " Coldspots (KnowBR)"), | |
| tags$td("Block groups with anomalously low species richness relative to sampling effort"), | |
| tags$td("KnowBR / GBIF")), | |
| tags$tr(tags$td(icon("dove"), " Species Richness"), | |
| tags$td("Unique GBIF species per census block group"), | |
| tags$td("GBIF")), | |
| tags$tr(tags$td(icon("database"), " Data Availability"), | |
| tags$td("Total GBIF occurrence records per block group"), | |
| tags$td("GBIF")), | |
| tags$tr(tags$td(icon("smog"), " CalEnviroScreen (CI Score)"), | |
| tags$td("Cumulative environmental and health burden by census tract"), | |
| tags$td("OEHHA")), | |
| tags$tr(tags$td(icon("balance-scale"), " SF EJ Communities"), | |
| tags$td("SF Environment Dept. environmental justice community burden scores"), | |
| tags$td("SF Environment")), | |
| tags$tr(tags$td(icon("route"), " Transit Routes"), | |
| tags$td("All SF Muni routes from GTFS shapes, coloured by official SFMTA route colour"), | |
| tags$td("SFMTA GTFS")), | |
| tags$tr(tags$td(icon("bus"), " Transit Stops"), | |
| tags$td("All SF Muni stops with AM peak headway and departure frequency"), | |
| tags$td("SFMTA GTFS")), | |
| tags$tr(tags$td(icon("draw-polygon"), " Isochrones"), | |
| tags$td("Generated travel-time polygons (one per mode Γ time combination)"), | |
| tags$td("Mapbox / gtfsrouter")), | |
| tags$tr(tags$td(icon("leaf"), " NDVI Raster"), | |
| tags$td("Sentinel-2 NDVI cropped and masked to the isochrone union"), | |
| tags$td("Sentinel-2")) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ββ Row 5: Transport modes βββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("car"), " Transportation Modes"), | |
| status = "primary", solidHeader = TRUE, width = 12, | |
| p( | |
| style = "font-size: 13px; color: #555;", | |
| "Six modes are supported across two routing engines. Both transit modes now walk at BOTH ends ", | |
| "β you cannot reach nature by riding past it, only by getting off and walking β so they differ ", | |
| "in how generous they are, not in whether walking is included." | |
| ), | |
| tags$div( | |
| style = paste("background:#eef4fb; border-left:4px solid #2166ac; padding:11px 13px;", | |
| "margin:4px 0 12px 0; font-size:13px; line-height:1.55;"), | |
| tags$b("Why there are two transit modes"), | |
| tags$p(style = "margin:6px 0 0 0;", | |
| "They bracket the answer rather than duplicating it. Measured at a 15-minute budget the ", | |
| "optimistic mode returns roughly two to four times the area of the conservative one; the ", | |
| "honest reading is that reality sits between them."), | |
| tags$ul( | |
| style = "margin:8px 0 0 0; padding-left:18px;", | |
| tags$li(tags$b("Transit β optimistic (no wait). "), | |
| "Access walk capped at 10 minutes, then all remaining time is given to the walk out ", | |
| "from each reachable stop. No waiting for the vehicle is charged. Walking is a ", | |
| "straight-line buffer at 60 m/min β calibrated, not assumed: the median Mapbox ", | |
| "walking-network isochrone at 5/10/15 minutes implies 62.3 / 60.1 / 59.4 m/min, so a ", | |
| "nominal 80 m/min would overstate reachable area by about 1.8x. No API calls, under ", | |
| "a second per query. ", tags$b("This is the construction used in the manuscript.")), | |
| tags$li(tags$b("Walk + Transit β conservative. "), | |
| "First and last mile each capped by the sliders (5 minutes by default), only the 12 ", | |
| "best last-mile stops are buffered, the best departure within the flexibility window ", | |
| "is searched, and the walksheds are real Mapbox street-network isochrones rather than ", | |
| "circles. Slower, and it consumes Mapbox API calls.") | |
| ), | |
| tags$p(style = "margin:8px 0 0 0; color:#44515c;", | |
| "Neither charges a half-headway wait. Since the median Muni headway is 15 minutes, an ", | |
| "expected 7.5-minute wait would consume half of a 15-minute budget, so both modes remain ", | |
| "optimistic about waiting even though they differ in how far they let you walk.") | |
| ), | |
| tags$table( | |
| class = "table table-hover table-sm", | |
| style = "font-size: 13px;", | |
| tags$thead( | |
| tags$tr( | |
| tags$th("Mode"), tags$th("Engine"), tags$th("Data Source"), tags$th("Notes") | |
| ) | |
| ), | |
| tags$tbody( | |
| tags$tr( | |
| tags$td(icon("car"), " Driving"), tags$td("Mapbox"), | |
| tags$td("OSM road network"), tags$td("Free-flow speed") | |
| ), | |
| tags$tr( | |
| tags$td(icon("person-walking"), " Walking"), tags$td("Mapbox"), | |
| tags$td("Pedestrian network"), tags$td("Pedestrian paths and crossings") | |
| ), | |
| tags$tr( | |
| tags$td(icon("bicycle"), " Cycling"), tags$td("Mapbox"), | |
| tags$td("Bicycle network"), tags$td("Dedicated cycle lanes where available") | |
| ), | |
| tags$tr( | |
| tags$td(icon("traffic-light"), " Driving with Traffic"), tags$td("Mapbox"), | |
| tags$td("Traffic-aware road network"), tags$td("Real-time + historical congestion") | |
| ), | |
| tags$tr( | |
| tags$td(icon("bus"), " Transit (GTFS)"), tags$td("gtfsrouter"), | |
| tags$td("SF Muni GTFS"), tags$td("Timetable-based stop-to-stop reachability from nearest stop") | |
| ), | |
| tags$tr( | |
| tags$td(tagList(icon("person-walking"), "+", icon("bus"), " Walk + Transit")), | |
| tags$td("Mapbox + gtfsrouter"), | |
| tags$td("Pedestrian + SF Muni GTFS"), | |
| tags$td("First-mile walk β Muni ride β last-mile walk within one total time budget") | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ββ Row 6: BAI explanation βββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("chart-area"), " Biodiversity Access Index (BAI) & Spider Plot"), | |
| status = "warning", solidHeader = TRUE, width = 12, | |
| p( | |
| "The ", strong("Biodiversity Access Index (BAI)"), | |
| " is calculated separately for each generated isochrone. Where a comparable Step-5 reference metric exists, ", | |
| "the raw value is converted to a percentile using only the precomputed 100 m SF origins with the ", | |
| strong("same transportation mode and same travel-time budget"), | |
| ". Walking 10-minute results are therefore compared only with Walking 10-minute references; ", | |
| "different modes and travel times are never pooled. The validated reference contains 24,182 SF 100 m origin cells in each supported mode Γ time group. ", | |
| "For each metric, the percentile uses all finite comparable values from that full matching citywide group. ", | |
| "Raw scored metrics use the same explicit 100 m grid support as Step 5: a cell contributes when its projected centroid falls within that one isochrone." | |
| ), | |
| tags$div( | |
| style = "background-color:#e3f2fd; border-left:4px solid #1976d2; padding:10px 14px; margin:10px 0;", | |
| tags$b("Reference rule: "), | |
| "one user isochrone β one matching mode Γ time reference distribution. ", | |
| "If no comparable precomputed reference exists (for example transit, walk + transit, or driving with traffic), ", | |
| "the app reports the raw metric and an N/A percentile rather than borrowing a different benchmark." | |
| ), | |
| tags$table( | |
| class = "table table-bordered table-sm", | |
| style = "font-size: 13px; margin-top: 12px;", | |
| tags$thead( | |
| tags$tr( | |
| tags$th("#"), tags$th("BAI Dimension"), tags$th("Variable"), | |
| tags$th("What it measures"), tags$th("Direction") | |
| ) | |
| ), | |
| tags$tbody( | |
| tags$tr( | |
| tags$td("1"), tags$td(strong("Mobility Access")), | |
| tags$td(code("Transit_Access_Score")), | |
| tags$td("Muni stops per kmΒ² across the isochrone's 100 m grid support"), | |
| tags$td(icon("arrow-up"), " Higher = better") | |
| ), | |
| tags$tr( | |
| tags$td("2"), tags$td(strong("Route Access")), | |
| tags$td(code("Unique_Muni_Routes")), | |
| tags$td("Distinct Muni route IDs intersecting the selected 100 m support cells"), | |
| tags$td(icon("arrow-up"), " Higher = better") | |
| ), | |
| tags$tr( | |
| tags$td("3"), tags$td(strong("Biodiversity Potential")), | |
| tags$td(code("GBIF_Species")), | |
| tags$td("Unique GBIF species assigned to selected 100 m support cells"), | |
| tags$td(icon("arrow-up"), " Higher = better") | |
| ), | |
| tags$tr( | |
| tags$td("4"), tags$td(strong("Sampling Density")), | |
| tags$td(code("SamplingDensity_km2")), | |
| tags$td("GBIF records per kmΒ² across the selected 100 m support cells β proxy for observation coverage"), | |
| tags$td(icon("arrow-up"), " Higher = better") | |
| ), | |
| tags$tr( | |
| tags$td("5"), tags$td(strong("Environmental Quality")), | |
| tags$td(code("MeanNDVI")), | |
| tags$td("Area-weighted mean of 100 m cell NDVI values selected by the isochrone"), | |
| tags$td(icon("arrow-up"), " Higher = better") | |
| ), | |
| tags$tr( | |
| tags$td("6"), tags$td(strong("Greenspace Cover")), | |
| tags$td(code("Greenspace_percent")), | |
| tags$td("OSM greenspace area as % of the selected 100 m grid-support area"), | |
| tags$td(icon("arrow-up"), " Higher = better") | |
| ), | |
| tags$tr( | |
| tags$td("7"), tags$td(strong("Equity Context")), | |
| tags$td(code("SF_EJ_Score (inverted)")), | |
| tags$td("Lower EJ burden β more favourable access context"), | |
| tags$td(icon("arrow-down"), " Lower burden = better") | |
| ) | |
| ) | |
| ), | |
| tags$p( | |
| style = "margin-top: 12px;", | |
| "The displayed isochrone-benchmarked BAI is calculated only when all seven percentile-standardised dimensions are available for that single isochrone. ", | |
| "Unsupported dimensions remain missing and are not replaced with census-block-group benchmarks. ", | |
| "The score box reports how many comparable reference dimensions are available; an incomplete seven-axis profile returns BAI = N/A rather than silently changing the denominator." | |
| ), | |
| tags$pre( | |
| style = "background: #f8f9fa; border-left: 3px solid #f0ad4e; padding: 10px;", | |
| "For each isochrone i:\n percentile(metric_i) = rank within SAME mode Γ SAME time reference\n BAI_i = mean(all 7 percentile-standardised dimensions for isochrone i) | |
| if any required axis is unavailable: BAI_i = N/A\n\nNo averaging or union across different isochrones occurs." | |
| ), | |
| tags$div( | |
| style = "background-color: #fff3cd; border-left: 4px solid #f0ad4e; padding: 10px 14px; margin-top: 12px;", | |
| icon("exclamation-triangle"), tags$b(" Prototype / Work in Progress: "), | |
| " Variable weighting, spatial units, and benchmark distributions should be refined through ", | |
| "stakeholder co-development before the BAI is used as a policy-grade score." | |
| ) | |
| ) | |
| ), | |
| # ββ Row 7: Data sources ββββββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("database"), " Data Sources & Cyberinfrastructure"), | |
| status = "success", solidHeader = TRUE, width = 12, | |
| fluidRow( | |
| column(7, | |
| tags$table( | |
| class = "table table-hover table-sm", | |
| style = "font-size: 13px;", | |
| tags$thead( | |
| tags$tr( | |
| tags$th("Dataset"), tags$th("Source"), tags$th("Format"), tags$th("Use in App") | |
| ) | |
| ), | |
| tags$tbody( | |
| tags$tr(tags$td(strong("GBIF occurrences")), tags$td("Global Biodiversity Information Facility"), | |
| tags$td("Parquet"), tags$td("Species richness, sampling density, taxonomic breakdowns")), | |
| tags$tr(tags$td(strong("ACS / Census")), tags$td("US Census Bureau (tidycensus)"), | |
| tags$td(".Rdata"), tags$td("Population and median income per census block group")), | |
| tags$tr(tags$td(strong("NDVI raster")), tags$td("Sentinel-2 (pre-processed)"), | |
| tags$td("GeoTIFF"), tags$td("Vegetation quality within isochrones")), | |
| tags$tr(tags$td(strong("OSM Greenspace")), tags$td("OpenStreetMap"), | |
| tags$td("GeoPackage"), tags$td("Greenspace cover %, distance raster, map layer")), | |
| tags$tr(tags$td(strong("SF Muni GTFS")), tags$td("SFMTA"), | |
| tags$td("CSV / ZIP"), tags$td("Transit isochrones, stop density, route access, headways")), | |
| tags$tr(tags$td(strong("CalEnviroScreen 4.0")), tags$td("OEHHA"), | |
| tags$td("File GDB"), tags$td("Cumulative environmental burden scores")), | |
| tags$tr(tags$td(strong("SF EJ Communities")), tags$td("SF Environment Dept."), | |
| tags$td("Shapefile"), tags$td("Environmental justice burden and equity context")), | |
| tags$tr(tags$td(strong("Hotspots / Coldspots")), tags$td("KnowBR analysis on GBIF"), | |
| tags$td("Shapefile"), tags$td("Under- and over-observed biodiversity areas")), | |
| tags$tr(tags$td(strong("RSF Program Projects")), tags$td("RSF Initiative"), | |
| tags$td("GeoPackage"), tags$td("Partner project areas overlay")) | |
| ) | |
| ) | |
| ), | |
| column(5, | |
| tags$div( | |
| style = "background-color: #e8f5e9; border-left: 4px solid #2e8b57; padding: 12px 14px; margin-bottom: 12px;", | |
| tags$b(icon("cloud"), " Remote Data Hosting"), | |
| tags$p( | |
| style = "margin-top: 6px; font-size: 13px;", | |
| "Greenspace, CBG, hotspots, NDVI, and GBIF data are hosted on ", | |
| tags$a( | |
| href = "https://huggingface.co/datasets/boettiger-lab/sf_biodiv_access", | |
| target = "_blank", | |
| "HuggingFace (boettiger-lab/sf_biodiv_access)" | |
| ), | |
| ". The cloud deployment streams these via GDAL's ", code("/vsicurl/"), | |
| " virtual filesystem β no large files need to be bundled in the Docker image." | |
| ) | |
| ), | |
| tags$div( | |
| style = "background-color: #e3f2fd; border-left: 4px solid #1976D2; padding: 12px 14px; margin-bottom: 12px;", | |
| tags$b(icon("bolt"), " GBIF Queries via DuckDB"), | |
| tags$p( | |
| style = "margin-top: 6px; font-size: 13px;", | |
| "GBIF occurrence records (~3M rows for SF) are stored as a local ", | |
| code(".parquet"), " file and queried on-the-fly using ", | |
| tags$a(href = "https://duckdb.org/", target = "_blank", "DuckDB"), | |
| " with the spatial extension. SQL ", code("ST_Intersects"), | |
| " filters records to the isochrone without loading the full dataset into memory." | |
| ) | |
| ), | |
| tags$div( | |
| style = "background-color: #f3e5f5; border-left: 4px solid #7B1FA2; padding: 12px 14px;", | |
| tags$b(icon("github"), " Open Source"), | |
| tags$p( | |
| style = "margin-top: 6px; font-size: 13px;", | |
| "Full source code, data pipeline scripts, and setup instructions are available at:", | |
| tags$br(), | |
| tags$a( | |
| href = "https://github.com/diego-ellis-soto/SF_biodiv_access_shiny", | |
| target = "_blank", | |
| icon("github"), " github.com/diego-ellis-soto/SF_biodiv_access_shiny" | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| # ββ Row 8: Roadmap βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| fluidRow( | |
| box( | |
| title = tagList(icon("road"), " Status & Roadmap"), | |
| status = "warning", solidHeader = TRUE, width = 12, | |
| fluidRow( | |
| column(6, | |
| tags$div( | |
| style = "background-color: #fff3cd; border-left: 4px solid #f0ad4e; padding: 12px 14px;", | |
| tags$b(icon("flask"), " Current Status"), | |
| tags$p( | |
| style = "margin-top: 6px; font-size: 13px;", | |
| "This tool is a ", strong("decision-support prototype"), " co-developed with the RSF Data Working Group. ", | |
| "The BAI should be treated as an exploratory indicator; variable weights and reference ", | |
| "distributions are subject to revision through ongoing stakeholder engagement." | |
| ) | |
| ) | |
| ), | |
| column(6, | |
| tags$b(icon("tasks"), " Planned Additions"), | |
| tags$ul( | |
| style = "font-size: 13px; line-height: 1.85; margin-top: 8px;", | |
| tags$li("Impervious surface coverage layer"), | |
| tags$li("National Walkability Index integration"), | |
| tags$li("CDC Social Vulnerability Index"), | |
| tags$li("NatureServe biodiversity and rarity maps"), | |
| tags$li("Frequency-weighted multimodal transit accessibility score"), | |
| tags$li("Pre-cached transit isochrones for faster querying"), | |
| tags$li("Stakeholder-driven BAI dimension weighting"), | |
| tags$li("Historical and comparative isochrone analysis") | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| # ============================================================================= | |
| # SERVER | |
| # ============================================================================= | |
| server <- function(input, output, session) { | |
| # --------------------------------------------------------------------------- | |
| # Shared HTML report-card helpers | |
| # --------------------------------------------------------------------------- | |
| report_card_escape <- function(x) { | |
| htmltools::htmlEscape(as.character(x), attribute = FALSE) | |
| } | |
| report_card_table <- function(df) { | |
| if (is.null(df) || nrow(df) == 0) return('<div class="note">No rows available.</div>') | |
| if (inherits(df, "sf")) df <- sf::st_drop_geometry(df) | |
| df <- as.data.frame(df, stringsAsFactors = FALSE) | |
| head_html <- paste0( | |
| "<tr>", | |
| paste0("<th>", vapply(names(df), report_card_escape, character(1)), "</th>", collapse = ""), | |
| "</tr>" | |
| ) | |
| body_html <- vapply(seq_len(nrow(df)), function(i) { | |
| row <- df[i, , drop = FALSE] | |
| vals <- vapply(row, function(v) { | |
| v <- v[[1]] | |
| if (length(v) == 0 || is.na(v)) "β" else report_card_escape(v) | |
| }, character(1)) | |
| paste0("<tr>", paste0("<td>", vals, "</td>", collapse = ""), "</tr>") | |
| }, character(1)) | |
| paste0( | |
| '<div class="table-wrap"><table><thead>', head_html, | |
| '</thead><tbody>', paste(body_html, collapse = ""), | |
| '</tbody></table></div>' | |
| ) | |
| } | |
| write_report_card <- function(file, title, subtitle = NULL, sections = character(), caution = NULL) { | |
| html <- paste0( | |
| '<!doctype html><html><head><meta charset="utf-8">', | |
| '<meta name="viewport" content="width=device-width,initial-scale=1">', | |
| '<title>', report_card_escape(title), '</title>', | |
| '<style>', | |
| 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;margin:0;background:#f4f8f5;color:#263a31;}', | |
| 'main{max-width:1100px;margin:0 auto;padding:30px 24px 50px;}', | |
| 'header{background:linear-gradient(90deg,#5d8d70,#38684f);color:white;border-radius:14px;padding:22px 24px;margin-bottom:18px;}', | |
| 'h1{margin:0 0 5px;font-size:28px;}h2{color:#294337;margin-top:0}.subtitle{opacity:.92;font-size:14px;}', | |
| '.section{background:white;border:1px solid #dbe8e1;border-radius:12px;padding:18px 20px;margin:14px 0;}', | |
| '.note{background:#eef6f1;border-left:4px solid #5d8d70;padding:10px 12px;border-radius:7px;margin:8px 0;}', | |
| '.caution{background:#fff6df;border-left:4px solid #d6a542;padding:10px 12px;border-radius:7px;margin:14px 0;}', | |
| '.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:13px}th{background:#eef6f1;text-align:left}', | |
| 'th,td{border-bottom:1px solid #dde7e1;padding:8px 9px;vertical-align:top}footer{color:#66766e;font-size:12px;margin-top:20px}', | |
| '@media print{body{background:white}.section{break-inside:avoid}main{max-width:none;padding:10px}}', | |
| '</style></head><body><main><header><h1>', report_card_escape(title), '</h1>', | |
| if (!is.null(subtitle)) paste0('<div class="subtitle">', report_card_escape(subtitle), '</div>') else "", | |
| '</header>', | |
| if (!is.null(caution)) paste0('<div class="caution">', report_card_escape(caution), '</div>') else "", | |
| paste(sections, collapse = ""), | |
| '<footer>SF Biodiversity Access Decision Support Tool Β· Generated ', | |
| report_card_escape(format(Sys.time(), "%Y-%m-%d %H:%M")), | |
| ' Β· Open in a browser and use Print β Save as PDF if desired.</footer>', | |
| '</main></body></html>' | |
| ) | |
| writeLines(html, con = file, useBytes = TRUE) | |
| } | |
| # Store a lightweight static spatial map inside downloadable HTML report cards. | |
| # The PNG is embedded as a base64 data URI, so the report remains self-contained. | |
| report_card_map_uri <- function(writer) { | |
| map_file <- tempfile(fileext = ".png") | |
| on.exit(unlink(map_file), add = TRUE) | |
| map_ok <- tryCatch(writer(map_file), error = function(e) FALSE) | |
| if (!isTRUE(map_ok) || !file.exists(map_file) || | |
| !is.finite(file.info(map_file)$size) || file.info(map_file)$size <= 0 || | |
| !requireNamespace("base64enc", quietly = TRUE)) { | |
| return(NULL) | |
| } | |
| paste0("data:image/png;base64,", base64enc::base64encode(map_file)) | |
| } | |
| report_card_map_section <- function(map_uri, alt = "Static spatial context map") { | |
| map_html <- if (!is.null(map_uri)) { | |
| paste0( | |
| '<img src="', map_uri, '" alt="', | |
| htmltools::htmlEscape(as.character(alt), attribute = TRUE), | |
| '" style="display:block;width:100%;height:auto;max-height:620px;', | |
| 'object-fit:contain;border:1px solid #dbe8e1;border-radius:8px;background:white;">' | |
| ) | |
| } else { | |
| '<div style="min-height:220px;display:flex;align-items:center;justify-content:center;background:#f4f7f5;color:#66766e;border:1px solid #dbe8e1;border-radius:8px;">Static report map unavailable.</div>' | |
| } | |
| paste0( | |
| '<section class="section"><h2>Spatial context</h2>', | |
| '<div class="note">Static map of the isochrone area(s) used for this report card. The map is embedded in this HTML file so the spatial context is retained with the report.</div>', | |
| map_html, | |
| '</section>' | |
| ) | |
| } | |
| write_report_isochrone_map <- function( | |
| file, | |
| iso_list, | |
| labels = NULL, | |
| colors = NULL, | |
| map_title = "Isochrone spatial context" | |
| ) { | |
| if (inherits(iso_list, "sf")) { | |
| iso_list <- lapply(seq_len(nrow(iso_list)), function(i) iso_list[i, , drop = FALSE]) | |
| } | |
| if (!is.list(iso_list)) iso_list <- list(iso_list) | |
| iso_list <- Filter( | |
| function(x) !is.null(x) && inherits(x, "sf") && nrow(x) > 0, | |
| iso_list | |
| ) | |
| if (length(iso_list) == 0) return(FALSE) | |
| iso_list <- lapply(iso_list, function(x) { | |
| suppressWarnings(sf::st_transform(sf::st_make_valid(x), 3857)) | |
| }) | |
| n_iso <- length(iso_list) | |
| if (is.null(labels) || length(labels) != n_iso) { | |
| labels <- paste0("Isochrone ", seq_len(n_iso)) | |
| } | |
| if (is.null(colors) || length(colors) != n_iso) { | |
| colors <- rep("#2f6fb0", n_iso) | |
| } | |
| colors <- as.character(colors) | |
| colors[is.na(colors) | !nzchar(colors)] <- "#2f6fb0" | |
| focus_geom <- do.call(c, lapply(iso_list, sf::st_geometry)) | |
| bb <- sf::st_bbox(focus_geom) | |
| dx <- as.numeric(bb[["xmax"]] - bb[["xmin"]]) | |
| dy <- as.numeric(bb[["ymax"]] - bb[["ymin"]]) | |
| pad_x <- max(dx * 0.18, 400) | |
| pad_y <- max(dy * 0.18, 400) | |
| map_bb <- sf::st_bbox(c( | |
| xmin = bb[["xmin"]] - pad_x, | |
| ymin = bb[["ymin"]] - pad_y, | |
| xmax = bb[["xmax"]] + pad_x, | |
| ymax = bb[["ymax"]] + pad_y | |
| ), crs = sf::st_crs(3857)) | |
| local_cbg <- NULL | |
| if (exists("cbg_vect_sf", inherits = TRUE)) { | |
| local_cbg <- tryCatch({ | |
| cbg <- get("cbg_vect_sf", inherits = TRUE) | |
| suppressWarnings(sf::st_crop(sf::st_transform(cbg, 3857), map_bb)) | |
| }, error = function(e) NULL) | |
| } | |
| grDevices::png(file, width = 1500, height = 920, res = 170, bg = "white") | |
| on.exit(grDevices::dev.off(), add = TRUE) | |
| graphics::par(mar = c(0.4, 0.4, 1.4, 0.4), xaxs = "i", yaxs = "i") | |
| graphics::plot( | |
| sf::st_geometry(iso_list[[1]]), | |
| col = NA, | |
| border = NA, | |
| xlim = c(map_bb[["xmin"]], map_bb[["xmax"]]), | |
| ylim = c(map_bb[["ymin"]], map_bb[["ymax"]]), | |
| axes = FALSE, | |
| asp = 1 | |
| ) | |
| if (!is.null(local_cbg) && nrow(local_cbg) > 0) { | |
| graphics::plot( | |
| sf::st_geometry(local_cbg), | |
| add = TRUE, | |
| col = "#f6f7f6", | |
| border = "#d8ddda", | |
| lwd = 0.5 | |
| ) | |
| } | |
| for (i in seq_len(n_iso)) { | |
| graphics::plot( | |
| sf::st_geometry(iso_list[[i]]), | |
| add = TRUE, | |
| col = grDevices::adjustcolor(colors[[i]], alpha.f = 0.20), | |
| border = colors[[i]], | |
| lwd = 2.2 | |
| ) | |
| } | |
| label_points <- lapply(iso_list, function(x) { | |
| suppressWarnings(sf::st_point_on_surface(sf::st_union(sf::st_geometry(x)))) | |
| }) | |
| label_xy <- do.call(rbind, lapply(label_points, sf::st_coordinates)) | |
| if (!is.null(label_xy) && nrow(label_xy) == n_iso) { | |
| graphics::points( | |
| label_xy[, 1], label_xy[, 2], | |
| pch = 21, bg = "white", col = colors, | |
| cex = 1.3, lwd = 1.8 | |
| ) | |
| graphics::text( | |
| label_xy[, 1], label_xy[, 2], | |
| labels = seq_len(n_iso), | |
| cex = 0.72, font = 2, col = colors | |
| ) | |
| } | |
| graphics::legend( | |
| "bottomleft", | |
| legend = paste0(seq_len(n_iso), ". ", labels), | |
| fill = grDevices::adjustcolor(colors, alpha.f = 0.20), | |
| border = colors, | |
| bg = grDevices::adjustcolor("white", alpha.f = 0.92), | |
| cex = 0.72, | |
| bty = "o" | |
| ) | |
| graphics::mtext(map_title, side = 3, line = 0.2, cex = 0.82, font = 2, col = "#294337") | |
| TRUE | |
| } | |
| # --------------------------------------------------------------------------- | |
| # DuckDB connection | |
| # --------------------------------------------------------------------------- | |
| con <- dbConnect(duckdb::duckdb(), dbdir = ":memory:") | |
| dbExecute(con, "INSTALL spatial; LOAD spatial;") | |
| dbExecute(con, "INSTALL httpfs; LOAD httpfs;") | |
| gbif_tab <- tbl(con, glue("read_parquet('{gbif_parquet}')")) | |
| onStop(function() { | |
| try(dbDisconnect(con, shutdown = TRUE), silent = TRUE) | |
| }) | |
| chosen_point <- reactiveVal(NULL) | |
| corridor_reference_point <- reactiveVal(NULL) | |
| proposed_greenspace <- reactiveVal(NULL) | |
| greenspace_draw_active <- reactiveVal(FALSE) | |
| # --------------------------------------------------------------------------- | |
| # Legacy CBG contextual distributions | |
| # --------------------------------------------------------------------------- | |
| # Retained because corridor-planning thresholds still use greenspace_cover. | |
| # These CBG distributions are NOT used for the same-mode Γ same-time | |
| # isochrone percentile scores or the new isochrone-benchmarked BAI. | |
| city_benchmarks <- local({ | |
| bench_cache <- file.path(cache_dir, "city_benchmarks.rds") | |
| if (file.exists(bench_cache)) { | |
| message("Loading city_benchmarks from cache...") | |
| return(readRDS(bench_cache)) | |
| } | |
| message("Computing legacy citywide contextual distributions for corridor thresholds (first run only)...") | |
| cbg_bench <- cbg_vect_sf |> | |
| st_transform(3857) |> | |
| mutate(area_km2 = as.numeric(st_area(geometry)) / 1e6) |> | |
| st_transform(4326) | |
| transit_density_bench <- rep(0, nrow(cbg_bench)) | |
| if (!is.null(gtfs_stops_sf)) { | |
| stop_join <- tryCatch( | |
| st_join(gtfs_stops_sf, cbg_bench[, c("GEOID")], left = FALSE), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(stop_join) && nrow(stop_join) > 0) { | |
| transit_density_bench <- stop_join |> | |
| st_drop_geometry() |> | |
| count(GEOID, name = "n_stops") |> | |
| right_join( | |
| cbg_bench |> st_drop_geometry() |> select(GEOID, area_km2), | |
| by = "GEOID" | |
| ) |> | |
| mutate( | |
| n_stops = replace_na(n_stops, 0), | |
| transit_density = ifelse(area_km2 > 0, n_stops / area_km2, 0) | |
| ) |> | |
| pull(transit_density) | |
| } | |
| } | |
| sampling_density_bench <- cbg_bench |> | |
| st_drop_geometry() |> | |
| mutate( | |
| obs = ifelse(is.na(n_observations), 0, n_observations), | |
| sampling_density = ifelse(area_km2 > 0, obs / area_km2, 0) | |
| ) |> | |
| pull(sampling_density) | |
| biodiversity_bench <- cbg_bench |> | |
| st_drop_geometry() |> | |
| mutate(unique_species = replace_na(unique_species, 0)) |> | |
| pull(unique_species) | |
| ndvi_bench <- cbg_bench |> | |
| st_drop_geometry() |> | |
| mutate(ndvi_ref = dplyr::coalesce(ndvi_mean, ndvi_sentinel)) |> | |
| pull(ndvi_ref) | |
| ej_bench <- if (!is.null(sf_ej_sf) && "score" %in% names(sf_ej_sf)) { | |
| sf_ej_sf |> st_drop_geometry() |> pull(score) |> na.omit() | |
| } else { | |
| numeric(0) | |
| } | |
| # Greenspace coverage (%) per CBG, read from the precomputed coverage table | |
| # (code/prep/build_cbg_greenspace_coverage.R) -- the same source the socio | |
| # summary uses -- instead of recomputing the CBG x greenspace intersection. | |
| # gs_pct = 100 * greenspace_m2 / cbg_area_m2 reproduces the old value exactly. | |
| greenspace_cover_bench <- tryCatch({ | |
| cov_path <- hf_or_local("cbg_greenspace_coverage.csv") | |
| readr::read_csv(cov_path, col_types = readr::cols(GEOID = readr::col_character()), | |
| show_col_types = FALSE) |> | |
| # NA (not 0) for zero-area CBGs so the is.finite() filter below drops them, | |
| # matching the old inline computation exactly. | |
| mutate(gs_pct = ifelse(cbg_area_m2 > 0, 100 * greenspace_m2 / cbg_area_m2, NA_real_)) |> | |
| pull(gs_pct) | |
| }, error = function(e) { | |
| warning("Greenspace coverage benchmark failed: ", e$message) | |
| numeric(0) | |
| }) | |
| # Unique Muni routes accessible per CBG (for route-access axis) | |
| route_access_bench <- if (!is.null(gtfs_routes_sf)) { | |
| message("Computing per-CBG unique route counts for BAI benchmark...") | |
| tryCatch({ | |
| route_join <- st_join( | |
| gtfs_routes_sf[, "route_id"], | |
| cbg_bench[, "GEOID"], | |
| left = FALSE | |
| ) | |
| route_join |> | |
| st_drop_geometry() |> | |
| group_by(GEOID) |> | |
| summarise(n_routes = n_distinct(route_id), .groups = "drop") |> | |
| right_join( | |
| cbg_bench |> st_drop_geometry() |> select(GEOID), | |
| by = "GEOID" | |
| ) |> | |
| mutate(n_routes = replace_na(n_routes, 0L)) |> | |
| pull(n_routes) | |
| }, error = function(e) { | |
| warning("Route access benchmark failed: ", e$message) | |
| numeric(0) | |
| }) | |
| } else { | |
| numeric(0) | |
| } | |
| bench <- list( | |
| transit_density = transit_density_bench[is.finite(transit_density_bench)], | |
| biodiversity = biodiversity_bench[is.finite(biodiversity_bench)], | |
| sampling = sampling_density_bench[is.finite(sampling_density_bench)], | |
| ndvi = ndvi_bench[is.finite(ndvi_bench)], | |
| ej = ej_bench[is.finite(ej_bench)], | |
| route_access = route_access_bench[is.finite(route_access_bench)], | |
| greenspace_cover = greenspace_cover_bench[is.finite(greenspace_cover_bench)] | |
| ) | |
| if (!dir.exists(cache_dir)) dir.create(cache_dir, recursive = TRUE) | |
| saveRDS(bench, bench_cache) | |
| message("City benchmarks saved to cache.") | |
| bench | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Logos | |
| # --------------------------------------------------------------------------- | |
| output$combine_logo <- renderImage({ | |
| list( | |
| src = file.path("www", "Combined_logos.png"), | |
| width = "50%", | |
| height = "auto", | |
| alt = "UC Berkeley ESPM Β· California Academy of Sciences Β· Reimagining San Francisco" | |
| ) | |
| }, deleteFile = FALSE) | |
| # --------------------------------------------------------------------------- | |
| # Base map | |
| # --------------------------------------------------------------------------- | |
| output$isoMap <- renderLeaflet({ | |
| pal_cbg <- colorNumeric("YlOrRd", cbg_vect_sf$medincE) | |
| pal_rich <- colorNumeric("YlOrRd", domain = cbg_vect_sf$unique_species) | |
| # GBIF effort is extremely right-skewed: across SF block groups the median is | |
| # 72 records, the maximum 740,656, and the top 1% of block groups hold 68% of | |
| # every record in the city. On a linear ramp that paints Golden Gate Park dark | |
| # and the entire rest of the city white, which is exactly the inequality the | |
| # map is supposed to let you SEE rather than hide. Log-spaced bins in real | |
| # units keep the legend readable without a transformed axis to explain. | |
| avail_bins <- c(0, 1, 10, 50, 200, 1000, 5000, 50000, Inf) | |
| pal_data <- colorBin("Blues", domain = cbg_vect_sf$n_observations, | |
| bins = avail_bins, na.color = "transparent") | |
| hotspot_sf <- safe_biodiv_hotspots() | |
| coldspot_sf <- safe_biodiv_coldspots() | |
| m <- leaflet() |> | |
| addTiles(group = "Street Map (Default)") |> | |
| addProviderTiles(providers$Esri.NatGeoWorldMap, group = "National Geographic") |> | |
| addProviderTiles(providers$CartoDB.Positron, group = "CartoDB.Positron") |> | |
| addProviderTiles(providers$Esri.WorldImagery, group = "Satellite (ESRI)") |> | |
| addPolygons( | |
| data = cbg_vect_sf, group = "Income", | |
| fillColor = ~pal_cbg(medincE), fillOpacity = 0.6, | |
| color = "white", weight = 1, label = ~GEOID | |
| ) |> | |
| addPolygons( | |
| data = osm_greenspace, group = "Existing SF Greenspace", | |
| fillColor = "#81c784", fillOpacity = 0.26, | |
| color = "#2e7d32", weight = 1.2, label = ~name | |
| ) |> | |
| addPolygons( | |
| data = cbg_vect_sf, group = "Species Richness", | |
| fillColor = ~pal_rich(unique_species), fillOpacity = 0.6, | |
| color = "white", weight = 1, label = ~unique_species | |
| ) |> | |
| addPolygons( | |
| data = cbg_vect_sf, group = "Data Availability", | |
| fillColor = ~pal_data(n_observations), fillOpacity = 0.75, | |
| color = "white", weight = 1, | |
| label = ~sprintf("%s GBIF records", format(n_observations, big.mark = ",")) | |
| ) |> | |
| addLegend( | |
| position = "bottomleft", pal = pal_data, | |
| values = cbg_vect_sf$n_observations, | |
| title = "GBIF records<br>per block group", | |
| labFormat = labelFormat(big.mark = ","), | |
| group = "Data Availability" | |
| ) | |
| if (FALSE && !is.null(hotspot_sf)) { | |
| m <- m |> | |
| addPolygons( | |
| data = hotspot_sf, | |
| group = "Hotspots (KnowBR)", | |
| fillColor = "firebrick", fillOpacity = 0.2, | |
| color = "firebrick", weight = 2 | |
| ) | |
| } | |
| if (FALSE && !is.null(coldspot_sf)) { | |
| m <- m |> | |
| addPolygons( | |
| data = coldspot_sf, | |
| group = "Coldspots (KnowBR)", | |
| fillColor = "navy", fillOpacity = 0.2, | |
| color = "navy", weight = 2 | |
| ) | |
| } | |
| rsf_proj_sf <- choose_existing_sf_object(c("rsf_projects", "rsf_project_areas", "rsf_polygons", "rsf_programs")) | |
| if (!is.null(rsf_proj_sf)) { | |
| m <- m |> | |
| addPolygons( | |
| data = rsf_proj_sf, | |
| group = "RSF Program Projects", | |
| fillColor = "purple", | |
| fillOpacity = 0.3, | |
| color = "purple", | |
| weight = 1, | |
| label = ~if (exists("prj_name")) ~prj_name else "RSF Project", | |
| highlightOptions = highlightOptions( | |
| weight = 5, | |
| color = "blue", | |
| fillOpacity = 0.5, | |
| bringToFront = TRUE | |
| ), | |
| labelOptions = labelOptions( | |
| style = list("font-weight" = "bold", "color" = "blue"), | |
| textsize = "12px", | |
| direction = "auto", | |
| noHide = FALSE | |
| ) | |
| ) | |
| } | |
| if (FALSE && exists("greenspace_dist_raster")) { | |
| greenspace_vals_clean <- values(greenspace_dist_raster) |> | |
| as.vector() |> | |
| (\(x) x[is.finite(x)])() | |
| if (length(greenspace_vals_clean) > 0) { | |
| upper_limit <- quantile(greenspace_vals_clean, 0.998, na.rm = TRUE) | |
| pal_greenspace_dist <- colorNumeric( | |
| palette = rev(brewer.pal(9, "YlGnBu")), | |
| domain = c(0, upper_limit), | |
| na.color = "transparent" | |
| ) | |
| m <- m |> | |
| addRasterImage( | |
| x = greenspace_dist_raster, | |
| colors = pal_greenspace_dist, | |
| opacity = 0.65, | |
| project = TRUE, | |
| group = "Greenspace Distance" | |
| ) |> | |
| addLegend( | |
| position = "bottomleft", | |
| pal = pal_greenspace_dist, | |
| values = c(0, upper_limit), | |
| title = "Distance to<br>Greenspace (m)", | |
| group = "Greenspace Distance" | |
| ) | |
| } | |
| } | |
| if (FALSE && exists("rsfprogram_dist_raster")) { | |
| rsf_vals_clean <- values(rsfprogram_dist_raster) |> | |
| as.vector() |> | |
| (\(x) x[is.finite(x)])() | |
| if (length(rsf_vals_clean) > 0) { | |
| upper_rsf <- quantile(rsf_vals_clean, 0.998, na.rm = TRUE) | |
| pal_rsf_dist <- colorNumeric( | |
| palette = rev(brewer.pal(9, "Purples")), | |
| domain = c(0, upper_rsf), | |
| na.color = "transparent" | |
| ) | |
| m <- m |> | |
| addRasterImage( | |
| x = rsfprogram_dist_raster, | |
| colors = pal_rsf_dist, | |
| opacity = 0.65, | |
| project = TRUE, | |
| group = "RSF Program Distance" | |
| ) |> | |
| addLegend( | |
| position = "bottomright", | |
| pal = pal_rsf_dist, | |
| values = c(0, upper_rsf), | |
| title = "Distance to<br>RSF program (m)", | |
| group = "RSF Program Distance" | |
| ) | |
| } | |
| } | |
| if (!is.null(gtfs_routes_sf)) { | |
| m <- m |> | |
| addPolylines( | |
| data = gtfs_routes_sf, | |
| group = "Transit Routes", | |
| color = ~route_color_hex, | |
| weight = 2, | |
| opacity = 0.8, | |
| label = ~paste0(route_short_name, ": ", route_long_name) | |
| ) | |
| } | |
| # if (!is.null(gtfs_stops_sf)) { | |
| # m <- m |> | |
| # addCircleMarkers( | |
| # data = gtfs_stops_sf, | |
| # group = "Transit Stops", | |
| # radius = 4, | |
| # color = "#005B95", fillColor = "#005B95", | |
| # fillOpacity = 0.7, stroke = FALSE, | |
| # label = ~stop_name | |
| # ) | |
| # } | |
| # Inserted this to make reactive to URL: | |
| if (!is.null(gtfs_stops_sf)) { | |
| stops_for_map <- gtfs_stops_sf | |
| if (!"stop_url" %in% names(stops_for_map)) { | |
| stops_for_map$stop_url <- NA_character_ | |
| } | |
| if (!"mean_headway_min" %in% names(stops_for_map)) { | |
| stops_for_map$mean_headway_min <- NA_real_ | |
| } | |
| if (!"n_departures_peak" %in% names(stops_for_map)) { | |
| stops_for_map$n_departures_peak <- NA_real_ | |
| } | |
| stops_for_map$popup_html <- paste0( | |
| "<strong>", stops_for_map$stop_name, "</strong>", | |
| "<br>Stop ID: ", stops_for_map$stop_id, | |
| ifelse( | |
| !is.na(stops_for_map$mean_headway_min), | |
| paste0("<br>Mean headway (AM): ", round(stops_for_map$mean_headway_min, 1), " min"), | |
| "" | |
| ), | |
| ifelse( | |
| !is.na(stops_for_map$n_departures_peak), | |
| paste0("<br>Departures (7β9am): ", stops_for_map$n_departures_peak), | |
| "" | |
| ), | |
| ifelse( | |
| !is.na(stops_for_map$stop_url) & stops_for_map$stop_url != "", | |
| paste0("<br><a href=\"", stops_for_map$stop_url, "\" target=\"_blank\">Open stop page</a>"), | |
| "" | |
| ) | |
| ) | |
| m <- m |> | |
| addCircleMarkers( | |
| data = stops_for_map, | |
| group = "Transit Stops", | |
| radius = 4, | |
| color = "#005B95", | |
| fillColor = "#005B95", | |
| fillOpacity = 0.7, | |
| stroke = FALSE, | |
| label = ~stop_name, | |
| popup = ~popup_html | |
| ) | |
| } | |
| if (!is.null(cenv_sf)) { | |
| pal_cenv <- colorNumeric("YlOrBr", domain = cenv_sf$CIscore, na.color = "transparent") | |
| m <- m |> | |
| addPolygons( | |
| data = cenv_sf, | |
| group = "CalEnviroScreen (CI Score)", | |
| fillColor = ~pal_cenv(CIscore), | |
| fillOpacity = 0.65, | |
| color = "white", | |
| weight = 0.5, | |
| label = ~paste0("CI Score: ", round(CIscore, 1)) | |
| ) | |
| } | |
| if (!is.null(ces5_sf)) { | |
| # The published CalEnviroScreen map colours TRACTS BY PERCENTILE, not by | |
| # raw score, in ten classes on OEHHA's yellow-to-dark-red ramp. Colouring | |
| # by raw CIscore instead compresses most of the city into one shade, the | |
| # same mistake the Data Availability layer used to make. | |
| CES_DECILES <- c("#FFFFCC", "#FFEDA0", "#FED976", "#FEB24C", "#FD8D3C", | |
| "#FC4E2A", "#E31A1C", "#BD0026", "#980043", "#800026") | |
| ces_pal <- colorBin(CES_DECILES, domain = c(0, 100), bins = seq(0, 100, 10), | |
| na.color = "transparent") | |
| m <- m |> | |
| addPolygons( | |
| data = ces5_sf, group = "CalEnviroScreen 5.0", | |
| fillColor = ~ces_pal(CIscore_Pctl), fillOpacity = 0.7, | |
| color = "white", weight = 0.5, | |
| label = ~sprintf("Tract %s - CES 5.0 score %.1f (%.0fth percentile statewide)", | |
| tract, CIscore, CIscore_Pctl) | |
| ) |> | |
| addLegend( | |
| position = "bottomright", pal = ces_pal, values = c(0, 100), | |
| title = "CalEnviroScreen 5.0<br>percentile (statewide)", | |
| labFormat = labelFormat(suffix = "%"), | |
| group = "CalEnviroScreen 5.0" | |
| ) | |
| } | |
| if (!is.null(sf_ej_sf)) { | |
| m <- m |> | |
| addPolygons( | |
| data = sf_ej_sf, | |
| group = "SF EJ Communities", | |
| fillColor = ~symbol_hex, | |
| fillOpacity = 0.7, | |
| color = "white", | |
| weight = 0.5, | |
| label = ~paste0(ej_label, ifelse(is.na(score), "", paste0(" (score: ", score, ")"))) | |
| ) | |
| } | |
| m |> | |
| setView(lng = -122.4194, lat = 37.7749, zoom = 12) |> | |
| addLayersControl( | |
| baseGroups = c("Street Map (Default)", "National Geographic", "CartoDB.Positron", "Satellite (ESRI)"), | |
| overlayGroups = c( | |
| "Income", "Existing SF Greenspace", "RSF Program Projects", | |
| "Species Richness", "Data Availability", | |
| "CalEnviroScreen 5.0", "CalEnviroScreen (CI Score)", "SF EJ Communities", | |
| "Transit Routes", "Transit Stops", | |
| "Isochrones", "Transit Isochrones", "Muni Stops Reached", "NDVI Raster" | |
| ), | |
| options = layersControlOptions(collapsed = TRUE) | |
| ) |> | |
| hideGroup("Income") |> | |
| hideGroup("RSF Program Projects") |> | |
| hideGroup("Species Richness") |> | |
| hideGroup("Data Availability") |> | |
| hideGroup("CalEnviroScreen 5.0") |> | |
| hideGroup("CalEnviroScreen (CI Score)") |> | |
| hideGroup("SF EJ Communities") |> | |
| hideGroup("Transit Routes") |> | |
| hideGroup("Transit Stops") | |
| }) | |
| # A separate, simplified map for intervention planning. Keeping drawing and | |
| # scenario layers off the Isochrone Explorer prevents the two workflows from | |
| # competing for controls, map clicks, and visual attention. | |
| output$corridorMap <- renderLeaflet({ | |
| m <- leaflet() |> | |
| addMapPane("corridorBasePane", zIndex = 360) |> | |
| addMapPane("corridorIsochronePane", zIndex = 385) |> | |
| addMapPane("corridorProposalPane", zIndex = 430) |> | |
| addTiles(group = "Street Map") |> | |
| addProviderTiles(providers$Esri.NatGeoWorldMap, group = "National Geographic") |> | |
| addProviderTiles(providers$CartoDB.Positron, group = "CartoDB.Positron") |> | |
| addProviderTiles(providers$Esri.WorldImagery, group = "Satellite (ESRI)") |> | |
| addPolygons( | |
| data = osm_greenspace, | |
| group = "Existing SF Greenspace", | |
| fillColor = "#81c784", | |
| fillOpacity = 0.28, | |
| color = "#2e7d32", | |
| weight = 1.2, | |
| label = ~ifelse(is.na(name) | name == "", "Unnamed mapped greenspace", name), | |
| options = pathOptions(pane = "corridorBasePane") | |
| ) | |
| if (!is.null(sf_ej_sf) && inherits(sf_ej_sf, "sf") && nrow(sf_ej_sf) > 0) { | |
| m <- m |> | |
| addPolygons( | |
| data = sf_ej_sf, | |
| group = "SF EJ Communities", | |
| fillColor = ~symbol_hex, | |
| fillOpacity = 0.40, | |
| color = "#8e44ad", | |
| weight = 1, | |
| label = ~paste0(ej_label, ifelse(is.na(score), "", paste0(" (score: ", score, ")"))), | |
| options = pathOptions(pane = "corridorBasePane") | |
| ) | |
| } | |
| m |> | |
| setView(lng = -122.4194, lat = 37.7749, zoom = 12) |> | |
| addScaleBar( | |
| position = "bottomright", | |
| options = scaleBarOptions(metric = TRUE, imperial = FALSE, maxWidth = 140) | |
| ) |> | |
| addDrawToolbar( | |
| targetGroup = "Your Drawn Route or Shape", | |
| position = "topleft", | |
| polylineOptions = drawPolylineOptions( | |
| shapeOptions = drawShapeOptions( | |
| color = "#1565c0", | |
| weight = 5, | |
| opacity = 0.95 | |
| ) | |
| ), | |
| polygonOptions = drawPolygonOptions( | |
| showArea = TRUE, | |
| metric = TRUE, | |
| shapeOptions = drawShapeOptions( | |
| color = "#1565c0", | |
| weight = 3, | |
| fillColor = "#90caf9", | |
| fillOpacity = 0.40 | |
| ) | |
| ), | |
| circleOptions = FALSE, | |
| rectangleOptions = FALSE, | |
| markerOptions = FALSE, | |
| circleMarkerOptions = FALSE, | |
| editOptions = editToolbarOptions( | |
| edit = TRUE, | |
| remove = TRUE, | |
| selectedPathOptions = selectedPathOptions(maintainColor = TRUE) | |
| ) | |
| ) |> | |
| addLayersControl( | |
| baseGroups = c("Street Map", "National Geographic", "CartoDB.Positron", "Satellite (ESRI)"), | |
| overlayGroups = c( | |
| "Existing SF Greenspace", "SF EJ Communities", "Explorer Location", | |
| "Active Analysis Isochrone", | |
| "Your Drawn Route or Shape", "Your Proposed Habitat Area", | |
| "Existing Greenspaces Reached", "Measured Connection Gaps", | |
| "Population Estimate Area (Optional)" | |
| ), | |
| options = layersControlOptions(collapsed = TRUE) | |
| ) |> | |
| addControl( | |
| html = paste0( | |
| '<div style="background:rgba(255,255,255,0.96); padding:9px 11px; border:1px solid #bbb; border-radius:4px; line-height:1.3; max-width:270px;">', | |
| '<div style="font-weight:700; margin-bottom:6px;">How to read this scenario</div>', | |
| '<div style="margin:4px 0;"><span style="display:inline-block;width:16px;height:12px;background:#81c784;border:2px solid #2e7d32;margin-right:7px;vertical-align:middle;"></span><b>Existing greenspace</b><br><span style="margin-left:27px;color:#555;">Mapped parks and green areas already present</span></div>', | |
| '<div style="margin:4px 0;"><span style="display:inline-block;width:16px;height:12px;background:#9fa8da;border:3px solid #3949ab;margin-right:7px;vertical-align:middle;"></span><b>Active analysis isochrone</b><br><span style="margin-left:27px;color:#555;">The only reachable area used for baseline-vs-proposal greenspace and BAI</span></div>', | |
| '<div style="margin:4px 0;"><span style="display:inline-block;width:16px;height:12px;background:#42a5f5;border:2px solid #0d47a1;margin-right:7px;vertical-align:middle;"></span><b>Your proposed intervention</b><br><span style="margin-left:27px;color:#555;">For a line, the blue area reflects the selected total width; zoom in to inspect it</span></div>', | |
| '<div style="margin:4px 0;"><span style="display:inline-block;width:16px;height:12px;background:#ffb74d;border:2px solid #e65100;margin-right:7px;vertical-align:middle;"></span><b>Greenspaces reached</b><br><span style="margin-left:27px;color:#555;">Existing patches within the selected rule</span></div>', | |
| '<div style="margin:4px 0;"><span style="display:inline-block;width:16px;height:12px;background:#ce93d8;border:2px dashed #6a1b9a;margin-right:7px;vertical-align:middle;"></span><b>Population-estimate area</b><br><span style="margin-left:27px;color:#555;">Optional; hidden until enabled</span></div>', | |
| '<div style="margin-top:6px;color:#555;"><span style="display:inline-block;width:18px;border-top:3px dashed #ef6c00;margin-right:6px;vertical-align:middle;"></span>Measured ecological gap</div>', | |
| '</div>' | |
| ), | |
| position = "bottomleft", | |
| className = "corridor-map-key" | |
| ) |> | |
| hideGroup("SF EJ Communities") | |
| }) | |
| # Transfer the selected Explorer location and generated isochrones into the | |
| # dedicated corridor workflow. All generated isochrones remain available in | |
| # the selector, but exactly ONE isochrone is active at a time. No averaging | |
| # across transport modes or travel times is used for corridor BAI comparisons. | |
| corridor_isochrone_catalog <- function(iso_data) { | |
| if (is.null(iso_data) || !inherits(iso_data, "sf") || nrow(iso_data) == 0) { | |
| return(NULL) | |
| } | |
| out <- iso_data | |
| mode_chr <- as.character(out$mode) | |
| mode_label <- vapply(mode_chr, pretty_mode, character(1)) | |
| time_num <- suppressWarnings(as.numeric(out$time)) | |
| time_label <- ifelse(is.finite(time_num), paste0(format(time_num, trim = TRUE), " min"), "time unavailable") | |
| raw_key <- paste0(mode_chr, "__", ifelse(is.finite(time_num), time_num, "NA")) | |
| out$.corridor_iso_key <- make.unique(raw_key, sep = "__") | |
| out$.corridor_iso_label <- paste0(mode_label, " β ", time_label) | |
| out | |
| } | |
| # Default analysis context: Walking at the shortest available travel time. | |
| # If Walking was not generated, use the shortest available isochrone overall. | |
| corridor_default_iso_index <- function(iso_data) { | |
| if (is.null(iso_data) || nrow(iso_data) == 0) return(NA_integer_) | |
| mode_chr <- tolower(as.character(iso_data$mode)) | |
| time_num <- suppressWarnings(as.numeric(iso_data$time)) | |
| time_rank <- ifelse(is.finite(time_num), time_num, Inf) | |
| walking_idx <- which(mode_chr == "walking") | |
| if (length(walking_idx) > 0) { | |
| return(walking_idx[[which.min(time_rank[walking_idx])]]) | |
| } | |
| if (any(is.finite(time_num))) return(which.min(time_rank)) | |
| 1L | |
| } | |
| corridor_active_iso_info <- reactive({ | |
| iso_data <- corridor_isochrone_catalog(isochrones_data()) | |
| if (is.null(iso_data) || nrow(iso_data) == 0) return(NULL) | |
| selected_key <- input$corridor_active_isochrone | |
| idx <- match(selected_key, iso_data$.corridor_iso_key) | |
| if (length(idx) == 0 || is.na(idx)) idx <- corridor_default_iso_index(iso_data) | |
| list( | |
| index = idx, | |
| key = iso_data$.corridor_iso_key[[idx]], | |
| label = iso_data$.corridor_iso_label[[idx]], | |
| data = iso_data[idx, , drop = FALSE], | |
| all = iso_data | |
| ) | |
| }) | |
| output$corridorIsochroneSelectorUI <- renderUI({ | |
| iso_data <- corridor_isochrone_catalog(isochrones_data()) | |
| if (is.null(iso_data) || nrow(iso_data) == 0) { | |
| return(tags$div( | |
| style = "font-size:12px; color:#777; margin:7px 0;", | |
| icon("info-circle"), | |
| " No generated Explorer isochrones are currently available. Generate them in the Explorer, then return here." | |
| )) | |
| } | |
| choices <- iso_data$.corridor_iso_key | |
| names(choices) <- iso_data$.corridor_iso_label | |
| selected <- isolate(input$corridor_active_isochrone) | |
| if (is.null(selected) || !(selected %in% choices)) { | |
| selected <- choices[[corridor_default_iso_index(iso_data)]] | |
| } | |
| tagList( | |
| selectInput( | |
| "corridor_active_isochrone", | |
| "Active analysis isochrone:", | |
| choices = choices, | |
| selected = selected, | |
| selectize = FALSE | |
| ), | |
| tags$div( | |
| style = "background:#e3f2fd; border-left:4px solid #1976d2; padding:8px 9px; margin:0 0 7px 0; font-size:12px;", | |
| tags$b("Used for: "), | |
| "greenspace cover and BAI baseline-versus-proposal results. This is the only isochrone used for those calculations.", | |
| tags$br(), | |
| tags$b("Not used for: "), | |
| "habitat connectivity or population estimates; those are calculated independently of the active isochrone." | |
| ), | |
| tags$div( | |
| style = "font-size:12px; color:#2e7d32; margin:-2px 0 7px 0;", | |
| icon("check-circle"), " ", | |
| "Default = Walking at the shortest generated travel time. You can select a different single mode Γ time above." | |
| ) | |
| ) | |
| }) | |
| draw_explorer_isochrones_on_corridor <- function( | |
| fit = FALSE, | |
| active_key = NULL, | |
| iso_data = NULL | |
| ) { | |
| proxy <- leafletProxy("corridorMap") |> | |
| clearGroup("Active Analysis Isochrone") | |
| if (is.null(iso_data) || !inherits(iso_data, "sf") || nrow(iso_data) == 0) { | |
| return(invisible(NULL)) | |
| } | |
| iso_display <- corridor_isochrone_catalog(iso_data) | |
| iso_display <- tryCatch( | |
| st_transform(st_make_valid(iso_display), 4326), | |
| error = function(e) NULL | |
| ) | |
| if (is.null(iso_display) || nrow(iso_display) == 0) return(invisible(NULL)) | |
| active_idx <- match(active_key, iso_display$.corridor_iso_key) | |
| if (length(active_idx) == 0 || is.na(active_idx)) { | |
| active_idx <- corridor_default_iso_index(iso_display) | |
| } | |
| poly_i <- iso_display[active_idx, , drop = FALSE] | |
| mode_i <- as.character(poly_i$mode[[1]]) | |
| time_i <- suppressWarnings(as.numeric(poly_i$time[[1]])) | |
| mode_label <- pretty_mode(mode_i) | |
| is_transit <- identical(mode_i, "transit") | |
| is_walk_transit <- identical(mode_i, "walk_transit") | |
| dash_style <- if (is_transit) "6,4" else if (is_walk_transit) "4,4" else NULL | |
| proxy <- proxy |> | |
| addPolygons( | |
| data = poly_i, | |
| group = "Active Analysis Isochrone", | |
| color = "#3949ab", | |
| weight = 4, | |
| opacity = 1, | |
| dashArray = dash_style, | |
| fillColor = "#9fa8da", | |
| fillOpacity = 0.18, | |
| label = paste0( | |
| "ACTIVE ANALYSIS ISOCHRONE β ", | |
| mode_label, | |
| if (is.finite(time_i)) paste0(" β ", time_i, " min") else "" | |
| ), | |
| popup = paste0( | |
| "<strong>Active analysis isochrone</strong><br>", | |
| mode_label, | |
| if (is.finite(time_i)) paste0(" β ", time_i, " min") else "", | |
| "<br><small>This is the only Explorer isochrone used for baseline-versus-proposal greenspace and BAI. No averaging across isochrones is performed.</small>" | |
| ), | |
| options = pathOptions(pane = "corridorIsochronePane") | |
| ) | |
| if (isTRUE(fit)) { | |
| bb <- tryCatch( | |
| st_bbox(st_union(st_geometry(poly_i))), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(bb) && all(is.finite(as.numeric(bb)))) { | |
| leafletProxy("corridorMap") |> | |
| fitBounds( | |
| lng1 = unname(bb[["xmin"]]), | |
| lat1 = unname(bb[["ymin"]]), | |
| lng2 = unname(bb[["xmax"]]), | |
| lat2 = unname(bb[["ymax"]]) | |
| ) | |
| } | |
| } | |
| invisible(NULL) | |
| } | |
| focus_corridor_on_reference <- function( | |
| pt, | |
| zoom = 15, | |
| active_key = NULL, | |
| iso_data = NULL | |
| ) { | |
| if (is.null(pt) || length(pt) < 2) return(invisible(NULL)) | |
| leafletProxy("corridorMap") |> | |
| clearGroup("Explorer Location") |> | |
| addCircleMarkers( | |
| lng = unname(pt[["lon"]]), | |
| lat = unname(pt[["lat"]]), | |
| radius = 7, | |
| color = "#212121", | |
| fillColor = "#ffffff", | |
| fillOpacity = 1, | |
| weight = 2, | |
| group = "Explorer Location", | |
| label = "Location transferred from Isochrone Explorer" | |
| ) | |
| has_iso <- !is.null(iso_data) && inherits(iso_data, "sf") && nrow(iso_data) > 0 | |
| draw_explorer_isochrones_on_corridor( | |
| fit = has_iso, | |
| active_key = active_key, | |
| iso_data = iso_data | |
| ) | |
| if (!has_iso) { | |
| leafletProxy("corridorMap") |> | |
| flyTo( | |
| lng = unname(pt[["lon"]]), | |
| lat = unname(pt[["lat"]]), | |
| zoom = zoom | |
| ) | |
| } | |
| invisible(NULL) | |
| } | |
| transfer_explorer_location <- function() { | |
| pt <- chosen_point() | |
| if (is.null(pt)) { | |
| showNotification( | |
| "Select a location in the Isochrone Explorer first.", | |
| type = "warning" | |
| ) | |
| return(invisible(NULL)) | |
| } | |
| corridor_reference_point(pt) | |
| updateTabItems(session, "tabs", selected = "corridor") | |
| iso_data <- isochrones_data() | |
| if (is.null(iso_data) || !inherits(iso_data, "sf") || nrow(iso_data) == 0) { | |
| showNotification( | |
| "The location was transferred. Generate isochrones in the Explorer to display reachable areas here.", | |
| type = "message", | |
| duration = 6 | |
| ) | |
| } | |
| # Capture reactive values before entering onFlushed(). The callback itself | |
| # must not read input$... or reactive expressions directly. | |
| iso_snapshot <- iso_data | |
| active_snapshot <- input$corridor_active_isochrone | |
| session$onFlushed(function() { | |
| focus_corridor_on_reference( | |
| pt, | |
| active_key = active_snapshot, | |
| iso_data = iso_snapshot | |
| ) | |
| }, once = TRUE) | |
| invisible(NULL) | |
| } | |
| observeEvent(input$evaluate_green_intervention, { | |
| transfer_explorer_location() | |
| }) | |
| observeEvent(input$use_explorer_location, { | |
| transfer_explorer_location() | |
| }) | |
| observeEvent(input$return_to_isochrone, { | |
| updateTabItems(session, "tabs", selected = "isochrone") | |
| }) | |
| # Keep the corridor map synchronized with exactly one active isochrone when | |
| # the selector changes or Explorer isochrones are regenerated/cleared. | |
| observe({ | |
| active_key <- input$corridor_active_isochrone | |
| iso_snapshot <- isochrones_data() | |
| session$onFlushed(function() { | |
| draw_explorer_isochrones_on_corridor( | |
| fit = FALSE, | |
| active_key = active_key, | |
| iso_data = iso_snapshot | |
| ) | |
| }, once = TRUE) | |
| }) | |
| observeEvent(input$tabs, { | |
| if (identical(input$tabs, "corridor")) { | |
| pt <- corridor_reference_point() | |
| active_key <- input$corridor_active_isochrone | |
| iso_snapshot <- isochrones_data() | |
| if (!is.null(pt)) { | |
| session$onFlushed(function() { | |
| focus_corridor_on_reference( | |
| pt, | |
| active_key = active_key, | |
| iso_data = iso_snapshot | |
| ) | |
| }, once = TRUE) | |
| } else { | |
| session$onFlushed(function() { | |
| draw_explorer_isochrones_on_corridor( | |
| fit = FALSE, | |
| active_key = active_key, | |
| iso_data = iso_snapshot | |
| ) | |
| }, once = TRUE) | |
| } | |
| } | |
| }, ignoreInit = TRUE) | |
| corridor_drawing_mode <- reactive({ | |
| proposal <- proposed_greenspace() | |
| if (is.null(proposal) || nrow(proposal) == 0) return("none") | |
| types <- as.character(st_geometry_type(st_zm(proposal, drop = TRUE, what = "ZM"))) | |
| has_line <- any(types %in% c("LINESTRING", "MULTILINESTRING")) | |
| has_polygon <- any(types %in% c("POLYGON", "MULTIPOLYGON")) | |
| if (has_line && has_polygon) return("mixed") | |
| if (has_line) return("line") | |
| if (has_polygon) return("polygon") | |
| "other" | |
| }) | |
| output$corridorWidthPreviewUI <- renderUI({ | |
| width_m <- suppressWarnings(as.numeric(input$corridor_width_m)) | |
| if (!is.finite(width_m) || width_m <= 0) width_m <- 10 | |
| half_width <- width_m / 2 | |
| mode <- corridor_drawing_mode() | |
| # A deliberately exaggerated cross-section makes the selected width legible | |
| # even when the map is zoomed out. It is explanatory, not map-scale. | |
| preview_px <- round(34 + 166 * (width_m - 2) / 38) | |
| preview_px <- max(34, min(200, preview_px)) | |
| mode_note <- switch( | |
| mode, | |
| line = tags$div( | |
| style = "margin-top:6px; color:#0d47a1;", | |
| icon("check-circle"), | |
| " A line is currently drawn, so this width is being used." | |
| ), | |
| polygon = tags$div( | |
| style = "margin-top:6px; color:#8a6d3b;", | |
| icon("info-circle"), | |
| " A polygon is currently drawn. The width setting does not change polygons." | |
| ), | |
| mixed = tags$div( | |
| style = "margin-top:6px; color:#8a6d3b;", | |
| icon("info-circle"), | |
| " The width is applied only to the line portion; polygon portions keep their drawn shape." | |
| ), | |
| tags$div( | |
| style = "margin-top:6px; color:#666;", | |
| "Draw a line to apply this width, or draw a polygon to define area directly." | |
| ) | |
| ) | |
| area_note <- NULL | |
| if (mode %in% c("line", "mixed")) { | |
| proposal <- proposed_greenspace() | |
| proposal_3857 <- tryCatch( | |
| proposal |> st_transform(3857) |> st_zm(drop = TRUE, what = "ZM"), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(proposal_3857) && nrow(proposal_3857) > 0) { | |
| types <- as.character(st_geometry_type(proposal_3857)) | |
| line_idx <- types %in% c("LINESTRING", "MULTILINESTRING") | |
| if (any(line_idx)) { | |
| route_length_m <- sum(as.numeric(st_length(st_geometry(proposal_3857[line_idx, , drop = FALSE]))), na.rm = TRUE) | |
| estimated_line_area_ha <- route_length_m * width_m / 10000 | |
| if (is.finite(route_length_m) && route_length_m > 0) { | |
| area_note <- tags$div( | |
| style = "margin-top:6px; font-size:12px; color:#444;", | |
| paste0( | |
| "Current line length: ", scales::comma(round(route_length_m)), | |
| " m. Approximate planted area from the line: ", | |
| round(estimated_line_area_ha, 2), " ha before overlap corrections." | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| tags$div( | |
| style = "background:#eef6fb; border:1px solid #b8d8ef; border-left:4px solid #1976d2; padding:8px 9px; margin:6px 0 4px 0;", | |
| tags$div( | |
| style = "font-size:12px; color:#333; margin-bottom:5px;", | |
| tags$b(paste0(width_m, " m total planted width")), | |
| paste0(" = ", half_width, " m on each side of the line") | |
| ), | |
| tags$div( | |
| style = "height:30px; display:flex; justify-content:center; align-items:center; position:relative;", | |
| tags$div( | |
| style = paste0( | |
| "width:", preview_px, "px; height:22px; background:#42a5f5; border:2px solid #0d47a1; position:relative;" | |
| ), | |
| tags$div(style = "position:absolute; left:50%; top:-4px; bottom:-4px; border-left:2px dashed #263238;") | |
| ) | |
| ), | |
| tags$div( | |
| style = "display:flex; justify-content:space-between; font-size:11px; color:#555; max-width:220px; margin:0 auto;", | |
| tags$span(paste0(half_width, " m")), | |
| tags$span("drawn centerline"), | |
| tags$span(paste0(half_width, " m")) | |
| ), | |
| tags$small( | |
| style = "display:block; margin-top:5px; color:#666;", | |
| "The cross-section is enlarged for explanation. Use the zoom button to inspect the actual map footprint." | |
| ), | |
| mode_note, | |
| area_note | |
| ) | |
| }) | |
| output$corridorLocationStatus <- renderUI({ | |
| pt <- corridor_reference_point() | |
| if (is.null(pt)) { | |
| return(tags$div( | |
| style = "background:#f7f9fb; border-left:4px solid #9e9e9e; padding:8px 10px; margin-bottom:10px;", | |
| tags$b("No Explorer location transferred."), | |
| tags$br(), | |
| tags$small("You can still draw anywhere in San Francisco, or return to the Explorer and select a location first.") | |
| )) | |
| } | |
| tags$div( | |
| style = "background:#f5f5f5; border-left:4px solid #212121; padding:8px 10px; margin-bottom:10px;", | |
| tags$b("Explorer location carried into this tab"), | |
| tags$br(), | |
| tags$small( | |
| paste0( | |
| "Longitude ", round(unname(pt[["lon"]]), 5), | |
| "; latitude ", round(unname(pt[["lat"]]), 5), | |
| ". The black-and-white point is a reference location only." | |
| ) | |
| ) | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Proposed greenspace drawing scenario | |
| # --------------------------------------------------------------------------- | |
| observeEvent(input$corridorMap_draw_start, { | |
| greenspace_draw_active(TRUE) | |
| }) | |
| observeEvent(input$corridorMap_draw_stop, { | |
| greenspace_draw_active(FALSE) | |
| }) | |
| observeEvent(input$corridorMap_draw_editstart, { | |
| greenspace_draw_active(TRUE) | |
| }) | |
| observeEvent(input$corridorMap_draw_editstop, { | |
| greenspace_draw_active(FALSE) | |
| }) | |
| observeEvent(input$corridorMap_draw_all_features, { | |
| proposed_greenspace( | |
| tryCatch( | |
| draw_features_to_sf(input$corridorMap_draw_all_features), | |
| error = function(e) { | |
| showNotification( | |
| paste("Could not read the proposed habitat drawing:", e$message), | |
| type = "error" | |
| ) | |
| NULL | |
| } | |
| ) | |
| ) | |
| }, ignoreNULL = FALSE) | |
| observeEvent(input$clear_proposed_greenspace, { | |
| proposed_greenspace(NULL) | |
| leafletProxy("corridorMap") |> | |
| clearGroup("Your Drawn Route or Shape") |> | |
| clearGroup("Your Proposed Habitat Area") |> | |
| clearGroup("Existing Greenspaces Reached") |> | |
| clearGroup("Measured Connection Gaps") |> | |
| clearGroup("Population Estimate Area (Optional)") | |
| }) | |
| # Convert all user drawings to habitat area in EPSG:3857. Polygon drawings are | |
| # used directly; line drawings are buffered by half the selected full corridor | |
| # width on each side. Geometry is unioned without carrying Leaflet attributes. | |
| proposed_habitat_3857 <- reactive({ | |
| proposal <- proposed_greenspace() | |
| if (is.null(proposal) || nrow(proposal) == 0) return(NULL) | |
| proposal_3857 <- proposal |> | |
| st_transform(3857) |> | |
| st_zm(drop = TRUE, what = "ZM") |> | |
| st_make_valid() | |
| geom_type <- as.character(st_geometry_type(proposal_3857)) | |
| polygon_idx <- geom_type %in% c("POLYGON", "MULTIPOLYGON") | |
| line_idx <- geom_type %in% c("LINESTRING", "MULTILINESTRING") | |
| parts <- list() | |
| if (any(polygon_idx)) { | |
| parts[[length(parts) + 1]] <- st_geometry(proposal_3857[polygon_idx, , drop = FALSE]) | |
| } | |
| if (any(line_idx)) { | |
| corridor_width <- suppressWarnings(as.numeric(input$corridor_width_m)) | |
| if (!is.finite(corridor_width) || corridor_width <= 0) corridor_width <- 10 | |
| parts[[length(parts) + 1]] <- st_buffer( | |
| st_geometry(proposal_3857[line_idx, , drop = FALSE]), | |
| dist = corridor_width / 2, | |
| endCapStyle = "ROUND", | |
| joinStyle = "ROUND" | |
| ) | |
| } | |
| if (length(parts) == 0) return(NULL) | |
| combined <- do.call(c, parts) | |
| proposal_union <- suppressWarnings(st_union(combined)) | |
| out <- st_sf(geometry = proposal_union, crs = 3857) |> st_make_valid() | |
| out <- out[!st_is_empty(out), , drop = FALSE] | |
| if (nrow(out) == 0) NULL else out | |
| }) | |
| # Remove portions of a proposal that already overlap mapped OSM greenspace. | |
| # This prevents double-counting when a user draws over an existing park. | |
| net_proposed_greenspace_3857 <- reactive({ | |
| proposal_sf <- proposed_habitat_3857() | |
| if (is.null(proposal_sf) || nrow(proposal_sf) == 0) return(NULL) | |
| if (is.null(osm_greenspace_3857) || nrow(osm_greenspace_3857) == 0) { | |
| return(proposal_sf) | |
| } | |
| hits <- st_intersects(proposal_sf, osm_greenspace_3857, sparse = TRUE)[[1]] | |
| if (length(hits) == 0) return(proposal_sf) | |
| existing_local <- st_union(st_geometry(osm_greenspace_3857[hits, , drop = FALSE])) | |
| net_geometry <- tryCatch( | |
| suppressWarnings(st_difference(st_geometry(proposal_sf), existing_local)), | |
| error = function(e) st_geometry(proposal_sf) | |
| ) | |
| out <- st_sf(geometry = net_geometry, crs = 3857) |> st_make_valid() | |
| out <- out[!st_is_empty(out), , drop = FALSE] | |
| if (nrow(out) == 0) NULL else out | |
| }) | |
| # Generic structural connectivity around the proposed intervention. Existing | |
| # OSM greenspace polygons are treated as habitat patches. The complete SF-wide | |
| # layer is searched, but only patches near the proposal are returned and drawn. | |
| # A user-selected gap threshold is a structural planning proxy rather than a | |
| # species-specific functional-connectivity model. | |
| corridor_connectivity <- reactive({ | |
| proposal_area <- proposed_habitat_3857() | |
| proposal_features <- proposed_greenspace() | |
| if (is.null(proposal_area) || nrow(proposal_area) == 0 || | |
| is.null(osm_greenspace_3857) || nrow(osm_greenspace_3857) == 0) { | |
| return(list( | |
| available = FALSE, | |
| message = "Draw a corridor line or habitat polygon to estimate connectivity." | |
| )) | |
| } | |
| gap_m <- suppressWarnings(as.numeric(input$connectivity_gap_m)) | |
| if (!is.finite(gap_m) || gap_m < 0) gap_m <- 100 | |
| existing_geom <- st_geometry(osm_greenspace_3857) |> st_make_valid() | |
| raw_names <- if ("name" %in% names(osm_greenspace_3857)) { | |
| as.character(osm_greenspace_3857$name) | |
| } else { | |
| rep(NA_character_, length(existing_geom)) | |
| } | |
| raw_names[is.na(raw_names) | trimws(raw_names) == ""] <- NA_character_ | |
| patches <- st_sf( | |
| patch_id = seq_along(existing_geom), | |
| patch_name = raw_names, | |
| geometry = existing_geom, | |
| crs = 3857 | |
| ) | |
| patches$patch_area_ha <- as.numeric(st_area(patches)) / 10000 | |
| nearby_idx <- which( | |
| lengths(st_is_within_distance(patches, proposal_area, dist = gap_m)) > 0 | |
| ) | |
| feature_types <- if (is.null(proposal_features)) { | |
| character(0) | |
| } else { | |
| as.character(st_geometry_type(proposal_features)) | |
| } | |
| line_idx <- feature_types %in% c("LINESTRING", "MULTILINESTRING") | |
| poly_idx <- feature_types %in% c("POLYGON", "MULTIPOLYGON") | |
| corridor_length_m <- 0 | |
| if (any(line_idx)) { | |
| corridor_length_m <- sum( | |
| as.numeric(st_length(st_transform(proposal_features[line_idx, , drop = FALSE], 3857))), | |
| na.rm = TRUE | |
| ) | |
| } | |
| novel <- net_proposed_greenspace_3857() | |
| novel_ha <- if (is.null(novel) || nrow(novel) == 0) { | |
| 0 | |
| } else { | |
| sum(as.numeric(st_area(novel)), na.rm = TRUE) / 10000 | |
| } | |
| if (length(nearby_idx) == 0) { | |
| nearest_idx <- tryCatch(st_nearest_feature(proposal_area, patches), error = function(e) integer(0)) | |
| nearest_patch_m <- if (length(nearest_idx) > 0 && is.finite(nearest_idx[[1]])) { | |
| tryCatch( | |
| as.numeric(st_distance(proposal_area, patches[nearest_idx[[1]], , drop = FALSE], by_element = TRUE)), | |
| error = function(e) NA_real_ | |
| ) | |
| } else { | |
| NA_real_ | |
| } | |
| return(list( | |
| available = TRUE, | |
| classification = "Isolated intervention", | |
| gap_m = gap_m, | |
| linked_patches = 0, | |
| overlapping_patches = 0, | |
| baseline_components = 0, | |
| scenario_components = 1, | |
| components_reduced = 0, | |
| nearest_patch_m = nearest_patch_m, | |
| maximum_linked_gap_m = NA_real_, | |
| existing_linked_area_ha = 0, | |
| novel_area_ha = novel_ha, | |
| scenario_connected_area_ha = novel_ha, | |
| corridor_length_m = corridor_length_m, | |
| drawn_patch_count = sum(poly_idx), | |
| linked_sf = patches[0, , drop = FALSE], | |
| links_sf = st_sf( | |
| display_id = character(0), | |
| distance_m = numeric(0), | |
| geometry = st_sfc(crs = 3857) | |
| ) | |
| )) | |
| } | |
| linked <- patches[nearby_idx, , drop = FALSE] | |
| proposal_union <- st_union(st_geometry(proposal_area)) | |
| prop_dist <- vapply( | |
| seq_len(nrow(linked)), | |
| function(i) { | |
| as.numeric(st_distance(st_geometry(linked)[i], proposal_union))[[1]] | |
| }, | |
| numeric(1) | |
| ) | |
| nearest_patch_m <- min(prop_dist, na.rm = TRUE) | |
| overlapping_patches <- sum(prop_dist <= 0.01, na.rm = TRUE) | |
| # Identify baseline network components among the affected existing patches. | |
| adjacency <- st_is_within_distance(linked, linked, dist = gap_m) | |
| n <- nrow(linked) | |
| visited <- rep(FALSE, n) | |
| component_id <- integer(n) | |
| baseline_components <- 0L | |
| for (i in seq_len(n)) { | |
| if (visited[i]) next | |
| baseline_components <- baseline_components + 1L | |
| queue <- i | |
| visited[i] <- TRUE | |
| component_id[i] <- baseline_components | |
| while (length(queue) > 0) { | |
| current <- queue[[1]] | |
| queue <- queue[-1] | |
| neighbors <- setdiff(adjacency[[current]], current) | |
| new_nodes <- neighbors[!visited[neighbors]] | |
| if (length(new_nodes) > 0) { | |
| visited[new_nodes] <- TRUE | |
| component_id[new_nodes] <- baseline_components | |
| queue <- c(queue, new_nodes) | |
| } | |
| } | |
| } | |
| linked$baseline_component <- component_id | |
| linked$distance_to_proposal_m <- prop_dist | |
| linked$relationship <- ifelse( | |
| prop_dist <= 0.01, | |
| "Direct overlap / expansion", | |
| paste0(round(prop_dist), " m gap") | |
| ) | |
| linked$display_id <- paste0("P", seq_len(nrow(linked))) | |
| linked$display_name <- ifelse( | |
| is.na(linked$patch_name), | |
| paste0("Unnamed mapped greenspace ", linked$display_id), | |
| linked$patch_name | |
| ) | |
| # Draw a dashed nearest-distance segment from the proposal to every affected | |
| # patch. These segments make the selected connection threshold visible. | |
| link_geoms <- lapply(seq_len(nrow(linked)), function(i) { | |
| tryCatch( | |
| st_nearest_points(proposal_union, st_geometry(linked)[i])[[1]], | |
| error = function(e) st_geometrycollection() | |
| ) | |
| }) | |
| links_sf <- st_sf( | |
| display_id = linked$display_id, | |
| patch_name = linked$display_name, | |
| distance_m = linked$distance_to_proposal_m, | |
| geometry = st_sfc(link_geoms, crs = 3857) | |
| ) | |
| links_sf <- links_sf[!st_is_empty(links_sf), , drop = FALSE] | |
| linked_area_ha <- as.numeric(st_area(st_union(st_geometry(linked)))) / 10000 | |
| maximum_linked_gap_m <- max(prop_dist, na.rm = TRUE) | |
| classification <- dplyr::case_when( | |
| nrow(linked) >= 2 && baseline_components >= 2 ~ "Bridge", | |
| nrow(linked) >= 2 && baseline_components == 1 ~ "Network reinforcement", | |
| nrow(linked) == 1 && overlapping_patches >= 1 ~ "Patch expansion", | |
| nrow(linked) >= 1 ~ "Stepping-stone", | |
| TRUE ~ "Isolated intervention" | |
| ) | |
| list( | |
| available = TRUE, | |
| classification = classification, | |
| gap_m = gap_m, | |
| linked_patches = nrow(linked), | |
| overlapping_patches = overlapping_patches, | |
| baseline_components = baseline_components, | |
| scenario_components = 1L, | |
| components_reduced = max(0L, baseline_components - 1L), | |
| nearest_patch_m = nearest_patch_m, | |
| maximum_linked_gap_m = maximum_linked_gap_m, | |
| existing_linked_area_ha = linked_area_ha, | |
| novel_area_ha = novel_ha, | |
| scenario_connected_area_ha = linked_area_ha + novel_ha, | |
| corridor_length_m = corridor_length_m, | |
| drawn_patch_count = sum(poly_idx), | |
| linked_sf = linked, | |
| links_sf = links_sf | |
| ) | |
| }) | |
| # Display the full buffered intervention footprint so line width is visible. | |
| observe({ | |
| footprint <- proposed_habitat_3857() | |
| proxy <- leafletProxy("corridorMap") |> clearGroup("Your Proposed Habitat Area") | |
| if (!is.null(footprint) && nrow(footprint) > 0) { | |
| width_m <- suppressWarnings(as.numeric(input$corridor_width_m)) | |
| if (!is.finite(width_m) || width_m <= 0) width_m <- 10 | |
| mode <- corridor_drawing_mode() | |
| gross_area_ha <- sum(as.numeric(st_area(footprint)), na.rm = TRUE) / 10000 | |
| width_text <- if (mode %in% c("line", "mixed")) { | |
| paste0("Assumed total line width: ", width_m, " m") | |
| } else { | |
| "Polygon area follows the shape you drew; line width is not applied" | |
| } | |
| footprint_map <- st_transform(footprint, 4326) | |
| footprint_map$popup_html <- paste0( | |
| "<strong>Your proposed habitat area</strong>", | |
| "<br>", width_text, | |
| "<br>Modeled footprint area: ", round(gross_area_ha, 2), " ha", | |
| "<br><em>Zoom in to distinguish narrow width choices.</em>" | |
| ) | |
| proxy |> addPolygons( | |
| data = footprint_map, | |
| group = "Your Proposed Habitat Area", | |
| color = "#0d47a1", weight = 4, | |
| fillColor = "#42a5f5", fillOpacity = 0.42, | |
| label = if (mode %in% c("line", "mixed")) { | |
| paste0("Proposed habitat: ", width_m, " m total width") | |
| } else { | |
| "Your proposed habitat polygon" | |
| }, | |
| popup = ~popup_html, | |
| options = pathOptions(pane = "corridorProposalPane") | |
| ) | |
| } | |
| }) | |
| # Highlight the specific existing greenspaces affected by the proposal and | |
| # draw the threshold links used by the connectivity calculation. | |
| observe({ | |
| connectivity <- corridor_connectivity() | |
| proxy <- leafletProxy("corridorMap") |> | |
| clearGroup("Existing Greenspaces Reached") |> | |
| clearGroup("Measured Connection Gaps") | |
| if (is.null(connectivity) || !isTRUE(connectivity$available) || | |
| is.null(connectivity$linked_sf) || nrow(connectivity$linked_sf) == 0) { | |
| return(invisible(NULL)) | |
| } | |
| linked_map <- st_transform(connectivity$linked_sf, 4326) | |
| linked_map$popup_html <- paste0( | |
| "<strong>", linked_map$display_id, ": ", linked_map$display_name, "</strong>", | |
| "<br>Relationship: ", linked_map$relationship, | |
| "<br>Existing area: ", round(linked_map$patch_area_ha, 2), " ha", | |
| "<br>Baseline network: ", linked_map$baseline_component | |
| ) | |
| proxy <- proxy |> | |
| addPolygons( | |
| data = linked_map, | |
| group = "Existing Greenspaces Reached", | |
| color = "#e65100", | |
| weight = 4, | |
| fillColor = "#ffb74d", | |
| fillOpacity = 0.48, | |
| label = ~paste0(display_id, ": ", display_name), | |
| popup = ~popup_html, | |
| highlightOptions = highlightOptions( | |
| weight = 6, | |
| color = "#bf360c", | |
| fillOpacity = 0.58, | |
| bringToFront = TRUE | |
| ) | |
| ) | |
| label_points <- suppressWarnings(st_point_on_surface(linked_map)) | |
| proxy <- proxy |> | |
| addLabelOnlyMarkers( | |
| data = label_points, | |
| group = "Existing Greenspaces Reached", | |
| label = ~display_id, | |
| labelOptions = labelOptions( | |
| noHide = TRUE, | |
| direction = "center", | |
| textOnly = TRUE, | |
| style = list( | |
| "font-weight" = "bold", | |
| "font-size" = "12px", | |
| "color" = "#7f2704", | |
| "background" = "rgba(255,255,255,0.88)", | |
| "border" = "1px solid #e65100", | |
| "padding" = "1px 4px" | |
| ) | |
| ) | |
| ) | |
| if (!is.null(connectivity$links_sf) && nrow(connectivity$links_sf) > 0) { | |
| links_map <- st_transform(connectivity$links_sf, 4326) | |
| proxy <- proxy |> | |
| addPolylines( | |
| data = links_map, | |
| group = "Measured Connection Gaps", | |
| color = "#ef6c00", | |
| weight = 3, | |
| opacity = 0.9, | |
| dashArray = "6,7", | |
| label = ~paste0( | |
| display_id, | |
| ifelse(distance_m <= 0.01, ": direct overlap", paste0(": ", round(distance_m), " m gap")) | |
| ) | |
| ) | |
| } | |
| }) | |
| # At city scale, a 2--40 m buffer can be only a few screen pixels wide. | |
| # This action centers the map on the middle of a drawn line at street scale so | |
| # the selected total planted width can be inspected directly. | |
| observeEvent(input$inspect_corridor_width, { | |
| proposal <- proposed_greenspace() | |
| if (is.null(proposal) || nrow(proposal) == 0) { | |
| showNotification("Draw a line first, then use this button to inspect its planted width.", type = "warning") | |
| return(invisible(NULL)) | |
| } | |
| proposal_3857 <- tryCatch( | |
| proposal |> st_transform(3857) |> st_zm(drop = TRUE, what = "ZM"), | |
| error = function(e) NULL | |
| ) | |
| if (is.null(proposal_3857) || nrow(proposal_3857) == 0) return(invisible(NULL)) | |
| types <- as.character(st_geometry_type(proposal_3857)) | |
| line_idx <- types %in% c("LINESTRING", "MULTILINESTRING") | |
| if (!any(line_idx)) { | |
| showNotification( | |
| "The width control applies only to line drawings. Your polygon already defines its own area.", | |
| type = "message" | |
| ) | |
| return(invisible(NULL)) | |
| } | |
| line_geom <- suppressWarnings(st_union(st_geometry(proposal_3857[line_idx, , drop = FALSE]))) | |
| inspect_point <- tryCatch( | |
| st_line_sample(line_geom, sample = 0.5), | |
| error = function(e) st_centroid(line_geom) | |
| ) | |
| if (length(inspect_point) == 0 || all(st_is_empty(inspect_point))) { | |
| inspect_point <- st_centroid(line_geom) | |
| } | |
| inspect_sf <- st_sf(geometry = inspect_point, crs = 3857) |> st_transform(4326) | |
| coords <- st_coordinates(inspect_sf) | |
| if (nrow(coords) == 0) return(invisible(NULL)) | |
| width_m <- suppressWarnings(as.numeric(input$corridor_width_m)) | |
| if (!is.finite(width_m) || width_m <= 0) width_m <- 10 | |
| leafletProxy("corridorMap") |> | |
| showGroup("Your Drawn Route or Shape") |> | |
| showGroup("Your Proposed Habitat Area") |> | |
| setView(lng = coords[1, "X"], lat = coords[1, "Y"], zoom = 19) | |
| showNotification( | |
| paste0( | |
| "Street-scale view: the blue footprint is ", width_m, | |
| " m wide in total (", width_m / 2, " m on each side)." | |
| ), | |
| type = "message", | |
| duration = 5 | |
| ) | |
| invisible(NULL) | |
| }) | |
| # One stakeholder-facing action produces a clean corridor view and zooms to | |
| # the proposal plus the existing patches it affects. | |
| observeEvent(input$show_corridor_result, { | |
| footprint <- proposed_habitat_3857() | |
| connectivity <- corridor_connectivity() | |
| if (is.null(footprint) || nrow(footprint) == 0) { | |
| showNotification("Draw a corridor line or habitat polygon first.", type = "warning") | |
| return() | |
| } | |
| proxy <- leafletProxy("corridorMap") |> | |
| showGroup("Existing SF Greenspace") |> | |
| showGroup("Your Drawn Route or Shape") |> | |
| showGroup("Your Proposed Habitat Area") |> | |
| showGroup("Existing Greenspaces Reached") |> | |
| showGroup("Measured Connection Gaps") | |
| focus_parts <- list(st_geometry(footprint)) | |
| if (!is.null(connectivity$linked_sf) && nrow(connectivity$linked_sf) > 0) { | |
| focus_parts[[length(focus_parts) + 1]] <- st_geometry(connectivity$linked_sf) | |
| } | |
| focus_geom <- do.call(c, focus_parts) | |
| focus_sf <- st_sf(geometry = focus_geom, crs = 3857) |> st_transform(4326) | |
| bb <- st_bbox(focus_sf) | |
| pad_x <- max((bb[["xmax"]] - bb[["xmin"]]) * 0.15, 0.002) | |
| pad_y <- max((bb[["ymax"]] - bb[["ymin"]]) * 0.15, 0.002) | |
| proxy |> fitBounds( | |
| lng1 = bb[["xmin"]] - pad_x, | |
| lat1 = bb[["ymin"]] - pad_y, | |
| lng2 = bb[["xmax"]] + pad_x, | |
| lat2 = bb[["ymax"]] + pad_y | |
| ) | |
| }) | |
| # Straight-line walking catchment around the novel proposed greenspace. This is | |
| # intentionally labelled as an approximation: it uses the app's existing | |
| # 80 m/min walking assumption rather than a routed pedestrian network. | |
| scenario_beneficiary_catchment_3857 <- reactive({ | |
| proposal_net <- net_proposed_greenspace_3857() | |
| if (is.null(proposal_net) || nrow(proposal_net) == 0) return(NULL) | |
| walk_min <- suppressWarnings(as.numeric(input$beneficiary_walk_min)) | |
| if (!is.finite(walk_min) || walk_min <= 0) return(NULL) | |
| catchment_geom <- st_geometry(proposal_net) |> | |
| st_union() |> | |
| st_buffer(dist = walk_min * walk_speed_m_per_min) |> | |
| st_make_valid() | |
| out <- st_sf( | |
| walk_minutes = walk_min, | |
| geometry = catchment_geom, | |
| crs = 3857 | |
| ) | |
| out <- out[!st_is_empty(out), , drop = FALSE] | |
| if (nrow(out) == 0) NULL else out | |
| }) | |
| # Area-weighted beneficiary estimates. Population is apportioned by the share | |
| # of each census block group covered by the catchment, which assumes residents | |
| # are uniformly distributed within each block group. EJ beneficiaries are the | |
| # portion of that population also falling inside the SF EJ-community polygons. | |
| scenario_beneficiaries <- reactive({ | |
| catchment <- scenario_beneficiary_catchment_3857() | |
| if (is.null(catchment) || nrow(catchment) == 0) { | |
| proposal <- proposed_greenspace() | |
| msg <- if (!is.null(proposal) && nrow(proposal) > 0) { | |
| "No novel proposed area remains after overlap with existing OSM greenspace is removed." | |
| } else { | |
| "Draw proposed greenspace to estimate beneficiaries." | |
| } | |
| return(list(available = FALSE, message = msg)) | |
| } | |
| required_cbg_cols <- c("GEOID", "popE", "medincE") | |
| if (!inherits(cbg_vect_sf, "sf") || !all(required_cbg_cols %in% names(cbg_vect_sf))) { | |
| return(list( | |
| available = FALSE, | |
| message = "Beneficiary estimates require GEOID, popE, and medincE in cbg_vect_sf." | |
| )) | |
| } | |
| cbg_3857 <- cbg_vect_sf |> | |
| dplyr::select(dplyr::all_of(required_cbg_cols)) |> | |
| st_transform(3857) |> | |
| st_make_valid() |> | |
| mutate(cbg_area_m2 = as.numeric(st_area(geometry))) | |
| catchment_geom <- st_geometry(catchment) | |
| cbg_inter <- tryCatch( | |
| suppressWarnings(st_intersection(cbg_3857, catchment_geom)), | |
| error = function(e) NULL | |
| ) | |
| if (is.null(cbg_inter) || nrow(cbg_inter) == 0) { | |
| return(list( | |
| available = TRUE, | |
| walk_minutes = catchment$walk_minutes[[1]], | |
| walk_distance_m = catchment$walk_minutes[[1]] * walk_speed_m_per_min, | |
| catchment_area_km2 = as.numeric(st_area(catchment_geom)) / 1e6, | |
| total_population = 0, | |
| ej_population = 0, | |
| ej_share = NA_real_, | |
| ej_vulnerability_threshold = NA_real_, | |
| ej_vulnerability_percentile = 75, | |
| low_access_population = 0, | |
| low_access_threshold = median(city_benchmarks$greenspace_cover, na.rm = TRUE), | |
| weighted_income = NA_real_, | |
| mean_ej_score = NA_real_, | |
| n_cbgs = 0 | |
| )) | |
| } | |
| cbg_inter <- cbg_inter |> | |
| mutate( | |
| inter_area_m2 = as.numeric(st_area(geometry)), | |
| area_fraction = pmin(1, pmax(0, inter_area_m2 / cbg_area_m2)), | |
| served_population = pmax(0, as.numeric(popE)) * area_fraction | |
| ) | |
| # Attach existing CBG greenspace cover so beneficiaries in relatively | |
| # low-greenspace areas can be reported. The threshold is the citywide median. | |
| low_access_threshold <- median(city_benchmarks$greenspace_cover, na.rm = TRUE) | |
| if (exists("cbg_greenspace_coverage") && | |
| !is.null(cbg_greenspace_coverage) && | |
| all(c("GEOID", "greenspace_m2", "cbg_area_m2") %in% names(cbg_greenspace_coverage))) { | |
| gs_lookup <- cbg_greenspace_coverage |> | |
| transmute( | |
| GEOID = as.character(GEOID), | |
| existing_greenspace_pct = ifelse( | |
| cbg_area_m2 > 0, | |
| 100 * greenspace_m2 / cbg_area_m2, | |
| NA_real_ | |
| ) | |
| ) | |
| cbg_inter <- cbg_inter |> | |
| mutate(GEOID = as.character(GEOID)) |> | |
| left_join(gs_lookup, by = "GEOID") | |
| } else { | |
| cbg_inter$existing_greenspace_pct <- NA_real_ | |
| } | |
| total_population <- sum(cbg_inter$served_population, na.rm = TRUE) | |
| weighted_income <- if (total_population > 0) { | |
| sum(cbg_inter$medincE * cbg_inter$served_population, na.rm = TRUE) / | |
| sum(cbg_inter$served_population[is.finite(cbg_inter$medincE)], na.rm = TRUE) | |
| } else { | |
| NA_real_ | |
| } | |
| if (!is.finite(weighted_income)) weighted_income <- NA_real_ | |
| low_access_population <- if (is.finite(low_access_threshold)) { | |
| sum( | |
| cbg_inter$served_population[ | |
| is.finite(cbg_inter$existing_greenspace_pct) & | |
| cbg_inter$existing_greenspace_pct < low_access_threshold | |
| ], | |
| na.rm = TRUE | |
| ) | |
| } else { | |
| NA_real_ | |
| } | |
| ej_population <- 0 | |
| mean_ej_score <- NA_real_ | |
| ej_vulnerability_threshold <- NA_real_ | |
| ej_vulnerability_percentile <- 75 | |
| if (!is.null(sf_ej_sf) && inherits(sf_ej_sf, "sf") && nrow(sf_ej_sf) > 0) { | |
| ej_all_3857 <- sf_ej_sf |> | |
| st_transform(3857) |> | |
| st_make_valid() | |
| if ("score" %in% names(ej_all_3857)) { | |
| city_ej_scores <- suppressWarnings(as.numeric(ej_all_3857$score)) | |
| city_ej_scores <- city_ej_scores[is.finite(city_ej_scores)] | |
| if (length(city_ej_scores) > 0) { | |
| ej_vulnerability_threshold <- as.numeric( | |
| stats::quantile( | |
| city_ej_scores, | |
| probs = 0.75, | |
| na.rm = TRUE, | |
| names = FALSE, | |
| type = 7 | |
| ) | |
| ) | |
| } | |
| ej_vulnerable_3857 <- ej_all_3857 |> | |
| dplyr::filter( | |
| is.finite(score), | |
| score > 0, | |
| is.finite(ej_vulnerability_threshold), | |
| score >= ej_vulnerability_threshold | |
| ) | |
| } else { | |
| ej_vulnerable_3857 <- ej_all_3857[0, , drop = FALSE] | |
| } | |
| vulnerable_union <- if (nrow(ej_vulnerable_3857) > 0) { | |
| st_union(st_geometry(ej_vulnerable_3857)) | |
| } else { | |
| st_sfc(crs = 3857) | |
| } | |
| vulnerable_catchment <- tryCatch( | |
| suppressWarnings(st_intersection(catchment_geom, vulnerable_union)), | |
| error = function(e) st_sfc(crs = 3857) | |
| ) | |
| if (length(vulnerable_catchment) > 0 && | |
| !all(st_is_empty(vulnerable_catchment))) { | |
| ej_cbg_inter <- tryCatch( | |
| suppressWarnings(st_intersection(cbg_3857, vulnerable_catchment)), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(ej_cbg_inter) && nrow(ej_cbg_inter) > 0) { | |
| ej_cbg_inter <- ej_cbg_inter |> | |
| mutate( | |
| inter_area_m2 = as.numeric(st_area(geometry)), | |
| area_fraction = pmin(1, pmax(0, inter_area_m2 / cbg_area_m2)), | |
| served_population = pmax(0, as.numeric(popE)) * area_fraction | |
| ) | |
| ej_population <- sum(ej_cbg_inter$served_population, na.rm = TRUE) | |
| } | |
| } | |
| if ("score" %in% names(ej_all_3857)) { | |
| ej_score_inter <- tryCatch( | |
| suppressWarnings(st_intersection(ej_all_3857[, "score"], catchment_geom)), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(ej_score_inter) && nrow(ej_score_inter) > 0) { | |
| ej_score_inter$area_m2 <- as.numeric(st_area(ej_score_inter)) | |
| valid <- is.finite(ej_score_inter$score) & ej_score_inter$area_m2 > 0 | |
| if (any(valid)) { | |
| mean_ej_score <- weighted.mean( | |
| ej_score_inter$score[valid], | |
| ej_score_inter$area_m2[valid], | |
| na.rm = TRUE | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| list( | |
| available = TRUE, | |
| walk_minutes = catchment$walk_minutes[[1]], | |
| walk_distance_m = catchment$walk_minutes[[1]] * walk_speed_m_per_min, | |
| catchment_area_km2 = as.numeric(st_area(catchment_geom)) / 1e6, | |
| total_population = total_population, | |
| ej_population = min(ej_population, total_population), | |
| ej_share = if (total_population > 0) 100 * min(ej_population, total_population) / total_population else NA_real_, | |
| ej_vulnerability_threshold = ej_vulnerability_threshold, | |
| ej_vulnerability_percentile = ej_vulnerability_percentile, | |
| low_access_population = low_access_population, | |
| low_access_threshold = low_access_threshold, | |
| weighted_income = weighted_income, | |
| mean_ej_score = mean_ej_score, | |
| n_cbgs = dplyr::n_distinct(cbg_inter$GEOID) | |
| ) | |
| }) | |
| # Draw the beneficiary catchment as a separate, toggleable map layer. | |
| observe({ | |
| catchment <- scenario_beneficiary_catchment_3857() | |
| proxy <- leafletProxy("corridorMap") |> | |
| clearGroup("Population Estimate Area (Optional)") | |
| if (isTRUE(input$show_beneficiary_catchment) && | |
| !is.null(catchment) && nrow(catchment) > 0) { | |
| catchment_4326 <- st_transform(catchment, 4326) | |
| proxy |> | |
| addPolygons( | |
| data = catchment_4326, | |
| group = "Population Estimate Area (Optional)", | |
| color = "#6a1b9a", | |
| weight = 2, | |
| dashArray = "6, 4", | |
| fillColor = "#ce93d8", | |
| fillOpacity = 0.14, | |
| label = paste0( | |
| catchment$walk_minutes[[1]], | |
| "-minute population-estimate area (approximate)" | |
| ) | |
| ) | |
| } | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Location selection | |
| # --------------------------------------------------------------------------- | |
| observeEvent(input$isoMap_click, { | |
| req(input$location_choice == "map_click") | |
| req(!isTRUE(greenspace_draw_active())) | |
| click <- input$isoMap_click | |
| if (!is.null(click)) { | |
| chosen_point(c(lon = click$lng, lat = click$lat)) | |
| leafletProxy("isoMap") |> | |
| clearGroup("selected_point") |> | |
| addCircleMarkers( | |
| lng = click$lng, lat = click$lat, | |
| radius = 6, color = "firebrick", | |
| group = "selected_point", label = "Map Click Location" | |
| ) | |
| } | |
| }) | |
| observeEvent(input$geocoder, { | |
| req(input$location_choice == "address") | |
| geocode_result <- input$geocoder | |
| if (!is.null(geocode_result)) { | |
| xy <- geocoder_as_xy(geocode_result) | |
| chosen_point(c(lon = xy[1], lat = xy[2])) | |
| leafletProxy("isoMap") |> | |
| clearGroup("selected_point") |> | |
| addCircleMarkers( | |
| lng = xy[1], lat = xy[2], | |
| radius = 6, color = "navy", | |
| group = "selected_point", label = "Geocoded Address" | |
| ) |> | |
| flyTo(lng = xy[1], lat = xy[2], zoom = 13) | |
| } | |
| }) | |
| observeEvent(input$clear_map, { | |
| chosen_point(NULL) | |
| iso_store$data <- NULL | |
| leafletProxy("isoMap") |> | |
| removeControl("ndvi_legend") |> | |
| clearGroup("selected_point") |> | |
| clearGroup("Isochrones") |> | |
| clearGroup("Transit Isochrones") |> | |
| clearGroup("Muni Stops Reached") |> | |
| clearGroup("NDVI Raster") | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Isochrone generation | |
| # --------------------------------------------------------------------------- | |
| # iso_store holds the computed sf; only written inside observeEvent(input$generate_iso). | |
| # Nothing can write to it except the button press, so map clicks cannot trigger | |
| # Mapbox calls regardless of what other reactives read isochrones_data(). | |
| iso_store <- reactiveValues(data = NULL) | |
| isochrones_data <- reactive({ iso_store$data }) | |
| # Flag for the conditionalPanel that reveals the spider plot once isochrones | |
| # exist. suspendWhenHidden = FALSE so the condition is evaluated while the box | |
| # is still hidden. | |
| output$iso_ready <- reactive({ | |
| d <- isochrones_data() | |
| !is.null(d) && nrow(d) > 0 | |
| }) | |
| outputOptions(output, "iso_ready", suspendWhenHidden = FALSE) | |
| output$proposal_ready <- reactive({ | |
| d <- proposed_greenspace() | |
| !is.null(d) && nrow(d) > 0 | |
| }) | |
| outputOptions(output, "proposal_ready", suspendWhenHidden = FALSE) | |
| # build_isochrones() lives in the sourced helper and its signature is shared | |
| # with the comparer tab, so the wait toggle is passed as an option rather than | |
| # threaded through as another argument. | |
| observeEvent(input$transit_charge_wait, { | |
| options(bas.transit_charge_wait = isTRUE(input$transit_charge_wait)) | |
| }, ignoreNULL = FALSE) | |
| observeEvent(input$generate_iso, { | |
| options(bas.transit_charge_wait = isTRUE(input$transit_charge_wait)) | |
| pt <- chosen_point() | |
| if (is.null(pt)) return() | |
| if (length(input$transport_modes) == 0) return() | |
| if (length(input$iso_times) == 0) return() | |
| # A time budget with no precomputed reference still produces a valid | |
| # isochrone and valid raw metrics, but add_reference_bai() has nothing to | |
| # rank it against, so the Biodiversity Access Index comes back NA. Say so | |
| # rather than showing a blank index. | |
| no_ref <- setdiff(as.numeric(input$iso_times), REFERENCE_TIMES) | |
| if (length(no_ref)) { | |
| showNotification( | |
| HTML(paste0("<b>", paste(no_ref, collapse = ", "), "-minute isochrones have no ", | |
| "citywide reference yet.</b> The map and the raw metrics are correct; the ", | |
| "Biodiversity Access Index needs same-mode, same-time percentiles and will ", | |
| "be blank for ", ifelse(length(no_ref) > 1, "those budgets", "that budget"), | |
| " until <code>code/prep/extend_reference.R</code> is run.")), | |
| type = "warning", duration = 12) | |
| } | |
| leafletProxy("isoMap") |> | |
| removeControl("ndvi_legend") |> | |
| clearGroup("Isochrones") |> | |
| clearGroup("Transit Isochrones") |> | |
| clearGroup("Muni Stops Reached") |> | |
| clearGroup("NDVI Raster") | |
| # All isochrone construction lives in build_isochrones() (code/iso_metrics_AUDITED.R) | |
| # so the comparer tab builds isochrones the exact same way. | |
| iso_store$data <- build_isochrones( | |
| point = pt, | |
| modes = input$transport_modes, | |
| times = input$iso_times, | |
| transit_hour = input$transit_hour, | |
| walk_to_stop_min = input$walk_to_stop_min, | |
| walk_from_stop_min = input$walk_from_stop_min, | |
| transit_departure_window_min = input$transit_departure_window_min | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Render isochrones and NDVI | |
| # --------------------------------------------------------------------------- | |
| observeEvent(isochrones_data(), { | |
| iso_data <- isochrones_data() | |
| req(iso_data) | |
| leafletProxy("isoMap") |> | |
| removeControl("ndvi_legend") |> | |
| clearGroup("Isochrones") |> | |
| clearGroup("Transit Isochrones") |> | |
| clearGroup("Muni Stops Reached") |> | |
| clearGroup("NDVI Raster") | |
| standard_like_modes <- c("driving", "walking", "cycling", "driving-traffic", "walk_transit") | |
| standard_iso <- iso_data[iso_data$mode %in% standard_like_modes, ] | |
| if (nrow(standard_iso) > 0) { | |
| for (i in seq_len(nrow(standard_iso))) { | |
| poly_i <- standard_iso[i, ] | |
| mode_i <- as.character(poly_i$mode[[1]]) | |
| time_i <- as.numeric(poly_i$time[[1]]) | |
| mode_label <- pretty_mode(mode_i) | |
| is_walk_transit <- identical(mode_i, "walk_transit") | |
| line_weight <- if (is_walk_transit) 3 else 2 | |
| dash_style <- if (is_walk_transit) "5, 5" else NULL | |
| fill_alpha <- if (is_walk_transit) 0.28 else 0.35 | |
| color_i <- unname(mode_palette[mode_label]) | |
| if (is.na(color_i) || length(color_i) == 0) color_i <- "#666666" | |
| popup_i <- if (is_walk_transit) { | |
| paste0( | |
| "<strong>Walk + Transit β ", time_i, " min</strong>", | |
| "<br><b>First-mile walk:</b> up to ", input$walk_to_stop_min, " min", | |
| "<br><b>Transit:</b> uses remaining budget after first-mile access", | |
| "<br><b>Departure flexibility window:</b> ", input$transit_departure_window_min, " min", | |
| "<br><b>Last-mile walk:</b> up to ", input$walk_from_stop_min, " min", | |
| "<br><small>Built from reachable transit stops plus walking-network access.</small>" | |
| ) | |
| } else { | |
| paste0("<strong>", mode_label, " β ", time_i, " min</strong>") | |
| } | |
| leafletProxy("isoMap") |> | |
| addPolygons( | |
| data = poly_i, | |
| group = "Isochrones", | |
| color = color_i, | |
| weight = line_weight, | |
| dashArray = dash_style, | |
| fillOpacity = fill_alpha, | |
| label = paste0(mode_label, " β ", time_i, " mins"), | |
| popup = popup_i | |
| ) | |
| } | |
| } | |
| transit_iso <- iso_data[iso_data$mode == "transit", ] | |
| if (nrow(transit_iso) > 0 && !is.null(gtfs_stops_sf)) { | |
| for (i in seq_len(nrow(transit_iso))) { | |
| poly_i <- transit_iso[i, ] | |
| time_i <- as.numeric(poly_i$time[[1]]) | |
| # keep the stops themselves, not just the count: they are drawn below so | |
| # the user can see WHERE the isochrone lets them get off and walk out. | |
| stops_in <- tryCatch(suppressWarnings(st_intersection(gtfs_stops_sf, poly_i)), | |
| error = function(e) NULL) | |
| n_stops_in <- if (is.null(stops_in)) 0 else nrow(stops_in) | |
| area_km2 <- round(as.numeric(st_area(st_transform(poly_i, 3857))) / 1e6, 2) | |
| t_score <- if (area_km2 > 0) round(n_stops_in / area_km2, 2) else NA_real_ | |
| iso_wkt_t <- st_as_text(st_geometry(poly_i)[[1]]) | |
| gbif_t <- tryCatch({ | |
| gbif_tab |> | |
| filter(sql(glue("ST_Intersects(ST_GeomFromText(geom_wkt), ST_GeomFromText('{iso_wkt_t}'))"))) |> | |
| summarise(n_records = n(), n_species = n_distinct(species)) |> | |
| collect() | |
| }, error = function(e) NULL) | |
| n_gbif_rec <- if (!is.null(gbif_t) && nrow(gbif_t) > 0) gbif_t$n_records[[1]] else 0L | |
| n_gbif_sp <- if (!is.null(gbif_t) && nrow(gbif_t) > 0) gbif_t$n_species[[1]] else 0L | |
| popup_html <- paste0( | |
| "<strong>Transit Isochrone β ", time_i, " min</strong>", | |
| "<br><b>Transit Access Score:</b> ", t_score, " stops/kmΒ²", | |
| "<br><b>Muni stops within:</b> ", n_stops_in, | |
| "<br><b>Area:</b> ", area_km2, " kmΒ²", | |
| "<br><b>GBIF records within:</b> ", n_gbif_rec, | |
| "<br><b>Unique species:</b> ", n_gbif_sp | |
| ) | |
| leafletProxy("isoMap") |> | |
| addPolygons( | |
| data = poly_i, | |
| group = "Transit Isochrones", | |
| color = mode_palette[["Transit"]], | |
| weight = 2, | |
| dashArray = "6, 4", | |
| fillOpacity = 0.22, | |
| label = paste0("Transit ", time_i, " min"), | |
| popup = popup_html | |
| ) | |
| # Draw the reachable stops only for the LARGEST budget, which is a | |
| # superset of the smaller ones - drawing them per budget would stack | |
| # several markers on the same stop. | |
| if (!is.null(stops_in) && nrow(stops_in) > 0 && | |
| isTRUE(time_i == max(as.numeric(transit_iso$time), na.rm = TRUE))) { | |
| leafletProxy("isoMap") |> | |
| addCircleMarkers( | |
| data = stops_in, | |
| group = "Muni Stops Reached", | |
| radius = 3.5, weight = 1, | |
| color = "#20456e", fillColor = "#4a90d9", fillOpacity = 0.9, | |
| label = ~paste0(stop_name, " (", stop_id, ")"), | |
| popup = ~paste0("<strong>", stop_name, "</strong><br>Stop ID: ", stop_id, | |
| "<br><small>Reachable within ", time_i, | |
| " min, including the walk at each end.</small>") | |
| ) | |
| } | |
| } | |
| } | |
| if (exists("ndvi")) { | |
| iso_union <- st_union(iso_data) | |
| iso_union_vect <- vect(iso_union) | |
| ndvi_crop <- terra::crop(ndvi, iso_union_vect) | |
| ndvi_mask <- terra::mask(ndvi_crop, iso_union_vect) | |
| ndvi_vals <- values(ndvi_mask) | |
| ndvi_vals <- ndvi_vals[!is.na(ndvi_vals)] | |
| if (length(ndvi_vals) > 0) { | |
| ndvi_pal <- colorNumeric("YlGn", domain = range(ndvi_vals, na.rm = TRUE), na.color = "transparent") | |
| leafletProxy("isoMap") |> | |
| addRasterImage(x = ndvi_mask, colors = ndvi_pal, opacity = 0.7, project = TRUE, group = "NDVI Raster") |> | |
| addLegend( | |
| position = "bottomright", pal = ndvi_pal, values = ndvi_vals, title = "NDVI", | |
| layerId = "ndvi_legend" | |
| ) | |
| } | |
| } | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Per-isochrone summaries | |
| # --------------------------------------------------------------------------- | |
| base_socio_data <- reactive({ | |
| iso_data <- isochrones_data() | |
| if (is.null(iso_data) || nrow(iso_data) == 0) return(data.frame()) | |
| # compute_iso_metrics() preserves one row per generated isochrone. Immediately | |
| # score EACH row independently against the matching precomputed mode Γ time | |
| # distribution. No union/mean across generated isochrones enters percentile | |
| # calculation. | |
| raw_metrics <- quiet_sf_overlay( | |
| compute_iso_metrics(iso_data, isolate(chosen_point()), gbif_tab) | |
| ) | |
| score_iso_metrics_against_reference(raw_metrics) | |
| }) | |
| # Apply the planning scenario to each isochrone. The drawn area modifies only | |
| # Greenspace_percent; all observed biodiversity, transit, NDVI and equity values | |
| # remain unchanged. Each scenario row is then re-ranked against the SAME mode Γ | |
| # time precomputed reference and its own per-isochrone composite is updated. | |
| socio_data <- reactive({ | |
| base <- base_socio_data() | |
| if (is.null(base) || nrow(base) == 0) return(base) | |
| base$Baseline_Greenspace_percent <- base$Greenspace_percent | |
| base$Added_Greenspace_m2 <- 0 | |
| base$Added_Greenspace_percent <- 0 | |
| proposal_net <- net_proposed_greenspace_3857() | |
| if (is.null(proposal_net) || nrow(proposal_net) == 0) return(base) | |
| # compute_iso_metrics() returns a non-spatial data.frame. Use the original | |
| # isochrone sf object for geometry and keep the metrics table separate. | |
| iso_sf <- isochrones_data() | |
| if (is.null(iso_sf) || !inherits(iso_sf, "sf") || nrow(iso_sf) == 0) { | |
| return(base) | |
| } | |
| # compute_iso_metrics() is expected to preserve one row per isochrone in the | |
| # same order. Fail safely rather than calling an sf method on the metrics table. | |
| if (nrow(iso_sf) != nrow(base)) { | |
| warning( | |
| "Proposed-greenspace scenario skipped: metric rows (", nrow(base), | |
| ") do not match isochrone rows (", nrow(iso_sf), ")." | |
| ) | |
| return(base) | |
| } | |
| # Greenspace is now a 100 m grid-supported metric in BOTH the live app and | |
| # Step 5. Therefore the scenario must modify the same support: the union of | |
| # grid cells whose centroids fall within each individual isochrone. | |
| if (!("MetricSupportArea_km2" %in% names(base))) { | |
| warning("Scenario skipped: MetricSupportArea_km2 is missing from harmonized metrics.") | |
| return(base) | |
| } | |
| support_area_m2 <- as.numeric(base$MetricSupportArea_km2) * 1e6 | |
| proposal_geometry <- st_geometry(proposal_net) | |
| added_m2 <- vapply(seq_len(nrow(iso_sf)), function(i) { | |
| support_geom_i <- tryCatch( | |
| iso_metric_support_geometry(iso_sf[i, , drop = FALSE]), | |
| error = function(e) st_sfc(crs = 3857) | |
| ) | |
| if (length(support_geom_i) == 0) return(0) | |
| proposal_geometry_i <- tryCatch( | |
| st_transform(st_sf(geometry = proposal_geometry), st_crs(support_geom_i)) |> st_geometry(), | |
| error = function(e) st_sfc(crs = st_crs(support_geom_i)) | |
| ) | |
| intersection_i <- tryCatch( | |
| suppressWarnings(st_intersection(support_geom_i, proposal_geometry_i)), | |
| error = function(e) st_sfc(crs = st_crs(support_geom_i)) | |
| ) | |
| if (length(intersection_i) == 0) return(0) | |
| sum(as.numeric(st_area(intersection_i)), na.rm = TRUE) | |
| }, numeric(1)) | |
| added_pct <- ifelse( | |
| is.finite(support_area_m2) & support_area_m2 > 0, | |
| 100 * added_m2 / support_area_m2, | |
| NA_real_ | |
| ) | |
| base$Added_Greenspace_m2 <- added_m2 | |
| base$Added_Greenspace_percent <- added_pct | |
| base$Greenspace_percent <- ifelse( | |
| is.na(base$Baseline_Greenspace_percent), | |
| NA_real_, | |
| pmin(100, base$Baseline_Greenspace_percent + added_pct) | |
| ) | |
| # compute_iso_metrics() stores several summary values as custom attributes. | |
| # Reattach non-percentile attributes, then RE-SCORE each scenario isochrone | |
| # independently so a changed greenspace value receives a new percentile | |
| # against its own matching mode Γ time reference distribution. | |
| for (nm in c( | |
| "closest_greenspace", "closest_greenspace_dist_m", | |
| "closest_rsf_program", "closest_rsf_program_dist_m" | |
| )) { | |
| attr(base, nm) <- attr(base_socio_data(), nm) | |
| } | |
| score_iso_metrics_against_reference(base) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Biodiversity Access Index β same-mode Γ same-time isochrone benchmarks | |
| # --------------------------------------------------------------------------- | |
| baseline_biodiversity_access_index <- reactive({ | |
| base <- base_socio_data() | |
| if (is.null(base) || nrow(base) == 0) return(base) | |
| add_reference_bai(base) | |
| }) | |
| biodiversity_access_index <- reactive({ | |
| scenario <- socio_data() | |
| if (is.null(scenario) || nrow(scenario) == 0) return(scenario) | |
| add_reference_bai(scenario) | |
| }) | |
| # Corridor access comparisons use exactly one active Explorer isochrone. | |
| # NEVER average different modes or travel times for the corridor scenario. | |
| # The selected row alone enters the baseline-versus-proposal BAI calculation. | |
| corridor_selected_isochrone_metrics <- reactive({ | |
| info <- corridor_active_iso_info() | |
| if (is.null(info)) return(NULL) | |
| idx <- info$index | |
| base_raw <- base_socio_data() | |
| scenario_raw <- socio_data() | |
| base_bai <- baseline_biodiversity_access_index() | |
| scenario_bai <- biodiversity_access_index() | |
| objects <- list(base_raw, scenario_raw, base_bai, scenario_bai) | |
| if (any(vapply(objects, function(x) is.null(x) || nrow(x) < idx, logical(1)))) { | |
| return(NULL) | |
| } | |
| list( | |
| index = idx, | |
| key = info$key, | |
| label = info$label, | |
| baseline_raw = base_raw[idx, , drop = FALSE], | |
| scenario_raw = scenario_raw[idx, , drop = FALSE], | |
| baseline_bai = base_bai[idx, , drop = FALSE], | |
| scenario_bai = scenario_bai[idx, , drop = FALSE] | |
| ) | |
| }) | |
| greenspace_scenario_comparison <- reactive({ | |
| selected <- corridor_selected_isochrone_metrics() | |
| proposal <- proposed_greenspace() | |
| if (is.null(proposal) || nrow(proposal) == 0 || is.null(selected)) { | |
| return(NULL) | |
| } | |
| baseline <- selected$baseline_bai | |
| scenario <- selected$scenario_bai | |
| axis_cols <- c( | |
| "Mobility_Access_std", "Route_Access_std", | |
| "Biodiversity_Potential_std", "Observation_Intensity_std", | |
| "Environmental_Quality_std", "Greenspace_Cover_std", | |
| "Equity_Context_std" | |
| ) | |
| if (!all(c(axis_cols, "BAI") %in% names(baseline)) || | |
| !all(c(axis_cols, "BAI") %in% names(scenario))) { | |
| return(NULL) | |
| } | |
| list( | |
| baseline = baseline, | |
| scenario = scenario, | |
| baseline_raw = selected$baseline_raw, | |
| scenario_raw = selected$scenario_raw, | |
| axis_cols = axis_cols, | |
| index = selected$index, | |
| label = selected$label | |
| ) | |
| }) | |
| output$corridorImpactSummary <- renderUI({ | |
| comparison <- greenspace_scenario_comparison() | |
| connectivity <- corridor_connectivity() | |
| if (is.null(comparison)) return(NULL) | |
| safe_num <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| x <- x[is.finite(x)] | |
| if (length(x) == 0) NA_real_ else x[[1]] | |
| } | |
| base_bai <- 100 * safe_num(comparison$baseline$BAI) | |
| scenario_bai <- 100 * safe_num(comparison$scenario$BAI) | |
| delta_bai <- scenario_bai - base_bai | |
| base_bai_pctile <- safe_num(comparison$baseline$pctile_BAI) | |
| scenario_bai_pctile <- safe_num(comparison$scenario$pctile_BAI) | |
| delta_bai_pctile <- scenario_bai_pctile - base_bai_pctile | |
| base_gs_pctile <- 100 * safe_num(comparison$baseline$Greenspace_Cover_std) | |
| scenario_gs_pctile <- 100 * safe_num(comparison$scenario$Greenspace_Cover_std) | |
| delta_gs_pctile <- scenario_gs_pctile - base_gs_pctile | |
| base_gs_raw <- safe_num(comparison$baseline_raw$Greenspace_percent) | |
| scenario_gs_raw <- safe_num(comparison$scenario_raw$Greenspace_percent) | |
| delta_gs_raw <- scenario_gs_raw - base_gs_raw | |
| # Identify which validated BAI axes actually changed in this modeled scenario. | |
| bai_axis_cols <- c( | |
| "Mobility_Access_std", | |
| "Route_Access_std", | |
| "Biodiversity_Potential_std", | |
| "Observation_Intensity_std", | |
| "Environmental_Quality_std", | |
| "Greenspace_Cover_std", | |
| "Equity_Context_std" | |
| ) | |
| axis_delta <- vapply(bai_axis_cols, function(nm) { | |
| if (!(nm %in% names(comparison$baseline)) || | |
| !(nm %in% names(comparison$scenario))) return(NA_real_) | |
| 100 * ( | |
| safe_num(comparison$scenario[[nm]]) - | |
| safe_num(comparison$baseline[[nm]]) | |
| ) | |
| }, numeric(1)) | |
| changed_axes <- names(axis_delta)[is.finite(axis_delta) & abs(axis_delta) >= 0.1] | |
| greenspace_only <- identical(changed_axes, "Greenspace_Cover_std") | |
| impact_banner <- if (greenspace_only) { | |
| tags$div( | |
| style = paste0( | |
| "background:#e8f5e9; border-left:5px solid #2f7d4b; ", | |
| "border-radius:7px; padding:9px 12px; margin:8px 0 10px 0;" | |
| ), | |
| tags$b("Modeled BAI gain is driven by greenspace cover only. "), | |
| "The other six validated BAI dimensions are held constant in this scenario." | |
| ) | |
| } else if (length(changed_axes) > 0) { | |
| tags$div( | |
| style = paste0( | |
| "background:#eef6f1; border-left:5px solid #5d8d70; ", | |
| "border-radius:7px; padding:9px 12px; margin:8px 0 10px 0;" | |
| ), | |
| tags$b("Modeled BAI change is concentrated in: "), | |
| paste(changed_axes, collapse = ", "), | |
| "." | |
| ) | |
| } else { | |
| tags$div( | |
| style = paste0( | |
| "background:#f4f4f4; border-left:5px solid #888; ", | |
| "border-radius:7px; padding:9px 12px; margin:8px 0 10px 0;" | |
| ), | |
| tags$b("No validated BAI dimension changed under the current scenario settings.") | |
| ) | |
| } | |
| fmt_arrow <- function(a, b, suffix = "", digits = 1) { | |
| if (!is.finite(a) || !is.finite(b)) return("N/A") | |
| paste0(round(a, digits), suffix, " β ", round(b, digits), suffix) | |
| } | |
| fmt_delta <- function(x, suffix = "", digits = 1) { | |
| if (!is.finite(x)) return("N/A") | |
| paste0(if (x >= 0) "+" else "", round(x, digits), suffix) | |
| } | |
| connectivity_value <- "Unavailable" | |
| connectivity_detail <- "Structural connectivity could not be evaluated." | |
| connectivity_subdetail <- NULL | |
| if (!is.null(connectivity) && isTRUE(connectivity$available)) { | |
| connectivity_value <- connectivity$classification | |
| if (identical(connectivity$classification, "Network reinforcement") && | |
| is.finite(connectivity$baseline_components) && | |
| is.finite(connectivity$scenario_components)) { | |
| connectivity_detail <- paste0( | |
| "The habitat network remains ", | |
| connectivity$baseline_components, " β ", connectivity$scenario_components, | |
| "; the proposal reinforces habitat around ", | |
| connectivity$linked_patches, " reached greenspaces." | |
| ) | |
| } else { | |
| connectivity_detail <- paste0( | |
| connectivity$baseline_components, " β ", connectivity$scenario_components, | |
| " habitat networks; ", connectivity$linked_patches, " greenspaces reached." | |
| ) | |
| } | |
| connectivity_subdetail <- paste0( | |
| "Separate structural-connectivity result β not included as a BAI axis." | |
| ) | |
| } | |
| connectivity_sentence <- if (!is.null(connectivity) && isTRUE(connectivity$available)) { | |
| dplyr::case_when( | |
| identical(connectivity$classification, "Network reinforcement") ~ | |
| paste0( | |
| "It reinforces an existing habitat network rather than joining separate networks under the ", | |
| connectivity$gap_m, " m connection rule." | |
| ), | |
| identical(connectivity$classification, "Bridge") ~ | |
| paste0( | |
| "It joins previously separate habitat networks under the ", | |
| connectivity$gap_m, " m connection rule." | |
| ), | |
| identical(connectivity$classification, "Patch expansion") ~ | |
| "It expands an existing mapped habitat patch rather than creating a new network connection.", | |
| identical(connectivity$classification, "Stepping-stone") ~ | |
| "It adds a potential stepping stone but does not join separate habitat networks.", | |
| TRUE ~ | |
| "It remains structurally isolated from mapped greenspaces under the selected connection rule." | |
| ) | |
| } else { | |
| "Structural connectivity is unavailable for the current proposal." | |
| } | |
| stakeholder_summary <- if ( | |
| is.finite(base_gs_raw) && is.finite(scenario_gs_raw) && | |
| is.finite(base_gs_pctile) && is.finite(scenario_gs_pctile) && | |
| is.finite(base_bai) && is.finite(scenario_bai) | |
| ) { | |
| paste0( | |
| "This intervention increases accessible greenspace from ", | |
| round(base_gs_raw, 1), "% to ", round(scenario_gs_raw, 1), | |
| "%, moving the greenspace benchmark from ", | |
| round(base_gs_pctile, 1), " to ", round(scenario_gs_pctile, 1), | |
| " percentile points and the composite BAI from ", | |
| round(base_bai, 1), " to ", round(scenario_bai, 1), "/100. ", | |
| connectivity_sentence | |
| ) | |
| } else { | |
| paste0("The modeled intervention changes accessible greenspace and the selected-isochrone BAI. ", connectivity_sentence) | |
| } | |
| tags$div( | |
| style = "background:#f7fbf8; border:1px solid #cfe2d6; border-radius:10px; padding:12px 12px 10px 12px; margin-bottom:10px;", | |
| tags$div( | |
| style = "display:flex; justify-content:space-between; align-items:flex-start; gap:10px; flex-wrap:wrap;", | |
| tags$div( | |
| tags$div( | |
| style = "font-size:19px; font-weight:700; color:#28543b;", | |
| "Conservation action impact dashboard" | |
| ), | |
| tags$small( | |
| "Two different outcomes are shown together: validated BAI change and structural connectivity." | |
| ) | |
| ), | |
| tags$span( | |
| style = "background:#e8f5e9; border:1px solid #c6dfca; border-radius:999px; padding:5px 10px; font-weight:700; color:#2f6b45;", | |
| comparison$label | |
| ) | |
| ), | |
| tags$div( | |
| style = "background:#ffffff; border-left:5px solid #2f855a; border-radius:8px; padding:10px 12px; margin:9px 0;", | |
| tags$b("Stakeholder summary: "), | |
| stakeholder_summary | |
| ), | |
| impact_banner, | |
| tags$div( | |
| style = "display:grid; grid-template-columns:3fr 2fr; gap:10px; margin-bottom:9px;", | |
| tags$div( | |
| style = "background:white; border:1px solid #cfe2d6; border-radius:9px; padding:10px;", | |
| tags$div( | |
| style = "font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#557064; font-weight:700;", | |
| "BAI IMPACT β validated 7-axis accessibility index" | |
| ), | |
| tags$div( | |
| style = "display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; margin-top:7px;", | |
| tags$div( | |
| style = "background:#f8fbf9; border:1px solid #e0ebe4; border-radius:8px; padding:9px;", | |
| tags$small(tags$b("Composite BAI")), | |
| tags$div(style = "font-size:21px; font-weight:700;", fmt_arrow(base_bai, scenario_bai)), | |
| tags$small( | |
| style = if (is.finite(delta_bai) && delta_bai > 0) "color:#2f7d4b; font-weight:700;" else "color:#66766e;", | |
| paste0("Change: ", fmt_delta(delta_bai)) | |
| ) | |
| ), | |
| tags$div( | |
| style = "background:#f3f7fb; border:1px solid #d9e4ef; border-radius:8px; padding:9px;", | |
| tags$small(tags$b("BAI citywide percentile")), | |
| tags$div(style = "font-size:21px; font-weight:700;", fmt_arrow(base_bai_pctile, scenario_bai_pctile)), | |
| tags$small( | |
| style = if (is.finite(delta_bai_pctile) && delta_bai_pctile > 0) "color:#2f7d4b; font-weight:700;" else "color:#66766e;", | |
| paste0("Change: ", fmt_delta(delta_bai_pctile, " pp")) | |
| ) | |
| ), | |
| tags$div( | |
| style = "background:#f8fbf9; border:1px solid #e0ebe4; border-radius:8px; padding:9px;", | |
| tags$small(tags$b("Greenspace percentile")), | |
| tags$div(style = "font-size:21px; font-weight:700;", fmt_arrow(base_gs_pctile, scenario_gs_pctile)), | |
| tags$small( | |
| style = if (is.finite(delta_gs_pctile) && delta_gs_pctile > 0) "color:#2f7d4b; font-weight:700;" else "color:#66766e;", | |
| paste0("Change: ", fmt_delta(delta_gs_pctile, " pp")) | |
| ) | |
| ), | |
| tags$div( | |
| style = "background:#f8fbf9; border:1px solid #e0ebe4; border-radius:8px; padding:9px;", | |
| tags$small(tags$b("Greenspace cover (raw)")), | |
| tags$div(style = "font-size:21px; font-weight:700;", fmt_arrow(base_gs_raw, scenario_gs_raw, "%")), | |
| tags$small( | |
| style = if (is.finite(delta_gs_raw) && delta_gs_raw > 0) "color:#2f7d4b; font-weight:700;" else "color:#66766e;", | |
| paste0("Change: ", fmt_delta(delta_gs_raw, " pp")) | |
| ) | |
| ) | |
| ) | |
| ), | |
| tags$div( | |
| style = "background:#fffaf0; border:1px solid #ecd8aa; border-radius:9px; padding:10px;", | |
| tags$div( | |
| style = "font-size:12px; text-transform:uppercase; letter-spacing:.04em; color:#7a622b; font-weight:700;", | |
| "CONNECTIVITY OUTCOME β separate structural metric" | |
| ), | |
| tags$div( | |
| style = "font-size:23px; line-height:1.1; font-weight:800; color:#5f4a19; margin-top:8px;", | |
| connectivity_value | |
| ), | |
| tags$div( | |
| style = "font-size:13px; line-height:1.35; margin-top:6px; color:#4f493c;", | |
| connectivity_detail | |
| ), | |
| if (!is.null(connectivity_subdetail)) { | |
| tags$small( | |
| style = "display:block; margin-top:7px; color:#766b55;", | |
| connectivity_subdetail | |
| ) | |
| } | |
| ) | |
| ) | |
| ) | |
| }) | |
| output$corridorBaiComparisonNote <- renderUI({ | |
| info <- corridor_active_iso_info() | |
| if (is.null(info)) { | |
| return(tags$p("Choose an Explorer isochrone to compare baseline and proposal conditions.")) | |
| } | |
| tags$p( | |
| tags$b("How to read this scenario: "), info$label, " is the only active access area. ", | |
| "The proposal changes greenspace cover inside that same isochrone and re-ranks it against the same citywide mode Γ time reference. ", | |
| "Observed species richness, NDVI, transit access, route access, sampling density, and equity context are held constant. ", | |
| "Therefore any outward movement in the radar identifies the modeled access-score effect of the planned greenspace action rather than a prediction of future biodiversity." | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # HTML report cards | |
| # --------------------------------------------------------------------------- | |
| output$download_explorer_report <- downloadHandler( | |
| filename = function() paste0("sf_biodiversity_access_explorer_", format(Sys.Date(), "%Y%m%d"), ".html"), | |
| contentType = "text/html", | |
| content = function(file) { | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) stop("Generate isochrones before downloading this report card.") | |
| colnum <- function(nm, mult = 1) { | |
| if (!(nm %in% names(df))) return(rep(NA_real_, nrow(df))) | |
| suppressWarnings(as.numeric(df[[nm]])) * mult | |
| } | |
| report_df <- data.frame( | |
| Isochrone = vapply(seq_len(nrow(df)), function(i) iso_metric_row_label(df, i), character(1)), | |
| `Transit raw (stops/kmΒ²)` = round(colnum("Transit_Access_Score"), 2), | |
| `Transit percentile` = round(colnum("pctile_Transit_Access_Score"), 1), | |
| `Muni routes raw` = round(colnum("Unique_Muni_Routes")), | |
| `Muni route percentile` = round(colnum("pctile_Unique_Muni_Routes"), 1), | |
| `Total species raw` = round(colnum("GBIF_Species")), | |
| `Species percentile` = round(colnum("pctile_GBIF_Species"), 1), | |
| `Sampling density raw` = round(colnum("SamplingDensity_km2"), 1), | |
| `Sampling-density percentile` = round(colnum("pctile_SamplingDensity_km2"), 1), | |
| `Mean NDVI raw` = round(colnum("MeanNDVI"), 3), | |
| `NDVI percentile` = round(colnum("pctile_MeanNDVI"), 1), | |
| `Greenspace cover raw (%)` = round(colnum("Greenspace_percent"), 1), | |
| `Greenspace percentile` = round(colnum("pctile_Greenspace_percent"), 1), | |
| `SF EJ score raw` = round(colnum("SF_EJ_Score"), 1), | |
| `Favorable equity percentile` = round(colnum("pctile_SF_EJ_Score"), 1), | |
| `BAI score (/100)` = round(colnum("BAI", 100), 1), | |
| `BAI citywide percentile` = round(colnum("pctile_BAI"), 1), | |
| `GBIF records` = round(colnum("GBIF_Records")), | |
| check.names = FALSE | |
| ) | |
| map_iso <- isochrones_data() | |
| map_labels <- if (!is.null(map_iso) && nrow(map_iso) > 0) { | |
| vapply(seq_len(nrow(map_iso)), function(i) { | |
| paste0(pretty_mode(as.character(map_iso$mode[[i]])), " ", as.numeric(map_iso$time[[i]]), " min") | |
| }, character(1)) | |
| } else character(0) | |
| map_colors <- if (!is.null(map_iso) && nrow(map_iso) > 0) { | |
| vapply(seq_len(nrow(map_iso)), function(i) { | |
| clr <- unname(mode_palette[pretty_mode(as.character(map_iso$mode[[i]]))]) | |
| if (length(clr) == 0 || is.na(clr) || !nzchar(clr)) "#666666" else clr | |
| }, character(1)) | |
| } else character(0) | |
| map_uri <- if (!is.null(map_iso) && nrow(map_iso) > 0) { | |
| report_card_map_uri(function(path) { | |
| write_report_isochrone_map( | |
| path, | |
| map_iso, | |
| labels = map_labels, | |
| colors = map_colors, | |
| map_title = "Isochrone Explorer β spatial context" | |
| ) | |
| }) | |
| } else NULL | |
| write_report_card( | |
| file, | |
| "Isochrone Explorer β Biodiversity Access Report Card", | |
| "Citywide same-mode Γ same-time benchmarking", | |
| sections = paste0( | |
| report_card_map_section(map_uri, "Map of Isochrone Explorer analysis areas"), | |
| '<section class="section"><h2>Results</h2>', | |
| '<div class="note">Every BAI dimension is shown as a raw value paired with its citywide percentile. BAI score /100 is the seven-axis mean; BAI citywide percentile is a separate rank of that composite score against complete same-mode Γ same-time reference BAIs.</div>', | |
| report_card_table(report_df), | |
| '</section>' | |
| ) | |
| ) | |
| } | |
| ) | |
| output$download_comparer_report <- downloadHandler( | |
| filename = function() paste0("sf_biodiversity_access_comparison_", format(Sys.Date(), "%Y%m%d"), ".html"), | |
| contentType = "text/html", | |
| content = function(file) { | |
| res <- cmp_results() | |
| if (is.null(res)) stop("Run the Point A vs Point B comparison first.") | |
| side_row <- function(side, side_name) { | |
| if (is.null(side) || is.null(side$metrics) || nrow(side$metrics) == 0) { | |
| return(data.frame(Point = side_name, Location = "Unavailable", check.names = FALSE)) | |
| } | |
| m <- side$metrics | |
| b <- side$bai | |
| gm <- function(nm) if (nm %in% names(m)) suppressWarnings(as.numeric(m[[nm]][[1]])) else NA_real_ | |
| gb <- function(nm) if (!is.null(b) && nm %in% names(b)) suppressWarnings(as.numeric(b[[nm]][[1]])) else NA_real_ | |
| data.frame( | |
| Point = side_name, | |
| Location = if (!is.null(side$label)) side$label else iso_metric_row_label(m, 1), | |
| `Transit raw (stops/kmΒ²)` = round(gm("Transit_Access_Score"), 2), | |
| `Transit percentile` = round(gm("pctile_Transit_Access_Score"), 1), | |
| `Muni routes raw` = round(gm("Unique_Muni_Routes")), | |
| `Muni route percentile` = round(gm("pctile_Unique_Muni_Routes"), 1), | |
| `Total species raw` = round(gm("GBIF_Species")), | |
| `Species percentile` = round(gm("pctile_GBIF_Species"), 1), | |
| `Sampling density raw` = round(gm("SamplingDensity_km2"), 1), | |
| `Sampling-density percentile` = round(gm("pctile_SamplingDensity_km2"), 1), | |
| `Mean NDVI raw` = round(gm("MeanNDVI"), 3), | |
| `NDVI percentile` = round(gm("pctile_MeanNDVI"), 1), | |
| `Greenspace raw (%)` = round(gm("Greenspace_percent"), 1), | |
| `Greenspace percentile` = round(gm("pctile_Greenspace_percent"), 1), | |
| `SF EJ raw` = round(gm("SF_EJ_Score"), 1), | |
| `Favorable equity percentile` = round(gm("pctile_SF_EJ_Score"), 1), | |
| `BAI score (/100)` = round(100 * gb("BAI"), 1), | |
| `BAI citywide percentile` = round(gb("pctile_BAI"), 1), | |
| check.names = FALSE | |
| ) | |
| } | |
| report_df <- dplyr::bind_rows(side_row(res$a, "Point A"), side_row(res$b, "Point B")) | |
| map_iso <- list() | |
| map_labels <- character() | |
| map_colors <- character() | |
| if (!is.null(res$a) && !is.null(res$a$iso) && nrow(res$a$iso) > 0) { | |
| map_iso[[length(map_iso) + 1L]] <- res$a$iso | |
| map_labels <- c(map_labels, paste0("Point A β ", res$a$label)) | |
| map_colors <- c(map_colors, cmp_color_a) | |
| } | |
| if (!is.null(res$b) && !is.null(res$b$iso) && nrow(res$b$iso) > 0) { | |
| map_iso[[length(map_iso) + 1L]] <- res$b$iso | |
| map_labels <- c(map_labels, paste0("Point B β ", res$b$label)) | |
| map_colors <- c(map_colors, cmp_color_b) | |
| } | |
| map_uri <- if (length(map_iso) > 0) { | |
| report_card_map_uri(function(path) { | |
| write_report_isochrone_map( | |
| path, | |
| map_iso, | |
| labels = map_labels, | |
| colors = map_colors, | |
| map_title = "Isochrone Comparer β spatial context" | |
| ) | |
| }) | |
| } else NULL | |
| write_report_card( | |
| file, | |
| "Isochrone Comparer β Report Card", | |
| "Point A and Point B are benchmarked independently", | |
| sections = paste0( | |
| report_card_map_section(map_uri, "Map of Point A and Point B isochrone analysis areas"), | |
| '<section class="section"><h2>Comparison</h2>', | |
| '<div class="note">Each point uses its own matching SF transportation-mode Γ travel-time reference. Raw values and percentiles remain paired, and the two BAI quantities are reported separately.</div>', | |
| report_card_table(report_df), | |
| '</section>' | |
| ) | |
| ) | |
| } | |
| ) | |
| output$download_displacement_report <- downloadHandler( | |
| filename = function() paste0("sf_green_investment_displacement_context_", format(Sys.Date(), "%Y%m%d"), ".html"), | |
| contentType = "text/html", | |
| content = function(file) { | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) stop("Generate isochrones before downloading this context report card.") | |
| colnum <- function(nm, mult = 1) { | |
| if (!(nm %in% names(df))) return(rep(NA_real_, nrow(df))) | |
| suppressWarnings(as.numeric(df[[nm]])) * mult | |
| } | |
| gs <- colnum("pctile_Greenspace_percent") | |
| eq <- colnum("pctile_SF_EJ_Score") | |
| report_df <- data.frame( | |
| Isochrone = vapply(seq_len(nrow(df)), function(i) iso_metric_row_label(df, i), character(1)), | |
| `Greenspace cover raw (%)` = round(colnum("Greenspace_percent"), 1), | |
| `Greenspace percentile` = round(gs, 1), | |
| `Relative greenspace-access gap` = round(100 - gs, 1), | |
| `SF EJ score raw` = round(colnum("SF_EJ_Score"), 1), | |
| `Favorable equity-context percentile` = round(eq, 1), | |
| `EJ burden context` = round(100 - eq, 1), | |
| `Median-income context` = round(colnum("MedianIncome")), | |
| `BAI score (/100)` = round(colnum("BAI", 100), 1), | |
| `BAI citywide percentile` = round(colnum("pctile_BAI"), 1), | |
| check.names = FALSE | |
| ) | |
| map_iso <- isochrones_data() | |
| map_labels <- if (!is.null(map_iso) && nrow(map_iso) > 0) { | |
| vapply(seq_len(nrow(map_iso)), function(i) { | |
| paste0(pretty_mode(as.character(map_iso$mode[[i]])), " ", as.numeric(map_iso$time[[i]]), " min") | |
| }, character(1)) | |
| } else character(0) | |
| map_colors <- if (!is.null(map_iso) && nrow(map_iso) > 0) { | |
| vapply(seq_len(nrow(map_iso)), function(i) { | |
| clr <- unname(mode_palette[pretty_mode(as.character(map_iso$mode[[i]]))]) | |
| if (length(clr) == 0 || is.na(clr) || !nzchar(clr)) "#666666" else clr | |
| }, character(1)) | |
| } else character(0) | |
| map_uri <- if (!is.null(map_iso) && nrow(map_iso) > 0) { | |
| report_card_map_uri(function(path) { | |
| write_report_isochrone_map( | |
| path, | |
| map_iso, | |
| labels = map_labels, | |
| colors = map_colors, | |
| map_title = "Green Investment & Displacement Context β spatial context" | |
| ) | |
| }) | |
| } else NULL | |
| write_report_card( | |
| file, | |
| "Green Investment & Displacement Context β Report Card", | |
| "Equity-sensitive greening context; not a displacement-risk score", | |
| sections = paste0( | |
| report_card_map_section(map_uri, "Map of isochrone areas used for the green investment and displacement context report"), | |
| '<section class="section"><h2>Context</h2>', | |
| report_card_table(report_df), | |
| '</section>', | |
| '<section class="section"><h2>Interpretation</h2>', | |
| '<p>This screen does <strong>not</strong> calculate green-gentrification or displacement risk. A descriptive overlap between greenspace need and EJ burden is a reason to consider community governance and anti-displacement safeguards, not evidence that greening will cause displacement.</p>', | |
| '<p>A defensible displacement-risk screen would require housing tenure, rent burden, eviction/displacement events, housing-cost trends, affordable-housing protection, and related housing-market indicators.</p>', | |
| '</section>' | |
| ), | |
| caution = "Do not interpret this context screen as evidence that a proposed greening action will cause displacement." | |
| ) | |
| } | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Green Investment & Displacement Context | |
| # --------------------------------------------------------------------------- | |
| output$displacementContextSummary <- renderUI({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) { | |
| return(tags$div( | |
| style = "color:#66766e;", | |
| "Generate one or more isochrones in the Explorer to populate this context screen." | |
| )) | |
| } | |
| safe_num <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || !is.finite(x[[1]])) NA_real_ else x[[1]] | |
| } | |
| cards <- lapply(seq_len(nrow(df)), function(i) { | |
| gs_raw <- if ("Greenspace_percent" %in% names(df)) safe_num(df$Greenspace_percent[[i]]) else NA_real_ | |
| gs_pct <- if ("pctile_Greenspace_percent" %in% names(df)) safe_num(df$pctile_Greenspace_percent[[i]]) else NA_real_ | |
| equity_pct <- if ("pctile_SF_EJ_Score" %in% names(df)) safe_num(df$pctile_SF_EJ_Score[[i]]) else NA_real_ | |
| ej_raw <- if ("SF_EJ_Score" %in% names(df)) safe_num(df$SF_EJ_Score[[i]]) else NA_real_ | |
| income <- if ("MedianIncome" %in% names(df)) safe_num(df$MedianIncome[[i]]) else NA_real_ | |
| bai <- if ("BAI" %in% names(df)) 100 * safe_num(df$BAI[[i]]) else NA_real_ | |
| bai_pct <- if ("pctile_BAI" %in% names(df)) safe_num(df$pctile_BAI[[i]]) else NA_real_ | |
| greenspace_gap <- if (is.finite(gs_pct)) 100 - gs_pct else NA_real_ | |
| ej_burden_context <- if (is.finite(equity_pct)) 100 - equity_pct else NA_real_ | |
| planning_context <- if (!is.finite(greenspace_gap) || !is.finite(ej_burden_context)) { | |
| "Insufficient comparable percentile information for a descriptive planning context." | |
| } else if (greenspace_gap >= 50 && ej_burden_context >= 50) { | |
| "Greening opportunity overlaps with greater EJ burden context. Consider community-led planning and explicit anti-displacement safeguards alongside ecological investment." | |
| } else if (greenspace_gap >= 50) { | |
| "Larger relative greenspace-access gap, but lower EJ burden context on this screen. Greening need is visible; displacement vulnerability is not estimated." | |
| } else if (ej_burden_context >= 50) { | |
| "Greater EJ burden context despite comparatively stronger greenspace access. Equity considerations remain important, but this is not a displacement-risk classification." | |
| } else { | |
| "Neither the relative greenspace-access gap nor EJ burden context falls in the upper half of these descriptive citywide scales." | |
| } | |
| tags$div( | |
| style = paste0( | |
| "border:1px solid #dbe8e1; border-radius:9px; padding:10px 12px; ", | |
| "margin-bottom:9px; background:white;" | |
| ), | |
| tags$div( | |
| style = "font-size:15px; font-weight:700; color:#294337; margin-bottom:7px;", | |
| iso_metric_row_label(df, i) | |
| ), | |
| tags$div( | |
| style = "display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:8px;", | |
| tags$div( | |
| style = paste0("border-top:4px solid ", BAI_GROUP_COLORS[["green_environment"]], "; padding-top:5px;"), | |
| tags$small(tags$b("Greenspace cover")), | |
| tags$div(style = "font-size:19px; font-weight:700;", if (is.finite(gs_raw)) paste0(round(gs_raw, 1), "%") else "N/A"), | |
| tags$small("Citywide rank: ", format_pct(gs_pct)) | |
| ), | |
| tags$div( | |
| style = paste0("border-top:4px solid ", BAI_GROUP_COLORS[["equity"]], "; padding-top:5px;"), | |
| tags$small(tags$b("SF EJ context")), | |
| tags$div(style = "font-size:19px; font-weight:700;", if (is.finite(ej_raw)) round(ej_raw, 1) else "N/A"), | |
| tags$small("Favorable citywide rank: ", format_pct(equity_pct)) | |
| ), | |
| tags$div( | |
| tags$small(tags$b("Median-income context")), | |
| tags$div( | |
| style = "font-size:19px; font-weight:700;", | |
| if (is.finite(income)) scales::dollar(income, accuracy = 1) else "N/A" | |
| ), | |
| tags$small("Context only; not a vulnerability score") | |
| ), | |
| tags$div( | |
| tags$small(tags$b("BAI score")), | |
| tags$div(style = "font-size:19px; font-weight:700;", if (is.finite(bai)) paste0(round(bai, 1), "/100") else "N/A"), | |
| tags$small("Seven-axis mean") | |
| ), | |
| tags$div( | |
| tags$small(tags$b("BAI citywide rank")), | |
| tags$div(style = "font-size:19px; font-weight:700;", format_pct(bai_pct)), | |
| tags$small("Same mode Γ same travel time") | |
| ) | |
| ), | |
| tags$div( | |
| style = paste0( | |
| "background:#fff7e8; border-left:4px solid #d6a542; ", | |
| "border-radius:6px; padding:7px 9px; margin-top:8px; font-size:12px;" | |
| ), | |
| tags$b("Planning interpretation β descriptive, not a risk classification: "), | |
| planning_context | |
| ), | |
| tags$div( | |
| style = paste0( | |
| "background:#f7f7f7; border-left:4px solid #888; border-radius:6px; ", | |
| "padding:7px 9px; margin-top:6px; font-size:12px;" | |
| ), | |
| tags$b("Displacement-risk estimate: not calculated. "), | |
| "Housing tenure, rent burden, eviction, housing-cost trend, and affordable-housing data are not currently included." | |
| ) | |
| ) | |
| }) | |
| tags$div(cards) | |
| }) | |
| output$displacementContextPlot <- renderPlot({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0 || | |
| !("pctile_Greenspace_percent" %in% names(df)) || | |
| !("pctile_SF_EJ_Score" %in% names(df))) { | |
| plot.new() | |
| title("Generate supported isochrones to view the context plot.") | |
| return(invisible(NULL)) | |
| } | |
| plot_df <- data.frame( | |
| label = vapply( | |
| seq_len(nrow(df)), | |
| function(i) iso_metric_row_label(df, i), | |
| character(1) | |
| ), | |
| greenspace_gap = 100 - suppressWarnings(as.numeric(df$pctile_Greenspace_percent)), | |
| ej_burden_context = 100 - suppressWarnings(as.numeric(df$pctile_SF_EJ_Score)), | |
| stringsAsFactors = FALSE | |
| ) | |
| plot_df <- plot_df[ | |
| is.finite(plot_df$greenspace_gap) & | |
| is.finite(plot_df$ej_burden_context), | |
| , | |
| drop = FALSE | |
| ] | |
| if (nrow(plot_df) == 0) { | |
| plot.new() | |
| title("No comparable same-mode Γ same-time context values are available.") | |
| return(invisible(NULL)) | |
| } | |
| ggplot2::ggplot( | |
| plot_df, | |
| ggplot2::aes( | |
| x = greenspace_gap, | |
| y = ej_burden_context, | |
| label = label | |
| ) | |
| ) + | |
| ggplot2::annotate( | |
| "rect", | |
| xmin = 50, xmax = 100, | |
| ymin = 50, ymax = 100, | |
| fill = "#fff4d6", | |
| alpha = 0.45 | |
| ) + | |
| ggplot2::geom_vline( | |
| xintercept = 50, | |
| linetype = "dashed", | |
| color = "#9aa7a0" | |
| ) + | |
| ggplot2::geom_hline( | |
| yintercept = 50, | |
| linetype = "dashed", | |
| color = "#9aa7a0" | |
| ) + | |
| ggplot2::geom_point( | |
| size = 4.2, | |
| shape = 21, | |
| fill = "#5d8d70", | |
| color = "#294337", | |
| stroke = 1.1 | |
| ) + | |
| ggplot2::geom_text( | |
| nudge_y = 3.2, | |
| size = 4, | |
| fontface = "bold", | |
| check_overlap = TRUE | |
| ) + | |
| ggplot2::annotate( | |
| "text", | |
| x = 75, | |
| y = 96, | |
| label = "High greening opportunity\\n+ greater EJ burden context", | |
| fontface = "bold", | |
| size = 4.2, | |
| color = "#795b16" | |
| ) + | |
| ggplot2::scale_x_continuous( | |
| limits = c(0, 100), | |
| breaks = c(0, 25, 50, 75, 100) | |
| ) + | |
| ggplot2::scale_y_continuous( | |
| limits = c(0, 100), | |
| breaks = c(0, 25, 50, 75, 100) | |
| ) + | |
| ggplot2::labs( | |
| x = "Relative greenspace-access gap\\n100 β greenspace-cover percentile", | |
| y = "EJ burden context\\n100 β favorable equity-context percentile", | |
| title = "Equity-Sensitive Greening Context", | |
| subtitle = paste0( | |
| "Upper-right highlights overlap between a larger greenspace-access gap ", | |
| "and greater EJ burden context. It is not a displacement-risk classification." | |
| ), | |
| caption = paste0( | |
| "Each point is one independently scored isochrone. ", | |
| "The 50/50 guide lines are descriptive only." | |
| ) | |
| ) + | |
| ggplot2::theme_minimal(base_size = 13) + | |
| ggplot2::theme( | |
| panel.grid.minor = ggplot2::element_blank(), | |
| panel.grid.major = ggplot2::element_line(color = "#e1e9e4"), | |
| plot.title = ggplot2::element_text( | |
| face = "bold", | |
| color = "#294337", | |
| size = 16 | |
| ), | |
| axis.title = ggplot2::element_text(face = "bold"), | |
| plot.subtitle = ggplot2::element_text(color = "#596861"), | |
| plot.caption = ggplot2::element_text( | |
| hjust = 0, | |
| color = "#66766e" | |
| ) | |
| ) | |
| }) | |
| output$ngoReportingSummary <- renderUI({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) { | |
| return(tags$p( | |
| "Generate an isochrone to create reporting-ready citywide benchmark language." | |
| )) | |
| } | |
| safe_num <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || !is.finite(x[[1]])) NA_real_ else x[[1]] | |
| } | |
| rows <- lapply(seq_len(nrow(df)), function(i) { | |
| species_raw <- if ("GBIF_Species" %in% names(df)) safe_num(df$GBIF_Species[[i]]) else NA_real_ | |
| species_pct <- if ("pctile_GBIF_Species" %in% names(df)) safe_num(df$pctile_GBIF_Species[[i]]) else NA_real_ | |
| greenspace_raw <- if ("Greenspace_percent" %in% names(df)) safe_num(df$Greenspace_percent[[i]]) else NA_real_ | |
| greenspace_pct <- if ("pctile_Greenspace_percent" %in% names(df)) safe_num(df$pctile_Greenspace_percent[[i]]) else NA_real_ | |
| equity_raw <- if ("SF_EJ_Score" %in% names(df)) safe_num(df$SF_EJ_Score[[i]]) else NA_real_ | |
| equity_pct <- if ("pctile_SF_EJ_Score" %in% names(df)) safe_num(df$pctile_SF_EJ_Score[[i]]) else NA_real_ | |
| bai <- if ("BAI" %in% names(df)) 100 * safe_num(df$BAI[[i]]) else NA_real_ | |
| bai_pct <- if ("pctile_BAI" %in% names(df)) safe_num(df$pctile_BAI[[i]]) else NA_real_ | |
| sentence_parts <- c( | |
| paste0(iso_metric_row_label(df, i), ":"), | |
| if (is.finite(species_raw) && is.finite(species_pct)) { | |
| paste0( | |
| scales::comma(round(species_raw)), | |
| " accessible unique GBIF species (", format_pct(species_pct), ")" | |
| ) | |
| } else NULL, | |
| if (is.finite(greenspace_raw) && is.finite(greenspace_pct)) { | |
| paste0( | |
| round(greenspace_raw, 1), | |
| "% greenspace cover (", format_pct(greenspace_pct), ")" | |
| ) | |
| } else NULL, | |
| if (is.finite(bai)) { | |
| paste0( | |
| "BAI score ", round(bai, 1), "/100", | |
| if (is.finite(bai_pct)) paste0(" with a citywide BAI rank of ", format_pct(bai_pct)) else "" | |
| ) | |
| } else NULL, | |
| if (is.finite(equity_raw) && is.finite(equity_pct)) { | |
| paste0( | |
| "SF EJ raw score ", round(equity_raw, 1), | |
| " with a favorable equity-context rank of ", format_pct(equity_pct), | |
| " (higher rank = lower EJ burden)" | |
| ) | |
| } else NULL | |
| ) | |
| tags$div( | |
| style = paste0( | |
| "background:#f3f8f5; border-left:4px solid #5d8d70; ", | |
| "border-radius:7px; padding:9px 11px; margin-bottom:8px;" | |
| ), | |
| paste0(paste(sentence_parts, collapse = "; "), ".") | |
| ) | |
| }) | |
| tags$div( | |
| rows, | |
| tags$small( | |
| style = "display:block; color:#66766e; margin-top:7px;", | |
| "Recommended wording attributes each benchmark to the specific isochrone/site and matching transportation scenarioβnot to the NGO as an organization. BAI score and BAI citywide percentile are reported separately." | |
| ) | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Summary outputs | |
| # --------------------------------------------------------------------------- | |
| output$dataTable <- renderDT({ | |
| df <- base_socio_data() | |
| if (nrow(df) == 0) return(DT::datatable(data.frame(Message = "No isochrones generated yet."))) | |
| out <- df |> | |
| mutate( | |
| MedianIncome = ifelse(is.na(MedianIncome), NA, dollar(MedianIncome)), | |
| MeanNDVI = round(MeanNDVI, 3) | |
| ) | |
| DT::datatable( | |
| out, | |
| options = list(pageLength = 10, autoWidth = TRUE, scrollX = TRUE), | |
| rownames = FALSE | |
| ) | |
| }) | |
| output$bioScoreBox <- renderUI({ | |
| df <- base_socio_data() | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| fmt_count <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || !is.finite(x)) return("N/A") | |
| scales::comma(round(x)) | |
| } | |
| # One independently benchmarked result per generated isochrone. Never show a | |
| # percentile for the union/mean of multiple mode Γ time polygons. | |
| cards <- lapply(seq_len(nrow(df)), function(i) { | |
| total_raw <- if ("GBIF_Species" %in% names(df)) df$GBIF_Species[[i]] else NA_real_ | |
| bird_raw <- if ("Bird_Species" %in% names(df)) df$Bird_Species[[i]] else NA_real_ | |
| mammal_raw <- if ("Mammal_Species" %in% names(df)) df$Mammal_Species[[i]] else NA_real_ | |
| plant_raw <- if ("Plant_Species" %in% names(df)) df$Plant_Species[[i]] else NA_real_ | |
| total_pct <- if ("pctile_GBIF_Species" %in% names(df)) df$pctile_GBIF_Species[[i]] else NA_real_ | |
| bird_pct <- if ("pctile_Bird_Species" %in% names(df)) df$pctile_Bird_Species[[i]] else NA_real_ | |
| mammal_pct <- if ("pctile_Mammal_Species" %in% names(df)) df$pctile_Mammal_Species[[i]] else NA_real_ | |
| plant_pct <- if ("pctile_Plant_Species" %in% names(df)) df$pctile_Plant_Species[[i]] else NA_real_ | |
| # % native among plant species with an iNaturalist establishment status | |
| # (native/endemic vs introduced); status-unknown species are excluded. | |
| plant_native <- if ("Plant_Native_Species" %in% names(df)) df$Plant_Native_Species[[i]] else NA_real_ | |
| plant_intro <- if ("Plant_Introduced_Species" %in% names(df)) df$Plant_Introduced_Species[[i]] else NA_real_ | |
| plant_known <- plant_native + plant_intro | |
| n_ref <- if ("nref_GBIF_Species" %in% names(df)) df$nref_GBIF_Species[[i]] else NA_integer_ | |
| tags$div( | |
| style = "padding:10px 0 12px 0; border-bottom:1px solid #e6e6e6;", | |
| tags$b(iso_metric_row_label(df, i)), | |
| tags$div( | |
| style = "display:flex; gap:12px; align-items:flex-end; flex-wrap:wrap; margin-top:5px;", | |
| tags$div( | |
| tags$div( | |
| style = "font-size:25px; line-height:1.05; font-weight:700; color:#28543b;", | |
| paste0(fmt_count(total_raw), " species") | |
| ), | |
| tags$small("Total unique GBIF species in this isochrone") | |
| ), | |
| tags$div( | |
| style = "padding-left:12px; border-left:3px solid #b9dfc8;", | |
| tags$div( | |
| style = "font-size:20px; line-height:1.05; font-weight:700;", | |
| format_pct(total_pct) | |
| ), | |
| tags$small("Citywide rank for the same mode Γ time") | |
| ) | |
| ), | |
| tags$div( | |
| style = "display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:9px;", | |
| tags$div( | |
| style = "background:#f6faf7; border:1px solid #e0ebe4; border-radius:6px; padding:6px 8px;", | |
| tags$b("Birds"), tags$br(), | |
| tags$small(paste0(fmt_count(bird_raw), " species Β· ", format_pct(bird_pct))) | |
| ), | |
| tags$div( | |
| style = "background:#f6faf7; border:1px solid #e0ebe4; border-radius:6px; padding:6px 8px;", | |
| tags$b("Mammals"), tags$br(), | |
| tags$small(paste0(fmt_count(mammal_raw), " species Β· ", format_pct(mammal_pct))) | |
| ), | |
| tags$div( | |
| style = "background:#f6faf7; border:1px solid #e0ebe4; border-radius:6px; padding:6px 8px;", | |
| tags$b("Plants"), tags$br(), | |
| tags$small(paste0(fmt_count(plant_raw), " species Β· ", format_pct(plant_pct))), | |
| if (is.finite(plant_known) && plant_known > 0) { | |
| tags$small( | |
| style = "display:block;", | |
| title = paste0( | |
| "Of ", fmt_count(plant_known), " plant species with an iNaturalist establishment status, ", | |
| fmt_count(plant_native), " are native or endemic. ", | |
| fmt_count(plant_raw - plant_known), " species without a status are excluded." | |
| ), | |
| paste0(round(100 * plant_native / plant_known), "% native") | |
| ) | |
| } | |
| ) | |
| ), | |
| if (is.finite(total_pct)) { | |
| tags$div( | |
| style = paste0( | |
| "background:#f0f7f3; border-left:4px solid #5d8d70; ", | |
| "border-radius:6px; padding:7px 9px; margin-top:8px; font-size:12px;" | |
| ), | |
| tags$b("Reporting sentence: "), | |
| paste0( | |
| iso_metric_row_label(df, i), | |
| " ranks at the ", | |
| format_pct(total_pct), | |
| " for accessible total species richness among comparable SF origins with the same mode and travel time." | |
| ) | |
| ) | |
| }, | |
| if (is.finite(n_ref)) { | |
| tags$small( | |
| style = "display:block; color:#66766e; margin-top:6px;", | |
| paste0("Percentile reference uses ", scales::comma(n_ref), " finite comparable SF origins.") | |
| ) | |
| } | |
| ) | |
| }) | |
| wellPanel( | |
| tags$div( | |
| style = "max-height:390px; overflow-y:auto;", | |
| cards | |
| ), | |
| tags$small( | |
| style = "display:block; color:#666; margin-top:7px;", | |
| "Raw species counts and citywide percentiles are shown separately. Each result is compared only with precomputed SF origins having the same transportation mode and travel time. Isochrones are never pooled." | |
| ) | |
| ) | |
| }) | |
| output$transitScoreBox <- renderUI({ | |
| df <- base_socio_data() | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| cards <- lapply(seq_len(nrow(df)), function(i) { | |
| raw <- if ("Transit_Access_Score" %in% names(df)) { | |
| suppressWarnings(as.numeric(df$Transit_Access_Score[[i]])) | |
| } else NA_real_ | |
| pct <- if ("pctile_Transit_Access_Score" %in% names(df)) { | |
| suppressWarnings(as.numeric(df$pctile_Transit_Access_Score[[i]])) | |
| } else NA_real_ | |
| n_ref <- if ("nref_Transit_Access_Score" %in% names(df)) df$nref_Transit_Access_Score[[i]] else NA_integer_ | |
| tags$div( | |
| style = "padding:8px 0; border-bottom:1px solid #e6e6e6;", | |
| tags$b(iso_metric_row_label(df, i)), | |
| tags$div( | |
| style = "font-size:21px; line-height:1.15; margin-top:2px;", | |
| if (is.finite(raw)) paste0(round(raw, 2), " stops/kmΒ²") else "N/A" | |
| ), | |
| tags$small( | |
| "Citywide isochrone percentile: ", format_pct(pct), | |
| if (is.finite(n_ref)) paste0(" Β· n=", scales::comma(n_ref)) else "" | |
| ) | |
| ) | |
| }) | |
| wellPanel( | |
| tags$div(style = "max-height:310px; overflow-y:auto;", cards), | |
| tags$small( | |
| style = "display:block; color:#666; margin-top:7px;", | |
| "Raw transit values are retained. A percentile is shown only when a comparable same-mode Γ same-time precomputed reference exists." | |
| ) | |
| ) | |
| }) | |
| output$dataCoverageBox <- renderUI({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| safe_num <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || !is.finite(x[[1]])) NA_real_ else x[[1]] | |
| } | |
| cards <- lapply(seq_len(nrow(df)), function(i) { | |
| records <- if ("GBIF_Records" %in% names(df)) safe_num(df$GBIF_Records[[i]]) else NA_real_ | |
| density <- if ("SamplingDensity_km2" %in% names(df)) safe_num(df$SamplingDensity_km2[[i]]) else NA_real_ | |
| pct <- if ("pctile_SamplingDensity_km2" %in% names(df)) safe_num(df$pctile_SamplingDensity_km2[[i]]) else NA_real_ | |
| n_ref <- if ("nref_SamplingDensity_km2" %in% names(df)) safe_num(df$nref_SamplingDensity_km2[[i]]) else NA_real_ | |
| interpretation <- if (!is.finite(pct)) { | |
| "No comparable observation-density benchmark is available." | |
| } else if (pct >= 75) { | |
| "Relatively well sampled compared with matched SF isochrones." | |
| } else if (pct >= 25) { | |
| "Mid-range sampling intensity compared with matched SF isochrones." | |
| } else { | |
| "Relatively lightly sampled compared with matched SF isochrones." | |
| } | |
| tags$div( | |
| style = paste0( | |
| "border-left:5px solid ", BAI_GROUP_COLORS[["biodiversity"]], | |
| "; background:#fffaf3; border-radius:8px; padding:9px 11px; ", | |
| "margin-bottom:8px;" | |
| ), | |
| tags$b(iso_metric_row_label(df, i)), | |
| tags$div( | |
| style = "display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin-top:7px;", | |
| tags$div( | |
| tags$small(tags$b("GBIF records")), | |
| tags$div( | |
| style = "font-size:20px; font-weight:700;", | |
| if (is.finite(records)) scales::comma(round(records)) else "N/A" | |
| ) | |
| ), | |
| tags$div( | |
| tags$small(tags$b("Sampling density")), | |
| tags$div( | |
| style = "font-size:20px; font-weight:700;", | |
| if (is.finite(density)) paste0(round(density, 1), " records/kmΒ²") else "N/A" | |
| ) | |
| ), | |
| tags$div( | |
| tags$small(tags$b("Citywide sampling-density rank")), | |
| tags$div( | |
| style = "font-size:20px; font-weight:700;", | |
| format_pct(pct) | |
| ), | |
| if (is.finite(n_ref)) tags$small(paste0("n=", scales::comma(round(n_ref)))) else NULL | |
| ) | |
| ), | |
| tags$div( | |
| style = "margin-top:7px; font-size:12px; color:#5d5245;", | |
| tags$b("Interpretation: "), | |
| interpretation | |
| ) | |
| ) | |
| }) | |
| tagList( | |
| cards, | |
| tags$small( | |
| style = "display:block; color:#66766e; margin-top:5px;", | |
| "This is an observation-coverage indicator, not statistical confidence and not proof that true species richness is completely sampled. The descriptive low/mid/high wording is based on the matched sampling-density percentile." | |
| ) | |
| ) | |
| }) | |
| output$baiDimensionDetailsBox <- renderUI({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| safe_num <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| if (length(x) == 0 || !is.finite(x[[1]])) NA_real_ else x[[1]] | |
| } | |
| spec <- list( | |
| list(group = "ACCESS", color = BAI_GROUP_COLORS[["access"]], | |
| label = "Transit access", raw = "Transit_Access_Score", | |
| pct = "pctile_Transit_Access_Score", nref = "nref_Transit_Access_Score", | |
| fmt = function(x) if (is.finite(x)) paste0(round(x, 2), " stops/kmΒ²") else "N/A"), | |
| list(group = "ACCESS", color = BAI_GROUP_COLORS[["access"]], | |
| label = "Muni route access", raw = "Unique_Muni_Routes", | |
| pct = "pctile_Unique_Muni_Routes", nref = "nref_Unique_Muni_Routes", | |
| fmt = function(x) if (is.finite(x)) paste0(round(x), " routes") else "N/A"), | |
| list(group = "BIODIVERSITY EVIDENCE", color = BAI_GROUP_COLORS[["biodiversity"]], | |
| label = "Total species richness", raw = "GBIF_Species", | |
| pct = "pctile_GBIF_Species", nref = "nref_GBIF_Species", | |
| fmt = function(x) if (is.finite(x)) paste0(scales::comma(round(x)), " species") else "N/A"), | |
| list(group = "BIODIVERSITY EVIDENCE", color = BAI_GROUP_COLORS[["biodiversity"]], | |
| label = "GBIF sampling density", raw = "SamplingDensity_km2", | |
| pct = "pctile_SamplingDensity_km2", nref = "nref_SamplingDensity_km2", | |
| fmt = function(x) if (is.finite(x)) paste0(round(x, 1), " records/kmΒ²") else "N/A"), | |
| list(group = "GREEN ENVIRONMENT", color = BAI_GROUP_COLORS[["green_environment"]], | |
| label = "Vegetation NDVI", raw = "MeanNDVI", | |
| pct = "pctile_MeanNDVI", nref = "nref_MeanNDVI", | |
| fmt = function(x) if (is.finite(x)) format(round(x, 3), nsmall = 3) else "N/A"), | |
| list(group = "GREEN ENVIRONMENT", color = BAI_GROUP_COLORS[["green_environment"]], | |
| label = "Greenspace cover", raw = "Greenspace_percent", | |
| pct = "pctile_Greenspace_percent", nref = "nref_Greenspace_percent", | |
| fmt = function(x) if (is.finite(x)) paste0(round(x, 1), "%") else "N/A"), | |
| list(group = "EQUITY CONTEXT", color = BAI_GROUP_COLORS[["equity"]], | |
| label = "SF EJ context", raw = "SF_EJ_Score", | |
| pct = "pctile_SF_EJ_Score", nref = "nref_SF_EJ_Score", | |
| fmt = function(x) if (is.finite(x)) paste0("raw score ", round(x, 1)) else "N/A") | |
| ) | |
| iso_sections <- lapply(seq_len(nrow(df)), function(i) { | |
| metric_cards <- lapply(spec, function(s) { | |
| raw <- if (s$raw %in% names(df)) safe_num(df[[s$raw]][[i]]) else NA_real_ | |
| pct <- if (s$pct %in% names(df)) safe_num(df[[s$pct]][[i]]) else NA_real_ | |
| nref <- if (s$nref %in% names(df)) safe_num(df[[s$nref]][[i]]) else NA_real_ | |
| tags$div( | |
| style = paste0( | |
| "border-top:4px solid ", s$color, | |
| "; background:white; border-radius:8px; padding:8px 9px; ", | |
| "border-left:1px solid #e0e6e2; border-right:1px solid #e0e6e2; ", | |
| "border-bottom:1px solid #e0e6e2;" | |
| ), | |
| tags$div( | |
| style = paste0("font-size:11px; font-weight:800; color:", s$color, "; letter-spacing:.03em;"), | |
| s$group | |
| ), | |
| tags$b(s$label), | |
| tags$div( | |
| style = "font-size:18px; font-weight:700; margin-top:3px;", | |
| s$fmt(raw) | |
| ), | |
| tags$small( | |
| "Citywide rank: ", format_pct(pct), | |
| if (is.finite(nref)) paste0(" Β· n=", scales::comma(round(nref))) else "" | |
| ), | |
| if (identical(s$raw, "SF_EJ_Score")) { | |
| tags$small( | |
| style = "display:block; color:#6b5a78; margin-top:2px;", | |
| "Percentile is inverted once: higher rank = lower EJ burden." | |
| ) | |
| } else NULL | |
| ) | |
| }) | |
| tags$div( | |
| style = "margin-bottom:14px;", | |
| tags$div( | |
| style = "font-size:16px; font-weight:700; margin-bottom:7px; color:#294337;", | |
| iso_metric_row_label(df, i) | |
| ), | |
| tags$div( | |
| style = "display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px;", | |
| metric_cards | |
| ) | |
| ) | |
| }) | |
| tagList( | |
| iso_sections, | |
| tags$small( | |
| style = "display:block; color:#66766e;", | |
| "Every raw value is paired with its independently calculated citywide percentile from the same transportation mode Γ travel-time reference." | |
| ) | |
| ) | |
| }) | |
| output$biodiversityAccessIndexBox <- renderUI({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| cards <- lapply(seq_len(nrow(df)), function(i) { | |
| bai <- if ("BAI" %in% names(df)) suppressWarnings(as.numeric(df$BAI[[i]])) else NA_real_ | |
| bai_pct <- if ("pctile_BAI" %in% names(df)) suppressWarnings(as.numeric(df$pctile_BAI[[i]])) else NA_real_ | |
| n_bai_ref <- if ("nref_BAI" %in% names(df)) suppressWarnings(as.numeric(df$nref_BAI[[i]])) else NA_real_ | |
| n_comp <- if ("BAI_n_components" %in% names(df)) df$BAI_n_components[[i]] else NA_integer_ | |
| tags$div( | |
| style = "padding:9px 0; border-bottom:1px solid #e6e6e6;", | |
| tags$b(iso_metric_row_label(df, i)), | |
| tags$div( | |
| style = "display:grid; grid-template-columns:1fr 1fr; gap:9px; margin-top:5px;", | |
| tags$div( | |
| style = "background:#fff9ee; border-radius:7px; padding:7px 9px;", | |
| tags$small(tags$b("BAI score")), | |
| tags$div( | |
| style = "font-size:24px; line-height:1.15; font-weight:700;", | |
| if (is.finite(bai)) paste0(round(100 * bai, 1), "/100") else "N/A" | |
| ), | |
| tags$small("Mean of the seven percentile-standardized dimensions") | |
| ), | |
| tags$div( | |
| style = "background:#f3f7fb; border-radius:7px; padding:7px 9px;", | |
| tags$small(tags$b("BAI citywide rank")), | |
| tags$div( | |
| style = "font-size:21px; line-height:1.15; font-weight:700;", | |
| format_pct(bai_pct) | |
| ), | |
| tags$small( | |
| if (is.finite(n_bai_ref)) { | |
| paste0("Compared with ", scales::comma(round(n_bai_ref)), " complete seven-axis reference isochrones") | |
| } else { | |
| "No complete matching BAI reference distribution" | |
| } | |
| ) | |
| ) | |
| ), | |
| tags$small( | |
| style = "display:block; margin-top:6px;", | |
| if (is.finite(n_comp)) { | |
| paste0(n_comp, " of 7 same-mode Γ same-time percentile dimensions available") | |
| } else { | |
| "No comparable precomputed reference dimensions" | |
| } | |
| ) | |
| ) | |
| }) | |
| wellPanel( | |
| tags$div(style = "max-height:380px; overflow-y:auto;", cards), | |
| tags$small( | |
| style = "display:block; color:#666; margin-top:7px;", | |
| "BAI score and BAI percentile are different quantities. The score is the seven-axis mean; the citywide rank compares that score with complete seven-axis BAIs from the exact same mode Γ travel-time reference. Each user isochrone is ranked independently." | |
| ) | |
| ) | |
| }) | |
| output$greenspaceScenarioSummary <- renderUI({ | |
| proposal <- proposed_greenspace() | |
| if (is.null(proposal) || nrow(proposal) == 0) { | |
| return(tags$small( | |
| style = "color:#666;", | |
| "No proposed habitat or corridor drawn." | |
| )) | |
| } | |
| safe_num <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| x <- x[is.finite(x)] | |
| if (length(x) == 0) NA_real_ else x[[1]] | |
| } | |
| gross_m2 <- tryCatch({ | |
| footprint <- proposed_habitat_3857() | |
| if (is.null(footprint) || nrow(footprint) == 0) NA_real_ else | |
| sum(as.numeric(st_area(footprint)), na.rm = TRUE) | |
| }, error = function(e) NA_real_) | |
| proposal_net <- net_proposed_greenspace_3857() | |
| net_m2 <- if (is.null(proposal_net) || nrow(proposal_net) == 0) { | |
| 0 | |
| } else { | |
| sum(as.numeric(st_area(proposal_net)), na.rm = TRUE) | |
| } | |
| selected_metrics <- corridor_selected_isochrone_metrics() | |
| cover_line <- NULL | |
| bai_line <- NULL | |
| if (!is.null(selected_metrics)) { | |
| base_cover <- safe_num(selected_metrics$baseline_raw$Greenspace_percent) | |
| scenario_cover <- safe_num(selected_metrics$scenario_raw$Greenspace_percent) | |
| base_cover_pct <- safe_num(selected_metrics$baseline_bai$pctile_Greenspace_percent) | |
| scenario_cover_pct <- safe_num(selected_metrics$scenario_bai$pctile_Greenspace_percent) | |
| if (is.finite(base_cover) && is.finite(scenario_cover)) { | |
| cover_line <- tags$p( | |
| style = "margin:4px 0 0 0;", | |
| tags$b(paste0(selected_metrics$label, " greenspace cover: ")), | |
| paste0( | |
| round(base_cover, 1), "% (", format_pct(base_cover_pct), ") β ", | |
| round(scenario_cover, 1), "% (", format_pct(scenario_cover_pct), ")" | |
| ) | |
| ) | |
| } | |
| base_bai_value <- 100 * safe_num(selected_metrics$baseline_bai$BAI) | |
| scenario_bai_value <- 100 * safe_num(selected_metrics$scenario_bai$BAI) | |
| base_bai_pct <- safe_num(selected_metrics$baseline_bai$pctile_BAI) | |
| scenario_bai_pct <- safe_num(selected_metrics$scenario_bai$pctile_BAI) | |
| if (is.finite(base_bai_value) && is.finite(scenario_bai_value)) { | |
| bai_line <- tags$p( | |
| style = "margin:2px 0 0 0;", | |
| tags$b("Selected-isochrone BAI: "), | |
| paste0( | |
| round(base_bai_value, 1), "/100 (", format_pct(base_bai_pct), ") β ", | |
| round(scenario_bai_value, 1), "/100 (", format_pct(scenario_bai_pct), ")" | |
| ) | |
| ) | |
| } | |
| } | |
| tagList( | |
| tags$p( | |
| style = "margin:6px 0 0 0;", | |
| tags$b("Your proposed habitat area: "), | |
| if (is.finite(gross_m2)) paste0(round(gross_m2 / 10000, 2), " ha") else "N/A" | |
| ), | |
| tags$p( | |
| style = "margin:2px 0 0 0;", | |
| tags$b("New area not already mapped as greenspace: "), | |
| paste0(round(net_m2 / 10000, 2), " ha") | |
| ), | |
| tags$small( | |
| style = "display:block; color:#555; margin:4px 0 6px 0;", | |
| "The blue area is what the app treats as the intervention. A drawn line is widened using the selected corridor planting width; a drawn polygon is used directly." | |
| ), | |
| cover_line, | |
| bai_line, | |
| tags$small( | |
| style = "color:#666;", | |
| "Scenario changes greenspace cover and BAI only; it does not predict future NDVI or biodiversity." | |
| ) | |
| ) | |
| }) | |
| output$corridorConnectivitySummary <- renderUI({ | |
| c <- corridor_connectivity() | |
| if (is.null(c) || !isTRUE(c$available)) { | |
| return(tags$small( | |
| style = "color:#666;", | |
| if (!is.null(c$message)) c$message else "Connectivity estimate unavailable." | |
| )) | |
| } | |
| fmt_num <- function(x, digits = 0) { | |
| if (is.null(x) || !is.finite(x)) "N/A" else scales::comma(round(x, digits)) | |
| } | |
| fmt_ha <- function(x) { | |
| if (is.null(x) || !is.finite(x)) "N/A" else paste0(round(x, 2), " ha") | |
| } | |
| interpretation <- dplyr::case_when( | |
| c$classification == "Bridge" ~ paste0( | |
| "This proposal brings ", c$linked_patches, | |
| " existing mapped greenspaces from ", c$baseline_components, | |
| " separate baseline networks into one potential corridor network." | |
| ), | |
| c$classification == "Network reinforcement" ~ paste0( | |
| "This proposal adds habitat around ", c$linked_patches, | |
| " existing patches that are already structurally connected under the selected threshold." | |
| ), | |
| c$classification == "Patch expansion" ~ | |
| "This proposal directly overlaps and enlarges one existing mapped greenspace.", | |
| c$classification == "Stepping-stone" ~ | |
| "This proposal creates a potential stepping stone near an existing mapped greenspace, but it does not join separate baseline networks.", | |
| TRUE ~ paste0( | |
| "No existing mapped greenspace lies within the selected ", c$gap_m, | |
| " m connection threshold. The intervention is currently classified as isolated." | |
| ) | |
| ) | |
| tagList( | |
| tags$div( | |
| style = "background:#e3f2fd; border-left:5px solid #1976d2; padding:12px 14px; margin-bottom:12px;", | |
| tags$h3(style = "margin:0 0 4px 0;", c$classification), | |
| tags$p(style = "margin:0; font-size:15px;", interpretation) | |
| ), | |
| fluidRow( | |
| column( | |
| 6, | |
| wellPanel( | |
| tags$h3(style = "margin-top:0;", fmt_num(c$linked_patches)), | |
| tags$b("Existing greenspaces reached"), | |
| tags$br(), | |
| tags$small("Orange and numbered P1, P2, β¦ on the map") | |
| ) | |
| ), | |
| column( | |
| 6, | |
| wellPanel( | |
| tags$h3(style = "margin-top:0;", paste0(c$baseline_components, " β ", c$scenario_components)), | |
| tags$b("Habitat networks: baseline β scenario"), | |
| tags$br(), | |
| tags$small(paste0("Using a ", c$gap_m, " m connection rule")) | |
| ) | |
| ), | |
| column( | |
| 6, | |
| wellPanel( | |
| tags$h3(style = "margin-top:0;", fmt_ha(c$scenario_connected_area_ha)), | |
| tags$b("Potential connected habitat area"), | |
| tags$br(), | |
| tags$small(paste0(fmt_ha(c$existing_linked_area_ha), " existing + ", fmt_ha(c$novel_area_ha), " novel")) | |
| ) | |
| ), | |
| column( | |
| 6, | |
| wellPanel( | |
| tags$h3( | |
| style = "margin-top:0;", | |
| if (is.finite(c$maximum_linked_gap_m)) paste0(fmt_num(c$maximum_linked_gap_m), " m") else "N/A" | |
| ), | |
| tags$b("Largest proposal-to-patch gap"), | |
| tags$br(), | |
| tags$small("Dashed orange lines show these measured gaps") | |
| ) | |
| ) | |
| ), | |
| if (is.finite(c$corridor_length_m) && c$corridor_length_m > 0) { | |
| tags$p( | |
| style = "margin:8px 0 0 0; color:#555;", | |
| paste0( | |
| "Drawn corridor length: ", fmt_num(c$corridor_length_m), | |
| " m; planted width: ", input$corridor_width_m, " m." | |
| ) | |
| ) | |
| }, | |
| tags$small( | |
| style = "color:#666; display:block; margin-top:6px;", | |
| paste0( | |
| "Coverage note: the analysis searches all ", | |
| scales::comma(osm_greenspace_feature_count), | |
| " mapped OSM greenspace polygons loaded for San Francisco, but OSM may not represent every real or ecologically suitable habitat. This result describes structural proximity only; it does not model vegetation quality, roads, mortality risk, or species-specific movement." | |
| ) | |
| ) | |
| ) | |
| }) | |
| output$linkedHabitatTable <- renderDT({ | |
| c <- corridor_connectivity() | |
| if (is.null(c) || !isTRUE(c$available) || | |
| is.null(c$linked_sf) || nrow(c$linked_sf) == 0) { | |
| return(DT::datatable( | |
| data.frame(Message = "No existing mapped greenspace is affected under the selected connection rule."), | |
| rownames = FALSE, | |
| options = list(dom = "t") | |
| )) | |
| } | |
| linked_table <- c$linked_sf |> | |
| st_drop_geometry() |> | |
| transmute( | |
| ID = display_id, | |
| `Existing greenspace` = display_name, | |
| Relationship = relationship, | |
| `Distance to proposal (m)` = round(distance_to_proposal_m), | |
| `Existing area (ha)` = round(patch_area_ha, 2), | |
| `Baseline network` = baseline_component | |
| ) | |
| DT::datatable( | |
| linked_table, | |
| rownames = FALSE, | |
| escape = TRUE, | |
| options = list( | |
| pageLength = 7, | |
| lengthChange = FALSE, | |
| searching = FALSE, | |
| info = FALSE, | |
| scrollX = TRUE, | |
| autoWidth = TRUE | |
| ) | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Corridor scenario exports | |
| # --------------------------------------------------------------------------- | |
| corridor_scenario_name <- reactive({ | |
| raw_name <- input$corridor_scenario_name | |
| nm <- if (is.null(raw_name)) "" else trimws(as.character(raw_name)) | |
| if (!nzchar(nm)) "Green corridor scenario" else nm | |
| }) | |
| safe_export_stem <- function(x) { | |
| x <- iconv(x, to = "ASCII//TRANSLIT", sub = "") | |
| x <- tolower(gsub("[^A-Za-z0-9]+", "_", x)) | |
| x <- gsub("^_+|_+$", "", x) | |
| if (!nzchar(x)) "green_corridor_scenario" else substr(x, 1, 80) | |
| } | |
| # Build a mixed-geometry GeoJSON package containing the analyzed intervention, | |
| # novel habitat, reached existing greenspaces, measured gap lines, the optional | |
| # population-estimate area, and the transferred Explorer location when present. | |
| scenario_export_sf <- reactive({ | |
| footprint <- proposed_habitat_3857() | |
| if (is.null(footprint) || nrow(footprint) == 0) return(NULL) | |
| scenario_name <- corridor_scenario_name() | |
| generated_at <- format(Sys.time(), "%Y-%m-%d %H:%M:%S %Z") | |
| gap_rule <- suppressWarnings(as.numeric(input$connectivity_gap_m)) | |
| width_m <- suppressWarnings(as.numeric(input$corridor_width_m)) | |
| connectivity <- corridor_connectivity() | |
| beneficiaries <- scenario_beneficiaries() | |
| active_iso_info <- corridor_active_iso_info() | |
| active_iso_label <- if (is.null(active_iso_info)) NA_character_ else active_iso_info$label | |
| connectivity_class <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$classification else NA_character_ | |
| baseline_network_count <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$baseline_components else NA_integer_ | |
| scenario_network_count <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$scenario_components else NA_integer_ | |
| linked_patch_count <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$linked_patches else NA_integer_ | |
| potential_population <- if (!is.null(beneficiaries) && isTRUE(beneficiaries$available)) beneficiaries$total_population else NA_real_ | |
| ej_population <- if (!is.null(beneficiaries) && isTRUE(beneficiaries$available)) beneficiaries$ej_population else NA_real_ | |
| make_layer <- function(x, feature_type, feature_id = NULL, | |
| feature_name = NULL, relationship = NULL, | |
| distance_m = NULL, baseline_network = NULL, | |
| walk_minutes = NULL) { | |
| if (is.null(x) || !inherits(x, "sf") || nrow(x) == 0) return(NULL) | |
| x <- tryCatch( | |
| st_zm(x, drop = TRUE, what = "ZM"), | |
| error = function(e) x | |
| ) | |
| x <- suppressWarnings(st_transform(x, 4326)) | |
| n <- nrow(x) | |
| recycle <- function(value, default = NA) { | |
| if (is.null(value)) return(rep(default, n)) | |
| rep(value, length.out = n) | |
| } | |
| st_sf( | |
| scenario_name = rep(scenario_name, n), | |
| feature_type = rep(feature_type, n), | |
| feature_id = as.character(recycle(feature_id, NA_character_)), | |
| feature_name = as.character(recycle(feature_name, NA_character_)), | |
| relationship = as.character(recycle(relationship, NA_character_)), | |
| distance_m = suppressWarnings(as.numeric(recycle(distance_m, NA_real_))), | |
| baseline_network = suppressWarnings(as.integer(recycle(baseline_network, NA_integer_))), | |
| connection_rule_m = rep(gap_rule, n), | |
| selected_explorer_isochrone = rep(active_iso_label, n), | |
| assumed_planted_width_m = rep(width_m, n), | |
| population_walk_minutes = suppressWarnings(as.numeric(recycle(walk_minutes, NA_real_))), | |
| connectivity_classification = rep(connectivity_class, n), | |
| baseline_network_count = rep(baseline_network_count, n), | |
| scenario_network_count = rep(scenario_network_count, n), | |
| linked_patch_count = rep(linked_patch_count, n), | |
| potentially_reached_population = rep(potential_population, n), | |
| ej_population = rep(ej_population, n), | |
| generated_at = rep(generated_at, n), | |
| geometry = st_geometry(x), | |
| crs = 4326 | |
| ) | |
| } | |
| layers <- list( | |
| make_layer( | |
| footprint, | |
| feature_type = "proposed_habitat_area", | |
| feature_id = "proposal", | |
| feature_name = scenario_name, | |
| relationship = "Analyzed footprint" | |
| ) | |
| ) | |
| novel <- net_proposed_greenspace_3857() | |
| if (!is.null(novel) && nrow(novel) > 0) { | |
| layers[[length(layers) + 1]] <- make_layer( | |
| novel, | |
| feature_type = "novel_habitat_area", | |
| feature_id = "novel_area", | |
| feature_name = paste0(scenario_name, " β area not already mapped as greenspace"), | |
| relationship = "Excludes overlap with existing mapped OSM greenspace" | |
| ) | |
| } | |
| if (!is.null(connectivity) && isTRUE(connectivity$available)) { | |
| linked <- connectivity$linked_sf | |
| if (!is.null(linked) && nrow(linked) > 0) { | |
| layers[[length(layers) + 1]] <- make_layer( | |
| linked, | |
| feature_type = "existing_greenspace_reached", | |
| feature_id = linked$display_id, | |
| feature_name = linked$display_name, | |
| relationship = linked$relationship, | |
| distance_m = linked$distance_to_proposal_m, | |
| baseline_network = linked$baseline_component | |
| ) | |
| } | |
| links <- connectivity$links_sf | |
| if (!is.null(links) && nrow(links) > 0) { | |
| layers[[length(layers) + 1]] <- make_layer( | |
| links, | |
| feature_type = "measured_connection_gap", | |
| feature_id = links$display_id, | |
| feature_name = links$patch_name, | |
| relationship = ifelse( | |
| links$distance_m <= 0.01, | |
| "Direct overlap", | |
| paste0(round(links$distance_m), " m measured gap") | |
| ), | |
| distance_m = links$distance_m | |
| ) | |
| } | |
| } | |
| if (isTRUE(input$show_beneficiary_catchment)) { | |
| catchment <- scenario_beneficiary_catchment_3857() | |
| if (!is.null(catchment) && nrow(catchment) > 0) { | |
| layers[[length(layers) + 1]] <- make_layer( | |
| catchment, | |
| feature_type = "population_estimate_area", | |
| feature_id = "population_area", | |
| feature_name = paste0(catchment$walk_minutes[[1]], "-minute approximate population-estimate area"), | |
| relationship = "Straight-line proximity screen; not a routed walkshed", | |
| walk_minutes = catchment$walk_minutes | |
| ) | |
| } | |
| } | |
| ref_pt <- corridor_reference_point() | |
| if (!is.null(ref_pt) && length(ref_pt) >= 2) { | |
| ref_sf <- st_as_sf( | |
| data.frame( | |
| lon = unname(ref_pt[["lon"]]), | |
| lat = unname(ref_pt[["lat"]]) | |
| ), | |
| coords = c("lon", "lat"), | |
| crs = 4326 | |
| ) | |
| layers[[length(layers) + 1]] <- make_layer( | |
| ref_sf, | |
| feature_type = "explorer_reference_location", | |
| feature_id = "explorer_location", | |
| feature_name = "Location transferred from Isochrone Explorer", | |
| relationship = "Context point only" | |
| ) | |
| } | |
| layers <- Filter(function(x) !is.null(x) && nrow(x) > 0, layers) | |
| if (length(layers) == 0) return(NULL) | |
| do.call(rbind, layers) | |
| }) | |
| html_value <- function(x) { | |
| htmltools::htmlEscape(as.character(x), attribute = FALSE) | |
| } | |
| report_metric <- function(label, value, detail = NULL) { | |
| paste0( | |
| '<div class="metric"><div class="metric-label">', html_value(label), '</div>', | |
| '<div class="metric-value">', html_value(value), '</div>', | |
| if (!is.null(detail) && nzchar(detail)) paste0('<div class="metric-detail">', html_value(detail), '</div>') else "", | |
| '</div>' | |
| ) | |
| } | |
| # Draw a lightweight static spatial summary for the downloadable report. This | |
| # avoids external map tiles and keeps the report self-contained and printable. | |
| write_corridor_report_map <- function(file) { | |
| footprint <- proposed_habitat_3857() | |
| connectivity <- corridor_connectivity() | |
| if (is.null(footprint) || nrow(footprint) == 0) return(FALSE) | |
| linked <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$linked_sf else NULL | |
| links <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$links_sf else NULL | |
| catchment <- if (isTRUE(input$show_beneficiary_catchment)) scenario_beneficiary_catchment_3857() else NULL | |
| focus_parts <- list(st_geometry(footprint)) | |
| if (!is.null(linked) && nrow(linked) > 0) focus_parts[[length(focus_parts) + 1]] <- st_geometry(linked) | |
| focus_geom <- do.call(c, focus_parts) | |
| bb <- st_bbox(focus_geom) | |
| pad_x <- max(as.numeric(bb[["xmax"]] - bb[["xmin"]]) * 0.18, 250) | |
| pad_y <- max(as.numeric(bb[["ymax"]] - bb[["ymin"]]) * 0.18, 250) | |
| map_bb <- st_bbox(c( | |
| xmin = bb[["xmin"]] - pad_x, | |
| ymin = bb[["ymin"]] - pad_y, | |
| xmax = bb[["xmax"]] + pad_x, | |
| ymax = bb[["ymax"]] + pad_y | |
| ), crs = st_crs(3857)) | |
| local_green <- tryCatch( | |
| suppressWarnings(st_crop(osm_greenspace_3857, map_bb)), | |
| error = function(e) osm_greenspace_3857[0, , drop = FALSE] | |
| ) | |
| local_cbg <- tryCatch( | |
| suppressWarnings(st_crop(st_transform(cbg_vect_sf, 3857), map_bb)), | |
| error = function(e) NULL | |
| ) | |
| grDevices::png(file, width = 1500, height = 920, res = 170, bg = "white") | |
| on.exit(grDevices::dev.off(), add = TRUE) | |
| graphics::par(mar = c(0.3, 0.3, 1.1, 0.3), xaxs = "i", yaxs = "i") | |
| graphics::plot( | |
| st_geometry(footprint), | |
| col = NA, border = NA, | |
| xlim = c(map_bb[["xmin"]], map_bb[["xmax"]]), | |
| ylim = c(map_bb[["ymin"]], map_bb[["ymax"]]), | |
| axes = FALSE, asp = 1 | |
| ) | |
| if (!is.null(local_cbg) && nrow(local_cbg) > 0) { | |
| graphics::plot(st_geometry(local_cbg), add = TRUE, col = "#f5f5f5", border = "#d8d8d8", lwd = 0.5) | |
| } | |
| if (!is.null(catchment) && nrow(catchment) > 0) { | |
| graphics::plot(st_geometry(catchment), add = TRUE, col = grDevices::adjustcolor("#ce93d8", alpha.f = 0.20), border = "#6a1b9a", lty = 2, lwd = 2) | |
| } | |
| if (!is.null(local_green) && nrow(local_green) > 0) { | |
| graphics::plot(st_geometry(local_green), add = TRUE, col = "#b7d9b0", border = "#2e7d32", lwd = 0.8) | |
| } | |
| if (!is.null(linked) && nrow(linked) > 0) { | |
| graphics::plot(st_geometry(linked), add = TRUE, col = "#ffb74d", border = "#e65100", lwd = 2.2) | |
| } | |
| graphics::plot(st_geometry(footprint), add = TRUE, col = grDevices::adjustcolor("#42a5f5", alpha.f = 0.55), border = "#0d47a1", lwd = 2.4) | |
| if (!is.null(links) && nrow(links) > 0) { | |
| graphics::plot(st_geometry(links), add = TRUE, col = "#ef6c00", lty = 2, lwd = 2) | |
| } | |
| if (!is.null(linked) && nrow(linked) > 0) { | |
| pts <- suppressWarnings(st_point_on_surface(linked)) | |
| xy <- st_coordinates(pts) | |
| graphics::text(xy[, 1], xy[, 2], labels = linked$display_id, cex = 0.72, font = 2, col = "#7f2704") | |
| } | |
| graphics::legend( | |
| "bottomleft", | |
| legend = c("Existing greenspace", "Proposal", "Existing greenspaces reached", "Measured gap"), | |
| fill = c("#b7d9b0", "#42a5f5", "#ffb74d", NA), | |
| border = c("#2e7d32", "#0d47a1", "#e65100", NA), | |
| lty = c(NA, NA, NA, 2), | |
| col = c(NA, NA, NA, "#ef6c00"), | |
| lwd = c(NA, NA, NA, 2), | |
| bg = grDevices::adjustcolor("white", alpha.f = 0.90), | |
| cex = 0.72, | |
| bty = "o" | |
| ) | |
| graphics::mtext("Screening map β schematic context without street basemap", side = 3, line = 0.15, cex = 0.72, col = "#555555") | |
| TRUE | |
| } | |
| output$download_corridor_report <- downloadHandler( | |
| filename = function() { | |
| paste0(safe_export_stem(corridor_scenario_name()), "_screening_report.html") | |
| }, | |
| contentType = "text/html", | |
| content = function(file) { | |
| footprint <- proposed_habitat_3857() | |
| if (is.null(footprint) || nrow(footprint) == 0) { | |
| stop("Draw a corridor line or habitat polygon before downloading a report.") | |
| } | |
| connectivity <- corridor_connectivity() | |
| beneficiaries <- scenario_beneficiaries() | |
| gross_ha <- sum(as.numeric(st_area(footprint)), na.rm = TRUE) / 10000 | |
| novel <- net_proposed_greenspace_3857() | |
| novel_ha <- if (is.null(novel) || nrow(novel) == 0) 0 else sum(as.numeric(st_area(novel)), na.rm = TRUE) / 10000 | |
| fmt_num <- function(x, digits = 0) if (is.null(x) || !is.finite(x)) "Not available" else scales::comma(round(x, digits)) | |
| fmt_ha <- function(x) if (is.null(x) || !is.finite(x)) "Not available" else paste0(round(x, 2), " ha") | |
| fmt_pct <- function(x) if (is.null(x) || !is.finite(x)) "Not available" else paste0(round(x, 1), "%") | |
| fmt_money <- function(x) if (is.null(x) || !is.finite(x)) "Not available" else scales::dollar(round(x)) | |
| class_value <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$classification else "Not available" | |
| linked_value <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$linked_patches else NA_real_ | |
| network_value <- if (!is.null(connectivity) && isTRUE(connectivity$available)) paste0(connectivity$baseline_components, " β ", connectivity$scenario_components) else "Not available" | |
| connected_area <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$scenario_connected_area_ha else NA_real_ | |
| largest_gap <- if (!is.null(connectivity) && isTRUE(connectivity$available)) connectivity$maximum_linked_gap_m else NA_real_ | |
| gap_rule <- suppressWarnings(as.numeric(input$connectivity_gap_m)) | |
| width_m <- suppressWarnings(as.numeric(input$corridor_width_m)) | |
| selected_access <- corridor_selected_isochrone_metrics() | |
| active_iso_label <- if (is.null(selected_access)) "No Explorer isochrone selected" else selected_access$label | |
| bai_base <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$baseline_bai$BAI[[1]])) * 100 else NA_real_ | |
| bai_scenario <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$scenario_bai$BAI[[1]])) * 100 else NA_real_ | |
| bai_pct_base <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$baseline_bai$pctile_BAI[[1]])) else NA_real_ | |
| bai_pct_scenario <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$scenario_bai$pctile_BAI[[1]])) else NA_real_ | |
| cover_base <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$baseline_raw$Greenspace_percent[[1]])) else NA_real_ | |
| cover_scenario <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$scenario_raw$Greenspace_percent[[1]])) else NA_real_ | |
| cover_pct_base <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$baseline_bai$pctile_Greenspace_percent[[1]])) else NA_real_ | |
| cover_pct_scenario <- if (!is.null(selected_access)) suppressWarnings(as.numeric(selected_access$scenario_bai$pctile_Greenspace_percent[[1]])) else NA_real_ | |
| map_file <- tempfile(fileext = ".png") | |
| on.exit(unlink(map_file), add = TRUE) | |
| map_ok <- tryCatch(write_corridor_report_map(map_file), error = function(e) FALSE) | |
| map_uri <- if (isTRUE(map_ok) && file.exists(map_file) && requireNamespace("base64enc", quietly = TRUE)) { | |
| paste0("data:image/png;base64,", base64enc::base64encode(map_file)) | |
| } else { | |
| NULL | |
| } | |
| corridor_detail <- if (!is.null(connectivity) && isTRUE(connectivity$available) && | |
| is.finite(connectivity$corridor_length_m) && connectivity$corridor_length_m > 0) { | |
| paste0("Route length: ", fmt_num(connectivity$corridor_length_m), " m; assumed planted width: ", fmt_num(width_m), " m") | |
| } else { | |
| paste0("Assumed line width: ", fmt_num(width_m), " m; polygons retain their drawn area") | |
| } | |
| access_value <- if (is.finite(cover_base) && is.finite(cover_scenario) && | |
| is.finite(bai_base) && is.finite(bai_scenario)) { | |
| paste0("Cover ", round(cover_base, 1), "% β ", round(cover_scenario, 1), "%") | |
| } else { | |
| "Generate isochrones to calculate" | |
| } | |
| access_detail <- if (is.finite(bai_base) && is.finite(bai_scenario)) { | |
| paste0( | |
| active_iso_label, | |
| "; greenspace percentile: ", round(cover_pct_base, 1), " β ", round(cover_pct_scenario, 1), | |
| "; BAI score: ", round(bai_base, 1), " β ", round(bai_scenario, 1), "/100", | |
| "; BAI citywide percentile: ", round(bai_pct_base, 1), " β ", round(bai_pct_scenario, 1), | |
| "; only greenspace cover changes" | |
| ) | |
| } else { | |
| "BAI comparison requires a selected isochrone from the Explorer" | |
| } | |
| metric_html <- paste0( | |
| report_metric("Connectivity classification", class_value, paste0("Using a ", fmt_num(gap_rule), " m connection rule")), | |
| report_metric("Existing greenspaces reached", fmt_num(linked_value), "Mapped OSM greenspaces within the selected rule"), | |
| report_metric("Habitat networks", network_value, "Separate baseline groups β potential scenario group"), | |
| report_metric("Potential connected habitat", fmt_ha(connected_area), "Existing reached area plus novel proposed area"), | |
| report_metric("Proposed habitat area", fmt_ha(gross_ha), corridor_detail), | |
| report_metric("Novel habitat area", fmt_ha(novel_ha), "Excludes overlap with existing mapped greenspace"), | |
| report_metric("Largest proposal-to-patch gap", if (is.finite(largest_gap)) paste0(fmt_num(largest_gap), " m") else "Not available", "Measured straight-line separation"), | |
| report_metric( | |
| "Potentially reached residents", | |
| if (!is.null(beneficiaries) && isTRUE(beneficiaries$available)) fmt_num(beneficiaries$total_population) else "Not available", | |
| if (!is.null(beneficiaries) && isTRUE(beneficiaries$available)) paste0("Approximate ", beneficiaries$walk_minutes, "-minute area; lower-greenspace residents: ", fmt_num(beneficiaries$low_access_population)) else "" | |
| ), | |
| report_metric( | |
| "Residents in highest-burden SF EJ quartile", | |
| if (!is.null(beneficiaries) && isTRUE(beneficiaries$available)) fmt_num(beneficiaries$ej_population) else "Not available", | |
| if (!is.null(beneficiaries) && isTRUE(beneficiaries$available)) { | |
| paste0( | |
| fmt_pct(beneficiaries$ej_share), | |
| " of potentially reached residents", | |
| if (is.finite(beneficiaries$ej_vulnerability_threshold)) { | |
| paste0( | |
| "; SF EJ score β₯ ", | |
| round(beneficiaries$ej_vulnerability_threshold, 1), | |
| " (75th-percentile cutoff)" | |
| ) | |
| } else "" | |
| ) | |
| } else "" | |
| ), | |
| report_metric("Selected isochrone access comparison", access_value, access_detail) | |
| ) | |
| linked_rows <- "" | |
| linked_note <- "No existing mapped greenspaces were reached under the selected rule." | |
| if (!is.null(connectivity) && isTRUE(connectivity$available) && | |
| !is.null(connectivity$linked_sf) && nrow(connectivity$linked_sf) > 0) { | |
| linked_df <- connectivity$linked_sf |> | |
| st_drop_geometry() |> | |
| transmute( | |
| ID = display_id, | |
| Name = display_name, | |
| Relationship = relationship, | |
| `Area (ha)` = round(patch_area_ha, 2), | |
| `Baseline network` = baseline_component | |
| ) |> | |
| slice_head(n = 10) | |
| linked_rows <- paste0(apply(linked_df, 1, function(row) { | |
| paste0( | |
| "<tr>", | |
| paste0("<td>", vapply(row, html_value, character(1)), "</td>", collapse = ""), | |
| "</tr>" | |
| ) | |
| }), collapse = "") | |
| linked_note <- paste0( | |
| "Showing ", nrow(linked_df), " of ", nrow(connectivity$linked_sf), | |
| " reached greenspaces. The table below summarizes the mapped features included in this report card." | |
| ) | |
| } | |
| map_html <- if (!is.null(map_uri)) { | |
| paste0('<img class="map-image" src="', map_uri, '" alt="Static map of the proposed corridor and existing greenspaces reached">') | |
| } else { | |
| '<div class="map-unavailable">Static report map unavailable.</div>' | |
| } | |
| scenario_name <- html_value(corridor_scenario_name()) | |
| generated <- html_value(format(Sys.time(), "%Y-%m-%d %H:%M %Z")) | |
| source_count <- html_value(scales::comma(osm_greenspace_feature_count)) | |
| html <- paste0( | |
| '<!doctype html><html lang="en"><head><meta charset="utf-8">', | |
| '<meta name="viewport" content="width=device-width,initial-scale=1">', | |
| '<title>', scenario_name, ' β corridor screening report</title>', | |
| '<style>', | |
| '@page{size:letter landscape;margin:0.35in}*{box-sizing:border-box}', | |
| 'body{margin:0;background:#eef3f1;color:#23352d;font-family:Arial,Helvetica,sans-serif;font-size:10px}', | |
| '.page{width:100%;max-width:11in;min-height:7.6in;margin:0 auto;background:white;padding:18px 22px}', | |
| 'header{display:flex;justify-content:space-between;gap:20px;border-bottom:3px solid #2e7d32;padding-bottom:8px;margin-bottom:10px}', | |
| 'h1{font-size:22px;margin:0;color:#205c35}h2{font-size:13px;margin:0 0 6px;color:#205c35}', | |
| '.subtitle{margin-top:4px;color:#56645d;font-size:11px}.meta{text-align:right;color:#56645d;white-space:nowrap}', | |
| '.main{display:grid;grid-template-columns:1.05fr .95fr;gap:12px}.panel{border:1px solid #cfd9d3;border-radius:7px;padding:9px;background:#fff}', | |
| '.map-image{display:block;width:100%;height:305px;object-fit:contain}.map-unavailable{height:305px;display:flex;align-items:center;justify-content:center;background:#f4f4f4;color:#666}', | |
| '.metrics{display:grid;grid-template-columns:1fr 1fr;gap:7px}.metric{border-left:4px solid #1976d2;background:#f3f8fb;padding:7px 8px;min-height:58px}', | |
| '.metric-label{font-size:9px;color:#4d5d55;text-transform:uppercase;letter-spacing:.03em}.metric-value{font-size:16px;font-weight:700;color:#173d2a;margin-top:2px}', | |
| '.metric-detail{font-size:8.5px;color:#66736c;margin-top:2px;line-height:1.25}', | |
| '.lower{display:grid;grid-template-columns:1.15fr .85fr;gap:12px;margin-top:10px}', | |
| 'table{width:100%;border-collapse:collapse;font-size:8.5px}th,td{border-bottom:1px solid #dfe6e1;padding:3px 4px;text-align:left;vertical-align:top}th{background:#edf5ef;color:#2f5f3e}', | |
| '.note{background:#fff8e1;border-left:4px solid #f9a825;padding:8px 10px;line-height:1.35}.limitations{margin:4px 0 0 16px;padding:0}.limitations li{margin:2px 0}', | |
| '.screening{display:inline-block;background:#e3f2fd;color:#165b8b;border-radius:999px;padding:3px 8px;font-weight:700;margin-bottom:5px}', | |
| '.print-btn{position:fixed;right:14px;top:14px;background:#205c35;color:white;border:0;border-radius:5px;padding:8px 11px;cursor:pointer}', | |
| '.footer{margin-top:8px;padding-top:6px;border-top:1px solid #dfe6e1;color:#68756e;font-size:8.5px;display:flex;justify-content:space-between;gap:12px}', | |
| '@media print{body{background:white}.page{padding:0;max-width:none}.print-btn{display:none}}', | |
| '@media(max-width:850px){.main,.lower{grid-template-columns:1fr}.page{min-height:auto}.map-image{height:auto}}', | |
| '</style></head><body>', | |
| '<button class="print-btn" onclick="window.print()">Print / Save as PDF</button>', | |
| '<main class="page">', | |
| '<header><div><div class="screening">Preliminary screening report</div><h1>', scenario_name, '</h1>', | |
| '<div class="subtitle">Green Corridor Planner β habitat connectivity, greenspace access, and equity context</div></div>', | |
| '<div class="meta"><b>Generated</b><br>', generated, '<br><b>Connection rule</b><br>', html_value(fmt_num(gap_rule)), ' m</div></header>', | |
| '<section class="main"><div class="panel"><h2>Scenario map</h2>', map_html, | |
| '<div style="margin-top:5px;color:#647169">Green = existing mapped greenspace; blue = proposal; orange = existing greenspaces reached; dashed orange = measured gaps.</div></div>', | |
| '<div class="panel"><h2>Key screening results</h2><div class="metrics">', metric_html, '</div></div></section>', | |
| '<section class="lower"><div class="panel"><h2>Existing greenspaces reached</h2>', | |
| '<table><thead><tr><th>ID</th><th>Name</th><th>Relationship</th><th>Area (ha)</th><th>Baseline network</th></tr></thead><tbody>', linked_rows, '</tbody></table>', | |
| '<div style="margin-top:5px;color:#68756e">', html_value(linked_note), '</div></div>', | |
| '<div class="note"><h2>Interpretation and limitations</h2><ul class="limitations">', | |
| '<li>Connectivity is structural proximity under the selected distance rule; it is not evidence of actual wildlife movement.</li>', | |
| '<li>The analysis uses ', source_count, ' mapped OSM greenspace polygons loaded for San Francisco. OSM may omit or misclassify ecologically useful spaces.</li>', | |
| '<li>Population estimates use area-weighted census block groups and assume residents are uniformly distributed within each block group.</li>', | |
| '<li>The population-estimate area is a straight-line proximity buffer, not a routed pedestrian walkshed.</li>', | |
| '<li>Land ownership, street design, utilities, slopes, vegetation quality, road barriers, maintenance, cost, and community preferences are not assessed.</li>', | |
| '<li>Field assessment, agency review, ecological expertise, and community consultation are required before implementation.</li>', | |
| '</ul></div></section>', | |
| '<div class="footer"><span>Print or save this HTML report card as PDF if needed.</span><span>SF Biodiversity Access Decision Support Tool</span></div>', | |
| '</main></body></html>' | |
| ) | |
| writeLines(html, con = file, useBytes = TRUE) | |
| } | |
| ) | |
| output$greenspaceBeneficiarySummary <- renderUI({ | |
| b <- scenario_beneficiaries() | |
| if (is.null(b) || !isTRUE(b$available)) { | |
| return(tags$small(style = "color:#666;", if (!is.null(b$message)) b$message else "Beneficiary estimates unavailable.")) | |
| } | |
| format_people <- function(x) { | |
| if (!is.finite(x)) "N/A" else scales::comma(round(x)) | |
| } | |
| format_pct <- function(x) { | |
| if (!is.finite(x)) "N/A" else paste0(round(x, 1), "%") | |
| } | |
| format_income <- function(x) { | |
| if (!is.finite(x)) "N/A" else scales::dollar(round(x)) | |
| } | |
| fluidRow( | |
| column( | |
| 3, | |
| wellPanel( | |
| tags$h3(format_people(b$total_population)), | |
| tags$b("Potentially served residents"), | |
| tags$br(), | |
| tags$small(paste0("Within the ", b$walk_minutes, "-minute catchment")) | |
| ) | |
| ), | |
| column( | |
| 3, | |
| wellPanel( | |
| tags$h3(format_people(b$ej_population)), | |
| tags$b("Residents in highest-burden SF EJ quartile"), | |
| tags$br(), | |
| tags$small( | |
| paste0( | |
| format_pct(b$ej_share), | |
| " of potentially served residents", | |
| if (is.finite(b$ej_vulnerability_threshold)) { | |
| paste0( | |
| " Β· SF EJ score β₯ ", | |
| round(b$ej_vulnerability_threshold, 1), | |
| " (75th-percentile cutoff)" | |
| ) | |
| } else "" | |
| ) | |
| ) | |
| ) | |
| ), | |
| column( | |
| 3, | |
| wellPanel( | |
| tags$h3(format_people(b$low_access_population)), | |
| tags$b("Residents in lower-greenspace CBGs"), | |
| tags$br(), | |
| tags$small( | |
| if (is.finite(b$low_access_threshold)) { | |
| paste0("Existing cover below city median: ", round(b$low_access_threshold, 1), "%") | |
| } else { | |
| "Existing greenspace benchmark unavailable" | |
| } | |
| ) | |
| ) | |
| ), | |
| column( | |
| 3, | |
| wellPanel( | |
| tags$h3(format_income(b$weighted_income)), | |
| tags$b("Income context"), | |
| tags$br(), | |
| tags$small("Population-weighted average of CBG median incomes") | |
| ) | |
| ), | |
| column( | |
| 12, | |
| tags$p( | |
| style = "margin:0; color:#555;", | |
| tags$b("Method: "), | |
| paste0( | |
| b$walk_minutes, " minutes Γ ", walk_speed_m_per_min, | |
| " m/min = ", round(b$walk_distance_m), | |
| " m straight-line buffer; ", b$n_cbgs, | |
| " census block groups intersect; catchment area ", | |
| round(b$catchment_area_km2, 2), " kmΒ²." | |
| ) | |
| ), | |
| if (is.finite(b$mean_ej_score)) { | |
| tags$p( | |
| style = "margin:2px 0 0 0; color:#555;", | |
| paste0("Area-weighted SF EJ score within the catchment: ", round(b$mean_ej_score, 1), ".") | |
| ) | |
| }, | |
| tags$small( | |
| style = "color:#666;", | |
| "Population is apportioned by intersected CBG area and therefore assumes residents are uniformly distributed within each block group. The vulnerable-community count uses only SF EJ polygons in the highest-burden quartile (raw score at or above the citywide 75th-percentile cutoff, excluding score 0). This is not a network-routed walkshed." | |
| ) | |
| ) | |
| ) | |
| }) | |
| output$greenspaceScenarioDiffTable <- renderDT({ | |
| comparison <- greenspace_scenario_comparison() | |
| if (is.null(comparison)) { | |
| return(DT::datatable(data.frame(Message = "Draw proposed greenspace after generating isochrones."))) | |
| } | |
| safe_value <- function(x) { | |
| x <- suppressWarnings(as.numeric(x)) | |
| x <- x[is.finite(x)] | |
| if (length(x) == 0) NA_real_ else x[[1]] | |
| } | |
| base <- comparison$baseline | |
| scen <- comparison$scenario | |
| base_raw <- comparison$baseline_raw | |
| scen_raw <- comparison$scenario_raw | |
| spec <- list( | |
| list("ACCESS β Transit access (raw stops/kmΒ²)", "raw", "Transit_Access_Score", 1), | |
| list("ACCESS β Transit access percentile", "pct", "pctile_Transit_Access_Score", 1), | |
| list("ACCESS β Muni routes (raw)", "raw", "Unique_Muni_Routes", 0), | |
| list("ACCESS β Muni route percentile", "pct", "pctile_Unique_Muni_Routes", 1), | |
| list("BIODIVERSITY β Total species (raw)", "raw", "GBIF_Species", 0), | |
| list("BIODIVERSITY β Species richness percentile", "pct", "pctile_GBIF_Species", 1), | |
| list("BIODIVERSITY β Sampling density (raw records/kmΒ²)", "raw", "SamplingDensity_km2", 1), | |
| list("BIODIVERSITY β Sampling-density percentile", "pct", "pctile_SamplingDensity_km2", 1), | |
| list("GREEN β Mean NDVI (raw)", "raw", "MeanNDVI", 3), | |
| list("GREEN β NDVI percentile", "pct", "pctile_MeanNDVI", 1), | |
| list("GREEN β Greenspace cover (raw %)", "raw", "Greenspace_percent", 1), | |
| list("GREEN β Greenspace-cover percentile", "pct", "pctile_Greenspace_percent", 1), | |
| list("EQUITY β SF EJ score (raw)", "raw", "SF_EJ_Score", 1), | |
| list("EQUITY β Favorable equity-context percentile", "pct", "pctile_SF_EJ_Score", 1), | |
| list("COMPOSITE β BAI score (/100)", "bai", "BAI", 1), | |
| list("COMPOSITE β BAI citywide percentile", "pct_bai", "pctile_BAI", 1) | |
| ) | |
| one <- function(s, which_side = c("baseline", "scenario")) { | |
| which_side <- match.arg(which_side) | |
| source_raw <- if (which_side == "baseline") base_raw else scen_raw | |
| source_bai <- if (which_side == "baseline") base else scen | |
| kind <- s[[2]] | |
| nm <- s[[3]] | |
| value <- if (kind == "raw") { | |
| if (nm %in% names(source_raw)) safe_value(source_raw[[nm]]) else NA_real_ | |
| } else if (kind == "bai") { | |
| if (nm %in% names(source_bai)) 100 * safe_value(source_bai[[nm]]) else NA_real_ | |
| } else { | |
| if (nm %in% names(source_bai)) safe_value(source_bai[[nm]]) else NA_real_ | |
| } | |
| value | |
| } | |
| baseline_values <- vapply(spec, one, numeric(1), which_side = "baseline") | |
| scenario_values <- vapply(spec, one, numeric(1), which_side = "scenario") | |
| tbl <- data.frame( | |
| Metric = vapply(spec, function(x) x[[1]], character(1)), | |
| Baseline = vapply(seq_along(spec), function(i) round(baseline_values[[i]], spec[[i]][[4]]), numeric(1)), | |
| Scenario = vapply(seq_along(spec), function(i) round(scenario_values[[i]], spec[[i]][[4]]), numeric(1)), | |
| Difference = vapply(seq_along(spec), function(i) round(scenario_values[[i]] - baseline_values[[i]], spec[[i]][[4]]), numeric(1)), | |
| check.names = FALSE | |
| ) | |
| DT::datatable( | |
| tbl, | |
| rownames = FALSE, | |
| options = list(dom = "t", ordering = FALSE, paging = FALSE) | |
| ) |> | |
| DT::formatStyle("Baseline", color = "#555555", fontWeight = "bold") |> | |
| DT::formatStyle("Scenario", color = "#238b45", fontWeight = "bold") |> | |
| DT::formatStyle( | |
| "Difference", | |
| fontWeight = "bold", | |
| backgroundColor = DT::styleInterval(c(-0.05, 0.05), c("#fde0dd", "#f7f7f7", "#e5f5e0")) | |
| ) | |
| }) | |
| output$closestGreenspaceUI <- renderUI({ | |
| df <- base_socio_data() | |
| if (nrow(df) == 0) return(NULL) | |
| gs_name <- attr(df, "closest_greenspace") | |
| gs_dist <- attr(df, "closest_greenspace_dist_m") | |
| if (is.null(gs_name) || is.na(gs_name)) gs_name <- "None" | |
| cover_rows <- lapply(seq_len(nrow(df)), function(i) { | |
| raw_cover <- if ("Greenspace_percent" %in% names(df)) { | |
| suppressWarnings(as.numeric(df$Greenspace_percent[[i]])) | |
| } else NA_real_ | |
| pct_cover <- if ("pctile_Greenspace_percent" %in% names(df)) { | |
| suppressWarnings(as.numeric(df$pctile_Greenspace_percent[[i]])) | |
| } else NA_real_ | |
| tags$div( | |
| style = "padding:5px 0; border-bottom:1px solid #eee;", | |
| tags$b(iso_metric_row_label(df, i)), | |
| tags$br(), | |
| tags$small( | |
| "Cover: ", | |
| if (is.finite(raw_cover)) paste0(round(raw_cover, 1), "%") else "N/A", | |
| " Β· percentile: ", format_pct(pct_cover) | |
| ) | |
| ) | |
| }) | |
| tagList( | |
| strong("Closest Greenspace to selected point:"), | |
| p(gs_name), | |
| if (!is.null(gs_dist) && !is.na(gs_dist)) p(paste0("Distance: ", round(gs_dist, 1), " m")), | |
| hr(), | |
| strong("Greenspace Cover by Isochrone:"), | |
| tags$div(style = "max-height:220px; overflow-y:auto;", cover_rows), | |
| tags$small( | |
| style = "display:block; color:#666; margin-top:5px;", | |
| "No averaging across generated isochrones." | |
| ) | |
| ) | |
| }) | |
| output$closestRSFProgramUI <- renderUI({ | |
| df <- base_socio_data() | |
| if (nrow(df) == 0) return(NULL) | |
| rsf_name <- attr(df, "closest_rsf_program") | |
| rsf_dist <- attr(df, "closest_rsf_program_dist_m") | |
| if (is.null(rsf_name) || is.na(rsf_name)) rsf_name <- "None" | |
| tagList( | |
| strong("Nearest RSF program (to your selected point):"), | |
| p(rsf_name), | |
| if (!is.null(rsf_dist) && !is.na(rsf_dist)) p(paste0("Distance: ", round(rsf_dist, 1), " m")) | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # GBIF tab | |
| # --------------------------------------------------------------------------- | |
| output$classTable <- renderDT({ | |
| if (is.null(gbif_tab)) { | |
| return(DT::datatable(data.frame(Message = "GBIF parquet connection not available."))) | |
| } | |
| q <- gbif_tab | |
| if (input$class_filter != "All") { | |
| q <- q |> filter(class == input$class_filter) | |
| } | |
| if (input$family_filter != "All") { | |
| q <- q |> filter(family == input$family_filter) | |
| } | |
| species_counts <- tryCatch({ | |
| q |> | |
| group_by(species) |> | |
| summarise( | |
| n_records = n(), | |
| .groups = "drop" | |
| ) |> | |
| arrange(desc(n_records)) |> | |
| collect() | |
| }, error = function(e) NULL) | |
| if (is.null(species_counts) || nrow(species_counts) == 0) { | |
| return(DT::datatable(data.frame(Message = "No records for that combination."))) | |
| } | |
| DT::datatable(species_counts, options = list(pageLength = 10), rownames = FALSE) | |
| }) | |
| filtered_data <- reactive({ | |
| data <- cbg_vect_sf | |
| if ("class" %in% names(data) && input$class_filter != "All") { | |
| data <- data[data$class == input$class_filter, ] | |
| } | |
| if ("family" %in% names(data) && input$family_filter != "All") { | |
| data <- data[data$family == input$family_filter, ] | |
| } | |
| data | |
| }) | |
| output$obsVsSpeciesPlot <- renderPlot({ | |
| data <- filtered_data() | |
| if (nrow(data) == 0) { | |
| plot.new() | |
| title("No data available for selected filters.") | |
| return(NULL) | |
| } | |
| ggplot(data, aes(x = log(n_observations + 1), y = log(unique_species + 1))) + | |
| geom_point(color = "blue", alpha = 0.6) + | |
| labs( | |
| x = "Log(Number of Observations + 1)", | |
| y = "Log(Species Richness + 1)", | |
| title = "Data Availability vs. Species Richness" | |
| ) + | |
| theme_minimal(base_size = 14) + | |
| theme(plot.title = element_text(hjust = 0.5, size = 16, face = "bold")) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Main plots | |
| # --------------------------------------------------------------------------- | |
| output$transitMetricsPlot <- renderPlot({ | |
| df <- base_socio_data() | |
| if (nrow(df) == 0) return(NULL) | |
| df_long <- df |> | |
| mutate(IsoLabel = paste0(Mode, "\n", Time, " min")) |> | |
| select(IsoLabel, Mode, GBIF_Records, GBIF_Species, Transit_Stops, Unique_Muni_Routes) |> | |
| pivot_longer( | |
| cols = c(GBIF_Records, GBIF_Species, Transit_Stops, Unique_Muni_Routes), | |
| names_to = "Metric", | |
| values_to = "Value" | |
| ) |> | |
| mutate(Metric = recode( | |
| Metric, | |
| "GBIF_Records" = "GBIF Records", | |
| "GBIF_Species" = "Unique Species", | |
| "Transit_Stops" = "Muni Stops (n)", | |
| "Unique_Muni_Routes" = "Muni Routes (n)" | |
| )) | |
| ggplot(df_long, aes(x = IsoLabel, y = Value, fill = Mode)) + | |
| geom_col(alpha = 0.85, width = 0.7) + | |
| geom_text(aes(label = Value), vjust = -0.4, size = 3.2, color = "grey30") + | |
| scale_fill_manual(values = mode_palette, name = "Mode") + | |
| facet_wrap(~Metric, scales = "free_y", ncol = 2) + | |
| labs( | |
| x = NULL, | |
| y = "Count", | |
| title = "Biodiversity & Transit Metrics by Transport Mode" | |
| ) + | |
| theme_minimal(base_size = 13) + | |
| theme( | |
| axis.text.x = element_text(angle = 45, hjust = 1, size = 10), | |
| plot.title = element_text(hjust = 0.5, size = 15, face = "bold"), | |
| strip.text = element_text(face = "bold", size = 12), | |
| legend.position = "bottom" | |
| ) | |
| }) | |
| output$radarPlot <- renderPlot({ | |
| df <- baseline_biodiversity_access_index() | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| draw_reference_radar( | |
| df, | |
| title = "Citywide Biodiversity Access Profile", | |
| subtitle = "Each line is one independently scored isochrone; farther outward = higher / more favourable percentile.", | |
| plot_style = "standard" | |
| ) | |
| }) | |
| output$corridorRadarPlot <- renderPlot({ | |
| comparison <- greenspace_scenario_comparison() | |
| if (is.null(comparison)) return(NULL) | |
| draw_reference_radar( | |
| bai_rows = list(comparison$baseline, comparison$scenario), | |
| labels = c("Existing conditions", "Planned conservation action"), | |
| colors = c("#6b6b6b", "#238b45"), | |
| title = "Planned Conservation Action β Before vs After", | |
| subtitle = paste0( | |
| comparison$label, | |
| " Β· dashed = existing conditions Β· solid green = planned action Β· Ξ labels show changed percentile dimensions" | |
| ), | |
| plot_style = "before_after" | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Community science tab | |
| # --------------------------------------------------------------------------- | |
| community_sf <- safe_partner_orgs() | |
| output$communityMap <- renderLeaflet({ | |
| if (is.null(community_sf) || nrow(community_sf) == 0) { | |
| return( | |
| leaflet() |> | |
| addTiles() |> | |
| setView(lng = -122.4194, lat = 37.7749, zoom = 12) | |
| ) | |
| } | |
| name_col <- intersect(c("name", "Name", "organization", "organization_name", "org_name"), names(community_sf)) | |
| popup_col <- if (length(name_col) > 0) name_col[1] else names(community_sf)[1] | |
| leaflet(community_sf) |> | |
| addTiles() |> | |
| addCircleMarkers( | |
| radius = 6, | |
| color = "#2e8b57", | |
| fillOpacity = 0.9, | |
| label = ~as.character(.data[[popup_col]]), | |
| popup = ~as.character(.data[[popup_col]]) | |
| ) |> | |
| fitBounds( | |
| lng1 = min(st_coordinates(st_centroid(st_union(community_sf)))[,1], na.rm = TRUE) - 0.05, | |
| lat1 = min(st_coordinates(st_centroid(st_union(community_sf)))[,2], na.rm = TRUE) - 0.05, | |
| lng2 = max(st_coordinates(st_centroid(st_union(community_sf)))[,1], na.rm = TRUE) + 0.05, | |
| lat2 = max(st_coordinates(st_centroid(st_union(community_sf)))[,2], na.rm = TRUE) + 0.05 | |
| ) | |
| }) | |
| output$communityTable <- renderDT({ | |
| if (is.null(community_sf) || nrow(community_sf) == 0) { | |
| return(DT::datatable(data.frame(Message = "No partner/community organization layer found in setup.R."))) | |
| } | |
| DT::datatable( | |
| st_drop_geometry(community_sf), | |
| options = list(pageLength = 10, scrollX = TRUE), | |
| rownames = FALSE | |
| ) | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Isochrone Comparer tab | |
| # --------------------------------------------------------------------------- | |
| # Two point pickers (A and B), one Compare button, two spider plots, and a | |
| # difference table. Every score reuses the shared iso_metrics_AUDITED.R functions, so | |
| # a comparison is computed exactly like the main Explorer tab. Nothing here | |
| # runs until Compare is pressed (cmp_results is an eventReactive). | |
| # One colour per point, reused everywhere (map marker, map isochrone, radar | |
| # line) so Point A always reads green and Point B always reads blue. | |
| cmp_color_a <- "#1b9e77" | |
| cmp_color_b <- "#2c7fb8" | |
| cmp_point_a <- reactiveVal(NULL) | |
| cmp_point_b <- reactiveVal(NULL) | |
| # Wire one side's map + geocoder to its point reactiveVal. Called once per side | |
| # so the two pickers stay identical without duplicating the handler code. | |
| setup_cmp_point <- function(map_id, geocoder_id, point_rv, marker_color) { | |
| output[[map_id]] <- renderLeaflet({ | |
| leaflet() |> | |
| addTiles() |> | |
| setView(lng = -122.4194, lat = 37.7749, zoom = 12) | |
| }) | |
| observeEvent(input[[paste0(map_id, "_click")]], { | |
| click <- input[[paste0(map_id, "_click")]] | |
| if (is.null(click)) return() | |
| point_rv(c(lon = click$lng, lat = click$lat)) | |
| leafletProxy(map_id) |> | |
| clearGroup("cmp_pt") |> | |
| addCircleMarkers(lng = click$lng, lat = click$lat, radius = 7, | |
| color = marker_color, group = "cmp_pt") | |
| }) | |
| observeEvent(input[[geocoder_id]], { | |
| g <- input[[geocoder_id]] | |
| if (is.null(g)) return() | |
| xy <- geocoder_as_xy(g) | |
| point_rv(c(lon = xy[1], lat = xy[2])) | |
| leafletProxy(map_id) |> | |
| clearGroup("cmp_pt") |> | |
| addCircleMarkers(lng = xy[1], lat = xy[2], radius = 7, | |
| color = marker_color, group = "cmp_pt") |> | |
| flyTo(lng = xy[1], lat = xy[2], zoom = 13) | |
| }) | |
| } | |
| setup_cmp_point("cmp_map_a", "cmp_geocoder_a", cmp_point_a, cmp_color_a) | |
| setup_cmp_point("cmp_map_b", "cmp_geocoder_b", cmp_point_b, cmp_color_b) | |
| # Build + score one side. Returns NULL if no isochrone could be built (e.g. a | |
| # transit mode with no reachable stops) so the outputs can show a clear note. | |
| # Keeps the isochrone sf so it can be drawn back onto the side's map. | |
| cmp_score_side <- function(point, mode, time) { | |
| iso <- quiet_sf_overlay( | |
| build_isochrones(point, modes = mode, times = as.numeric(time)) | |
| ) | |
| if (is.null(iso) || nrow(iso) == 0) return(NULL) | |
| raw_metrics <- quiet_sf_overlay( | |
| compute_iso_metrics(iso, point, gbif_tab) | |
| ) | |
| # Each comparer side contains one isochrone and is scored only against its | |
| # own matching mode Γ time reference distribution. Point A is never ranked | |
| # directly against Point B. | |
| metrics <- score_iso_metrics_against_reference(raw_metrics) | |
| list( | |
| iso = iso, | |
| metrics = metrics, | |
| bai = add_reference_bai(metrics), | |
| label = paste0(pretty_mode(mode), " ", time, " min") | |
| ) | |
| } | |
| cmp_results <- eventReactive(input$cmp_compare, { | |
| pa <- cmp_point_a() | |
| pb <- cmp_point_b() | |
| missing_points <- c( | |
| if (is.null(pa)) "Point A" else NULL, | |
| if (is.null(pb)) "Point B" else NULL | |
| ) | |
| if (length(missing_points) > 0) { | |
| shiny::showNotification( | |
| paste0( | |
| "Place ", paste(missing_points, collapse = " and "), | |
| " by clicking its map or searching for an address, then press Compare." | |
| ), | |
| type = "warning", | |
| duration = 6 | |
| ) | |
| return(NULL) | |
| } | |
| list( | |
| a = cmp_score_side(pa, input$cmp_mode_a, input$cmp_time_a), | |
| b = cmp_score_side(pb, input$cmp_mode_b, input$cmp_time_b) | |
| ) | |
| }, ignoreInit = TRUE) | |
| # Flag for the conditionalPanel that reveals the comparison results. FALSE | |
| # until Compare has produced a value (cmp_results errors before its first | |
| # event, and on validation failure -- both caught here as "not ready"). | |
| output$cmp_ready <- reactive({ | |
| tryCatch(!is.null(cmp_results()), error = function(e) FALSE) | |
| }) | |
| outputOptions(output, "cmp_ready", suspendWhenHidden = FALSE) | |
| # Draw each side's isochrone back onto its picker map, and frame it. Fires only | |
| # when Compare produced results (cmp_results is an eventReactive). | |
| observeEvent(cmp_results(), { | |
| res <- cmp_results() | |
| draw_side_iso <- function(map_id, side, color) { | |
| proxy <- leafletProxy(map_id) |> clearGroup("cmp_iso") | |
| if (!is.null(side) && !is.null(side$iso)) { | |
| bb <- as.numeric(sf::st_bbox(side$iso)) | |
| proxy |> | |
| addPolygons(data = side$iso, group = "cmp_iso", | |
| color = color, weight = 2, fillColor = color, fillOpacity = 0.25) |> | |
| fitBounds(bb[1], bb[2], bb[3], bb[4]) | |
| } | |
| } | |
| draw_side_iso("cmp_map_a", res$a, cmp_color_a) | |
| draw_side_iso("cmp_map_b", res$b, cmp_color_b) | |
| }) | |
| # One consolidated radar: Point A vs Point B on a single axis, coloured by point. | |
| output$cmp_radar <- renderPlot({ | |
| res <- cmp_results() | |
| rows <- list(); labs <- character(); cols <- character() | |
| if (!is.null(res$a) && !is.null(res$a$bai)) { | |
| rows <- c(rows, list(res$a$bai)); labs <- c(labs, paste0("Point A: ", res$a$label)); cols <- c(cols, cmp_color_a) | |
| } | |
| if (!is.null(res$b) && !is.null(res$b$bai)) { | |
| rows <- c(rows, list(res$b$bai)); labs <- c(labs, paste0("Point B: ", res$b$label)); cols <- c(cols, cmp_color_b) | |
| } | |
| if (length(rows) == 0) { | |
| plot.new(); title("No isochrones could be built for either point."); return() | |
| } | |
| draw_reference_radar( | |
| rows, | |
| labels = labs, | |
| colors = cols, | |
| title = "Point A vs Point B β Citywide Biodiversity Access", | |
| subtitle = "Each point is independently benchmarked against its own matching SF mode Γ travel-time reference.", | |
| plot_style = "point_compare" | |
| ) | |
| }) | |
| output$cmp_diff_table <- renderDT({ | |
| res <- cmp_results() | |
| side_scores <- function(side) { | |
| if (is.null(side) || is.null(side$metrics)) return(NULL) | |
| m <- side$metrics | |
| b <- side$bai | |
| gm <- function(nm) { | |
| if (!(nm %in% names(m))) return(NA_real_) | |
| suppressWarnings(as.numeric(m[[nm]][[1]])) | |
| } | |
| gb <- function(nm) { | |
| if (is.null(b) || !(nm %in% names(b))) return(NA_real_) | |
| suppressWarnings(as.numeric(b[[nm]][[1]])) | |
| } | |
| list( | |
| reference = if ("Reference_Status" %in% names(m)) as.character(m$Reference_Status[[1]]) else "N/A", | |
| transit_raw = gm("Transit_Access_Score"), | |
| transit_pct = gm("pctile_Transit_Access_Score"), | |
| routes_raw = gm("Unique_Muni_Routes"), | |
| routes_pct = gm("pctile_Unique_Muni_Routes"), | |
| species_raw = gm("GBIF_Species"), | |
| species_pct = gm("pctile_GBIF_Species"), | |
| sampling_raw = gm("SamplingDensity_km2"), | |
| sampling_pct = gm("pctile_SamplingDensity_km2"), | |
| ndvi_raw = gm("MeanNDVI"), | |
| ndvi_pct = gm("pctile_MeanNDVI"), | |
| greenspace_raw = gm("Greenspace_percent"), | |
| greenspace_pct = gm("pctile_Greenspace_percent"), | |
| ej_raw = gm("SF_EJ_Score"), | |
| ej_pct = gm("pctile_SF_EJ_Score"), | |
| bai = 100 * gb("BAI"), | |
| bai_pct = gb("pctile_BAI") | |
| ) | |
| } | |
| a <- side_scores(res$a) | |
| b <- side_scores(res$b) | |
| if (is.null(a)) a <- list() | |
| if (is.null(b)) b <- list() | |
| val <- function(x, digits = 1, suffix = "") { | |
| if (is.null(x) || length(x) == 0 || !is.finite(x)) return("β") | |
| paste0(round(x, digits), suffix) | |
| } | |
| dint <- function(x) { | |
| if (is.null(x) || length(x) == 0 || !is.finite(x)) return("β") | |
| scales::comma(round(x)) | |
| } | |
| delta <- function(x, y, digits = 1, suffix = "") { | |
| if (is.null(x) || is.null(y) || length(x) == 0 || length(y) == 0 || | |
| !is.finite(x) || !is.finite(y)) return("β") | |
| d <- y - x | |
| paste0(if (d >= 0) "+" else "", round(d, digits), suffix) | |
| } | |
| get <- function(z, nm) if (nm %in% names(z)) z[[nm]] else NA_real_ | |
| rows <- list( | |
| c("Reference group used for percentile scoring", get(a, "reference"), get(b, "reference"), "β"), | |
| c("ACCESS β Transit access (raw stops/kmΒ²)", val(get(a,"transit_raw"),2), val(get(b,"transit_raw"),2), delta(get(a,"transit_raw"),get(b,"transit_raw"),2)), | |
| c("ACCESS β Transit access percentile", val(get(a,"transit_pct")), val(get(b,"transit_pct")), delta(get(a,"transit_pct"),get(b,"transit_pct"),1," pp")), | |
| c("ACCESS β Muni routes (raw)", dint(get(a,"routes_raw")), dint(get(b,"routes_raw")), delta(get(a,"routes_raw"),get(b,"routes_raw"),0)), | |
| c("ACCESS β Muni route percentile", val(get(a,"routes_pct")), val(get(b,"routes_pct")), delta(get(a,"routes_pct"),get(b,"routes_pct"),1," pp")), | |
| c("BIODIVERSITY β Total species (raw)", dint(get(a,"species_raw")), dint(get(b,"species_raw")), delta(get(a,"species_raw"),get(b,"species_raw"),0)), | |
| c("BIODIVERSITY β Species richness percentile", val(get(a,"species_pct")), val(get(b,"species_pct")), delta(get(a,"species_pct"),get(b,"species_pct"),1," pp")), | |
| c("BIODIVERSITY β Sampling density (raw records/kmΒ²)", val(get(a,"sampling_raw"),1), val(get(b,"sampling_raw"),1), delta(get(a,"sampling_raw"),get(b,"sampling_raw"),1)), | |
| c("BIODIVERSITY β Sampling-density percentile", val(get(a,"sampling_pct")), val(get(b,"sampling_pct")), delta(get(a,"sampling_pct"),get(b,"sampling_pct"),1," pp")), | |
| c("GREEN β Mean NDVI (raw)", val(get(a,"ndvi_raw"),3), val(get(b,"ndvi_raw"),3), delta(get(a,"ndvi_raw"),get(b,"ndvi_raw"),3)), | |
| c("GREEN β NDVI percentile", val(get(a,"ndvi_pct")), val(get(b,"ndvi_pct")), delta(get(a,"ndvi_pct"),get(b,"ndvi_pct"),1," pp")), | |
| c("GREEN β Greenspace cover (raw %)", val(get(a,"greenspace_raw"),1,"%"), val(get(b,"greenspace_raw"),1,"%"), delta(get(a,"greenspace_raw"),get(b,"greenspace_raw"),1," pp")), | |
| c("GREEN β Greenspace-cover percentile", val(get(a,"greenspace_pct")), val(get(b,"greenspace_pct")), delta(get(a,"greenspace_pct"),get(b,"greenspace_pct"),1," pp")), | |
| c("EQUITY β SF EJ score (raw; lower burden is better)", val(get(a,"ej_raw")), val(get(b,"ej_raw")), delta(get(a,"ej_raw"),get(b,"ej_raw"))), | |
| c("EQUITY β Favorable equity-context percentile", val(get(a,"ej_pct")), val(get(b,"ej_pct")), delta(get(a,"ej_pct"),get(b,"ej_pct"),1," pp")), | |
| c("COMPOSITE β BAI score (/100)", val(get(a,"bai")), val(get(b,"bai")), delta(get(a,"bai"),get(b,"bai"))), | |
| c("COMPOSITE β BAI citywide percentile", val(get(a,"bai_pct")), val(get(b,"bai_pct")), delta(get(a,"bai_pct"),get(b,"bai_pct"),1," pp")) | |
| ) | |
| tbl <- as.data.frame(do.call(rbind, rows), stringsAsFactors = FALSE) | |
| names(tbl) <- c("Metric", "Point A", "Point B", "Difference") | |
| DT::datatable( | |
| tbl, | |
| rownames = FALSE, | |
| options = list(dom = "t", ordering = FALSE, paging = FALSE) | |
| ) |> | |
| DT::formatStyle("Point A", color = cmp_color_a, fontWeight = "bold") |> | |
| DT::formatStyle("Point B", color = cmp_color_b, fontWeight = "bold") |> | |
| DT::formatStyle("Difference", fontWeight = "bold", backgroundColor = "#fff8e1") | |
| }) | |
| } # end server | |
| # ============================================================================= | |
| # RUN APP | |
| # ============================================================================= | |
| shinyApp(ui, server) | |