diego-ellis-soto commited on
Commit
d90cea5
Β·
1 Parent(s): 5ac159a

Fixed muni and default map

Browse files
Files changed (1) hide show
  1. app.R +233 -40
app.R CHANGED
@@ -231,8 +231,8 @@ pretty_mode <- function(x) {
231
  x == "walking" ~ "Walking",
232
  x == "cycling" ~ "Cycling",
233
  x == "driving-traffic" ~ "Driving-Traffic",
234
- x == "transit" ~ "Transit",
235
- x == "walk_transit" ~ "Walk-Transit",
236
  TRUE ~ tools::toTitleCase(x)
237
  )
238
  }
@@ -677,6 +677,101 @@ gtfs_isochrone_compat <- function(gtfs, from, start_time, end_time,
677
  out[!duplicated(out$stop_id), , drop = FALSE]
678
  }
679
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  build_walk_transit_isochrone <- function(
681
  location_sf, total_time_min, dep_secs,
682
  walk_to_stop_min, walk_from_stop_min,
@@ -834,37 +929,45 @@ build_walk_transit_isochrone <- function(
834
  # call for the pure Transit mode. It is loaded here with that one call rewritten
835
  # to the shim above, so this file stays the only thing that changes on disk.
836
  # Equivalent to source("code/iso_metrics_AUDITED.R", local = TRUE) otherwise.
837
- .iso_audited_src <- paste(readLines("code/iso_metrics_AUDITED.R", warn = FALSE), collapse = "\n")
838
-
839
- # (a) the missing routing function, as above.
840
- if (!grepl("gtfsrouter::gtfs_isochrone(", .iso_audited_src, fixed = TRUE))
841
- message("[app] no gtfs_isochrone call found in code/iso_metrics_AUDITED.R - loading it unchanged.")
842
- .iso_audited_src <- gsub("gtfsrouter::gtfs_isochrone(", "gtfs_isochrone_compat(",
843
- .iso_audited_src, fixed = TRUE)
844
-
845
- # (b) the Transit polygon is the convex hull of the REACHABLE STOPS only, which
846
- # does not contain the origin. Whenever the lines serving the nearest stop run
847
- # off in one direction, the hull sits to one side and the start point falls
848
- # outside its own isochrone. Adding the origin to the point set fixes that: a
849
- # convex hull of a set containing the origin always contains the origin.
850
- # The origin is added as a small DISC, not a bare point: added as a point it
851
- # becomes a vertex of the convex hull, so it lands exactly on the boundary and
852
- # still reads as "outside" to the user and to st_within(). 100 m matches the
853
- # buffer this file already uses for the few-stops fallback below.
854
- .iso_audited_src <- sub(
855
- "st_convex_hull(st_union(reachable_sf))",
856
- paste0("st_convex_hull(st_combine(c(st_geometry(reachable_sf), ",
857
- "st_geometry(st_transform(st_buffer(st_transform(location_sf, 3857), 100), 4326)))))"),
858
- .iso_audited_src, fixed = TRUE)
859
- .iso_audited_src <- sub(
860
- "st_union(st_buffer(st_transform(reachable_sf, 3857), 100))",
861
- "st_union(st_buffer(st_transform(st_combine(c(st_geometry(reachable_sf), st_geometry(location_sf))), 3857), 100))",
862
- .iso_audited_src, fixed = TRUE)
 
 
 
 
 
 
 
 
863
 
864
  # Evaluated at top level, so the definitions land in this app environment -
865
  # exactly where source(..., local = TRUE) would have put them.
866
- eval(parse(text = .iso_audited_src))
867
- rm(.iso_audited_src)
868
 
869
  # The audited helper is sourced into this app environment. Replace only its two
870
  # lazy lookup loaders so they keep using the absolute project-root paths above
@@ -1882,8 +1985,8 @@ transport_mode_choices <- list(
1882
  "Walking" = "walking",
1883
  "Cycling" = "cycling",
1884
  "Driving with Traffic" = "driving-traffic",
1885
- "Transit (GTFS)" = "transit",
1886
- "Walk + Transit (Muni)" = "walk_transit"
1887
  )
1888
 
1889
  # =============================================================================
@@ -2297,8 +2400,8 @@ ui <- dashboardPage(
2297
  "Walking" = "walking",
2298
  "Cycling" = "cycling",
2299
  "Driving with Traffic" = "driving-traffic",
2300
- "Transit (GTFS)" = "transit",
2301
- "Walk + Transit (Muni)" = "walk_transit"
2302
  ),
2303
  selected = c("driving", "walking")
2304
  ),
@@ -2315,7 +2418,36 @@ ui <- dashboardPage(
2315
  "Transit departure flexibility window (minutes):",
2316
  min = 0, max = 20, value = 10, step = 5
2317
  ),
2318
- helpText("Several departures after the selected time can be evaluated for walk + transit.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2319
  ),
2320
 
2321
  conditionalPanel(
@@ -3229,9 +3361,37 @@ ui <- dashboardPage(
3229
  status = "primary", solidHeader = TRUE, width = 12,
3230
  p(
3231
  style = "font-size: 13px; color: #555;",
3232
- "Six modes are supported across two routing engines. Walk + Transit is an approximation ",
3233
- "combining a Mapbox first-mile walk, GTFS stop-to-stop reachability (SF Muni), and a last-mile ",
3234
- "walk buffer β€” not a full door-to-door multimodal isochrone."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3235
  ),
3236
  tags$table(
3237
  class = "table table-hover table-sm",
@@ -4125,7 +4285,7 @@ server <- function(input, output, session) {
4125
  "Species Richness", "Data Availability",
4126
  "CalEnviroScreen (CI Score)", "SF EJ Communities",
4127
  "Transit Routes", "Transit Stops",
4128
- "Isochrones", "Transit Isochrones", "NDVI Raster"
4129
  ),
4130
  options = layersControlOptions(collapsed = TRUE)
4131
  ) |>
@@ -5487,6 +5647,7 @@ server <- function(input, output, session) {
5487
  clearGroup("selected_point") |>
5488
  clearGroup("Isochrones") |>
5489
  clearGroup("Transit Isochrones") |>
 
5490
  clearGroup("NDVI Raster")
5491
  })
5492
 
@@ -5514,7 +5675,15 @@ server <- function(input, output, session) {
5514
  })
5515
  outputOptions(output, "proposal_ready", suspendWhenHidden = FALSE)
5516
 
 
 
 
 
 
 
 
5517
  observeEvent(input$generate_iso, {
 
5518
  pt <- chosen_point()
5519
  if (is.null(pt)) return()
5520
  if (length(input$transport_modes) == 0) return()
@@ -5524,6 +5693,7 @@ server <- function(input, output, session) {
5524
  removeControl("ndvi_legend") |>
5525
  clearGroup("Isochrones") |>
5526
  clearGroup("Transit Isochrones") |>
 
5527
  clearGroup("NDVI Raster")
5528
 
5529
  # All isochrone construction lives in build_isochrones() (code/iso_metrics_AUDITED.R)
@@ -5550,6 +5720,7 @@ server <- function(input, output, session) {
5550
  removeControl("ndvi_legend") |>
5551
  clearGroup("Isochrones") |>
5552
  clearGroup("Transit Isochrones") |>
 
5553
  clearGroup("NDVI Raster")
5554
 
5555
  standard_like_modes <- c("driving", "walking", "cycling", "driving-traffic", "walk_transit")
@@ -5603,7 +5774,11 @@ server <- function(input, output, session) {
5603
  poly_i <- transit_iso[i, ]
5604
  time_i <- as.numeric(poly_i$time[[1]])
5605
 
5606
- n_stops_in <- tryCatch(nrow(st_intersection(gtfs_stops_sf, poly_i)), error = function(e) 0)
 
 
 
 
5607
  area_km2 <- round(as.numeric(st_area(st_transform(poly_i, 3857))) / 1e6, 2)
5608
  t_score <- if (area_km2 > 0) round(n_stops_in / area_km2, 2) else NA_real_
5609
 
@@ -5637,6 +5812,24 @@ server <- function(input, output, session) {
5637
  label = paste0("Transit ", time_i, " min"),
5638
  popup = popup_html
5639
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5640
  }
5641
  }
5642
 
 
231
  x == "walking" ~ "Walking",
232
  x == "cycling" ~ "Cycling",
233
  x == "driving-traffic" ~ "Driving-Traffic",
234
+ x == "transit" ~ "Transit (optimistic)",
235
+ x == "walk_transit" ~ "Walk + Transit (conservative)",
236
  TRUE ~ tools::toTitleCase(x)
237
  )
238
  }
 
677
  out[!duplicated(out$stop_id), , drop = FALSE]
678
  }
679
 
680
+ # ---------------------------------------------------------------------------
681
+ # REALISTIC TRANSIT ISOCHRONE
682
+ # ---------------------------------------------------------------------------
683
+ # The original Transit mode drew the CONVEX HULL of the stops reachable from the
684
+ # single nearest stop. A hull measures how far apart the outermost stops are,
685
+ # not what you can reach: a few far-flung stops on fast radial corridors fill in
686
+ # a huge triangle of land no route serves, while dense inner-city service with
687
+ # fifty nearby stops draws a small one. Measured from two origins at 15 min,
688
+ # Telegraph Hill reached MORE of the network (52 stops vs 43) yet got a hull less
689
+ # than half the size (1.41 vs 3.03 km2).
690
+ #
691
+ # This is the standard construction instead - walk to nearby stops, ride, then
692
+ # walk out from each destination stop with whatever time remains, and union the
693
+ # pieces, giving the characteristic "beads along corridors" shape:
694
+ #
695
+ # reachable = walkshed(origin, T)
696
+ # U { walkshed(stop s, T - access(s0) - ride(s0 -> s)) }
697
+ #
698
+ # Walking speed is 60 m/min, calibrated rather than assumed: the median Mapbox
699
+ # walking-network isochrone at 5/10/15 min has an equal-area radius implying
700
+ # 62.3 / 60.1 / 59.4 m/min, so a straight-line 80 m/min would overstate the
701
+ # reachable area by about (80/60)^2 = 1.8x. Buffers are computed in UTM
702
+ # (ISO_METRIC_CRS), not Web Mercator, which at this latitude would inflate every
703
+ # distance by roughly 1/cos(37.8) = 1.27x.
704
+ build_transit_isochrone_union <- function(
705
+ location_sf, total_time_min, dep_secs, gtfs_stops_sf, gtfs_router,
706
+ walk_m_per_min = 60, max_access_min = 10, max_board_stops = 8,
707
+ charge_wait = FALSE
708
+ ) {
709
+ if (is.null(gtfs_router) || is.null(gtfs_stops_sf)) return(NULL)
710
+ if (!is.finite(total_time_min) || total_time_min <= 0) return(NULL)
711
+
712
+ loc_m <- st_transform(location_sf, ISO_METRIC_CRS)
713
+ stops_m <- st_transform(gtfs_stops_sf, ISO_METRIC_CRS)
714
+
715
+ # you can always just walk, so the origin's own walkshed is always included
716
+ parts <- list(st_geometry(st_buffer(loc_m, total_time_min * walk_m_per_min)))
717
+
718
+ access_min <- as.numeric(st_distance(loc_m, stops_m)) / walk_m_per_min
719
+
720
+ # Expected wait for the first vehicle. A rider who turns up without consulting
721
+ # a timetable waits, on average, half the headway. The median Muni headway is
722
+ # 15 min, so this charges about 7.5 min of a 15 min budget - which is why the
723
+ # paper reports it as a separate variant rather than folding it in silently.
724
+ # Waiting at any TRANSFER is already inside gtfs_traveltimes()'s duration, so
725
+ # only the boarding wait is added here.
726
+ wait_min <- rep(0, nrow(stops_m))
727
+ if (isTRUE(charge_wait)) {
728
+ hw <- suppressWarnings(as.numeric(gtfs_stops_sf$mean_headway_min))
729
+ if (all(is.na(hw))) {
730
+ warning("no headway data on gtfs_stops_sf; expected wait not charged")
731
+ } else {
732
+ hw[is.na(hw)] <- stats::median(hw, na.rm = TRUE) # stops with no data
733
+ wait_min <- hw / 2
734
+ }
735
+ }
736
+
737
+ board <- which(access_min + wait_min <= total_time_min &
738
+ access_min <= min(max_access_min, total_time_min))
739
+
740
+ if (length(board) > 0) {
741
+ # nearest few boarding stops only: routing every stop within walking range
742
+ # would make one map click take tens of seconds and barely move the shape.
743
+ board <- board[order(access_min[board])][seq_len(min(length(board), max_board_stops))]
744
+
745
+ sid_all <- as.character(gtfs_stops_sf$stop_id)
746
+ best <- rep(Inf, length(sid_all)) # best total time to reach each stop
747
+ for (bi in board) {
748
+ remaining <- total_time_min - access_min[bi] - wait_min[bi]
749
+ if (remaining <= 0) next
750
+ rr <- gtfs_isochrone_compat(
751
+ gtfs = gtfs_router, from = sid_all[bi], start_time = dep_secs,
752
+ end_time = dep_secs + remaining * 60, from_is_id = TRUE
753
+ )
754
+ if (is.null(rr) || !nrow(rr)) next
755
+ idx <- match(as.character(rr$stop_id), sid_all)
756
+ ok <- !is.na(idx)
757
+ if (!any(ok)) next
758
+ cost <- access_min[bi] + wait_min[bi] + rr$duration[ok] / 60
759
+ best[idx[ok]] <- pmin(best[idx[ok]], cost)
760
+ }
761
+
762
+ egress_min <- total_time_min - best
763
+ keep <- which(is.finite(egress_min) & egress_min > 0)
764
+ if (length(keep) > 0) {
765
+ parts <- append(parts, list(st_geometry(
766
+ st_buffer(stops_m[keep, ], egress_min[keep] * walk_m_per_min))))
767
+ }
768
+ }
769
+
770
+ out <- st_union(do.call(c, parts))
771
+ if (length(out) == 0) return(NULL)
772
+ st_transform(out, 4326)
773
+ }
774
+
775
  build_walk_transit_isochrone <- function(
776
  location_sf, total_time_min, dep_secs,
777
  walk_to_stop_min, walk_from_stop_min,
 
929
  # call for the pure Transit mode. It is loaded here with that one call rewritten
930
  # to the shim above, so this file stays the only thing that changes on disk.
931
  # Equivalent to source("code/iso_metrics_AUDITED.R", local = TRUE) otherwise.
932
+ .iso_lines <- readLines("code/iso_metrics_AUDITED.R", warn = FALSE)
933
+
934
+ # (a) Replace the whole Transit block with a call to the realistic builder
935
+ # above. Line-based splice rather than a text substitution, because the block
936
+ # spans nested braces that no single regex matches safely.
937
+ .b0 <- grep("^ stop_dists <- st_distance\\(location_sf, gtfs_stops_sf\\)$", .iso_lines)
938
+ .b1 <- grep("^ if \\(!is.null\\(iso_poly\\)\\) \\{$", .iso_lines)
939
+ if (length(.b0) == 1 && length(.b1) >= 1 && any(.b1 > .b0)) {
940
+ .b1 <- .b1[.b1 > .b0][1]
941
+ .iso_lines <- c(
942
+ .iso_lines[seq_len(.b0 - 1L)],
943
+ " dep_secs <- as.numeric(transit_hour) * 3600",
944
+ "",
945
+ " for (t in times) {",
946
+ " # transit_iso_cache is bypassed on purpose: it stores the old convex",
947
+ " # hulls keyed on the nearest stop, which are the wrong geometry.",
948
+ " iso_poly <- tryCatch(",
949
+ " build_transit_isochrone_union(",
950
+ " location_sf = location_sf, total_time_min = t, dep_secs = dep_secs,",
951
+ " gtfs_stops_sf = gtfs_stops_sf, gtfs_router = gtfs_router,",
952
+ " charge_wait = isTRUE(getOption(\"bas.transit_charge_wait\", FALSE))),",
953
+ " error = function(e) {",
954
+ " warning(\"transit isochrone failed: \", conditionMessage(e)); NULL })",
955
+ "",
956
+ .iso_lines[seq(.b1, length(.iso_lines))]
957
+ )
958
+ } else {
959
+ message("[app] could not locate the Transit block in code/iso_metrics_AUDITED.R - left unchanged.")
960
+ }
961
+
962
+ # (b) safety net: any remaining call to the function that does not exist in
963
+ # gtfsrouter 0.1.4 still routes through the shim.
964
+ .iso_lines <- gsub("gtfsrouter::gtfs_isochrone(", "gtfs_isochrone_compat(",
965
+ .iso_lines, fixed = TRUE)
966
 
967
  # Evaluated at top level, so the definitions land in this app environment -
968
  # exactly where source(..., local = TRUE) would have put them.
969
+ eval(parse(text = paste(.iso_lines, collapse = "\n")))
970
+ rm(.iso_lines, .b0, .b1)
971
 
972
  # The audited helper is sourced into this app environment. Replace only its two
973
  # lazy lookup loaders so they keep using the absolute project-root paths above
 
1985
  "Walking" = "walking",
1986
  "Cycling" = "cycling",
1987
  "Driving with Traffic" = "driving-traffic",
1988
+ "Transit - optimistic (no wait)" = "transit",
1989
+ "Walk + Transit - conservative" = "walk_transit"
1990
  )
1991
 
1992
  # =============================================================================
 
2400
  "Walking" = "walking",
2401
  "Cycling" = "cycling",
2402
  "Driving with Traffic" = "driving-traffic",
2403
+ "Transit - optimistic (no wait)" = "transit",
2404
+ "Walk + Transit - conservative" = "walk_transit"
2405
  ),
2406
  selected = c("driving", "walking")
2407
  ),
 
