Spaces:
Sleeping
Sleeping
| # ============================================================================ | |
| # Shared isochrone + metric functions (sourced once by app.R at startup) | |
| # ============================================================================ | |
| # These four functions are the single source of truth for turning a point into | |
| # an isochrone, scoring it, and drawing its BAI spider plot. Both the main | |
| # "Isochrone Explorer" tab and the "Isochrone Comparer" tab call them, so the | |
| # scoring logic lives in exactly one place. | |
| # | |
| # build_isochrones() point + modes/times -> isochrone sf (mode,time,geom) | |
| # compute_iso_metrics() isochrone sf + point -> per-isochrone metric data.frame | |
| # add_bai() metric df + benchmarks -> df + 7 BAI axes + composite BAI | |
| # draw_radar() BAI df -> spider/radar plot (base graphics) | |
| # | |
| # These functions read the *static* objects loaded by Rscripts/setup_unified.R | |
| # directly as globals (cbg_vect_sf, osm_greenspace, the distance/NDVI rasters, | |
| # rsf_projects, cbg_greenspace_coverage, gtfs_stops_sf, gtfs_routes_sf, | |
| # gtfs_router, transit_iso_cache, cenv_sf, sf_ej_sf) plus the helpers/config | |
| # defined at the top of app.R (mapbox_token, mode_palette, pretty_mode, ecdf01, | |
| # standardize_iso_sf, build_walk_transit_isochrone, safe_biodiv_hotspots/coldspots). | |
| # | |
| # Two objects are created per-session inside server() and so are passed in as | |
| # explicit arguments rather than read as globals: | |
| # gbif_tab -- the session's DuckDB handle on the GBIF parquet | |
| # city_benchmarks -- the citywide ECDF reference distributions for the BAI | |
| # | |
| # compute_iso_metrics() reports progress via withProgress(), so it must be | |
| # called from within a Shiny reactive context (which both tabs satisfy). | |
| # ============================================================================ | |
| # ---------------------------------------------------------------------------- | |
| # build_isochrones(): point + chosen modes/times -> combined isochrone sf | |
| # ---------------------------------------------------------------------------- | |
| # point: named numeric c(lon = , lat = ) (the shape chosen_point() stores), or NULL. | |
| # modes: character vector from c("driving","walking","cycling","driving-traffic", | |
| # "transit","walk_transit"). times: numeric minutes. The transit_* / walk_* | |
| # arguments only matter when a transit mode is selected; they default to the | |
| # main tab's defaults so the comparer can pass a single mode/time and ignore them. | |
| # Returns an sf with columns mode, time, geometry (EPSG:4326), or NULL if nothing | |
| # could be built. | |
| build_isochrones <- function(point, modes, times, | |
| transit_hour = 9, | |
| walk_to_stop_min = 5, | |
| walk_from_stop_min = 5, | |
| transit_departure_window_min = 10) { | |
| if (is.null(point) || length(modes) == 0 || length(times) == 0) return(NULL) | |
| location_sf <- st_as_sf( | |
| data.frame(lon = point["lon"], lat = point["lat"]), | |
| coords = c("lon", "lat"), | |
| crs = 4326 | |
| ) | |
| iso_list <- list() | |
| times <- as.numeric(times) | |
| # --- Mapbox modes (driving / walking / cycling / driving-traffic) ---------- | |
| mapbox_modes <- intersect(modes, c("driving", "walking", "cycling", "driving-traffic")) | |
| for (mode in mapbox_modes) { | |
| for (t in times) { | |
| iso <- tryCatch( | |
| mb_isochrone(location_sf, time = t, profile = mode, access_token = mapbox_token), | |
| # Surface the Mapbox error to the log instead of silently dropping it -- | |
| # otherwise a failed call just looks like "no isochrone" with no clue why. | |
| error = function(e) { warning("mb_isochrone failed (", mode, " ", t, " min): ", conditionMessage(e)); NULL } | |
| ) | |
| if (!is.null(iso)) { | |
| iso_std <- standardize_iso_sf(iso, mode_name = mode, time_min = t) | |
| if (!is.null(iso_std)) iso_list <- append(iso_list, list(iso_std)) | |
| } | |
| } | |
| } | |
| # --- Transit (GTFS) -------------------------------------------------------- | |
| if ("transit" %in% modes && !is.null(gtfs_router) && !is.null(gtfs_stops_sf)) { | |
| stop_dists <- st_distance(location_sf, gtfs_stops_sf) | |
| nearest_idx <- which.min(stop_dists) | |
| nearest_id <- as.character(gtfs_stops_sf$stop_id[nearest_idx]) | |
| dep_secs <- as.numeric(transit_hour) * 3600 | |
| for (t in times) { | |
| iso_poly <- NULL | |
| if (!is.null(transit_iso_cache) && | |
| !is.null(transit_iso_cache[[nearest_id]]) && | |
| !is.null(transit_iso_cache[[nearest_id]][[as.character(t)]])) { | |
| iso_poly <- transit_iso_cache[[nearest_id]][[as.character(t)]] | |
| } | |
| if (is.null(iso_poly)) { | |
| iso_result <- tryCatch( | |
| gtfsrouter::gtfs_isochrone( | |
| gtfs = gtfs_router, | |
| from = nearest_id, | |
| start_time = dep_secs, | |
| end_time = dep_secs + t * 60, | |
| from_is_id = TRUE | |
| ), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(iso_result) && nrow(iso_result) > 2) { | |
| reachable_sf <- gtfs_stops_sf |> | |
| filter(stop_id %in% as.character(iso_result$stop_id)) | |
| if (nrow(reachable_sf) > 2) { | |
| iso_poly <- st_convex_hull(st_union(reachable_sf)) | |
| } else if (nrow(reachable_sf) > 0) { | |
| iso_poly <- st_union(st_buffer(st_transform(reachable_sf, 3857), 100)) |> | |
| st_transform(4326) | |
| } | |
| } | |
| } | |
| if (!is.null(iso_poly)) { | |
| iso_sf <- st_sf( | |
| mode = "transit", | |
| time = as.numeric(t), | |
| geometry = st_geometry(st_as_sf(iso_poly)), | |
| crs = 4326 | |
| ) | |
| iso_sf <- standardize_iso_sf(iso_sf, mode_name = "transit", time_min = t) | |
| iso_list <- append(iso_list, list(iso_sf)) | |
| } | |
| } | |
| } | |
| # --- Walk + Transit (Muni) ------------------------------------------------- | |
| if ("walk_transit" %in% modes && !is.null(gtfs_router) && !is.null(gtfs_stops_sf)) { | |
| dep_secs <- as.numeric(transit_hour) * 3600 | |
| valid_times <- times[times > walk_to_stop_min] | |
| for (t in valid_times) { | |
| wt_iso <- tryCatch( | |
| build_walk_transit_isochrone( | |
| location_sf = location_sf, | |
| total_time_min = t, | |
| dep_secs = dep_secs, | |
| walk_to_stop_min = walk_to_stop_min, | |
| walk_from_stop_min = walk_from_stop_min, | |
| gtfs_stops_sf = gtfs_stops_sf, | |
| gtfs_router = gtfs_router, | |
| mapbox_token = mapbox_token, | |
| departure_window_min = transit_departure_window_min, | |
| departure_step_min = 5, | |
| max_last_mile_stops = 12, | |
| include_first_mile_polygon = TRUE | |
| ), | |
| error = function(e) NULL | |
| ) | |
| if (!is.null(wt_iso) && nrow(wt_iso) > 0) { | |
| iso_list <- append(iso_list, list(wt_iso)) | |
| } | |
| } | |
| } | |
| if (length(iso_list) == 0) return(NULL) | |
| dplyr::bind_rows(iso_list) |> | |
| st_as_sf() |> | |
| st_make_valid() |> | |
| st_transform(4326) | |
| } | |
| # ---------------------------------------------------------------------------- | |
| # compute_iso_metrics(): isochrone sf -> per-isochrone metric data.frame | |
| # ---------------------------------------------------------------------------- | |
| # iso_data: the sf returned by build_isochrones(). point: the c(lon, lat) the | |
| # isochrones were built from (used for the nearest-greenspace / nearest-RSF | |
| # lookups), or NULL. gbif_tab: the session's DuckDB handle on the GBIF parquet. | |
| # Returns a data.frame (one row per isochrone) carrying aggregate summaries as | |
| # attributes: bio_percentile, city/mean transit scores, closest_greenspace(+dist), | |
| # closest_rsf_program(+dist). | |
| compute_iso_metrics <- function(iso_data, point, gbif_tab) { | |
| if (is.null(iso_data) || nrow(iso_data) == 0) return(data.frame()) | |
| hotspot_union <- safe_biodiv_hotspots() | |
| coldspot_union <- safe_biodiv_coldspots() | |
| if (!is.null(hotspot_union)) hotspot_union <- st_union(hotspot_union) | |
| if (!is.null(coldspot_union)) coldspot_union <- st_union(coldspot_union) | |
| acs_wide <- cbg_vect_sf |> | |
| mutate(population = popE, med_income = medincE) | |
| results <- data.frame() | |
| n_isos <- nrow(iso_data) | |
| user_point_sf <- NULL | |
| if (!is.null(point)) { | |
| user_point_sf <- st_as_sf( | |
| data.frame(lon = point["lon"], lat = point["lat"]), | |
| coords = c("lon", "lat"), | |
| crs = 4326 | |
| ) | |
| } | |
| min_dist_val_global <- NA_real_ | |
| osm_greenspace_name_global <- NA_character_ | |
| if (!is.null(user_point_sf) && exists("greenspace_dist_raster") && exists("greenspace_osmid_raster")) { | |
| try({ | |
| # Distance from the selected point to its nearest greenspace, read straight | |
| # from the distance raster. terra::extract() returns data.frame(ID, value), | |
| # so the distance is column 2 -- pulling column 1 (the point ID) was the bug | |
| # that made this come back as "1" instead of the real distance in metres. | |
| min_dist_val_global <- (greenspace_dist_raster |> extract(vect(user_point_sf)) |> pull(2))[1] | |
| user_point_osm_id <- (greenspace_osmid_raster |> extract(vect(user_point_sf)) |> pull(2))[1] | |
| osm_greenspace_name_global <- osm_greenspace |> | |
| mutate(osm_id = as.numeric(osm_id)) |> | |
| filter(osm_id == user_point_osm_id) |> | |
| pull(name) | |
| if (length(osm_greenspace_name_global) == 0 || is.na(osm_greenspace_name_global[1])) { | |
| osm_greenspace_name_global <- "Unnamed Greenspace" | |
| } else { | |
| osm_greenspace_name_global <- osm_greenspace_name_global[1] | |
| } | |
| }, silent = TRUE) | |
| } | |
| min_rsf_dist_global <- NA_real_ | |
| rsf_program_name_global <- NA_character_ | |
| if (!is.null(user_point_sf) && exists("rsfprogram_dist_raster") && exists("rsfprogram_id_raster") && exists("rsf_projects")) { | |
| try({ | |
| # Distance is column 2 (column 1 is the point ID) -- same fix as greenspace above. | |
| min_rsf_dist_global <- (rsfprogram_dist_raster |> extract(vect(user_point_sf)) |> pull(2))[1] | |
| user_point_rsf_pid <- (rsfprogram_id_raster |> extract(vect(user_point_sf)) |> pull(2))[1] | |
| rsf_program_name_global <- rsf_projects |> | |
| dplyr::filter(as.numeric(.data$polygon_id) == as.numeric(user_point_rsf_pid)) |> | |
| dplyr::pull(.data$prj_name) | |
| if (length(rsf_program_name_global) == 0 || is.na(rsf_program_name_global[1])) { | |
| rsf_program_name_global <- "Unknown RSF program" | |
| } else { | |
| rsf_program_name_global <- rsf_program_name_global[1] | |
| } | |
| }, silent = TRUE) | |
| } | |
| withProgress(message = "Analyzing isochrones...", value = 0, { | |
| for (i in seq_len(n_isos)) { | |
| poly_i <- iso_data[i, ] | |
| vect_poly_i <- vect(poly_i) | |
| incProgress( | |
| 1 / n_isos, | |
| detail = paste0( | |
| pretty_mode(as.character(poly_i$mode[[1]])), | |
| " – ", poly_i$time[[1]], " min" | |
| ) | |
| ) | |
| dist_hot_km <- if (!is.null(hotspot_union)) { | |
| round(as.numeric(min(st_distance(poly_i, hotspot_union))) / 1000, 3) | |
| } else NA_real_ | |
| dist_cold_km <- if (!is.null(coldspot_union)) { | |
| round(as.numeric(min(st_distance(poly_i, coldspot_union))) / 1000, 3) | |
| } else NA_real_ | |
| inter_acs <- tryCatch(intersect(vect(acs_wide), vect_poly_i) |> st_as_sf(), error = function(e) NULL) | |
| pop_total <- 0 | |
| w_income <- NA_real_ | |
| if (!is.null(inter_acs) && nrow(inter_acs) > 0) { | |
| inter_acs <- inter_acs |> | |
| mutate( | |
| area_num = as.numeric(st_area(st_transform(geometry, 3857))), | |
| weighted_pop = population * (area_num / sum(area_num, na.rm = TRUE)) | |
| ) | |
| pop_total <- round(sum(inter_acs$weighted_pop, na.rm = TRUE)) | |
| w_income <- sum(inter_acs$med_income * inter_acs$area_num, na.rm = TRUE) / | |
| sum(inter_acs$area_num, na.rm = TRUE) | |
| } | |
| iso_area_m2 <- as.numeric(st_area(st_transform(poly_i, 3857))) | |
| iso_area_km2 <- round(iso_area_m2 / 1e6, 3) | |
| # Greenspace area within this isochrone: use pre-cached per-CBG coverage | |
| # (replaces slow per-isochrone raster cellSize() which took ~40-55 s each). | |
| # Method: intersect isochrone with CBGs, scale each CBG's greenspace_m2 by | |
| # the fraction of that CBG falling inside the isochrone, then sum. | |
| gs_area_m2 <- tryCatch({ | |
| if (exists("cbg_greenspace_coverage") && !is.null(cbg_greenspace_coverage)) { | |
| poly_proj <- st_transform(poly_i, 3857) | |
| cbg_proj_i <- st_transform(cbg_vect_sf[, "GEOID"], 3857) |> | |
| mutate(cbg_area_m2 = as.numeric(st_area(geometry))) |> | |
| st_make_valid() | |
| inter_df <- st_intersection(cbg_proj_i, poly_proj) |> | |
| mutate(inter_area_m2 = as.numeric(st_area(geometry))) |> | |
| st_drop_geometry() |> | |
| left_join( | |
| cbg_greenspace_coverage[, c("GEOID", "greenspace_m2", "cbg_area_m2")], | |
| by = "GEOID", suffix = c("_iso", "_cbg") | |
| ) |> | |
| mutate( | |
| greenspace_m2 = tidyr::replace_na(greenspace_m2, 0), | |
| cbg_area_m2 = dplyr::coalesce(cbg_area_m2_cbg, cbg_area_m2_iso), | |
| contrib = ifelse(cbg_area_m2 > 0, | |
| inter_area_m2 / cbg_area_m2 * greenspace_m2, | |
| 0) | |
| ) | |
| sum(inter_df$contrib, na.rm = TRUE) | |
| } else if (exists("greenspace_dist_raster")) { | |
| # Fallback to raster method if cache is unavailable | |
| dist_crop <- terra::crop(greenspace_dist_raster, vect_poly_i) | |
| dist_mask <- terra::mask(dist_crop, vect_poly_i) | |
| is_greenspace <- dist_mask == 0 | |
| cell_areas <- terra::cellSize(is_greenspace, unit = "m") | |
| as.numeric(terra::global(cell_areas * is_greenspace, "sum", na.rm = TRUE)[1, 1]) | |
| } else { | |
| 0 | |
| } | |
| }, error = function(e) 0) | |
| gs_percent <- ifelse(iso_area_m2 > 0, 100 * gs_area_m2 / iso_area_m2, 0) | |
| mean_ndvi <- NA_real_ | |
| if (exists("ndvi")) { | |
| ndvi_vals <- tryCatch(values(terra::mask(terra::crop(ndvi, vect_poly_i), vect_poly_i)), error = function(e) NA) | |
| ndvi_vals <- ndvi_vals[!is.na(ndvi_vals)] | |
| mean_ndvi <- ifelse(length(ndvi_vals) > 0, round(mean(ndvi_vals, na.rm = TRUE), 3), NA_real_) | |
| } | |
| iso_wkt <- st_as_text(st_geometry(poly_i)[[1]]) | |
| gbif_summary <- tryCatch({ | |
| gbif_tab |> | |
| filter(sql(glue("ST_Intersects(ST_GeomFromText(geom_wkt), ST_GeomFromText('{iso_wkt}'))"))) |> | |
| summarise( | |
| n_records = n(), | |
| n_species = n_distinct(species), | |
| n_birds = n_distinct(case_when(class == "Aves" ~ species, TRUE ~ NA_character_)), | |
| n_mammals = n_distinct(case_when(class == "Mammalia" ~ species, TRUE ~ NA_character_)), | |
| n_plants = n_distinct(case_when( | |
| class %in% c("Magnoliopsida", "Liliopsida", "Pinopsida", "Polypodiopsida", | |
| "Equisetopsida", "Bryopsida", "Marchantiopsida") ~ species, | |
| TRUE ~ NA_character_ | |
| )) | |
| ) |> | |
| collect() | |
| }, error = function(e) NULL) | |
| n_records <- if (!is.null(gbif_summary) && nrow(gbif_summary) > 0) gbif_summary$n_records[[1]] else 0L | |
| n_species <- if (!is.null(gbif_summary) && nrow(gbif_summary) > 0) gbif_summary$n_species[[1]] else 0L | |
| n_birds <- if (!is.null(gbif_summary) && nrow(gbif_summary) > 0) gbif_summary$n_birds[[1]] else 0L | |
| n_mammals <- if (!is.null(gbif_summary) && nrow(gbif_summary) > 0) gbif_summary$n_mammals[[1]] else 0L | |
| n_plants <- if (!is.null(gbif_summary) && nrow(gbif_summary) > 0) gbif_summary$n_plants[[1]] else 0L | |
| n_transit_stops <- NA_real_ | |
| transit_access_score <- NA_real_ | |
| freq_weighted_score <- NA_real_ | |
| mean_headway_iso <- NA_real_ | |
| nearest_stop_m <- NA_real_ | |
| nearest_stop_name <- NA_character_ | |
| if (!is.null(gtfs_stops_sf)) { | |
| inter_transit <- tryCatch(st_intersection(gtfs_stops_sf, poly_i), error = function(e) NULL) | |
| n_transit_stops <- if (!is.null(inter_transit)) nrow(inter_transit) else 0 | |
| dist_transit <- st_distance(poly_i, gtfs_stops_sf) | |
| nearest_stop_m <- round(as.numeric(min(dist_transit)), 0) | |
| nearest_stop_name <- gtfs_stops_sf$stop_name[which.min(dist_transit)] | |
| transit_access_score <- ifelse(iso_area_km2 > 0, round(n_transit_stops / iso_area_km2, 2), NA_real_) | |
| freq_weighted_score <- if (!is.null(inter_transit) && | |
| "mean_headway_min" %in% names(inter_transit) && | |
| nrow(inter_transit) > 0 && | |
| iso_area_km2 > 0) { | |
| hw <- inter_transit$mean_headway_min | |
| hw <- hw[!is.na(hw) & hw > 0] | |
| if (length(hw) > 0) round(sum(60 / hw) / iso_area_km2, 2) else NA_real_ | |
| } else NA_real_ | |
| mean_headway_iso <- if (!is.null(inter_transit) && | |
| "mean_headway_min" %in% names(inter_transit) && | |
| nrow(inter_transit) > 0) { | |
| round(mean(inter_transit$mean_headway_min, na.rm = TRUE), 1) | |
| } else NA_real_ | |
| } | |
| # Unique Muni route IDs whose shapes intersect this isochrone | |
| n_unique_routes <- 0L | |
| if (!is.null(gtfs_routes_sf)) { | |
| routes_inter <- tryCatch( | |
| st_intersection(gtfs_routes_sf[, "route_id"], poly_i), | |
| error = function(e) NULL | |
| ) | |
| n_unique_routes <- if (!is.null(routes_inter) && "route_id" %in% names(routes_inter)) { | |
| length(unique(routes_inter$route_id)) | |
| } else 0L | |
| } | |
| sampling_density_km2 <- ifelse(iso_area_km2 > 0, round(n_records / iso_area_km2, 2), NA_real_) | |
| mean_ciscore <- if (!is.null(cenv_sf)) { | |
| tryCatch({ | |
| ce_inter <- st_intersection(cenv_sf, poly_i) | |
| if (nrow(ce_inter) > 0) { | |
| ce_inter$a <- as.numeric(st_area(st_transform(ce_inter, 3857))) | |
| round(weighted.mean(ce_inter$CIscore, w = ce_inter$a, na.rm = TRUE), 1) | |
| } else NA_real_ | |
| }, error = function(e) NA_real_) | |
| } else NA_real_ | |
| mean_traffic_pctl <- if (!is.null(cenv_sf)) { | |
| tryCatch({ | |
| ce_inter <- st_intersection(cenv_sf, poly_i) | |
| if (nrow(ce_inter) > 0) { | |
| ce_inter$a <- as.numeric(st_area(st_transform(ce_inter, 3857))) | |
| round(weighted.mean(ce_inter$Traffic_Pctl, w = ce_inter$a, na.rm = TRUE), 1) | |
| } else NA_real_ | |
| }, error = function(e) NA_real_) | |
| } else NA_real_ | |
| mean_ej_score <- if (!is.null(sf_ej_sf)) { | |
| tryCatch({ | |
| ej_inter <- st_intersection(sf_ej_sf, poly_i) | |
| if (nrow(ej_inter) > 0) { | |
| ej_inter$a <- as.numeric(st_area(st_transform(ej_inter, 3857))) | |
| valid <- ej_inter[!is.na(ej_inter$score), ] | |
| if (nrow(valid) > 0) { | |
| round(weighted.mean(valid$score, w = valid$a, na.rm = TRUE), 1) | |
| } else NA_real_ | |
| } else NA_real_ | |
| }, error = function(e) NA_real_) | |
| } else NA_real_ | |
| row_i <- data.frame( | |
| Mode = pretty_mode(as.character(poly_i$mode[[1]])), | |
| Time = as.numeric(poly_i$time[[1]]), | |
| IsochroneArea_km2 = iso_area_km2, | |
| DistToHotspot_km = dist_hot_km, | |
| DistToColdspot_km = dist_cold_km, | |
| EstimatedPopulation = pop_total, | |
| MedianIncome = round(w_income, 2), | |
| MeanNDVI = mean_ndvi, | |
| GBIF_Records = n_records, | |
| GBIF_Species = n_species, | |
| Bird_Species = n_birds, | |
| Mammal_Species = n_mammals, | |
| Plant_Species = n_plants, | |
| SamplingDensity_km2 = sampling_density_km2, | |
| Greenspace_percent = round(gs_percent, 2), | |
| Transit_Stops = n_transit_stops, | |
| Unique_Muni_Routes = n_unique_routes, | |
| Transit_Access_Score = transit_access_score, | |
| Freq_Weighted_Score = freq_weighted_score, | |
| Mean_Headway_min = mean_headway_iso, | |
| Nearest_Stop_m = nearest_stop_m, | |
| Nearest_Stop_Name = nearest_stop_name, | |
| CalEnviro_CIscore = mean_ciscore, | |
| CalEnviro_Traffic_Pctl = mean_traffic_pctl, | |
| SF_EJ_Score = mean_ej_score, | |
| closest_greenspace = osm_greenspace_name_global, | |
| closest_greenspace_dist_m = min_dist_val_global, | |
| closest_rsf_program = rsf_program_name_global, | |
| closest_rsf_program_dist_m = min_rsf_dist_global, | |
| stringsAsFactors = FALSE | |
| ) | |
| results <- rbind(results, row_i) | |
| } | |
| }) # end withProgress | |
| union_wkt <- st_as_text(st_geometry(st_union(iso_data))[[1]]) | |
| union_n_species <- tryCatch({ | |
| gbif_tab |> | |
| filter(sql(glue("ST_Intersects(ST_GeomFromText(geom_wkt), ST_GeomFromText('{union_wkt}'))"))) |> | |
| summarise(n_species = n_distinct(species)) |> | |
| collect() |> | |
| pull(n_species) | |
| }, error = function(e) 0L) | |
| attr(results, "bio_percentile") <- round(100 * ecdf(cbg_vect_sf$unique_species)(union_n_species), 1) | |
| if (!is.null(gtfs_stops_sf)) { | |
| sf_city_area_km2 <- 121.4 | |
| attr(results, "city_transit_score") <- round(nrow(gtfs_stops_sf) / sf_city_area_km2, 2) | |
| attr(results, "mean_transit_score") <- round(mean(results$Transit_Access_Score, na.rm = TRUE), 2) | |
| attr(results, "mean_transit_stops") <- round(mean(results$Transit_Stops, na.rm = TRUE), 1) | |
| attr(results, "mean_muni_routes") <- round(mean(results$Unique_Muni_Routes, na.rm = TRUE), 1) | |
| } else { | |
| attr(results, "city_transit_score") <- NA_real_ | |
| attr(results, "mean_transit_score") <- NA_real_ | |
| attr(results, "mean_transit_stops") <- NA_real_ | |
| attr(results, "mean_muni_routes") <- NA_real_ | |
| } | |
| if (nrow(results) > 0) { | |
| closest_gs <- results |> | |
| filter(!is.na(closest_greenspace_dist_m)) |> | |
| slice_min(closest_greenspace_dist_m, n = 1) | |
| if (nrow(closest_gs) > 0) { | |
| attr(results, "closest_greenspace") <- closest_gs$closest_greenspace[1] | |
| attr(results, "closest_greenspace_dist_m") <- closest_gs$closest_greenspace_dist_m[1] | |
| } else { | |
| attr(results, "closest_greenspace") <- "None" | |
| attr(results, "closest_greenspace_dist_m") <- NA_real_ | |
| } | |
| closest_rsf <- results |> | |
| filter(!is.na(closest_rsf_program_dist_m)) |> | |
| slice_min(closest_rsf_program_dist_m, n = 1) | |
| if (nrow(closest_rsf) > 0) { | |
| attr(results, "closest_rsf_program") <- closest_rsf$closest_rsf_program[1] | |
| attr(results, "closest_rsf_program_dist_m") <- closest_rsf$closest_rsf_program_dist_m[1] | |
| } else { | |
| attr(results, "closest_rsf_program") <- "None" | |
| attr(results, "closest_rsf_program_dist_m") <- NA_real_ | |
| } | |
| } | |
| results | |
| } | |
| # ---------------------------------------------------------------------------- | |
| # add_bai(): metric df + citywide benchmarks -> df + 7 BAI axes + composite BAI | |
| # ---------------------------------------------------------------------------- | |
| # df: a data.frame from compute_iso_metrics(). city_benchmarks: the list of | |
| # citywide reference distributions (the ECDFs each axis is scored against). | |
| # Returns df with the seven *_std columns (each 0-1) plus BAI (their row mean), | |
| # or NULL if df is empty. | |
| add_bai <- function(df, city_benchmarks) { | |
| if (is.null(df) || nrow(df) == 0) return(NULL) | |
| ref <- city_benchmarks | |
| eq_ref_inverted <- if (length(ref$ej) > 0) max(ref$ej, na.rm = TRUE) - ref$ej else numeric(0) | |
| eq_obs_inverted <- ifelse(is.na(df$SF_EJ_Score), NA_real_, max(ref$ej, na.rm = TRUE) - df$SF_EJ_Score) | |
| tmp <- df |> | |
| mutate( | |
| Mobility_Access_std = ecdf01(Transit_Access_Score, ref$transit_density), | |
| Biodiversity_Potential_std = ecdf01(GBIF_Species, ref$biodiversity), | |
| Observation_Intensity_std = ecdf01(SamplingDensity_km2, ref$sampling), | |
| Environmental_Quality_std = ecdf01(MeanNDVI, ref$ndvi), | |
| Greenspace_Cover_std = ecdf01(Greenspace_percent, ref$greenspace_cover), | |
| Equity_Context_std = ecdf01(eq_obs_inverted, eq_ref_inverted), | |
| Route_Access_std = ecdf01(Unique_Muni_Routes, ref$route_access) | |
| ) | |
| tmp$BAI <- rowMeans( | |
| tmp[, c( | |
| "Mobility_Access_std", | |
| "Biodiversity_Potential_std", | |
| "Observation_Intensity_std", | |
| "Environmental_Quality_std", | |
| "Greenspace_Cover_std", | |
| "Equity_Context_std", | |
| "Route_Access_std" | |
| )], | |
| na.rm = TRUE | |
| ) | |
| tmp | |
| } | |
| # Axis order shared by both radar functions, and the column each axis reads from. | |
| # Keep these two vectors aligned -- the group labels below assume this clockwise | |
| # order (1-2 Urban Access, 3-4 Biodiversity, 5-6 Environment, 7 EJ). | |
| RADAR_AXIS_COLS <- c("Mobility_Access_std", "Route_Access_std", "Biodiversity_Potential_std", | |
| "Observation_Intensity_std", "Environmental_Quality_std", | |
| "Greenspace_Cover_std", "Equity_Context_std") | |
| RADAR_AXIS_LABELS <- c("Stop\nDensity", "Route\nDiversity", "Species\nRichness", | |
| "Obs.\nIntensity", "Vegetation\n(NDVI)", "Greenspace\nCover", "EJ\nContext") | |
| # Radial group subheaders placed just beyond the axis labels. Shared by both | |
| # radar functions so the grouping (Urban Access / Biodiversity / Environment / | |
| # EJ) is drawn identically. Call after fmsb::radarchart() has drawn the chart. | |
| draw_radar_group_labels <- function() { | |
| n_ax <- 7 | |
| angs <- pi/2 - (0:(n_ax - 1)) * (2 * pi / n_ax) | |
| cat_r <- 1.48 # radius just outside axis labels (~1.2-1.3) | |
| text(cat_r * cos(mean(angs[1:2])), cat_r * sin(mean(angs[1:2])), | |
| "Urban Access", cex = 0.72, col = "#2166ac", font = 2, xpd = TRUE) | |
| text(cat_r * cos(mean(angs[3:4])), cat_r * sin(mean(angs[3:4])), | |
| "Biodiversity", cex = 0.72, col = "#1b7837", font = 2, xpd = TRUE) | |
| text(cat_r * cos(mean(angs[5:6])), cat_r * sin(mean(angs[5:6])), | |
| "Environment", cex = 0.72, col = "#762a83", font = 2, xpd = TRUE) | |
| text(cat_r * cos(angs[7]), cat_r * sin(angs[7]), | |
| "Environmental\nJustice", cex = 0.72, col = "#b2182b", font = 2, xpd = TRUE) | |
| } | |
| # ---------------------------------------------------------------------------- | |
| # draw_radar(): BAI df -> spider/radar plot (base graphics, via fmsb) | |
| # ---------------------------------------------------------------------------- | |
| # bai_df: the data.frame returned by add_bai() (one row per isochrone). title: | |
| # the chart title. Draws directly to the current graphics device, so call it | |
| # from inside a renderPlot({ }). Lines are coloured by transport mode. | |
| draw_radar <- function(bai_df, title = "Biodiversity Access Index Profile") { | |
| if (is.null(bai_df) || nrow(bai_df) == 0) return(NULL) | |
| radar_df <- bai_df |> | |
| mutate( | |
| ModeTime = paste0(Mode, "_", Time, "m"), | |
| `Stop\nDensity` = Mobility_Access_std, | |
| `Route\nDiversity` = Route_Access_std, | |
| `Species\nRichness` = Biodiversity_Potential_std, | |
| `Obs.\nIntensity` = Observation_Intensity_std, | |
| `Vegetation\n(NDVI)`= Environmental_Quality_std, | |
| `Greenspace\nCover` = Greenspace_Cover_std, | |
| `EJ\nContext` = Equity_Context_std | |
| ) |> | |
| select( | |
| ModeTime, | |
| `Stop\nDensity`, | |
| `Route\nDiversity`, | |
| `Species\nRichness`, | |
| `Obs.\nIntensity`, | |
| `Vegetation\n(NDVI)`, | |
| `Greenspace\nCover`, | |
| `EJ\nContext` | |
| ) | |
| radar_mat <- as.data.frame(radar_df[, -1]) | |
| rownames(radar_mat) <- radar_df$ModeTime | |
| radar_mat <- rbind( | |
| rep(1, ncol(radar_mat)), | |
| rep(0, ncol(radar_mat)), | |
| radar_mat | |
| ) | |
| labels <- rownames(radar_mat)[-(1:2)] | |
| line_cols <- mode_palette[gsub("_(.*)$", "", labels)] | |
| line_cols[is.na(line_cols)] <- "#666666" | |
| # Extra margin so two-line axis labels aren't clipped | |
| par(mar = c(2, 2, 3, 2)) | |
| fmsb::radarchart( | |
| radar_mat, | |
| axistype = 1, | |
| pcol = line_cols, | |
| plwd = 2, | |
| plty = 1, | |
| cglcol = "grey80", | |
| cglty = 1, | |
| cglwd = 0.8, | |
| axislabcol = "grey40", | |
| vlcex = 0.88, | |
| title = title | |
| ) | |
| draw_radar_group_labels() | |
| legend( | |
| "topright", | |
| legend = labels, | |
| col = line_cols, | |
| lty = 1, | |
| lwd = 2, | |
| cex = 0.75, | |
| bty = "n" | |
| ) | |
| } | |
| # ---------------------------------------------------------------------------- | |
| # draw_compare_radar(): overlay several BAI rows on ONE radar (Comparer tab) | |
| # ---------------------------------------------------------------------------- | |
| # bai_rows: a list of single-row add_bai() data.frames (one per point). | |
| # labels: legend text, one per row (e.g. "Point A: Walking 5 min"). | |
| # colors: one line colour per row -- this is where the A-vs-B colour scheme | |
| # lives, so keep it in sync with the point markers / isochrones. | |
| # Unlike draw_radar() (coloured by mode), this colours by *point* so two | |
| # isochrones of the same mode are still distinguishable. Draws to the current | |
| # device; call from inside renderPlot({ }). | |
| draw_compare_radar <- function(bai_rows, labels, colors, | |
| title = "Biodiversity Access Index — Point A vs Point B") { | |
| if (length(bai_rows) == 0) return(NULL) | |
| mat <- as.data.frame(do.call(rbind, lapply(bai_rows, function(d) as.numeric(d[1, RADAR_AXIS_COLS])))) | |
| names(mat) <- RADAR_AXIS_LABELS | |
| # fmsb wants the axis max (1) and min (0) as the first two rows. | |
| mat <- rbind(rep(1, ncol(mat)), rep(0, ncol(mat)), mat) | |
| par(mar = c(2, 2, 3, 2)) | |
| fmsb::radarchart( | |
| mat, | |
| axistype = 1, | |
| pcol = colors, | |
| pfcol = grDevices::adjustcolor(colors, alpha.f = 0.2), # translucent fills so overlaps read | |
| plwd = 3, | |
| plty = 1, | |
| cglcol = "grey80", | |
| cglty = 1, | |
| cglwd = 0.8, | |
| axislabcol = "grey40", | |
| vlcex = 0.9, | |
| title = title | |
| ) | |
| draw_radar_group_labels() | |
| legend( | |
| "topright", | |
| legend = labels, | |
| col = colors, | |
| lty = 1, | |
| lwd = 3, | |
| cex = 0.85, | |
| bty = "n" | |
| ) | |
| } | |