Data_preprocessing

library(jsonlite)
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr     1.1.4     ✔ readr     2.1.5
✔ forcats   1.0.1     ✔ stringr   1.5.2
✔ ggplot2   4.0.0     ✔ tibble    3.3.0
✔ lubridate 1.9.4     ✔ tidyr     1.3.1
✔ purrr     1.1.0     
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter()  masks stats::filter()
✖ purrr::flatten() masks jsonlite::flatten()
✖ dplyr::lag()     masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(lubridate)
library(sf)
Linking to GEOS 3.13.0, GDAL 3.8.5, PROJ 9.5.1; sf_use_s2() is TRUE
library(ggplot2)
library(tmap)
library(leaflet)
library(mapview)

Importing the google timeline

timeline_raw <- fromJSON("timeline.json", flatten = TRUE)

names(timeline_raw)
 [1] "endTime"                           "startTime"                        
 [3] "timelinePath"                      "visit.hierarchyLevel"             
 [5] "visit.probability"                 "visit.isTimelessVisit"            
 [7] "visit.topCandidate.probability"    "visit.topCandidate.semanticType"  
 [9] "visit.topCandidate.placeID"        "visit.topCandidate.placeLocation" 
[11] "activity.probability"              "activity.end"                     
[13] "activity.distanceMeters"           "activity.start"                   
[15] "activity.topCandidate.type"        "activity.topCandidate.probability"
glimpse(timeline_raw)
Rows: 1,336
Columns: 16
$ endTime                           <chr> "2026-03-04T17:40:50.000+01:00", "20…
$ startTime                         <chr> "2026-03-04T16:08:48.010+01:00", "20…
$ timelinePath                      <list> <NULL>, <NULL>, <NULL>, <NULL>, <NU…
$ visit.hierarchyLevel              <chr> "0", NA, NA, NA, "0", NA, "0", NA, N…
$ visit.probability                 <chr> "0.779056", NA, NA, NA, "0.725025", …
$ visit.isTimelessVisit             <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, …
$ visit.topCandidate.probability    <chr> "0.528044", NA, NA, NA, "0.357672", …
$ visit.topCandidate.semanticType   <chr> "Home", NA, NA, NA, "Unknown", NA, "…
$ visit.topCandidate.placeID        <chr> "ChIJpbEFXIAKkEcRakasHhqE6-Y", NA, N…
$ visit.topCandidate.placeLocation  <chr> "geo:47.406967,8.549913", NA, NA, NA…
$ activity.probability              <chr> NA, "0.961114", "0.991149", "0.62617…
$ activity.end                      <chr> NA, "geo:47.406085,8.548405", "geo:4…
$ activity.distanceMeters           <chr> NA, "80.271576", "3631.161377", "227…
$ activity.start                    <chr> NA, "geo:47.406357,8.549391", "geo:4…
$ activity.topCandidate.type        <chr> NA, "walking", "in tram", "walking",…
$ activity.topCandidate.probability <chr> NA, "0.763010", "0.755614", "0.53380…

Cleaning of the dataset, keep only relevant

#convert timestamps into movement data. 

timeline <- timeline_raw |>
  mutate(
    start_time = ymd_hms(startTime),
    end_time = ymd_hms(endTime),
    duration_min = as.numeric(difftime(end_time, start_time, units = "mins")),
    date = as_date(start_time),
    weekday = wday(start_time, label = TRUE, week_start = 1),
    hour = hour(startTime),

    record_type = case_when(
      !is.na(activity.distanceMeters) ~ "movement",
      !is.na(visit.probability) ~ "visit",
      TRUE ~ "unknown"
    ),

    location = case_when(
      record_type == "movement" ~ activity.start,
      record_type == "visit" ~ visit.topCandidate.placeLocation
    ),

    lat = as.numeric(str_extract(location, "(?<=geo:)[0-9.\\-]+")),
    lon = as.numeric(str_extract(location, "(?<=,)[0-9.\\-]+")),

    transport_mode = activity.topCandidate.type,
    placeID = visit.topCandidate.placeID,
    activity_category = NA_character_
  ) |>
  select(start_time, end_time, duration_min, date, weekday,
         record_type, transport_mode, activity_category,
         placeID, lat, lon) |>
  filter(!is.na(lat), !is.na(lon)) |>
  arrange(start_time)