2418
  "Transit departure flexibility window (minutes):",
2419
  min = 0, max = 20, value = 10, step = 5
2420
  ),
2421
+ helpText("Several departures after the selected time can be evaluated for walk + transit."),
2422
+ checkboxInput(
2423
+ "transit_charge_wait",
2424
+ "Charge the expected wait (half the headway at the boarding stop)",
2425
+ value = FALSE
2426
+ ),
2427
+ helpText(
2428
+ "Off by default, matching the paper's turn-up-and-go upper bound. Switching it on ",
2429
+ "reproduces the paper's scheduled variant: the median Muni headway is 15 minutes, so ",
2430
+ "this costs about 7.5 minutes of a 15-minute budget."
2431
+ ),
2432
+ tags$div(
2433
+ style = paste("background:#eef4fb; border-left:4px solid #2166ac;",
2434
+ "padding:9px 11px; margin-top:9px; font-size:12px; line-height:1.5;"),
2435
+ tags$b("The two transit modes bracket the answer - they are not duplicates."),
2436
+ tags$ul(
2437
+ style = "margin:6px 0 0 0; padding-left:18px;",
2438
+ tags$li(tags$b("Transit - optimistic. "),
2439
+ "Walk up to 10 min to a stop, ride, then walk out with ALL remaining time. ",
2440
+ "No waiting is charged. Straight-line walking at a calibrated 60 m/min. ",
2441
+ "Fast, no API cost. ", tags$i("This is the method used in the paper.")),
2442
+ tags$li(tags$b("Walk + Transit - conservative. "),
2443
+ "First and last mile each capped by your sliders above (default 5 min), ",
2444
+ "only the 12 best last-mile stops, best departure searched across the window, ",
2445
+ "and real street-network walksheds from Mapbox. Slower and uses API calls.")
2446
+ ),
2447
+ tags$div(style = "margin-top:6px; color:#44515c;",
2448
+ "Measured at 15 min, the optimistic mode returns roughly 2-4x the area. ",
2449
+ "The truth sits between them: treat them as an upper and a lower bound.")
2450
+ )
2451
  ),
