Building geographic crosswalks
ONgeoR contributors
Source:vignettes/building-crosswalks.Rmd
building-crosswalks.RmdA crosswalk records how features in one polygon layer relate to
features in another. build_crosswalk() standardizes the
result and includes provenance needed to audit it later. This vignette
uses synthetic polygons and requires no network connection.
A reproducible polygon example
library(ONgeoR)
square <- function(xmin, ymin, xmax, ymax) {
sf::st_polygon(list(rbind(
c(xmin, ymin), c(xmax, ymin), c(xmax, ymax),
c(xmin, ymax), c(xmin, ymin)
)))
}
phu <- sf::st_sf(
PHU_ID = c("P1", "P2"),
PHU_NAME_ENG = c("West PHU", "East PHU"),
geometry = sf::st_sfc(
square(0, 0, 1, 1), square(1, 0, 2, 1), crs = 3857
)
)
municipal <- sf::st_sf(
MUNID = c("M1", "M2"),
MUNICIPAL_NAME = c("West municipality", "East municipality"),
geometry = sf::st_sfc(
square(0.1, 0.1, 0.9, 0.9), square(1.1, 0.1, 1.9, 0.9), crs = 3857
)
)
attr(phu, "source_name") <- "Synthetic PHU boundaries"
attr(phu, "source_url") <- "https://example.invalid/phu"
attr(phu, "retrieved_at") <- as.POSIXct("2026-01-01", tz = "UTC")
attr(municipal, "source_name") <- "Synthetic municipal boundaries"
attr(municipal, "source_url") <- "https://example.invalid/municipal"
crosswalk <- build_crosswalk(municipal, phu, method = "within")
crosswalk
#> # A tibble: 2 × 14
#> from_id from_name from_source to_id to_name to_source match_method
#> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 M1 West municipality Synthetic muni… P1 West P… Syntheti… within
#> 2 M2 East municipality Synthetic muni… P2 East P… Syntheti… within
#> # ℹ 7 more variables: match_distance_km <dbl>, coverage <dbl>,
#> # from_id_col <chr>, to_id_col <chr>, source_url_from <chr>,
#> # source_url_to <chr>, retrieved_at <dttm>The default path for polygon-to-polygon linking is now
build_link(), which calls build_intersection()
and returns every overlapping pair with area shares in a single pass.
This removes the need to choose a predicate for most workflows.
build_crosswalk() and its five methods
(within, intersects,
point_on_surface, largest_overlap,
weighted) remain available for direct callers who need a
specific rule, such as when a workflow depends on the one-to-one
assignment that largest_overlap provides.
Real municipal layers are simplified by default. Generalizing each
layer can move nominally shared borders enough that a municipal polygon
is no longer strictly within a PHU polygon. For those layers, the
intersection path handles the overlap naturally: every positive-area
overlap is reported, and the share_of_target and
share_of_source columns let you filter or weight the result
after the fact.
What linking does, by layer types
build_link() inspects the geometry types of the two
layers and dispatches to the appropriate operation. The table below is
rendered from the same matrix that drives the Shiny app and the package
internals.
| source_kind | target_kind | mode | what_it_does | output |
|---|---|---|---|---|
| point | point | Nearest | Each target point is matched to its single nearest source point. | nearest table |
| point | polygon | Containment | Each point is matched to the boundary it falls inside. | crosswalk |
| point | raster | Sampling | Each point takes the value of the cell containing it. | linked values table |
| polygon | point | Containment | Direction is auto-corrected internally. | crosswalk |
| polygon | polygon | Intersection | Every overlapping pair, with the share of each target covered and the share of each source falling inside. | intersection table |
| polygon | raster | Sampling | Each polygon samples the raster values it overlaps. | linked values table |
| raster | point | Sampling | Raster reduced to cell centroids. | linked values table |
| raster | polygon | Cell sampling into boundaries | Each cell centroid is matched to the boundary it falls inside. | linked values table |
| raster | raster | Not supported | Not supported; align/resample with terra first. | none |
Linking never creates or emits geometry: the output is always an assignment or values table, and overlap areas are used only as internal arithmetic.
Two area-share columns are returned for polygon-to-polygon linking. They are not interchangeable, and getting the distinction wrong is silent:
-
share_of_targetis the fraction of the TARGET polygon covered by the source. It characterizes how much of the target is accounted for by each overlapping source. -
share_of_sourceis the fraction of the SOURCE polygon falling inside the target. It is the apportionment weight to use when moving an extensive variable such as population or case counts from the source to the target.
build_intersection() returns every overlapping pair with
both area shares in one pass, which covers the same ground as the
weighted method of build_crosswalk() without
requiring a method choice.
Weighted crosswalks
Use weighted when one source polygon should be
apportioned across every target it overlaps, such as many-to-many
allocation. Use largest_overlap when each source needs one
target assignment. Weighted coverage values for a source sum to at most
1; largest_overlap selects the largest weighted row.
build_intersection() now returns every overlapping pair
with both area shares in one pass, which covers the same ground without
a method choice.
weighted_from <- sf::st_sf(
from_id = c("F1", "F2"),
geometry = sf::st_sfc(
square(0, 0, 1, 1), square(1, 0, 2, 1), crs = 3857
)
)
weighted_to <- sf::st_sf(
to_id = c("T1", "T2"),
geometry = sf::st_sfc(
square(0, 0, 1.25, 1), square(1.25, 0, 2, 1), crs = 3857
)
)
weighted <- build_crosswalk(weighted_from, weighted_to, method = "weighted")
weighted[, c("from_id", "to_id", "coverage")]Inspect the audit fields
The canonical output contains source and target identifiers and names, the registry-style source labels, the matching method, reserved distance field, source URLs, and the target retrieval time.
names(crosswalk)
#> [1] "from_id" "from_name" "from_source"
#> [4] "to_id" "to_name" "to_source"
#> [7] "match_method" "match_distance_km" "coverage"
#> [10] "from_id_col" "to_id_col" "source_url_from"
#> [13] "source_url_to" "retrieved_at"
crosswalk[, c(
"from_id", "from_name", "to_id", "to_name", "match_method",
"source_url_from", "source_url_to", "retrieved_at"
)]
#> # A tibble: 2 × 8
#> from_id from_name to_id to_name match_method source_url_from source_url_to
#> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 M1 West municip… P1 West P… within https://exampl… https://exam…
#> 2 M2 East municip… P2 East P… within https://exampl… https://exam…
#> # ℹ 1 more variable: retrieved_at <dttm>match_distance_km is currently NA because
build_crosswalk() implements topological polygon matching,
not nearest-neighbour matching.
Export only after checking unmatched or duplicated source identifiers and the provenance fields. This write is deliberately not run during the vignette build:
write.csv(crosswalk, "municipal-to-phu.csv", row.names = FALSE)Live Ontario example
The following example requires network access or populated ONgeoR caches and is not executed while this vignette builds.
municipal <- retrieve_municipal("upper")
phu <- retrieve_phu()
municipal_to_phu <- build_crosswalk(
municipal,
phu,
method = "intersects"
)
map_layers(Municipalities = municipal, PHUs = phu)