convert to spatial object

#Convert table into Spatial object

timeline_sf <- timeline |>
  st_as_sf(coords = c("lon", "lat"), crs = 4326) |>
  st_transform(2056)

# separate coordinates in 2 columns like the Sabi example.
coords <- st_coordinates(timeline_sf)

timeline_sf <- timeline_sf |>
  mutate(
    E = coords[, 1],
    N = coords[, 2]
  )

Semantic Annotation of Activity categories

Semantic annotation categories

activity_categories <- tibble::tribble(
  ~category, ~includes,

  "transport", "Train stations, tram stops, bus stops, airports - probably to be filtered out",
  "home", "Residence",
  "education", "University, school",
  "work", "Workplace",
  "shopping", "Supermarket, stores, post office",
  "leisure", "Restaurants, bars, cinema, culture",
  "sport", "Gym, climbing, swimming",
  "nature", "Hikes, parks, mountains",
  "social", "Friends/family",
  "movement", "No POI (reclassify to movement as record type instead of visit)",
  "healthcare", "Doctor, pharmacy",
  "unknown", "Unresolved"
)

activity_categories
# A tibble: 12 × 2
   category   includes                                                          
   <chr>      <chr>                                                             
 1 transport  Train stations, tram stops, bus stops, airports - probably to be …
 2 home       Residence                                                         
 3 education  University, school                                                
 4 work       Workplace                                                         
 5 shopping   Supermarket, stores, post office                                  
 6 leisure    Restaurants, bars, cinema, culture                                
 7 sport      Gym, climbing, swimming                                           
 8 nature     Hikes, parks, mountains                                           
 9 social     Friends/family                                                    
10 movement   No POI (reclassify to movement as record type instead of visit)   
11 healthcare Doctor, pharmacy                                                  
12 unknown    Unresolved                                                        
#creates one row per unique visited place
visits <- timeline_sf |>
  filter(record_type == "visit") |>
  distinct(placeID, .keep_all = TRUE) |>
  arrange(placeID) |>
  mutate(
    place_number = row_number()
  )
timeline_sf <- timeline_sf |>
  left_join(
    visits |>
      st_drop_geometry() |>
      select(placeID, place_number),
    by = "placeID"
  )