2452
 
2453
  conditionalPanel(
 
3361
  status = "primary", solidHeader = TRUE, width = 12,
3362
  p(
3363
  style = "font-size: 13px; color: #555;",
3364
+ "Six modes are supported across two routing engines. Both transit modes now walk at BOTH ends ",
3365
+ "β€” you cannot reach nature by riding past it, only by getting off and walking β€” so they differ ",
3366
+ "in how generous they are, not in whether walking is included."
3367
+ ),
3368
+ tags$div(
3369
+ style = paste("background:#eef4fb; border-left:4px solid #2166ac; padding:11px 13px;",
3370
+ "margin:4px 0 12px 0; font-size:13px; line-height:1.55;"),
3371
+ tags$b("Why there are two transit modes"),
3372
+ tags$p(style = "margin:6px 0 0 0;",
3373
+ "They bracket the answer rather than duplicating it. Measured at a 15-minute budget the ",
3374
+ "optimistic mode returns roughly two to four times the area of the conservative one; the ",
3375
+ "honest reading is that reality sits between them."),
3376
+ tags$ul(
3377
+ style = "margin:8px 0 0 0; padding-left:18px;",
3378
+ tags$li(tags$b("Transit β€” optimistic (no wait). "),
3379
+ "Access walk capped at 10 minutes, then all remaining time is given to the walk out ",
3380
+ "from each reachable stop. No waiting for the vehicle is charged. Walking is a ",
3381
+ "straight-line buffer at 60 m/min β€” calibrated, not assumed: the median Mapbox ",
3382
+ "walking-network isochrone at 5/10/15 minutes implies 62.3 / 60.1 / 59.4 m/min, so a ",
3383
+ "nominal 80 m/min would overstate reachable area by about 1.8x. No API calls, under ",
3384
+ "a second per query. ", tags$b("This is the construction used in the manuscript.")),
3385
+ tags$li(tags$b("Walk + Transit β€” conservative. "),
3386
+ "First and last mile each capped by the sliders (5 minutes by default), only the 12 ",
3387
+ "best last-mile stops are buffered, the best departure within the flexibility window ",
3388
+ "is searched, and the walksheds are real Mapbox street-network isochrones rather than ",
3389
+ "circles. Slower, and it consumes Mapbox API calls.")
3390
+ ),
3391
+ tags$p(style = "margin:8px 0 0 0; color:#44515c;",
3392
+ "Neither charges a half-headway wait. Since the median Muni headway is 15 minutes, an ",
3393
+ "expected 7.5-minute wait would consume half of a 15-minute budget, so both modes remain ",
3394
+ "optimistic about waiting even though they differ in how far they let you walk.")
3395
  ),
3396
  tags$table(
3397
  class = "table table-hover table-sm",
 
4285
  "Species Richness", "Data Availability",
4286
  "CalEnviroScreen (CI Score)", "SF EJ Communities",
4287
  "Transit Routes", "Transit Stops",
4288
+ "Isochrones", "Transit Isochrones", "Muni Stops Reached", "NDVI Raster"
4289
  ),
4290
  options = layersControlOptions(collapsed = TRUE)
4291
  ) |>
 
