A line layer becomes a routable graph when its segments are joined at
their endpoints and weighted by a traversal cost. vectra builds that
graph once and holds it resident in a vectra_network
object, the network counterpart of the small resident sf
layer the streaming verbs compare against. Origins then stream past the
resident graph: each batch of start points is routed in native C, so a
national set of origins flows past a fixed memory budget while the graph
stays in memory.
The graph and the shortest-path solver are pure C, a binary-heap
Dijkstra over a compressed adjacency, with no igraph
dependency. The solver parallelises over a batch of origins with
OpenMP.
spatial_network() takes a line layer and returns a
vectra_network. Endpoints are snapped to shared nodes
(exactly coincident by default, or within tolerance CRS
units), and each edge is weighted by its geometry length unless a
weight column names another cost.
The examples use a small grid of unit streets.
mk <- function(x1, y1, x2, y2)
st_linestring(rbind(c(x1, y1), c(x2, y2)))
streets <- st_sfc(
mk(0, 0, 1, 0), mk(1, 0, 2, 0), mk(2, 0, 3, 0), # bottom row
mk(0, 1, 1, 1), mk(1, 1, 2, 1), mk(2, 1, 3, 1), # middle row
mk(0, 2, 1, 2), mk(1, 2, 2, 2), mk(2, 2, 3, 2), # top row
mk(0, 0, 0, 1), mk(0, 1, 0, 2), # left verticals
mk(1, 0, 1, 1), mk(1, 1, 1, 2),
mk(2, 0, 2, 1), mk(2, 1, 2, 2),
mk(3, 0, 3, 1), mk(3, 1, 3, 2)) # right verticals
net <- spatial_network(streets)
net
#> <vectra_network>
#> nodes: 12
#> edges: 34 (undirected)
#> components: 1spatial_route() streams a layer of origin points past
the network and returns the shortest path from each origin to one or
more destinations. With geometry = FALSE it returns only
the cost, so a set of destinations per origin is an origin-destination
cost matrix in long form.
f <- tempfile(fileext = ".vtr")
write_vtr(data.frame(id = 1L, x = 0, y = 0), f)
dest <- st_sfc(st_point(c(3, 2)))
tbl(f) |>
spatial_route(net, to = dest, coords = c("x", "y"), geometry = FALSE) |>
collect()
#> id x y destination cost
#> 1 1 0 0 1 5The cost is the five unit steps from the bottom-left corner to the
top-right. With geometry = TRUE (the default) each row also
carries the route line, ready to materialise with
collect_sf():
tbl(f) |>
spatial_route(net, to = dest, coords = c("x", "y")) |>
collect_sf()
#> Simple feature collection with 1 feature and 5 fields
#> Geometry type: LINESTRING
#> Dimension: XY
#> Bounding box: xmin: 0 ymin: 0 xmax: 3 ymax: 2
#> CRS: NA
#> id x y destination cost geometry
#> 1 1 0 0 1 5 LINESTRING (0 0, 1 0, 1 1, ...Several destinations, named through to_id, give one row
per origin and destination, the cost matrix in long form:
g <- tempfile(fileext = ".vtr")
write_vtr(data.frame(id = 1:2, x = c(0, 3), y = c(0, 0)), g)
dests <- st_sf(
name = c("top_left", "top_right"),
geometry = st_sfc(st_point(c(0, 2)), st_point(c(3, 2))))
tbl(g) |>
spatial_route(net, to = dests, to_id = "name", geometry = FALSE,
coords = c("x", "y")) |>
collect()
#> id x y destination cost
#> 1 1 0 0 top_left 2
#> 2 1 0 0 top_right 5
#> 3 2 3 0 top_left 5
#> 4 2 3 0 top_right 2Each origin carries its own attributes (id here) onto
every route row. Unreachable pairs return an infinite cost rather than
dropping the row.
spatial_service_area() returns, per origin, the part of
the network reachable within a cost budget. A vector of budgets returns
nested travel-cost bands, one row per origin and band. The
output argument selects the shape: the reachable nodes as a
multipoint, the reachable edges as lines, or their convex hull as a
service-area polygon.
tbl(f) |>
spatial_service_area(net, cost = c(1, 2), output = "nodes",
coords = c("x", "y")) |>
collect_sf()
#> Simple feature collection with 2 features and 4 fields
#> Geometry type: MULTIPOINT
#> Dimension: XY
#> Bounding box: xmin: 0 ymin: 0 xmax: 2 ymax: 2
#> CRS: NA
#> id x y band geometry
#> 1 1 0 0 1 MULTIPOINT ((0 0), (1 0), (...
#> 2 1 0 0 2 MULTIPOINT ((0 0), (1 0), (...The first band reaches the start node and its two immediate
neighbours; the second band reaches everything within two steps. Asking
for output = "polygon" wraps each band in its hull, the
usual drive-time catchment:
By default every edge is two-way and weighted by length. A
weight column sets a different traversal cost (a travel
time, a toll), and directed = TRUE makes edges one-way. The
direction column then carries one-way codes
("B" two-way, "FT" along the digitised
direction, "TF" against it, "N" closed), and
weight_to gives the reverse cost on a two-way edge.
streets_df <- st_sf(
cost = runif(length(streets), 1, 3),
geometry = streets)
wnet <- spatial_network(streets_df, weight = "cost")
tbl(f) |>
spatial_route(wnet, to = dest, coords = c("x", "y"), geometry = FALSE) |>
collect()
#> id x y destination cost
#> 1 1 0 0 1 9.146327The route now minimises the summed cost weight instead
of the step count.
The graph is the resident budget: it is built once and held in the
vectra_network object for the life of the queries against
it. The origin layer is what streams. Each batch of origins is solved
against the resident graph with one Dijkstra per origin, run in parallel
across the batch, so the query side scales by streaming while memory
tracks the graph plus one batch. This is the network analogue of the
resident-y pattern the streaming spatial verbs use for a
small locator layer.