mapview::mapview(visits)
# manual labelling 
visits <- visits |>
  mutate(
    activity_category = case_when(
      place_number == 1 ~ "shopping",
      place_number == 2 ~ "movement",
      place_number == 3 ~ "leisure",
      place_number == 4 ~ "movement",
      place_number == 5 ~ "work",
      place_number == 6 ~ "transport",
      place_number == 7 ~ "social",
      place_number == 8 ~ "transport",
      place_number == 9 ~ "education",
      place_number == 10 ~ "movement",
      place_number == 11 ~ "social",
      place_number == 12 ~ "nature",
      place_number == 13 ~ "sport",
      place_number == 14 ~ "education",
      place_number == 15 ~ "healthcare",
      place_number == 16 ~ "shopping",
      place_number == 17 ~ "movement",
      place_number == 18 ~ "education",
      place_number == 19 ~ "leisure",
      place_number == 20 ~ "transport",
      place_number == 21 ~ "leisure",
      place_number == 22 ~ "movement",
      place_number == 23 ~ "movement",
      place_number == 24 ~ "shopping",
      place_number == 25 ~ "transport",
      place_number == 26 ~ "shopping",
      place_number == 27 ~ "movement",
      place_number == 28 ~ "movement",
      place_number == 29 ~ "social",
      place_number == 30 ~ "social",
      place_number == 31 ~ "leisure",
      place_number == 32 ~ "movement",
      place_number == 33 ~ "transport",
      place_number == 34 ~ "sport",
      place_number == 35 ~ "movement",
      place_number == 36 ~ "transport",
      place_number == 37 ~ "sport",
      place_number == 38 ~ "social",
      place_number == 39 ~ "leisure",
      place_number == 40 ~ "shopping",
      place_number == 41 ~ "leisure",
      place_number == 42 ~ "shopping",
      place_number == 43 ~ "leisure",
      place_number == 44 ~ "movement",
      place_number == 45 ~ "leisure",
      place_number == 46 ~ "leisure",
      place_number == 47 ~ "nature",
      place_number == 48 ~ "movement",
      place_number == 49 ~ "social",
      place_number == 50 ~ "transport",
      place_number == 51 ~ "movement",
      place_number == 52 ~ "nature",
      place_number == 53 ~ "movement",
      place_number == 54 ~ "movement",
      place_number == 55 ~ "movement",
      place_number == 56 ~ "shopping",
      place_number == 57 ~ "education",
      place_number == 58 ~ "nature",
      place_number == 59 ~ "nature",
      place_number == 60 ~ "transport",
      place_number == 61 ~ "social",
      place_number == 62 ~ "nature",
      place_number == 63 ~ "social",
      place_number == 64 ~ "shopping",
      place_number == 65 ~ "healthcare",
      place_number == 66 ~ "social",
      place_number == 67 ~ "movement",
      place_number == 68 ~ "sport",
      place_number == 69 ~ "movement",
      place_number == 70 ~ "movement",
      place_number == 71 ~ "transport",
      place_number == 72 ~ "shopping",
      place_number == 73 ~ "movement",
      place_number == 74 ~ "movement",
      place_number == 75 ~ "movement",
      place_number == 76 ~ "nature",
      place_number == 77 ~ "social",
      place_number == 78 ~ "leisure",
      place_number == 79 ~ "nature",
      place_number == 80 ~ "social",
      place_number == 81 ~ "social",
      place_number == 82 ~ "movement",
      place_number == 83 ~ "shopping",
      place_number == 84 ~ "transport",
      place_number == 85 ~ "movement",
      place_number == 86 ~ "movement",
      place_number == 87 ~ "leisure",
      place_number == 88 ~ "transport",
      place_number == 89 ~ "healthcare",
      place_number == 90 ~ "transport",
      place_number == 91 ~ "education",
      place_number == 92 ~ "nature",
      place_number == 93 ~ "shopping",
      place_number == 94 ~ "movement",
      place_number == 95 ~ "transport",
      place_number == 96 ~ "movement",
      place_number == 97 ~ "shopping",
      place_number == 98 ~ "education",
      place_number == 99 ~ "education",
      place_number == 100 ~ "transport",
      place_number == 101 ~ "movement",
      place_number == 102 ~ "movement",
      place_number == 103 ~ "social",
      place_number == 104 ~ "leisure",
      place_number == 105 ~ "shopping",
      place_number == 106 ~ "shopping",
      place_number == 107 ~ "shopping",
      place_number == 108 ~ "movement",
      place_number == 109 ~ "shopping",
      place_number == 110 ~ "transport",
      place_number == 111 ~ "leisure",
      place_number == 112 ~ "movement",
      place_number == 113 ~ "movement",
      place_number == 114 ~ "movement",
      place_number == 115 ~ "social",
      place_number == 116 ~ "movement",
      place_number == 117 ~ "shopping",
      place_number == 118 ~ "shopping",
      place_number == 119 ~ "movement",
      place_number == 120 ~ "transport",
      place_number == 121 ~ "shopping",
      place_number == 122 ~ "transport",
      place_number == 123 ~ "leisure",
      place_number == 124 ~ "shopping",
      place_number == 125 ~ "education",
      place_number == 126 ~ "nature",
      place_number == 127 ~ "leisure",
      place_number == 128 ~ "shopping",
      place_number == 129 ~ "social",
      place_number == 130 ~ "transport",
      place_number == 131 ~ "transport",
      place_number == 132 ~ "healthcare",
      place_number == 133 ~ "movement",
      place_number == 134 ~ "movement",
      place_number == 135 ~ "transport",
      place_number == 136 ~ "leisure",
      place_number == 137 ~ "leisure",
      place_number == 138 ~ "movement",
      place_number == 139 ~ "movement",
      place_number == 140 ~ "leisure",
      place_number == 141 ~ "shopping",
      place_number == 142 ~ "transport",
      place_number == 143 ~ "education",
      place_number == 144 ~ "home",
      place_number == 145 ~ "transport",
      place_number == 146 ~ "social",
      place_number == 147 ~ "social",
      place_number == 148 ~ "nature",
      place_number == 149 ~ "movement",
      place_number == 150 ~ "movement",
      place_number == 151 ~ "education",
      place_number == 152 ~ "movement",
      place_number == 153 ~ "leisure",
      place_number == 154 ~ "transport",
      place_number == 155 ~ "shopping",
      place_number == 156 ~ "transport",
      place_number == 157 ~ "movement",
      place_number == 158 ~ "movement",
      place_number == 159 ~ "education",
      place_number == 160 ~ "movement",
      place_number == 161 ~ "sport",
      place_number == 162 ~ "education",
      place_number == 163 ~ "sport",
      place_number == 164 ~ "sport",
      place_number == 165 ~ "leisure",
      place_number == 166 ~ "nature",
      place_number == 167 ~ "social",
      TRUE ~ "unknown"
    )
  )