5647
  clearGroup("selected_point") |>
5648
  clearGroup("Isochrones") |>
5649
  clearGroup("Transit Isochrones") |>
5650
+ clearGroup("Muni Stops Reached") |>
5651
  clearGroup("NDVI Raster")
5652
  })
5653
 
 
5675
  })
5676
  outputOptions(output, "proposal_ready", suspendWhenHidden = FALSE)
5677
 
5678
+ # build_isochrones() lives in the sourced helper and its signature is shared
5679
+ # with the comparer tab, so the wait toggle is passed as an option rather than
5680
+ # threaded through as another argument.
5681
+ observeEvent(input$transit_charge_wait, {
5682
+ options(bas.transit_charge_wait = isTRUE(input$transit_charge_wait))
5683
+ }, ignoreNULL = FALSE)
5684
+
5685
  observeEvent(input$generate_iso, {
5686
+ options(bas.transit_charge_wait = isTRUE(input$transit_charge_wait))
5687
  pt <- chosen_point()
5688
  if (is.null(pt)) return()
5689
  if (length(input$transport_modes) == 0) return()
 
5693
  removeControl("ndvi_legend") |>
5694
  clearGroup("Isochrones") |>
5695
  clearGroup("Transit Isochrones") |>
5696
+ clearGroup("Muni Stops Reached") |>
5697
  clearGroup("NDVI Raster")
5698
 
5699
  # All isochrone construction lives in build_isochrones() (code/iso_metrics_AUDITED.R)
 
5720
  removeControl("ndvi_legend") |>
5721
  clearGroup("Isochrones") |>
5722
  clearGroup("Transit Isochrones") |>
5723
+ clearGroup("Muni Stops Reached") |>
5724
  clearGroup("NDVI Raster")
5725
 
5726
  standard_like_modes <- c("driving", "walking", "cycling", "driving-traffic", "walk_transit")
 
5774
  poly_i <- transit_iso[i, ]
5775
  time_i <- as.numeric(poly_i$time[[1]])
5776
 
5777
+ # keep the stops themselves, not just the count: they are drawn below so
5778
+ # the user can see WHERE the isochrone lets them get off and walk out.
5779
+ stops_in <- tryCatch(suppressWarnings(st_intersection(gtfs_stops_sf, poly_i)),
5780
+ error = function(e) NULL)
5781
+ n_stops_in <- if (is.null(stops_in)) 0 else nrow(stops_in)
5782
  area_km2 <- round(as.numeric(st_area(st_transform(poly_i, 3857))) / 1e6, 2)
5783
  t_score <- if (area_km2 > 0) round(n_stops_in / area_km2, 2) else NA_real_
5784
 
 
5812
  label = paste0("Transit ", time_i, " min"),
5813
  popup = popup_html
5814
  )
5815
+
5816
+ # Draw the reachable stops only for the LARGEST budget, which is a
5817
+ # superset of the smaller ones - drawing them per budget would stack
5818
+ # several markers on the same stop.
5819
+ if (!is.null(stops_in) && nrow(stops_in) > 0 &&
5820
+ isTRUE(time_i == max(as.numeric(transit_iso$time), na.rm = TRUE))) {
5821
+ leafletProxy("isoMap") |>
5822
+ addCircleMarkers(
5823
+ data = stops_in,
5824
+ group = "Muni Stops Reached",
5825
+ radius = 3.5, weight = 1,
5826
+ color = "#20456e", fillColor = "#4a90d9", fillOpacity = 0.9,
5827
+ label = ~paste0(stop_name, " (", stop_id, ")"),
5828
+ popup = ~paste0("<strong>", stop_name, "</strong><br>Stop ID: ", stop_id,
5829
+ "<br><small>Reachable within ", time_i,
5830
+ " min, including the walk at each end.</small>")
5831
+ )
5832
+ }
5833
  }
5834
  }
5835