Merging the labelling to the dataset

timeline_sf <- timeline_sf |>
  left_join(
    visits |>
      st_drop_geometry() |>
      select(placeID, activity_category),
    by = "placeID",
    suffix = c("", "_labelled")
  ) |>
  mutate(
    activity_category = coalesce(activity_category_labelled, activity_category)
  ) |>
  select(-activity_category_labelled)

timeline_sf |>
  filter(record_type == "visit") |>
  count(activity_category, sort = TRUE)
Simple feature collection with 11 features and 2 fields
Geometry type: GEOMETRY
Dimension:     XY
Bounding box:  xmin: 2380425 ymin: 1149298 xmax: 2793758 ymax: 1943799
Projected CRS: CH1903+ / LV95
First 10 features:
   activity_category   n                       geometry
1               home 108        POINT (2683877 1251277)
2          transport  68 MULTIPOINT ((2382331 163662...
3           movement  57 MULTIPOINT ((2380785 164014...
4             social  36 MULTIPOINT ((2380628 164037...
5           shopping  28 MULTIPOINT ((2380425 164090...
6            leisure  27 MULTIPOINT ((2383144 163808...
7          education  26 MULTIPOINT ((2680574 125141...
8              sport  21 MULTIPOINT ((2683429 124725...
9             nature  12 MULTIPOINT ((2541767 115056...
10              work   8        POINT (2683960 1246650)
# si category_type movement, reclassify in record type 
timeline_sf <- timeline_sf |>
  mutate(
    record_type = case_when(
      activity_category == "movement" ~ "movement",
      TRUE ~ record_type
    ),
    activity_category = case_when(
      activity_category == "movement" ~ NA_character_,
      TRUE ~ activity_category
    )
  )

movements <- timeline_sf |> filter(record_type == "movement")
visits_labelled <- timeline_sf |> filter(record_type == "visit")

Visualize

# Visits by activity category
tm_shape(visits_labelled) +
  tm_dots(
    col = "activity_category",
    size = 1,
    palette = "Set2",
    title = "Activity category"
  ) +
  tm_layout(
    title = "Visited places by activity category"
  )
── tmap v3 code detected ───────────────────────────────────────────────────────
[v3->v4] `tm_tm_dots()`: migrate the argument(s) related to the scale of the
visual variable `fill` namely 'palette' (rename to 'values') to fill.scale =
tm_scale(<HERE>).
[v3->v4] `tm_dots()`: use 'fill' for the fill color of polygons/symbols
(instead of 'col'), and 'col' for the outlines (instead of 'border.col').
[tm_dots()] Argument `title` unknown.
[v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(title = )`
[cols4all] color palettes: use palettes from the R package cols4all. Run
`cols4all::c4a_gui()` to explore them. The old palette name "Set2" is named
"brewer.set2"
Multiple palettes called "set2" found: "brewer.set2", "hcl.set2". The first one, "brewer.set2", is returned.

[plot mode] fit legend/component: Some legend items or map compoments do not
fit well, and are therefore rescaled.
ℹ Set the tmap option `component.autoscale = FALSE` to disable rescaling.

tmap_mode("view")
ℹ tmap modes "plot" - "view"
ℹ toggle with `tmap::ttm()`
# Unique visited places, sized by frequency
visit_frequency <- visits_labelled |>
  st_drop_geometry() |>
  count(placeID, activity_category, sort = TRUE)

visit_points <- visits_labelled |>
  distinct(placeID, .keep_all = TRUE) |>
  left_join(visit_frequency, by = c("placeID", "activity_category"))

tm_shape(visit_points) +
  tm_dots(
    col = "activity_category",
    size = "n",
    palette = "Set2",
    title.col = "Activity category",
    title.size = "Number of visits"
  ) +
  tm_layout(
    title = "Most frequently visited places"
  )

── tmap v3 code detected ───────────────────────────────────────────────────────
[v3->v4] `tm_tm_dots()`: migrate the argument(s) related to the scale of the
visual variable `fill` namely 'palette' (rename to 'values') to fill.scale =
tm_scale(<HERE>).[v3->v4] `tm_dots()`: migrate the argument(s) related to the legend of the
visual variable `fill` namely 'title.col' (rename to 'title') to 'fill.legend =
tm_legend(<HERE>)'[v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(title = )`[cols4all] color palettes: use palettes from the R package cols4all. Run
`cols4all::c4a_gui()` to explore them. The old palette name "Set2" is named
"brewer.set2"Multiple palettes called "set2" found: "brewer.set2", "hcl.set2". The first one, "brewer.set2", is returned.
# Movements by transport mode

tm_shape(movements) +
  tm_dots(
    col = "transport_mode",
    size = 0.8,
    palette = "Dark2",
    title = "Transport mode"
  ) +
  tm_layout(
    title = "Movement points by transport mode"
  )

── tmap v3 code detected ───────────────────────────────────────────────────────
[v3->v4] `tm_tm_dots()`: migrate the argument(s) related to the scale of the
visual variable `fill` namely 'palette' (rename to 'values') to fill.scale =
tm_scale(<HERE>).[tm_dots()] Argument `title` unknown.[v3->v4] `tm_layout()`: use `tm_title()` instead of `tm_layout(title = )`[cols4all] color palettes: use palettes from the R package cols4all. Run
`cols4all::c4a_gui()` to explore them. The old palette name "Dark2" is named
"brewer.dark2"Multiple palettes called "dark2" found: "brewer.dark2", "hcl.dark2". The first one, "brewer.dark2", is returned.

Activity shape per activity category

# for one category
category <- "education"

activity_space_cat <- timeline_sf |>
  filter(record_type == "visit") |>
  filter(activity_category == category) |>
  summarise() |>
  st_convex_hull()

tm_shape(activity_space_cat) +
  tm_polygons(alpha = 0.4) +
tm_shape(
  timeline_sf |>
    filter(activity_category == category)
) +
  tm_dots(size = 0.05)
── tmap v3 code detected ───────────────────────────────────────────────────────
[v3->v4] `tm_polygons()`: use `fill_alpha` instead of `alpha`.
# for all categories
activity_spaces <- timeline_sf |>
  filter(record_type == "visit") |>
  filter(!is.na(activity_category)) |>
  group_by(activity_category) |>
  summarise() |>
  st_convex_hull()

tm_shape(activity_spaces) +
  tm_polygons(
    col = "activity_category",
    alpha = 0.4
  ) +
tm_shape(
  timeline_sf |>
    filter(record_type == "visit")
) +
  tm_dots(
    col = "activity_category",
    size = 0.05
  )

── tmap v3 code detected ───────────────────────────────────────────────────────
[v3->v4] `tm_polygons()`: use `fill_alpha` instead of `alpha`.
# full activity space
activity_space_full <- timeline_sf |>
  filter(record_type == "visit") |>
  summarise() |>
  st_convex_hull()

tm_shape(activity_space_full) +
  tm_polygons(
    alpha = 0.3
  ) +
tm_shape(
  timeline_sf |>
    filter(record_type == "visit")
) +
  tm_dots(size = 0.03)

── tmap v3 code detected ───────────────────────────────────────────────────────
[v3->v4] `tm_polygons()`: use `fill_alpha` instead of `alpha`.

integration of isochrones

#load the saved objects 
isochrones_15min <- readRDS("outputs/isochrones_15min.rds")
pois_by_iso <- readRDS("outputs/pois_by_iso.rds")

#Make sure CRS match
isochrones_15min <- st_transform(
  isochrones_15min,
  st_crs(visits_labelled)
)

# join isochrones to visited places
visits_labelled_iso <- visits_labelled |>
  mutate(
    within_walk_15 =
      lengths(st_within(
        geometry,
        isochrones_15min[isochrones_15min$mode == "Walking", ]
      )) > 0,

    within_bike_15 =
      lengths(st_within(
        geometry,
        isochrones_15min[isochrones_15min$mode == "Cycling", ]
      )) > 0,

    within_pt_15 =
      lengths(st_within(
        geometry,
        isochrones_15min[isochrones_15min$mode == "Public transport proxy", ]
      )) > 0
  )

match activity category of point of interest with activity categories of mobility data.

category_crosswalk <- tibble(
  activity_category = c(
    "shopping",
    "nature",
    "healthcare",
    "leisure",
    "sport",
    "education",
    "work"
  ),
  poi_category = c(
    "Groceries",
    "Park / forest",
    "Pharmacy / doctor",
    "Restaurants",
    "Sport",
    "University",
    "Work"
  )
)

Results

# practice
mobility_table <- visits_labelled_iso |>
  st_drop_geometry() |>
  filter(activity_category != "home") |>
  group_by(activity_category) |>
  summarise(
    n_visits = n(),
    within_walk_15 = sum(within_walk_15, na.rm = TRUE),
    within_bike_15 = sum(within_bike_15, na.rm = TRUE),
    within_pt_15 = sum(within_pt_15, na.rm = TRUE),
    .groups = "drop"
  ) |>
  mutate(
    share_walk_15 = round(within_walk_15 / n_visits * 100, 1),
    share_bike_15 = round(within_bike_15 / n_visits * 100, 1),
    share_pt_15 = round(within_pt_15 / n_visits * 100, 1)
  ) |>
  arrange(desc(n_visits))

#in theory
accessibility_table <- pois_by_iso |>
  st_drop_geometry() |>
  group_by(poi_category) |>
  summarise(
    n_pois = n(),

    within_walk_15 = sum(mode == "Walking"),
    within_bike_15 = sum(mode == "Cycling"),
    within_pt_15 = sum(mode == "Public transport proxy"),

    .groups = "drop"
  ) |>
  mutate(
    share_walk_15 = round(within_walk_15 / n_pois * 100, 1),
    share_bike_15 = round(within_bike_15 / n_pois * 100, 1),
    share_pt_15 = round(within_pt_15 / n_pois * 100, 1)
  )

names(pois_by_iso)
  [1] "osm_id"                            "name"                             
  [3] "addr:city"                         "addr:housenumber"                 
  [5] "addr:postcode"                     "addr:street"                      
  [7] "check_date"                        "check_date:currency:XBT"          
  [9] "consulting"                        "currency:XBT"                     
 [11] "office"                            "opening_hours"                    
 [13] "operator"                          "payment:lightning"                
 [15] "payment:lightning_contactless"     "payment:onchain"                  
 [17] "phone"                             "survey:date"                      
 [19] "website"                           "source_geometry"                  
 [21] "poi_category"                      "poi_subcategory"                  
 [23] "osm_key"                           "osm_value"                        
 [25] "level"                             "addr:country"                     
 [27] "building"                          "building:levels"                  
 [29] "owner"                             "start_date"                       
 [31] "leisure"                           "sport"                            
 [33] "amenity"                           "bath:type"                        
 [35] "wheelchair"                        "alt_name"                         
 [37] "bath:open_air"                     "description"                      
 [39] "fee"                               "name:de"                          
 [41] "name:gsw"                          "swimming_pool"                    
 [43] "wikidata"                          "wikimedia_commons"                
 [45] "wikipedia"                         "branch"                           
 [47] "brand"                             "brand:wikidata"                   
 [49] "brand:wikipedia"                   "check_date:opening_hours"         
 [51] "contact:website"                   "email"                            
 [53] "fixme"                             "old_name"                         
 [55] "sauna"                             "brand:website"                    
 [57] "cash_withdrawal"                   "cash_withdrawal:fee"              
 [59] "cash_withdrawal:purchase_required" "cash_withdrawal:type"             
 [61] "check_date:diet:gluten_free"       "check_date:diet:halal"            
 [63] "check_date:diet:kosher"            "cuisine"                          
 [65] "diet:gluten_free"                  "diet:halal"                       
 [67] "diet:kosher"                       "indoor"                           
 [69] "internet_access"                   "membership"                       
 [71] "organic"                           "payment:american_express"         
 [73] "payment:app"                       "payment:apple_pay"                
 [75] "payment:cash"                      "payment:coins"                    
 [77] "payment:contactless"               "payment:google_pay"               
 [79] "payment:maestro"                   "payment:mastercard"               
 [81] "payment:postfinance_card"          "payment:samsung_pay"              
 [83] "payment:twint"                     "payment:v_pay"                    
 [85] "payment:visa"                      "payment:vpay"                     
 [87] "shop"                              "type"                             
 [89] "delivery"                          "dog"                              
 [91] "internet_access:fee"               "note"                             
 [93] "opening_hours:signed"              "outdoor_seating"                  
 [95] "payment:credit_cards"              "payment:debit_cards"              
 [97] "source"                            "takeaway"                         
 [99] "wheelchair:description"            "access"                           
[101] "addr:place"                        "air_conditioning"                 
[103] "bar"                               "brewery"                          
[105] "capacity"                          "changing_table"                   
[107] "check_date:diet:vegetarian"        "check_date:opening_hours:url"     
[109] "contact:email"                     "contact:facebook"                 
[111] "contact:instagram"                 "contact:phone"                    
[113] "diet:lactose_free"                 "diet:meat"                        
[115] "diet:seafood"                      "diet:vegan"                       
[117] "diet:vegetarian"                   "drive_in"                         
[119] "drive_through"                     "entrance"                         
[121] "fax"                               "highchair"                        
[123] "indoor_seating"                    "internet_access:ssid"             
[125] "kids_area"                         "microbrewery"                     
[127] "name:it"                           "not:brand:wikidata"               
[129] "opening_hours:covid19"             "opening_hours:kitchen"            
[131] "opening_hours:url"                 "oven"                             
[133] "payment:cards"                     "payment:qr_code"                  
[135] "reservation"                       "self_service"                     
[137] "smoking"                           "toilets"                          
[139] "toilets:access"                    "toilets:wheelchair"               
[141] "website:menu"                      "changing_table:location"          
[143] "check_date:diet:vegan"             "stroller"                         
[145] "building:colour"                   "building:material"                
[147] "roof:colour"                       "barrier"                          
[149] "bicycle"                           "foot"                             
[151] "horse"                             "locked"                           
[153] "maxwidth:physical"                 "motor_vehicle"                    
[155] "vehicle"                           "dog:conditional"                  
[157] "landuse"                           "loc_name"                         
[159] "name:ja"                           "operator:type"                    
[161] "operator:wikidata"                 "layer"                            
[163] "leaf_type"                         "natural"                          
[165] "dispensing"                        "healthcare"                       
[167] "addr:floor"                        "healthcare:speciality"            
[169] "contact:city"                      "contact:housenumber"              
[171] "contact:postcode"                  "contact:street"                   
[173] "cafe"                              "origin"                           
[175] "self_checkout"                     "check_date:smoking"               
[177] "date_off"                          "date_on"                          
[179] "disused:amenity"                   "fast_food"                        
[181] "operator:website"                  "url"                              
[183] "changing_table:count"              "changing_table:fee"               
[185] "disused:shop"                      "kids_area:fee"                    
[187] "kids_area:indoor"                  "official_name"                    
[189] "short_name"                        "display"                          
[191] "support"                           "visibility"                       
[193] "name:etymology:wikidata"           "operator:wikipedia"               
[195] "contact:fax"                       "health_facility:type"             
[197] "ele"                               "addr:housename"                   
[199] "education"                         "name:tr"                          
[201] "fair_trade"                        "ref"                              
[203] "surface"                           "architect"                        
[205] "length"                            "name:en"                          
[207] "int_ref"                           "school:gender"                    
[209] "description:covid19"               "mobile"                           
[211] "payment:lunch_check"               "payment:nfc"                      
[213] "payment:visa_debit"                "restaurant"                       
[215] "delivery:covid19"                  "takeaway:covid19"                 
[217] "cycle_barrier"                     "cycle_barrier:installation"       
[219] "name:etymology"                    "health:facility"                  
[221] "health_service:child_care"         "health_specialty:pediatrics"      
[223] "medical_system"                    "treat:inpatient"                  
[225] "roof:shape"                        "club"                             
[227] "mode"                              "speed_kmh"                        
[229] "minutes"                           "geometry"                         
# match the categories
comparison_table <- mobility_table |>
  left_join(
    category_crosswalk,
    by = "activity_category"
  ) |>
  left_join(
    accessibility_table,
    by = "poi_category",
    suffix = c("_mobility", "_theory")
  )

Join the actual mobility and theoretical accessibility

summary_table <- mobility_table |>
  left_join(
    category_crosswalk,
    by = "activity_category"
  ) |>
  left_join(
    accessibility_table,
    by = "poi_category",
    suffix = c("_mobility", "_theory")
  ) |>
  transmute(
    Activity = activity_category,
    Visits = n_visits,

    `Walk (%)` = share_walk_15_mobility,
    `Walk Opportunities` = within_walk_15_theory,

    `Bike (%)` = share_bike_15_mobility,
    `Bike Opportunities` = within_bike_15_theory,

    `PT (%)` = share_pt_15_mobility,
    `PT Opportunities` = within_pt_15_theory
  )

Visualisation

What proportion of activities occur within 15 minutes?

rq1_data <- summary_table |>
  select(Activity, `Walk (%)`, `Bike (%)`, `PT (%)`) |>
  pivot_longer(
    -Activity,
    names_to = "Mode",
    values_to = "Share"
  )
# as a frouped bar chart
ggplot(rq1_data,
       aes(x = reorder(Activity, -Share),
           y = Share,
           fill = Mode)) +
  geom_col(position = "dodge") +
  labs(
    x = "",
    y = "% of visits within 15 minutes",
    fill = "Transport mode"
  ) +
  theme_minimal() +
  coord_flip()

Accessibility Gap

access_gap_data <- summary_table |>
  filter(!Activity %in% c("transport", "social")) |>
  mutate(
    `Walk opportunity share (%)` =
      `Walk Opportunities` / sum(`Walk Opportunities`, na.rm = TRUE) * 100,
    `Bike opportunity share (%)` =
      `Bike Opportunities` / sum(`Bike Opportunities`, na.rm = TRUE) * 100,
    `PT opportunity share (%)` =
      `PT Opportunities` / sum(`PT Opportunities`, na.rm = TRUE) * 100
  )

bike_gap <- access_gap_data |>
  transmute(
    Activity,
    `Observed visits within 15 min (%)` = `Bike (%)`,
    `Share of bike-accessible opportunities (%)` = `Bike opportunity share (%)`
  ) |>
  pivot_longer(
    -Activity,
    names_to = "Measure",
    values_to = "Value"
  )

ggplot(
  bike_gap,
  aes(
    x = reorder(Activity, Value),
    y = Value,
    fill = Measure
  )
) +
  geom_col(position = "dodge") +
  coord_flip() +
  labs(
    title = "Observed use versus theoretical accessibility by bike",
    subtitle = "Comparison between actual visits within 15 minutes and nearby opportunities",
    x = "",
    y = "%",
    fill = ""
  ) +
  theme_minimal()

Activities occurring beyond the 15-minute city

only the cycling isochrone was used here

#considering the absolute frequency of visits 
outside_visits <- summary_table |>
  mutate(
    outside_bike = round(Visits * (100 - `Bike (%)`) / 100)
  ) |>
  arrange(desc(outside_bike))

ggplot(outside_visits,
       aes(reorder(Activity, outside_bike),
           outside_bike)) +
  geom_col() +
  coord_flip()

# considering relative frequency.( invert from research question 1)
figure3_bike <- summary_table |>
  filter(!Activity %in% c("transport")) |>
  mutate(
    Beyond_15min = 100 - `Bike (%)`
  ) |>
  arrange(desc(Beyond_15min))

ggplot(
  figure3_bike,
  aes(
    x = reorder(Activity, Beyond_15min),
    y = Beyond_15min
  )
) +
  geom_col() +
  coord_flip() +
  labs(
    title = "Activities occurring beyond 15 minutes by bike",
    x = "",
    y = "% of visits beyond 15 minutes"
  ) +
  theme_minimal()